在跑长任务时,Agent 的上下文总会越滚越大。
代码重构、查日志、连续跑测试,几十轮下来,token 很快逼近上限。以前最直接的办法是单次压缩(Single-pass Compaction):一旦超标,停下主流程,把前面的历史一股脑发给模型做个全局摘要,再把旧消息换掉。
跑起来之后,有几个问题很明显:
- 主流程卡顿:几十 KB 的历史发去调一次 LLM 摘要,经常要等好几秒甚至十几秒。客户端的输入框就一直卡在 loading。
- 细节被洗掉:压缩成自然语言摘要后,前面具体的报错行号、文件路径和零散参数都没了。后面一旦要用,模型要么重新查一遍,要么开始胡说。
- KV Cache 失效:要是把生成的 summary 直接塞进 System Prompt,模型服务端的 Prompt 前缀全变了,后续所有请求的 Prompt Caching 全部失效,首字延迟和费用直接飙升。
- 明明剪枝就行,却白白做了一次全文摘要:很多时候只是前两轮某个
git diff或cargo check的工具输出太大。把工具输出截断就能腾出几万 token,根本不需要调用 LLM 去做语义摘要。
这几天我把 Agent 运行时的上下文管理重写了一遍,主要改了四件事。
flowchart TD
subgraph Prefire[阶段 1: Pass 1 后台预热]
A[Token 达到 90% 阈值] --> B[切分 95% 历史前缀]
B --> C[异步发起 LLM 预摘要]
C --> D[缓存 NOTE1 笔记与指纹]
end
subgraph Foreground[前台主流程]
U[正常交互与工具调用] --> E[生成新消息]
end
subgraph Compaction[阶段 2: Pass 2 正式压缩]
F[Token 突破 100% 阈值] --> G{指纹是否匹配?}
G -->|匹配| H[只合并 NOTE1 与尾部增量]
G -->|失效或失败| I[回退为单次全量摘要]
H --> J[生成最终 Summary]
I --> J
J --> K[独立注入 Summary 消息与 JSONL 指针]
end
Prefire -.->|异步后台跑| Foreground
Foreground --> F
1. Two-Pass Prefire:把耗时的摘要挪到后台 #
要想前台不卡,最直接的办法就是在真正超限前,先把大头在后台做完。
阶段一:前缀预热(Pass 1) #
配置里加了个提前量(prefire_margin_tokens,默认预留 10% 缓冲)。
当上下文用到阈值的 90% 时,主循环不暂停,而是派发一个后台异步任务:
- 切分前缀:拿前 95% 的历史消息。切分点必须严格保护
tool_use与tool_result,不能把成对的工具调用从中间切断。 - 生成 NOTE1 笔记:把前缀历史发给模型生成一份结构化中间笔记(
NOTE1),并给这段前缀计算一个指纹(Fingerprint)存进缓存。 - 主流程该流式输出就流式输出,前台完全不感知。
阶段二:增量压缩(Pass 2) #
当后面的交互把总 token 推过 100% 阈值时,正式触发压缩:
- 先核对指纹。如果前缀没变且后台任务已经跑完,直接拿缓存好的
NOTE1。 - 这次发给模型的不是全部历史,而是
NOTE1加上 Pass 1 之后新产生的那几条尾部消息(Tail)。 - 就算指纹失效或者 Pass 1 失败了,直接降级回原来的单次全量压缩,逻辑不会断。
这样正式压缩时发给模型的 token 减少了 80% 以上,主流程等待时间降到了百毫秒级别。
2. 压缩不是删除:用 recovery_hint 指向本地 JSONL #
压缩不能把原始细节彻底扔掉。
- 唯一事实来源在本地:所有原始消息、工具参数和完整输出,自始至终都是追加写入本地 Session JSONL 的,物理上从不删除。
- 带上回捞提示:压缩生成的
<compaction-summary>后面,会自动带上<recovery_hint>标签,把当前会话的 JSONL 绝对路径写进去:历史已被概括。完整原始记录保存在
session.jsonl中,如果后续需要核对具体的代码行号、报错堆栈或输出细节,可以用读工具直接检索该文件。 - 不污染 System Prompt:Summary 作为一条独立的对话消息插入,System Prompt 从头到尾保持原样。这样大模型服务端的 Prefix Cache 始终命中,首字延迟很稳。
3. 先剪枝(Prune),再考虑 LLM 摘要 #
很多时候压根用不着调模型做摘要。
在跑压缩逻辑前,先跑一遍 trim_old_tool_results,把老旧工具输出里超长的文本截断成预览。如果剪完之后释放出来的 token 已经足够,直接标记 context_changed = true 并放行重试,省下一次调 LLM 摘要的开销。
另外还有一个边界问题:如果一个 turn 的最后一条回复(收到 EndTurn)刚好让上下文越界,以前往往要等下一次用户提问前才触发压缩。现在在 MessageComplete 之后、TurnEnd 之前直接完成压缩检查点落盘,下次启动会话拿到的就是处理好的干净上下文。
4. 实测效果 #
在一段连续 47 小时、1000 次请求(累计处理 4.9 亿 input tokens)的长会话中测试,数据表现如下:
| 指标 | 优化前 (964 轮) | 优化后 (36 轮) | 变化 |
|---|---|---|---|
| 均 Input / 轮 | 496,806 tokens | 299,625 tokens | ↓ 40% |
| 上下文形态 | 175 万全量重放 (膨胀) | ~25 万压缩起步 / 280K 平衡 | 受控 |
| Prompt Cache 命中率 | 99.11% | 96.91% (含首请求重建) | 后续维持 99%+ |
单轮平均 token 降了 40%,上下文从之前失控的百万级别稳定在 280K 左右。除了压缩后刚进下一轮的首个请求会有 2 万左右的缓存重建 miss 之外,后续请求的 Prompt Cache 命中率基本都在 99% 到 100%。
长任务跑得稳不稳,关键就在于这些底层的状态流转和缓存细节有没有处理干净。