Claude Code 源码深度解析:拆解上下文的组成与缓存

0 阅读40分钟

Hi,大家好,欢迎来到维元码簿。

本文属于 《Claude Code 源码 Deep Dive》 系列。如果你对整体模块地图感兴趣,可以先看Claude Code 源码架构概览:51万行代码的模块地图

本文聚焦一件事:模型的上下文到底是什么,由什么组成,怎么组装出来的。

为什么这件事重要?因为 Agent 的每一次决策都只看得到上下文。模型不知道你的项目结构、不知道你刚才改了什么文件、不知道有哪些工具可以用——除非你在上下文里告诉它。上下文质量直接决定行为质量,上下文编排就是 Claude Code 工程体系里最精密的部分。

读完全文,你将能回答这几个问题:

  • 模型是怎么“看到”你的对话的?你的项目信息呢? 你的文字、项目结构、工具能力,是怎么混在一起送到模型眼前的?
  • Claude Code 专业沉稳的“人设”是谁写的? 没有一个人定义了它,那它是怎么被塑造出来的?
  • 你只打了一句话,模型实际收到了多少信息? 工具描述、CLAUDE.md、系统提醒……这些内容是怎么和你的文字混在一起的?
  • 同一个项目聊了50轮,每一轮的 API 费用都一样吗? 系统在背后做了什么,让你感觉不到成本在翻倍?

我们会从一条真实的 API 调用出发,拆开请求参数,反推出上下文的三大板块,然后逐章深入每个板块的内部结构。至于上下文的优化——缓存、压缩、记忆——那些是后续文章的主题,本文只在必要的地方点到为止。

好,让我们开始。

本篇覆盖的源码范围

在看细节之前,先给一个全局地图。本篇涉及的源码分布和代码量:

模块核心文件代码行职责
System Prompt 组装src/constants/prompts.ts~915 行静态/动态区段组装、所有 section 定义
System Prompt 优先级src/utils/systemPrompt.ts~124 行Override > Coordinator > Agent > Custom > Default
System Prompt Sections 缓存src/constants/systemPromptSections.ts~69 行Memoized vs DANGEROUS_uncached 两种缓存策略
上下文获取src/context.ts~190 行UserContext(CLAUDE.md + 日期)、SystemContext(Git 状态)
上下文拼装src/utils/queryContext.ts~180 行fetchSystemPromptParts 并行获取三大片段
消息处理src/utils/messages.ts~5500 行消息规范化、API 映射、Attachments 展开
API 请求构建src/services/api/claude.ts~3420 行queryModel() 核心函数、paramsFromContext() 最终组装
缓存切分src/utils/api.ts~719 行splitSysPromptPrefix()、缓存控制标记
工具 Schema 缓存src/utils/toolSchemaCache.ts~27 行会话级 Schema 字节锁定

总计约 7000+ 行核心代码,横跨 9 个文件。本篇会逐层拆解这些代码做了什么。

从一条 API 调用出发——上下文全景图

我们这篇文章的定位是上下文工程——拆解模型每次调用时 Claude Code 到底组装了什么。那最自然的起点,就是找到那个组装的终点:实际发给 Anthropic API 的请求参数。

从 callModel 到 paramsFromContext

一切从 src/query.ts 第 659 行开始。当你输入一条消息后,Agent 循环最终会走到这里:

// src/query.ts L659
const result = await deps.callModel({
  messages: prependUserContext(messagesForQuery, userContext),
  systemPrompt: fullSystemPrompt,
  tools,
  ...options,
})

追踪链:deps.callModelqueryModel()paramsFromContext()src/services/api/claude.ts L1538-1729)。最终由 paramsFromContext() 返回发给 API 的完整参数,拆开看看:

// src/services/api/claude.ts L1699-1729(简化)
return {
  model: normalizeModelStringForAPI(options.model),  // 用哪个模型
  messages: addCacheBreakpoints(messagesForAPI, ...), // ← 模型上下文
  system,                                             // ← 模型上下文
  tools: allTools,                                    // ← 模型上下文
  tool_choice: options.toolChoice,                    // 限制工具选择
  betas: betasParams,                                 // 启用哪些实验功能
  metadata: getAPIMetadata(),                         // 计费/追踪元数据
  max_tokens: maxOutputTokens,                        // 最大输出长度
  thinking,                                           // 思维链配置
  temperature,                                        // 采样温度
  context_management: contextManagement,              // cache editing 指令
  output_config: outputConfig,                        // 输出格式/effort
  speed,                                              // 快模式开关
}

这么多字段,模型到底能看到什么?

模型的上下文输入:System Prompt、Messages、Tools

结论先行:模型能看到的上下文只有三个板块——System Prompt、Messages、Tools。

paramsFromContext() 返回的 JSON 中,真正变成 token 喂给模型的只有这三个字段:

字段作用预估占比
system告诉模型"你是谁、怎么做事"的指令集~30%
messages对话历史:用户输入、模型回复、工具调用结果~60%
tools工具 Schema:告诉模型可以调用哪些工具~10%

其余字段(modelmax_tokensthinkingbetas 等)都是服务端配置,控制模型怎么回答而非看到什么,不进入 token 序列。

把三个板块合在一起,模型看到的上下文全景如下:

在这里插入图片描述

三个板块各是什么:

  • System Promptsystem,~30%):模型的“身份设定与环境感知”,由 7 个静态模块 + 11+ 个动态模块组装成 string[]。静态区所有用户共享,可命中全局缓存;动态区因会话变化,以 DYNAMIC_BOUNDARY 为分界线。总入口 getSystemPrompt(),但可能被优先级链(Override > Coordinator > Agent > Custom > Default)替换。
  • Messagesmessages,~60%):模型的“对话与上下文记忆”,承载用户输入、工具结果、附件展开、隐藏注入等全部对话信息。内部维护 5 种类型,经 normalizeMessagesForAPI() 清洗后只留 user/assistant 两种。关键注入点:UserContext(CLAUDE.md+日期)注入 messages[0]、Attachments 展开为 UserMessage、tool_use/tool_result 精确配对。
  • Toolstools,~10%):30+ 内置工具 + MCP 外部工具的 JSON Schema。支持延迟加载(defer_loading),不常用工具只发精简描述。有 MCP 工具时 System Prompt 跳过全局缓存,避免不同用户的工具 Schema 冲突。

