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

Source: https://blog.ferstar.org/posts/agent-context-compaction-two-pass-prefire/





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

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

跑起来之后，有几个问题很明显：

1. **主流程卡顿**：几十 KB 的历史发去调一次 LLM 摘要，经常要等好几秒甚至十几秒。客户端的输入框就一直卡在 loading。
2. **细节被洗掉**：压缩成自然语言摘要后，前面具体的报错行号、文件路径和零散参数都没了。后面一旦要用，模型要么重新查一遍，要么开始胡说。
3. **KV Cache 失效**：要是把生成的 summary 直接塞进 System Prompt，模型服务端的 Prompt 前缀全变了，后续所有请求的 Prompt Caching 全部失效，首字延迟和费用直接飙升。
4. **明明剪枝就行，却白白做了一次全文摘要**：很多时候只是前两轮某个 `git diff` 或 `cargo check` 的工具输出太大。把工具输出截断就能腾出几万 token，根本不需要调用 LLM 去做语义摘要。

这几天我把 Agent 运行时的上下文管理重写了一遍，主要改了四件事。

```mermaid
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

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

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%。

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

