上下文满了,Claude Code 扔什么、留什么?

0 阅读17分钟

1 不能扔,那就压

上一篇 《对话才几轮,上下文窗口怎么就满了?》的结尾撂下一个死结:上下文窗口眼看要撞顶,想扔几样腾地方——系统提示词不能扔,模型转眼就忘了自己是谁、活该怎么干;对话不能扔,你三分钟前说的话,它得一直看得见。怎么办?

Claude Code 的答案是两个字:压缩。把旧的、占地方的,换成短的。听着简单,可实际并不好做——压缩早了,丢掉了还用得着的细节;要是晚了,请求发出去就被服务器拒绝了;压缩错了内容,丢的就是模型的干活记忆了。

真去翻代码,你会发现"压缩"在 Claude Code 里根本不是一个动作,是一条流水线:主循环每转一圈、调模型之前,开头都排着五道关卡,从轻到重,一级一级往外腾地方。每一关扔什么、留什么,各有各的规则。这一篇就顺着一个一个来看。

2 五道关,从轻到重

先看全景。打开 src/query.ts,主循环每圈开头是一段固定的工序——消息要先过五道关,才轮到发给模型:

// 调模型之前,消息先过五道关(依 src/query.ts 改写,省略参数)
messages = await applyToolResultBudget(messages)   // 第 0 关 回执预算
messages = snipCompactIfNeeded(messages).messages  // 第 1 级 snip
messages = await microcompact(messages)            // 第 2 级 微压缩
messages = await applyCollapsesIfNeeded(messages)  // 第 3 级 折叠
await autocompact(messages, snipTokensFreed)       // 第 4 级 自动压缩

01-five-gates.png

五道关的次序有讲究,原则一条:代价小的在前,代价大的在后。代价怎么算?看每一关动手后,模型丢掉多少还看得见的内容——丢得越少,代价越小。最直接的证据是折叠函数开头的一段注释:

Runs BEFORE autocompact so that if collapse gets us under the autocompact threshold, autocompact is a no-op and we keep granular context instead of a single summary. (排在自动压缩之前——要是折叠就能把用量压回阈值以下,自动压缩就成了空操作,细粒度的上下文也就保住了,而不是只剩一篇摘要。)

翻译过来就是:能用小代价解决的,就别付大代价。五道关丢东西的量,一级比一级大——第 0 关只搬文件,第 1 级只删最老的一段,第 2 级只清回执,第 3 级把旧区间换成摘要、原文存档;到第 4 级,才是"整段历史换一页摘要"的大手术。站在前面能解决的,绝不惊动后面。

一句交代,入门篇说的是"四级压缩流水线",这里怎么是五道?因为第 0 关是道预算岗,管的是进门盘查,不算压缩;真正动手的,还是那四级。对得上。

3 第 0 关:超大回执,搬去磁盘

模型一口气并行调一堆工具——读五个文件、跑两条命令、搜一轮代码——一批回执同时落进同一条消息。管这一摊的是一道按字符算的预算:同一条消息里的回执加起来,总量上限 20 万字符(src/constants/toolLimits.ts 里的常量 MAX_TOOL_RESULTS_PER_MESSAGE_CHARS)。

单位先说死:是 20 万字符,不是 20 万 token——上下文窗口的上限才是 20 万 token,这里数的是字符,两个 20 万纯属巧合。按字符不按 token,是因为每轮真跑分词器太贵,字符数当个便宜的粗估用(源码注释里写明了这层)。

超了怎么办?不是砍,是搬。规矩三条:

  • 挑谁搬:这批新回执里,照占地方从大到小挑;不够就接着挑,直到落回预算线内;
  • 搬去哪:原文整个写进磁盘文件——会话目录下的 tool-results/,一笔回执一个文件,按工具调用编号命名;
  • 原位换成什么:发给模型的位置,换成一段包装文本——写着内容存在哪个文件,外加开头 2000 字节的预览。

包装文本大致长这样(虚构示意):

<persisted-output>
This tool result exceeded the size limit and was saved to:
  tool-results/toolu_01AB2xyz….txt
Preview (first 2.0 kB):
  1  import { Command } from 'commander'
  2  import { program } from './main'
  …
</persisted-output>

为什么偏偏搬最新的?

