Agent 上下文压缩不是删历史:从六大工具到云端四级水位线
你好,我是程序员无隅
这是我的个人主页:无隅的主页
技术永无止境,希望我的内容能帮助到你
热门专栏:Java 面试八股文 | LeetCode 算法笔记 | Claude Code 源码解读
本文是基于《横向拆解 Claude Code、Codex 等六大 Agent 上下文压缩策略后,我们做了第 7 个》这篇材料做的学习型整理。重点不是复刻原文,而是把它背后的工程取舍讲清楚,方便我们理解 Agent 上下文工程。

一、先讲本质:上下文压缩不是为了省 token,而是为了保护注意力
很多人第一次听到上下文压缩,会下意识以为它就是:
- 对话太长了,删掉一点历史;
- 工具输出太多了,截断一点文本;
- 快超过窗口了,让模型总结一下。
这些都对,但只说到了表层。
Agent 真正麻烦的地方在于:它不是一次问答,而是一条会持续运行的任务链。用户的需求、模型的思考、工具调用结果、错误日志、文件内容、临时计划,都会不断塞进上下文窗口。
窗口一旦变胖,问题不只是“放不下”。更危险的是模型的注意力会被稀释。
它可能忘记用户刚刚强调过的约束,可能被很早之前的工具输出干扰,也可能在一堆日志里找不到真正影响下一步决策的信息。
一句话理解:上下文压缩的目标不是把历史变短,而是让模型在下一轮调用时还能看见真正重要的东西。
这也是为什么优秀的上下文压缩系统,一定不能粗暴地“一刀切”。
用户原话、最近几轮对话、关键工具结果、任务状态、错误栈、待办事项,这些信息的价值是不一样的。压缩系统真正要做的是:给信息分层,然后按成本递增的方式逐步处理。
二、为什么“撑不住才压缩”的第一代方案体验很差
最直观的做法,是等上下文快满了再动手:
- 每轮结束后估算一下 token。
- 如果没超过阈值,就什么都不做。
- 如果快满了,就把前面的历史一次性摘要掉。
这个思路听起来很合理,因为实现简单,触发条件也清楚。
但用在 Agent 里,问题会很快暴露出来。
1. 触发太晚,模型已经开始“走神”
上下文不是到了 100% 才出问题。
在窗口使用率升高之后,模型已经可能出现注意力下降、短期连续性变弱、指令漂移等现象。等到真的溢出再处理,往往已经晚了。
这就像系统内存快爆了才开始清理,不是不能救,而是用户体验已经抖过一轮了。
2. 全量摘要容易丢细节
全量摘要会把前面十几轮对话压成一段总结。
可 Agent 执行任务时,经常需要的不是“大概发生了什么”,而是非常具体的细节:
- 用户贴过的某段代码;
- 某个工具输出里的文件名;
- 一个错误栈的关键行;
- 一条用户明确强调的限制;
- 某个任务已经完成还是没完成。
如果这些细节被摘要吞掉,模型下一步就可能做错。
3. 粗略 token 估算不可靠
很多实现一开始会用 text.length / 3 之类的方式估算 token。
这个方法做内部排序还勉强能用,比如“先处理哪段工具输出”。但如果拿它做触发阈值,就容易出事。
中英文混合、代码片段、日志、JSON、Markdown 表格,它们的 token 密度差异很大。估算值看起来 70%,实际 API 返回可能已经到 90% 以上。
更稳的做法是:真正触发压缩时,尽量使用 LLM API 返回的 usage.totalTokens。
4. 用户内容不能和工具输出同等对待
用户贴的一段代码,和 grep 返回的 5000 行结果,不应该用同一种策略处理。
前者代表用户意图,后者只是工具观察结果。
如果为了省空间把用户原话也压没了,模型会忘记任务源头;如果为了保守把所有工具输出都完整保留,上下文又会被日志淹没。
所以第一代方案的根本问题是:它把压缩当成一次性事故处理,而不是一个持续维护上下文质量的工程能力。
三、横向看各家方案:同一个问题,七种取舍
先放一张总览图。

