Claude Code 的上下文压缩流水线:一个 200K 窗口是怎么被精打细算的

上下文窗口管理是 AI Agent 最容易踩坑的地方。Claude Code 不是"快满了再做一次摘要",而是把多种上下文管理机制串成了一条前置压缩链,再补一条调用后的兜底恢复链。本文按调用链拆解这套设计;不过先说明一下,当前公开的源码里有少数实验模块的实现文件缺失,所以 Context CollapseReactive Compact 这两块主要依据调用方和源码注释来还原。

一、先讲结论

读完 Claude Code 的上下文压缩源码,最让我意外的不是某个单独的技术点,而是整条流水线的层次设计

传统做法要么是简单截断(token 不够就丢历史),要么是一次性摘要(让 LLM 把全部对话总结成一段话)。Claude Code 走了第三条路:分层递进压缩 。更准确地说,是在 query.ts 里放了 5 个固定的 context-management 调用点,从细粒度到粗粒度依次尝试,再在 API 真实报错时走调用后的 fallback。

scss 复制代码
┌─────────────────────────────── query() 每轮循环 ──────────────────────────────┐
│                                                                                │
│  ① Tool Result Budget ──→ ② Snip Compact ──→ ③ Microcompact                   │
│         ↓ 不够                    ↓ 不够              ↓ 不够                   │
│  ④ Context Collapse ──→ ⑤ AutoCompact(含 Session Memory Compact)            │
│                                                                                │
└────────────────────────────────────────────────────────────────────────────────┘

几个关键判断:

  • 不是一个策略,是多个固定调用点query.ts 的顺序是固定的,但每一层是否真正生效,取决于 feature gate、querySource、模型能力和当前 token 状态
  • 越靠前的层越便宜 。第 ① 层是磁盘持久化 + 字符串替换,第 ③ 层是内容清空或 cache_edits,只有第 ⑤ 层才会在压缩当下真正发起"总结旧上下文"的请求
  • 不是严格的"无损 → 有损" 。更准确的说法是:从低成本、可回溯程度更高 的删减,一路走到高成本、摘要化的重写
  • 缓存感知是核心设计约束。Anthropic API 有 prompt cache,很多代码都在"释放 token"和"别把缓存前缀打碎"之间权衡
  • 5 层只是主动链 。除此之外,query.ts 里还能看到一条调用后的恢复链:真实触发 prompt_too_long 时,先让 Context Collapse 尝试排水,再尝试 Reactive Compact

二、它在解决什么问题

假设你让 Claude Code 帮你做一个跨 20 个文件的重构任务。对话可能是这样的:

  1. 你说"重构 auth 模块"
  2. Agent 读了 15 个文件(每个 FileRead 返回几百行代码)
  3. Agent 编辑了 8 个文件
  4. 你说"测试跑一下"
  5. Agent 执行 shell 命令,拿回一堆测试输出
  6. 你说"第 3 个测试挂了,修一下"
  7. Agent 又读了几个文件......

到第 7 步,上下文里堆积的内容大部分是第 2-3 步的旧工具结果------那些已经被修改过的文件内容、已经过时的 grep 结果、已经执行完毕的 shell 输出。它们占据大量 token,却对当前任务的帮助越来越小。

简单截断?你会丢掉用户最初的需求描述。一次性摘要?LLM 摘要调用本身就要消耗一大笔 token,而且你无法保证摘要不丢关键细节。

Claude Code 的解决思路是:先把最不值钱的内容(旧工具结果)用最便宜的方式处理掉,只在必要时才动用 LLM 做摘要

三、5 层压缩流水线的完整执行路径

query.ts 的主循环中,API 调用前有 5 个固定调用点,顺序如下。但要注意,它们并不是"每轮都一定生效",很多步骤都只是经过这个调用点,然后根据 gate/状态决定是否 no-op

