深度解读 Claude Code 的 Context Engineering:一个注意力预算管理系统的设计哲学
从 Claude Code 2.1.88 源码出发,解剖其 Context Engineering 机制的设计哲学:多层级注意力预算管理、prompt cache 经济学、分层压缩策略,以及设计断裂点分析。
大多数人对 context window 的理解是错的
当开发者谈论 LLM 的 context window 时,脑子里想的是"内存"——一块连续的存储空间,能放多少就放多少,放不下就报错。Context window is 200K tokens? 那就是 200K 的内存条。
这个心智模型是错的。
Claude Code 的源码揭示了一个更精确的比喻:context window 不是内存,是注意力预算。就像一个人一天只有 16 小时的清醒时间,不是所有事情都值得花注意力去想。Claude Code 的 Context Engineering 系统做的事情,本质上是在一个有限的认知窗口里持续回答一个问题:什么值得被记住?
这套系统的精妙之处不在于压缩算法有多先进,而在于它对"记忆的经济学"的深度理解——不同类型的上下文有截然不同的衰减速率和召回价值,系统据此构建了一整套分层的缓存失效策略。一条 CLAUDE.md 指令的生命周期可能跨越整个会话,而一次 grep 的输出可能在三轮对话后就毫无价值。Claude Code 的设计就是围绕这个不对称性展开的。
所以,这篇文章不会带你"遍历源码"。我们要搞清楚的是:在一个 200K token 的注意力预算里,Claude Code 是怎么做分配决策的,为什么这样做,以及这套决策系统在什么条件下会崩溃。
抽象模型:上下文的经济学
在写第一行代码之前,让我们先建立一个抽象模型。
想象你是一个项目经理,手下有一个记忆力有限的天才工程师。每次你跟他开会,他只能记住最近 N 小时的对话内容。你需要设计一套"信息喂养策略":
- 身份信息(他叫什么名字、在哪个团队、遵循什么编码规范)几乎永远不变——应该刻在脑子里,不占对话时间
- 项目上下文(当前分支状态、最近的 commit)在会话开始时有用,之后逐渐过时
- 工作中间产物(刚读的文件内容、搜索结果)有即时价值,但衰减极快
- 对话历史(他之前做了什么、用户说了什么)价值随时间递减,但不能完全丢弃
现实世界里,这四类信息的"保质期"差异巨大。如果你像对待内存一样对待 context window——先进先出,满了就裁——你会在最关键的时刻丢失最重要的上下文。
Claude Code 的解法是:给不同类型的信息设计不同的生命周期管理策略。
┌─────────────────────────────────────────────────────────────┐
│ Context Window (~200K tokens) │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ System Prompt (静态层) │ │
│ │ ┌────────────┐ ┌──────────┐ ┌──────────────────────┐ │ │
│ │ │ Attribution │ │ CLI Prefix│ │ Core Instructions │ │ │
│ │ │ (uncached) │ │ (org) │ │ (global/org cache) │ │ │
│ │ └────────────┘ └──────────┘ └──────────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ User Context (半静态层) │ │
│ │ CLAUDE.md + MEMORY.md + git status + date │ │
│ │ → <system-reminder> 标签包裹,isMeta 标记 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Conversation (动态层) │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │ Turn 1 │ │ Turn 2 │ │ Turn 3 │ │ Turn N │ │ │
│ │ │ (stale) │ │ (stale) │ │(recent)│ │(active)│ │ │
│ │ └────────┘ └────────┘ └────────┘ └────────┘ │ │
│ │ ↑ microcompact ↑ autocompact │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Reserved (输出预留) │ │
│ │ ~20K tokens for model response + summary │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
这个分层模型是理解后续所有设计决策的钥匙。接下来让我们看看源码里这个模型长什么样。
第一层:静态上下文的装配线
System Prompt 的三明治结构
Claude Code 的 system prompt 不是一整块文本,而是一条精心分区的装配线。每一块都有明确的 cache scope 标记,决定了它在 Anthropic API 服务端的缓存粒度。
splitSysPromptPrefix() (src/utils/api.ts:321-435) 将 system prompt 切分成最多四个 block:
- Attribution header (
cacheScope: null) — 计费指纹,不缓存 - CLI prefix (
cacheScope: 'org') — 标识 Claude Code 身份的短字符串 - Static content (
cacheScope: 'global') — 核心指令、工具使用规范、代码规范。这部分跨用户、跨组织共享缓存 - Dynamic content (
cacheScope: null) — 动态生成的内容,每次都重新计算,不缓存
这里有一个关键的设计判断:SYSTEM_PROMPT_DYNAMIC_BOUNDARY 标记。系统在 prompt 数组中插入一个边界标记,把"不会变的"和"会变的"分开。边界标记之前的内容可以用 global scope 缓存(所有用户共享),之后的内容不缓存。
System Prompt = [attribution] + [prefix] + [--- 静态 ---BOUNDARY--- 动态 ---]
uncached org global uncached
为什么这么做?因为 prompt cache 是 Claude Code 最重要的隐性经济约束。每次 API 调用,没有命中缓存的 token 需要全额付费作为 cache_creation。一个典型的 system prompt 可能有 15K-20K token——如果每次都重新创建缓存,成本会非常高。把核心指令标记为 global 意味着整个 Anthropic 平台上所有 Claude Code 用户可以共享同一份缓存。
但如果你挂载了 MCP server 呢? MCP 工具的 schema 会被注入到 tool 列表中,而 tool 列表的 hash 变化会打破 prompt cache。所以当 MCP 工具存在时,系统自动降级到 org scope——你的组织内还能共享,但不再跨组织了 (src/utils/api.ts:326-360)。
这个设计揭示了一个深层 trade-off:上下文的可定制性与缓存效率是对立的。你的 system prompt 越个性化,能共享的缓存就越少,每次 API 调用的成本就越高。
CLAUDE.md 的层级发现机制
CLAUDE.md 是用户注入自定义指令的主要入口。但它不是单个文件——它是一棵树。
发现顺序 (src/utils/claudemd.ts:790-1075) 从低优先级到高优先级:
/etc/claude-code/CLAUDE.md— 企业管理员策略~/.claude/CLAUDE.md— 用户全局指令./CLAUDE.md、./.claude/CLAUDE.md、./.claude/rules/*.md— 项目指令(从 CWD 向上遍历到根目录)./CLAUDE.local.md— 本地私有指令(不提交到 git)MEMORY.md— 自动记忆系统入口
这些文件最终通过 getUserContext() (src/context.ts:155-189) 聚合成一个 key-value 对象,然后被 prependUserContext() (src/utils/api.ts:449-474) 包裹在 <system-reminder> 标签里,作为对话的第一条 user message 注入:
createUserMessage({
content: `<system-reminder>
As you answer the user's questions, you can use the following context:
# claudeMd
${claudeMd}
# currentDate
Today's date is 2026/04/07.
IMPORTANT: this context may or may not be relevant...
</system-reminder>`,
isMeta: true, // UI 可以过滤掉这条消息
})
注意这里的设计选择:CLAUDE.md 的内容不在 system prompt 里,而在第一条 user message 里。为什么?因为 system prompt 的变化会打破整个 prompt cache。而 user message 的变化只影响从该 message 之后的缓存。通过把频繁变化的用户配置放在 user message 层(最后一条 system prompt block 之后),系统保护了 system prompt 的 cache 命中率。
这就像把易变的食材放在冰箱门上,把不变的调料放在冰箱深处——靠近门的东西拿取方便但容易被替换,深处的东西稳定不动。
所以 Claude Code 的静态上下文装配,核心逻辑不是"怎么把信息塞进去",而是**"怎么在塞信息的同时不打破缓存"**。每一个设计决策——分区、scope 分级、boundary marker、user message 注入——都在服务于这个经济学约束。
System Prompt Section 的缓存机制
systemPromptSections.ts 提供了两种 prompt section 构造器:
systemPromptSection(name, compute)— 计算一次,缓存到/clear或/compactDANGEROUS_uncachedSystemPromptSection(name, compute, reason)— 每轮重新计算,会打破 prompt cache
"DANGEROUS" 这个前缀不是吓你的。它意味着:每次这个 section 的值变化,服务端缓存的整个 system prompt prefix 都要重新创建。函数签名强制你传一个 reason 参数,解释为什么不得不打破缓存。
这个 API 设计本身就是一个工程哲学的体现:让昂贵的操作在代码层面看起来就很昂贵。
第二层:工具结果的新陈代谢
如果说 system prompt 是骨架,工具结果就是肌肉——它们是对话中最大、最多、衰减也最快的 token 消耗者。一次 grep 可能返回数千行,一次文件读取可能吃掉 5000 token,而这些结果在三轮对话后大概率已经没有参考价值了。
Claude Code 为此设计了一个精细的"新陈代谢"系统——不是简单地删除旧结果,而是根据不同条件选择不同的代谢路径。
Microcompact 的三条路径
microcompactMessages() (src/services/compact/microCompact.ts:253-293) 是工具结果清理的入口。它有三条路径,优先级从高到低:
路径一:Time-based Microcompact(冷缓存路径)
当最后一条 assistant 消息距今超过阈值(默认 60 分钟),系统判定服务端 prompt cache 已过期。此时做什么都会导致缓存重建,所以不如趁机把旧工具结果全部清掉。
// microCompact.ts:446-529
const gapMinutes = (Date.now() - lastAssistantTimestamp) / 60_000
if (gapMinutes >= config.gapThresholdMinutes) {
// 保留最近 N 个工具结果,其余替换为占位符
const keepSet = new Set(compactableIds.slice(-keepRecent))
// 直接修改 message content → '[Old tool result content cleared]'
}
关键细节:keepRecent 的下限是 Math.max(1, config.keepRecent)——至少保留一个,因为 slice(-0) 返回整个数组(JavaScript 的经典陷阱),而清除所有结果会让模型失去工作上下文。
路径二:Cached Microcompact(热缓存路径)
当 prompt cache 还活着时(最近 60 分钟内有过 API 调用),系统使用 cache_edits API——一个不修改本地消息内容、而是告诉服务端"删除缓存中这些 tool_use_id 对应的 token"的机制。
这条路径的精妙在于:它不改变本地消息。本地的对话历史保持完整,但发送给 API 的请求中附带了一个 cache_edits block,指示服务端从缓存副本中移除指定的工具结果。这样既减少了输入 token,又保持了缓存前缀的连续性。
// microCompact.ts:305-399 (cachedMicrocompactPath)
// 不修改 messages,而是生成 pendingCacheEdits
// API 层在构建请求时注入 cache_edits block
return {
messages, // 原封不动
compactionInfo: {
pendingCacheEdits: {
trigger: 'auto',
deletedToolIds: toolsToDelete,
baselineCacheDeletedTokens: baseline,
},
},
}
路径三:不做任何事
如果不在主线程(子 agent、session_memory agent 等),或者 cached microcompact 不可用,就什么都不做——把压力留给下一层的 autocompact。
哪些工具的结果可以被代谢?
不是所有工具结果都一样。COMPACTABLE_TOOLS 白名单 (microCompact.ts:41-50) 列出了可以被清理的工具:
FileRead, Shell (Bash), Grep, Glob, WebSearch, WebFetch, FileEdit, FileWrite
注意这里没有 AgentTool、TaskTool 等控制流工具。这些工具的结果包含了任务编排的元信息——删掉它们可能导致模型在后续轮次中失去对任务流程的理解。
这就是"新陈代谢"而非"垃圾回收"的含义:系统不是在找"最老的"信息来删除,而是在判断"这条信息的类型是否允许它被遗忘"。
所以 Claude Code 对工具结果的管理策略可以总结为一句话:缓存热时用 cache_edits 无痛瘦身,缓存冷时趁机大扫除,始终只清理那些"读过就用过"的一次性结果。
第三层:对话压缩的不可能三角
当工具结果的微观代谢不够用时——整个对话的 token 数逼近 context window 上限——系统需要做一件更激烈的事:压缩整段对话历史。
这里存在一个不可能三角:信息保真度、压缩速度、成本——三者最多取其二。Claude Code 的解法是提供三种压缩策略,各自在三角中选择了不同的位置。
触发阈值:一套精心校准的数字
在深入三种策略之前,先看触发机制 (autoCompact.ts:62-65):
const AUTOCOMPACT_BUFFER_TOKENS = 13_000 // 自动压缩缓冲区
const WARNING_THRESHOLD_BUFFER_TOKENS = 20_000 // 黄色警告
const ERROR_THRESHOLD_BUFFER_TOKENS = 20_000 // 红色警告
const MANUAL_COMPACT_BUFFER_TOKENS = 3_000 // 硬阻断限制
有效 context window = 模型上下文窗口 − max(模型最大输出 token, 20000)。对于 200K 上下文的 Opus,有效窗口约 180K。
- ~93% 满 (180K - 13K = 167K):触发 autocompact
- ~89% 满 (167K - 20K = 147K):用户看到黄色警告
- ~98% 满 (180K - 3K = 177K):硬阻断,拒绝用户输入
这些数字不是随意选的。13K 的缓冲区意味着系统在"还有大约一轮完整工具调用的空间"时触发压缩。太早压缩浪费信息,太晚压缩可能导致 API 返回 prompt_too_long 错误。
策略一:Session Memory Compaction(快速、低保真)
这是一个实验性功能 (sessionMemoryCompact.ts),优先于传统压缩执行。
核心思想:不发送 API 请求来生成摘要,而是使用后台持续提取的 session memory 文件作为压缩后的上下文。
// sessionMemoryCompact.ts:57-61
const DEFAULT_SM_COMPACT_CONFIG = {
minTokens: 10_000, // 至少保留 10K token 的原始消息
minTextBlockMessages: 5, // 至少保留 5 条有文本的消息
maxTokens: 40_000, // 最多保留 40K token 的原始消息
}
算法从 lastSummarizedMessageId 开始向前扩展,直到满足最小保留要求或达到上限。一个微妙的处理是 adjustIndexToPreserveAPIInvariants() (sessionMemoryCompact.ts:232-314)——确保不会把 tool_use 和对应的 tool_result 拆散。API 要求每个 tool_result 都有匹配的 tool_use,如果压缩边界恰好落在一对 tool 调用中间,会导致 API 报错。
这个策略的 trade-off 很明确:零额外 API 成本(不需要调用 Claude 生成摘要),但信息保真度取决于后台 session memory 提取的质量——而那个提取过程本身就是异步的、有延迟的。
策略二:Legacy Full Compaction(慢、高保真)
传统策略 (compact.ts:387+):把整段对话发给一个 forked agent,让 Claude 自己生成结构化摘要。
摘要 prompt (compact/prompt.ts) 要求生成 9 个章节:Primary Request、Key Technical Concepts、Files and Code、Errors and Fixes、Problem Solving、All User Messages、Pending Tasks、Current Work、Optional Next Step。
一个值得注意的工程技巧:摘要 prompt 要求模型先在 <analysis> 标签内写思考过程,再在 <summary> 标签内写最终摘要。formatCompactSummary() 随后把 <analysis> 部分整个删掉——它只是一个"草稿纸",用来提升摘要质量但不进入最终上下文。
// prompt.ts:311-335
function formatCompactSummary(summary: string): string {
// 删掉 analysis 草稿——它提升了摘要质量但不值得保留
formattedSummary = formattedSummary.replace(
/<analysis>[\s\S]*?<\/analysis>/, ''
)
// 提取 summary 正文
const summaryMatch = formattedSummary.match(/<summary>([\s\S]*?)<\/summary>/)
// ...
}
压缩后,系统不是简单地用摘要替换一切。buildPostCompactMessages() (compact.ts:330-338) 的重建顺序是:
boundaryMarker → summaryMessages → messagesToKeep → attachments → hookResults
其中 attachments 包括:
- 最近读过的文件(最多 5 个,每个最多 5K token)
- 活跃计划文件
- 已调用的 skill 内容(每个最多 5K token,总预算 25K token)
- deferred tools 的 delta announcement
- MCP instructions 的 delta announcement
这套重建逻辑揭示了另一个设计判断:压缩不是终点,重建才是。系统知道压缩会丢信息,所以在压缩后立刻把"最可能需要的"信息重新注入。总重建预算是 50K token (POST_COMPACT_TOKEN_BUDGET)——大约占有效窗口的 28%。
策略三:Reactive Compact(最后防线)
当前两种策略都失败或来不及触发,API 返回 prompt_too_long 413 错误时,reactive compact 接管。它做的事情更粗暴:从最老的 API round group 开始删除,直到腾出足够空间。
还有一个精心设计的熔断机制 (autoCompact.ts:70):
const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3
注释里的数据很有说服力:
BQ 2026-03-10: 1,279 sessions had 50+ consecutive failures (up to 3,272) in a single session, wasting ~250K API calls/day globally.
没有熔断机制时,上下文不可恢复地超限的会话会在每一轮都尝试压缩——每次都失败——每天浪费 25 万次 API 调用。三次失败后停止重试,是用数据驱动的工程判断。
对话压缩的三种策略,本质上是在"用 token 买信息保真度"这条轴上的不同位置:Session Memory 免费但粗糙,Full Compact 昂贵但精确,Reactive Compact 是在悬崖边的最后一把。
Prompt Cache:隐藏的经济约束
前面反复提到 prompt cache,现在该正面解剖它了。Prompt cache 不只是一个性能优化——它是 Claude Code 所有上下文管理决策背后的隐藏约束条件。
为什么 cache 如此重要
每次 API 调用的成本可以分解为:
- cache_read: 命中缓存的 token,极低成本
- cache_creation: 没命中、需要写入缓存的 token,高成本
- input: 既没命中也不写缓存的 token,中等成本
一个典型的 Claude Code 会话,system prompt + 工具 schema 可能有 30K-50K token。如果每次调用都是 cache miss,这部分的成本会非常可观。所以 Claude Code 的几乎每一个设计决策,都在回答同一个问题:怎么让这 30K-50K token 尽可能多地命中缓存?
Cache Break Detection:侦探系统
promptCacheBreakDetection.ts (727 行) 是一个完整的"缓存失效侦探系统"。它追踪所有可能导致缓存失效的因素:
// promptCacheBreakDetection.ts:29-69
type PreviousState = {
systemHash: number // system prompt 内容 hash
toolsHash: number // 工具 schema hash
cacheControlHash: number // cache scope/TTL 变化
model: string // 模型切换
fastMode: boolean // fast mode 切换
globalCacheStrategy: string // 'tool_based' | 'system_prompt' | 'none'
betas: string[] // beta header 列表
effortValue: string // reasoning effort 变化
extraBodyHash: number // 额外 body 参数变化
// ...
}
每次 API 调用后,系统比较当前状态与上次状态。如果发现 cache_read_input_tokens 突然下降,就分析是哪个因素导致了缓存失效,并记录到 analytics。
这不是防御性编程——这是运营观测。Anthropic 工程团队用这些数据来发现和修复导致不必要缓存失效的 bug。比如注释中提到:
BQ 2026-03-01: missing this [notification] made 20% of tengu_prompt_cache_break events false positives
Latch 模式:只升不降
一些会打破缓存的 header(如 AFK mode、cache editing beta)使用了"sticky latch"模式:一旦开启就不再关闭。因为 header 列表的变化会改变 cache key,反复切换 = 反复打破缓存。Latch 确保它只变化一次(从 off 到 on),之后稳定。
这是一个反直觉的设计:为了全局的缓存效率,放弃了局部的精确控制。
多层 Cache Scope
System prompt 的缓存分三个 scope:
- global: 全平台共享。核心指令不会因为你的项目而变化——所有 Claude Code 用户看到的都一样
- org: 组织内共享。加了 MCP 工具后降级到这里
- null (uncached): 每次都重新处理。动态内容、Attribution header
这解释了为什么 system prompt 要用 boundary marker 分区——不是为了代码组织,而是为了精确控制缓存粒度。每一个字符的变化都可能传播为一次 cache miss,而一次 cache miss 可能意味着 30K+ token 的 cache_creation 成本。
所以 prompt cache 不是"有了更好"的优化,它是 Claude Code 经济模型的基石。整个 Context Engineering 系统的分层设计——system prompt 的分区、CLAUDE.md 放在 user message 而非 system prompt、microcompact 的 cache_edits 路径、压缩后重建逻辑——每一层都在围绕"不要打破缓存"这个经济学约束做设计。
应力测试:设计在哪里会断裂
好的工程分析不只说系统怎么工作,更要说什么条件下它会失败。
断裂场景一:MCP 工具的缓存污染
每添加一个 MCP server,其工具 schema 就会被注入到 tool 列表中。Tool schema 的 hash 变化会立即打破 prompt cache。如果一个 MCP server 的工具列表不稳定(比如动态注册/注销工具),每次变化都是一次全额 cache_creation。
更微妙的是:MCP 工具的描述变化也会打破缓存。tool schema 被 hash 后对比 (promptCacheBreakDetection.ts:37-38),description 的任何修改都会被检测到。如果你的 MCP server 在描述里嵌入了时间戳或版本号——恭喜,你的缓存命中率归零。
断裂场景二:Session Memory 与 Full Compact 的竞态
Session Memory Compaction 依赖后台异步提取的 session memory 文件。如果提取延迟过高(比如模型响应慢),waitForSessionMemoryExtraction() 会超时,然后 fallback 到 Full Compact。但 Full Compact 本身也是一次 API 调用——在 context 已经接近上限时发起的 API 调用。如果这次压缩请求本身也触发 prompt_too_long,系统会进入 PTL retry 循环 (compact.ts:450-491),从最老的 API round 开始削减,最多重试 3 次。
最坏情况:Session Memory 提取失败 → Full Compact 发起 → PTL 报错 → 重试 3 次都 PTL → 抛出 ERROR_MESSAGE_PROMPT_TOO_LONG → 用户被告知"按两次 Esc 然后重试"。
断裂场景三:工具结果积累的速度超过代谢速度
Microcompact 的 time-based 路径需要 60 分钟的空闲间隔才会触发。Cached microcompact 需要 CACHED_MICROCOMPACT feature flag 开启。如果两者都不可用(比如非 Anthropic 第一方用户、使用第三方 proxy),工具结果只能靠 autocompact 来清理。
但 autocompact 在 ~93% 满时才触发。如果一个密集的编码会话在短时间内产生大量工具调用(每次 Read + Edit + Bash 就是三个工具结果),token 积累速度可能快到在 autocompact 触发前就逼近硬阻断限制。
断裂场景四:压缩后信息的不可逆丢失
Full Compact 后,系统重新注入最近读过的 5 个文件(每个最多 5K token)。但如果你在 20 轮前读了一个关键的配置文件、此后一直在基于它做决策——压缩后这个文件的内容就消失了。模型的摘要里可能会提到"读了 config.yaml",但具体内容不会被保留。
这是压缩的根本性 trade-off:摘要保留了"做了什么"的事实,但丢失了"基于什么信息做的"的细节。
面试深度拷打
Q1: Claude Code 的 system prompt 为什么要分成多个 block?
表面答案: 为了代码组织和可维护性。
源码级答案: 为了 prompt cache 的分级缓存策略。splitSysPromptPrefix() 将 system prompt 切分成最多 4 个 block,每个 block 带有独立的 cacheScope 标记(global/org/null)。Static content 标记为 global 可以跨所有用户共享缓存,dynamic content 标记为 null 避免污染缓存前缀。SYSTEM_PROMPT_DYNAMIC_BOUNDARY 标记是切分的关键——它决定了哪些 token 可以被全局缓存。当 MCP 工具存在时,global scope 降级为 org scope,因为 MCP 工具的 schema 注入改变了 tool 列表的 hash。
追问链: → 为什么 MCP 工具的存在会导致 cache scope 降级? → tool schema 的 hash 是怎么计算的?哪些变化会触发重算? → 如果一个 MCP server 的工具描述每次都不同(嵌入了时间戳),对系统有什么影响? →
DANGEROUS_uncachedSystemPromptSection的reason参数有什么作用?它被记录到哪里?
Q2: CLAUDE.md 的内容为什么放在 user message 而不是 system prompt 里?
表面答案: 为了把用户指令和系统指令分开。
源码级答案: 为了保护 system prompt 的 prompt cache 命中率。prependUserContext() (api.ts:449-474) 将 CLAUDE.md 内容包裹在 <system-reminder> 标签里作为第一条 isMeta: true 的 user message 注入。如果把这些内容放在 system prompt 里,每次用户修改 CLAUDE.md(甚至不同项目的 CLAUDE.md 不同)都会打破 system prompt 的 cache prefix,导致全量 cache_creation。放在 user message 层意味着 system prompt 的缓存保持稳定,只有 user message 之后的增量需要重新处理。
Q3: Autocompact 为什么有熔断机制?3 次的阈值是怎么来的?
表面答案: 防止无限重试。
源码级答案: 源自生产数据驱动的决策。MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3 (autoCompact.ts:70),注释中引用了真实数据——"BQ 2026-03-10: 1,279 sessions had 50+ consecutive failures (up to 3,272) in a single session, wasting ~250K API calls/day globally"。没有熔断时,context 不可恢复地超限的会话会在每轮都触发压缩尝试并失败,每天全球浪费 25 万次 API 调用。3 次是"给系统足够的重试机会但不浪费资源"的工程平衡点。circuit breaker 状态通过 AutoCompactTrackingState.consecutiveFailures 在 query loop 的每轮之间透传。
追问链: → 压缩失败后,会话会处于什么状态?用户看到什么? → Reactive compact 是熔断后的 fallback 吗?它和 autocompact 的关系是什么? →
CONTEXT_COLLAPSEfeature flag 开启时,autocompact 为什么被完全禁用?两者是什么竞争关系?
Q4: Post-compact 重建为什么要注入最近读过的文件?预算是怎么分配的?
表面答案: 保留上下文连续性。
源码级答案: 压缩的固有缺陷是丢失"raw data"——摘要保留了决策和行为的描述,但丢失了做决策时依据的原始数据。createPostCompactFileAttachments() 从 readFileState cache(一个记录了本会话所有文件读取操作的 LRU map)中取最近 5 个不同的文件,每个截断到 5K token。总预算 50K token (POST_COMPACT_TOKEN_BUDGET),其中文件占最多 25K (5×5K),skills 占最多 25K (POST_COMPACT_SKILLS_TOKEN_BUDGET,每个 skill 最多 5K)。此外还会重新注入活跃的 plan 文件、deferred tools 的 delta announcement、MCP instructions 的 delta announcement。这些数字是经验校准的——50K 大约占有效窗口的 28%,在"保留足够上下文"和"给新对话留足够空间"之间取平衡。
Q5: Microcompact 的 cached 路径和 time-based 路径有什么区别?为什么不统一?
表面答案: 是同一个功能的两种触发方式。
源码级答案: 是两种完全不同的机制,服务于不同的缓存状态。Cached microcompact(cachedMicrocompactPath)假定服务端 prompt cache 是热的,通过 cache_edits API 在不改变本地消息的情况下指示服务端从缓存副本中删除指定的 tool_result——本地消息不变,缓存前缀不打破。Time-based microcompact(maybeTimeBasedMicrocompact)假定缓存已冷(距上次调用 >60 分钟),直接修改本地消息内容,把旧工具结果替换为 '[Old tool result content cleared]'。两者不能统一的原因是:改变消息内容会改变缓存 key,在热缓存下这么做会打破缓存;不改变消息内容在冷缓存下是无效的(反正要重建缓存,不如直接删内容减少 token 数)。它们是对同一个问题在两种边界条件下的不同最优解。
工程启示:可迁移的设计原则
原则一:让昂贵的操作在代码层面看起来就很昂贵
DANGEROUS_uncachedSystemPromptSection 不是过度设计——它是一个 API 设计模式:当一个操作有隐性成本时,让调用者在写代码时就感受到这个成本。类似的模式可以用在:数据库写操作、网络请求、文件 I/O 等任何有隐性代价的地方。不用 @dangerous 注解,直接在函数名里写明。
适用场景: 你的系统中有某些操作看起来便宜(只是改个字符串)但实际上很贵(打破全局缓存)。 不适用场景: 所有操作都差不多贵的系统——全标 DANGEROUS 等于没标。
原则二:分层衰减优于统一淘汰
Claude Code 不用 LRU 来管理 context,而是根据信息类型分配不同的生命周期管理策略。这个模式适用于任何"容量有限但内容异质"的系统:CDN 缓存(静态资源和动态 API 响应的 TTL 应该不同)、数据库连接池(长事务和短查询的超时应该不同)、甚至团队管理(不同层级的信息需要不同的同步频率)。
核心判断: 如果你系统中不同类型的数据有显著不同的"保质期",统一的 TTL/LRU 策略一定是次优的。
原则三:用数据驱动 circuit breaker 的阈值
MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3 这个数字,不是拍脑袋想的。它来自对生产环境中 1,279 个 session 的行为分析。设计 circuit breaker 时,不要用"感觉合理"的数字——部署后收集数据,然后用数据校准。
关键指标: 没有熔断时的浪费量 (250K API calls/day) vs 有熔断后可能的误杀率。选择让总成本最小的阈值。
原则四:压缩后的重建和压缩本身一样重要
Claude Code 花了 50K token(约占有效窗口的 28%)在 post-compact 重建上。这不是浪费——这是对"压缩必然丢信息"这个事实的正面应对。如果你的系统有数据压缩/归档步骤,永远要问:压缩后,什么信息需要被立刻重建?不要等到用户抱怨"上下文丢了"才想起来。