这些方案看起来差异很大,但都在回答同一个问题:
当上下文快满时,我到底应该牺牲什么,保护什么,如何让模型还能继续干活?
Claude Code:五段流水线,先本地处理,最后才摘要
Claude Code 的思路比较工程化。
它不是一上来就调用 LLM 摘要,而是先做便宜的本地处理,比如调整工具输出预算、截短老工具结果、对局部输出做压缩。只有前面几层救不回来时,才进入更重的结构化摘要。
这种做法的核心优点是成本递增:能用字符串处理解决的,就不花模型调用的钱。
Codex CLI:优先保护近期用户消息
Codex CLI 的策略可以理解成:用户说的话更值得保留,模型和工具产生的内容可以被重写。
在上下文接近上限时,它会生成一份 handoff 摘要,替换掉旧历史,同时尽量保留近期用户消息原文。
这个思路很符合 Agent 任务链:用户的最近指令往往决定下一步方向,模型自己的中间表达可以被摘要重构。
OpenCode:先轻量隐藏,再重型摘要
OpenCode 的方案很有意思,它把压缩拆成两步。
第一步是轻量级的 prune,不一定真的删除数据,而是让模型看到的内容变成占位提示。比如老的工具结果还在数据库里,但上下文里只留下“这里有一段旧工具输出已被压缩”的标记。
第二步才是调用 LLM 生成结构化摘要,并且会回放最后一条用户消息,让模型从最近指令继续,而不是从摘要里继续。
这个设计的关键是:可恢复。
压缩后的内容不一定彻底消失,而是从“模型每轮都看见”变成“需要时可以找回”。
Cline:手动压缩和自动压缩并存
Cline 的特点是给用户控制权。
用户可以主动触发压缩,让当前任务在同一个会话里继续;也可以让系统在接近上限时自动触发。
这类方案适合单人本地使用,因为用户能感知当前任务是否需要“整理一下上下文”。
Cursor:压缩后仍然能回查历史
Cursor 的方向不是只做摘要,还会把聊天历史变成可搜索对象。
也就是说,压缩后的 Agent 不一定只能依赖摘要继续工作,它还可以在需要时回头检索原始历史。
这个方向很重要:摘要适合给模型一个当前状态,但检索适合找回具体证据。
Amp:不递归压缩,直接换线程
Amp 的立场比较鲜明:递归摘要可能让上下文质量越来越差,所以不如在合适的时候用 /handoff 开一条新线程,把当前要点带过去。
这个方案牺牲的是长对话连续性,换来的是更干净的任务边界。
MemGPT / Letta:把上下文当 RAM,把历史当磁盘
MemGPT / Letta 这一派更像是在模拟操作系统的内存层级。
模型当前窗口是 RAM,能直接访问但容量有限;完整历史和长期记忆像磁盘,容量大但需要检索;Agent 自己决定什么时候把信息换入、什么时候换出。
这个方向适合跨会话长期记忆,但它引入了更复杂的存储、检索和一致性问题。
四、真正的共识:分层渐进,而不是一刀切
横向看完这些方案,会发现大家的实现细节不一样,但共识很接近。
第一,压缩要分层。
能不压就不压,能用便宜的字符串处理就别急着调用 LLM。越接近上限,动作越激进。
第二,成本要递增。
截短工具输出、替换占位符、裁剪旧 assistant 文本,这些都是低成本动作。LLM 摘要应该放在最后。
第三,摘要要增量。
每次把全部历史重新摘要一遍,成本高,也容易让语义漂移。更好的方式是维护一份活的摘要,每次只把新增 delta 合并进去。
第四,token 要看真实 usage。
估算可以用于排序,但真正决定是否触发压缩,最好使用模型调用返回的真实 token 用量。
第五,用户消息和保护区不能随便动。
用户原话、最近几轮对话、带有状态意义的工具输出、任务管理类结果,应该有更高优先级。
第六,stub 替换不能每步滑动。
有一种很容易踩的坑:每个 step 都重新判断“哪些旧工具输出要替换成 stub”。
听起来很正常,但如果每一步都让某个 part 的文本位置和内容发生变化,就会破坏 Prompt Cache 的稳定前缀。最后看起来是在省上下文,实际上每轮都在重新写入缓存,成本可能更高。
更好的做法是:一旦决定某个 part 被替换,就让这个决定在后续 step 里稳定下来。
五、四级水位线:把压缩动作做成成本递增的几层
如果把上面的共识落成一个容易理解的工程方案,可以用“四级水位线”来描述。

