1. 从“第20轮失忆”说起:Agent上下文管理的真实痛点
如果你正在做 Agent 开发,大概率遇到过这个场景:前几轮对话里,Agent 还能准确引用用户十分钟前提到的偏好、约束和中间结论,聊到第 15 到 20 轮之后,它开始答非所问,把之前确认过的参数忘得一干二净,甚至把用户明确否定的方案又拿出来推荐一遍。这不是模型“变笨”了,而是上下文窗口被塞满之后,系统做了某种粗暴的截断或压缩,把关键信息一起丢掉了。
我最早踩这个坑是在一个多轮任务型 Agent 上。用户让它帮忙规划一份跨三周的学习计划,前 12 轮都很顺,到第 18 轮用户问“那第三周周末的复习安排呢”,Agent 直接回了一句“我们还没有确定第三周的内容”。实际上第三周的框架在第 6 轮就已经敲定了。排查下来发现,当时用的方案是“超过 token 阈值就把最早的消息整段删掉”,而第 6 轮那条消息恰好落在被删的区间里。
这件事让我意识到一个核心区别:管理历史和管理上下文,是两件完全不同的事。管理历史是“把对话记录存下来、按时间顺序拼进去”,管理上下文是“在有限的 token 预算内,动态决定此刻模型应该看到什么”。前者是存储问题,后者是调度问题。标题里说的“高手管理的是上下文”,指的就是后者。
这篇内容适合三类人:正在做 Agent 应用开发、被多轮对话稳定性折磨的工程师;准备从零搭建 Agent 框架、想提前避开上下文坑的开发者;以及已经在用现成 Agent 平台、但发现长对话质量下滑、想搞清楚底层机制的产品和技术同学。我会围绕Context Editing(上下文编辑)、Compaction(压缩)、Memory Tool(记忆工具)这几个关键词,把“为什么会失忆”“怎么判断该用哪种策略”“具体怎么落地”讲透,并给出可以直接参考的实现思路和参数取舍逻辑。
需要先明确一点:上下文管理没有银弹。任何方案都是在“信息完整性”“token 成本”“响应延迟”“实现复杂度”这四个维度之间做权衡。下面我会逐个拆解。
2. 上下文窗口到底被什么吃掉了:一次 token 账单的拆解
很多人以为上下文就是“用户说的话 + 模型回的话”,实际远不止。一个真实运行的 Agent,每一轮请求里塞进去的内容通常包括以下几类,我按占用比例从高到低排:
| 内容类型 | 典型占比 | 是否随轮次增长 | 备注 |
|---|---|---|---|
| 系统提示词(System Prompt) | 5%~15% | 否 | 包含角色设定、工具说明、输出格式约束 |
| 工具定义(Tool Schema) | 10%~30% | 否 | 工具越多、参数越复杂,占用越夸张 |
| 历史对话消息 | 30%~60% | 是 | 主要增长源 |
| 工具调用结果 | 10%~40% | 是 | 搜索结果、文件内容、API 返回,极易膨胀 |
| 检索增强内容(RAG) | 0%~20% | 视情况 | 每轮可能重新检索 |
| 当前用户输入 | 1%~5% | 否 | 通常最小 |
这张表里最容易被低估的是工具调用结果。我见过一个 Agent,单次网页抓取返回的正文就有 8000 token,连续抓三次就把 32k 的窗口吃掉大半。还有工具定义,如果你接了 20 个工具,每个工具 schema 平均 200 token,光工具定义就 4000 token,还没开始聊天就没了。
所以“第 20 轮失忆”往往不是第 20 轮才发生的,而是从第 8 轮开始,系统就在悄悄做取舍了。常见的粗暴做法有三种:
- 滑动窗口:只保留最近 N 轮,超出的直接丢。问题是早期确认的关键约束(比如“预算不超过 5000”“不要用某个库”)会被丢掉。
- 头部截断:保留 system prompt 和最近消息,中间挖空。问题是中间恰恰是任务推进的核心过程。
- 无脑摘要:把旧消息丢给模型总结成一段话。问题是摘要会丢细节,而且摘要本身也可能越滚越大。
提示:判断你的 Agent 是否已经“隐性失忆”,可以在 system prompt 里加一条指令,要求模型在每轮回答前先复述当前已知的关键约束。如果复述开始缺项,说明上下文已经在丢信息了。
理解了 token 账单,才能理解为什么“管理上下文”必须是有策略的,而不是简单的“存下来再拼回去”。
3. Context Editing:不是删消息,而是重写消息
Context Editing 这个词听起来很玄,本质上是在把消息发给模型之前,对消息列表做一次有目的的重写。注意是“重写”,不是“删除”。删除是不可逆的信息损失,重写是把信息换一种更省 token 的形态保留下来。
我常用的 Context Editing 手段有这么几类,按侵入性从低到高:
3.1 消息合并与去冗余
多轮对话里大量 token 浪费在客套和重复上。比如用户说“帮我查一下北京明天的天气”,Agent 回“好的,我来帮您查询北京明天的天气”,这一来一回里有效信息只有“北京”“明天”“天气”。Context Editing 的第一步就是把这类冗余压掉。
具体做法是维护一个“消息规范化”函数,在写入历史前先处理:
def normalize_message(msg): # 去掉纯确认类回复 if msg.role == "assistant" and is_pure_acknowledgement(msg.content): return None # 合并连续的同角色消息 # 压缩空白和重复表述 msg.content = collapse_whitespace(msg.content) return msg实测下来,光这一步在长对话里能省 15%~25% 的 token,而且几乎不损失信息。
3.2 工具结果的按需保留
工具调用结果是重灾区。我的做法是给每个工具结果打一个“保留等级”:
- 关键结果(如用户明确要求的数据、最终计算值):完整保留。
- 中间结果(如搜索到的候选列表):只保留前若干条 + 一句概括。
- 过程性结果(如“正在执行”“已提交”):只保留状态,不保留内容。
比如一次搜索返回 20 条结果,模型真正需要的可能只是前 5 条的标题和链接,剩下的可以压成“另有 15 条类似结果”。这个策略我在多个项目里用过,单次能省 60% 以上的工具结果 token。
3.3 结构化状态外置
这是我认为最有效的一招:把对话中沉淀下来的“事实”抽出来,存成结构化状态,而不是留在对话历史里。
举个例子,用户和 Agent 聊了 15 轮,确定了这些事实:
- 目标城市:北京
- 出行日期:3 月 15 日
- 预算:5000 元以内
- 禁忌:不吃辣
与其让这些信息散落在 15 轮对话里,不如维护一个 JSON 状态:
{ "destination": "北京", "date": "2025-03-15", "budget": 5000, "dietary_restriction": ["no_spicy"] }每轮请求时,把这个状态以精简形式注入 system prompt 或作为一条特殊消息。这样即使原始对话被压缩,关键事实也不会丢。这就是“管理上下文”和“管理历史”的分水岭——历史可以丢,状态不能丢。
注意:结构化状态需要一套抽取和更新逻辑。我的经验是让模型在每轮结束时输出一个“状态增量”,由代码合并进主状态,而不是让模型每轮重新生成完整状态,后者容易漂移。
4. Compaction:什么时候压、压什么、压到什么程度
Compaction(压缩)是 Context Editing 里最需要拿捏分寸的部分。压得太轻没效果,压得太狠丢信息。我把它拆成三个决策:触发时机、压缩对象、压缩目标。
4.1 触发时机:别等爆了才压
最常见的错误是“等 token 超过阈值再压缩”。这时候往往已经晚了,因为压缩本身也要消耗 token 和一次模型调用,而且压缩过程中如果上下文已经接近上限,压缩请求本身可能失败——热词里那个error running remote compact task: fatal error: remote compaction v2 expected就是这类问题的典型表现。
我的做法是设置双阈值:
- 软阈值(如窗口的 60%):开始做轻量 Context Editing,去冗余、压工具结果。
- 硬阈值(如窗口的 80%):触发 Compaction,对早期对话做摘要。
这样压缩发生在还有余量的时候,压缩请求本身不会因为窗口不够而失败。
4.2 压缩对象:优先压“过程”,保留“结论”
压缩不是把所有旧消息一视同仁地总结。我的优先级是:
- 优先压缩:Agent 的中间推理、试错过程、被否决的方案。
- 谨慎压缩:用户的明确指令、确认过的参数、最终结论。
- 绝不压缩:结构化状态、当前任务目标、安全约束。
一个实用的压缩 prompt 长这样:
以下是一段 Agent 与用户的历史对话。请压缩为要点列表,要求: 1. 保留所有用户明确提出的约束和偏好 2. 保留所有已确认的结论和参数 3. 丢弃试错过程和被否决的方案,只保留"曾尝试X但被否决"的一句话 4. 输出不超过 300 字4.3 压缩目标:分层摘要而非单一摘要
单一摘要的问题是它会随对话持续增长,最后又变成新的负担。我用的是分层摘要:
- 最近 5 轮:原文保留。
- 第 6~15 轮:一段中等粒度摘要。
- 第 15 轮以前:一段高粒度摘要,只保留结论和约束。
这样 token 占用是可控的,而且越久远的信息越精简,符合“近期细节重要、远期只需结论”的直觉。
| 压缩策略 | token 节省 | 信息损失风险 | 适用场景 |
|---|---|---|---|
| 去冗余 | 15%~25% | 极低 | 所有长对话 |
| 工具结果裁剪 | 40%~60% | 低 | 工具调用频繁的 Agent |
| 单层摘要 | 50%~70% | 中 | 对话轮次中等 |
| 分层摘要 | 60%~80% | 中低 | 超长对话、任务型 Agent |
| 结构化状态外置 | 视情况 | 极低 | 有明确任务状态的场景 |
这张表是我自己项目里实测的粗略区间,具体数值会因任务类型差异很大,但相对关系是稳定的。
5. Memory Tool:把“记不住”变成“查得到”
前面讲的 Context Editing 和 Compaction 都是“在窗口内做减法”。Memory Tool 是另一条路:把信息挪出窗口,需要时再查回来。这解决的是“窗口再大也不够用”的根本问题。
5.1 记忆的三种类型
我在设计 Memory Tool 时,会把记忆分成三类,分别用不同的存储和检索策略:
- 事实记忆:用户的偏好、身份、长期约束。存结构化数据库,每轮无条件注入精简版。
- 情节记忆:某次任务的完整过程。存向量库,按语义检索。
- 工作记忆:当前任务的中间状态。存内存或 KV,任务结束即清。
很多 Agent 框架把这三类混在一起塞进向量库,结果检索出来的东西要么太泛要么太碎。分开管理之后,命中率明显提升。
5.2 写入时机比检索更重要
Memory Tool 最容易做砸的地方是“什么都往里写”。我的原则是只在信息具备长期价值时才写入,判断标准有三条:
- 这条信息在未来对话中可能被再次引用吗?
- 它是稳定的,还是临时的?
- 它是否已经被结构化状态覆盖了?
只有前两条为“是”、第三条为“否”时才写入长期记忆。否则宁可让它随对话自然衰减。
5.3 检索回来的内容要“再压缩”
从记忆里检索出来的内容,不能原样塞回上下文。我的做法是检索后先做一次“相关性重排 + 摘要”,只把最相关的 2~3 条、每条压到 100 字以内注入。这样既用上了长期记忆,又不会把窗口重新撑爆。
提示:Memory Tool 的检索质量高度依赖写入时的元数据。写入时一定要带上时间、任务类型、涉及实体这些标签,否则后期检索只能靠语义相似度,召回会很不稳定。
6. 一套可落地的上下文管理流水线
把前面几块拼起来,我给出一套我自己在用的流水线。它不是唯一解,但每个环节的取舍逻辑我都验证过。
6.1 每轮请求的组装顺序
- System Prompt:角色 + 工具说明 + 输出约束(固定)。
- 结构化状态:当前任务的事实状态(精简 JSON)。
- 长期记忆注入:检索到的 2~3 条相关记忆(已压缩)。
- 分层对话历史:近期原文 + 中期摘要 + 远期摘要。
- 当前用户输入。
这个顺序有讲究:把最稳定、最重要的放前面,把最易变的放后面。这样即使后面被截断,前面的核心约束也不会丢。
6.2 每轮结束的收尾动作
- 抽取本轮产生的状态增量,合并进结构化状态。
- 判断是否有值得写入长期记忆的信息。
- 更新分层摘要的边界(是否需要把某轮从“原文”降级为“摘要”)。
- 记录本轮 token 占用,用于监控趋势。
6.3 监控指标
我必看的三个指标:
- 每轮 token 占用曲线:如果持续上升且不收敛,说明压缩策略失效。
- 关键约束复述准确率:定期用探针问题测试模型是否还记得核心约束。
- 压缩触发频率:如果每两三轮就触发一次硬压缩,说明软阈值设置太晚或压缩力度不够。
# 简化的监控埋点 def log_turn_metrics(turn_id, token_usage, compaction_triggered, constraint_recall_ok): metrics.append({ "turn": turn_id, "tokens": token_usage, "compacted": compaction_triggered, "recall_ok": constraint_recall_ok })这套流水线跑下来,我那个原本第 18 轮就失忆的 Agent,稳定跑到了 60 轮以上,关键约束的复述准确率保持在 95% 以上。
7. 几个我踩过的坑和对应的处理经验
最后分享几个具体教训,都是文档里不会写、但实际开发中一定会遇到的。
坑一:压缩请求本身超窗口。前面提过,硬阈值设太晚,压缩时窗口已经不够。解决办法是把压缩做成“分段压缩”,每次只压一段,而不是一次性压全部。
坑二:摘要越滚越大。单层摘要用久了会膨胀。换成分层摘要后解决,核心是给每层设 token 上限,超了就往下层合并。
坑三:结构化状态漂移。让模型每轮重新生成完整状态,几轮之后字段就开始乱。改成“增量更新 + 代码合并”后稳定了。
坑四:记忆检索污染。早期什么都往长期记忆写,结果检索出来的全是无关内容。加上写入门槛和元数据标签后,召回质量明显改善。
坑五:工具定义占太多。接了太多工具,光 schema 就吃掉三分之一窗口。后来做了工具分组,按当前任务动态加载相关工具,不相关的工具定义不注入。
这些坑的共同点是:它们都不是模型能力问题,而是上下文调度问题。这也是为什么我说“高手管理的是上下文”——模型本身没变,变的是你喂给它的东西。
如果你现在正在被多轮对话的稳定性困扰,建议先从“结构化状态外置”这一步做起,它的投入产出比最高,而且不依赖任何特定框架。等这一步跑顺了,再往上叠 Compaction 和 Memory Tool,会稳很多。