flowchart TD START["开始新一轮 API 调用"] --> TRB["① Tool Result Budget
超大结果 → 持久化到磁盘"] TRB --> SNIP["② Snip Compact
历史截断(feature gate 控制)"] SNIP --> MC["③ Microcompact
旧工具结果 → 清除/缓存编辑"] MC --> CC["④ Context Collapse
提交日志式折叠(读时投影)"] CC --> CHECK{"token 是否超过阈值
effectiveWindow - 13K?"} CHECK -->|是| AC["⑤ AutoCompact
Session Memory 或 LLM 摘要"] CHECK -->|否| API["发起 API 调用"] AC --> API

下面逐层详解。

第 ① 层:Tool Result Budget ------ 大结果优先落盘,但不是无脑落盘

一句话 :这层不是"见大就落盘",而是给 tool_result 设两层预算,超标部分持久化到磁盘,上下文里只留一个预览和文件路径。

触发条件:这里其实有两套阈值:

  • 单个 tool result 的默认持久化阈值50_000 字符
  • 单个 API-level user message 内 tool_result 的 aggregate budget 默认是 200_000 字符

也就是说,50K 不是"整条消息预算",而是单个结果的默认持久化阈值;真正控制"这一轮并行工具结果总量"的,是 200K 的 per-message aggregate budget。

核心实现utils/toolResultStorage.ts):

yaml 复制代码
工具结果 ──→ 三分区决策 ──→ mustReapply: 之前已替换过 → 重用缓存字符串
                        ──→ frozen: 已在缓存中但未替换 → 不动(保护 prompt cache)
                        ──→ fresh: 新结果 → 按大小降序选择最大的,直到满足预算

被替换后,上下文里的内容变成这样:

lua 复制代码
<persisted-output>
Output too large (523 KB). Full output saved to:
  /path/to/.../tool-results/xxx-yyy.txt

Preview (first 2000 bytes):
[预览内容...]
...
</persisted-output>

为什么放在第一层 :这一层零 LLM 调用,主要是文件落盘和字符串替换,而且后面的 Microcompact(缓存编辑)只看 tool_use_id、不看内容,两者互不干扰。源码注释原话:

"cached MC operates purely by tool_use_id (never inspects content), so content replacement is invisible to it and the two compose cleanly"

三分区策略的设计考量 :为什么不能无脑替换所有超标结果?因为 Anthropic API 有 prompt cache。如果你修改了已经在缓存中的消息内容,缓存就失效了。所以已经在缓存中但当时没被替换的结果(frozen)绝对不能动。这意味着 Budget 层的决策是不可逆的------一旦某个结果进入 frozen 状态,即使后续发现它是最大的占空间结果,也不会再去替换它。这个"宁可少释放一点空间,也不打碎缓存前缀"的取舍,是整条流水线中缓存感知设计哲学的缩影。

还有一个很容易忽略的细节:Read 结果默认不会被这一层持久化 。因为 Read 工具本身的 maxResultSizeChars = Infinity,注释里直接写了原因:把读出来的文件内容再落到磁盘、再让模型去 Read 那个文件,是一层没意义的循环。也就是说,Budget 层主要处理的是 shell / grep / web fetch 这类结果,而不是把所有大文件读取都改写成"去磁盘再读一遍"。

第 ② 层:Snip Compact ------ 历史截断

一句话:当上下文增长到一定程度,直接从历史头部截断一段消息,释放的 token 数会传给后面的 AutoCompact 做阈值调整。

这一层目前在 feature('HISTORY_SNIP') 的门控之后(构建期 feature flag),执行时:

typescript 复制代码
const snipResult = snipModule.snipCompactIfNeeded(messagesForQuery)
messagesForQuery = snipResult.messages
snipTokensFreed = snipResult.tokensFreed  // 传给 AutoCompact

关键设计点:snipTokensFreed 会向下传递。AutoCompact 计算"是否需要触发"时,用的是 tokenCount - snipTokensFreed,这意味着 Snip 释放了多少空间,AutoCompact 就少干多少活。两层之间有显式的配合关系。

第 ③ 层:Microcompact ------ 旧工具结果的增量清除

这是最精巧的一层。不过先说边界:这一层本身也不是"默认总会发生",而是取决于不同实验开关和环境条件。代码里能确认它至少有两条路径:

路径 A:时间触发微压缩

