Agent上下文三级压缩
核心问题:Agent 的上下文窗口不是无限的
LLM 的上下文窗口是有限的(4K/128K/200K token),但用户对话可以无限长。
问题演进过程:
用户第 1 轮:"我想买特斯拉股票" → 上下文:1K token
用户第 5 轮:"刚才说的股票现在怎么样了" → 上下文:5K token
用户第 20 轮:讨论新能源/美股/技术分析... → 上下文:20K token
用户第 50 轮:上下文超出 LLM 上限 → ❌ 开始丢信息
当上下文满了,Agent 会丢失:
1. 用户早期说过的偏好("我偏好稳健投资")
2. 之前查过的信息("特斯拉股价 180 美元")
3. 之前的决策结论("决定先买 100 股试试")
三级压缩的目标:
在保证 Agent 信息完整的前提下,最大限度节省 token
三级压缩的设计哲学
面试官问:"为什么需要三级压缩,一级不够吗?"
答:
一级不够,因为不同类型的信息适合不同的压缩方式:
- 最近对话(5 轮内):信息完整,语义连贯
→ 用滑动窗口直接丢弃最旧的,保留全部细节(第一级)
- 中期对话(5-20 轮前):太长不适合全量保留
→ 但有关键决策和偏好不能丢
→ 用 LLM 生成摘要,保留因果链(第二级)
- 长期偏好(跨会话):用户说了"我总是选最便宜的物流"
→ 这类偏好太重要,不能因一次对话结束就消失
→ 存入向量库,按需检索(第三级)
三层互相配合,按需触发,不浪费计算资源。
第一级:滑动窗口压缩(会话级)
原理
对话队列:[msg1, msg2, msg3, ..., msgN]
滑动窗口大小 = 5 轮(经验值)
当第 6 轮结束时:
队列左边出队 msg1(被丢弃)
队列右边进队 msg6(新消息)
保留:[msg2, msg3, msg4, msg5, msg6]
"被丢弃的 msg1 里的信息去哪了?" → 进入第二级压缩
为什么是 5 轮(面试高频追问,必须会答)
面试官追问:"为什么是 5 轮?3 轮或 7 轮行不行?"
标准答案:
"5 轮是工程经验值,来自实际业务数据的统计。
3 轮的问题:
- 对话太短,Agent 丢失太多上下文
- 很多问题需要 3 轮以上才能完整描述(用户描述情况 → AI 追问细节 → 用户补充)
- 3 轮 Agent 容易反复问用户已经说过的信息,体验差
7 轮的问题:
- Token 消耗显著增加,LLM 对中间部分注意力下降(Lost in the Middle)
- 成本上涨,上下文越长 LLM 调用延迟越高
- 7 轮以后信息密度往往下降(闲聊部分增多)
5 轮的本质是'信息完整性和成本控制'的平衡点,
实际项目中会根据平均对话长度和 LLM 调用成本动态调优。"
Java 对照:
LinkedList 的窗口淘汰(超出容量时 removeFirst())
或者 Guava Cache 的 LRU(最近最少使用)
代码实现
python
"""
第一级压缩器:滑动窗口
业务背景:
多轮对话进行时,超出窗口的历史消息直接丢弃。
这是最快、最低成本的压缩方式。
设计思路:
1. system prompt 永远保留(Agent 的角色锚定)
2. 对话历史按轮次(user+assistant 配对)计算,不按单条消息
3. 超出窗口的部分送去第二级压缩(不直接丢弃)
Java 对照:
LinkedList.removeFirst() + 容量检查
或者 Java NIO 的 ByteBuffer 循环缓冲区
"""
class SlidingWindowCompressor:
"""
滑动窗口压缩器
核心思想:
用一个固定大小的窗口"扫描"对话历史,
窗口内的是当前上下文,窗口外的是待压缩历史。
"""
def __init__(self, window_size: int = 5):
"""
Args:
window_size: 窗口大小(单位:对话轮次)
为什么用"轮次"而不是"消息条数"?
因为一个完整的问题-回答才算一轮,
按消息条数会导致半轮对话被切分
"""
self.window_size = window_size
def compress(self, all_messages: list) -> dict:
"""
执行第一级压缩
Args:
all_messages: 原始完整对话历史,格式:
[
{"role": "system", "content": "你是客服助手"},
{"role": "user", "content": "查下我的订单"},
{"role": "assistant", "content": "好的,请提供订单号"},
...
]
Returns:
{
"recent_turns": [...], # 保留的最近 N 轮(进入 LLM 上下文)
"to_summarize": [...], # 更早的历史(送去第二级压缩)
"system_prompt": {...} # system prompt(永不压缩)
}
处理步骤:
1. 分离 system prompt(永远保留)
2. 计算当前轮次(按 user+assistant 配对算一轮)
3. 轮次 <= 窗口大小 → 不压缩,直接返回
4. 轮次 > 窗口大小 → 保留最近 N 轮,其余送去第二级
"""
# ----------------------------------------------------------
# 步骤 1:分离 system prompt
# 原因:system prompt 是 Agent 的"行为锚",压缩它会导致角色漂移
# 且 system prompt 通常很短(< 1K token),压缩收益极低
# ----------------------------------------------------------
system_msgs = [m for m in all_messages if m["role"] == "system"]
dialog_msgs = [m for m in all_messages if m["role"] != "system"]
# ----------------------------------------------------------
# 步骤 2:按轮次计算(每对 user + assistant = 一轮)
# 坑点:不要按消息条数算,要按 user+assistant 配对算
# ----------------------------------------------------------
turns = self._group_into_turns(dialog_msgs)
total_turns = len(turns)
if total_turns <= self.window_size:
# 对话轮次还没超过窗口,不需要压缩
# 🔥 优化点:这里不需要调用 LLM,直接返回,节省成本
return {
"recent_turns": dialog_msgs, # 全部保留
"to_summarize": [], # 没有要压缩的
"system_prompt": system_msgs[0] if system_msgs else None
}
# ----------------------------------------------------------
# 步骤 3:超过窗口大小,执行压缩
# ----------------------------------------------------------
# 最近 N 轮保留(进入 LLM 上下文)
recent_turns = self._flatten_turns(turns[-self.window_size:])
# 更早的历史送去第二级(不直接丢弃!)
old_turns = self._flatten_turns(turns[:-self.window_size])
return {
"recent_turns": recent_turns,
"to_summarize": old_turns,
"system_prompt": system_msgs[0] if system_msgs else None
}
def _group_into_turns(self, messages: list) -> list:
"""
将消息列表按轮次分组
为什么按轮次分组:
因为第二轮 user 的问题和第一轮 user 的问题是独立的,
不能把第一轮 assistant 的尾巴留到第二轮的窗口里。
算法:
遍历消息,遇到 user 消息 → 开始新的一轮
轮次结构:[{"user": "...", "assistant": "..."}, ...]
"""
turns = []
current_turn = {}
for msg in messages:
role = msg["role"]
if role == "user":
# user 消息标志新的一轮开始
if current_turn and "user" in current_turn:
# 上一轮只有 user 没有 assistant(异常情况),保存它
turns.append(current_turn)
current_turn = {"user": msg["content"]}
elif role == "assistant" and "user" in current_turn:
# assistant 消息紧跟当前的 user,进入同一轮
current_turn["assistant"] = msg["content"]
else:
# 没有 user 消息就直接来的 assistant(异常),单独成轮
if current_turn and "user" in current_turn:
turns.append(current_turn)
current_turn = {"assistant": msg["content"]}
if current_turn and current_turn.get("user"):
turns.append(current_turn)
return turns
def _flatten_turns(self, turns: list) -> list:
"""
将轮次列表还原为消息列表(方便后续拼接 prompt)
Args:
turns: [{"user": "...", "assistant": "..."}, ...]
Returns:
[{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}, ...]
"""
result = []
for turn in turns:
if "user" in turn:
result.append({"role": "user", "content": turn["user"]})
if "assistant" in turn:
result.append({"role": "assistant", "content": turn["assistant"]})
return result
第二级:LLM 摘要压缩(摘要级)
原理
第一级被丢弃的历史(msg1):
"用户:我想买特斯拉股票"
"助手:特斯拉(TSLA)目前股价 180 美元,市盈率 65"
"用户:我比较保守,先观望一下"
"助手:了解,特斯拉近期波动较大,建议关注季报数据"
"用户:好的,那苹果呢"
...
第一级的处理结果:
- recent_turns:保留最近 5 轮
- to_summarize:[msg1, msg2, ...] (被丢弃的历史)
第二级的任务:
把 to_summarize 的内容压缩成一段摘要,
保留关键信息(偏好、决策、结论),丢弃细节(闲聊、追问等)
为什么需要第二级(而不是直接存入向量库)
面试追问:"直接用向量库存不行吗?为什么要摘要?"
答:
1. 向量检索适合"按需查询",无法压缩"顺序信息"
例:"用户先说想买特斯拉,助手推荐后,用户说先观望"
这两句话有因果顺序,向量检索会丢失这个关系,摘要不会
2. 向量检索的召回有误差(可能召回到相似但不相关的记忆)
摘要里的信息是 LLM 确认过的"关键点",更可靠
3. 第二级摘要作为第三级向量库的"索引"
第三级存储的是摘要而非原始对话,这样:
- 检索快(向量维度更低)
- 召回准(摘要内容已经是精炼过的关键点)
两层配合:摘要做浓缩,向量做检索,各司其职。
代码实现
python
"""
第二级压缩器:LLM 摘要
业务背景:
第一级滑动窗口丢弃了"更早的对话",
但这些对话里可能有重要信息(用户偏好、决策结论、未完成的任务)。
用 LLM 把这些对话压缩成一段摘要,保留关键信息。
设计思路:
1. 摘要 prompt 必须规定格式(方便后续解析)
2. 摘要必须包含"未完成的任务"(用户问过但还没解决的事)
3. 如果对话中没有重要信息,返回空(避免引入无关内容)
Java 对照:
MapReduce 的 Combiner 阶段(在 map 和 reduce 之间做局部汇总)
或者 Java 日志框架的 log compaction(只保留最新状态)
"""
from langchain_openai import ChatOpenAI
class SummaryCompressor:
"""
LLM 摘要压缩器
为什么用 LLM 做摘要(而不是规则提取):
1. 用户的表达多种多样,规则难以覆盖("我比较保守" vs "我求稳" vs "我风险承受低")
2. LLM 能理解同义词和隐含意图
3. 摘要质量高,且能捕捉"因果关系"
"""
# ----------------------------------------------------------
# 摘要 Prompt(这是核心,prompt 质量决定摘要质量)
# ----------------------------------------------------------
# 坑点:这个 prompt 是面试高频追问点,要能解释每一部分的作用
SYSTEM_PROMPT = """你是一个对话摘要专家。将对话压缩成摘要。
必须保留的 4 类信息(按优先级排序):
1. 关键决策(用户做了什么选择/决定)
2. 已查到的信息(助手返回过什么重要答案)
3. 用户偏好(用户明确说过的喜好/习惯/限制)
4. 未完成的任务(用户问过但还没解决的事)
格式要求:
<summary>
意图:用户问了什么
决策:用户做了什么决定(或未决定)
偏好:用户的偏好是什么
未完成任务:用户还有哪些问题未解决
</summary>
重要规则:
- 如果对话中没有重要信息,只保留意图,其他字段写"无"
- 不要捏造信息,只写对话中明确说过的内容
- 不要写"助手建议..."(那是助手的,不是用户的偏好)
"""
def __init__(self, llm: ChatOpenAI):
"""
Args:
llm: 用于生成摘要的 LLM 实例
为什么用 LLM 而不是轻量模型?
摘要需要理解语义("保守"≈"求稳"≈"风险承受低"),
轻量模型做不好这个
"""
self.llm = llm
def compress(self, old_messages: list) -> str:
"""
执行第二级压缩
Args:
old_messages: 第一级过滤出来的"待压缩历史"
格式:[{"role": "user", "content": "..."}, ...]
Returns:
摘要字符串,格式:<summary>...</summary>
如果对话中没有重要信息,返回空字符串 ""
坑点 1:如果 old_messages 本身就很长,可能超出 LLM 输入限制
解法:检查 token 数,超阈值时递归压缩(先压缩再总结)
坑点 2:LLM 输出的格式可能不标准(忘了加 </summary>)
解法:解析时做容错处理
"""
if not old_messages:
# 没有需要压缩的内容
return ""
# ----------------------------------------------------------
# 坑点 3:LLM 的输入有 token 限制
# 如果 old_messages 太长,需要先截断或递归压缩
# ----------------------------------------------------------
MAX_INPUT_TOKENS = 3000 # 安全阈值(留余量给 prompt)
# 简单检查:消息条数过多时,截断最旧的部分
# 真实场景:用 tiktoken 精确算 token 数
if len(old_messages) > 30:
old_messages = old_messages[-30:] # 最多保留最近 30 条
# 拼装待压缩内容
content_parts = []
for msg in old_messages:
role_cn = "用户" if msg["role"] == "user" else "助手"
content_parts.append(f"{role_cn}:{msg['content']}")
compressed_text = "\n".join(content_parts)
# 调用 LLM 生成摘要
# 🔥 关键:不要在 system prompt 里加太多约束,
# 让 LLM 专注于"提取信息"而不是"写格式"
response = self.llm.invoke([
{"role": "system", "content": self.SYSTEM_PROMPT},
{"role": "user", "content": f"请压缩以下对话:\n{compressed_text}"}
])
# ----------------------------------------------------------
# 解析摘要(容错处理)
# ----------------------------------------------------------
raw_content = response.content or ""
# 尝试提取 <summary>...</summary> 标签内容
if "<summary>" in raw_content and "</summary>" in raw_content:
start = raw_content.index("<summary>") + len("<summary>")
end = raw_content.index("</summary>")
summary = raw_content[start:end].strip()
else:
# LLM 没有按格式输出,容错处理:取全部内容
summary = raw_content.strip()
# ----------------------------------------------------------
# 坑点 4:摘要可能为空(全篇都是闲聊)
# 空摘要不要加入上下文(会干扰 LLM)
# ----------------------------------------------------------
# 检查是否只有"无"
if all(line.split(":", 1)[-1].strip() in ["", "无"]
for line in summary.split("\n") if ":" in line):
return "" # 没有实质内容,返回空
return summary
第三级:向量数据库长期记忆(向量级)
原理
第三级解决的是"跨会话记忆"的问题:
用户 A 在上周会话中说过:"我总是选择最便宜的物流"
这个信息:
- 不在当前会话的最近 5 轮里
- 不在历史摘要里(上周聊的是别的)
- 但对当前会话可能有帮助(用户又下单了,需要推荐物流)
存入向量库 → 需要时按相关性检索回来
这就是第三级的作用:跨会话共享的长期偏好记忆。
什么时候需要第三级
面试追问:"什么时候用第三级?前两级不够吗?"
答:
第一级(滑动窗口):覆盖最近 5 轮对话
第二级(摘要压缩):覆盖 5 轮之前的会话(同一会话内)
但如果用户:
- 退出了对话(会话结束)
- 第二天又来(新的会话)
第一级和第二级都失效了(它们都是会话级的)。
第三级的作用:在会话之间共享信息。
典型应用:
- 用户偏好("用户总是选最便宜")
- 之前买过的商品类别("用户买过两次显卡")
- 用户的约束条件("用户公司只能开增值税普通发票")
代码实现
python
"""
第三级压缩器:向量数据库长期记忆
业务背景:
用户在多轮对话或多个会话中说过的关键偏好,
需要跨会话保留,供未来的对话检索使用。
设计思路:
1. 每个用户有独立的向量空间(用 user_id 隔离)
2. 存储的是"关键信息片段",不是原始对话
3. 检索时用当前问题生成向量,找到最相似的记忆
为什么用向量检索而不是直接存数据库:
用户的表达方式多样("便宜"≈"省钱"≈"性价比高"≈"经济实惠")
向量检索能捕捉语义相似性,精确匹配做不到
Java 对照:
Redis 的 sorted set(按相关度召回,但 sorted set 是精确匹配)
这里用向量数据库(Milvus/Qdrant)做语义匹配,更接近"智能检索"
"""
class VectorMemory:
"""
长期记忆向量库
三级压缩中这一级最慢(需要调 LLM 生成向量 + 查向量库)
但也是信息最精准的一级(按需召回,不浪费上下文)
"""
def __init__(self, embed_model, vector_db):
"""
Args:
embed_model: Embedding 模型(如 text-embedding-3-small / BGE)
为什么不用 LLM 做 embedding:
- embedding 模型专门为语义匹配训练,效果更好
- 速度快 100 倍,成本低 1000 倍
- 可以离线批量处理
vector_db: 向量数据库实例(Milvus / Qdrant / Chroma)
"""
self.embed_model = embed_model
self.vector_db = vector_db
def store(self, info: dict, session_id: str):
"""
存入长期记忆
Args:
info: 要存储的信息,格式:
{
"content": "用户偏好稳健投资,不喜欢高风险",
"type": "preference", # 信息类型:preference / fact / task
"source_turn": 3, # 来源对话轮次(方便溯源)
"timestamp": "2026-06-09"
}
session_id: 归属的会话 ID
什么时候调用 store:
- 对话结束时(Session End 事件)
- 用户明确表达了偏好(实时捕捉)
- 第二级摘要生成时(摘要里的偏好信息也要存)
坑点 1:必须带 session_id,否则不同用户的偏好会串线
真实事故:用户 A 的偏好被用户 B 检索到 → 隐私泄露
"""
# 1. 生成向量
text = info["content"]
vector = self.embed_model.encode(text)
# 2. 存入向量库(带 metadata,用于过滤和溯源)
self.vector_db.insert(
vector=vector.tolist(), # 转成 list(有些向量库不支持 numpy)
metadata={
**info, # 原始内容
"session_id": session_id, # 🔥 坑点:忘记加这个 = 隐私泄露
"stored_at": info.get("timestamp", "")
}
)
def retrieve(self, query: str, session_id: str, user_id: str, top_k: int = 3) -> list:
"""
从长期记忆中检索相关信息
Args:
query: 当前问题(用于生成检索向量)
session_id: 只想检索"当前会话"的记忆
为什么不是跨会话?
用户 B 的偏好不应该影响用户 A 的体验
user_id: 用户 ID(用于确认归属)
top_k: 召回数量
Returns:
相关记忆列表,格式:
[
{"content": "用户偏好稳健投资", "type": "preference", "source": "..."},
...
]
坑点 2:忘记做 user_id / session_id 过滤
→ 用户 A 可能搜到用户 B 的记忆(严重隐私问题)
坑点 3:top_k 设置过大
→ 召回数太多会干扰 LLM(Context 里堆满了不相关的记忆)
→ 经验值:top_k = 3-5
"""
# 1. 生成查询向量
query_vector = self.embed_model.encode(query)
# 2. 向量检索(带双重过滤:user_id + session_id)
# 🔥 坑点 4:过滤条件必须加,否则隐私泄露
results = self.vector_db.search(
query_vector=query_vector.tolist(),
top_k=top_k,
filter_expr={
# 只检索当前用户的记忆
# 条件:session_id = 当前会话 OR session_id = "global"(全局偏好)
"user_id": user_id
}
)
# 3. 解析结果(只取 metadata,不取向量本身)
memories = []
for result in results:
score = result.get("score", 0)
# 🔥 坑点 5:相似度阈值(低于阈值的不采纳)
SIMILARITY_THRESHOLD = 0.7
if score < SIMILARITY_THRESHOLD:
continue # 太不相关,丢弃
memories.append({
"content": result["metadata"]["content"],
"type": result["metadata"].get("type", "unknown"),
"relevance_score": round(score, 3)
})
return memories
def cleanup_outdated(self, session_id: str, keep_recent: int = 10):
"""
清理过期记忆
为什么需要清理:
用户的偏好会变化(比如"以前喜欢保守,现在开始尝试激进")
如果不清理,旧偏好会一直干扰新的对话
Args:
session_id: 清理哪个会话的记忆
keep_recent: 保留最近多少条(防止把所有记忆清没了)
"""
# 真实场景:按 timestamp 倒序,删除最旧的
# 但要保留标记为"global"的全局偏好
self.vector_db.delete(
filter_expr={
"session_id": session_id,
"is_global": False, # 不删除全局偏好
},
keep_recent=keep_recent
)
三级压缩协同工作流程
用户发起第 21 轮对话:
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 第一级:滑动窗口 │
│ │
│ 对话历史共 21 轮,窗口大小 = 5 │
│ → 保留最近 5 轮(第 17-21 轮) │
│ → 第 1-16 轮进入"待压缩区" │
└────────────────────────────┬────────────────────────────────┘
│ 有更早历史(> 5 轮)
▼
┌─────────────────────────────────────────────────────────────┐
│ 第二级:LLM 摘要 │
│ │
│ 对第 1-16 轮生成摘要: │
│ <summary> │
│ 意图:用户想了解股票投资 │
│ 偏好:用户偏好稳健投资,不喜欢高风险 │
│ 决策:用户暂缓买入,等待合适时机 │
│ 未完成任务:无 │
│ </summary> │
└────────────────────────────┬────────────────────────────────┘
│ 摘要里有偏好信息
▼
┌─────────────────────────────────────────────────────────────┐
│ 第三级:向量库存储 │
│ │
│ 摘要里的偏好信息("用户偏好稳健投资")存入向量库 │
│ 标注 type=preference,user_id=xxx │
│ → 下次新会话也可以检索到 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 当前会话 LLM 输入 │
│ │
│ 【系统设定】 │
│ 你是一个投资顾问 │
│ │
│ 【历史摘要】(第二级产物) │
│ 用户曾询问股票投资,偏好稳健投资,已暂缓买入 │
│ │
│ 【用户相关偏好】(第三级产物,从向量库检索) │
│ - 用户偏好稳健投资,不喜欢高风险(相关度 0.92) │
│ - 用户公司只能开增值税普通发票(相关度 0.88) │
│ │
│ 【最近对话】(第一级产物) │
│ 用户:现在有什么好股票推荐吗 │
│ │
│ 【当前问题】 │
│ 帮我推荐一支适合我的股票 │
└─────────────────────────────────────────────────────────────┘
面试核心答案汇总
Q1:为什么需要三级压缩?
A:一级不够。三级各有分工:滑动窗口处理最近对话(快),
摘要压缩处理中期历史(保留因果链),向量库处理跨会话记忆(持久)。
Q2:为什么是 5 轮?
A:5 轮是经验平衡点。3 轮上下文太少信息不足,7 轮 token 消耗大且 LLM 注意力下降。
Q3:为什么第二级用 LLM 摘要而不是直接存向量?
A:向量检索会丢失顺序和因果关系。摘要能保留"先做了什么决定,再做什么",
这是投资/客服等场景的关键信息。
Q4:第三级和第二级有什么区别?
A:第二级是"同会话内的历史压缩"(会话结束时触发)。
第三级是"跨会话的长期记忆共享"(按需检索)。
两者的触发时机、存储介质、召回方式都不同。
Q5:三级压缩的执行顺序?
A:按需触发,不是每次都跑全三级。
对话未超窗口 → 只用第一级
超窗口 → 第一级 + 第二级
新会话开始 + 有相关偏好 → 第三级检索
Java 对照总结
| 级别 | Agent 概念 | Java 对照 | 核心操作 |
|------|-----------|---------|---------|
| 第一级 | 滑动窗口 | LinkedList (LRU) | removeFirst() |
| 第二级 | LLM 摘要 | MapReduce Combiner | 局部汇总 |
| 第三级 | 向量检索 | Redis Sorted Set / ES | 按相似度召回 |
| 组装 | 上下文拼接 | StringBuilder | 按顺序 append |