# Step 29: 压缩别切散工具对
一句话导读:step24 的对话压缩按「条数」硬切历史,可能正好切在
tool_use和它配对的tool_result中间,留下一个找不到来源的「孤儿 tool_result」,Anthropic API 直接 400。这一步把切点约束到「干净的用户回合开头」,并把被压缩的工具轨迹「翻译成文字」而不是整段丢弃。
# 一、这一步做了什么(What)
修两件事,都在 utils/compact.ts:
- 切点保护:压缩时先按条数取一个候选切点,再往前退到最近的「纯文本用户提问」上,保证工具对(
tool_use+tool_result)要么整对进摘要、要么整对保留,绝不被劈开。退不到就放弃这次压缩。 - 压缩不丢内容:老版本只提取
text块,工具调用和结果被整段扔掉,模型压缩后会「失忆」。新版把每个块翻译成一句紧凑文字([调用 Bash(...)]/[结果: ...])再交给摘要器。
# 二、面试官视角:为什么要做?(Why)
面试题:对话历史压缩,为什么不能简单地「保留最后 N 条、其余丢进摘要」?
因为 Anthropic 的消息历史不是一串独立的行,而是有强绑定结构的。一轮工具调用在历史里永远成对出现:
assistant: [tool_use id=abc] ← 第 N 条
user: [tool_result id=abc] ← 第 N+1 条
2
tool_result 块携带一个 tool_use_id,API 会校验:每个 tool_result 必须能在更早的消息里找到同 id 的 tool_use。如果切点 cut = messages.length - keepCount 恰好落在这两条之间,尾部就只剩下 tool_result id=abc——而它的 tool_use 已经被压进摘要文本里了。API 看到一个「孤儿结果块」,直接拒绝:
400 messages: tool_result block(s) provided when previous message
does not contain the corresponding tool_use block(s)
2
这类 bug 阴险在于:平时不触发,只有压缩恰好发生在一次工具调用中间时才炸——依赖对话长度和工具使用位置,很难在测试里稳定复现。所以正确的做法不是「靠概率躲开」,而是从切点合法性上根除。
# 三、原理:它是怎么工作的(How)
# 机制:把切点约束到「干净的用户回合开头」
「干净起点」= role 为 user、且 content 里不含任何 tool_result 块(即它是一次真正的用户提问,不是工具回合的延续)。真实代码:
function isCleanUserStart(m: any): boolean {
if (!m || m.role !== "user") return false;
if (typeof m.content === "string") return true; // 纯文本提问,天然干净
return !(Array.isArray(m.content) &&
m.content.some((b: any) => b.type === "tool_result"));
}
let cut = messages.length - keepCount;
// 先按条数取候选切点,再往前退到最近的干净起点,绝不切断工具对
while (cut > 0 && !isCleanUserStart(messages[cut])) cut--;
// 退到 0 说明整段就是一个大工具轮次,没有安全前缀可压 → 放弃这次压缩
if (cut <= 0) return { compressed: false, messages, summary: "", tokensSaved: 0 };
2
3
4
5
6
7
8
9
10
11
12
只允许切在「纯文本的用户提问」上。因为一个完整的工具回合总是 user提问 → assistant(tool_use) → user(tool_result) → …,从 user 提问处切,保证工具对被完整地划到某一侧。
# 数据流
messages(含若干工具对)
↓ cut = length - keepCount (按条数取候选切点)
↓ while !isCleanUserStart(cut): cut-- (向前退到纯文本用户提问)
↓ cut<=0 ?→ 放弃压缩(安全降级)
↓
toCompress = messages[0..cut) toKeep = messages[cut..]
↓ 每条 → blockToText 逐块翻译成文字(不是丢弃)
↓ summarizeFn(翻译后的文字)
↓
[ compactMsg(伪 user 摘要) , ...toKeep ] ← 工具对要么整对进摘要,要么整对保留
2
3
4
5
6
7
8
9
10
# 压缩是「翻译」不是「删除」
第二个修复点:blockToText 把每种块浓缩成一句话,保留「做了什么、拿到什么结果」:
function blockToText(b: any): string {
if (b.type === "text") return b.text || "";
if (b.type === "tool_use") return `[调用 ${b.name}(${trunc(b.input, 200)})]`;
if (b.type === "tool_result")
return `[结果${b.is_error ? "(失败)" : ""}: ${trunc(b.content, 500)}]`;
return ""; // thinking 等:丢弃(冗长、非事实推理痕迹)
}
2
3
4
5
6
7
结果过长就截断——截断正是压缩想要的。这样摘要器知道 AI 读过哪些文件、跑过哪些命令,压缩后模型不会「我干过啥都忘了」。
# 四、深入追问(面试常见 follow-up)
Q:为什么退到「用户回合开头」就一定安全,而不是退到 assistant 消息?
A:因为工具回合的结构是固定的——一次用户提问后,才会跟着 assistant(tool_use) / user(tool_result) 的往返。从「纯文本用户提问」处切,这一整轮工具往返必然完整落在切点的某一侧;而如果从 assistant 处切,很可能它下面就挂着待配对的 tool_result,照样切散。用户提问是天然的「回合边界」。
Q:如果向前一直退到 0 都找不到干净起点怎么办?
A:if (cut <= 0) return { compressed: false, ... }——放弃这次压缩,原样返回。这是「安全降级」:找不到合法切点时,宁可这次不压(下一轮对话变长后还有机会),也绝不冒险发一个必然 400 的请求。失败要静默降级,不能升级成崩溃。
Q:为什么压缩时要保留工具轨迹,而 thinking 块反而丢弃?
A:两者性质不同。工具调用和结果是事实记录(读了什么、命令输出了什么),是后续推理的依据,丢了模型就失忆。thinking 是非事实的推理痕迹,冗长且一次性,留着只占摘要篇幅、没有信息增量——所以 blockToText 对 thinking 返回空串。
Q:这个 bug 为什么测试里难发现?怎么防止回归?
A:它是位置依赖的——只有「压缩触发点恰好落在某次工具调用中间」才炸,取决于对话长度、工具使用频率,随机性强。防回归的正确姿势不是加更多集成测试碰运气,而是把「切点合法」变成一个结构不变量(切点只能是 isCleanUserStart),并在 step31 把这个判断收成单一权威定义,让所有压缩路径共用同一份、不可能各写各的。
# 五、踩坑 / 设计权衡
- 按位置切 vs 按结构切:
messages.length - keepCount是「按位置」,简单但会切散强绑定的块对。正解是「按结构」——切点必须落在合法边界(用户回合开头)。凡是结构化数据,都不能按下标乱切。 - 保多少 vs 保结构:为了退到干净起点,实际保留的条数可能比
keepPairs*2略多(退点带来的额外前缀)。这是刻意的取舍:宁可多留几条,也不切散工具对。 - 这个坑在真实源码里被从根上规避:真实版用
compact_boundary系统消息显式标记压缩分界(见 step34),而不是靠事后「退点」去躲——从结构设计上就不会产生孤儿块。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
isCleanUserStart 事后退点保护 | services/compact/ 用 compact_boundary 边界消息,从结构上规避 |
blockToText 把工具轨迹浓缩成文字 | 压缩摘要同样保留工具使用轨迹而非丢弃 |
cut<=0 放弃压缩(安全降级) | 长会话用 snipCompact 截断内存,UI 仍能投影完整历史 |
| 压缩产物是一条伪 user 摘要 | 边界消息 + 摘要,结构更规整 |
# 七、一句话总结
结构化历史不能按位置乱切:tool_use / tool_result 是靠 id 强绑定的对,切点必须落在「干净的用户回合开头」,否则留下孤儿结果块被 API 400;同时压缩是「把工具轨迹翻译成文字」而非删除,模型才不会失忆。找不到合法切点就安全放弃。
# 下一节预告
至此我们有了一个完整的 headless mini Claude Code。Phase 3.5 起开始第一次质变——把散在 index.ts 的对话循环抽象成可复用的 QueryEngine 类。