系列回顾:主循环 · 代码库工具 · REPL · 项目上下文 · Skills · 权限 + Write · MCP 概念 · MCP 实现 · Context Budget · Bash

这是 compact 的续篇。上一刀(Context Budget)让 react-agent-mini 学会了「裁」:截断超长 tool_result、超条数就丢最早轮次。
但它有两个毛病:明明装得下也在裁,而且一裁就把整轮对话骨架丢掉
这篇把同一根柱子改聪明:先看装不装得下,装不下先清「可再生」的旧载荷——并且把裁剪拆成三步管道,方便分别理解和测试。


上一版哪里不对?

回忆 v3 的策略:每轮 callModel 前,一个大函数里无条件跑两件事。

1. 所有 tool_result 超过 4000 字符 → 截断
2. 消息条数超过 40 → 从头丢掉最早的轮次

问题出在第 2 条的触发条件——它只看条数,不看实际有多大

场景:你和 Agent 聊了 50 条,但每条都很短(「嗯」「继续」「改一下」)
      估算总量可能才 3000 字符,离上下文窗口远得很
v3:  条数 50 > 40 → 照样丢掉最早 10 条

历史明明装得下,却被扔了。而丢掉的往往是最值钱的东西

丢掉的内容 能否恢复
你最初的需求描述 不能(只有你知道)
中间的决策讨论 不能
Read 某个文件的结果 (再 Read 一次)
Bash 的测试输出 (再跑一次)

v3 一刀切下去,不可再生的对话骨架和可再生的工具载荷同等待遇。这就像书柜满了,你把日记本和图书馆借来的书一起扔——书能再借,日记扔了就没了。


新策略:三步管道 + 重估

v4 不再把逻辑塞进一个大函数,而是把出站裁剪拆成三个可单独测试的阶段

① applyToolResultBudget   — 拆单条炸弹(几乎每轮都跑)
② deps.microcompact       — 超阈值时清旧 tool_result(可再生载荷)
③ applyRetainTailIfNeeded — 重估后仍超阈值才丢最早轮次(最后手段)

生产路径在 query.ts 里逐步调用:

    // —— 阶段 1:出站 compact 管道(只影响发给模型的消息)——
    // applyToolResultBudget → deps.microcompact →(重估后)retainTail
    const compactOpts = params.compact
    let outbound = applyToolResultBudget(messages, compactOpts)
    outbound = await Promise.resolve(
      deps.microcompact(outbound, compactOpts),
    )
    outbound = applyRetainTailIfNeeded(outbound, compactOpts)

compactMessages() 仍保留为单测编排入口(一次跑完三步),但主循环走的是上面这条线。

microcompact 还进了 QueryDeps,测试里可以注入 no-op,不必 mock 全局模块——和 callModel 同一套依赖注入思路。


第一步:applyToolResultBudget — 永远先拆炸弹

applyToolResultBudget 的规则很直接:不管总量超不超阈值,单条 tool_result 超过 maxToolResultChars(默认 4000)就硬截断。

/**
 * ① 单条 tool_result 硬截断
 *
 * 无论是否超阈值都会执行(防单条炸弹)。出站-only。
 */

一次 Read 大文件就能塞进几万字符——这种单点炸弹不能等总量超标才处理。

TRACE 示例:

[trace] compact.run stage=budget truncatedBlocks=1 strategy=truncate

第二步:microcompact — 超阈值才清可再生载荷

microcompactMessages(经 deps.microcompact 注入)先估算出站字符量

export function estimateOutboundChars(messages: Message[]): number {
  let total = 0
  for (const message of messages) {
    for (const block of message.content) {
      if (block.type === 'text') {
        total += block.text.length
      } else if (block.type === 'tool_result') {
        total += block.content.length
      } else if (block.type === 'tool_use') {
        total += block.name.length
        try {
          total += JSON.stringify(block.input).length
        } catch {
          total += 0
        }
      }
    }
  }
  return total
}
估算规模 microcompact 做什么
≤ 阈值(默认 8 万字符) 原样返回,不占位、不丢轮
> 阈值 较早的大块、且属于 COMPACTABLEtool_result → 短占位

不是所有工具结果都会被清:内置集合为 Read / Write / Edit / Bash / Grep / Glob,以及名字以 mcp__ 开头的 MCP 工具。Echo 等不在集合里的结果会保留。核心思路很简单:优先清掉“需要时还能再读回来”的旧载荷,不随便丢掉更难复原的对话语义。

占位示例(这里保留英文短句,方便模型识别这是“旧结果已清空”的信号):

原来:tool_result: "import …(8000 字符源码)"
之后:tool_result: "[Old tool result content cleared] tool=Read src/query.ts"

线索从配对的 tool_use 里挖(tool=、优先读 file_path),模型知道去哪儿重新获取。

