04-System-Prompt-工程
第 4 篇:System Prompt 工程 — 精密控制模型行为的提示词体系
本篇是《深入 Claude Code 源码》系列的第 4 篇。我们将深入
constants/prompts.ts(约 915 行)这个核心文件,揭示 Claude Code 如何通过精心设计的 System Prompt 架构,在「精确控制模型行为」和「最大化 Prompt Cache 命中率」之间取得平衡。
为什么 System Prompt 值得单独一篇?
在大多数 AI 应用中,System Prompt 就是一段硬编码的字符串。但在 Claude Code 这样的生产级 AI Agent 中,System Prompt 是一个工程系统:
- 它不是一段话,而是十几个独立模块的组装结果 — 每个模块有独立的生命周期和缓存策略
- 它直接影响 API 成本 — Anthropic 的 Prompt Cache 机制可以大幅降低重复 token 的费用,但前提是 prompt 前缀保持稳定
- 它是模型行为的根基 — 代码风格约束、安全指令、工具使用优先级、输出格式,全部编码在这里
- 它有内外版差异 — 通过
process.env.USER_TYPE === 'ant'区分内部版(Anthropic 员工)和外部版(公开用户),同一套代码产出不同的行为指引
本篇将回答三个核心问题:
- System Prompt 由哪些模块组成,如何组装? —
getSystemPrompt()的完整流程 - 静态与动态内容如何分离以优化缓存? —
SYSTEM_PROMPT_DYNAMIC_BOUNDARY机制 - 提示词中编码了哪些关键的行为引导技巧? — 从安全指令到代码风格约束
一、整体架构:分段组装的 System Prompt
1.1 getSystemPrompt() — 核心组装函数
System Prompt 的组装入口是 constants/prompts.ts 中的 getSystemPrompt() 函数(第 444-577 行)。它接收工具列表、模型 ID、工作目录和 MCP 客户端作为参数,返回一个 string[] 数组 — 注意,不是单个字符串,而是多个字符串片段的数组。这种设计是为后续的缓存分块做准备。
1 | // constants/prompts.ts:444-577 (示意代码,省略了部分 feature-gated 分支) |
这段代码揭示了整个 System Prompt 的两段式架构:
- 静态段(Boundary 之前):跨所有用户/会话都相同的内容,可以使用
cacheScope: 'global'缓存 - 动态段(Boundary 之后):包含会话特定的信息(环境信息、MCP 指令、语言偏好等)
注意两个容易被忽略的细节:
getSimpleSystemSection()(prompts.ts:186-197)是独立于getSimpleIntroSection()的一个 section,包含了 prompt injection 防御、权限模式说明、Hooks 处理、上下文压缩提示等基础系统约束。它在静态段的位置紧跟 intro 之后,是整个行为规范的基座。getSimpleDoingTasksSection()是条件化的 — 当用户启用了自定义 Output Style 且该 style 未设置keepCodingInstructions: true时,任务执行指引(代码风格约束等)会被跳过。这允许 Output Style 完全重新定义模型的编程行为。
下面这张图展示了 System Prompt 的完整分段组装流程:
1 | graph TD |
1.2 Section 缓存系统:systemPromptSection() 与 DANGEROUS_uncachedSystemPromptSection()
动态段中的每个片段都通过一个精心设计的缓存系统管理。在 constants/systemPromptSections.ts 中定义了两种 section 构造函数:
1 | // constants/systemPromptSections.ts:10-38 |
这个命名方式本身就是一种工程文化的体现 —— DANGEROUS_ 前缀强制开发者意识到代价。每次使用 uncached section,都必须写明原因(_reason 参数),因为它会在每个 turn 重新计算,导致 prompt cache 失效,直接增加 API 调用成本。
在当前版本的代码库中,仅发现一处调用了 DANGEROUS_uncachedSystemPromptSection:
1 | // constants/prompts.ts:513-519 |
MCP 服务器可以在对话过程中连接或断开,所以它的指令必须每轮重新计算。但即使如此,代码中也引入了 isMcpInstructionsDeltaEnabled() 开关 — 当启用时,MCP 指令改为通过消息附件(attachment)传递而非放在 System Prompt 中,从而避免破坏缓存。
Section 的解析逻辑同样简洁:
1 | // constants/systemPromptSections.ts:43-58 |
缓存在 /clear(清空对话)或 /compact(压缩上下文)时被清除,同时重置 beta header 的 latch 状态,让新对话获得全新的评估。
两层缓存的区别:这里描述的
resolveSystemPromptSections缓存是本地会话内缓存(section cache) — 同一个 section 在整个会话中只计算一次。这与后文将要讨论的 Anthropic API Prompt Cache(通过cacheScope控制)是完全不同的层级。前者是「本地避免重复计算」,后者是「API 侧避免重复处理 token」。一个 section 即使在本地被缓存了,它在 boundary 之后的位置仍然意味着不参与 API 的全局 prompt cache。
二、SYSTEM_PROMPT_DYNAMIC_BOUNDARY — 缓存优化的核心机制
2.1 为什么需要分界线?
Anthropic API 的 Prompt Cache 机制是这样工作的:如果两次请求的 system prompt 前缀完全一致,API 可以复用之前缓存的 KV Cache,大幅减少计算量和费用。关键词是前缀 — 只有从头部开始完全匹配的部分才能被缓存。
这意味着:如果在 system prompt 的第一段就插入了用户特定的内容(如环境信息),那么所有用户的请求都无法共享缓存,即使后续内容完全相同。
SYSTEM_PROMPT_DYNAMIC_BOUNDARY 就是为解决这个问题而设计的分界线:
1 | // constants/prompts.ts:106-115 |
2.2 splitSysPromptPrefix() — 根据分界线拆分缓存块
当 System Prompt 数组被传递到 API 层时,utils/api.ts 中的 splitSysPromptPrefix() 函数(第 321-435 行)会根据 boundary marker 将其拆分为不同缓存范围的块。
实际拆分出的不只是「静态」和「动态」两个块。函数会单独识别两个特殊前缀 — attribution header(以 x-anthropic-billing-header 开头的计费标记)和 CLI sysprompt prefix(CLI_SYSPROMPT_PREFIXES 集合中定义的身份前缀,如 "You are Claude Code, Anthropic's official CLI for Claude.",见 constants/system.ts:10-28),将它们作为独立块处理:
1 | // utils/api.ts:362-405 (global cache 路径,简化) |
注意一个关键细节:attribution header 和 CLI sysprompt prefix 的 cacheScope 是 null(不缓存),不是 'global'。只有 boundary 之前、排除这两个特殊前缀后的核心静态内容才使用 cacheScope: 'global'。这是因为 attribution header 包含版本号和 fingerprint(每次构建都不同),而 CLI sysprompt prefix 在不同入口模式下有不同取值(DEFAULT_PREFIX / AGENT_SDK_PREFIX 等,见 constants/system.ts:30-46),它们都不适合参与全局缓存。
完整的缓存块结构如下:
| Block | 内容 | cacheScope | 说明 |
|---|---|---|---|
| 1 | attribution header | null |
计费标记,含版本号,不稳定 |
| 2 | CLI sysprompt prefix | null |
身份前缀,因入口模式而异 |
| 3 | 静态内容(boundary 前) | 'global' |
所有用户共享的行为指引 |
| 4 | 动态内容(boundary 后) | null |
会话特定的环境/配置信息 |
最终在 services/api/claude.ts 中,buildSystemPromptBlocks() 将这些块转换为 API 请求参数:
1 | // services/api/claude.ts:3213-3237 |
这套机制的效果是:Block 3(核心静态内容)可以被所有 Claude Code 用户共享缓存(global scope),而 attribution header、CLI 前缀和动态内容则不参与全局缓存。
2.3 MCP 工具场景的特殊处理
当用户启用了 MCP 工具时,缓存策略会降级 — 因为 MCP 的工具定义(tool schema)本身就会破坏全局缓存。此时 skipGlobalCacheForSystemPrompt 为 true,所有内容降级为 org 级缓存而非 global,boundary marker 被忽略:
1 | // utils/api.ts:326-360 |
三、Context 层:System Prompt 之外的上下文注入
System Prompt 并不是模型看到的全部指令。在 query.ts 中,还有两个上下文层被额外注入:
1 | // query.ts:449-451 |
context.ts 提供了两个 memoized 函数来生成这些上下文:
getSystemContext()(第 116-150 行)— 追加到 System Prompt 末尾。具体实现是 appendSystemContext() 将 context 对象的所有键值对序列化为 key: value 格式的单个字符串块追加到 prompt 数组中(见 utils/api.ts:437-447):
- Git 状态信息(branch、status、recent commits)
- Cache breaker 注入(仅 ant-only 调试用)
getUserContext()(第 155-189 行)— 前置到用户消息中(而非 System Prompt)。prependUserContext() 将 context 注入到消息列表的第一条用户消息之前(在测试环境下会跳过注入,见 utils/api.ts:449-455):
- CLAUDE.md 内容(项目级和用户级记忆文件)
- 当前日期
1 | // context.ts:155-189 |
这两个函数都用 memoize 包装,确保在整个会话中只计算一次。getUserContext() 放在用户消息而非 System Prompt 中,这是因为 CLAUDE.md 的内容可能很长且因项目而异,放在 System Prompt 中会严重影响缓存命中率。
Git 状态信息的生成也值得注意 — 它并行执行 5 个 Git 命令(branch、defaultBranch、status --short、log -n 5、config user.name),并将 status 输出截断到 2000 字符以避免过长:
1 | // context.ts:61-77 |
四、提示词中的行为引导技巧
现在让我们深入 System Prompt 的具体内容,看看它编码了哪些关键的行为引导技巧。
4.1 安全指令:多层防线
安全指令分散在 prompt 的多个位置,形成纵深防御:
第一层 — 网络安全风险指令(CYBER_RISK_INSTRUCTION):
1 | // constants/cyberRiskInstruction.ts:24 |
这段指令由 Safeguards 团队专门维护,文件头部有醒目的警告:「DO NOT MODIFY THIS INSTRUCTION WITHOUT SAFEGUARDS TEAM REVIEW」。
第二层 — URL 生成限制(位于 getSimpleIntroSection()):
1 | // constants/prompts.ts:183 |
第三层 — 基础系统约束(getSimpleSystemSection(),prompts.ts:186-197):
这个容易被忽视的 section 包含了几条关键的安全/系统约束,包括 Prompt 注入防御和 Hooks 处理:
1 | // constants/prompts.ts:186-197 (简化) |
第四层 — 操作安全准则(getActionsSection(),prompts.ts:255-267):
这一整段约 1500 字的指引详细规定了操作的可逆性判断,包含具体的高风险操作示例(删除文件、force-push、发送消息等),以及核心原则:「measure twice, cut once」。
4.2 代码风格约束:反「过度工程」的明确指令
Claude Code 的代码风格指令非常具体,几乎是「反 AI 编程典型毛病」的宣言:
1 | // constants/prompts.ts:200-213 |
注意最后一组注释指令仅对内部版启用(process.env.USER_TYPE === 'ant')。源码中的注释标签 @[MODEL LAUNCH] 说明这是针对特定模型版本(Capybara)调整的 — 因为该模型默认过度注释,需要明确的反向引导。
4.3 工具使用优先级:让模型用对工具
getUsingYourToolsSection() 函数(第 269-314 行)编码了一个关键的行为规则 — 优先使用专用工具而非 BashTool:
1 | // constants/prompts.ts:291-301 |
这个设计有实际的工程原因:专用工具有更好的权限控制(isReadOnly() 检查)、更精确的进度展示(renderToolUseProgressMessage),以及更安全的执行环境(无需通过 shell 解析器)。
4.4 内外版差异:USER_TYPE === 'ant' 的条件分支
整个 prompts.ts 中有多处 process.env.USER_TYPE === 'ant' 分支。以下是主要的差异:
| 内容区域 | 外部版 | 内部版(ant) |
|---|---|---|
| 输出风格 | 简洁指令:”Be extra concise” | 详细的沟通指导:「写给人看,不是写日志」 |
| 代码注释 | 无特殊指令 | 「默认不写注释,除非 WHY 不明显」 |
| 错误报告 | 无特殊指令 | 「忠实报告结果,不要虚报测试通过」 |
| 完成验证 | 无特殊指令 | 「报告完成前先验证:运行测试、检查输出」 |
| 反馈渠道 | 通用 /help 指引 | 推荐 /issue 和 /share 命令 + Slack 频道 |
| 长度锚点 | 无 | 「工具调用间 ≤25 字,最终回复 ≤100 字」 |
内部版的「False-claims 缓解」指令尤其引人注目:
1 | // constants/prompts.ts:237-241 |
这揭示了一个实际的模型问题 — AI 会倾向于「虚报成功」。源码注释 @[MODEL LAUNCH]: False-claims mitigation for Capybara v8 (29-30% FC rate vs v4's 16.7%) 表明 Capybara v8 模型的虚报率高达 29-30%,相比 v4 的 16.7% 显著上升,因此需要更强的提示词约束。
4.5 输出效率:内外版的风格分水岭
输出效率是内外版差异最大的区域之一。getOutputEfficiencySection() 函数根据 USER_TYPE 返回完全不同的指引:
外部版简短直接 — 「Go straight to the point. Be extra concise.」
内部版则是长达数百字的详细沟通指南(第 404-414 行),核心要求是:
- 假设用户已经离开 — 写内容时要让读者能「cold pick up」
- 写流畅的散文,避免碎片化 — 不要过度使用 em dash、符号和表格
- 语义不要回溯 — 让读者线性阅读,不需要重新解析前面的内容
- 倒金字塔结构 — 先给出行动和结论,过程放在最后
五、System Prompt 的优先级体系
getSystemPrompt() 并不总是唯一的 prompt 来源。utils/systemPrompt.ts 中的 buildEffectiveSystemPrompt() 定义了一个清晰的优先级体系:
1 | // utils/systemPrompt.ts:41-123 |
这个优先级设计有两个关键洞察:
- Coordinator mode 不会无条件覆盖 Agent prompt — 只有当没有
mainThreadAgentDefinition时才走 coordinator 路径(utils/systemPrompt.ts:62-65)。如果同时指定了 agent definition,agent prompt 优先。 - Agent prompt 在普通模式下替换默认 prompt,但在 Proactive 模式下追加。这是因为 Proactive 模式的默认 prompt 已经包含了自主行为的核心指引(tick 处理、sleep 策略等),Agent 只需在此基础上添加领域特定的指令。
六、Subagent 的 Prompt 增强
当一个 Agent(subagent)被创建时,它的 System Prompt 通常会经过 enhanceSystemPromptWithEnvDetails() 函数(prompts.ts:760-791)的增强:
1 | // constants/prompts.ts:760-791 |
关键约束 — Agent 线程每次 bash 调用后 CWD 会被重置,因此必须使用绝对路径。这是 Agent 隔离机制的一部分,确保子 Agent 不会意外修改父 Agent 的工作目录状态。
Fork Subagent 的特殊路径
但并非所有 subagent 都走 enhanceSystemPromptWithEnvDetails() 路径。Fork subagent 是一个重要的例外(见 tools/AgentTool/forkSubagent.ts:54-58):
1 | // tools/AgentTool/forkSubagent.ts:54-58 |
Fork subagent 的 prompt 策略是直接复用父线程已经渲染好的 system prompt 字节,通过 toolUseContext.renderedSystemPrompt 传递,而不是重新调用 getSystemPrompt() 生成。这是因为:
- 缓存一致性:重新生成可能因 GrowthBook A/B 测试的冷/热状态不同而产生微小差异,破坏 prompt cache
- 字节级精确:fork children 之间需要 byte-identical 的 API 请求前缀来共享缓存
- 工具定义一致:fork child 使用
tools: ['*']且useExactTools标记,直接继承父线程的完整工具池
这种「byte-exact prompt threading」是一个极致的缓存优化 — 牺牲灵活性换取所有 fork children 之间的缓存共享。
七、预取策略:在用户打字时准备好 Prompt
System Prompt 的计算不是等到用户发送消息才开始的。main.tsx 中的 startDeferredPrefetches() 会在 REPL 渲染后立即开始预取:
1 | // main.tsx:388-406 |
其中 prefetchSystemContextIfSafe() 还有一个安全考量 — 只在用户已经接受信任对话框(trust dialog)后才预取 Git 状态,因为 Git 状态可能包含敏感信息:
1 | // main.tsx:360-380 |
八、可迁移的设计模式
模式 1:静态/动态分界线 + 缓存作用域
将 prompt 分为「所有用户都相同的静态部分」和「每个会话不同的动态部分」,用一个明确的边界标记分隔。API 层根据标记对不同部分应用不同的缓存策略。
适用场景:任何使用 LLM API 且有多用户的产品。Anthropic 的 Prompt Cache 按前缀匹配 — 将不变的内容放在前面、可变的内容放在后面,可以显著降低 API 成本。
模式 2:DANGEROUS_ 命名约定强制意识到代价
对于会打破缓存的操作,用恐吓性命名(DANGEROUS_uncachedSystemPromptSection)并要求附带原因参数。这不是技术限制,而是文化约束 — 让每个开发者在使用时都经过思考。
适用场景:任何存在「方便但昂贵」操作的系统。例如数据库的全表扫描、CI 中的无缓存构建等,都可以用类似的命名约定来提醒使用者。
模式 3:分段构建 + 条件编译
System Prompt 不是一个大字符串模板,而是由十几个独立函数生成的片段数组。每个函数可以独立地:
- 通过
process.env.USER_TYPE切换内外版内容 - 通过
feature()在编译期消除整个分支 - 通过
enabledTools集合判断工具可用性 - 返回
null来跳过不需要的段落
适用场景:任何需要根据多维条件组合生成复杂文本的场景,如邮件模板系统、配置文件生成器、动态文档生成等。
下一篇预告
第 5 篇:对话循环 — query.ts 如何驱动一次完整的 AI 交互
我们将深入 query.ts(约 1729 行),追踪从用户输入到模型回复的完整数据流,理解流式响应处理、tool_use 循环、错误重试等核心机制。
全部内容请关注 https://github.com/luyao618/Claude-Code-Source-Study (求一颗免费的小星星)