Tier 0:什么都不做
上下文还很宽裕时,最好的优化就是不优化。
因为任何压缩都会引入信息损失风险。窗口还够用时,让模型看到完整上下文,通常是最稳的选择。
Tier 1:Snip,便宜的整理
到了 60% 左右,可以开始做预防性维护。
这一层不调用 LLM,只做字符串级处理:
- 老的工具输出太长,就截短;
- 代码块太长,就保留文件名、前几行和总行数;
grep结果太多,就保留前几个命中和“还有多少条结果”;- 无状态读取类工具可以压,关键任务状态类工具不要压。
这一层的目标不是大幅瘦身,而是先挡住一部分无意义膨胀。
Tier 2:Prune,更狠的释放
到了 80% 左右,单纯截短可能不够了。
这时可以把一部分更老的工具输出替换成占位符。例如:
[Content compacted to save space]
模型看到的是“这里曾经有内容,但已经压缩”,而不是继续吞下完整日志。
这一层依然不一定需要 LLM,重点是更大幅度地释放空间。
Tier 3:Summarize,最后防线
到了 95% 附近,前两层已经救不回来,就必须动用 LLM 摘要。
但这里最好不要每次全量摘要,而是做增量摘要:
- 找出上次摘要之后、保护区之前的新增消息;
- 输入“上次摘要 + 新增 delta”;
- 生成新的合并摘要;
- 用新摘要替换旧摘要和 delta;
- 保护区保持不动。
这样做有两个好处。
第一,摘要输入更短,成本更低。
第二,同一段历史不会被反复重写,语义漂移会小很多。
红线:任何 Tier 都不能乱动的内容
压缩系统最重要的不是“能压什么”,而是“不能压什么”。
常见红线包括:
- 保护区内的最近消息;
- 用户消息的纯文本部分;
Skill、Task这类带有结构化状态的工具输出;AskUserQuestion这类代表用户回答的工具输出;- 明确带有
compactionProtected标记的业务内容。
如果这些内容都被压掉了,那上下文虽然变短了,Agent 的行为却会开始漂。
六、云端多用户 Agent:水位线之外还要补四层设计
如果只是本地 CLI 工具,压缩做得粗一点,最坏也就是开一条新会话。
但云端多用户 Agent 不一样。
用户可能今天跑到一半关掉浏览器,明天回来还希望接着上次继续;服务可能重启,流量可能漂移到另一个 Pod;工具完整日志可能用于审计和调试,不能为了省 context 就直接丢掉。
所以在四级水位线之上,还需要额外几层工程设计。

