# Step 24: 对话压缩
一句话导读:当对话变长、逼近上下文/预算上限时,不粗暴丢弃早期消息,而是把它们切出来、摘成一段短文本、拼回历史最前面——保留最近 N 轮的完整细节,把更早的部分「有损压缩」成一句梗概。
# 一、这一步做了什么(What)
新增 utils/compact.ts 的 compressMessages(),并接进主循环:
- 按「保留最近
keepPairs对(默认 3 对 = 6 条)」把历史切成toCompress(要压的)和toKeep(要留的); - 从
toCompress里抽出文本、逐条截断拼成 summary; - 用一条
role:"user"的[Earlier compressed:...]消息替换掉整段早期历史,后面接上完整的最近几轮; - 提供
/compact手动触发;主循环还有自动触发:消息数 > 8 且距上次压缩超过 3 轮,就自动压一次。
一句话:用「最近清晰 + 远期模糊」替换「全部清晰但装不下」。
# 二、面试官视角:为什么要做对话压缩?(Why)
面试题:上下文快满了,直接把最早的消息
slice丢掉不就行了?为什么还要费劲做摘要压缩?
因为丢弃是无差别失忆,压缩是有损但保留骨架。
| 策略 | 做法 | 代价 |
|---|---|---|
| 截断丢弃 | 直接删最早的消息 | 早期的关键约定("用 TS"、"别改 config")彻底丢失,模型会"重犯旧错" |
| 全量保留 | 什么都不删 | 迟早撞窗口上限,API 400,或预算爆掉 |
| 摘要压缩 | 早期→梗概,近期→原样 | 早期细节有损,但主线信息(做过什么、定了什么)留下来了 |
关键洞察是对话里的信息价值随时间衰减但不归零:十轮前商定的「技术栈用 TypeScript、不要动 CI 配置」,具体措辞不重要,但这条约束必须活着。截断会让它彻底消失,模型下一步就可能违背它;摘要则把它浓缩成一行留在上下文里。同时最近几轮必须逐字保留——因为模型当前的推理强依赖最近的工具结果和用户指令,这些一旦模糊,直接影响下一步动作的正确性。所以压缩的本质是一个分段保真策略:近处高保真,远处低保真。
# 三、原理:它是怎么工作的(How)
# 完整实现(真实代码,约 20 行)
function extractText(msg: any): string {
if (typeof msg.content === 'string') return msg.content;
return (msg.content || []).filter((b: any) => b.type === 'text')
.map((b: any) => b.text).join(' ');
}
export function compressMessages(messages: any[], keepPairs = 3) {
const totalPairs = Math.floor(messages.length / 2);
if (totalPairs <= keepPairs) return { compressed: false, messages, summary: "" }; // 太短,不压
const keepCount = keepPairs * 2; // 3 对 = 6 条
const toCompress = messages.slice(0, messages.length - keepCount); // 早期:要压
const toKeep = messages.slice(messages.length - keepCount); // 近期:原样留
const parts: string[] = [];
for (const msg of toCompress) {
const text = extractText(msg);
if (text && text.trim()) {
parts.push((msg.role === 'user' ? 'User' : 'Asst') + ': ' + text.slice(0, 300)); // 每条截 300 字
}
}
const summary = parts.join("\n").slice(0, 3000); // 整段再截 3000 字
const compactMsg = { role: "user", content: "[Earlier compressed:\n" + summary + "\n]" };
return { compressed: true, messages: [compactMsg, ...toKeep], summary };
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 三个核心机制拆解
1. keepPairs —— 「一对」是什么,为什么按对切
对话天然是 user/assistant 交替的,一问一答算「一对」。keepPairs=3 意味着保留最近 3 问 3 答共 6 条。按「对」而不是按「条」切,是为了不把一问一答拆散——如果按奇数条切,可能留下一个孤零零的 assistant 回复而丢了对应的 user 提问,语义就断了。
2. 切分点 —— slice 的两刀
messages: [ m0 m1 m2 m3 m4 | m5 m6 m7 m8 m9 m10 ]
切分点 = length - keepPairs*2
└──── toCompress ────┘└──────── toKeep ────────┘
压成一段 summary 逐字保留
2
3
4
切分点 = length - keepCount。左边全压,右边全留,边界干净。
3. 摘要生成 —— 两层截断
这一版的「摘要」是规则式截断而非调用 AI:每条消息取前 300 字(slice(0,300)),拼起来后整段再取前 3000 字(slice(0,3000))。两层截断保证 summary 有确定上界,不会因为某条超长消息把摘要撑爆。产出的 summary 被包进一条 role:"user" 的 [Earlier compressed:...] 消息。
# 数据流:主循环里的自动压缩
用户输入
↓
messages.length > 8 且 turnCount - lastCompact > 3 ? // 双条件
↓ 是
r = compressMessages(messages, 3)
↓ r.compressed
messages = r.messages // [compactMsg, ...最近6条]
lastCompact = turnCount // 记下这次压缩的轮次,避免连压
↓
正常发请求
2
3
4
5
6
7
8
9
10
对应 index.ts 真实接线:
if (messages.length > 8 && turnCount - lastCompact > 3) {
const r = compressMessages(messages, 3);
if (r.compressed) { messages = r.messages; /* 更新 lastCompact */ }
}
2
3
4
# 设计细节表
| 细节 | 做法 | 为什么 |
|---|---|---|
| 太短不压 | totalPairs <= keepPairs 直接返回 | 6 条以内压了没意义,还丢信息 |
summary 用 role:"user" | 摘要伪装成用户消息 | 简单可靠,不需要构造特殊角色;模型把它当背景读 |
| 双条件自动触发 | 长度 > 8 且 距上次 > 3 轮 | 长度防「不够长瞎压」,间隔防「每轮都压」抖动 |
| lastCompact 记账 | 压完记下轮次 | 下次要再等 3 轮,避免刚压完又压 |
# 四、深入追问(面试常见 follow-up)
Q:这一版的「摘要」其实是字符串截断,不是真 AI 摘要。截断会不会把关键信息切没?
A:会,这是这一版的最大局限。slice(0,300) 只保头不保尾,若关键结论在一条长消息的末尾就丢了。真实 Claude Code 的 services/compact/ 是让模型自己摘要——把早期对话喂给一次 AI 调用,生成语义完整的梗概。代价是多一次 API 花费。学习版用截断把「压缩」这件事的骨架讲清楚,语义质量留给真实版。
Q:为什么摘要消息用 role:"user" 而不是 system 或 assistant?
A:三点考虑。一是 Anthropic API 首条通常需要 user,用 user 角色放在最前最稳妥;二是 system 一般是全局指令、不适合塞历史梗概;三是若用 assistant,模型可能误以为那是自己刚说的话。用 user 说「这是之前发生的事」,语义最干净,实现也最省事。
Q:keepPairs=3 这个数怎么定的?留多了/少了各有什么问题?
A:是保真度与压缩率的权衡。留太少(如 1 对),当前推理缺上下文、模型容易「断片」;留太多(如 10 对),压缩省不下多少空间、失去意义。3 对≈最近的完整问答闭环,够模型接着往下走,又能把更早的大头压掉。真实版会按 token 量动态决定保留多少,而非固定对数。
Q:自动压缩为什么要「长度>8 且 距上次>3 轮」两个条件,只留一个不行吗?
A:单条件都有毛病。只看长度:对话一旦过 8 条就每轮都触发压缩,抖动且浪费;只看间隔:短对话没必要压也会被压。两条件组合 = 「够长了(值得压)」AND「离上次够远了(别频繁压)」,才是稳定的触发时机。lastCompact 就是为第二个条件记账的。
Q:压缩会不会切在「工具调用对」中间,留下孤儿 tool_result? A:这一版按 user/assistant 对切,没专门处理工具调用块。而 tool_use 和 tool_result 必须成对出现,若切分点正好落在中间,就会留下没有配对 tool_use 的孤儿 tool_result,API 会 400。这正是 step29 要修的真实坑——这里先把压缩主干立起来,边界安全留到下一步补。
# 五、踩坑 / 设计权衡
- 规则截断 vs AI 摘要:截断零成本、可预测,但语义有损严重;AI 摘要贵一次调用但保真高。生产必然选后者,学习版用前者讲清结构。
- 固定 keepPairs vs 按 token 动态保留:固定对数简单,但一对长消息和一对短消息占的 token 天差地别。真实版按 token 预算倒推该保留多少轮,更精准。
- 孤儿 tool_result 风险:如上,按「对」切不等于按「工具块」切,边界不安全——这是 step29 的伏笔。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
utils/compact.ts(~20 行,规则截断) | services/compact/(15 文件,AI 摘要) |
keepPairs=3 固定对数 | 按 token 预算动态决定保留量 |
| 双条件自动触发 | query/compact.ts 更完整的触发策略 |
| summary 伪装成 user 消息 | 结构化摘要 + 专门的消息组织 |
| 未处理工具对边界 | 保证不切断 tool_use/tool_result 对(我们 step29 补) |
# 七、一句话总结
对话压缩 = 分段保真的上下文瘦身:按 keepPairs 把历史切成「近期原样 + 早期梗概」,双层截断生成有上界的 summary,双条件自动触发防抖,本质是「信息价值随时间衰减」这一洞察的工程落地——用有损换生存,让长对话不撞墙。
# 下一节预告
压缩省的是上下文空间,但花的还是同一个模型的钱。step25 换个思路省钱:按预算在 Haiku/Sonnet/Opus 之间自动切换,简单任务用便宜模型,复杂任务才上贵的。