上下文窗口管理是 AI Agent 最容易踩坑的地方。Claude Code 不是”快满了再做一次摘要”,而是把多种上下文管理机制串成了一条前置压缩链,再补一条调用后的兜底恢复链。本文按调用链拆解这套设计;不过先说明一下,当前公开的源码里有少数实验模块的实现文件缺失,所以
Context Collapse、Reactive 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 个文件的重构任务。对话可能是这样的:
- 你说"重构 auth 模块"
- Agent 读了 15 个文件(每个 FileRead 返回几百行代码)
- Agent 编辑了 8 个文件
- 你说"测试跑一下"
- Agent 执行 shell 命令,拿回一堆测试输出
- 你说"第 3 个测试挂了,修一下"
- 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.ts、setup.ts、autoCompact.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_memorytengu_sm_compact
此外,session memory 为空、还是模板、压完后仍然超过阈值,都会回退到传统 compact。
它的配置是:
DEFAULT_SM_COMPACT_CONFIG = {
minTokens: 10_000, // 最少保留的 token
minTextBlockMessages: 5, // 最少保留的有文本的消息数
maxTokens: 40_000, // 最多保留的 token(硬上限)
}
而 Session Memory 本身也不是每轮都写。后台抽取的默认阈值是:
- 上下文先达到
10_000token,才初始化 session memory - 之后上下文至少再增长
5_000token(这个是硬门槛,必须满足) - 在 token 门槛满足的前提下,满足以下任一条件即触发:累计 3 次 tool call,或者最近一轮没有 tool call(说明是自然停顿点)
也就是说,它更像一个后台持续维护的会话笔记,而不是“compact 时临时总结一次”。
LLM 摘要的具体流程:
当 Session Memory 路径不可用时,进入经典的 LLM 摘要:
- 预处理:剥离图片(替换为
[image]标记)、剥离可重注入的 skill 附件 - 先试共享缓存的 forked agent:优先走
runForkedAgent,复用父会话的 prompt cache prefix——这意味着摘要请求的实际计费 token 远少于"从零开始发一整段对话",因为大段前缀已经在服务端缓存了;失败再退回普通 streaming 摘要路径 - 摘要输出格式受严格约束:专门的 compact prompt 强制模型输出
<analysis>+<summary>,并把工具调用显式禁止 - PTL 应急:如果 compact 请求本身就
prompt too long,才会用groupMessagesByApiRound()按 API 轮次切组,截掉最旧的一批再重试,最多 3 次 - 后压缩恢复:恢复最近读过的文件附件、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 用了"禁止调用工具"的强硬措辞
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.ts、autoCompact.ts、microCompact.ts、toolResultStorage.ts、compact.ts、sessionMemory*.ts这些文件里的行为 - 但这份公开源码里,
services/contextCollapse/*、reactiveCompact.ts、cachedMicrocompact.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 利用效率"的工程产物。当窗口足够大到不需要压缩时,这一切都不需要。但在当前的技术条件下,这可能是最精细的上下文管理方案之一。