上下文压缩的三难与六大解法:从"全量回放"到"该选哪个家族"
做对话型 Agent 时,几乎每个人都会先用最朴素的做法:每一轮,把整段对话历史原样重新喂给 LLM。它出奇地好用,直到对话变长——token 线性膨胀、延迟变高、迟早撞窗口上限。于是你加摘要;加了摘要又发现它和 prefix caching 打架;想靠"调整 prompt 顺序"救场,结果发现没救到点子上。
顺着这条线往上抽一层,会发现这不是一堆零散的工程 trick,而是一个有清晰本质、有完整解法谱系的问题域。本文把它沉淀成一张地图:① 全量回放为什么合理 → ② 本质是什么(三难权衡)→ ③ 摘要与缓存为何冲突、顺序能不能救 → ④ 六大解法家族 → ⑤ 怎么选。
姊妹篇 上下文窗口管理 从一个"每轮重复摘要"的具体 bug 切入讲方法论与开源实践;本文则聚焦解法谱系与决策框架,两篇互补。适用读者:做 Agent / 对话系统的工程师。
一、起点:为什么"全量回放"是正确的默认
根子在于 LLM 是无状态的。模型本身不"记得"任何事,它对一次调用的全部认知 = 这次 prompt 里的 token。要让它有"记忆",唯一办法就是把过去的内容放进 context。全量回放是这件事的最大保真版本:
- 无信息损失——任何压缩 / 裁剪都是有损的,全量是上界
- 实现最简——一条 SQL 拉历史、按序拼接,没有"该留哪条"的判断逻辑,不会因为裁错而丢关键信息
- 推理质量最高——模型能看到全部细节(用户上一轮"问天气但没给城市",模型这一轮自然能逮到并追问)
这也是为什么几乎所有 chat 框架的默认行为都是它。先用全量,是对的。 它的问题不在"错",而在"不可持续"。
它什么时候出问题
| 维度 | 表现 |
|---|---|
| 成本 | 每轮 input token ≈ 历史长度,单会话累计是 O(n²)(第 k 轮要重发前 k−1 轮) |
| 延迟 | input 越长,首 token 越慢 |
| 硬上限 | 迟早撞 context window 上限,撞了就报错或被迫截断 |
| 质量反降 | "lost in the middle" / context rot——历史太长,中间的关键信息反而被忽略;无关旧轮变噪声 |
注意最后一条:不是越多越好。超过某个长度,全量回放的推理质量会掉头向下。
二、本质问题:把"无界历史"投影进"有界窗口"
抽到底,这是 Agent 记忆 / 上下文工程(Context Engineering) 下的核心子问题——上下文窗口管理。一句话概括本质:
把"无界、有状态、会一直增长的历史",投影进一个"有界、昂贵、且最好能复用(可缓存)的固定窗口"。
它难,是因为它不是单纯的压缩,而是 「不知道未来会问什么」前提下的预测性有损压缩。你现在要决定丢 / 压哪些信息,但哪些信息将来有用,要等未来的 turn 才知道。本质上是个带不确定性的相关性预测问题,卡在三个互相打架的约束之间:
| 约束 | 想要什么 | 谁为它服务 |
|---|---|---|
| 保真 Fidelity | 别丢未来会用到的信息 | 全量回放 |
| 成本/延迟 Cost | token 少、窗口小 | 摘要 / 截断 |
| 稳定 Stability | 前缀只增不改(可缓存) | append-only 布局 |
这是个不可能三角。 全量回放拉满保真、牺牲成本;摘要换成本、牺牲保真和稳定;prefix caching 只管稳定这一条轴。没有免费午餐——所有方案都是在这个三角里选一个落点。理解了这点,后面的"解法家族"就只是这个三角里的不同坐标而已。
三、一个典型陷阱:摘要与 prefix caching 为何冲突,调顺序能不能救
这是"稳定"轴上最容易踩的坑,值得单独讲清,因为它揭示了一个反直觉的结论。
prefix caching 的规则:从第 0 个 token 起、连续相同的最长前缀才命中,遇到第一个不同的 token,后面全部作废。
现在看一个"滚动摘要"(每次把旧历史整体重写成一段 summary)在压缩发生时的变化:
压缩前: [System, Summary_v1, V1,V2,V3,V4,V5, user]
压缩后: [System, Summary_v2, V3,V4,V5, user] ← Summary 被整体重写 v1→v2
Summary 紧挨 System,它一变,从它往后全废,缓存命中只剩 [System]。一个很自然的念头是:"把易变的 summary 挪到后面、稳定的放前面,不就行了?"
直觉方向对,但这个具体做法救不到点子上。 因为"谁导致作废"取决于谁的内容变了,而不是谁排在哪。把 summary 挪到 recent 之后:
[System, V3,V4,V5, Summary_v2, user]
压缩那一轮 recent 窗口也滑动了(V1、V2 被折进 summary),前缀在 V3 处就开始不同,一样从中间断;而且还打乱了时序——模型先读到较新的逐字对话、再读到更老内容的摘要,容易困惑。
真正的解法不是改位置,是改"写法":让 summary 只增不改(增量 / 冻结式)。 老摘要块写一次就永不动,需要再压时新写一块追加在后面:
压缩前: [System, SumBlk1, V1,V2,V3,V4,V5, user]
压缩后: [System, SumBlk1, SumBlk2(新), V3,V4,V5, user]
↑ 没动 ↑ 追加,不是重写
此时前缀 [System, SumBlk1] 完好命中,只有 SumBlk2 往后重算。会话越长、冻结块越多,可缓存前缀是增长的,而不是每次压缩都塌回 [System]。
由此得到一条可推广的排序原则:按可变性升序——最不变的在前,最易变的在最后。 而一旦 summary 冻结,这个顺序恰好等于时间序:
System(永不变) → 冻结的老摘要块(写完不变) → 近 K 轮逐字(随对话滑动) → 当前提问(每轮全新)
最稳 ───────────────────────────────────────────────► 最易变
所以 [System, Summary, recent, user] 的顺序本身没错,病在 summary 可变。顺带,两个会偷偷废掉前缀的细节也要盯:{today} 这类每天变的占位符进 system 等于缓存每天清零(可接受),但 {now} 精确时间戳、或每轮都变的 plan/todos 排在历史前面,会在很靠前处打断前缀——易变注入应尽量靠近 current user 放。
四、解法全景:六大家族
把"对历史怎么处理"分类,从轻到重——上面讲的"增量冻结摘要",只是家族 2 里一个同时照顾了稳定轴的点,绝不是唯一解:
1. 截断 / 丢弃(Truncation) 滑动窗口(只留最近 N 轮)、重要性淘汰(留关键、丢寒暄)。最简单。代价:老信息直接失忆。
2. 压缩(Compression) — 摘要在这 滚动摘要(重写)、增量 / 分层摘要(append-only)、摘要的摘要(recursive)。代价:有损 + 重写式会破缓存(见 §三)。
3. 外置 + 检索(Retrieval / RAG memory) 历史全量存外部(向量库 / KV),每轮只检索相关切片塞回窗口 = "无限记忆、有限工作集"。MemGPT / Letta 的"内存分页"是这家族的极致。代价:检索不到 = 失忆,且每轮检索集都变 → 对 prefix caching 极不友好(与稳定轴天然冲突)。
4. 结构化状态抽取(Structured State) 不存原文,把"用户在北京""当前任务 = 查 6 月能耗"抽成结构化字段 / 实体 / 知识图谱,agent 读写一份紧凑 state 而非重读 prose。最省、最稳定,但只能记住 schema 预先设计到的东西(另一种有损)。这也是经典的"agent 显式状态机"路线。
5. 架构层规避(Context Isolation / 分治) 干脆不让单个上下文长到要压缩:任务拆成子 agent,每个只拿自己需要的最小上下文(orchestrator-worker);用外部 scratchpad / 笔记文件当工作记忆,写完再读,而不是堆在 context 里;阶段间用 handoff 摘要交接。很多时候这是最优解——把问题消解掉,而不是优化它。
6. 系统 / 经济层(Caching) 不缩 token,而是让"重发"变便宜:prefix caching、缓存友好的布局(可变性升序)。它跟 1~4 正交——是底座,不是替代。
五、怎么选:取决于"未来的访问模式"
本质决策是一句话:「未来的 turn 主要会怎么访问过去?」 不同访问模式直接对应不同家族:
| 未来主要需要…… | 选哪个家族 |
|---|---|
| 看最近的上下文 | 滑动窗口(1) |
| 不可预测地查某条老事实 | 检索 / RAG memory(3) |
| 一组演进中的稳定事实(用户偏好、当前任务态) | 结构化 state(4) |
| 叙事连贯性 | 摘要(2) |
| 任务可分解 | 子 agent 隔离(5) |
生产级 agent 几乎都是分层混用(tiered memory):近期逐字(工作记忆)+ 可检索历史(情景记忆)+ 结构化事实 / 摘要(语义记忆)+ 底下垫一层缓存(经济层)。这跟人脑记忆、跟 MemGPT 的认知架构是同构的。
一句话结论
上下文管理不是"把更多东西塞进窗口",而是在 保真 / 成本 / 稳定 这个不可能三角里,根据"未来的访问模式"选一个落点。全量回放是正确的起点,不是终点;摘要是第一道闸,但要写成"增量冻结"才不破缓存;而最优雅的解法往往是家族 5——通过分治和外置,让单个上下文压根长不到需要压缩。先问"未来会怎么访问过去",再决定动哪个家族,而不是反过来。