长对话不撞墙:给最简 Agent 补上 Context Budget(compact)
系列回顾:主循环 · 代码库工具 · REPL · 项目上下文 · Skills · 权限 + Write · MCP 概念 · MCP 实现
到上一篇为止,react-agent-mini 会转、会逛仓库、能多轮聊、认识项目、有 Skills、有门卫、还能外挂 MCP 工具。
但有一个隐患一直没处理:REPL 聊得越久,messages[]越长,早晚撞上模型上下文窗口——或者先把你的钱包聊爆。这篇讲 Harness 的 Context Budget 柱的最小实现:
compactMessages()。
续篇(策略升级为分档 + microcompact):上下文不是垃圾桶
问题:历史只增不减
回忆第三篇的 QueryEngine:REPL 每轮把新消息 append 进 messages[],从不删。
第 1 轮:user + assistant → 2 条
第 2 轮:+ user + assistant(tool_use) + tool_result + assistant → 6 条
…
第 50 轮:几百条,其中夹着一堆几千字的 tool_result
后果按严重程度排:
| 后果 | 什么时候发生 |
|---|---|
| 每轮变贵、变慢 | 一直在发生(全部历史每轮重发给模型) |
| 撞上下文窗口报错 | 长会话迟早 |
| 模型「淹没在旧细节里」 | 大 tool_result 挤占注意力 |
其中最占空间的通常不是对话本身,而是 tool_result——一次 Read 一个大文件、一次 Grep 几百个匹配,动辄几千上万字符,而且越旧越没用(模型早就消化过了)。
解法光谱:从裁剪到摘要
完整版 Claude Code 有一套渐进策略:
microcompact → 清理旧 tool_result 等「可再生」内容
autocompact → 快满时让 LLM 把旧对话写成摘要,替换原文
/compact → 用户手动触发摘要
mini 刻意只做最左边的确定性裁剪:
| LLM 摘要(autocompact) | 确定性裁剪(mini) | |
|---|---|---|
| 保真度 | 高(语义压缩) | 低(直接砍) |
| 成本 | 要多花一次模型调用 | 零 |
| 可测性 | 难(输出不稳定) | 纯函数,单测直接断言 |
| 实现量 | 提示词 + 状态机 | ~100 行 |
学习目的下,先把「什么时候裁、裁哪里、怎么不裁坏」这三个问题弄清楚,摘要式压缩只是替换其中的「怎么裁」。
两级策略:先截断,再丢头
compactMessages(messages, options) 是个纯函数,做两件事:
第 1 级:单条太肥 → 截断
所有 tool_result.content 超过 maxToolResultChars(默认 4000)
→ 砍到上限,尾部拼上「…[tool_result 已截断(compact)]」
第 2 级:整体太长 → 丢最早的轮次
消息条数超过 maxMessages(默认 40)
→ 从头部丢弃旧消息,保留最近的完整轮次
第 1 级对应「大文件读取结果没必要一直带着」;第 2 级对应「五十轮前的闲聊可以放手了」。
关键细节:裁剪边界不能砍断配对
Anthropic 消息协议里,tool_use 和 tool_result 必须成对出现——如果历史里留了一条 tool_result,它引用的 tool_use 却被丢了,API 直接报错。
所以第 2 级不是「无脑保留最后 40 条」,而是:
/** user 纯文本消息 — 安全的裁剪起点(不会留下孤儿 tool_result) */
function isUserTextMessage(message: Message): boolean {
return (
message.type === 'user' && message.content.every(b => b.type === 'text')
)
}
/**
* 条数超限时保留尾部完整轮次
*
* 从 `length - maxMessages` 起向后找最近的 user 纯文本消息作为边界;
* 找不到时整体不裁(宁可超限也不裁断配对)。
*/
function retainTail(messages: Message[], maxMessages: number): Message[] {
if (messages.length <= maxMessages) return messages
let start = messages.length - maxMessages
while (start < messages.length && !isUserTextMessage(messages[start])) {
start++
}
if (start >= messages.length) return messages
return messages.slice(start)
}
翻译成人话:
- 裁剪起点必须是一条用户的纯文本提问——从这里开始的历史一定是自洽的
- 从「理论切点」往后找最近的这种消息;找不到就宁可超限也不裁
单测里专门验证了「裁完之后,每个 tool_result 都能在剩余列表里找到配对的 tool_use」。
最重要的设计决定:出站-only
compact 在哪里生效?看 query.ts 里的接线,就一行:
// —— 阶段 1:调用模型(出站副本先 compact,会话内存不变) ——
const outbound = compactMessages(messages, params.compact)
for await (const chunk of deps.callModel({
messages: outbound,
tools: params.tools,
systemPrompt: params.systemPrompt,
})) {
注意:裁剪的是 outbound 这个发送副本,state.messages(以及 REPL 的 QueryEngine.messages)原封不动。
QueryEngine.messages(完整历史,只增)
│
│ 每轮 callModel 前
▼
compactMessages() → outbound(裁剪副本)→ 发给模型
为什么不直接改内存里的历史?
| 出站-only(mini 的选择) | 写回内存 | |
|---|---|---|
| 可逆性 | 完全可逆(原始历史还在) | 裁了就没了 |
| 调试 | 随时能看完整对话 | 只剩裁剪后的 |
| 内存 | 持续增长(REPL 可 /clear) |
更省 |
对一个学习用 Agent,「不弄丢任何东西」比「省内存」重要得多。完整版在这点上更激进(摘要直接替换历史),mini 把它列为后续可选项。
另外两个不受影响的东西:
- systemPrompt:每轮单独传,不在
messages[]里,永远不会被裁——所以「重要约束写进AGENTS.md」(第四篇)在长对话里反而更可靠 - 无变更时恒等:没超限时直接返回原数组(
out === messages),零拷贝开销
配置与观测
| 配置 | 默认 | 说明 |
|---|---|---|
maxToolResultChars |
4000 | 单条 tool_result 字符上限 |
maxMessages |
40 | 出站消息条数上限 |
COMPACT=0 |
开启 | 环境变量一键关闭 |
params.compact |
— | query() 级别覆盖(测试常用) |
想亲眼看到它工作,开 TRACE:
TRACE=1 bun run dev
长会话里读几个大文件之后,stderr 会出现:
[trace] compact.run before=46 after=38 droppedMessages=8 truncatedBlocks=2
意思是:这轮出站前有 46 条消息,丢了 8 条旧轮次,截断了 2 个大 tool_result,实际发出 38 条。只有实质裁剪发生时才打日志——没裁就没有这行。
和主循环的关系(惯例对号)
L1 CLI / QueryEngine → 不变(内存历史完整保留)
L2 query() → callModel 前多一行 compactMessages
L3 runToolUse → 不变
L4 services/compact/ → 新增纯函数
记一句:
compact 不改变「循环怎么转」,只改变「每轮发出去多少」。
和前几篇的模式完全一致:MCP 改的是「工具表从哪来」,compact 改的是「出站消息有多大」——query() 的骨架从第一篇到现在没动过。
刻意没做什么?
| 没做 | 意味着什么 |
|---|---|
| LLM 摘要压缩 | 裁掉的信息就是没了,不会变成摘要 |
| 精确 token 计数 | 用字符数近似,够用且零依赖 |
| 按工具类型的精细 budget | 所有 tool_result 一视同仁 |
| 写回模式 | 内存持续增长,超长会话请 /clear |
| microcompact 式「可再生内容」识别 | 不区分哪些 tool_result 能重新获取 |
这些是完整版 Claude Code 值得深挖的方向;mini 先验证主干:
出站前 → 确定性裁剪 → 边界对齐完整轮次 → 配对不裁断 → 可关可观测
30 秒试一把
# 开 TRACE 跑 REPL,连续让它读几个大文件
TRACE=1 bun run dev
# 观察 stderr 里的 [trace] compact.run
# 关闭 compact 对比
COMPACT=0 TRACE=1 bun run dev
# 同样操作,不会再出现 compact.run
单测也可以直接跑:
bun test src/services/compact
系列拼图(到本篇为止)
| 篇 | 能力 |
|---|---|
| 1 | 主循环会转 |
| 2 | 逛代码库 |
| 3 | 多轮 REPL |
| 4 | 项目上下文 |
| 5 | Skills 按需加载 |
| 6 | 门卫 + Write |
| 7 | MCP 概念(六大能力) |
| 8 | MCP 接线实现 |
| 9 | Context Budget(compact) |
Harness 的支柱又立起来一根:循环、会话、上下文、技能、权限、外部工具、预算。
你可以从这里带走什么?
- 上下文是预算,不是无限仓库——每轮全量重发历史,长会话必然撞墙。
- 最肥的是旧 tool_result——先裁它,性价比最高。
- 裁剪边界要对齐协议——
tool_use/tool_result配对不能砍断,宁可超限不裁坏。 - 出站-only 是个好起手式——发送副本裁剪、内存历史完整,可逆、可调试、可测。
- 确定性优先——纯函数裁剪先跑通,LLM 摘要是后续的「怎么裁」升级,不改变「何时裁、裁哪里」。
仓库与相关文档
- GitHub:https://github.com/jimchou-h/react-agent-mini
- 主循环 · 代码库工具 · REPL · 项目上下文 · Skills · 权限 + Write · MCP 概念 · MCP 实现
- compact 源码:src/services/compact/compact.ts
- 单元测试:src/services/compact/__tests__/
欢迎 Star、Issue 和 PR。
本文基于 react-agent-mini 变更 v3-compact;续篇见 compact 2.0(含 v4-claude-align 的 COMPACTABLE 等校正)。
更多推荐

所有评论(0)