系统提示词组装

Agent 系统作者:Claude Code 的系统提示词是 20 段注册表 + 显式静态/动态边界的装配件。拆开它,看真实代码里的分层是怎么做的。

不是一条字符串,是一个装配件

大多数人对”系统提示词”的心智模型:一段文本,开发者写好,调用模型时传进去。

Claude Code 的系统提示词不是这样。它是多层按顺序注入的装配件,在源码里有明确的类型、显式的边界标记、注册表式的组织。不是”写好一段文本”的一次性工作,是一个装配系统

这一章把这个装配系统拆开讲。所有断言都绑定到源文件路径,括号里标注。


源码入口:三个函数 + 一个边界常量

先定位到核心代码。Claude Code 的系统提示词由三个函数装配出来:

  • getSystemPrompt(tools, model, ...) (constants/prompts.ts) —— 产出 string[],静态段 + 动态段的清单
  • buildEffectiveSystemPrompt({ ... }) (utils/systemPrompt.ts) —— 在 override / agent / custom / default 之间选
  • getSystemContext() + getUserContext() (context.ts) —— 拉取 git status / CLAUDE.md / 日期

最终发给模型的前,由 getCacheSharingParams(在 commands/compact/compact.ts 能看到)把这些组合起来。

关键常量

// constants/prompts.ts
export const SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'

这是一个字面量字符串 marker,会被放进 prompt 数组中,显式分隔静态内容(可 cache)和动态内容(可能变)。Anthropic 的 API 基于这个 marker 做 global cache scope。下面会详细讲。


五层优先级:谁的 prompt 赢

buildEffectiveSystemPrompt 实现了明确的优先级梯度(源码注释是这么写的):

0. Override system prompt (loop mode, REPLACES all others)
1. Coordinator system prompt (when coordinator mode is active)
2. Agent system prompt (when mainThreadAgentDefinition is set)
   - In proactive mode: APPENDED to default (agents add domain behavior on top)
   - Otherwise: REPLACES default
3. Custom system prompt (via --system-prompt)
4. Default system prompt (the standard Claude Code prompt)

appendSystemPrompt 永远追加在最后(除了 override 层——override 是完全替换,不留余地)。

几条值得关注的设计决策:

  • Override 是核弹级的loop mode 和类似场景可以完全替换整个系统提示词。这意味着 harness 本身提供了”这条 session 不是常规用法”的逃生舱
  • Proactive / Autonomous 模式特殊:agent prompt 追加到 default 而不是替换——因为 autonomous mode 的 default 已经很瘦(身份 + 记忆 + 环境 + proactive 段),agent 加的是 domain-specific 行为,和 default 的 autonomy 层不冲突
  • --system-prompt CLI flag:用户可以从命令行覆盖默认 prompt。这是可观测的产品能力,不是隐藏接口

这意味着自研 agent 的一个设计问题:你的 prompt 系统有几层权威?如果只有一层(就是默认 prompt 加一些 config),那 “loop mode” 这类特殊场景就没地方落——你会被迫把特殊指令写进 default,污染常规 session。


静态 / 动态边界:Cache 的分界线

getSystemPrompt 的返回是按这个布局拼接的:

// constants/prompts.ts 第 560-577 行左右
return [
  // --- Static content (cacheable) ---
  getSimpleIntroSection(outputStyleConfig),
  getSimpleSystemSection(),
  getSimpleDoingTasksSection(),      // unless output style overrides
  getActionsSection(),
  getUsingYourToolsSection(enabledTools),
  getSimpleToneAndStyleSection(),
  getOutputEfficiencySection(),
  // === BOUNDARY MARKER - DO NOT MOVE OR REMOVE ===
  ...(shouldUseGlobalCacheScope() ? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY] : []),
  // --- Dynamic content (registry-managed) ---
  ...resolvedDynamicSections,
].filter(s => s !== null)

