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

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

- 作者：David（道雾轩）
- 专栏：从 LLM 到 Coding Agent（https://daiw.org/manual/llm-to-agent.md）
- 最后更新：2026-08-03
- 原文：https://daiw.org/manual/llm-to-agent/auto-compaction
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

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

[缓存](https://daiw.org/manual/llm-to-agent/prompt-caching)解决了“重复的别重算”，但它救不了另一半问题：当历史本身**膨胀到装不下**窗口时，缓存无能为力——你得真的**把历史变小**。这一篇，我们正面处理这头猛兽。

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

分清两件事：

- [缓存](https://daiw.org/manual/llm-to-agent/prompt-caching)让你**便宜地重发**同一段历史，但历史该多大还多大——它一个 token 都没减少。
- 当[水位线](https://daiw.org/manual/llm-to-agent/token-budget)告诉你“上下文到 85% 了”，再便宜的重发也没用，**窗口就要装不下了**。

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

## 二、解决方案：保留近期，摘要远古

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

```text title="压缩:边界之前折叠成摘要,之后逐字保留"
压缩前:[msg1][msg2][msg3]...[msg40][msg41][msg42]   ← 太长,装不下
                    压缩边界 ┘
压缩后:[—— 摘要:msg1..msg40 的精华 ——][msg41][msg42]  ← 瘦身完成
```

- **边界之前**(远古历史)：喂给模型，让它写一段**结构化摘要**，然后用这段摘要**替换**掉这几十条消息。
- **边界之后**(近期历史)：**逐字保留**——模型干活最依赖最近的细节(刚读的文件、刚跑的报错)，这部分不能动。

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

```ts title="压缩 = 一次带专门 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 就是成败本身

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

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

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

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

## 四、两种触发：proactive vs reactive

什么时候压缩？有两条路，成熟系统两条都要：

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

<Callout type="info">
  **理想情况全靠 proactive：在还没撞墙时，水位一到阈值就主动压缩，用户无感。** 但现实中估算[总有误差](https://daiw.org/manual/llm-to-agent/token-budget)，偶尔会“没料到就超了”——这时 reactive 兜底：捕获 `prompt too long` 错误，压缩历史，然后**重试**刚才那个失败的请求。这也呼应了[第六阶段](https://daiw.org/manual/llm-to-agent)错误恢复的思路——把“上下文超长”当成一种**可恢复**的错误，而不是终点。两条路一主一备，才稳。
</Callout>

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

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

压缩和[缓存](https://daiw.org/manual/llm-to-agent/prompt-caching)天然打架，这个矛盾必须正视：

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

## 六、小结

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

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

压缩是“大刀阔斧”——一下折叠掉几十条消息。但有时你想要更精细的控制：比如“只把那几个占了两万 token 的巨型工具结果删掉，别的都留着”。这种“外科手术式”的上下文管理，是[下一篇](https://daiw.org/manual/llm-to-agent/context-compaction)的主题。

---

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