系列回顾:主循环 · 代码库工具 · 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_usetool_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 的支柱又立起来一根:循环、会话、上下文、技能、权限、外部工具、预算


你可以从这里带走什么?

  1. 上下文是预算,不是无限仓库——每轮全量重发历史,长会话必然撞墙。
  2. 最肥的是旧 tool_result——先裁它,性价比最高。
  3. 裁剪边界要对齐协议——tool_use/tool_result 配对不能砍断,宁可超限不裁坏。
  4. 出站-only 是个好起手式——发送副本裁剪、内存历史完整,可逆、可调试、可测。
  5. 确定性优先——纯函数裁剪先跑通,LLM 摘要是后续的「怎么裁」升级,不改变「何时裁、裁哪里」。

仓库与相关文档

欢迎 Star、Issue 和 PR。


本文基于 react-agent-mini 变更 v3-compact;续篇见 compact 2.0(含 v4-claude-align 的 COMPACTABLE 等校正)。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