注释里甚至写了 “BOUNDARY MARKER - DO NOT MOVE OR REMOVE”。这个边界之所以重要:

  • Marker 之前:静态——每个 session 都一样,跨 session 命中 prompt cache
  • Marker 之后:动态——CLAUDE.md 可能变、MCP server 可能连接 / 断开、session 级指令每轮可能变
  • Cache scope:Anthropic API 对 marker 之前的内容做 global cache scope,命中率远高于 per-session cache

这是 “late binding” 原则在 prompt 组装上的直接落地——不是风格偏好,是一条 cache 命中率的优化红线

静态 7 段(源码函数名对应的内容,和你在 Claude Code session 里看到的一模一样):

#函数内容
1getSimpleIntroSection”You are Claude Code, Anthropic’s official CLI for Claude” + 策略
2getSimpleSystemSection工程规则(tool results、hooks、prompt injection detection)
3getSimpleDoingTasksSection风格规则(什么时候简短、什么时候问)
4getActionsSection不可逆操作的谨慎原则(git push / rm 等)
5getUsingYourToolsSection工具使用规则(用专用工具优于 Bash、并行调用)
6getSimpleToneAndStyleSection语气、不用 emoji、file_path:line_number 引用
7getOutputEfficiencySection输出效率规则

这 7 段几乎永不变化——只有 Anthropic 发版会动它们。所以它们是 cache prefix 的最稳定部分。


动态段:注册表式组织

Marker 之后是动态段。源码用了一个注册表模式systemPromptSection + resolveSystemPromptSections),每段有名字、lazy producer、cache 策略:

const dynamicSections = [
  systemPromptSection('session_guidance', () =>
    getSessionSpecificGuidanceSection(enabledTools, skillToolCommands),
  ),
  systemPromptSection('memory', () => loadMemoryPrompt()),
  systemPromptSection('ant_model_override', () => getAntModelOverrideSection()),
  systemPromptSection('env_info_simple', () =>
    computeSimpleEnvInfo(model, additionalWorkingDirectories),
  ),
  systemPromptSection('language', () => getLanguageSection(settings.language)),
  systemPromptSection('output_style', () =>
    getOutputStyleSection(outputStyleConfig),
  ),
  // DANGEROUS: cache-busting. MCP servers connect/disconnect between turns.
  DANGEROUS_uncachedSystemPromptSection(
    'mcp_instructions',
    () => isMcpInstructionsDeltaEnabled() ? null : getMcpInstructionsSection(mcpClients),
    'MCP servers connect/disconnect between turns',
  ),
  systemPromptSection('scratchpad', () => getScratchpadInstructions()),
  systemPromptSection('frc', () => getFunctionResultClearingSection(model)),
  systemPromptSection(
    'summarize_tool_results',
    () => SUMMARIZE_TOOL_RESULTS_SECTION,
  ),
  // Ant-only A/B experiment — see below.
  ...(process.env.USER_TYPE === 'ant' ? [
    systemPromptSection('numeric_length_anchors', () =>
      'Length limits: keep text between tool calls to ≤25 words. Keep final responses to ≤100 words unless the task requires more detail.',
    ),
  ] : []),
  // Feature-flagged.
  ...(feature('TOKEN_BUDGET') ? [ ... ] : []),
  ...(feature('KAIROS') || feature('KAIROS_BRIEF') ? [ ... ] : []),
]

DANGEROUS_uncachedSystemPromptSection ——显式承认”这段会打 cache”

有一个特殊工厂函数 DANGEROUS_uncachedSystemPromptSection,字面意思”危险的非缓存动态段”。目前只有 mcp_instructions 用它。注释解释了原因:

MCP servers connect/disconnect between turns

MCP server 可能在 turn 之间连接或断开,所以 MCP instruction 段不能被 cache。每次 turn 都重新计算。源码显式命名了这个设计选择的代价——任何改动这段的 PR 都会看到 “DANGEROUS” 提示。

给自研 agent 的启示让 cache 破坏点显式化。代码里能看到 “这段会破 cache” 比隐性破坏好得多——后者会让团队悄悄贡献出破 cache 的段。

数据驱动的 prompt 工程:numeric_length_anchors

