5.2 压缩:把长对话塞进窗口
一句话结论:压缩不只是为了"装得下"。它更重要的价值是把"需要思考才能得到的结论"变成"可以直接检索的知识"——因为注意力擅长查找,不擅长统计归纳。
读这篇之前
需要先读 2.2(上下文学习的内部机制)。这一篇把 2.2 那台"只有一半的检索引擎"补上缺失的另一半。
一、三个动机,第一个最好理解、后两个更重要
为什么要压缩?多数人只想到第一个。[原文 4568]
动机一:长度与成本
最直观的原因:窗口有限(比如 128K Token),工具结果动辄数万字符,几轮交互就可能撑满,任务被迫中断。同时 Token 越多,API 成本越高,推理延迟也急剧上升。
动机二:总结后的知识比原始形式更利于模型使用
这一条层次更深,也更容易被忽视。
即使窗口足够大,把所有原始信息堆进去也不是最优:十几轮搜索的原始结果散落在各处,模型每次决策都要在数万 Token 中反复检索,注意力被分散,关键信息容易被遗漏。
若先用一次 LLM 调用把已有信息结构化总结成"目前已知:A 是……,B 是……,还缺 C 的信息",后续思考就能直接使用这份精炼表示。
动机三:缓解"上下文焦虑"
这是一个很有意思的现象:当模型认为上下文窗口即将耗尽时,可能在任务尚未完成前提前收尾。[原文 4568]
所以,在窗口远未耗尽时主动压缩,反而可能提升决策质量。
二、一个例子说透"检索 vs 归纳"
假设上下文里有一段宠物店巡查记录:
笼子 1:黑猫。笼子 2:白猫。笼子 3:黑猫……(共 100 个笼子,其中 90 只黑猫、10 只白猫)
现在问:"黑猫和白猫各有多少只?"
- "笼子 37 里是什么猫?" → 这是查找,注意力很擅长。
- "总共有多少只黑猫?" → 这是统计归纳,需要遍历全部记录并维护计数状态,本质上是思考而非检索。
所以不开思维链时,模型很难直接答对。开了思维链能数对,但每问一次就要从头数一遍——而在 Agent 场景里,这类统计常常要被反复使用,累积成本很高。
解法:提前做一次总结,直接在上下文里写入:
当前统计:黑猫 90 只,白猫 10 只
模型立刻就能"检索"到这个结论。
这就是压缩的核心价值:把需要思考才能得到的结论,变成可以直接检索的知识。
三、两个必须区分的概念:溢出 vs 腐化
| 上下文溢出 | 上下文腐化(Context Rot) | |
|---|---|---|
| 表现 | 装不下了,任务中断 | 装得下,但找不到了 |
| 是否明显 | 明显(报错/截断) | 隐蔽:Agent 表面正常,决策质量悄然下降 |
| 典型症状 | 报错 | 反复纠结已解决的问题;找不到明明在上下文里的信息 |
| 原因 | 超过窗口上限 | 注意力被分散;无关内容占比过大 |
腐化比溢出更危险,因为它不报错。
原文的描述很准确:随着上下文变长,注意力权重被分散到更多 Token 上,每个 Token 获得的权重变小;更关键的是,无关内容一旦占到大头,决策质量就明显下滑。
打个比方:在一个巨大的图书馆找某本书,书架上摆的无关书籍越多,找到目标就越难。
所以压缩的设计原则是:与其期望模型从冗长上下文中自动学习,不如主动地、显式地进行知识提炼。虽然需要额外计算(专门的 LLM 调用做总结),但产出的是高密度知识表示。
四、三种压缩手法
| 手法 | 做法 | 成本 | 信息损失 | 适用 |
|---|---|---|---|---|
| 截断 | 丢掉最早的/最不相关的 | 零 | 高(不可控) | 兜底、明确过时内容 |
| 摘要 | 用一次 LLM 调用把历史压成要点 | 中 | 中(可控) | 长对话的常规手段 |
| 结构化笔记 | 按固定模板提炼(已知/未知/下一步) | 中 | 低 | 长任务、研究型任务 |
第三种最值得学
结构化笔记的模板:
【已知】
- 问题定位在 X 模块的 Y 函数
- 错误原因是配置项 Z 未设置
【已做】
- 读取了 3 个相关文件
- 尝试了方案 A,失败(原因:……)
【未知 / 下一步】
- 还需要确认 W 是否有默认值
- 下一步:读 W 的定义
为什么比自由摘要好? 因为它的结构是固定的,模型每次都能"检索"到对应条目,而不是在一段散文里找。这仍然是"检索优于归纳"的应用。
五、压缩与缓存:看似矛盾,实则互补
一个常见的困惑:5.3 会说"稳定的前缀能享受缓存",而压缩会改动历史——那不就破坏缓存了吗?
答案是:它们作用于不同的部分。
| 作用对象 | 目的 | |
|---|---|---|
| 缓存 | 稳定的前缀(系统规则、工具定义) | 省钱省时 |
| 压缩 | 易变的中后段(历史、工具结果) | 保持信噪比 |
所以正确做法是:
[系统规则 + 工具定义] ← 保持不变,享受缓存
[状态栏] ← 每轮更新,体积小
[压缩后的历史摘要] ← 定期重写
[本轮工具结果] ← 每轮新增
压缩只动后两段,前缀不受影响。 这就是"看似矛盾实则互补"的原因。[原文 4568]
六、什么时候该压缩
四条触发信号,按可靠性排序:
| 信号 | 判断方式 | 可靠性 |
|---|---|---|
| Token 用量接近阈值 | 跟踪 usage,达到 60-70% 触发 | 高(客观) |
| 历史轮次过多 | 超过 N 轮(如 15 轮) | 中 |
| 出现腐化症状 | 重复纠结、找不到已知信息 | 中(事后) |
| 阶段性任务完成 | 一个子目标达成时 | 高(自然断点) |
第四条最优雅:在任务阶段转换时压缩,既不丢关键上下文,又自然形成"检查点"。
七、常见误区
误区一:压缩就是为了省 Token。
省 Token 只是最浅的动机。提升思考质量、缓解腐化才是更重要的价值。
误区二:窗口够大就不用压缩。
腐化与窗口大小无关。装得下也可能找不到。
误区三:压缩会丢信息,所以尽量别压。
不压导致的腐化损失更大。关键是"压得对",不是"压不压"。
误区四:让模型自己决定要不要压缩。
模型不擅长判断自己的上下文状态(2.2)。由代码监控 Token 用量来触发。
误区五:压缩会破坏缓存,所以不划算。
只压后段,前缀不动,缓存不受影响。
误区六:摘要越短越好。
过度压缩会丢掉关键细节(具体报错、具体路径)。保留"可行动的事实",压掉"过程性叙述"。
八、动手:三种压缩手法的具体实现
5.2 讲了截断、摘要、结构化笔记三种。这里给具体写法。
手法一:截断(最简单,也最危险)
def truncate_messages(msgs, keep_last=6, max_chars=20000):
# 保留系统消息
system = [m for m in msgs if m["role"] == "system"]
rest = [m for m in msgs if m["role"] != "system"]
# 保留最近 N 条
kept = rest[-keep_last:]
# 总量兜底:从最早的开始丢
while sum(len(str(m)) for m in kept) > max_chars and len(kept) > 2:
kept.pop(0)
return system + kept
三个必须注意的点:
| 点 | 原因 |
|---|---|
| 永远保留 system | 否则规则丢失 |
| 至少保留 2 条 | 否则对话不连贯 |
| 丢的时候要留下"已丢弃"标记 | 否则模型不知道有东西被删了 |
第三条经常被忽略:
加一条消息:「(此前有 12 条历史对话已省略)」
这一句话能显著减少"模型重复问已问过的事"。
手法二:摘要(性价比最高)
SUMMARY_PROMPT = """
把下面的对话历史压缩成结构化摘要,用于继续任务。
输出格式:
【目标】一句话
【已确认的事实】最多 5 条,每条一句话
【已完成的动作】最多 5 条
【待解决】最多 3 条
【不要压缩的内容】用户的明确约束与硬性要求,逐字保留
对话历史:
{history}
"""
四个设计要点:
| 要点 | 作用 |
|---|---|
| 固定结构(目标/事实/动作/待解决) | 便于检索(2.2) |
| 逐字保留硬性约束 | 防止压掉关键规则 |
| 条数上限 | 防止摘要本身膨胀 |
| 独立于主对话调用 | 不干扰主上下文 |
第三行最容易被忽略:如果压缩把"不要修改测试文件"这条约束压掉了,Agent 就会去改测试。
手法三:结构化笔记(长任务最有效)
【已知】
- 问题在 auth/session.ts 的过期判断
- 配置 timeout=5s 太短
【已做】
- 读日志(确认超时)
- 读配置文件(确认是 5s)
- 改配置为 30s(已执行)
【待办】
- 跑测试验证
- 检查是否影响其他模块
【决策】
- 选改配置而非改代码:因为默认值本身不合理
与自由摘要的区别:结构固定,模型每次都能"检索"到对应条目(5.2 第二节)。
九、什么时候压缩:四种触发
| 触发 | 判断方式 | 可靠性 |
|---|---|---|
| Token 接近阈值 | usage 达 60-70% | 高(客观) |
| 轮次超限 | 超过 15 轮 | 中 |
| 任务阶段完成 | 一个子目标达成 | 高(自然断点) |
| 出现腐化信号 | 重复纠结、找不到已知信息 | 中(事后) |
最优的是第三个:在任务阶段转换时压缩,既不丢关键上下文,又自然形成检查点(10.4)。
十、一个被低估的压缩:工具结果的截断
5.2 主要讲历史压缩,但工具结果往往是更早爆掉的那部分。
| 工具 | 典型返回 | 风险 |
|---|---|---|
| 读文件 | 几千到几万字符 | 高 |
| 搜索结果 | 20 条 × 每条 300 字 | 高 |
| API 返回 | 含大量不需要的字段 | 中 |
| 日志 | 上万行 | 极高 |
三个截断策略
| 策略 | 做法 | 适用 |
|---|---|---|
| 头尾保留 | 留前 N 行 + 后 M 行,中间省略 | 日志(错误常在尾部) |
| 摘要 | 先摘要再给模型 | 大文件 |
| 按需分段 | 先给"目录",需要时再读具体段 | 长文档 |
第三个最优雅
第一次调用:read_file(path) → 返回
「文件共 12 节:1.概述 2.安装 3.配置 … 12.排查
需要哪一节?用 read_section(path, index)」
第二次:read_section(path, 3) → 返回该节全文
这相当于给文件系统加了"目录页"——和 9.2 的渐进式引用是同一思路。
一个反例
差:read_file 一次返回 50,000 字符
→ 上下文炸了,且模型找不到重点
好:返回结构 + 按需分段读取
"能读多少"不等于"该给多少"。
十一、一个补充:压缩会丢什么,必须保住什么
压缩的风险不是"变短",而是"丢错东西"。一张对照表:
| 内容类型 | 压缩建议 |
|---|---|
| 用户的硬性约束("不要改测试") | 逐字保留 |
| 具体的报错信息 / 路径 / 参数值 | 保留 |
| 已确认的事实 | 保留(一句话) |
| 过程性叙述("我先看了…然后…") | 压缩或删掉 |
| 重复的工具返回 | 只留结论 |
| 已废弃的尝试 | "试过 A,失败(原因)"即可 |
一条判据
压缩后剩下的内容,要能让"接手的人"继续干下去。
如果压缩掉"配置文件在 config/db.yaml"这条,接手的人就得重新找。
一个常见的压缩事故
压缩前:“用户说不要用 npm,要用 pnpm”
压缩后:“项目使用 pnpm”
→ 丢失了"禁止用 npm"这个约束
→ 后来 Agent 在某些情况下用了 npm
"禁止"类约束丢失是压缩最危险的后果 —— 因为它不会立刻报错,而是在某个边界情况下冒出来。
一句补充
压缩的目标不是"更短",是"更高密度"。 一份 500 字、句句可行动的摘要,比 2000 字、一半是过程叙述的原文更有用。
十二、下一篇
压缩解决了"内容太多"。但还有一个更省的做法:有些内容根本不重复计算,可以直接复用。
这就是缓存。下一篇讲 KV Cache——它为什么能让成本降一个数量级,以及它如何反过来约束你的上下文设计。
记住这一条,压缩就不会压坏。