3651 字
约 12 分钟
18
5.2 压缩:把长对话塞进窗口
2026-10-25
无标签

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——它为什么能让成本降一个数量级,以及它如何反过来约束你的上下文设计。

记住这一条,压缩就不会压坏。

5.2 压缩:把长对话塞进窗口
https://clxhxhhr.top/posts/4888/
作者
clxstart
发布于
2026-10-25
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。