注意上面动态段清单里有一条 ant-only 的 section:

// Numeric length anchors — research shows ~1.2% output token reduction vs
// qualitative "be concise". Ant-only to measure quality impact first.
systemPromptSection(
  'numeric_length_anchors',
  () => 'Length limits: keep text between tool calls to ≤25 words. Keep final responses to ≤100 words unless the task requires more detail.',
)

“Length limits: keep text between tool calls to ≤25 words. Keep final responses to ≤100 words”——这句你在每个 Claude Code session 的 system prompt 里都能看到。源码注释明确记录:

research shows ~1.2% output token reduction vs qualitative “be concise”

1.2% 的输出 token 降幅,是具体数字对比泛泛”简短”措辞。ant-only 先上线测量质量影响,然后再考虑全量。

给自研 agent 的启示prompt 里的每一条指令都应该有”为什么这么写”的答案。Claude Code 的内部文化显然是:

  • 新 prompt 指令必须能指向数据或者实验结果
  • 旧 prompt 指令要进入 A/B 对照才能判断还活着
  • 量化措辞(“≤25 words”)经常比定性措辞(“be concise”)有更好的效果——先用数据验证,再推广

Git Status:snapshot in time

动态段里的 env info 来自 getSystemContext()context.ts),它包含 git status。源码值得看:

// context.ts: getGitStatus()
const [branch, mainBranch, status, log, userName] = await Promise.all([
  getBranch(),
  getDefaultBranch(),
  execFileNoThrow(gitExe(), ['--no-optional-locks', 'status', '--short'], ...),
  execFileNoThrow(gitExe(), ['--no-optional-locks', 'log', '--oneline', '-n', '5'], ...),
  execFileNoThrow(gitExe(), ['config', 'user.name'], ...),
])

const MAX_STATUS_CHARS = 2000
const truncatedStatus = status.length > MAX_STATUS_CHARS
  ? status.substring(0, MAX_STATUS_CHARS) +
    '\n... (truncated because it exceeds 2k characters. If you need more information, run "git status" using BashTool)'
  : status

几个细节:

  • 5 条命令并发执行——Promise.all,不是串行
  • --no-optional-locks——不阻塞其他 git 操作(很重要:session 启动时跑 git 不该锁死仓库)
  • MAX_STATUS_CHARS = 2000 截断——超限留占位:“run git status using BashTool”,告诉 agent 这是被截断过的
  • getSystemContextmemoize——session 启动时算一次,对话过程中不更新(system prompt 里也明写了 “this status is a snapshot in time”)

跳过 git status 的条件

  • CLAUDE_CODE_REMOTE 环境变量——云端 resume 时没必要再跑一次
  • shouldIncludeGitInstructions() 返回 false

CLAUDE.md 加载:6 种 memory 类型

源码里 memory 类型比 3 种多——是 6 种utils/memory/types.ts):

export const MEMORY_TYPE_VALUES = [
  'User',        // ~/.claude/CLAUDE.md
  'Project',     // <repo>/CLAUDE.md(git 跟踪)
  'Local',       // CLAUDE.local.md(用户的**私有** project instructions,不进 git)
  'Managed',     // policy/企业管理的配置
  'AutoMem',     // auto-memory,跨对话持久化
  ...(feature('TEAMMEM') ? (['TeamMem'] as const) : []),  // 团队共享记忆
] as const

每种类型在 system prompt 里的描述后缀(源码 getClaudeMds 第 1169-1177 行):

类型描述后缀
Project' (project instructions, checked into the codebase)'
Local" (user's private project instructions, not checked in)"
TeamMem' (shared team memory, synced across the organization)'
AutoMem" (user's auto-memory, persists across conversations)"
User" (user's private global instructions for all projects)"

加载顺序