两个保护约束:

  • 保留最近窗口:默认最近 4 条可清理的 tool_result 保留全文(microKeepRecent
  • 小的不动:超过 500 字符(microMinChars)才占位

TRACE:

[trace] compact.micro replaced=6
[trace] compact.run stage=micro estimated=138400 overThreshold=true strategy=micro

第三步:applyRetainTailIfNeeded — 重估后才丢轮

这是 v4 相对 v3 最关键的行为变化

micro 把规模压回阈值以内时,保尾这一步直接 no-op。

/**
 * ③ 保尾 — 作为 autocompact 的确定性替身(无 LLM)
 *
 * **重估**出站字符;仍超阈值且条数超限时才丢最早轮次。
 * micro 已把规模压回去时本步 no-op,前面已经够了,后面就不再额外丢历史。
 */
v3:  超条数 → 丢轮(不管 micro 有没有救回来)
v4:  micro 后重估 → 仍超阈值 + 条数超限 → 才丢轮

保尾边界仍对齐 user 纯文本消息,避免留下孤儿 tool_result——v3 里这条安全规则没丢。

TRACE:

[trace] compact.run stage=retain droppedMessages=48 estimated=210500 overThreshold=true strategy=retain

若 micro 已足够,不会出现 stage=retain——这就是「前面够了后面不加重」。


完整决策链(更新版)

callModel 前(出站-only,内存历史不变)
    │
    ├─ COMPACT=0 → 三步全跳过
    │
    ├─ ① applyToolResultBudget(总是)
    │      单条 tool_result 超 4000 → 硬截断
    │
    ├─ ② deps.microcompact
    │      估算 ≤ 8 万 → 原样
    │      估算 > 8 万 → 旧大块且 COMPACTABLE(及 mcp__*)→ 占位
    │
    └─ ③ applyRetainTailIfNeeded
           重估 ≤ 8 万 → no-op(micro 救回来了)
           重估仍超 + 条数 > 40 → 丢最早轮次

顺序是从最不痛到最痛,且后一步看前一步的结果:

  1. 拆炸弹(单条,几乎每轮)
  2. 清可再生载荷(总量真超了才动)
  3. 丢对话骨架(micro 仍顶不住才动)

观测:按 stage 看每一步

TRACE=1 时,日志按阶段分开打,不再混在一个 strategy=micro+retain 里:

stage 含义
budget 单条硬截断
micro 旧 tool_result 占位
retain 重估后仍超,丢最早轮次
TRACE=1 bun run dev

只拆了单条炸弹:

[trace] compact.run stage=budget truncatedBlocks=1 strategy=truncate

micro 生效、保尾未触发(重估已回落):

[trace] compact.micro replaced=6
[trace] compact.run stage=micro … strategy=micro
(没有 stage=retain)

micro 仍不够,才保尾:

[trace] compact.micro replaced=9
[trace] compact.run stage=retain droppedMessages=48 … strategy=retain

亲自验一遍「可再生」

TRACE=1 COMPACT_THRESHOLD_CHARS=20000 bun run dev

REPL 里:

1. 读几个大文件:读 src/query.ts 和 src/Tool.ts,各总结一句
2. 多聊几轮,让历史涨起来
3. 看 stderr 出现 compact.micro
4. 再问:「src/query.ts 里 queryLoop 第一阶段做什么?」

模型会发现旧 Read 结果变成占位符,于是重新 Read 再答——证明 microcompact 的核心假设:清掉的可再生载荷,需要时能捡回来。

对照:

COMPACT=0 TRACE=1 bun run dev   # 全程无裁剪日志
bun test src/services/compact   # 单测覆盖三步管道

配置一览

配置 默认 作用
COMPACT=0 开启 关闭全部裁剪
COMPACT_THRESHOLD_CHARS 80000 micro / 保尾的估算阈值
maxToolResultChars 4000 ① budget 单条硬截断上限
microKeepRecent 4 ② 保留最近几条完整 tool_result
microMinChars 500 ② 多大才值得占位
maxMessages 40 ③ 保尾条数上限(仅重估仍超阈值时)

和主循环的关系

L1 CLI / QueryEngine → 不变(内存历史完整,出站-only)
L2 query()           → callModel 前三步 compact 管道(不再是单行 compactMessages)
L3 QueryDeps         → 新增可注入 microcompact
L4 services/compact/ → 拆成 budget / micro / retain 三个导出函数

这一刀改的是「出站管道怎么接」,不是 ReAct 循环怎么转。

query() 骨架仍不变;变化是 callModel 前从「一个函数」变成「三步管道 + 重估」。


刻意没做什么?

没做 意味着什么
LLM 摘要(autocompact) ③ 是确定性保尾替身,不是调模型写摘要
snip / contextCollapse 更进一步的上下文治理层,mini 还没接
精确 tokenizer 字符估算近似
写回 QueryEngine.messages 仍出站-only
按工具类型区分策略 Read / Bash 一视同仁

完整版在超阈值时会调模型把旧对话写成摘要。mini 先把确定性管道做扎实:

budget → micro → 重估 →(必要时)retain → 分 stage 可观测

系列拼图(到本篇为止)

能力
1–5 循环 → 代码库 → REPL → 上下文 → Skills
6 门卫 + Write
7–8 MCP 概念 / 接线
9 Context Budget:会裁了
10 Bash:读—改—跑闭环
11 compact 2.0:三步管道 + microcompact + 重估

同一根柱子的两刀:先学会裁(v3),再学会克制地裁、且按阶段拆开(v4)。


你可以从这里带走什么?

  1. 裁剪前先估算——条数超限 ≠ 装不下;保尾不应无条件触发。
  2. 信息有可再生性差异——先 micro 清 COMPACTABLE / mcp__* 的旧结果,最后才 retain 丢对话。
  3. 占位要留线索,文案对齐上游——[Old tool result content cleared] tool=Read …,path 线索读 file_path
  4. 重估是关键——micro 救回来了,就不要再丢轮;对齐「前面够了后面不加重」。
  5. 管道拆开可测可注入——budget / micro / retain 三步,micro 走 QueryDeps
  6. TRACE 按 stage 看——budget / micro / retain 比混在一起的 strategy 更易调试。

仓库与相关文档

欢迎 Star、Issue 和 PR。


本文基于 react-agent-mini 变更 v4-compact,并已按 v4-claude-align(COMPACTABLE / 英文占位 / file_path 线索)校正。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