摘要
本文基于 Claude Code 源码,分析其系统提示词的构建流程、分段策略与提示词缓存机制。重点考察 systemPromptSections 注册表、SYSTEM_PROMPT_DYNAMIC_BOUNDARY 静动态分界标记、splitSysPromptPrefix 三模式缓存切片算法,以及 CacheScope 两级缓存域的设计逻辑。同时讨论 MCP 工具接入对缓存策略的约束,以及消息级缓存断点的放置规则。
一、背景:提示词缓存的工程价值
大语言模型的推理成本在很大程度上由输入 token 决定。对于 Claude Code 这类交互式编程助手,每轮对话均携带完整的系统提示词,其长度通常在数千 token 以上。若每次请求都将系统提示词从头计算,成本极为可观。
Anthropic 提供了提示词缓存(Prompt Caching)能力,允许客户端在请求体中标记特定文本块为可缓存内容。服务端在满足条件时复用已有的 KV Cache,从而跳过对该段 token 的 prefill 计算。这一机制对系统提示词尤为有效——系统提示词跨会话高度稳定,是天然的缓存候选。
然而,"系统提示词"并非一个均质的整体。其内部既包含每次启动后不再变化的静态描述(工具说明、行为规范、环境信息),也包含随每轮请求动态更新的内容(当前会话特征、MCP 服务状态、功能开关读取结果)。若将两者混同处理,静态部分的缓存命中率将因动态部分的频繁变化而显著下降。
Claude Code 为此设计了一套分段架构,将系统提示词在构建阶段拆解为具有明确缓存语义的独立块,再按照不同的缓存域(global 或 org)打上标注,最终映射到 Anthropic API 的 cache_control 字段。本文将逐层拆解这一过程。
二、Section 注册表:静态性的声明式标注
系统提示词的内容管理入口位于 constants/systemPromptSections.ts。该文件定义了一个轻量的 Section 注册机制,其核心数据类型如下:
type SystemPromptSection = {
name: string
compute: () => string | null | Promise<string | null>
cacheBreak: boolean
}
字段语义清晰:name 用于调试追踪,compute 是实际的内容计算函数,cacheBreak 则是本文关注的关键字段——它标记该 section 是否具有跨轮次变化的语义,即是否应当成为缓存断点。
对应地,注册表提供两个构造函数:
export function systemPromptSection(name, compute): SystemPromptSection
// cacheBreak = false,内容在 session 内计算一次后被缓存
export function DANGEROUS_uncachedSystemPromptSection(name, compute, _reason): SystemPromptSection
// cacheBreak = true,每轮重新计算,名称前缀 DANGEROUS_ 是对调用者的显式警告
前者的实现依赖一个 session 级的 Map 做惰性缓存——首次调用时执行 compute(),后续调用直接返回缓存结果。后者则每次都调用 compute(),不做任何记忆。
DANGEROUS_uncachedSystemPromptSection 目前在代码中仅有一处实际应用:
DANGEROUS_uncachedSystemPromptSection(
'mcp_instructions',
() => getMcpInstructionsText(),
'MCP servers connect/disconnect between turns'
)
注释明确说明了原因:MCP 服务器可能在任意两轮之间连接或断开,其生成的工具指令内容随时可能改变。若对其做 session 级缓存,将导致系统提示词与实际可用工具集不一致。_reason 参数不参与运行时逻辑,仅作为强制要求调用者说明理由的文档约束。
resolveSystemPromptSections 函数负责批量执行一组 section 的 compute,返回 (string | null)[],null 值表示该 section 在当前上下文下不适用(例如某些仅在特定模式下启用的功能模块)。
三、SystemPrompt 类型与构建流程
系统提示词在类型系统层面被定义为品牌化字符串数组:
// utils/systemPromptType.ts
type SystemPrompt = readonly string[] & { __brand: 'SystemPrompt' }
使用 TypeScript 的品牌类型(Branded Type)而非 string[] 的原因有两点:其一,防止将普通字符串数组误传至需要构建好的系统提示词的 API;其二,强调该数组的语义不是任意字符串序列,而是一个有序的提示词段列表,其内部顺序具有语义意义。
buildEffectiveSystemPrompt(位于 utils/systemPrompt.ts)负责确定最终使用哪个系统提示词序列。其逻辑遵循一条优先链:
- 若存在显式 override,直接使用 override 内容
- 若当前为协调者(coordinator)模式,使用协调者专用提示词
- 若当前为子 Agent 模式,根据配置决定替换还是追加
- 否则使用默认的
getSystemPrompt()构建结果
无论走哪条路径,appendSystemPrompt 均在最后追加,这是用户自定义系统提示词的注入点。
getSystemPrompt() 是系统提示词的主体构建函数,位于 constants/prompts.ts。其结构决定了后续缓存分段的依据。
四、动态边界标记:SYSTEM_PROMPT_DYNAMIC_BOUNDARY
getSystemPrompt() 的返回值是一个 string[],其中大部分元素是通过 resolveSystemPromptSections 展平后的文本块。关键在于其中插入了一个特殊标记:
const SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'
该常量以双下划线包裹,在视觉上即可区分于普通文本内容。其在 getSystemPrompt() 中的插入逻辑如下:
return [
...staticSections,
...(shouldUseGlobalCacheScope() ? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY] : []),
...dynamicSections,
]
边界标记只在 shouldUseGlobalCacheScope() 返回 true 时才被插入——这是因为该标记的语义依赖于全局缓存能力的存在。若当前 API 提供商不支持全局缓存,插入边界标记没有意义(后续的 splitSysPromptPrefix 也会因此走不同的分支)。
边界标记的作用是在字符串数组层面划定一条逻辑分界线:标记之前的内容被认定为静态内容(跨会话稳定,适合全局缓存),标记之后的内容被认定为动态内容(会话特定,不应跨组织共享缓存)。
代码注释中对"什么内容应当置于边界之后"有明确记录。以 getSessionSpecificGuidanceSection 为例,其注释写道该 section 被置于动态边界之后,原因是它需要读取以下运行时状态:
isForkSubagentEnabled()— 功能开关,从 GrowthBook 读取getIsNonInteractiveSession()— 当前会话是否为非交互模式- 其他特性标志的当前值
这些值在不同会话、不同用户、不同时间点均可能不同,因而不可被全局缓存。
五、splitSysPromptPrefix:三模式缓存切片算法
splitSysPromptPrefix 是整个缓存架构的核心函数,位于 utils/api.ts。它接收由 getSystemPrompt() 产生的 string[],输出一个 SystemPromptBlock[]:
type CacheScope = 'global' | 'org'
type SystemPromptBlock = { text: string; cacheScope: CacheScope | null }
cacheScope 的取值含义:
'global':可跨组织缓存,适用于完全不含用户/组织特定信息的内容'org':仅在同一组织内缓存,适用于可能含有组织配置、用户偏好的内容null:不参与缓存,内容每次均需全量计算
函数内部根据两个条件决定走哪个分支:
条件一:是否存在 MCP 工具(hasMcpTools)
条件二:是否启用全局缓存且提示词中包含边界标记(useGlobalCacheFeature && hasBoundary)
三个分支的处理逻辑如下:
模式一:MCP 工具存在
attribution block → cacheScope: null
prefix blocks → cacheScope: 'org'
remaining blocks → cacheScope: 'org'
当 MCP 工具可用时,系统提示词中包含了由 MCP 服务器贡献的工具描述。MCP 工具的描述内容是每个用户、每个服务器连接独有的,带有明确的用户身份语义,不可被不同组织的用户共享。因此,所有块统一降级为 'org' 级别,完全放弃全局缓存。attribution 块(包含模型署名信息,undercover 场景下需要抹除)始终为 null,这一规则在三个模式中保持一致。
模式二:全局缓存启用且边界标记存在
attribution block → cacheScope: null
prefix blocks → cacheScope: null (attribution 之后、boundary 之前)
static blocks → cacheScope: 'global' (boundary 之后、动态内容之前)
dynamic blocks → cacheScope: null (动态内容,不缓存)
这是三个模式中最精细的一个。函数在此模式下扫描 string[],找到 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 标记的位置,以此为界将内容分为两段。边界之前的静态内容被标记为 'global',可以跨组织共享缓存;边界之后的动态内容标记为 null,不参与缓存。
一个值得关注的细节是 prefix 块(attribution 之后、boundary 之前的早期块)被标记为 null 而非 global。这部分内容包括一些早期的初始化文本,其全局缓存适用性的判断较为保守,因此未被纳入 global 范围。
模式三:默认模式
attribution block → cacheScope: null
prefix blocks → cacheScope: 'org'
remaining blocks → cacheScope: 'org'
当全局缓存不可用且无 MCP 工具时,退回到最朴素的策略:所有内容使用 'org' 级别缓存,即在同一组织内复用。
六、shouldUseGlobalCacheScope:全局缓存的启用条件
全局缓存并非对所有 API 提供商开放。shouldUseGlobalCacheScope() 的实现位于 utils/betas.ts:
export function shouldUseGlobalCacheScope(): boolean {
return getAPIProvider() === 'firstParty' &&
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)
}
函数返回 true 需要同时满足两个条件:
- API 提供商为
firstParty,即直接使用 Anthropic 官方 API,而非通过 Amazon Bedrock、Google Vertex AI 或 Anthropic Foundry 接入 - 未通过环境变量
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS禁用实验性 Beta 功能
全局缓存能力(cache_control 中的 scope: 'global')依赖一个 Beta API Header:
const PROMPT_CACHING_SCOPE_BETA_HEADER = 'prompt-caching-2025-04-11'
此 Header 在启用全局缓存时被添加到请求的 betas 数组中。Bedrock、Vertex 等第三方部署路径目前不支持该 Beta 特性,因此相应的代码分支被直接跳过。
七、getCacheControl 与 TTL 策略
确定了每个块的 cacheScope 之后,buildSystemPromptBlocks 函数(位于 services/api/claude.ts)将其转换为实际的 TextBlockParam,并附加 cache_control 字段。getCacheControl 函数负责根据 scope 和查询来源生成具体的缓存控制对象:
function getCacheControl({ scope, querySource }): CacheControlEphemeralParam {
return {
type: 'ephemeral',
...(should1hCacheTTL(querySource) ? { ttl: '1h' } : {}),
...(scope === 'global' ? { scope: 'global' } : {}),
}
}
标准 TTL 为 5 分钟(Anthropic API 默认值),1 小时 TTL 通过 should1hCacheTTL() 决定是否启用。该函数的判断条件包括:
- 当前用户为 Anthropic 内部用户(
ant用户类型) - 或者,当前用户为付费订阅者且未处于超额状态(non-overaged subscriber)
should1hCacheTTL() 的结果在 bootstrap 阶段被锁定,保存在全局状态中,整个 session 内保持不变。这样做的目的是防止订阅状态在 session 中途发生变化时导致缓存 TTL 频繁切换,进而引发缓存失效。
GrowthBook 功能开关系统(A/B 测试框架)用于管理 1h TTL 的灰度开放。代码中对 GrowthBook allowlist 的匹配使用了尾部通配符(trailing-* matching),支持按前缀匹配用户标识,便于按组织或用户群体进行分级灰度。
八、消息级缓存断点
除系统提示词外,Claude Code 还在消息历史中设置缓存断点。规则较为简单:在每轮请求时,将 cache_control 放置在最后一条用户消息和最后一条助手消息各自的最后一个内容块上。
这一做法的逻辑依据是:Anthropic API 的提示词缓存以"最后一个带 cache_control 标记的 token 位置"为缓存边界。在消息历史的末尾打断点,意味着下一轮请求时,历史消息部分可以被命中缓存,只有新增的用户输入需要重新 prefill。
对于多模态内容(图片、文件附件等),缓存断点同样放置在最后一个块上,不区分内容类型。对于空内容块或仅含工具结果的消息,缓存断点的放置逻辑有额外的边界处理。
九、工具 Schema 缓存稳定性
系统提示词缓存的一个潜在破坏因素是工具 Schema 的变化。Claude Code 集成了大量工具(文件操作、Bash 执行、代码搜索等),每个工具都有对应的 JSON Schema 描述,这些描述作为独立的 tool 块随请求发送。
若工具 Schema 在每次请求时都重新生成,即便内容不变,序列化后的字符串可能因字段顺序、空白符等细微差异而产生不同的 token 序列,导致缓存 miss。
toolSchemaCache.ts 通过在 session 级别缓存工具 Schema 的序列化结果来规避这一问题。GrowthBook 功能开关的翻转(A/B 测试分组变化)可能导致某些工具的可用性发生变化,进而引发 Schema 集合的变化。工具 Schema 缓存对此做了特殊处理,确保在 session 内工具集稳定的前提下,Schema 的序列化结果保持确定性,从而维持缓存的持续命中。
十、MCP 工具与全局缓存的冲突
MCP(Model Context Protocol)工具接入是全局缓存策略中最重要的约束来源。分析其不兼容的根本原因,需要理解两个层面:
语义层面:MCP 工具由用户在本地配置的服务器提供,其工具描述、参数 Schema、行为规范均带有强烈的用户/组织特异性。将含有此类内容的提示词缓存在全局(跨组织)层面,理论上存在信息泄露风险——不同组织的用户可能通过缓存命中间接获知他人的 MCP 工具配置信息。
稳定性层面:MCP 服务器连接在会话内是动态的,工具列表随时可能增减。即使退而求其次使用 org 级缓存,MCP 工具的高变动性也使缓存命中率受限。DANGEROUS_uncachedSystemPromptSection 对 MCP 指令的处理(每轮重新计算)正是对这一特性的响应。
在 splitSysPromptPrefix 的实现中,MCP 工具存在时的处理逻辑(模式一)完全绕过了全局缓存路径,即便 shouldUseGlobalCacheScope() 返回 true,只要检测到 MCP 工具存在,就立即降级为 org 级别。代码中对此有一处额外的检查:
needsToolBasedCacheMarker = useGlobalCacheFeature &&
filteredTools.some(t => t.isMcp && !willDefer(t))
willDefer 表示该 MCP 工具被推迟加载(defer),尚未实际可用。仅当存在已加载且非推迟的 MCP 工具时,才真正触发 global→org 的降级逻辑。
十一、架构总结
以下是系统提示词从构建到最终发送的完整数据流:
getSystemPrompt()
└── resolveSystemPromptSections([
staticSection_1, // systemPromptSection,session 内缓存
staticSection_2, // systemPromptSection,session 内缓存
...
SYSTEM_PROMPT_DYNAMIC_BOUNDARY, // 分界标记(仅 global cache 模式)
dynamicSection_1, // DANGEROUS_uncachedSystemPromptSection
dynamicSection_2, // systemPromptSection(但内容依赖运行时状态)
...
])
└── string[] (含边界标记)
│
▼
splitSysPromptPrefix(strings, { hasMcpTools, useGlobalCache })
└── SystemPromptBlock[]
{ text: "...", cacheScope: 'global' | 'org' | null }
│
▼
buildSystemPromptBlocks(blocks, enablePromptCaching)
└── TextBlockParam[]
{ type: 'text', text: "...", cache_control?: { type: 'ephemeral', ttl?, scope? } }
│
▼
Anthropic API Request
整个设计体现了一个核心原则:缓存策略的决策尽可能前置。systemPromptSection 和 DANGEROUS_uncachedSystemPromptSection 在 section 定义时即声明其缓存语义,而非在最终构建阶段做动态判断。SYSTEM_PROMPT_DYNAMIC_BOUNDARY 作为数据平面的标记,将提示词数组的语义分区编码进数据本身,使 splitSysPromptPrefix 的逻辑保持相对简单。
这种"声明优于推断"的设计取向,降低了缓存策略与内容逻辑之间的耦合度——添加新 section 时,开发者只需在定义处决定是否使用 DANGEROUS_ 前缀,以及是否置于边界之前,而不需要理解整个 splitSysPromptPrefix 的切分逻辑。
附录:关键常量与函数索引
| 符号 | 文件 | 说明 | |
|---|---|---|---|
systemPromptSection | constants/systemPromptSections.ts | 声明 session 内稳定的 section | |
DANGEROUS_uncachedSystemPromptSection | constants/systemPromptSections.ts | 声明每轮重计算的 section | |
SYSTEM_PROMPT_DYNAMIC_BOUNDARY | constants/prompts.ts | 静动态内容分界标记 | |
splitSysPromptPrefix | utils/api.ts | 三模式缓存切片核心函数 | |
CacheScope | utils/api.ts | `'global' | 'org'` 缓存域类型 |
SystemPromptBlock | utils/api.ts | 带缓存语义的文本块类型 | |
shouldUseGlobalCacheScope | utils/betas.ts | 全局缓存启用条件判断 | |
getCacheControl | services/api/claude.ts | 生成 cache_control 对象 | |
should1hCacheTTL | services/api/claude.ts | 1h TTL 扩展条件判断 | |
buildSystemPromptBlocks | services/api/claude.ts | 将 block 转换为 API 参数 | |
PROMPT_CACHING_SCOPE_BETA_HEADER | services/api/claude.ts | 全局缓存所需的 Beta Header | |
toolSchemaCache | utils/toolSchemaCache.ts | 工具 Schema 序列化稳定性缓存 |