接下来四章分别深入拆解 System Prompt、Messages、Tools 和上下文缓存。

System Prompt——模型的“身份设定与环境感知”

全景图告诉我们 System Prompt 大约占模型上下文的 30%。但 30% 背后的工程量,远比数字显示的更精密。System Prompt 不是一段写死的文本,而是一个动态组装的指令集——根据用户类型、运行模式、工具配置、MCP 连接状态实时拼装。

这一章我们从源码出发,拆开 System Prompt 的每一个零件。

本章拆解约 1200 行核心代码:src/constants/prompts.ts(~915 行,7 静态 + 11+ 动态模块组装)、src/utils/systemPrompt.ts(~124 行,优先级链决策)、src/constants/systemPromptSections.ts(~69 行,Section 缓存框架),外加 src/utils/api.ts 中的缓存切分和上下文追加。

总入口是 getSystemPrompt()src/constants/prompts.ts L444),返回 string[]——数组而非大字符串,目的是让后续缓存切分逻辑按元素粒度标记边界。标准模式下的组成结构如下:

在这里插入图片描述

如上图所示,中间的 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 把 prompt 分为静态区和动态区。静态区对所有用户完全相同,可用 scope: 'global' 跨组织缓存;动态区因会话而异,只能用 scope: 'org' 或逐 turn 重算。团队在设计 prompt 时就把缓存作为一等公民:每个新 section 都必须回答“放在边界之前还是之后?”。

静态区——7 个不变的模块

静态区的内容对所有用户完全相同——你可以和地球另一端的用户共享同一份 KV cache,只要字节一致。这 7 个模块构成了 Claude Code 的“人格基础”。

在这里插入图片描述

如上图所示,7 个模块按功能分层排列,从身份声明到输出效率逐层递进。其中 Doing Tasks 和 Actions 是最重要的两个模块——前者定义了模型“怎么写代码”,后者定义了模型“什么时候该慢下来”。

模块核心原文工程洞察
Intro / 身份声明You must NEVER generate or guess URLs“安全壳”设计——编程场景中模型编造 URL 可导致访问恶意网站。内部版有额外 CYBER_RISK_INSTRUCTION,外部构建时被 DCE 移除
System / 系统行为Your conversation is not limited by the context window告诉模型有自动压缩兆底,不会因“怕用完 token”而拒绝复杂任务。<system-reminder> 标签解释让模型区分系统注入和用户消息
Doing Tasks / 任务执行Don't speculate. Three lines of similar code are fine最长的静态模块。“不过度设计”贯穿始终:不 speculative 抽象、不提前加 error handling、不创建一次性 helper。先读后改:没读过的代码不要提修改建议
Actions / 行动准则Consider the reversibility and blast radius of actions如果只读一个模块就读这个。可逆操作自由执行,不可逆/影响他人(push、删分支)必须确认。授权范围必须与请求匹配——批准一次 push 不等于永久授权
Using Tools / 工具使用Prefer dedicated tools over BashBash 最不透明,用户难以审查。Read>cat、Edit>sed、Write>echo。支持并行调用——读 3 个文件并行比顺序快得多
Tone & Style / 语气不用 emoji、代码引用用 file:line、issue 用 owner/repo#123“不在工具调用前加冒号”解决了真实 UX 问题——工具调用可能不显示,加冒号会变成“让我读一下:”后面空白
Output EfficiencyGo straight to the point. Don't be unnecessarily terse外部版偏向简洁,内部 ant 版偏向可理解。过度追求简洁反而增加整体成本——用户需要追问来理解回复

几个值得单独展开的点:

Doing Tasks 的“不过度设计”。这四条规则每条背后都有模型曾犯过的真实错误:加了一个没人要求的特性、为三行相似代码抽了个 helper、在系统边界外加了不必要的 validation。Claude Code 团队选择在 prompt 层面显式禁止这些行为,而不是靠模型“自觉”——这是 prompt 工程的务实态度。

Actions 的“可逆性分级”。Claude Code 对 AI 安全的理解不是限制能力,而是让 AI 在高风险场景下主动慢下来。本地可逆操作自由执行,不可逆操作必须确认——简单但极其有效。更有意思的是“授权范围匹配”:用户批准一次 git push 不等于在所有上下文都批准,这防止了模型把单次授权泛化为永久权限。

Output Efficiency 的“简洁 vs 可理解”博弈。原文 What's most important is the reader understanding your output without mental overhead or follow-ups, not how terse you are——这句话本身就是两个优化目标的平衡。外部版偏简洁,内部版偏可理解,因为内部用户更频繁使用,追问成本更高。

动态区——11+ 个条件模块与 Section 缓存框架

如果说静态区是“所有员工共享的基本守则”,那动态区就是“针对每个岗位的个性化指引”。动态区的每个模块在首次计算后被缓存,直到 /clear/compact 才重置。

在这里插入图片描述

如上图所示,动态区模块按重要级 P0→P3 排列。P0 的三个模块(session_guidance、memory、mcp_instructions)几乎每次会话都会激活;P3 的模块只在特定条件下生效。每个模块都通过 Section 缓存框架包装:

先看缓存框架。动态区的每个模块都用 systemPromptSection()DANGEROUS_uncachedSystemPromptSection() 包装,这两个函数来自 src/constants/systemPromptSections.ts(仅 69 行):

// src/constants/systemPromptSections.ts
// 创建一个带缓存的 section —— 计算一次,缓存到 /clear 或 /compact
export function systemPromptSection(name: string, compute: ComputeFn): SystemPromptSection {
  return { name, compute, cacheBreak: false }
}

// 创建一个不缓存的 section —— 每 turn 重算,会破坏 prompt cache
export function DANGEROUS_uncachedSystemPromptSection(
  name: string, compute: ComputeFn, _reason: string
): SystemPromptSection {
  return { name, compute, cacheBreak: true }
}

解析时(resolveSystemPromptSections()),逻辑很简单:

