Claude Code 的系统提示词
Claude Code 的系统提示词不是一段硬编码大字符串,而是一套可分段、可缓存、可覆盖、可降级的控制面。
这一章要解决的是:系统提示词如何从多个来源生成,哪些内容被缓存,哪些内容必须每轮重算,静态规则和动态上下文如何分界,最后又如何变成 Anthropic API 的 TextBlockParam[]。
核心直觉
系统提示词不只是“模型应该怎么表现”的文本。它还是 prompt cache 的输入协议:哪些字节稳定、哪些字节变化、哪些块能全局缓存,都由源码明确控制。
先看源码入口
| 源码定位点 | 关键符号 | 负责什么 |
|---|---|---|
restored-src/src/constants/systemPromptSections.ts:10-14 | SystemPromptSection | 系统提示词段落抽象:名称、计算函数、缓存策略 |
restored-src/src/constants/systemPromptSections.ts:43-58 | resolveSystemPromptSections() | 并行解析段落,命中或写入段落缓存 |
restored-src/src/constants/systemPromptSections.ts:65-68 | clearSystemPromptSections() | /clear、/compact 后清理段落缓存和 beta header latch |
restored-src/src/constants/prompts.ts:114-115 | SYSTEM_PROMPT_DYNAMIC_BOUNDARY | 静态区和动态区的带内边界标记 |
restored-src/src/constants/prompts.ts:444-577 | getSystemPrompt() | 构建默认系统提示词数组 |
restored-src/src/constants/prompts.ts:491-555 | 动态 section 注册 | memory、env_info_simple、mcp_instructions 等动态段落 |
restored-src/src/constants/prompts.ts:513-520 | DANGEROUS_uncachedSystemPromptSection('mcp_instructions', ...) | 唯一每轮重算的动态 MCP 指令 |
restored-src/src/utils/api.ts:321-435 | splitSysPromptPrefix() | 把系统提示词数组切成带缓存作用域的块 |
restored-src/src/services/api/claude.ts:3213-3237 | buildSystemPromptBlocks() | 把 SystemPromptBlock[] 转成 API TextBlockParam[] |
restored-src/src/utils/systemPrompt.ts:41-123 | buildEffectiveSystemPrompt() | 合成 override、coordinator、agent、custom、default、append |
读这一章要把“提示词内容”和“缓存协议”放在一起看。很多源码设计不是为了文案优雅,而是为了缓存稳定。
总流程图
这条链路里有三个边界:
- 段落边界:
SystemPromptSection管一个段落能否缓存。 - 静态/动态边界:
SYSTEM_PROMPT_DYNAMIC_BOUNDARY管全局可缓存前缀。 - 来源优先级边界:
buildEffectiveSystemPrompt()管谁覆盖谁。
段落注册表:系统提示词的最小单位不是字符串,而是 section
原文给出的 section 抽象:
type SystemPromptSection = {
name: string;
compute: ComputeFn;
cacheBreak: boolean;
};
这个结构证明每个段落都有三件事:
| 字段 | 作用 |
|---|---|
name | 缓存键和可观测名称 |
compute | 计算段落内容,可能返回 string、null 或 Promise |
cacheBreak | 是否每轮重算,false 表示可记忆化 |
创建段落有两条路径:
| 工厂函数 | cacheBreak | 适合内容 |
|---|---|---|
systemPromptSection(name, compute) | false | 会话内稳定内容,如语言、环境、memory 读取结果 |
DANGEROUS_uncachedSystemPromptSection(name, compute, reason) | true | 两轮之间可能变化,且过期会造成错误的内容 |
DANGEROUS_ 前缀是故意增加 API 摩擦:开发者必须写 reason,代码审查时就能追问“为什么不能缓存”。
resolveSystemPromptSections() 如何缓存
原文给出的核心函数:
export async function resolveSystemPromptSections(
sections: SystemPromptSection[],
): Promise<(string | null)[]> {
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();
setSystemPromptSectionCacheEntry(s.name, value);
return value;
}),
);
}
这段代码证明四个行为:
| 行为 | 说明 |
|---|---|
Promise.all | 段落并行计算,适合读取 CLAUDE.md、环境信息等 I/O |
!s.cacheBreak && cache.has(s.name) | 可缓存段落命中后不再重算 |
null 也缓存 | “本段不存在”也是稳定结果,避免重复判断 |
cacheBreak 段落每次 compute | 易变段落不读缓存 |
缓存存放在 STATE.systemPromptSectionCache,不是普通模块变量。这样 /clear 和 /compact 可以统一清理。
清理函数:
export function clearSystemPromptSections(): void {
clearSystemPromptSectionState();
clearBetaHeaderLatches();
}
这段代码说明 /clear、/compact 不只是清聊天记录,也会让系统提示词段落重新计算,并重置 beta header 锁存器。
哪些段落必须每轮重算:mcp_instructions 是关键例子
原文指出源码中唯一典型的未缓存段落是 MCP 指令:
DANGEROUS_uncachedSystemPromptSection(
'mcp_instructions',
() =>
isMcpInstructionsDeltaEnabled()
? null
: getMcpInstructionsSection(mcpClients),
'MCP servers connect/disconnect between turns',
);
这段代码证明 mcp_instructions 不缓存的原因很具体:MCP server 可能在两轮之间连接或断开。如果第 1 轮缓存了 server A 的指令,第 3 轮 server B 连接了,但缓存仍返回旧指令,模型就永远不知道 B。
对比另一个案例更能看出取舍:原文提到 token_budget 曾经是未缓存段落,因为它依赖当前 turn budget。但这样每次预算变化都会破坏约 20K token 的缓存。后来改写提示词,让“没有预算”时自然成为 no-op,于是可以降级为普通 systemPromptSection。
读源码抓手
看到 DANGEROUS_uncachedSystemPromptSection,不要只读内容。一定要读 reason,它才说明这段为什么值得牺牲 prompt cache。
静态/动态边界:SYSTEM_PROMPT_DYNAMIC_BOUNDARY
系统提示词数组里有一个特殊标记:
export const SYSTEM_PROMPT_DYNAMIC_BOUNDARY =
'__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__';
这个字符串不是给模型看的,而是给下游 splitSysPromptPrefix() 看的带内信号。
在 getSystemPrompt() 返回数组里,它被放在静态段落和动态段落之间:
getSimpleIntroSection(...)
getSimpleSystemSection()
getSimpleDoingTasksSection()
getActionsSection()
getUsingYourToolsSection(...)
getSimpleToneAndStyleSection()
getOutputEfficiencySection()
SYSTEM_PROMPT_DYNAMIC_BOUNDARY
session_guidance
memory
env_info_simple
language
output_style
mcp_instructions
scratchpad
...
| 区域 | 内容类型 | 缓存目标 |
|---|---|---|
| 边界前 | 身份、通用行为、工具使用原则、输出风格等跨会话稳定规则 | 尽量 global cache |
| 边界后 | session guidance、CLAUDE.md、环境、语言、MCP、scratchpad 等会话内容 | 不全局缓存或只会话/组织级处理 |
边界只在 shouldUseGlobalCacheScope() 为真时插入:
...(shouldUseGlobalCacheScope() ? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY] : [])
原文还引用了 getSessionSpecificGuidanceSection 的源码注释:如果会话变量放在边界前,会让 Blake2b prefix hash 变体按 2^N 膨胀。
这就是系统提示词架构里最重要的缓存不变量:边界前不能混入会话状态。
splitSysPromptPrefix():把逻辑数组切成缓存块
splitSysPromptPrefix() 把 systemPrompt: string[] 转成带 cacheScope 的块。它有三条路径。
路径 1:MCP 降级
触发条件是 shouldUseGlobalCacheScope() 为真,但 skipGlobalCacheForSystemPrompt 也为真。原文说明这个 flag 来自 claude.ts:只有 MCP 工具实际渲染进请求时才触发。
核心行为:
for (const prompt of systemPrompt) {
if (!prompt) continue;
if (prompt === SYSTEM_PROMPT_DYNAMIC_BOUNDARY) continue;
if (prompt.startsWith('x-anthropic-billing-header')) {
attributionHeader = prompt;
} else if (CLI_SYSPROMPT_PREFIXES.has(prompt)) {
systemPromptPrefix = prompt;
} else {
rest.push(prompt);
}
}
这段代码证明 boundary 在降级路径里被跳过,剩余内容合并为 org 级缓存块。原因是 MCP 工具 schema 本身是用户级动态内容,即使系统提示词能全局缓存,整体请求也已有大块不可全局缓存内容,继续维护全局系统缓存收益变小。
路径 2:全局缓存 + 边界
这是无 MCP 降级时的高收益路径:
const boundaryIndex = systemPrompt.findIndex(
s => s === SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
);
if (boundaryIndex !== -1) {
for (let i = 0; i < systemPrompt.length; i++) {
const block = systemPrompt[i];
if (!block || block === SYSTEM_PROMPT_DYNAMIC_BOUNDARY) continue;
if (block.startsWith('x-anthropic-billing-header')) {
attributionHeader = block;
} else if (CLI_SYSPROMPT_PREFIXES.has(block)) {
systemPromptPrefix = block;
} else if (i < boundaryIndex) {
staticBlocks.push(block);
} else {
dynamicBlocks.push(block);
}
}
}
这段代码证明边界前后被分成不同块:
| 块 | cacheScope | 说明 |
|---|---|---|
| attribution header | null | 计费归因头,不缓存 |
| system prompt prefix | null | CLI 前缀标识,不缓存 |
| static content | 'global' | 跨组织共享的静态系统规则 |
| dynamic content | null | 每会话变化内容,不缓存 |
路径 3:默认 org 缓存
如果不启用全局缓存,或找不到边界标记,函数会把非特殊内容合成一个 org 级缓存块。
这条路径是保守兜底:没有边界,就不要假装知道哪里能全局缓存。
buildSystemPromptBlocks():cacheScope 变成 API cache_control
splitSysPromptPrefix() 的结果还要经过 buildSystemPromptBlocks():
export function buildSystemPromptBlocks(
systemPrompt: SystemPrompt,
enablePromptCaching: boolean,
options?: { skipGlobalCacheForSystemPrompt?: boolean; querySource?: QuerySource },
): TextBlockParam[] {
return splitSysPromptPrefix(systemPrompt, {
skipGlobalCacheForSystemPrompt: options?.skipGlobalCacheForSystemPrompt,
}).map(block => ({
type: 'text' as const,
text: block.text,
...(enablePromptCaching && block.cacheScope !== null && {
cache_control: getCacheControl({
scope: block.cacheScope,
querySource: options?.querySource,
}),
}),
}));
}
这段代码证明:缓存不是注释或内部标记,而是最终进入 API 请求的 cache_control。cacheScope !== null 的块才获得 cache_control;动态块或计费头这类 null 块不缓存。
行为链路是:
SYSTEM_PROMPT_DYNAMIC_BOUNDARY
-> splitSysPromptPrefix()
-> SystemPromptBlock.cacheScope
-> buildSystemPromptBlocks()
-> TextBlockParam.cache_control
-> API 后端按 global / org 缓存
getSystemPrompt():默认系统提示词如何构建
getSystemPrompt() 接收工具列表、模型、额外工作目录、MCP clients,返回 string[]。
它有三条快速路径:
| 路径 | 触发条件 | 行为 |
|---|---|---|
| Simple | CLAUDE_CODE_SIMPLE | 返回最小提示词,只有身份、cwd、日期 |
| Proactive | PROACTIVE 或 KAIROS 活跃 | 返回自治 Agent 精简提示词,不走 section registry |
| 标准路径 | 默认 | 构造动态 sections,解析后拼静态区、边界、动态区 |
标准路径里的动态段落包括:
| 段落名称 | 缓存类型 | 内容 |
|---|---|---|
session_guidance | 记忆化 | 工具引导、交互模式提示 |
memory | 记忆化 | CLAUDE.md 记忆文件 |
ant_model_override | 记忆化 | 内部模型覆盖指令 |
env_info_simple | 记忆化 | cwd、OS、Shell 等环境信息 |
language | 记忆化 | 语言偏好 |
output_style | 记忆化 | 输出风格 |
mcp_instructions | 易变 | MCP server 指令 |
scratchpad | 记忆化 | 草稿本指令 |
frc | 记忆化 | 函数结果清理指令 |
summarize_tool_results | 记忆化 | 工具结果摘要指令 |
numeric_length_anchors | 记忆化 | 内部长度锚点 |
token_budget | 记忆化 | token 预算指令 |
brief | 记忆化 | KAIROS brief 段落 |
唯一需要每轮重算的是 mcp_instructions。这让动态区大部分内容仍然可在会话内记忆化,减少重复 I/O 和计算。
buildEffectiveSystemPrompt():多来源优先级
默认提示词还不是最终提示词。实际 API 调用前,还要合成多个来源。
优先级链:
overrideSystemPrompt
> coordinatorSystemPrompt
> agentSystemPrompt
> customSystemPrompt
> defaultSystemPrompt
+ appendSystemPrompt
最高优先级是 override:
if (overrideSystemPrompt) {
return asSystemPrompt([overrideSystemPrompt]);
}
这段代码证明 override 是完整替换:一旦存在,其他来源包括 appendSystemPrompt 都被忽略。
没有 override 和 coordinator 时,核心合成逻辑:
return asSystemPrompt([
...(agentSystemPrompt
? [agentSystemPrompt]
: customSystemPrompt
? [customSystemPrompt]
: defaultSystemPrompt),
...(appendSystemPrompt ? [appendSystemPrompt] : []),
]);
这段代码证明:
| 来源 | 行为 |
|---|---|
agentSystemPrompt | 替换 custom/default |
customSystemPrompt | 替换 default |
defaultSystemPrompt | 最低优先级 |
appendSystemPrompt | 除 override 外追加到末尾 |
容易误解
“追加提示词”不是永远追加。overrideSystemPrompt 存在时,append 也不会生效,因为 override 表示整个控制面被替换。
状态和数据结构
| 结构 | 字段或值 | 影响 | |
|---|---|---|---|
SystemPromptSection | name | 段落缓存键 | |
SystemPromptSection | compute | 段落内容来源 | |
SystemPromptSection | cacheBreak | 是否每轮重算 | |
STATE.systemPromptSectionCache | `Map<string, string | null>` | 会话内段落缓存 |
SYSTEM_PROMPT_DYNAMIC_BOUNDARY | __SYSTEM_PROMPT_DYNAMIC_BOUNDARY__ | 切分静态区和动态区 | |
SystemPromptBlock | text、cacheScope | API 之前的缓存块 | |
TextBlockParam | cache_control | 最终 API 缓存控制 | |
Effective system prompt | override/coordinator/agent/custom/default/append | 最终模型看到的系统指令 |
设计取舍
| 取舍点 | 源码体现 | 工程判断 |
|---|---|---|
| 分段而非大字符串 | SystemPromptSection | 每段有独立计算和缓存策略,便于维护和失效 |
DANGEROUS_ API 摩擦 | 必填 reason | 每轮重算会破坏缓存,必须解释 |
null 也缓存 | cache.get(s.name) ?? null | “没有该段”也是稳定结果 |
| 边界是带内信号 | SYSTEM_PROMPT_DYNAMIC_BOUNDARY | 让逻辑数组携带缓存分区信息 |
| 会话变量赶到边界后 | session_guidance 等在动态区 | 防止全局缓存前缀变体爆炸 |
| MCP 触发降级 | skipGlobalCacheForSystemPrompt | 动态工具 schema 存在时,不强求全局系统缓存 |
| override 完整替换 | return [overrideSystemPrompt] | 需要强控制模式时,避免默认规则混入 |
| append 低侵入补丁 | appendSystemPrompt 末尾追加 | 支持补充指令,但不打乱主优先级链 |
读源码抓手
- 先看
systemPromptSections.ts,确认 section 的name / compute / cacheBreak。 - 再看
resolveSystemPromptSections(),理解段落缓存如何命中和写入。 - 找
DANGEROUS_uncachedSystemPromptSection的调用点,重点读mcp_instructions的 reason。 - 进入
constants/prompts.ts,找到SYSTEM_PROMPT_DYNAMIC_BOUNDARY在返回数组中的位置。 - 读
splitSysPromptPrefix()三条路径,确认 global/org/null cacheScope 如何产生。 - 看
buildSystemPromptBlocks(),把cacheScope和 APIcache_control对上。 - 最后读
buildEffectiveSystemPrompt(),确认 override、coordinator、agent、custom、default、append 谁覆盖谁。
小结
- Claude Code 的系统提示词是分段式控制面,不是单个字符串。 - systemPromptSection 让每个段落拥有独立缓存策略。 - SYSTEM_PROMPT_DYNAMIC_BOUNDARY 把静态全局前缀和动态会话内容分开。 - splitSysPromptPrefix() 把逻辑边界转成 global、org 或无缓存的 API 块。 - buildEffectiveSystemPrompt() 用清晰优先级链处理多来源提示词,避免不同模式互相污染。