按说该搬老的才对——过时了,还占着地方。可老回执一笔都动不得:上一篇讲过,对话每轮完整重发,服务器照前缀给缓存折扣,前缀哪怕变一个字节,重复部分就全按原价重算;老回执是早就发出去过的内容,此刻改动,等于亲手让折扣失效。所以老的一律冻结,连决策也一并冻结:哪笔搬过、包装长什么样,一旦定下一字不改,往后每轮原样重放,替你的账单保住缓存前缀。能下手的,只剩这批还没发出去过的新回执——何况,把这条消息撑爆的,本来就是它们。

最新的搬走了,内容不会丢吗?

没丢,只是被换成了那段包装文本。往后每一轮,这几笔的原文都被那段包装顶替,请求是真的小了,不是只在本地挪个地方。要用的照样到手:模型第一眼看到预览,真需要全文,照文件路径一读就来。原文一个字没丢,躺在磁盘上——搬走的是体积,不是信息。

4 第 1 级:snip,最老的一段直接删掉

第一级最直白:把最老的一段历史,从发给模型的那份里直接删掉。删掉的消息在本地完整记录里还留着,只是不再发给模型;近处的尾部受保护——模型正在干活的上下文,动不得。

这一级有个细节得先交代,判断上下文窗口"满没满"不逐轮重算,是拿最近一次服务器报回来的 token 总数当底数,之后新添的消息按长度粗估往上加。麻烦在这:snip 删的是最前面的老消息,而那些消息早就数进底数里了。删是本地删的,服务器没再报数,底数纹丝不动——估出来的数,就比实际的大。不修正的话,自动压缩拿这个偏大的数去比自己的触发线。偏差多少?看个示例:

上次服务器报数         150 000   ← 底数,之后不再变
这轮新添的消息(粗估) + 20 000
估出来的总数           170 000   ← 拿它比触发线:超了,该动手了
snip 删掉的最老一段    − 30 000  ← 删了多少,只有 snip 自己知道
实际                   140 000   ← 没超,不用动手

17 万是估的,实际只有 14 万——差 3 万。所以 snip 干完活得报个数:这一趟腾出多少 token,记在 snipTokensFreed 里传过去,自动压缩先减掉这 3 万再比线。第 2 节那段代码里 autocompact 多收的第二个参数,传的就是这个数。

想手动触发也有门路:有个斜杠命令 /force-snip

5 第 2 级:微压缩,旧回执清成一句话

第二级开始像"压缩"了。 微压缩 = 只清旧回执、别的什么都不碰的压缩。

清的,是回执里装的那份原文。模型每调一次工具,对话里就留下一对:它的请求,加工具交回来的回执。早先那次 Read,回执里装着整个文件的原文,从此每一轮都原样发给模型。压缩前后一对照(虚构示意):

