Claude Code 深度拆解:上下文里有什么——System Prompt 工程

0 阅读13分钟

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

本文属于 《Claude Code 源码 Deep Dive》 系列,专注于上下文工程中的 System Prompt 板块。如果你想了解整个系列,可以先看系列开篇 | Claude Code 源码架构概览:51万行代码的模块地图

本文聚焦一件事:模型的"人设"是怎么被塑造出来的——System Prompt 的每一个零件怎么组装,又怎么被缓存策略保护。

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

  • Claude Code 专业沉稳的"人设"是谁写的? 没有一个人定义了它,那它是怎么被塑造出来的?
  • System Prompt 为什么是一段数组而不是一个大字符串? 这个看似随意的决定,背后藏着什么工程考量?
  • 为什么有些模块每次调用都重新计算,有些算一次就够了? "会话内一旦确定,不再变化"——这句话背后的缓存哲学是什么?

前情提要:模型收到了什么

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

paramsFromContext()src/services/api/claude.ts)返回发给 API 的完整参数。拆开看,真正变成 token 喂给模型的有三个字段:

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

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

在这里插入图片描述

三个板块各是什么:

  • System Promptsystem,~30%):模型的"身份设定与环境感知",由 7 个静态模块 + 11+ 个动态模块组装成 string[]。静态区所有用户共享,可命中全局缓存;动态区因会话变化。总入口 getSystemPrompt(),但可能被优先级链替换。
  • Messagesmessages,~60%):对话与隐藏注入,承载用户输入、工具结果、附件展开等全部对话信息。
  • Toolstools,~10%):30+ 内置工具 + MCP 外部工具的 JSON Schema。

本文只拆解第一个板块——System Prompt。 Messages 和 Tools 分别在姊妹篇中展开。


System Prompt 的骨架——7 静态 + 11+ 动态

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

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

在这里插入图片描述

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

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

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

在这里插入图片描述

模块核心原文工程洞察
Intro / 身份声明You must NEVER generate or guess URLs"安全壳"设计——编程场景中模型编造 URL 可导致访问恶意网站
System / 系统行为Your conversation is not limited by the context window告诉模型有自动压缩兜底,不会因"怕用完 token"而拒绝复杂任务
Doing Tasks / 任务执行Don't speculate. Three lines of similar code are fine最长的静态模块。"不过度设计"贯穿始终
Actions / 行动准则Consider the reversibility and blast radius of actions如果只读一个模块就读这个。可逆操作自由执行,不可逆必须确认
Using Tools / 工具使用Prefer dedicated tools over BashBash 最不透明,用户难以审查
Tone & Style / 语气不用 emoji、代码引用用 file:line解决了真实 UX 问题
Output EfficiencyGo straight to the point. Don't be unnecessarily terse外部版偏简洁,内部版偏可理解

几个值得展开的设计亮点:

Doing Tasks 的"不过度设计"。四条规则每条背后都有模型曾犯过的真实错误:加了一个没人要求的特性、为三行相似代码抽了个 helper、在系统边界外加了不必要的 validation。团队选择在 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——外部版偏简洁,内部版偏可理解,因为内部用户更频繁使用,追问成本更高。

一个更深的问题:这些行为约束为什么不写进代码,而是写在 prompt 里? 答案是灵活性和可迭代性。代码约束是刚性的——一旦写死 禁止删除未跟踪文件,在所有场景下都生效。但 prompt 约束是软性的,模型可以根据上下文灵活判断。比如"可逆操作自由执行"——什么是可逆的,需要模型根据场景判断(创建文件可逆,push 到 shared branch 不可逆)。这种判断力用代码实现极其困难,但在 prompt 里只需一句话。

更重要的是,prompt 可以快速迭代。Claude Code 团队发现模型在某个场景下行为不理想时,最快的修复方式往往是在 prompt 里加一条规则——比改代码、发布、等用户更新快得多。7 个静态模块中的很多规则,就是这样在真实用户反馈中逐步积累的。