getMemoryFiles() (utils/claudemd.ts 第 790 行起) 的顺序:

  1. Managed(始终优先加载,policy 级)
  2. Managed .claude/rules/*.md
  3. User(如果 userSettings 启用)
  4. User ~/.claude/rules/*.md
  5. 文件系统根向 CWD 向下走,每一层检查:
    • CLAUDE.md(Project)
    • .claude/CLAUDE.md(Project)
    • .claude/rules/*.md(Project)
    • CLAUDE.local.md(Local)

Nested worktree 规则

源码里有一个看起来别扭但很合理的分支(第 868-884 行):

// When running from a git worktree nested inside its main repo, the upward
// walk passes through both the worktree root AND the main repo root. Both
// contain checked-in files like CLAUDE.md... Skip Project-type files from
// directories above the worktree but within the main repo.

翻译:如果你从一个嵌套在主仓库里的 worktree 里跑 Claude Code,向上走会碰到主仓库根目录,那里也有 CLAUDE.md。不加特殊处理就会加载两次同样的规则。源码显式 skip。

Issue 引用:github.com/anthropics/claude-code/issues/29599——这是真实 bug 修复留下的补丁,不是假想问题。

MEMORY_INSTRUCTION_PROMPT:override 宣言

CLAUDE.md 内容前面会被加一句字面量指令utils/claudemd.ts 第 89 行):

const MEMORY_INSTRUCTION_PROMPT =
  'Codebase and user instructions are shown below. Be sure to adhere to these instructions. ' +
  'IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.'

这段文字你在每个 Claude Code session 里都能看到。设计意图是 override——用户 / 项目方的指令权威高于默认 prompt。上面讲的 5 层优先级梯度在这里和 CLAUDE.md 层再配合一次:

默认规则 < CLAUDE.md / memory < session guidance < 用户当前消息

后面覆盖前面。层级越清晰,agent 越不会拧巴。

单个 memory 文件的大小上限

// utils/claudemd.ts 第 92 行
export const MAX_MEMORY_CHARACTER_COUNT = 40000

单个 CLAUDE.md 超过 40k 字符会被截断。这是一个具体的工程约束——有 1000 人的 repo 里 CLAUDE.md 容易膨胀,40k 字符约 6-8k tokens。


Env 杀手

Claude Code 暴露了几个环境变量 / CLI flag 可以绕过整个 memory 加载:

变量 / flag效果
CLAUDE_CODE_DISABLE_CLAUDE_MDS完全禁用 CLAUDE.md 加载(硬关)
--bare CLI flag跳过自动发现(CWD 向上走),但仍尊重 --add-dir 显式指定
CLAUDE_CODE_REMOTE跳过 git status(resume 场景省开销)
CLAUDE_CODE_SIMPLE使用极简 system prompt(仅身份 + CWD + date)

源码注释对 --bare 语义的阐释很值得学习:--bare means "skip what I didn't ask for", not "ignore what I asked for"——显式 --add-dir 仍然生效。这是一个细致的语义选择:参数设计应该传达用户意图,而不是机械执行。


Cache 破坏命令(ant-only)

context.ts 里还有一段调试向机制:

// ant-only, ephemeral debugging state
let systemPromptInjection: string | null = null

export function setSystemPromptInjection(value: string | null): void {
  systemPromptInjection = value
  getUserContext.cache.clear?.()
  getSystemContext.cache.clear?.()
}

ant-only 的 BREAK_CACHE_COMMAND 特性启用时,调用 setSystemPromptInjection("任何文本") 会把 [CACHE_BREAKER: 任何文本] 插进 system context——故意打破 prompt cache。用于调试:看看 cache miss 的影响。

给自研 agent 的启示调试时主动打破 cache 的能力应该内置。生产运维里经常要问”如果这个 prompt 今天冷启动会怎样”,有个一键 cache-bust 的开关比硬改代码强。


工具 schema + error 文本:也是 prompt

(这段内容在之前版本已经覆盖得很到位,这里简要回顾并补源码证据)

工具描述本身是 system prompt 的一部分——通过 getUsingYourToolsSection(enabledTools) 注入。每个工具的描述 / schema / error text 都经过了 Anthropic 的 prompt 工程迭代。

例如 Edit 工具的描述:

  • “You must use your Read tool at least once in the conversation before editing”——教 agent 先读再改
  • “This tool will error if you attempt an edit without reading the file”——error text 本身也在指导下一步行为
  • “The edit will FAIL if old_string is not unique in the file”——预告失败条件

给自研 agent 的启示:error text 写得好,agent 遇错能自我纠偏;写得差(“Invalid argument”),agent 死循环重试。


完整装配顺序(真实 session 观察)

系统提示词装配 (System Prompt Assembly) · 10 层 自上而下拼接。越靠前越稳定,命中缓存;最底层每轮都变,付全价。 # 层 (LAYER) 缓存生命周期 (CACHE LIFETIME) 1 静态骨架 (Static Skeleton) 身份 + 策略 + 风格 + 工具使用规则——Anthropic 写死 跨 session 2 环境注入 (Environment Injection) 平台、shell、OS、cwd、git 仓库状态、model ID 跨 session 3 Session Guidance Skills 列表 (L1) · deferred tools 提示 · plan-mode / subagent 上下文 session 内 4 Git Status + Recent Commits Session 启动时的快照——对话过程中不更新 session 内 5 CLAUDE.md (项目 + 用户) 前置 "OVERRIDES default behavior" 指令——授予项目方超越默认的权威 session 内 6 Auto Memory MEMORY.md 索引常驻 (≤ 200 行)——单条文件按需加载 session 内 7 userEmail 自动注入的用户邮箱常量 session 内 8 currentDate 今天的日期——放这里是为了让前缀 cache 保持稳定 session 内 9 Tool Schemas 完整的工具列表 + 教程式描述 + error 提示 session 内 10 Messages (对话历史) 尾部追加——每轮在后面加,前缀 cache 依然有效 每轮变 跨 session 稳定 永不改变——机器上所有 session 之间命中缓存 session 稳定 session 启动时固定——session 内所有轮之间命中缓存 每轮变化 每轮追加——尾部增长保留前缀

图里的 10 层是用户可见的 session level 分层。源码级看是静态 7 段 + 动态 13 段 + context getters 注入,总数更多——图里做了聚合以便读者理解。


给自研 agent 的要点

  1. 系统提示词是装配件,不是字符串。分静态段(cache 友好)+ 动态段(每轮可能变),用显式边界标记分隔——这是 cache 命中率优化的直接落地
  2. 用注册表组织动态段。每段名字 + lazy producer + 是否可 cache。乱塞字符串最终必然有人搞错
  3. 让 cache 破坏显式化。Claude Code 的 DANGEROUS_uncachedSystemPromptSection 命名是故意吓人的——让破 cache 这件事可见可评审
  4. 多层优先级梯度(override > coordinator > agent > custom > default)+ 追加机制(appendSystemPrompt)。单层权威在特殊场景一定会爆
  5. CLAUDE.md 机制:给项目 / 用户 / 企业 / auto-memory 各自的 prompt 接入点,前置显式的 “OVERRIDE” 指令,否则每个项目都要先和默认规则博弈
  6. Env 杀手CLAUDE_CODE_DISABLE_* 之类的环境变量不是奢侈品,是紧急刹车。一定要有一键关的办法
  7. Prompt 指令必须有数据支撑numeric_length_anchors 的 1.2% 遥测是范例——靠定性评判堆 prompt 规则,长期必失控
  8. Tool schema + error text 都是 prompt 工程任务,不是顺手写的 schema doc——这是 agent 自我纠偏能力的关键输入
  9. Memoize session-stable 内容getSystemContext / getUserContext 都 memoize 了——session 内不变的东西算一次就够

延伸阅读

  • Claude Code 源码:constants/prompts.tsutils/systemPrompt.tscontext.tsutils/claudemd.tsutils/memory/types.ts
  • 提示词设计 (Prompt Design)——prompt 分层的一般理论
  • 上下文压缩——这些层在压缩时怎么处理
  • 记忆系统——6 种 memory 类型的详细讲解
这页有帮助吗?