上下文管理
请使用电脑访问
https://myblog-ad4.pages.dev上下文压缩与 Token 管理
Agent 干了半小时活,怎么越来越慢了
你用 MewCode 跑了一个稍微复杂的任务,比如重构一个模块。Agent 先读了十几个文件了解代码结构,然后改了五六个文件,中间跑了好几次测试和编译。一切顺利。
但你慢慢会发现一个问题:越到后面,Agent 的响应越慢,而且 Token 用量蹭蹭往上涨。第一轮对话只用了 1000 多 input tokens,到第二十轮可能已经飙到十几万了。
因为 Claude API 是无状态的。每一轮请求,你都得把完整的对话历史发过去。Agent 读的每一个文件、执行的每一条命令、产生的每一段输出,全部堆在消息列表里。Token 数量随着对话轮次线性增长。
而 Claude 的上下文窗口有上限。200K tokens 听起来很多,但在 Agent 场景下真的不够用。一个 1000 行的源文件大约 15,000 tokens,读 10 个文件就是 15 万。再加上命令输出、搜索结果、AI 的回复,轻轻松松逼近 200K。一旦撞到上限,API 直接报错,Agent 就瘫了。
Token 到底花在哪里了
在想解决方案之前,先搞清楚钱花在哪了。
你可能以为 Token 主要花在用户的问题和 AI 的回复上。但实际分布跟你的直觉差很远:
| 消息类型 | 典型 Token 量 | 占比 |
|---|---|---|
| 固定开销(角色设定、环境信息、工具描述) | 5,000 - 15,000 | ~10% |
| 用户输入 | 50 - 500 | ~1% |
| AI 文字回复 | 200 - 2,000 | ~3% |
| 工具调用请求 | 50 - 200 | ~1% |
| 工具结果(文件内容) | 5,000 - 20,000 | ~55% |
| 工具结果(命令输出) | 500 - 5,000 | ~20% |
| 工具结果(搜索结果) | 500 - 3,000 | ~10% |
工具结果是 Token 消耗的绝对大头,占了 85% 左右。而且很多工具结果的「保质期」很短。Agent 在第 3 轮读了 handler.go,第 5 轮改了它,到第 10 轮,第 3 轮读的那个旧版本还占着 Token 预算,但内容已经过时了。
既然工具结果占比最高又最容易过时,压缩自然要从它们下手,用户的对话反而是需要着重保留的核心。
两层压缩:便宜的预防 + 贵但强的兜底
怎么解决?实际落地的策略可以归纳成两层:
| 层级 | 手段 | 信息损失 | API 开销 | 触发条件 |
|---|---|---|---|---|
| 第 1 层 | 大结果存磁盘 | 几乎为零 | 零 | 工具结果超阈值 |
| 第 2 层 | 全量摘要(Auto-Compact) | 高 | 高(一次 API 调用) | token 数逼近窗口上限 |
第一层几乎不花钱也不丢信息,挡住的是单次工具输出过大的情况。第二层代价高但压缩效果强,是上下文快爆的时候的终极兜底。两层之间的设计哲学就四个字:能轻则轻。轻量手段能扛住的,绝不动用昂贵的全量摘要。
第 1 层:大结果存磁盘
这是最轻量的一层,几乎零信息损失。
单个工具结果超限
当一个工具的执行结果超过 50,000 个字符(大约 12,500 tokens),系统不会把完整内容塞进对话历史。而是做两件事:把完整结果写到磁盘上的一个文件里,然后在对话历史里只放一个预览加文件路径。
<persisted-output> |
为什么这一层几乎零损失?因为完整结果还在磁盘上。如果模型后面需要看完整内容,用 ReadFile 读那个文件就行。而且这一层没有任何额外 API 开销,就是一次本地文件写入。
每条消息的聚合限制
光管单个结果还不够。想象一下,一轮循环里 Agent 并行调了 10 个工具,每个结果都是 40K 字符,单个没超 50K 阈值,但合起来就是 400K。
所以除了单个工具的阈值,还有一个每条消息的聚合限制:200K 字符。超了就把最大的那些存盘,直到总量降到预算以内。
幂等写入
这一层还有一个细节:存磁盘的文件名用的是 tool_use_id,而且用 wx 模式写入(文件已存在就跳过)。为什么?因为每次调 API 之前都会重新执行这一层的检查(大结果存盘的检查)。如果每一轮都重新写一次同样的文件就太浪费了。tool_use_id 是唯一的,内容也是确定的,写过一次就够了。
Prompt Cache 与决策冻结
第 1 层每轮都要重新检查所有工具结果是否超限。但这里藏着一个不明显的坑。
假设第 5 轮有一个 40K 字符的工具结果,没超 50K 阈值,保留在对话历史里。到了第 15 轮,这条消息所在的聚合总量超过了 200K 限制,按规则应该把这个 40K 的结果存盘替换成预览。但这一替换,对话历史的前缀就变了。
前缀一变,Anthropic 的 Prompt Cache 就失效了。Prompt Cache 的工作原理是:如果这一轮请求的消息列表前缀和上一轮完全一样(逐字节一致),API 可以跳过前缀部分的处理,只处理新增的消息。一个长会话如果 cache 持续命中,每轮只需要处理最新一两条消息的增量;如果 cache 反复失效,每轮都要重新处理几万甚至十几万 token 的前缀,延迟和成本都上去。
MewCode 采用的解决方案叫「决策冻结」,这也是 Claude Code 使用的策略。系统维护一个 ContentReplacementState,记录每个 tool_use_id 的替换决策。规则很简单:
一旦某个工具结果在某一轮被决定「不替换」,这个决定在整个会话内永远不变。 即使后来按聚合限制它应该被替换,也不替换,因为替换会破坏前缀。反过来,如果决定了「替换」,那每一轮都用完全一样的预览字符串去替换,确保前缀逐字节不变。
每一轮面对工具结果时,系统把它们分成三类:
已替换过的 -> 用缓存的预览字符串原样重放,不重新生成 |
用一个三轮的场景走一遍
光看规则不够直观,跑一遍三轮的场景就清楚了。
设定:单条消息的预算上限 20K 字符。Agent 连着跑三轮 bash,每轮产生一条 tool result:X 是 8K、Y 是 7K、Z 是 9K。每一轮都要把到目前为止的整段对话重新发给 Anthropic(API 是无状态的),所以「X 这一段该不该被替换」这种问题,每一轮发请求前都得重新问一次。
state 就两本账:
seenIds = 之前问过的 id 集合(不管答案是替换还是不替换) |
第一轮发请求前,消息里只有 X(8K)。
查 X:两本账本都没它,说明是个全新的候选,本轮做决定。算账:8K <= 20K,没超预算,X 保留原文。记账:seenIds 加入 X。模型本轮看到 X 原文 8K。
注意 X 这时候只进了 seenIds,没进 replacements。这一点第三轮会用上。
第二轮发请求前,消息里有 X(8K)、Y(7K)。
查 X:seenIds 命中,replacements 没有,意思是「以前问过,答案是不替换」。结论已经定了,跳过,不再评估。查 Y:两本账本都没有,新候选,本轮做决定。算账:8K + 7K = 15K,仍然没超。Y 保留原文。记账:seenIds 加入 Y。模型本轮看到 X 原文 + Y 原文。
关键是 X 这一段跟第一轮一模一样,前缀稳得住,prompt cache 命中。
第三轮发请求前(重点),消息里有 X(8K)、Y(7K)、Z(9K)。
X 和 Y 都被前面冻结过,跳过判断。Z 是新候选。算账:8K + 7K + 9K = 24K,超了 4K,必须从这一轮里挑一段出来替换。
挑谁?X 不能动:它在第一轮、第二轮模型看到的都是原文,要是第三轮突然变成「[X 已存盘…]」,前缀就漂了,整条 cache 失效。Y 同理。剩下能挑的只有 Z,它是这一轮才第一次出现,还没被冻结过,可以做替换决定。
于是 Z 被替换:内容写盘,生成 preview 字符串「[Z 的 9K 内容已存到 /tmp/abc/Z.txt,用 Read 读取]」。记账两条:seenIds 加入 Z;replacements 也写一条 Z -> 这串 preview。模型本轮看到 X 原文 + Y 原文 + [Z 已存盘...]。
往后第四轮、第五轮不管再产生什么新工具结果,X、Y、Z 这三段都不会再变了。X 永远原文,Y 永远原文,Z 永远是同一串 preview。前缀逐字节稳定,cache 一路命中下去。
这个优化揭示了一个容易被忽视的设计约束:上下文管理不仅要考虑「保留什么信息」,还要考虑「改动对话前缀的代价」。很多自己做 Agent 的人会天真地每轮重新评估所有工具结果,结果 prompt cache 命中率为零,每轮请求都在为重复处理前缀买单。
第 2 层:全量摘要(Auto-Compact)
当第一层不够用了,上下文仍然接近爆炸,就要动用最后的手段:调用 LLM 自己来生成一份对话摘要,替换掉旧消息。
这是代价最高的一层:需要额外调一次 API,花钱,而且摘要不可能保留所有细节,信息丢失不可避免。但它也是压缩效果最强的一层,能把十几万 Token 的对话压缩到几千 Token。
什么时候触发
以 200K Token 的上下文窗口为例,阈值是这么算的:
上下文窗口 200,000 |
为什么要留 13,000 的安全余量?因为自动压缩的检查点是每轮循环开始时,两次检查之间 Agent 可能读了一个大文件或执行了一条长输出的命令。这里要配合第一层一起看:单个工具结果超过 50K 字符的部分已经被存盘了,能渗进上下文的单轮增量基本都在几万 token 以内,13,000 的 buffer 够应对这种波动,防止「上一轮检查时没超,这一轮一个工具结果直接撞墙」的情况。
如果没有 13K 安全余量,阈值可能设成:200K - 20K = 180K
但是可能会出现下面情况。
第 N 轮开始
当前上下文:179K
检查 Auto-Compact:179K < 180K,不压缩
第 N 轮中
Agent 决定读文件
读文件结果进入上下文:+15K
当前上下文变成:194K
第 N 轮结束
第 N+1 轮开始
再次检查 Auto-Compact:194K > 180K,触发压缩
这时候虽然还没超过 200K,但已经只剩 6K,**不够安全地产生一个可能需要 20K 空间的摘要输出**,就可能出问题。所以安全余量的作用就是:不是防止“这次检查压缩”本身出问题,而是防止“两次检查之间突然新增一大坨内容后,下一次想压缩时已经太晚了”。
为什么是固定值而不是百分比
你可能会问:为什么不简单地设成「上下文用到 X% 就触发」?
因为 buffer 保护的是「单轮波动」,不是「总量」。每一轮 Agent 循环新增的 token 量是相对固定的:读一个文件可能 十几千 token,跑一条命令几百到几千 token。不管你的窗口是 200K 还是 1M,一次 ReadFile 都是那么大。buffer 要防的风险是「下一轮突然多了一个大工具结果」,这个风险的大小取决于单次工具结果有多大,跟总窗口无关。
如果用百分比会怎样?以「90% 触发」为例:
| 窗口大小 | 触发点 | 剩余空间 | 问题 |
|---|---|---|---|
| 200K | 180K | 20K | 刚好够一次大文件 |
| 1M | 900K | 100K | 浪费了,100K 的 buffer 能装 7 个大文件 |
1M 窗口下留 100K buffer,意味着白白浪费了 100K token 的空间。你付了 1M 的钱,却只用了 900K。
反过来如果把百分比设高一点,比如 98%:
| 窗口大小 | 触发点 | 剩余空间 | 问题 |
|---|---|---|---|
| 200K | 196K | 4K | 太危险了,一次普通文件读取就撞墙 |
| 1M | 980K | 20K | 刚好 |
不存在一个百分比能同时适配所有窗口大小。 而固定值没有这个问题:33K 的 buffer(20K + 13K)在任何窗口大小下都恰好够「挡住一到两次大工具调用」。
用同一套公式算 1M 窗口的阈值:
上下文窗口 1,000,000 |
1M 窗口下 Agent 可以用到 967K 才触发压缩,充分利用了大窗口的容量。而 buffer 仍然是 33K,刚好够应对单轮波动。
为什么预留 20K 给摘要输出
这个 20,000 是被两个约束从两头夹出来的。
下限来自摘要本身的需求。摘要有 9 个结构化部分(后面会详细讲),加上 <analysis> 草稿块,一次复杂会话的摘要输出大约 15,000 到 18,000 tokens。设成 15K 就有被截断的风险,20K 刚好够用。
上限来自有效窗口的压力。预留越大,有效窗口越小,压缩就触发得越频繁。如果预留 50K,有效窗口缩到 150K,自动压缩在 137K 就触发了。但摘要根本用不了 50K,模型不会因为你给了更多输出空间就写更长的摘要,摘要长度取决于对话的复杂度。多出来的 30K 预留纯粹是浪费。
为什么安全余量是 13K 而不是更大
同样的逻辑。13K 约等于一个大源文件的 token 量,恰好够防止两次检查之间一个意外偏大的工具结果把上下文撑爆。
手动 /compact 的安全余量只有 3K,比自动的 13K 小得多。原因也很简单:用户主动触发意味着他知道自己在做什么,Agent 不需要那么大的反应空间来「预防意外」。
这三个数(20K、13K、3K)都有一个共同特点:它们由单轮操作的特征决定,不由窗口总量决定。摘要输出的大小跟窗口无关,单次工具结果的大小跟窗口无关,用户手动触发时的风险跟窗口无关。所以它们是固定值。
摘要 Prompt 的设计
摘要质量直接决定了压缩后 Agent 的表现。一个好的摘要 Prompt 要求 LLM 生成一份结构化摘要,明确指定 9 个部分:
1. 主要请求和意图 用户到底想做什么 |
注意第 6 条:用户说过的话尽量原文保留。 为什么不能摘要改写?因为用户的原始表述包含意图、偏好、语气。如果用户说「不要用 interface{}」,用 any 概括成「用户偏好现代语法」,模型下次可能给出 type any = interface{} 这种不是用户想要的东西。
原文才能准确传达意图。当然,如果对话很长、用户消息很多,LLM 不可能在有限的摘要空间里塞进所有原文。这更多是一个优先级指引:告诉 LLM 用户消息优先保留原文而不是随意改写,空间不够时自然会取舍。
两阶段生成:先打草稿再写正文
还有一个很聪明的设计:Prompt 要求 LLM 先产出一个 <analysis> 草稿块来梳理思路,然后产出正式的 <summary> 块。最终只保留 summary,analysis 被丢弃。
为什么要「先打草稿再写正文」?因为实验发现这能显著提升摘要质量。分析阶段让 LLM 先把对话中发生了什么梳理一遍,然后在此基础上写摘要会更全面、更准确。但 analysis 本身作为中间产物没有保留价值,占了空间反而浪费,所以生成完就扔掉。
禁止工具调用
Prompt 还需要一段强硬的开头,明确禁止模型调用任何工具、只输出纯文本。为什么需要这条禁令?因为摘要 API 调用复用了原对话的消息上下文,工具定义会随消息一起传入这次调用,模型仍然「看得到」可用工具。如果不明确禁止,LLM 可能「好心」地试图调一个工具来帮你,结果浪费了这次调用。
还有一种更彻底的做法:调摘要 API 时直接不传 tools 参数,模型完全看不到工具定义,自然不会调。MewCode 走的就是这条路。Claude Code 则保留 tools(少一次重新组织 messages 的开销),靠 prompt 把模型「拦」在文本输出。两种思路都对,看实现取舍。
实践中,这段禁令往往出现两次:开头一次,结尾再强调一次。开头说「你只能输出纯文本」,结尾说「提醒:不要调任何工具,工具调用会被拒绝,你会失败」。两头堵,防止模型在长上下文中「忘记」开头的指令。
压缩后恢复
摘要替换了旧消息,但不能什么都不管。有些关键上下文需要重新附加:
- 最近访问的文件:最多恢复 5 个,每个最多 5,000 Token。Agent 压缩后仍然「记得」最近读过的文件
- 技能定义:如果之前使用过 Skill,重新注入定义,总预算 25,000 Token
- 工具列表:重新声明所有可用工具,确保摘要后的首次请求仍携带完整工具定义
- 压缩边界消息:一条特殊消息,告诉模型「这里之前是摘要,如果需要文件的具体内容请重新用 ReadFile 读取,不要靠摘要猜测」
压缩后的消息列表看起来像这样:
[system] 你是一个编程助手... |
这里的边界消息非常重要。它防止模型「编造」摘要里没有提到的细节。没有这条提示,模型可能根据摘要里「修复了 handler.go」这种模糊描述,脑补出一段不存在的代码。
熔断机制
如果全量摘要因为网络问题、API 错误或 prompt-too-long 等原因连续失败 3 次,系统停止自动触发。
为什么?想象一下:上下文已经超限了,每一轮都触发摘要,每一轮都失败,然后每一轮又重试…Agent 被卡在死循环里。熔断打破这个循环:3 次失败后认输,让用户手动处理。
如果摘要请求报 Prompt Too Long: |
紧急压缩:撞墙后的自救
前面讲的熔断和 PTL 重试都是「压缩请求自身出问题」的情况。但还有一条路径需要处理:正常的对话请求发出去后,API 返回 prompt_too_long 错误。
这说明自动压缩的阈值没挡住。可能是 token 估算偏差太大,也可能是某一轮工具结果异常巨大,两次检查之间直接跳过了阈值。不管什么原因,结果就是上下文已经超了 API 的硬限制,请求被拒绝了。
如果没有任何处理,Agent 就直接报错停工了。用户只能手动 /compact 然后重试,体验很差。
MewCode 的做法是在 Agent Loop 里捕获这个错误,立刻触发一次 ForceCompact,压缩完成后用新的消息列表重试原来的请求。相当于「撞墙了,赶紧压缩,然后再试一次」。如果压缩后仍然超限,就按正常错误流程处理,不再无限重试。
这是 Agent 的最后一道自救机制。它和自动压缩的关系是:自动压缩是预防,紧急压缩是治疗。大部分情况下自动压缩能在撞墙前拦住,但如果因为估算误差或极端情况漏过去了,紧急压缩兜底,让 Agent 不至于因为一次估算偏差就彻底瘫痪。
ForceCompact是一个强制进入压缩流程的控制动作。不要再用完整 messages 直接让模型压缩,而是先构造一个更小、更安全的压缩请求。它真正能不能救回来,取决于压缩流程是不是做了好的特殊处理。
手动 /compact
除了自动压缩,用户随时可以输入 /compact 手动触发。两个典型场景:
预防性压缩:你知道接下来要读一大堆文件,提前压缩腾出空间。
话题切换:前面的对话在调 bug A,现在要处理 bug B。手动压缩清理掉 bug A 的细节,减少噪声。
两层的协作关系
最后整理一下两层之间的关系。
每次调 API 之前,applyToolResultBudget 先执行,检查新产生的工具结果有没有超限。它解决的是「单次工具输出太大」的问题,是一个预防性措施。
紧接着 autoCompact 执行,检查整体上下文有没有逼近窗口上限。它解决的是「对话累积过长」的问题,是一个治疗性措施。
两层解决的是不同维度的问题。第 1 层管的是单条消息的大小,防止一次超大工具输出直接把上下文撑爆。第 2 层管的是对话历史的累积长度,防止几十轮对话下来 Token 总量超限。
一个常见的运行场景:Agent 连续读了 20 个小文件,每个都没超 50K 阈值,第 1 层什么都没做。但累积下来总 Token 数逼近 167K,第 2 层触发全量摘要,把 20 轮对话压缩成一份几千 Token 的结构化摘要。
另一个场景:Agent 执行了一条命令,输出了 80K 字符的日志。第 1 层把它存盘,只留 2K 预览。上下文总量远没到阈值,第 2 层不触发。
本章小结
上下文管理是 Agent 从「短任务玩具」变成「长时间工作伙伴」的关键。没有它,Agent 跑几轮就撞墙。
两层压缩体现了一个简洁但有效的设计哲学:能轻则轻。大结果存磁盘几乎零损失零成本,只有当对话累积到临界点时才动用全量摘要这个昂贵但强力的手段。
有意思的是,早期的 Coding Agent 产品在上下文管理上做了很多层,比如裁剪旧消息、清理过期工具输出、读时投影压缩,层层叠叠。但随着模型上下文窗口越来越大、智能度越来越高,趋势反而是层数越来越少。很多精细的中间层被证明收益不大,最终留下来的就是这两层最简洁的策略。对于我们自己做 Agent 来说,先用存盘挡住大结果,再用全量摘要兜底长对话,就足够了。