动态区——11+ 个条件模块

如果说静态区是"所有员工共享的基本守则",那动态区就是"针对每个岗位的个性化指引"。

在这里插入图片描述

模块重要级核心职责
session_guidanceP0基于工具集生成使用策略(Agent / Explore / Skill / Verification)
memoryP0加载 ~/.claude/memory/*.md,跨会话持久知识
mcp_instructionsP0MCP Server 使用说明
env_info_simpleP1环境信息(CWD / 平台 / Shell / 模型名 / 知识截止日期)
output_styleP1自定义输出风格
frcP1告知模型旧工具结果可能被清除
summarize_tool_resultsP1提醒模型处理工具结果时记录重要信息
languageP2用户语言偏好
scratchpadP2per-session 临时文件目录指引
token_budgetP2用户指定 token 目标时激活
ant_model_overrideP2内部 ant 用户额外指令覆盖

session_guidance 是动态区最复杂的模块。内容取决于"这个会话有哪些工具可用"——AgentTool 使用策略、Explore agent 调用时机、Skill 调用指南。因为涉及具体工具列表,不能放静态区,但 memoized 后会话内只算一次。

mcp_instructions 最为特殊——它是目前唯一可能每 turn 重算的模块(MCP Server 可能随时连接/断开)。但后来引入 mcp_instructions_delta 后,MCP 状态稳定时不注入任何内容,缓存完全命中。这是从"破坏缓存"到"保护缓存"的典型演进。

Section 缓存框架:memoized vs uncached

动态区的每个模块都用统一的缓存框架包装。来自 src/constants/systemPromptSections.ts(仅 69 行):

方法缓存策略何时重算对缓存的影响
systemPromptSection()计算一次,缓存到 /clear/compact会话重置时不影响缓存
DANGEROUS_uncachedSystemPromptSection()每 turn 重算每次 API 调用前可能破坏 prompt cache

在这里插入图片描述

绝大多数模块走 systemPromptSection() 路径——通过 session 级闭包缓存结果,整个会话只计算一次。只有 MCP 指令等少数场景走 DANGEROUS_uncached 路径。

缓存策略的演进也有故事:token_budget 曾是 DANGEROUS_uncached(每次 budget 翻转触发重算),后来改为 memoized——因为措辞用了条件句,没有 budget 激活时就是 no-op。这一改节省了约 20K tokens/次的缓存断裂。

整个 Section 缓存框架的核心思想只有一句话:尽量不重算,万不得已才重算。

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

前面看到的 getSystemPrompt() 返回的是"默认"System Prompt。但实际运行中,它只是决策树的一个叶子节点。外层的 buildEffectiveSystemPrompt() 决定了最终使用哪个 prompt:

分支行为频率
Override直接返回 overrideSystemPrompt,跳过所有默认逻辑极低,内部测试用
Coordinator用协调者 prompt 替换默认,用于多 Agent 编排极低,实验功能
Agent + ProactiveDefault 追加 Agent prompt,保留基础行为指引
Agent / CustomAgent/Custom 替换 Default,完全接管
Default(标准模式)使用 7+11 模块绝大多数用户

两个关键设计点:

  • Proactive 是追加,标准 Agent 是替换:Proactive 模式下 Agent prompt 追加到 Default 之后,因为自主代理仍需要基础行为指引。标准模式下 Agent 完全接管,用自己的指令体系替代默认的。
  • appendSystemPrompt 总是追加(Override 除外),确保额外内容不遗漏。

这对用户意味着什么? 绝大多数用户(Default 模式)看到的 Claude Code 是标准版——专业、简洁、不主动加戏。但如果你配置了自定义 Agent prompt,Claude Code 会完全变成你定义的样子——一个代码审查 Agent、一个测试编写 Agent、一个 DevOps Agent。同一个产品,不同的灵魂。

更有趣的是 Proactive 模式。在这个模式下,Agent prompt 追加到 Default 之后而不是替换——这意味着 Agent 保留了 Claude Code 的基础行为(不乱删文件、不用 Bash 能用 Read 就用 Read),同时叠加了领域特定的指令。就像一个有良好职业素养的员工,在基础守则之上接收了项目组的具体指引。这种"追加而非替换"的设计,是工程务实主义的体现。

缓存切分:能共享就共享,不能共享就降级

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

SYSTEM_PROMPT_DYNAMIC_BOUNDARY 是一个特殊字符串,不在模型的"人格指令"中——模型看到的是一个被替换后的分隔。它的真正作用是在 splitSysPromptPrefix() 中作为缓存切分锚点:

  1. 边界标记之前的所有元素(静态区)合并为一个 TextBlock,scope 为 global
  2. 边界标记之后的所有元素(动态区)合并为另一个 TextBlock,scope 为 org
  3. 两个 TextBlock 分开发送,API 的 KV cache 就能按不同 scope 缓存

在这里插入图片描述

三种场景的设计思路:能共享就共享,不能共享就降级。

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

场景 2:无 MCP 工具 + 有 boundary marker——静态区命中 global。 这是最优场景。7 个静态模块对所有用户完全相同,可以跨组织、跨用户共享缓存。

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

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

用户感知不到缓存的存在——直到它断裂。 缓存断裂的直接后果是:这一轮的响应变慢(因为要重新处理整个前缀),API 费用突增(重新处理意味着更多的 input tokens)。Claude Code 内置了"缓存断裂检测"(promptCacheBreakDetection.ts,651 行),它在每次 API 调用前后对比状态:调用前追踪 12 个维度的变更(system prompt 变了?工具 Schema 变了?模型换了?),调用后检查 cache_read_input_tokens 是否骤降。如果断裂了,系统会自动记录事件并归因到具体原因——"MCP 工具新增了 3 个"或"Beta header 从 5min TTL 切到了 1h TTL"。这个检测系统本身也是一个工程细节:不是出了问题才排查,而是主动监控、自动归因。

SystemContext:追加在 System Prompt 尾部

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

在这里插入图片描述

Claude Code 的做法是把这类信息作为 SystemContext 追加在 System Prompt 尾部。放 System Prompt 而非 Messages,是因为它的语义是"你当前所处的环境",不是用户说的话。

与之对应的是 UserContext,它注入到 Messages[0] 的位置。两者都叫"Context",但设计意图完全不同:SystemContext 是环境状态(身份层面),UserContext 是项目知识(对话层面)——详见姊妹篇[消息管道与隐藏注入](./02-Claude Code深度拆解-上下文里有什么-消息上下文管理.md)。

本章小结

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

  1. 静态区 7 模块:对所有用户相同,共享 global KV cache——你今天调用的缓存,可能复用了另一个用户 10 分钟前计算的结果
  2. 动态区 11+ 模块:通过 Section 缓存框架管理,会话内只算一次
  3. 优先级链:Override > Coordinator > Agent(proactive追加) > Custom > Default——同一个 Claude Code,在不同场景下展现的是不同面貌
  4. 缓存边界:静态区和动态区用 DYNAMIC_BOUNDARY 分隔,三层 scope(global/org/ephemeral)按条件降级
  5. SystemContext:环境状态追加在尾部,与 UserContext(注入 Messages)形成互补

没有一个人定义了 Claude Code 的人设。 18 个模块按优先级链拼装,你在不同场景下看到的 Claude Code,其实是同一套骨架在不同模块组合下的不同面貌。


系列导航

本文属于 《Claude Code 源码 Deep Dive》 系列中「上下文组成与缓存」命题的子篇章,专注于 System Prompt 的组装与缓存。

本文是完整版《Claude Code 源码深度解析:拆解上下文的组成与缓存》的子命题之一。如果你想了解上下文编排的全景(System Prompt + Messages + Tools + 缓存工程),推荐阅读完整版。

姊妹篇(可独立阅读):

如果这篇文章对你有帮助,欢迎点赞收藏支持一下。有任何想法或疑问,欢迎评论区留言讨论 👋