Your Site Name

(2)Compaction:上下文满了之后

2026年10年15日 · 1994 字

写在前面

让 agent 干过长时间任务的人,多半见过这个现象:干着干着,它开始忘事。忘了最初的目标,忘了前面已经试过什么,甚至忘了自己改过哪些文件。

上一篇讲过原因:无状态,历史每次全量重发,窗口有硬顶,而且装得下不等于用得好。历史涨到窗口边上,问题就从"贵"变成了"崩"。

这篇讲生产级 harness 的标准应对:compaction,上下文压缩。那篇解剖 11 个系统的论文里,七家用的都是同一个思路,细节上却各不相同,对照着看很有意思。

封面:从超载的马车到精简收纳,压缩保住最重要的三样

上下文被稀释:不止是装不下

先明确一点:压缩要解决的不只是"装不下"。

随着上下文变长,模型的指令遵循能力会下降,早期信息的影响越来越弱。Anthropic 在上下文工程那篇文章里描述过这个现象,用的词是 context rot,直译是"上下文腐烂"。但我认为更准确的描述是稀释:内容本身没有变,一字不少地躺在上下文里,变的是模型对它的注意力。新内容越多,早期信息被分到的注意力就越少,像一杯茶不断兑水,茶还是那些茶,味道越来越淡。窗口还没满,性能已经在掉。

上下文稀释:茶还是那些茶,味道越来越淡

所以触发压缩的时机不是"满了才压",而是提前量。判断标准也很实际:早期决策还在不在、目标还记得准不准。一个 agent 改了二十个文件之后忘了任务目标,这不是模型变笨了,是目标被二十个文件的工具输出淹没了。

IMPORTANT: 长会话的问题不是容量不够,是信噪比下降。压缩的目标不是省空间,是把任务状态从噪音里捞出来。

压缩的标准做法

基本套路七家一致,都是阈值触发加 LLM 摘要:

压缩工作流:阈值触发,摘要替换旧历史,保留最近一段原文

  1. 设一个阈值,留出提前量;
  2. 触发时,把历史交给模型生成一份摘要;
  3. 用摘要替换旧历史,保留最近一段原文。

拿 Claude Code 举例。它的压缩在剩余 token 距离窗口上限还有约 13000 时触发。压缩过程会先把消息里的图片剥掉(替换成占位符),按对话轮次分组,让模型生成摘要,然后画一条压缩边界,边界之前的历史被摘要替换。

还有两个善后细节:压缩后会把最多 5 个文件重新读回来(预算 50000 token),因为压缩把"刚才在改哪个文件"的现场也压没了,得补;skill 的内容也截断到单个 25000 token 以内。

Claude Code 的压缩与恢复流程

第一代的做法可以看 Aider,2022 年的递归折半:历史超限就砍成两半,保留最近的一半,前一半让一个便宜的小模型摘要,还不够就递归,最多三层。简单,但"最近的一半"是无差别保护的,没有任务状态的意识。

递归折半:超限就砍半,前一半摘要,不够再递归

五家实现,五个侧重

论文里七家做阈值压缩的,细节差异值得挨个看:

五家实现,五个侧重

系统特色
Claude Code13K 提前量;压缩后恢复 5 个文件现场
Gemini CLI50% 触发,保留最近 30% 原文;对文件按 FULL/PARTIAL/SUMMARY/EXCLUDED 分级,比"压/不压"二值细
Mistral Vibe压缩后的状态是个"信封":摘要之外把之前的用户消息重新注入,保证最初的任务目标在重启后存活;同一套逻辑还会在撞上超长报错时被动触发
OpenCode增量合并:摘要时把上一份摘要一起传回去,"仍然成立的部分保留,过期的部分删掉",而不是从头重摘
Hermes最特别:压缩不是改写历史,是结束当前会话、开一个子会话,用 parent_session_id 串起来。历史一个字都不销毁,检索时跨谱系去重

我最喜欢 Hermes 这个设计。它把"压缩"和"会话管理"合并成了一个机制:对模型来说每次压缩都是一次干净的新开始,对人来说完整的审计记录都在。代价是检索要做跨会话,实现复杂一些。

Pi 的做法介于两者之间:会话本身是一棵树,压缩时保留最近 20000 token 原文,摘要采用迭代合并(和 OpenCode 同思路),另外留了一个 hook,让扩展可以否决或替换压缩结果。

TIP: 增量合并(把旧摘要传回去一起合并)比从头重摘更能保住早期决策。自己写 harness 的话,这是最值得抄的一个细节。

压缩的代价

压缩不是免费的,代价有两个。

一是丢细节。摘要必然有损,早期对话里的微妙约束("不要动那个配置文件")可能被摘掉。所以各家都在"保护什么"上做文章:保护最近尾段、保护前几条消息、重注入用户原始消息,方向都是一样的,压缩不能把任务状态压没了。

二是破缓存。上一篇讲过 prompt caching 靠"前缀完全相同"命中。压缩把历史改写了,前缀一变,缓存全部失效,下一轮要全价。这就是为什么触发时机要留提前量、要加缓冲:压缩本身是个昂贵动作,不能频繁做。

这两个代价指向同一个结论:压缩是急救,不是长治久安。真正重要的信息,不应该只活在对话历史里。放到哪里、怎么取回来,是下一篇的事。

压缩的代价:丢细节,破缓存

pi 里的开关

回到第 0 篇选定的实物 pi。它的压缩是可配置的,我的配置文件里现在写着:

"compaction": {
  "enabled": false
}

关着。不是因为否定压缩,是日常会话很少长到触顶,压一次的缓存代价反而更亏。真跑长任务时再开。这本身就是个说明:压缩是 harness 里一个要按场景权衡的组件,不是默认正义。

按场景启用:一个开关的权衡

写在最后

把因果线接上:无状态导致历史无限增长,窗口和信噪比给出硬约束,压缩用有损摘要换生存空间,七家系统在"保什么、怎么保"上收敛又分化。

但压缩解决不了信息的持久性问题。"装不下就放外面"其实包含两个子问题:重要的东西放在外面哪里,以及要用的时候怎么取回来。

下一篇把这两个问题一起讲完。存,主要靠文件系统和跨会话记忆。取,其实分两类:取回自己外置出去的说明和记忆,以及取用本来就装不下的代码仓库。第二类正好是一个悬案的现场:为什么那 400 万行源码里,没有一家 agent 用向量 RAG 读代码。

从压缩到持久记忆:三个阶段

参考资料

0 次阅读