第 8 章 / 共 11 章
压:compaction 的取舍
8.1 压完回来,它开始给 409 加重试
shopfront 的迁移任务跑了两个多小时,上下文快满了,工具自动做了一次压实。屏幕上闪过一段摘要,对话继续。
接下来的一轮,agent 还记得自己在做 v2 迁移,还记得改的是 src/checkout/。然后它打开 src/api/client.ts,给 v2 的下单接口加了一段重试:收到 409 就退避重试三次。
问题是,你们二十轮前专门讨论过这件事。v2 的 409 不是冲突错误,是幂等命中——同一个幂等键的重复请求,服务端返回 409 表示「这单我已经建过了」,正确的处理是把它当成功,去查已有订单,绝对不能重试。重试会在监控里刷出一片假告警,运气差还会触发上游限流。
那次讨论是在一段来回排查里发生的:你贴了一段日志,agent 猜错了两次,你解释了内部网关的约定,它说「明白了」,然后你们继续改代码。这条结论从来没有被单独写下来过。它就是对话里的几句话,夹在两段无关的排查之间。
摘要器把它丢了。你很难说它错了。
8.2 压实是什么,什么时候被触发
压实(compaction):在上下文接近上限时,把已经发生的对话做一次高保真摘要,然后用这份摘要重新起头,让 agent 用尽可能小的性能损失继续原来的任务。它是长任务三法之一,另外两个是结构化笔记(第 6 章)和子代理架构(第 9 章)。
Claude Code 在上下文用到约 95% 时会触发自动压实。这是产品行为,阈值和触发命令都可能随版本变化——请以你当前版本的实际表现为准,自己核对一次。
从机制上看,压实做的事很简单:把 N 轮对话喂给模型,让它产出一段远比原文短的表述,然后拿这段表述当新的起点。所有关于「摘要会不会丢东西」的焦虑,都可以还原成一个问题:摘要器在决定留什么的时候,它看到的是什么。
8.3 压缩不是压缩机的问题
这是本章的核心论点,也是最容易被误解的一点:压缩丢信息,通常不是摘要器不够聪明,而是你没有提前把决定性的事实固化下来。
摘要器只能从它看到的东西里挑。它面对的是几十轮混杂的对话:工具调用的原始输出、你随口的确认、改了又撤的方案、真正定下来的结论——全都是同一种形态的文本,没有任何标记告诉它哪句是「已确认的领域约束」,哪句是「当时的一个猜测」。
在这种输入下,摘要器天然偏向保留结构显眼的东西:最近发生的事、明确的任务陈述、最后一次工具调用的结果。而「v2 的 409 是幂等成功」这条,形态上就是一句普通的对话,长度短、位置在中段、周围是排查噪音。它被丢掉是这个形态的必然结果,不是失误。
所以正确的因果链是这样的:不是「压缩导致信息丢失」,而是「信息一直只存在于易失的对话里,压缩只是让这件事暴露出来」。就算不压缩,跑到第 60 轮,那条结论在中间位置也已经很难被稳定注意到了——压实只是把慢性问题变成了急性事故。
推论是:改进压缩效果的操作,几乎全部发生在压缩之前。
8.4 压缩前必须固化的四类事实
固化的手段就是第 6 章的笔记文件。写和压在这里合流:写是为压做准备。把关键事实落到窗口外的文件里,压实之后,agent 读一次笔记就能把它们全部取回,不依赖摘要器的判断。
下面四类是最值得固化的,因为它们的共同点是——丢了不会立刻报错,但会安静地把后面几十轮带偏。
| 类别 | 为什么必须固化 | shopfront 里的具体内容 |
|---|---|---|
| ① 已确认的领域事实 | 通常来自一次性的排查或人工解释,形态上就是普通对话,最容易被摘要器当成过程丢掉 | v2 的 409 = 幂等命中,按成功处理,去查已有订单,不重试;v2 请求体用 amounts.total,legacyTotal 已废弃 |
| ② 已做出且不再重议的决策(含理由) | 只留结论不留理由,压完之后 agent 会重新把这个问题当开放问题讨论一遍 | 决策:按端点逐个迁移,不做一次性全量切换。理由:结算链路要保证任意时刻可回滚到 v1 |
| ③ 已排除的方案 | 排除记录不写下来,压完就等于没排除过,agent 会重新提出你已经否掉的做法 | 不引入新的 HTTP 客户端库(团队约定);不在 client.ts 里做字段兼容层(会把两版语义混在一起) |
| ④ 完成判据 | 判据丢了,agent 会在「差不多了吧」的位置停下,或者反过来无限扩大范围 | src/checkout/ 下无 v1 端点引用;tests/checkout/ 全绿;新增幂等重复请求的用例;docs/api-migration.md 勾掉已迁移端点 |
一份可以直接抄的笔记骨架:
# checkout v2 迁移 — 任务笔记
## 目标
把 src/checkout/ 的结算流程从内部 v1 API 迁到 v2,并补齐测试。
## 已确认的事实(不要再重新推断)
- v2 `409` = 幂等命中,按成功处理,改为查询已有订单。禁止重试。
- v2 请求体字段:amounts.total(legacyTotal 已废弃)。
## 已做的决策
- 逐端点迁移,不做全量切换 —— 理由:结算链路需随时可回滚。
## 已排除
- 不引入新 HTTP 客户端库(团队约定)。
- 不在 client.ts 做 v1/v2 兼容层(语义会混)。
## 完成判据
- [ ] src/checkout/ 无 v1 端点引用
- [ ] tests/checkout/ 全绿
- [ ] 新增幂等重复请求用例
- [ ] docs/api-migration.md 勾掉已迁移端点
## 进度
- [x] POST /orders 已迁
- [ ] POST /orders/{id}/pay 进行中
维护这份文件有一个额外的好处:把它重写到上下文末尾,本身就是一次复述——把目标从中间位置拉回到注意力最强的尾部。这是生产实践里很成熟的一招,压实前后各做一次,收益最明显。
8.5 在边界压,而不是在窗口满时压
自动压实的触发条件是「快满了」。这是一个纯粹由资源决定的时机,和任务本身的结构毫无关系——它可能正好落在你改了一半某个文件、排查到一半某个报错的时刻。这种时候「什么是结论、什么是过程」最模糊,摘要器的输入质量最差。
更好的压缩点是边界:
- 一个子任务做完(
POST /orders迁完了) - 一个文件迁移完(
src/api/client.ts改完并通过测试) - 一次子代理返回(它跑了几万 token,回传了一段提炼摘要)
- 一次方向确认(你和 agent 敲定了下一步做什么)
在边界压,是因为边界处结论和过程是分开的。POST /orders 迁完之后,「结论」是这个端点已迁、用了哪种幂等键写法、遇到过哪个坑;「过程」是中间那七次试错的工具输出。这时候你甚至不需要依赖摘要器判断——你自己就能说清该留哪一句。
实践上就是:**别等它自动压,到了边界主动压。**主动压的时候顺手做三件事——更新笔记文件、把完成判据的勾打上、把下一步写清楚。压实之后 agent 拿到的就不是一段模糊摘要,而是一份结构化的现场交接。

