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

2 阅读18分钟

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

一、先讲结论

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

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

┌─────────────────────────────── 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<br/>超大结果 → 持久化到磁盘"]
    TRB --> SNIP["② Snip Compact<br/>历史截断(feature gate 控制)"]
    SNIP --> MC["③ Microcompact<br/>旧工具结果 → 清除/缓存编辑"]
    MC --> CC["④ Context Collapse<br/>提交日志式折叠(读时投影)"]
    CC --> CHECK{"token 是否超过阈值<br/>effectiveWindow - 13K?"}
    CHECK -->|是| AC["⑤ AutoCompact<br/>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):

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

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

<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),执行时:

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 里是:

{
  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: 更新基线,确认删除生效

可压缩工具集

COMPACTABLE_TOOLS = new Set([
  'Read', 'Bash', 'PowerShell', 'Grep', 'Glob',
  'WebSearch', 'WebFetch', 'Edit', 'Write'
])

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

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

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

// 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。

触发阈值

// 有效窗口 = 上下文窗口大小 - 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<br/>可用且非空?"}
    SM -->|是| SMC["Session Memory Compact<br/>用增量 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。

它的配置是:

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 摘要 compactfork 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 用了"禁止调用工具"的强硬措辞

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 利用效率"的工程产物。当窗口足够大到不需要压缩时,这一切都不需要。但在当前的技术条件下,这可能是最精细的上下文管理方案之一。