跳过正文
  1. Posts/

把上下文压缩移出主路径:Agent 长会话的两阶段预热与可回捞设计

·4 分钟· · · #AI #Agent #Rust
目录
AI Coding - 这篇文章属于一个选集。
§ : 本文

在跑长任务时,Agent 的上下文总会越滚越大。

代码重构、查日志、连续跑测试,几十轮下来,token 很快逼近上限。以前最直接的办法是单次压缩(Single-pass Compaction):一旦超标,停下主流程,把前面的历史一股脑发给模型做个全局摘要,再把旧消息换掉。

跑起来之后,有几个问题很明显:

  1. 主流程卡顿:几十 KB 的历史发去调一次 LLM 摘要,经常要等好几秒甚至十几秒。客户端的输入框就一直卡在 loading。
  2. 细节被洗掉:压缩成自然语言摘要后,前面具体的报错行号、文件路径和零散参数都没了。后面一旦要用,模型要么重新查一遍,要么开始胡说。
  3. KV Cache 失效:要是把生成的 summary 直接塞进 System Prompt,模型服务端的 Prompt 前缀全变了,后续所有请求的 Prompt Caching 全部失效,首字延迟和费用直接飙升。
  4. 明明剪枝就行,却白白做了一次全文摘要:很多时候只是前两轮某个 git diffcargo 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_usetool_result,不能把成对的工具调用从中间切断。
  • 生成 NOTE1 笔记:把前缀历史发给模型生成一份结构化中间笔记(NOTE1),并给这段前缀计算一个指纹(Fingerprint)存进缓存。
  • 主流程该流式输出就流式输出,前台完全不感知。

阶段二:增量压缩(Pass 2)
#

当后面的交互把总 token 推过 100% 阈值时,正式触发压缩:

  • 先核对指纹。如果前缀没变且后台任务已经跑完,直接拿缓存好的 NOTE1
  • 这次发给模型的不是全部历史,而是 NOTE1 加上 Pass 1 之后新产生的那几条尾部消息(Tail)。
  • 就算指纹失效或者 Pass 1 失败了,直接降级回原来的单次全量压缩,逻辑不会断。

这样正式压缩时发给模型的 token 减少了 80% 以上,主流程等待时间降到了百毫秒级别。


2. 压缩不是删除:用 recovery_hint 指向本地 JSONL
#

压缩不能把原始细节彻底扔掉。

  1. 唯一事实来源在本地:所有原始消息、工具参数和完整输出,自始至终都是追加写入本地 Session JSONL 的,物理上从不删除。
  2. 带上回捞提示:压缩生成的 <compaction-summary> 后面,会自动带上 <recovery_hint> 标签,把当前会话的 JSONL 绝对路径写进去:

    历史已被概括。完整原始记录保存在 session.jsonl 中,如果后续需要核对具体的代码行号、报错堆栈或输出细节,可以用读工具直接检索该文件。

  3. 不污染 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%。

长任务跑得稳不稳,关键就在于这些底层的状态流转和缓存细节有没有处理干净。

AI Coding - 这篇文章属于一个选集。
§ : 本文

相关文章