如果距离上次主线程 Assistant 消息超过阈值,就直接把旧的可压缩工具结果内容清空为 '[Old tool result content cleared]'。原理很简单:既然服务端缓存大概率已经过期,下一次请求本来就要重写整段 prefix,不如在重写前先把过时工具结果瘦下来。

默认配置在 timeBasedMCConfig.ts 里是:

typescript 复制代码
{
  enabled: false,
  gapThresholdMinutes: 60,
  keepRecent: 5,
}

所以这条路径默认是关闭 的;就算开启了,安全阈值也是 60 分钟,不是几分钟。

路径 B:缓存编辑微压缩(Cached Microcompact)

这条路径利用 Anthropic API 的 cache_edits 机制。不修改本地消息内容,而是告诉 API:"帮我把缓存里的这几个 tool_result 删掉。"

sequenceDiagram participant MC as Microcompact participant State as CachedMCState participant API as Anthropic API MC->>State: 注册新工具调用 ID MC->>State: 按计数阈值选择要删除的旧调用 MC->>MC: 创建 cache_edits 块(pending) Note over MC: 不修改本地消息! MC-->>API: 请求中携带 cache_edits API-->>MC: 响应中返回 cache_deleted_input_tokens MC->>State: 更新基线,确认删除生效

可压缩工具集

typescript 复制代码
COMPACTABLE_TOOLS = new Set([
  'Read', 'Bash', 'PowerShell', 'Grep', 'Glob',
  'WebSearch', 'WebFetch', 'Edit', 'Write'
])

为什么这些工具可以被压缩?因为它们的返回值随时间贬值最快------读过的文件会被修改,跑过的命令输出会被新输出替代。

缓存编辑的核心优势 :传统的"清除旧工具结果"会改写本地消息内容,破坏 prompt cache prefix。而 cache_edits 是 API 层面的操作,目标就是尽量避免因为客户端改写前缀而重建整段缓存

这也是为什么 cached microcompact 不适用于子 Agent:

typescript 复制代码
// Only run cached MC for the main thread to prevent forked agents
// (session_memory, prompt_suggestion, etc.) from registering their
// tool_results in the global cachedMCState

如果子 Agent 的工具调用被登记进主线程的状态,主线程就会去删不存在的工具结果------直接崩溃。

第 ④ 层:Context Collapse ------ 读时投影的折叠

