# Step 24: 对话压缩

一句话导读:当对话变长、逼近上下文/预算上限时,不粗暴丢弃早期消息,而是把它们切出来、摘成一段短文本、拼回历史最前面——保留最近 N 轮的完整细节,把更早的部分「有损压缩」成一句梗概。


# 一、这一步做了什么(What)

新增 utils/compact.ts 的 compressMessages(),并接进主循环:

  1. 按「保留最近 keepPairs 对(默认 3 对 = 6 条)」把历史切成 toCompress(要压的)和 toKeep(要留的);
  2. 从 toCompress 里抽出文本、逐条截断拼成 summary;
  3. 用一条 role:"user" 的 [Earlier compressed:...] 消息替换掉整段早期历史,后面接上完整的最近几轮;
  4. 提供 /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 };
}
1
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        逐字保留
1
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      // 记下这次压缩的轮次,避免连压
  ↓
正常发请求
1
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 */ }
}
1
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 之间自动切换,简单任务用便宜模型,复杂任务才上贵的。