13 · 自动压缩:上下文快满了,把历史折叠成摘要

缓存省钱但不缩小历史;当历史真的装不下,就得把老对话摘要掉。讲清压缩边界(保留近期、摘要远古)、摘要 prompt 为何是成败关键、proactive 与 reactive 两种触发,以及压缩与缓存那对绕不开的矛盾。

作者 David更新于

13 · 自动压缩:把历史折叠成摘要

缓存解决了“重复的别重算”,但它救不了另一半问题:当历史本身膨胀到装不下窗口时,缓存无能为力——你得真的把历史变小。这一篇,我们正面处理这头猛兽。

一、背景:缓存不缩小任何东西

分清两件事:

  • 缓存让你便宜地重发同一段历史,但历史该多大还多大——它一个 token 都没减少。
  • 当水位线告诉你“上下文到 85% 了”,再便宜的重发也没用,窗口就要装不下了。

这时唯一的出路是:把一部分历史,从“逐字保留”变成“摘要保留”——用一小段摘要,替换掉一大坨老消息。这就是压缩(compaction)。

二、解决方案:保留近期,摘要远古

粗暴地把整个历史塞给模型“缩短一下”是不行的——你会丢掉太多细节。聪明的压缩有个清晰的结构:一条压缩边界,把历史切成两半。

压缩:边界之前折叠成摘要,之后逐字保留
压缩前:[msg1][msg2][msg3]...[msg40][msg41][msg42]   ← 太长,装不下
                    压缩边界 ┘
压缩后:[—— 摘要:msg1..msg40 的精华 ——][msg41][msg42]  ← 瘦身完成
  • 边界之前(远古历史):喂给模型,让它写一段结构化摘要,然后用这段摘要替换掉这几十条消息。
  • 边界之后(近期历史):逐字保留——模型干活最依赖最近的细节(刚读的文件、刚跑的报错),这部分不能动。

压缩本身是一次额外的 LLM 调用:把要压缩的历史发给模型,配一个专门的“压缩 prompt”,拿回摘要:

压缩 = 一次带专门 prompt 的模型调用
async function compact(oldMessages: Message[]): Promise<string> {
  const res = await client.messages.create({
    model, max_tokens: 2048,
    system: COMPACT_PROMPT,   // ← 摘要的质量,全系于这个 prompt
    messages: [...oldMessages, { role: 'user', content: '请把以上对话压缩成结构化摘要。' }],
  })
  return textOf(res)
}
// 压缩后的新历史 = [摘要] + 近期逐字保留的尾部
const newMessages = [{ role: 'user', content: `<summary>${summary}</summary>` }, ...recentTail]

三、决定性细节:摘要 prompt 就是成败本身

压缩的全部风险,集中在那个摘要 prompt 上。 摘要写好了,Agent 压缩完能无缝接着干,仿佛什么都没发生;摘要写砸了,Agent 当场失忆——忘了自己在修哪个 bug、忘了已经改过哪些文件、忘了用户最初的要求,然后开始胡乱重复劳动或跑偏。这是压缩最危险的地方:它是一个有损操作,而损掉的可能正是关键信息。

所以摘要 prompt 必须明确要求模型保留那些“继续干活所必需”的东西,通常包括:

  • 当前任务是什么、用户最初的完整意图。
  • 已经做了什么:改过哪些文件、跑过哪些命令、得到什么结果。
  • 关键事实与决策:发现的根因、选定的方案、踩过的坑。
  • 当前进行到哪一步、下一步打算干什么。
  • 重要的文件路径、符号名、配置值——这些具体的“锚点”丢了就得重新去查。

一个好的摘要 prompt,往往会给出一个固定的结构模板(任务/已完成/关键发现/当前状态/下一步),强制模型逐项填写,而不是随意“写个大概”。把摘要的结构定死,是对抗“压缩即失忆”的主要武器。

四、两种触发:proactive vs reactive

什么时候压缩?有两条路,成熟系统两条都要:

Proactive(主动)Reactive(被动)
触发水位到阈值(如 85%),主动压请求已经 413 超长了,被迫压
时机撞墙之前撞墙之后恢复
心态未雨绸缪亡羊补牢

理想情况全靠 proactive:在还没撞墙时,水位一到阈值就主动压缩,用户无感。 但现实中估算总有误差,偶尔会“没料到就超了”——这时 reactive 兜底:捕获 prompt too long 错误,压缩历史,然后重试刚才那个失败的请求。这也呼应了第六阶段错误恢复的思路——把“上下文超长”当成一种可恢复的错误,而不是终点。两条路一主一备,才稳。

还有一个不能忘的细节:压缩本身也要消耗上下文(要把老历史发给模型)。所以水位线那篇强调的“提前动手、留出余量”在这里兑现——你不能等 100% 满了才压,那时连“执行压缩”的空间都没了。

五、绕不开的矛盾:压缩 vs 缓存

压缩和缓存天然打架,这个矛盾必须正视:

压缩重写历史,必然改变字节,于是击穿缓存。 你刚压缩完那一轮,前面辛苦建立的缓存从压缩点起全废,得重新建立。所以压缩不是“纯赚”——它拿“一次缓存重建的代价 + 一次摘要调用的代价”,去换“上下文不爆 + 后续每轮更短”。这笔账通常划算(否则会话根本没法继续),但你要清楚自己在交易什么。精细的实现会尽量让压缩只折叠很老、本来也快过期的缓存段,保住近期的热缓存——这正是下一篇那些“外科手术式”精细化手段要解决的。

六、小结

自动压缩是上下文工程里“减负”的主力手段,也是有损、有风险、有代价的一步:

  1. 保留近期、摘要远古,用一条压缩边界切开。
  2. 摘要 prompt 决定成败——用固定结构强制保留“继续干活所必需”的信息,对抗“压缩即失忆”。
  3. proactive 主动 + reactive 兜底,并给压缩本身留出余量。
  4. 正视压缩击穿缓存的代价,想清楚这笔交易。

压缩是“大刀阔斧”——一下折叠掉几十条消息。但有时你想要更精细的控制:比如“只把那几个占了两万 token 的巨型工具结果删掉,别的都留着”。这种“外科手术式”的上下文管理,是下一篇的主题。


本篇对应代码:配套仓库加入 src/compact.ts(阈值触发的摘要压缩),接入循环的每轮开头。git checkout article-13。

本页目录