压缩前:
请求  Read src/login.ts                  ← 模型发的这条,不动
回执  1  export function login(user: string) {
      2    const token = await signIn(user)
      …(共 400 行,约 6 000 token)    ← 交回来的这条,被清

压缩后:
请求  Read src/login.ts                  ← 原样保留
回执  [Old tool result content cleared]  ← 一句话顶替 400 行

为什么微压缩后只留一句固定占位,连摘要都不写?

那句占位文本的意思是:旧的工具结果已清空。先看它清的什么内容?这一级只碰白名单上的工具:Read、Bash、Grep、Glob、WebSearch、WebFetch、Edit、Write。这份名单挑得很有讲究:能进来的全是结果可再生的工具——文件在磁盘上,命令能重跑,网页能再抓。而且请求那条还留在对话里,读了哪个文件、跑了哪条命令,写得一清二楚——真要用旧内容,照着再调一次就有,拿回来的是原文,比什么摘要都全。

所以白名单划的就是"清得起"的边界:清了也随时拿得回来的,才进名单;拿不回来的——你提问的回执、派子 Agent 的汇报——不在名单上,这一级永远不碰。原文也照旧躺在本地完整记录里,随时能翻回去。

清也只清旧的:最近 5 笔回执保留,更早的才清。

这一级还有一条闲置触发:距上一次模型响应超过 60 分钟,也顺手清一轮。为什么偏偏等满 60 分钟?留到第 9 节,和缓存折扣一起讲。

6 第 3 级:折叠,历史不动,视图现拼

第三级是"折叠":把又长又旧的一段对话,换成一段短摘要发给模型,原文原封不动。怎么换,看个例子(虚构示意):

完整历史(始终不动):
#12  模型  我先读 src/login.ts,看登录报错出在哪
#13  回执  (400 行文件原文)
#14  模型  再跑一遍登录测试
#15  回执  (测试输出,约 2 000 token)
     …(#16–#46:改一版、跑一遍,再改再跑)
#47  模型  定位了:缺 token 过期校验,修在 signIn()

发给模型的那份(每次发送前现拼):
#1–#11      原样照发
#12–#47  →  <collapsed id="0000000000000042">
            排查登录报错:读 login.ts、跑测试、改两版,定位到
            缺 token 过期校验,修在 signIn(),测试全过
            </collapsed>
#48 起      原样照发

读时投影

原文那份从头到尾一个字不动;发给模型的那份,是每次发送前照着一份折叠记录现拼的——记录里写着哪个区间换成了哪段摘要,拼的时候照做。Claude Code 管这叫读时投影:历史只有完整的一份,模型看到的那份,是"读的时候"才投影出来的。这份记录跟着会话一起落盘,重开会话照着再拼一遍,所以折叠跨轮、跨会话都生效。

记录格式:16 位的折叠编号、一段 <collapsed id="…">…</collapsed> 格式的摘要、归档区间的起止消息标识。

摘要是现写的吗?

摘要也不是用时现写的:平时后台就把候选区间备好、摘要生成好,每条带一个风险分——估的是这一段折叠之后,对接下来的活有多大影响。

什么时机触发折叠?

折叠的时机分两档:用量到90%,开始把备好的折叠一条条提交;到95%,改成阻塞式——手头的活先停下,把折叠做完再说。也因为它顶在前面,折叠一开启,第 4 级自动压缩整个关掉。注释给了原因:第 4 级自动压缩的触发线折算过来约 93%,正好卡在90%和95%之间——不关掉的话它总是抢跑,把历史一口气压成一篇摘要,折叠辛苦保住的细节就全没了。

7 第 4 级:自动压缩,整段历史换一页摘要

最后一级,也是唯一一级"整段重写"的。等式:自动压缩 = 让模型自己把整段旧对话写成一篇小结,用这篇小结顶替全部旧历史。

什么时候动手?

触发线不是 20 万(上下文窗口的上限),而是:

触发线 = 200 000 − 20 000(给摘要留的产出空间) − 13 000(缓冲) = 167 000 token

为什么提前 3.3 万动手?

两个原因。一是摘要是模型写的,产出动辄上万 token,得有地方放;二是更要命的——写摘要的请求,得把整段旧历史原样发过去给模型看,它自己就是一次满载请求,不留余量,压缩请求自己就会撞顶。那个 20 000 也不是拍脑袋:注释里写着,摘要产出的 p99.99 实测是 17387 token——取整留两万。拿真实数据定常量,这是整条流水线里我最喜欢的一处。

谁来写?

摘要不是外包给小模型,是主循环那个模型自己写:临时换上一句系统提示词——

You are a helpful AI assistant tasked with summarizing conversations.
你是一个负责给对话写总结的助手。

工具全禁,只干这一件事。为什么不用别的模型来写?就因为写摘要的还是主循环那个模型——同一个模型,写摘要的请求才能原样接上主对话的缓存前缀,旧历史不按全价重发;换一个模型,这份折扣就没了。

写什么?

摘要不是自由发挥,是填表——提示词里定死了九节:主要请求与意图、关键技术概念、涉及的文件与代码、错误与修复、问题解决过程、全部用户消息、待办任务、当前工作、可选的下一步。其中"全部用户消息"一节有硬要求:用户亲手敲的每一条,一条不落全列进去(工具回执不算)。

压缩完长什么样?

下一轮发给模型的,从一长串变成:

02-after-compact.png

两个值得看的点。其一,系统提示词、CLAUDE.md、git 状态原样都在——它们压根不在消息数组里,每轮由 harness 重新注入——就是入门篇讲过的四层静态,压缩想动也够不着。其二,压缩会顺手把最近读过的 5 个文件原文重新附上(总量预算 5 万 token,单个不超过 5 千):摘要里只有文件名和关键片段,可模型接下来还要接着干活——与其等它自己重新摸索着读文件,不如压缩时直接带上。

摘要消息的开头是真实文案:

This session is being continued from a previous conversation
that ran out of context.
本会话接自一个用完了上下文窗口的前一会话。

后面跟着完整记录的存档路径,和一句叮嘱:直接接着干,不要复述总结。

8 压缩自己也会失败

别以为压缩一触发就成功——压缩本身就是一次模型调用,也会失败,所以备了两道防线。

第一道,断路器:

自动压缩连着失败 3 次,本会话内不再自动尝试。这道闸的来历写在注释里:后台数据里曾有一个会话,自动压缩连败 3272 次;这类失败全局算下来,每天要浪费约 25 万次 API 调用。另外,自动压缩被整个关掉时,用量逼近上限,循环会提前硬停并报错,留出 3 千 token 的余量——给你手动敲 /compact 用的。

第二道,给压缩请求自己撞顶兜底:

写摘要要带着全部旧历史发出去,历史太大,这次请求自己也可能被服务器拒绝。拒了怎么办?按服务器报回来的差距,从最老的一轮开始整组丢弃,再试;最多试 3 次。还不行,就收手,给用户留一句实诚的报错:

Conversation too long. Press esc twice to go up a few
messages and try again.
对话太长了。按两下 Esc 回退几条消息,再试一次。

把"回退几条消息"写进报错里当解法——harness能做的都做完了,剩下的活交还给人。

9 压缩以后,缓存折扣还在吗?

缓存的规则之前讲过:对话每轮完整重发,服务器照前缀给缓存折扣,前缀一个字节都不能变——变了,重复部分全按原价重算。那就追问一句:后面四级真压缩,改写的全是已经发出去过的历史,岂不是每一级动手,都在亲手让折扣失效?

对,缓存折扣全都失效。

先说唯一不失效的:第 0 关。它只动还没发出去的内容,发过的一个字节不改,整条流水线里只有它白拿缓存折扣,所以每轮都跑、白跑也不亏。后面四级没这待遇,改写一落地,旧的折扣就没了。能查到触发线的三个,各自把动手时机往后挪:

  • 折叠守着90%、95%的用量线,自动压缩守着 16.7 万——非到高位不动手;
  • 微压缩备了一条闲置触发,闲置满 60 分钟才清——等的就是服务器端的缓存自己过期,那时候改写前缀,一分折扣都不亏;
  • 自动压缩最彻底,整段历史换成一页摘要,缓存从头再来。它也留了一手:第 7 节讲过,写摘要用的就是主循环同一个模型——旧历史按折扣价发完这最后一次,之后从摘要起重新养缓存。

还有一层,划得来:折扣只失效一轮——改写落地的那轮,变了的部分按原价重算,之后前缀照新的稳定下来,折扣接着有效;省下的 token,却是往后每一轮都在省。第 2 节说"代价小的在前,代价大的在后",代价的另一半就在这:越往后,不只扔掉的越多,连缓存折扣也一起失效。

10 你的话,保护得最深

五道关走完,回答标题的问题。汇总成一张表:

关卡扔什么留什么
第 0 关 · 回执预算超大回执的原文磁盘文件 + 路径 + 2000 字节预览
第 1 级 · snip最老的一段历史最近的对话(尾部受保护)+ 本地完整记录
第 2 级 · 微压缩旧回执的原文最近 5 笔回执 + 本地完整记录
第 3 级 · 折叠旧区间的逐字原文折叠摘要 + 完整历史存档
第 4 级 · 自动压缩几乎全部旧对话一页九节摘要 + 重新附上的最近文件

从上往下,越扔越多,可有一条线:**你的话,保护得最深。**第 0 关和微压缩只动回执,够不着你的话;snip 和折叠扔的是最老的一段旧历史;到最后一级整段重写,摘要里"全部用户消息"仍是必填的一节——一条不落。

凭什么这么排?按能不能再生排的。回执是替你跑腿的痕迹,丢了能再生成;你的话不能再生,丢一句少一句。所以整条流水线,可再生的先扔,不能再生的保到最后。

11 还有一层,压缩够不着

上下文窗口满了,模型自己什么都做不了——它连"忘"都不会。忘什么、留什么,全写在它外面那层代码里。智能在模型,干活在 harness——连记忆的取舍,都是 harness 替它做的主。

不过这条流水线管得再宽,也只管"这一个会话装不下了"。有一层东西压根不进消息数组,也不跟着会话结束而消失——你写的 CLAUDE.md。它怎么被一层层找出来、怎么活过一次次压缩?下一篇,拆它。


不信?找个长会话敲一下 /compact,翻翻模型写的那页九节小结——你敲过的每句话都列在里面,那就是第 4 级的产物。

连载 《拆解 Claude Code:Agent Harness 工程实践》 关注不迷路