图 8.1:左边是压实前的完整对话,右边是压实后剩下的内容。重点看中间那几条标注:哪些因为写进了笔记文件而被保住,哪些只存在于对话里因而消失了,以及失败记录为什么应该留在右边。
8.6 该留下来的,包括失败
有一条经验和「压缩」的直觉正好相反:保留失败的动作和报错。
直觉会说,压缩就是去噪,报错是噪音,清干净最省 token。生产经验给出的答案是反的——把错误记录清干净,模型很可能会再犯一次同样的错。
原因不难理解。模型的下一步动作是从上下文推出来的。一次失败的动作加上它的报错,构成了一条明确的负样本:「这条路走过,结果是这个」。留着它,模型会自己纠偏,绕开那条路。清掉它,上下文里就只剩下一个尚未解决的目标,和一片干净的、看起来什么都没试过的历史——那么最自然的下一个动作,恰好就是它刚刚失败的那一个。
具体到 shopfront:如果 agent 试过在 client.ts 里加兼容层、被测试打回,这次失败应该以某种形式留下来(写进笔记的「已排除」,或者在摘要里保留一行)。留下来的不必是完整的堆栈,一句「试过 X,失败,原因是 Y」就够——要保住的是因果,不是原文。
顺带说一句,这和「压缩前要固化的第三类事实」是同一件事的两面:已排除的方案,大多数正是从失败里来的。
8.7 修剪和摘要,两种不同的手术
「压」底下其实有两种做法,别混着说。
修剪(trimming):按规则丢弃消息。比如只保留最近 N 轮、丢掉超过某个体积的工具原始输出、把重复的文件读取结果去重。它是确定性的,不花额外的模型调用,不会引入新的错误——因为它不生成任何新内容,只做减法。代价是它不理解内容,规则说丢就丢,可能正好丢掉那句关键结论。
摘要(summarization):让模型生成一段新的浓缩表述。它能跨越几十轮把散落的信息收拢成几句话,这是修剪做不到的。代价是它引入了一次模型判断——判断可能错,而且新生成的文本可能带进原文没有的说法,这就是上下文投毒(context poisoning)的一个入口:一旦幻觉进了摘要,后面每一轮都在引用它。
选择很直接:
| 场景 | 用哪个 |
|---|---|
| 工具输出体积大、内容可再次获取(文件内容、目录列表、测试全量日志) | 修剪。需要时重新读一次就行,这正是第 7 章的「选」 |
| 跨越多轮的推理链、排查过程、你和 agent 的讨论 | 摘要。信息分散在多处,只有生成新表述才能收拢 |
| 已确认的事实、决策、判据 | 两个都不用——固化到笔记文件,让它根本不参与压缩 |
第三行是最重要的一行。最好的压缩策略,是让关键信息一开始就不在被压缩的那部分里。
8.8 压完不验证,等于没压
压实是一次有损操作,有损操作之后必须验证。给你一个成本很低的固定动作,叫压后三问——压实一结束,立刻让 agent 回答这三句:
- **复述当前目标。**一句话说清这个任务在做什么、做到哪儿了。
- **复述已确认的三条约束。**具体到字段名、错误码、禁止事项。不要接受「遵循团队规范」这种回答,要它说出内容。
- **复述下一步。**具体到文件和动作,不是「继续迁移」。
三句里任何一句对不上,立刻手动补——最快的补法就是让它读一遍笔记文件。别指望它自己发现缺口:从 agent 的视角看,被丢掉的信息和从未存在过的信息完全一样,它没有任何办法察觉自己少了什么。这就是为什么验证必须由你发起,而且要问具体内容,不能问「你都记得吗」。
开头那个 409 的事故,用第二问就能在加重试代码之前拦下来。
8.9 动手:给一次真实压实做一份前后对照
- **压之前先固化。**照 8.4 的骨架写一份笔记文件,四类事实各写至少一条。写的时候你大概率会发现:有些「已经定下来的事」你根本说不清是什么时候定的——这些正是最危险的一批。
- **在边界上手动触发压实。**别等自动触发。找一个子任务刚做完的时刻。
- **立刻做压后三问。**把 agent 的三个回答原样记下来。
- **逐条比对。**拿它的回答对照你的笔记文件,标出:说对的、说漏的、说错的。说错的比说漏的严重得多——那是投毒,不是遗忘。
- **补完并复盘。**让它读一遍笔记,重新回答三问。然后回头看:说漏的那几条,如果当初写进了笔记,还会漏吗?