上下文不是垃圾桶:从「每轮都扔」到「先清可再生载荷」
系列回顾:主循环 · 代码库工具 · 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 万字符) | 原样返回,不占位、不丢轮 |
| > 阈值 | 较早的大块、且属于 COMPACTABLE 的 tool_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 → 丢最早轮次
顺序是从最不痛到最痛,且后一步看前一步的结果:
- 拆炸弹(单条,几乎每轮)
- 清可再生载荷(总量真超了才动)
- 丢对话骨架(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)。
你可以从这里带走什么?
- 裁剪前先估算——条数超限 ≠ 装不下;保尾不应无条件触发。
- 信息有可再生性差异——先 micro 清 COMPACTABLE /
mcp__*的旧结果,最后才 retain 丢对话。 - 占位要留线索,文案对齐上游——
[Old tool result content cleared] tool=Read …,path 线索读file_path。 - 重估是关键——micro 救回来了,就不要再丢轮;对齐「前面够了后面不加重」。
- 管道拆开可测可注入——budget / micro / retain 三步,micro 走
QueryDeps。 - TRACE 按 stage 看——
budget/micro/retain比混在一起的 strategy 更易调试。
仓库与相关文档
- GitHub:https://github.com/jimchou-h/react-agent-mini
- 本文 CSDN:上下文不是垃圾桶
- 上一刀(必读前置):长对话不撞墙:Context Budget
- 主循环 · 代码库工具 · REPL · 项目上下文 · Skills · 权限 + Write · MCP 概念 · MCP 实现 · Bash
- compact 源码:src/services/compact/compact.ts
- 管道术语:src/services/compact/CONTEXT.md
- 单元测试:src/services/compact/__tests__/
欢迎 Star、Issue 和 PR。
本文基于 react-agent-mini 变更 v4-compact,并已按 v4-claude-align(COMPACTABLE / 英文占位 / file_path 线索)校正。
更多推荐

所有评论(0)