// L43-58(简化)
export async function resolveSystemPromptSections(sections) {
  const cache = getSystemPromptSectionCache()
  return Promise.all(sections.map(async s => {
    if (!s.cacheBreak && cache.has(s.name)) {
      return cache.get(s.name) ?? null  // 命中缓存,直接返回
    }
    const value = await s.compute()     // 未命中,调用 compute()
    setSystemPromptSectionCacheEntry(s.name, value)
    return value
  }))
}
方法缓存策略何时重算对缓存的影响
systemPromptSection()计算一次,缓存到 /clear/compact会话重置时不影响缓存
DANGEROUS_uncachedSystemPromptSection()每 turn 重算每次 API 调用前可能破坏 prompt cache

有了缓存框架的认知,现在逐个看动态区的模块:

模块重要级缓存类型核心职责关联模块
session_guidanceP0memoized基于工具集生成使用策略(Agent / Explore / Skill / Verification)工具与扩展系统
memoryP0memoized加载 ~/.claude/memory/*.md,跨会话持久知识上下文系统 > 会话记忆管理
mcp_instructionsP0DANGEROUS_uncachedMCP Server 使用说明工具与扩展系统 > MCP 协议实现
env_info_simpleP1memoized环境信息(CWD / 平台 / Shell / 模型名 / 知识截止日期)
output_styleP1memoized自定义输出风格
frcP1memoized告知模型旧工具结果可能被清除上下文系统 > 上下文压缩
summarize_tool_resultsP1memoized提醒模型处理工具结果时记录重要信息上下文系统 > 上下文压缩
languageP2memoized用户语言偏好
scratchpadP2memoizedper-session 临时文件目录指引
token_budgetP2memoized用户指定 token 目标时激活
ant_model_overrideP2memoized内部 ant 用户额外指令覆盖
numeric_length_anchorsP3memoizedant-only,数字化长度锚点(~1.2% 输出 token 减少)
briefP3memoized自主代理模式行为指引(Kairos / Proactive)

缓存策略的演进:从“破坏缓存”到“保护缓存”

动态区模块的缓存策略并非一成不变——核心思路只有一个:尽量不重算,万不得已才重算。

  • session_guidance 是动态区最复杂的模块。内容取决于“这个会话有哪些工具可用”——AgentTool 使用策略(fork vs subagent)、Explore agent 调用时机、Skill 调用指南、Verification agent 协议。因为涉及具体工具列表,不能放静态区,但 memoized 后会话内只算一次。
  • mcp_instructions 是目前唯一使用 DANGEROUS_uncached 的模块——MCP Server 可能随时连接/断开,必须每 turn 重算。但启用 mcp_instructions_delta 后,这个 section 返回 null,改用附件方式注入增量。MCP 状态稳定时不注入任何内容,缓存完全命中。这是从“破坏缓存”到“保护缓存”的典型演进。

在这里插入图片描述

上图展示了 systemPromptSection()DANGEROUS_uncachedSystemPromptSection() 两条路径的区别:前者通过 session 级闭包缓存结果,整个会话只计算一次;后者每 turn 都重新调用工厂函数。绝大多数模块走缓存路径,只有 MCP 指令等少数场景走 uncached 路径。

  • token_budget 曾是 DANGEROUS_uncached(每次 budget 翻转触发重算),后来改为 memoized——因为措辞用了条件句 "When the user specifies...",没有 budget 激活时就是 no-op。这一改节省了约 20K tokens/次的缓存断裂。
  • numeric_length_anchors 只对 ant 用户启用,内部 A/B 测试发现数字化锚点("保持 25 字以内")比定性描述("保持简洁")减少约 1.2% 的输出 token——小优化但跨用户累积效果可观。

上图的 Section 缓存框架是整个缓存策略的基石。下面讲到的优先级链和缓存切分,都建立在 memoized/uncached 的区分之上。

优先级链:System Prompt 的完整决策树

前面看到的 getSystemPrompt() 返回的是“默认”System Prompt。但实际运行中,它只是决策树的一个叶子节点。外层的 buildEffectiveSystemPrompt()src/utils/systemPrompt.ts L41-123)决定了最终使用哪个 prompt:

分支开启条件行为频率
OverrideREPL loop 模式,由内部框架设置直接返回 overrideSystemPrompt,跳过所有默认逻辑极低,内部测试用
CoordinatorFeature gate COORDINATOR_MODE + 环境变量用协调者 prompt 替换默认,用于多 Agent 编排极低,实验功能
Agent + Proactive配置了 Agent prompt + 自主代理模式激活Default 追加 Agent prompt,保留基础行为指引低,Kairos/Proactive 用户
Agent / Custom配置了 Agent prompt 或 Custom promptAgent/Custom 替换 Default,完全接管低,自定义 Agent 用户
Default(标准模式)以上条件均不满足使用 getSystemPrompt() 的 7+11 模块绝大多数用户

Default 分支内部还有两条罕见路径:极简模式CLAUDE_CODE_SIMPLE 环境变量,只返回一行身份声明)和自主代理精简模式(Proactive / Kairos,跳过静态区组装精简指令集)——绝大多数用户不会遇到。

源码逻辑(简化):

// src/utils/systemPrompt.ts L41-123
export function buildEffectiveSystemPrompt({...}): SystemPrompt {
  // 0. Override —— 最高优先级,直接返回
  if (overrideSystemPrompt) {
    return asSystemPrompt([overrideSystemPrompt])
  }
  // 1. Coordinator —— 协调者模式替换默认 prompt
  if (feature('COORDINATOR_MODE') && isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)) {
    return asSystemPrompt([getCoordinatorSystemPrompt(), ...append])
  }
  // 2. Agent + Proactive —— Agent 追加到 Default 之后
  if (agentSystemPrompt && isProactiveActive()) {
    return asSystemPrompt([...defaultSystemPrompt, agentSystemPrompt, ...append])
  }
  // 3. Agent / Custom / Default —— 三选一
  return asSystemPrompt([
    ...(agentSystemPrompt ? [agentSystemPrompt]     // Agent 替换 Default
      : customSystemPrompt ? [customSystemPrompt]   // Custom 替换 Default
      : defaultSystemPrompt),                        // 使用 Default
    ...append])
}

两个关键设计点:

  • Proactive 是追加,标准 Agent 是替换:Proactive 模式下 Agent prompt 追加到 Default 之后,因为自主代理仍需要基础行为指引,只是叠加领域特定指令。标准模式下 Agent 完全接管,用自己的指令体系替代默认的。源码注释:The proactive default prompt is already lean...and agents add domain-specific behavior on top — same pattern as teammates.
  • appendSystemPrompt 总是追加(Override 除外),确保额外内容不遗漏。

缓存边界:SYSTEM_PROMPT_DYNAMIC_BOUNDARY

回到 getSystemPrompt() 的返回结构:

return [
  // --- 静态区(scope: global) ---
  getSimpleIntroSection(outputStyleConfig),
  getSimpleSystemSection(),
  getSimpleDoingTasksSection(),
  getActionsSection(),
  getUsingYourToolsSection(enabledTools),
  getSimpleToneAndStyleSection(),
  getOutputEfficiencySection(),
  // === 边界标记 ===
  ...(shouldUseGlobalCacheScope() ? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY] : []),
  // --- 动态区(scope: org) ---
  ...resolvedDynamicSections,
].filter(s => s !== null)

SYSTEM_PROMPT_DYNAMIC_BOUNDARY 是一个特殊字符串 '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'。它不在模型的"人格指令"中——模型看到的是一个被替换后的分隔。它的真正作用是在 splitSysPromptPrefix()src/utils/api.ts L321-435)中作为缓存切分锚点:

  1. splitSysPromptPrefix() 扫描 string[],找到边界标记的位置
  2. 边界标记之前的所有元素合并为一个 TextBlock,标记 cache_control: { type: 'ephemeral' },scope 为 global
  3. 边界标记之后的所有元素合并为另一个 TextBlock,scope 为 org
  4. 两个 TextBlock 分开发送,API 的 KV cache 就能按不同 scope 缓存

这意味着:静态区可以被全球所有用户共享同一份 KV cache(因为内容完全相同),而动态区只按组织缓存(因为内容因用户/会话而异)。

还有一个条件:shouldUseGlobalCacheScope() 返回 false 时,边界标记不会被插入,所有内容使用同一个 scope。这通常在调试模式下使用。

关联模块:缓存切分的完整实现——splitSysPromptPrefix() 的三种场景、addCacheBreakpoints() 的 Messages 缓存断点标记——详见第四章

SystemContext:追加在 System Prompt 尾部

System Prompt 的主体是模块化组装的指令集,但还有一类信息不适合放在指令里——环境状态。当前 Git 分支、是否有未提交变更、工作目录结构……这些信息每轮都可能变化,但对模型来说更像是“身份背景”而非“对话内容”。

在这里插入图片描述

如上图所示,SystemContext 作为环境状态追加在 System Prompt 尾部,与 UserContext(项目知识注入到 Messages)形成互补。两者的设计分工是:SystemContext 回答“你当前在哪”,UserContext 回答“这个项目是什么”。

Claude Code 的做法是把这类信息作为 SystemContext 追加在 System Prompt 尾部(src/utils/api.tsappendSystemContext()),格式是简单的键值对。放 System Prompt 而非 Messages,是因为它的语义是“你当前所处的环境”,不是用户说的话。

与之对应的是 UserContext,它注入到 Messages[0] 的位置,包裹在 <system-reminder> 标签中。两者都叫“Context”,但设计意图完全不同:SystemContext 是环境状态(身份层面),UserContext 是项目知识(对话层面,包含 CLAUDE.md + 日期等)——详见Message 章节

本章小结

System Prompt 看起来只是一段文本,但背后的工程体系非常精密:

  1. 三条路径:极简 / 自主代理 / 标准,根据运行模式动态选择
  2. 静态区 7 模块:对所有用户相同,共享 global KV cache
  3. 动态区 11+ 模块:通过 Section 缓存框架管理,memoized 或 DANGEROUS_uncached
  4. 优先级链:Override > Coordinator > Agent(proactive追加) > Custom > Default
  5. 边界标记:静态区和动态区的缓存切分锚点
  6. SystemContext:追加在 System Prompt 尾部的环境状态信息

下一章我们进入 Messages——承载对话与隐藏注入的“消息管道”。

Message——承载对话与隐藏注入的“消息管道”

System Prompt 设定了“你是谁”和“你在什么环境里”,Messages 承载“发生了什么”。Messages 是上下文中占比最大的板块(约 60%),也是变化最频繁的部分——每个 turn 都会追加新消息。但 Messages 里不只有用户的键盘输入,还有工具结果、附件展开、系统提醒等大量隐藏注入。

理解 Messages 的关键在于三个问题:

  • 为什么内部用 5 种类型,API 只认 2 种? Claude Code 内部需要区分用户输入、工具结果、附件、系统标记、进度展示——但 Anthropic API 只接受 userassistant 两种 role。中间需要一个清洗管道做 5→2 的映射。
  • 为什么每条消息内部还有更小的单元? 每条消息的 content 不是一段文本,而是一个 Content Part 数组——文本、图片、工具调用、工具结果、思维链,每个都是独立的 Part。tool_usetool_result 通过 tool_use_id 精确配对,这是 Agent 循环的驱动机制:模型发出 tool_use → 系统执行工具 → 返回 tool_result → 模型继续推理,直到 end_turn
  • 除了用户主动输入,还有哪些信息悄悄注入了 Messages? 有两个隐藏注入点:UserContext(CLAUDE.md + 日期,注入到 messages[0])和 Attachments(40+ 种系统自动收集的补充信息,如 IDE 选中的代码、Hook 结果、压缩恢复数据,每 turn 以“信封”形式转为 UserMessage 注入)。它们不来自用户的键盘,但模型每次调用都能看到。

所以这一章的结构是:先看内部 5 种类型和它们的映射关系,再看 Content Part 和清洗管道如何把内部结构转为 API 格式,最后看 UserContext 和 Attachments 这两个隐藏注入点。

内部 5 种类型 vs API 2 种类型

在这里插入图片描述

如上图所示,Claude Code 内部维护 5 种消息类型,经过清洗管道后只保留 API 认识的 2 种。其中 UserMessage 和 AssistantMessage 直接映射,AttachmentMessage 中转后映射,SystemMessage 和 ProgressMessage 在管道中被过滤。

内部类型API 映射是否持久化核心职责
UserMessagerole: 'user'最忙的类型——用户输入、工具结果、附件、系统提醒
AssistantMessagerole: 'assistant'模型回复——唯一由模型“创造”的类型
AttachmentMessage先转为 UserMessage → 再映射上下文补充信封——不直接发 API
SystemMessage不发给 API内部状态标记(压缩边界、系统通知)
ProgressMessage不发给 API工具执行进度——仅 UI 展示

UserMessage 是“最忙”的类型,它承载的信息远不止用户的键盘输入:

  • 工具执行结果——每个 tool_result Content Part 都以 UserMessage 形式存在
  • 附件转换后的内容——AttachmentMessage 展开后注入为 UserMessage
  • 系统提醒——包裹在 <system-reminder> 标签中,通过 isMeta 标记对用户隐藏但模型可见
  • 压缩摘要——上下文压缩后的关键信息以 UserMessage 形式恢复到对话中

isMeta 标记是这里的关键设计:它让系统可以向模型传递额外信息(如 CLAUDE.md 内容、Git 状态),而不干扰用户的终端阅读体验——用户不会在终端里看到这些“隐藏消息”。

AssistantMessage 是 Agent 循环的“发动机”。每次模型返回 stop_reason: 'tool_use',系统就执行对应工具、把结果作为 UserMessage 追加、再调用模型——这个循环持续到 end_turn 或达到停止条件。AssistantMessage 的 content 数组中可能包含多个 tool_use Part(模型在一个回复中并行调用多个工具),每个都会触发独立的工具执行。

AttachmentMessage 是中转类型——它通过 attachmentToMessages() 转为 UserMessage 后注入对话,模型永远不会直接看到 AttachmentMessage。40+ 种子类型的展开策略在Attachments 章节展开。

Content Parts 与清洗管道

上面讲了消息的“类型”层面(5→2 映射),现在看消息的“内容”层面——每条消息的 content 是一个 Content Part 数组,每个 Part 是 API 层面最小的信息单元:

在这里插入图片描述

如上图所示,Content Part 分为双向(text)、User→API(image/document/tool_result)、Assistant→API(tool_use/thinking)三个方向。其中 tool_usetool_result 的配对是 Agent 循环的核心驱动机制。

类型方向说明
text双向文本内容,最基础的类型
image / documentUser → API图片和 PDF,base64 编码
tool_useAssistant → API模型决策调用工具,包含 idnameinput
tool_resultUser → API工具执行结果,通过 tool_use_idtool_use 配对
thinking / redacted_thinkingAssistant → APIExtended Thinking 的思维链
tool_referenceUser → API延迟工具发现——按需加载 MCP 工具

tool_usetool_result 的配对是 Agent 循环的核心机制——配对通过 tool_use_id 保证,如果压缩导致配对断开,ensureToolResultPairing() 会自动修复。

这些内部结构(5 种消息类型 × 多种 Content Part)最终都要通过 normalizeMessagesForAPI() 清洗为 API 能理解的格式。这个约 380 行的管道(src/utils/messages.ts L1989-2370)处理以下步骤:

  1. 过滤:移除 SystemMessage 和 ProgressMessage——它们对 API 没有意义
  2. 展开:AttachmentMessage 通过 attachmentToMessages() 转为 UserMessage
  3. 配对修复ensureToolResultPairing() 确保每个 tool_use 后面都有 tool_result
  4. 字段清理:移除 tool_reference(延迟加载模式)、advisor 相关字段等内部标记
  5. 不完整消息处理:如果最后一条 AssistantMessage 的 stop_reasonnull(模型还没回复完),这条消息不能发给 API
  6. 缓存断点标记addCacheBreakpoints() 在每条消息的最后一个 Content Block 上附加 cache_control

理解这个管道的意义在于:当你遇到“模型为什么看不到某个信息”或“为什么某个工具结果没传给模型”时,可以逐层排查——是过滤了?是配对修复了?还是缓存断点标记有问题?缓存断点标记的完整实现在缓存章节展开。

UserContext:注入到 messages[0] 的隐藏信息

除了用户主动输入的消息,还有一类“隐藏”的 UserMessage——UserContext。prependUserContext()src/utils/api.ts L449-474)在消息数组最前面注入一条包裹在 <system-reminder> 中的 UserMessage:

// src/utils/api.ts L449-474(简化)
export function prependUserContext(messages, context) {
  if (Object.entries(context).length === 0) return messages
  return [
    createUserMessage({
      content: `<system-reminder>
As you answer the user's questions, you can use the following context:
${Object.entries(context).map(([key, value]) => `# ${key}\n${value}`).join('\n')}

IMPORTANT: this context may or may not be relevant to your tasks.
You should not respond to this context unless it is highly relevant to your task.
</system-reminder>`,
      isMeta: true,  // 对用户隐藏,不显示在终端
    }),
    ...messages,
  ]
}

UserContext 包含两个关键信息:

claudeMdCLAUDE.md 文件的内容——用户自定义的项目级指令。Claude Code 自动发现并加载所有 CLAUDE.md(当前目录及上级目录递归搜索)。模型在每个新会话中都能“看到”这些约定。

currentDate:当前日期,如 "Today's date is 2025-04-18."。模型需要知道“今天是哪天”来判断 Git 日志时效性、时间相关的 Bug 等。

注意那句精心设计的提示词:IMPORTANT: this context may or may not be relevant to your tasks. 它防止模型过度依赖 CLAUDE.md——用户问“今天天气怎么样”时,模型不应该引用 CLAUDE.md 里的 TypeScript 约定。

getUserContext() 使用 lodash.memoize 缓存,在 setSystemPromptInjection() 变更时清除。文件内容在会话期间不会变,读一次就够了。

关联模块:UserContext 注入到 Messages 板块(prependUserContext → messages[0]),与之对应的是 SystemContext 追加在 System Prompt 尾部——见第二章 2.6 节

Attachments:补充信封的展开策略

Attachments 是 Claude Code 最独特的设计之一。它不是直接发给 API 的消息,而是一种“信封”——系统在每个 turn 自动收集各种补充信息,包装为 Attachment,然后通过 attachmentToMessages() 转换为 UserMessage 注入对话。

src/utils/attachments.ts(~4000 行)定义了 40+ 种 Attachment 子类型,分为 5 大类:

在这里插入图片描述

大类典型子类型触发时机
用户输入触发file(@-mention)、selected_lines_in_idemcp_resource用户操作时
线程级relevant_memoriesdate_changediagnostics每 turn 自动收集
Hook 相关async_hook_responsehook_blocking_errorHook 回调时
压缩恢复compact_file_referenceplan_file_referencetask_status压缩后自动恢复
系统/状态token_usagedeferred_tools_deltamcp_instructions_delta状态变化时

Attachments 的生命周期:

在这里插入图片描述

如上图所示,附件从收集到最终发送给 API 经过 5 个阶段。每个阶段都有明确的职责边界——收集器只负责收集,转换器只负责转换,清洗管道只负责清洗。

为什么需要 Attachment 机制而不直接放 System Prompt?

答案在于"变化频率"。System Prompt 相对稳定(每会话只组装一次),但有一类信息是"系统自动收集、每 turn 可能变化"——比如 IDE 选中的代码、Hook 结果、deferred_tools_delta。这些信息变化频率太高,放在 System Prompt 里会破坏 prompt cache。作为附件注入到消息流中,可以保持 System Prompt 的稳定性。

这也解释了 mcp_instructions 的设计演进:原来是 DANGEROUS_uncachedSystemPromptSection()(每 turn 重算,破坏缓存),改为 mcp_instructions_delta 附件后,只在 MCP Server 连接/断开时注入增量,System Prompt 保持稳定。同样的信息,用不同的传递方式,可以带来巨大的成本差异。

关联模块:压缩恢复类附件(compact_file_referencetask_status 等)在 Full Compact 后自动注入——这是压缩系统保证模型不“失忆”的关键,详见后续压缩专题文章。

本章小结

Messages 看起来只是“用户说一句、模型回一句”的简单结构,但内部的工程体系远比表面复杂:

在这里插入图片描述

如上图所示,从消息源(用户输入、附件、系统信息)到最终发送给 API 的消息,经过了类型映射、Content Part 组装、清洗管道三个阶段。整个过程中有两个关键设计:变化隔离(附件注入 Messages 而非 System Prompt)和隐藏注入(UserContext 和系统提醒通过 isMeta 对用户隐藏)。

下一章我们进入上下文缓存——这些精心编排的内容如何避免重复计算。

Tools——模型能使用的“工具箱”

前两章拆解了 System Prompt 和 Messages——模型知道了自己是谁、对话发生了什么。但一个只会“说话”的模型什么都做不了:它不能读文件、不能执行命令、不能搜索代码。Tools 就是模型的手和脚,告诉模型“你能做什么”。

Claude API 的每次请求有三个顶层参数:systemmessagestools。Tools 以 JSON Schema 数组的形式传入,每个工具是一个对象,包含名称、描述、参数定义。模型看到 Tools 后,就可以在回复中声明“我要调用某个工具”,客户端执行后将结果返回给模型。

模型实际拿到的单个工具大概长这样:

{
  "name": "Read",
  "description": "Reads a file from the local filesystem...",
  "input_schema": {
    "type": "object",
    "properties": {
      "file_path": { "type": "string", "description": "The path to read" }
    },
    "required": ["file_path"]
  }
}

Claude Code 有 30+ 个这样的内置工具(Read、Edit、Write、Bash、Glob、Grep 等),加上用户通过 MCP 协议接入的外部工具。全量发送可能占用数千 tokens。

在这里插入图片描述

如上图所示,内置工具按职责分为几大类:文件操作(Read/Edit/Write)、搜索(Glob/Grep)、执行(Bash)、信息获取(WebFetch/WebSearch)等。Tools 占上下文约 10%,但背后的编排逻辑涉及三个层面:工具从哪来、怎么精简、如何保持稳定

工具来源与组装链路

Claude Code 的工具池由两部分组成:内置工具(30+ 个,如 Read、Edit、Write、Bash、Glob、Grep)和 MCP 外部工具(用户配置的 MCP Server 提供的工具)。组装过程:

  1. getTools() 获取内置工具,根据运行模式(标准/REPL)和权限规则过滤
  2. assembleToolPool() 合并内置工具 + MCP 工具,按名称去重(内置优先),按字母排序
  3. toolToAPISchema() 将每个 Tool 对象转为 API 格式的 JSON Schema

排序不是随意的——内置工具排前面、MCP 工具排后面,这个顺序保证了 prompt cache 的稳定性。如果 MCP 工具穿插在内置工具之间,每次 MCP 连接变化都会导致缓存失效。

在这里插入图片描述

上图展示了从工具池到最终 API 请求的完整链路:内置工具和 MCP 工具分别经过权限过滤后,在 assembleToolPool() 中合并去重,再通过 toolToAPISchema() 转为 JSON Schema。

延迟加载:ToolSearch

工具数量多时(特别是有大量 MCP 工具时),全量传 Schema 会占用大量 token。Claude Code 引入了延迟加载机制:

  • 非延迟工具(内置工具):每次调用都发送完整 Schema
  • 延迟工具(MCP 工具默认延迟):只发工具名和一行摘要,不展开完整 Schema
  • ToolSearchTool:一个特殊的内置工具,模型可以用它按需“搜索并加载”延迟工具

具体流程是这样的:假设用户配置了 Slack MCP,模型第一次调用时看不到 slack_send_message 的完整 Schema,只看到一个名字。当用户说“给我发一条 Slack 消息”时,模型意识到需要 Slack 工具,于是调用 ToolSearch(query: slack),系统返回 slack_send_message 的完整 Schema。从下一轮开始,这个工具就自动包含在完整工具列表中了。

这个设计有一个精妙的缓存考量:延迟工具标记了 defer_loading,API 服务端在计算 prompt cache 时会忽略这些工具的 token——这意味着工具的增减不会影响 System Prompt 的缓存命中。

在这里插入图片描述

上图对比了延迟加载开启前后的差异:开启后,MCP 工具只占极少的 token(一个名字+一行摘要),而内置工具照常发送完整 Schema。

Schema 缓存

toolToAPISchema() 内部使用会话级缓存(toolSchemaCache,仅 27 行)。工具的 description 生成涉及 GrowthBook feature flag 检查(如 tengu_tool_peartengu_fgts),这些 flag 可能会话中途变化。如果每次重新序列化,Schema 的微妙变化会导致缓存失效。通过缓存,整个会话内工具 Schema 保持稳定。

本章小结

Tools 是上下文编排中“最安静”的一块——模型通常不会注意到它的存在,但它决定了模型能力的边界。工具组装的关键设计点:

  • 来源合并:内置 + MCP,按名称去重,按字母排序保证缓存稳定
  • 延迟加载:MCP 工具默认只报名不展开,通过 ToolSearch 按需加载
  • Schema 缓存:会话级锁定,防止 feature flag 变化破坏缓存
  • 全局缓存影响:有 MCP 工具时 System Prompt 跳过全局缓存(已在全景图章节提及)

工具的执行、调度、权限管理的完整拆解将在下一篇“工具与扩展系统”中展开。

上下文缓存——让每次调用更省钱

前三章讲了“组装什么”,这一章讲“怎么省着用”。

Prompt Caching 是 Anthropic API 提供的 KV cache 机制:如果连续请求的 prompt 前缀相同,可以跳过前缀的处理,直接从缓存读取。Claude Code 的一个典型 System Prompt 约 10K-15K tokens,一次对话可能有 50-100 个 turn。没有缓存时,每个 turn 都重新处理整个 System Prompt,总成本是 15K × 100 = 150 万 tokens。有了缓存,只有第一个 turn 处理完整 System Prompt,后续 turn 只处理变化部分,成本降到原来的 10-20%。

但缓存不是客户端单方面能完成的——它是一个两端协作的工程:客户端负责“在哪里标记缓存断点”,服务端负责“在哪里执行 KV cache 读写”。Claude Code 几乎每一个设计决策都考虑了缓存效率。

这一章拆解三层缓存标记(System Prompt / Messages / Tools)和四大稳定性保障,最后看一个精妙的案例——cache_edits 如何在不修改消息的前提下删除旧工具结果。

缓存解决什么问题?

模型处理 prompt 的方式是:将文本转为 token 序列,每个 token 映射为一个向量,然后逐层计算。如果 prompt 有 10K tokens,每层都要计算 10K 个向量的 attention——这是 O(n²) 复杂度的操作。

KV cache 的核心思想:如果连续请求的 prompt 前缀完全相同,服务端可以缓存前缀的 KV 向量,下次请求直接复用,跳过前缀的处理。缓存命中时,前缀部分不需要重新计算,只需要处理新增的部分。

但有一个关键约束:缓存是“前缀匹配”——一旦前缀发生变化(哪怕一个字符),从变化点开始的所有缓存全部失效。这就是为什么 Claude Code 花了那么多精力来保持前缀稳定。

在这里插入图片描述

上图展示了 KV cache 的工作原理:前缀相同时(绿色部分)直接复用缓存的 KV 向量,只计算新增部分(橙色部分)。一旦前缀变化(红色部分),从变化点开始的缓存全部失效,需要重新计算。

客户端标记 + 服务端执行:协作模型

Claude Code 的缓存策略是两端协作:

**客户端(Claude Code)**的职责:

  • 决定在哪里放置 cache_control 断点
  • 保持 prompt 前缀稳定,避免不必要的变更
  • 通过 Section Memoization、Schema Cache、Beta Header 锁存等机制保证字节一致性

**服务端(Anthropic API)**的职责:

  • 接收带 cache_control 标记的请求
  • 在标记位置建立 KV cache
  • 后续请求检查前缀匹配,命中则复用缓存

客户端的标记策略直接决定了缓存效率。让我拆解 Claude Code 的三层缓存标记。

System Prompt 缓存:global / org / ephemeral

System Prompt 的缓存切分由 splitSysPromptPrefix()src/utils/api.ts)处理。它的设计目标很明确:尽可能让静态区命中最高级别的缓存共享,同时避免不同用户的动态内容互相干扰。

在这里插入图片描述

上图展示了三种场景的缓存策略。核心设计思路可以用一句话概括:能共享就共享,不能共享就降级。

场景 1:有 MCP 工具时——全部降级为 org。 不同用户配置的 MCP 工具不同,工具 Schema 会影响 System Prompt 的内容。此时全局缓存不可用(因为用户 A 的缓存里可能有用户 B 看不到的工具),所有内容降级为组织级缓存。

场景 2:无 MCP 工具 + 有 boundary marker——静态区命中 global。 这是最优场景。7 个静态模块对所有用户完全相同,可以跨组织、跨用户共享缓存——你的请求可能复用另一个用户 10 分钟前计算的结果。动态区因会话而异,不做额外缓存。

场景 3:第三方 API 提供商——回退到 org。 Bedrock、Vertex 等平台可能不支持全局缓存,统一用组织级。

这三种场景体现了“缓存策略应该是条件性的,而不是一刀切的”工程原则。

Messages 缓存:每条消息标记一个断点

Messages 的缓存策略更简单:在每条消息上标记一个 ephemeral 断点。 这样当新消息追加时,之前的消息可以从缓存读取——每轮只处理新增的那一条。

听起来简单,但实际的工程挑战在于:消息可能连续出现相同 role(API 要求 user/assistant 交替)、单条消息可能过大(需要拆分以优化缓存粒度)、工具搜索结果需要在正确位置插入。这些都是为了让“每条消息加一个标记”这件简单的事在复杂场景下也能正确工作。

四大缓存稳定性保障

保持前缀稳定是缓存命中的前提。Claude Code 实现了四个机制来保障缓存稳定性:

保障机制保护对象核心思路代码位置
Section Memoization动态区模块计算一次,缓存到 /clear/compactsystemPromptSections.ts
Tool Schema Cache工具 JSON Schema会话级缓存序列化结果,即使 feature flag 中途变化也不影响toolSchemaCache.ts(27 行)
Beta Header 锁存API 请求头首次发送后锁定,防止 TTL 等缓存策略中途切换getPromptCache1hAllowlist()
MCP Instructions DeltaMCP 使用说明从“每 turn 重算”改为“仅在连接/断开时注入增量”附件方式

四个机制的核心逻辑一致:会话内一旦确定,不再变化。 每个机制背后都有真实的故障场景——Schema 微妙变化导致缓存失效、Beta header 中途切换导致行为不可预测、MCP 每轮重写浪费 500K tokens。这些不是理论上的风险,而是实际遇到并修复过的问题。

其中 MCP Instructions Delta 的收益最直观:假设 50 个 turn 的会话,只有 2 次 MCP 变化。如果用 DANGEROUS_uncached,50 次重写 × 10K tokens = 500K tokens 浪费;改为 delta 后,只有 2 次注入,成本接近零。

cache_edits:原地删除旧工具结果

这是缓存优化中最精妙的设计。

传统的“微压缩”是在本地删除旧工具结果,但这会修改消息数组,导致 prompt 前缀变化,缓存全部失效。省下的 token 还没补回来,缓存失效增加的成本就已经抵消了收益。

Cached Microcompact 用了一个完全不同的思路:不在本地修改消息,而是通过 API 的 Cache Editing 功能直接在服务端删除。

工作原理:

  1. Microcompact 检测到旧 tool_result 超过 keepRecent 阈值
  2. 通过 pinCacheEdits() 记录需要删除的 tool_use_id
  3. addCacheBreakpoints() 在构建请求时插入 cache_edits 指令
  4. 服务端收到请求后,在 KV cache 中原地删除对应的 tool_result

本地消息数组不变,System Prompt 不变,prompt 前缀完全一致——缓存完美命中。“删除”操作在服务端透明完成。

这就像告诉图书管理员“这些页的笔记不用给我看了”——书还是整整齐齐的,翻书速度不受影响。cache_edits 是压缩系统与缓存系统协同的关键接口,压缩的六道防线将在后续文章中展开。

缓存的代价与监控

缓存不是免费的。Claude Code 有时也会主动“放弃”缓存:调试时强制重算、服务端缓存过期后主动清理旧数据、Beta 策略切换时重建。系统还内置了缓存命中率监控——当异常下降时自动记录事件,方便排查“为什么这一轮突然变慢了”。

Compact Cache Reuse

缓存不仅服务于用户对话,还服务于系统内部的 Compact 操作。Compact 需要调用模型生成摘要,它复用主线程的 system prompt 和 tools,确保 prompt 前缀一致——你可以理解为 Compact 操作“借”了主线程的缓存身份,不用从零开始算。

本章小结

上下文缓存的核心是“两端协作”:客户端标记缓存断点,服务端执行 KV cache。三层缓存策略(global/org/ephemeral)+ 四大稳定性保障,确保绝大多数 turn 只处理增量。

当对话越来越长、token 接近上下文窗口限制时,缓存也救不了了——这时 Claude Code 会启动压缩策略。压缩是一个和缓存同等重要的命题,它有自己的六道防线和精妙设计,我们将在下一篇深入拆解。

总结:上下文编排的全景

回到开头提出的四个问题,现在逐一回答:


Q1:模型是怎么“看到”你的对话的?你的项目信息呢?

都不是直接“看到”的。模型对世界的全部认知,来自每次 API 请求里的三块文本:

板块占比承载的信息
System Prompt~30%项目规则和身份设定
Messages~60%对话历史 + 隐藏注入的项目上下文
Tools~10%可用能力的声明

你打的文字在 Messages 里,CLAUDE.md 的项目规则藏在 messages[0],工具能力在 Tools 里——它们被分别塞进三个通道,到了模型那里合成一个完整的世界。

在这里插入图片描述


Q2:Claude Code 专业沉稳的“人设”是谁写的?

没有一个人、没有一个文件定义了它。18 个模块(7 静态 + 11+ 动态)按优先级链拼装:Override 可以覆盖、Coordinator 可以追加、Agent 可以替换。你在不同场景下看到的 Claude Code,其实是同一套骨架在不同模块组合下的不同面貌。

更微妙的是,Tools 也在塑造“人设”——模型知道自己有 Read 工具却没有 Send Email 工具时,行为模式自然不同。人格和能力是分开传递的(System Prompt vs Tools),但共同定义了模型的边界。


Q3:你只打了一句话,模型实际收到了多少信息?

远不止你的文字:

  • CLAUDE.md 的指令被注入到 messages[0]
  • 今天的日期跟着一起塞进去
  • 剪贴板图片、IDE 选中代码、错误诊断作为 Attachments 展开
  • 消息经过 6 步清洗管道(过滤→展开→配对修复→字段清理→不完整消息→缓存断点),最终只留下 user/assistant 两种类型

你看到的是一句话,模型收到的是一封被层层塞过信件的“信封”。


Q4:同一个项目聊了 50 轮,每一轮的 API 费用都一样吗?

差别可能达 10 倍——但你感觉不到,因为系统在背后做了三层防线:

防线策略效果
缓存优先静态区全局缓存 + 动态区 Section 缓存 + 消息级缓存绝大多数 turn 只处理增量
cache_edits 兜底服务端原地删除旧工具结果省 token 不破坏缓存
压缩兆底token 即将超限时压缩对话历史最后手段,保证功能可用

让你永远不需要关心 token 在翻倍。

把这四个答案放在一起,整个上下文编排系统的设计可以归纳为三个核心原则:

原则一:分层缓存。从全局缓存(静态区 scope:global)到组织缓存(动态区 scope:org),再到消息级缓存(per-message cache_control),每一层都有明确的缓存策略和失效机制。

原则二:变化隔离。高变化频率的内容(Attachments)和低变化频率的内容(System Prompt)通过不同的通道传递——Attachments 注入 Messages,System Prompt 保持稳定。这避免了高频变化破坏低频内容的缓存。

原则三:缓存优先,压缩兆底。正常情况下通过缓存优化成本;token 即将超限时通过压缩保证功能可用性。cache_edits 是唯一完美解决“压缩与缓存冲突”的策略——本地消息不变,服务端透明删除。

这不是一段 prompt,而是一套系统工程。

系列导航

本文属于 《Claude Code 源码 Deep Dive》 系列,专注于上下文的组成与缓存。

引导篇Claude Code 源码架构概览:51万行代码的模块地图

后续篇目预告

  • 工具与扩展系统:30+ 工具的注册、调度、权限管理和 MCP 协议的完整链路
  • 记忆系统:CLAUDE.md 的加载机制、会话记忆管理、跨会话知识持久化
  • 压缩机制:六道防线的完整拆解——从 cache_edits 到 Full Compact

如果这篇文章对你有帮助,欢迎点赞收藏支持一下。如果你对 Claude Code 源码感兴趣,欢迎关注本系列后续更新。有任何想法或疑问,评论区见 👋