先说边界:当前公开源码里看不到 services/contextCollapse/* 的实现文件 ,所以这一层没法像前面几层那样逐函数读完。下面这些结论,是根据 query.tssetup.tsautoCompact.ts 里的调用点和注释能确认的部分。

一句话 :它试图把早期的完整 API 交互轮次折叠成摘要,但不直接改写 REPL 里的完整历史,而是用一个"投影视图"去给后续 API 调用喂精简版上下文。

query.ts 里的注释是这样写的:

"the collapsed view is a read-time projection over the REPL's full history. Summary messages live in the collapse store, not the REPL array."

这意味着至少可以确认:

  • 折叠是可逆的(不会丢失原始数据)
  • projectView() 在每轮 API 调用前重放"提交日志",把已折叠的消息替换成对应的摘要
  • persist through turns(跨轮次持久)

与 AutoCompact 的互斥关系 :这一点是可以明确确认的。当 Context Collapse 启用时,AutoCompact 的主动触发会被禁用。autoCompact.ts 里有大段注释解释为什么:

"Collapse IS the context management system when it's on --- the 90% commit / 95% blocking-spawn flow owns the headroom problem. Autocompact firing at effective-13k (~93% of effective) sits right between collapse's commit-start (90%) and blocking (95%), so it would race collapse and usually win, nuking granular context that collapse was about to save."

翻译一下:从注释推断,Context Collapse 试图把 headroom 管成一个 90% 开始提交折叠、95% 阻塞继续生成 的流程。AutoCompact 则大约在 effective window 的 93% 左右触发。如果两者同时开着,AutoCompact 会先把整个上下文做一次粗粒度摘要,直接打断 Collapse 想做的细粒度折叠。

第 ⑤ 层:AutoCompact ------ 最后的 LLM 摘要

当前面几层都没把 token 压到安全线以内时,就进入最"重"的一层:主动 compact。它先尝试 Session Memory Compact,再回退到传统的 LLM 摘要 compact。

触发阈值

typescript 复制代码
// 有效窗口 = 上下文窗口大小 - min(模型最大输出, 20K)
effectiveWindow = contextWindowSize - min(maxOutputTokens, 20_000)

// 触发线 = 有效窗口 - 13K 缓冲
autoCompactThreshold = effectiveWindow - 13_000

以 200K 窗口、且模型最大输出不少于 20K 为例:有效窗口约 180K,触发线约 167K。

电路断路器

连续失败 3 次后直接放弃,不再重试。源码注释揭示了这个设计背后的血泪数据:

"1,279 sessions had 50+ consecutive failures (up to 3,272) in a single session, wasting ~250K API calls/day globally"

Session Memory 优先,但它是实验性分支

AutoCompact 实际上有两条路径,先试 Session Memory Compact,不行再走 LLM 摘要:

flowchart TD TRIGGER["token > threshold"] --> SM{"Session Memory
可用且非空?"} SM -->|是| SMC["Session Memory Compact
用增量 session memory 替代摘要"] SM -->|否| LLM["LLM 摘要压缩"] SMC --> CHECK{"压缩后仍超阈值?"} CHECK -->|是| LLM CHECK -->|否| DONE["完成"] LLM --> DONE

Session Memory Compact 的优势在于:compact 触发的这一刻,不需要再额外发起一次"总结旧上下文"的请求。它直接读取已经存在的 session memory 文件,把成本前移到了后台抽取阶段。

不过这条路径不是默认永远可用的。代码里能看到它至少受两层 gate 控制:

  • tengu_session_memory
  • tengu_sm_compact

此外,session memory 为空、还是模板、压完后仍然超过阈值,都会回退到传统 compact。

它的配置是:

typescript 复制代码
DEFAULT_SM_COMPACT_CONFIG = {
  minTokens: 10_000,            // 最少保留的 token
  minTextBlockMessages: 5,      // 最少保留的有文本的消息数
  maxTokens: 40_000,            // 最多保留的 token(硬上限)
}

而 Session Memory 本身也不是每轮都写。后台抽取的默认阈值是:

  • 上下文先达到 10_000 token,才初始化 session memory
  • 之后上下文至少再增长 5_000 token(这个是硬门槛,必须满足)
  • 在 token 门槛满足的前提下,满足以下任一条件即触发:累计 3 次 tool call,或者最近一轮没有 tool call(说明是自然停顿点)

也就是说,它更像一个后台持续维护的会话笔记,而不是"compact 时临时总结一次"。

LLM 摘要的具体流程

当 Session Memory 路径不可用时,进入经典的 LLM 摘要:

  1. 预处理 :剥离图片(替换为 [image] 标记)、剥离可重注入的 skill 附件
  2. 先试共享缓存的 forked agent :优先走 runForkedAgent,复用父会话的 prompt cache prefix------这意味着摘要请求的实际计费 token 远少于"从零开始发一整段对话",因为大段前缀已经在服务端缓存了;失败再退回普通 streaming 摘要路径
  3. 摘要输出格式受严格约束 :专门的 compact prompt 强制模型输出 <analysis> + <summary>,并把工具调用显式禁止
  4. PTL 应急 :如果 compact 请求本身就 prompt too long,才会用 groupMessagesByApiRound() 按 API 轮次切组,截掉最旧的一批再重试,最多 3 次
  5. 后压缩恢复:恢复最近读过的文件附件、plan/plan mode、已调用 skill、deferred tools、agent listing、MCP instructions,再重新跑 session start hooks

摘要 Prompt 的设计 有一个值得注意的细节:它强制要求先输出 <analysis> 思考过程,再输出 <summary> 结构化总结。<analysis> 部分在最终摘要中会被 formatCompactSummary() 删掉------这是一个让 LLM 先"想"再"写"来提高摘要质量的技巧,和 chain-of-thought prompting 的思路一致。

额外一条:调用后的 Reactive Compact 兜底

上面这 5 层说的都是API 调用前 的主动压缩链。但 query.ts 里还能看到一条调用后的恢复链:

  • 如果真实 API 返回了 prompt_too_long
  • 并且错误消息在流式层被"暂时扣住"了
  • 系统会先让 Context Collapse(如果开启)尝试 recoverFromOverflow()
  • 再尝试 reactiveCompact.tryReactiveCompact(...)

这条链不属于上面的"5 层主动流水线",更像是真正撞墙之后的兜底恢复 。不过要再强调一次:reactiveCompact.ts 的实现文件在这份公开源码里也没看到,所以这里只能确认"这条恢复链存在",不宜写成已经完整读到了它的内部策略。

四、举一个具体例子

下面给一个更贴近源码约束的示意流程。注意它不是硬编码剧本,而是把几种常见路径拼在一起:

操作步骤 此时上下文 可能触发的压缩层 效果
用户说"重构 auth" 1K token ---
Agent 并行跑 Grep / Bash,同一轮 tool_result 合计 230K chars ~100K token ① Budget 把其中最大的 fresh 结果持久化到磁盘,当前上下文只留 preview
又过了几轮,旧 shell / grep 输出越来越多 ~160K token ③ Cached MC(若 gate 开启、模型支持、且是主线程) 通过 cache_edits 删除更早的旧 tool_result,尽量不改本地消息内容
用户一小时后回来继续 ~160K token ③ Time-based MC(若该实验开启) 清掉除最近 keepRecent 个外的旧 tool_result 内容
继续工作,超过 autoCompactThreshold ~175K token ⑤ Session Memory Compact(若 gate 开启且 session memory 可用) 用 session memory + 保留的最近消息重建上下文
Session Memory 不可用,或压缩后仍过线 ~175K token ⑤ LLM 摘要 compact fork summarizer agent,生成摘要,再恢复附件和 hooks

注意最后两行:Session Memory Compact 一般会保留更多原始消息尾部;传统 compact 则更像"摘要 + 一批恢复附件"的重建。

五、和其他方案的对比

维度 Claude Code 简单截断 纯 LLM 摘要
压缩层数 5 层主动链 + 1 条调用后兜底 1 层 1 层
最便宜的操作 零 LLM 调用的磁盘持久化 删头部消息 每次都调 LLM
缓存感知 三分区 + cache_edits
Session Memory 增量提取 + 压缩时复用
容错 电路断路器 + PTL 重试
信息保留度 高(渐进退化) 中(取决于摘要质量)
实现复杂度 非常高 极低 中等

Claude Code 的设计明显是为长对话、高 token 消耗的重度 Agent 使用场景优化的。如果你的 Agent 对话通常在 10K token 以内结束,这套流水线完全过度设计。但如果你的场景是"Agent 自治执行 50 步重构任务",这些层次就是刚需。

六、值得注意的工程细节

1. snipTokensFreed 的层间传递

Snip 层释放的 token 数不是"用完就忘"的,它显式传递给 AutoCompact 的阈值判断:tokenCount - snipTokensFreed。这意味着两层之间有协作关系,不是各自独立运行。

2. 摘要 Prompt 用了"禁止调用工具"的强硬措辞

vbnet 复制代码
CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.
Tool calls will be REJECTED and will waste your only turn --- you will fail the task.

子 Agent 只跑一轮(maxTurns: 1),如果它错误地调用了工具,这一轮就浪费了。源码注释提到 Sonnet 4.6 上有 2.79% 的失败率是因为模型试图调用工具(对比 Sonnet 4.5 只有 0.01%------差了两个数量级),所以这段 preamble 被放在 prompt 的最前面而不是最后面。

3. 后压缩恢复不止恢复文件

AutoCompact 做完摘要后,不是简单地把摘要加进去就完了,它还会:

  • 恢复最近读过的 5 个文件内容(上限 50K token)
  • 重注入已使用的 skill 内容
  • 重新运行 session start hooks(恢复 CLAUDE.md 等上下文)
  • 重新广播延迟工具的 delta 附件
  • 重新广播 MCP 指令
  • 恢复 plan mode 状态(如果正在使用)

这些恢复操作确保模型在摘要之后仍然能正常工作,而不是"忘记了自己有哪些工具"。

4. 按 API 轮次分组,主要用在 PTL 重试里

groupMessagesByApiRound() 按 assistant 的 message.id 来分界。同一个 API 响应里的流式 chunk 共用一个 message.id,算一组。它最重要的用途不是"日常 compact 一定会先分组",而是compact 请求自己也爆了时,系统能从最旧的 API round 开始裁剪,这对单个用户请求下跑出很多 agentic 子回合的场景尤其重要。

5. 不要把实验特性误读成默认行为

这篇文章里最容易被读快了的地方有三个:

  • Snip Compact 是 gate 控制的
  • Time-based MC 默认是关的,而且安全阈值是 60 分钟
  • Session Memory Compact 也不是默认永远有,要同时满足 session memory gate、sm compact gate、文件非空、压缩后不过线等条件

换句话说,query.ts 的顺序是固定的,但"哪些层在你这次会话里真的活着"是动态的。

七、这篇解读的边界

最后把边界说清楚,不然这篇文章会显得比源码本身更确定:

  • 我能直接核实的,是 query.tsautoCompact.tsmicroCompact.tstoolResultStorage.tscompact.tssessionMemory*.ts 这些文件里的行为
  • 但这份公开源码里,services/contextCollapse/*reactiveCompact.tscachedMicrocompact.js 这些实现文件本体不可见
  • 所以凡是涉及 Context Collapse 内部 commit log、Reactive Compact 内部裁剪算法、Cached MC 的完整状态机,最好都写成"从调用方和注释可确认",不要写成"源码已经完整证实"

八、最后总结

Claude Code 的上下文管理不是一个简单的"token 快满了就摘要"系统,而是一条从低成本删减到高成本摘要化重写的递进式流水线。

几个对 Agent 开发者有参考价值的设计原则:

  • 先做便宜的事:磁盘落盘、旧工具结果清理、缓存编辑,成本都低于重新做一遍摘要
  • 缓存成本是隐形杀手:prompt cache 命中与否直接影响成本和延迟,所以很多实现细节本质上都在保护 prefix
  • 摘要要有容错:LLM 摘要可能失败,需要电路断路器防止无限重试;摘要请求本身也可能超长,需要 PTL 重试策略
  • 把摘要成本前移:Session Memory 的思路,不是在 compact 那一刻临时总结,而是在后台持续维护一份可复用的会话笔记
  • 层间要协作:snip 释放的 token 要传给 autocompact,context collapse 和 autocompact 要互斥------压缩层不是独立的模块,而是一个有状态依赖的流水线
  • 默认行为和实验行为必须分开看 :同一个调用链里,可能同时混着默认逻辑、GrowthBook gate、模型能力判断和 querySource 限制

如果要评价这个设计,我会说:它是 200K 窗口限制下"追求极致 token 利用效率"的工程产物。当窗口足够大到不需要压缩时,这一切都不需要。但在当前的技术条件下,这可能是最精细的上下文管理方案之一。

相关推荐
Steiwe2 小时前
多模态大模型产生幻觉的直接原因是否是语言先验问题
人工智能·计算机视觉
掘金者阿豪2 小时前
2026全球视觉理解大模型盘点:国内外TOP20排行榜与技术格局
人工智能·后端
小凡同志2 小时前
Claude Code Plugin 到底是什么?别再和 MCP、Hook、Subagent、Skill 混着用了
人工智能·ai编程·claude
TG_yunshuguoji2 小时前
阿里云代理商:百炼模型部署成本优化指南
人工智能·阿里云·云计算·百炼大模型
YAMI掘金2 小时前
当 AI Agent 学会"社交"——多 Agent 协作系统的设计思考
人工智能·agent
酷虎软件2 小时前
视频解析/文案提取API接口
人工智能·方言数字人
AI程序员2 小时前
Claude Code 源码泄漏:拆解一个工业级 AI Coding Agent 到底是怎么造出来的
人工智能
ai产品老杨2 小时前
协议融合与边缘协同:基于 GB28181/RTSP 的企业级 AI 视频中台架构解析
人工智能·架构·音视频
zhangshuang-peta2 小时前
如果没有 MCP,AI 系统会走向哪里?
人工智能·ai agent·mcp·peta