6.1 存储分离:完整日志落盘,对话里只放截断版
工具调用可能吐出几万 token。
如果全部塞进对话历史,模型会被日志淹没;如果直接删除,又会丢掉审计和调试能力。
更合理的方式是:完整输出落盘,对话上下文里只保留截断版和一条回取路径。
模型看到的是:
- 前几行关键内容;
- “这里已截断”的提示;
- 完整日志路径或 metadata。
这样模型不用每轮都读完整输出,但系统需要时仍然能找回原始数据。
6.2 工具差异化:不是所有工具都该同等压缩
工具输出不是一种东西。
bash 可能吐出大量日志,grep 可能返回几十条命中,read 可能读取一整个文件,Task 可能携带一个子任务状态,Skill 可能关联知识库和执行规则。
如果用同一套阈值处理所有工具,就会很粗糙。
更合适的做法是按工具分类:
- 完全保护:如
Skill、Task,因为它们有结构化状态意义; - 微压缩豁免:如
AskUserQuestion,因为它代表用户回答; - 白名单可压:如
bash、read、grep、websearch,按工具设置不同预算; - 差异化上限:例如
Read可以比WebSearch保留更多内容,因为文件上下文通常更连续。
本质上,这是在回答一个问题:这段工具输出对下一步决策到底有多重要?
6.3 跨轮缓存:让压缩决策在重启后仍然稳定
云端有一个本地 CLI 不明显的问题:同一段历史在不同轮次里,不能长得不一样。
假设第 8 轮和第 14 轮都把某段工具输出 snip 过。到了第 21 轮,服务实例重启了,如果新进程重新计算压缩决策,可能会把同一个 part 压成另一种形态。
这会带来两个问题:
- Prompt Cache 前缀不稳定,缓存命中下降;
- 模型看到“同一段历史变了个样”,容易产生困惑。
解决思路是维护一个 ReplacementCache。
一旦某个 part 决定如何替换,就按 sessionId + partId 记下来。下一轮无论跑在哪个进程、哪个 Pod,都先查缓存。已经决定过的 part 直接复用之前的压缩结果。
这样做的价值不是省几行代码,而是让上下文前缀稳定。
6.4 多用户隔离和可观测:压缩不能成为黑盒
多租户场景里,压缩状态必须严格隔离。
数据库写入要带 userId 和 sessionId,对象存储路径也要按用户和会话分区,SSE 压缩事件要按 session 严格过滤。一个用户绝不能收到另一个用户的压缩通知,更不能读到别人的日志。
同时,压缩不能是黑盒。
前端和 trace 系统至少应该知道:
- 当前触发了哪个 Tier;
- 真实 token 使用率是多少;
- Snip 截了几个 part;
- Prune 替换了几个 part;
- Summarize 是否真的调用了;
- 预计节省了多少 token;
- ReplacementCache 命中了多少 part。
有了这些数据,后面才能根据真实生产表现调水位线,而不是凭感觉调参数。
七、几个容易踩的坑
1. 为了压缩,把 Prompt Cache 打碎了
压缩本来是为了省成本,但如果每一轮都让消息前缀发生变化,Prompt Cache 命中率会掉。
尤其是“滑窗式 stub 替换”,看起来每次只替换一点点,实际可能让后续几十步都重新写缓存。
所以压缩决策要尽量单调:一个 part 一旦被替换,后面就复用这个替换结果,而不是反复重新判断。
2. 把摘要当成长期记忆
摘要不是数据库。
摘要适合保留“当前任务进行到哪里”,不适合承载所有历史细节。真正需要长期记忆或完整审计时,应该靠落盘、检索、向量存储或日志系统。
3. 所有工具输出都按同一规则截断
这会让系统变得很脆。
grep 的几十条命中和 Task 的任务状态不是一类信息。前者可以压,后者乱压会让 Agent 忘记任务状态。
工具压缩策略最好按工具类型配置,而不是全局一个阈值打天下。
4. 只关注压缩率,不关注恢复路径
如果压缩后永远找不回原文,系统就会越来越依赖摘要。
比较稳的做法是:对话里保留截断版,完整日志落盘,并在 metadata 里留下路径。模型不用每次读,但系统和前端仍然可以按需回取。
5. 压缩没有可观测性
压缩发生在模型调用之前,用户通常看不见。
如果没有事件、日志和 trace,出了问题就很难判断:到底是模型能力不行,还是我们把关键上下文压没了?
八、回到项目链路:这个模块到底负责哪一步
如果把“上下文压缩”当成 Agent 系统里的一个模块来看,它的位置很清楚。
它不负责生成答案,也不负责调用工具。
它负责在每次模型调用之前,把即将送给模型的消息整理成一份更短、更稳定、更能保留关键语义的上下文。
这个模块解决什么问题
解决长任务里上下文膨胀、模型注意力下降、工具日志淹没用户意图、Prompt Cache 不稳定的问题。
输入是什么
输入通常包括:
- 当前会话消息列表;
- 每条消息或 part 的类型;
- 工具输出内容和工具名;
- 上一轮摘要;
- 真实 token 使用量;
- 保护标记;
- 跨轮 ReplacementCache;
- 用户和会话标识。
输出是什么
输出是一份新的上下文消息序列。
它会比原始历史短,但尽量保留:
- 用户近期意图;
- 当前任务状态;
- 关键工具观察;
- 可恢复路径;
- 结构化摘要;
- 稳定的消息前缀。
在项目链路里负责哪一步
它位于“模型调用前”的上下文维护层。
可以理解成:
原始会话历史
-> 上下文压缩 / 保护 / 摘要 / 缓存复用
-> 构造最终 prompt
-> 调用 LLM
-> 解析响应并继续执行工具
所以它不是一个小优化,而是 Agent 能不能长时间稳定运行的基础设施。
九、最后总结
Agent 上下文压缩,表面看是在处理 token,实际上是在处理注意力。
窗口再大,也不能把所有东西都无差别塞进去。真正可靠的方案,通常都遵循同一个方向:
- 先保护用户意图;
- 再保护最近几轮连续性;
- 再按工具价值分级;
- 先做便宜整理;
- 再做占位替换;
- 最后才做 LLM 增量摘要;
- 云端场景还要补上落盘、缓存、隔离和可观测。
我觉得这类系统最值得学习的地方,不是某个具体阈值,而是它背后的工程意识:
模型不是没有记忆,而是注意力有限。上下文工程要做的,就是帮模型把注意力放在这一轮最该看的地方。
2026 年做 Agent 应用,这个问题绕不开。
参考资料
- 腾讯技术工程:《横向拆解 Claude Code、Codex 等六大 Agent 上下文压缩策略后,我们做了第 7 个》
- Anthropic:Effective context engineering for AI agents
- Anthropic:Compaction for Claude API
- Justin3go:Shedding Heavy Memories: Context Compaction in Codex, Claude Code, and OpenCode
- badlogic:Context Compaction Research: Claude Code, Codex CLI, OpenCode, Amp
- Letta / MemGPT 相关 Agent Memory 资料
- Cline、Cursor、Amp 官方文档
更多推荐


所有评论(0)