3.2 系统提示词的构建
系统提示词是上下文中最稳定的部分——它定义了模型"是谁"以及"该怎么做"。正因为稳定,它也是提示词缓存的最佳候选。Claude Code 的系统提示词构建在稳定性和灵活性之间做了精心平衡。
归属头(Attribution Header)
基于指纹的身份标识,用于追踪请求来源。
CLI 系统提示词前缀
根据运行模式变化:交互式模式(REPL)和 -p 单次查询模式有不同的前缀指令。
系统提示词优先级
系统提示词的构建有严格的优先级,由 buildEffectiveSystemPrompt()(src/utils/systemPrompt.ts)实现:
// 优先级从高到低:
// 0. overrideSystemPrompt — 完全覆盖(如 loop 模式)
// 1. coordinatorSystemPrompt — 协调器模式(Feature-gated)
// 2. agentSystemPrompt — Agent 定义的提示词
// - Proactive 模式:追加到默认提示词后面
// - 普通模式:替换默认提示词
// 3. customSystemPrompt — --system-prompt 参数指定
// 4. defaultSystemPrompt — 标准 Claude Code 提示词
// + appendSystemPrompt 始终追加到末尾(除 override 模式)这个优先级链确保了不同运行模式(交互、Agent、协调器、SDK)都能获得正确的系统提示词,同时保留用户自定义的能力。
静态/动态边界标记
系统提示词中有一个关键的设计元素——SYSTEM_PROMPT_DYNAMIC_BOUNDARY(src/constants/prompts.ts:114)。这是一个哨兵字符串 __SYSTEM_PROMPT_DYNAMIC_BOUNDARY__,它将系统提示词数组分成两半:
- 边界之前:核心指令、工具描述、安全规则等——对所有用户的所有会话都完全相同的内容
- 边界之后:MCP 工具指令、输出风格、语言偏好等——因用户/会话而异的内容
为什么需要这个边界?因为它直接影响提示词缓存的效率。边界之前的静态部分可以使用 scope: 'global' 缓存,跨所有用户共享——这意味着全球数百万 Claude Code 用户可以共享同一份缓存的核心系统提示词。边界之后的动态部分则只能用 scope: 'org' 或不缓存。没有这个边界,整个系统提示词都只能做 org 级别缓存,浪费大量缓存存储在完全相同的内容上。
Section-Level 缓存
系统提示词的各个组成部分通过 systemPromptSections.ts 实现了 section 级别的缓存。这里有两种类型:
// 计算一次,缓存到 /clear 或 /compact
systemPromptSection('toolInstructions', () => buildToolPrompt(...))
// 每轮重新计算,会破坏提示词缓存
DANGEROUS_uncachedSystemPromptSection(
'mcp_instructions',
() => getMcpInstructionsSection(mcpClients),
'MCP servers connect/disconnect between turns' // 必须提供理由
)DANGEROUS_ 前缀是刻意为之的代码级警示——它提醒开发者:这个 section 每轮都会重新计算,如果值发生变化会破坏提示词缓存。开发者必须提供一个 _reason 参数解释为什么缓存破坏是必要的。大多数 section 都是稳定的(工具描述、安全规则),只有少数依赖轮次间可能变化的运行时状态(例如 MCP server 可能在轮次之间连接/断开)的 section 需要使用 DANGEROUS_ 变体。
clearSystemPromptSections() 在 /clear 和 /compact 时调用,同时重置 beta header 锁存(详见 3.6 第二层防御),让下一次对话获得完全新鲜的状态。
系统上下文(getSystemContext)
来自 src/context.ts 的 getSystemContext() 函数,被 memoize 缓存(每会话只计算一次)。
完整的实现展示了一个精心设计的上下文收集过程:
// src/context.ts — getGitStatus()
export const getGitStatus = memoize(async (): Promise<string | null> => {
const isGit = await getIsGit()
if (!isGit) return null
try {
// 5 个 git 命令并行执行
const [branch, mainBranch, status, log, userName] = await Promise.all([
getBranch(),
getDefaultBranch(),
execFileNoThrow(gitExe(), ['--no-optional-locks', 'status', '--short'], ...)
.then(({ stdout }) => stdout.trim()),
execFileNoThrow(gitExe(), ['--no-optional-locks', 'log', '--oneline', '-n', '5'], ...)
.then(({ stdout }) => stdout.trim()),
execFileNoThrow(gitExe(), ['config', 'user.name'], ...)
.then(({ stdout }) => stdout.trim()),
])
// 状态截断至 2000 字符,防止大量未提交文件撑爆上下文
const truncatedStatus = status.length > MAX_STATUS_CHARS
? status.substring(0, MAX_STATUS_CHARS) +
'\n... (truncated because it exceeds 2k characters...)'
: status
return [
// 这条 disclaimer 至关重要——它告诉模型 git 状态是会话开始时的快照,
// 防止模型在后续轮次中"幻觉"出实时的 git 状态更新
`This is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation.`,
`Current branch: ${branch}`,
`Main branch (you will usually use this for PRs): ${mainBranch}`,
...(userName ? [`Git user: ${userName}`] : []),
`Status:\n${truncatedStatus || '(clean)'}`,
`Recent commits:\n${log}`,
].join('\n\n')
} catch (error) {
logError(error)
return null
}
})值得注意的设计细节:
Promise.all并行:5 个 git 命令同时执行,而不是串行等待——这在大型仓库中可以节省数百毫秒--no-optional-locks:避免 git 命令获取锁导致与其他 git 操作冲突MAX_STATUS_CHARS = 2000:限制状态输出长度。想象一个有 500 个未提交文件的 monorepo——不截断的话,git status 本身就会消耗大量上下文预算- Disclaimer 文本:明确告诉模型这是快照,不会实时更新——这是防止模型幻觉的重要手段
getSystemContext() 本身还有条件跳过逻辑:
export const getSystemContext = memoize(async () => {
// CCR(Cloud Code Remote)模式或禁用 git-instructions 时跳过
const gitStatus =
isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) ||
!shouldIncludeGitInstructions()
? null
: await getGitStatus()
// 缓存断裂注入(内部调试功能,Feature-gated)
const injection = feature('BREAK_CACHE_COMMAND')
? getSystemPromptInjection()
: null
return {
...(gitStatus && { gitStatus }),
...(injection ? { cacheBreaker: `[CACHE_BREAKER: ${injection}]` } : {}),
}
})用户上下文(getUserContext)
export const getUserContext = memoize(async () => {
// --bare 模式的微妙语义:
// - CLAUDE_CODE_DISABLE_CLAUDE_MDS: 硬关闭,始终生效
// - --bare: 跳过自动发现(CWD 遍历),但尊重显式 --add-dir
// 注释原文:"bare means skip what I didn't ask for, not ignore what I asked for"
const shouldDisableClaudeMd =
isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS) ||
(isBareMode() && getAdditionalDirectoriesForClaudeMd().length === 0)
const claudeMd = shouldDisableClaudeMd
? null
: getClaudeMds(filterInjectedMemoryFiles(await getMemoryFiles()))
// 缓存给 yoloClassifier 使用,避免创建 import 循环
setCachedClaudeMdContent(claudeMd || null)
return {
...(claudeMd && { claudeMd }),
currentDate: `Today's date is ${getLocalISODate()}.`,
}
})CLAUDE.md 发现机制
CLAUDE.md 是 Claude Code 的 项目级指令文件,类似于 .editorconfig 或 .eslintrc,但面向 AI Agent。它的发现过程比看起来要复杂得多。
发现顺序(getMemoryFiles()):
- 管理策略文件:从 MDM(移动设备管理)策略中读取的指令(如
/etc/claude-code/CLAUDE.md) - 用户主目录:
~/.claude/CLAUDE.md下的全局配置 - 项目文件:从 CWD 向上遍历目录树,查找每一层的指令文件
- 本地文件:
CLAUDE.local.md(不提交到 git 的个人指令) - 显式附加目录:
--add-dir参数指定的额外目录
文件名模式:每个目录下检查 CLAUDE.md、.claude/CLAUDE.md,以及 .claude/rules/ 目录下的所有 .md 文件。这意味着你可以将不同领域的指令拆分成独立文件(如 .claude/rules/testing.md、.claude/rules/style.md),系统会自动加载它们。
优先级排序:文件按从远到近的顺序加载——靠近 CWD 的文件后加载,因此优先级更高。这符合"就近原则":项目根目录的全局规则可以被子目录的局部规则覆盖。由于 LLM 对上下文末尾的内容关注度更高(近因效应),后加载的指令在模型的"注意力"中权重更大。
@include 指令(src/utils/claudemd.ts):
CLAUDE.md 文件可以通过 @ 语法引用其他文件:
# 项目指令
@./docs/coding-standards.md
@~/global-rules.md
@/etc/company-policy.md@path(无前缀)等同于@./path,按相对路径解析@~/path从用户主目录解析@/path按绝对路径解析- 只在叶子文本节点中生效(代码块内的
@不会被解析) - 被引用的文件作为独立条目插入到引用文件之前
- 通过跟踪已处理文件防止循环引用
- 只允许文本文件扩展名(.md、.txt 等),防止加载二进制文件
过滤:filterInjectedMemoryFiles() 在 feature flag(tengu_moth_copse)开启时剔除 AutoMem / TeamMem 类型的记忆条目——这些自动记忆改由附件消息按需注入,不再进入 CLAUDE.md 上下文块
缓存失效:clearMemoryFileCaches() 纯清缓存、不触发 hook(worktree 切换、settings 同步时用);resetGetMemoryFilesCache() 在压缩 / /clear 时调用,使下一次 getMemoryFiles() 重新加载并以相应原因触发 InstructionsLoaded hook
上下文注入顺序
// src/utils/api.ts
const fullSystemPrompt = asSystemPrompt(
appendSystemContext(systemPrompt, systemContext) // 系统上下文后置
)
// userContext 在消息前置(prependUserContext)系统上下文后置于系统提示词,用户上下文前置于消息——这个顺序影响提示词缓存的效率。系统提示词是最稳定的部分(跨请求不变),放在最前面有利于缓存命中;而用户上下文(CLAUDE.md、日期)可能随会话变化,放在消息前面不会破坏系统提示词的缓存。