你好,我是程序员无隅

这是我的个人主页:无隅的主页

技术永无止境,希望我的内容能帮助到你

热门专栏:Java 面试八股文 | LeetCode 算法笔记 | Claude Code 源码解读


本文是基于《横向拆解 Claude Code、Codex 等六大 Agent 上下文压缩策略后,我们做了第 7 个》这篇材料做的学习型整理。重点不是复刻原文,而是把它背后的工程取舍讲清楚,方便我们理解 Agent 上下文工程。


在这里插入图片描述



一、先讲本质:上下文压缩不是为了省 token,而是为了保护注意力

很多人第一次听到上下文压缩,会下意识以为它就是:

  • 对话太长了,删掉一点历史;
  • 工具输出太多了,截断一点文本;
  • 快超过窗口了,让模型总结一下。

这些都对,但只说到了表层。

Agent 真正麻烦的地方在于:它不是一次问答,而是一条会持续运行的任务链。用户的需求、模型的思考、工具调用结果、错误日志、文件内容、临时计划,都会不断塞进上下文窗口。

窗口一旦变胖,问题不只是“放不下”。更危险的是模型的注意力会被稀释。

它可能忘记用户刚刚强调过的约束,可能被很早之前的工具输出干扰,也可能在一堆日志里找不到真正影响下一步决策的信息。

一句话理解:上下文压缩的目标不是把历史变短,而是让模型在下一轮调用时还能看见真正重要的东西。

这也是为什么优秀的上下文压缩系统,一定不能粗暴地“一刀切”。

用户原话、最近几轮对话、关键工具结果、任务状态、错误栈、待办事项,这些信息的价值是不一样的。压缩系统真正要做的是:给信息分层,然后按成本递增的方式逐步处理。


二、为什么“撑不住才压缩”的第一代方案体验很差

最直观的做法,是等上下文快满了再动手:

  1. 每轮结束后估算一下 token。
  2. 如果没超过阈值,就什么都不做。
  3. 如果快满了,就把前面的历史一次性摘要掉。

这个思路听起来很合理,因为实现简单,触发条件也清楚。

但用在 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 摘要。

但这里最好不要每次全量摘要,而是做增量摘要:

  1. 找出上次摘要之后、保护区之前的新增消息;
  2. 输入“上次摘要 + 新增 delta”;
  3. 生成新的合并摘要;
  4. 用新摘要替换旧摘要和 delta;
  5. 保护区保持不动。

这样做有两个好处。

第一,摘要输入更短,成本更低。

第二,同一段历史不会被反复重写,语义漂移会小很多。


红线:任何 Tier 都不能乱动的内容

压缩系统最重要的不是“能压什么”,而是“不能压什么”。

常见红线包括:

  • 保护区内的最近消息;
  • 用户消息的纯文本部分;
  • SkillTask 这类带有结构化状态的工具输出;
  • AskUserQuestion 这类代表用户回答的工具输出;
  • 明确带有 compactionProtected 标记的业务内容。

如果这些内容都被压掉了,那上下文虽然变短了,Agent 的行为却会开始漂。


六、云端多用户 Agent:水位线之外还要补四层设计

如果只是本地 CLI 工具,压缩做得粗一点,最坏也就是开一条新会话。

但云端多用户 Agent 不一样。

用户可能今天跑到一半关掉浏览器,明天回来还希望接着上次继续;服务可能重启,流量可能漂移到另一个 Pod;工具完整日志可能用于审计和调试,不能为了省 context 就直接丢掉。

所以在四级水位线之上,还需要额外几层工程设计。


在这里插入图片描述


6.1 存储分离:完整日志落盘,对话里只放截断版

工具调用可能吐出几万 token。

如果全部塞进对话历史,模型会被日志淹没;如果直接删除,又会丢掉审计和调试能力。

更合理的方式是:完整输出落盘,对话上下文里只保留截断版和一条回取路径。

模型看到的是:

  • 前几行关键内容;
  • “这里已截断”的提示;
  • 完整日志路径或 metadata。

这样模型不用每轮都读完整输出,但系统需要时仍然能找回原始数据。


6.2 工具差异化:不是所有工具都该同等压缩

工具输出不是一种东西。

bash 可能吐出大量日志,grep 可能返回几十条命中,read 可能读取一整个文件,Task 可能携带一个子任务状态,Skill 可能关联知识库和执行规则。

如果用同一套阈值处理所有工具,就会很粗糙。

更合适的做法是按工具分类:

  • 完全保护:如 SkillTask,因为它们有结构化状态意义;
  • 微压缩豁免:如 AskUserQuestion,因为它代表用户回答;
  • 白名单可压:如 bashreadgrepwebsearch,按工具设置不同预算;
  • 差异化上限:例如 Read 可以比 WebSearch 保留更多内容,因为文件上下文通常更连续。

本质上,这是在回答一个问题:这段工具输出对下一步决策到底有多重要?


6.3 跨轮缓存:让压缩决策在重启后仍然稳定

云端有一个本地 CLI 不明显的问题:同一段历史在不同轮次里,不能长得不一样。

假设第 8 轮和第 14 轮都把某段工具输出 snip 过。到了第 21 轮,服务实例重启了,如果新进程重新计算压缩决策,可能会把同一个 part 压成另一种形态。

这会带来两个问题:

  • Prompt Cache 前缀不稳定,缓存命中下降;
  • 模型看到“同一段历史变了个样”,容易产生困惑。

解决思路是维护一个 ReplacementCache。

一旦某个 part 决定如何替换,就按 sessionId + partId 记下来。下一轮无论跑在哪个进程、哪个 Pod,都先查缓存。已经决定过的 part 直接复用之前的压缩结果。

这样做的价值不是省几行代码,而是让上下文前缀稳定。


6.4 多用户隔离和可观测:压缩不能成为黑盒

多租户场景里,压缩状态必须严格隔离。

数据库写入要带 userIdsessionId,对象存储路径也要按用户和会话分区,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 官方文档
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