System Prompt 是 Agent 的世界观,CLAUDE.md 是它对这个项目的第一印象。
9.1 核心问题
第 8 章的 Agent 已经能安全执行工具,但它对你的项目一无所知:
用户:这个项目用的是什么框架?
Agent:我没有关于这个项目的信息,请告诉我更多细节。
这不对——Claude Code 打开项目后,应该能"看到"当前目录、理解 git 历史、读取项目级别的说明文件。如何把这些上下文安全、高效地送进 LLM?
具体有三个子问题:
- 静态上下文:
CLAUDE.md这类文件内容要嵌入,但它会变动——何时重新加载? - 动态上下文:git 状态、当前日期每次都不同,如何避免破坏 Prompt Cache?
- Token 预算:System Prompt 越长,留给对话的空间越少——内容截断策略是什么?
9.2 原理讲解
9.2.1 System Prompt 的两种信道
Claude Code 把上下文拆分为两个独立信道,分别走不同的 API 参数:
┌─────────────────────────────────────────────────────────┐
│ Claude API 请求结构 │
├─────────────────────────────────────────────────────────┤
│ system: [ │
│ "你是 Claude Code,一个 CLI 工具...", │ ← systemPrompt
│ "git status: ...", │ ← systemContext
│ ] │
│ messages: [ │
│ { role: "user", content: [ │
│ "<system-reminder> │
│ # claudeMd │
│ # CLAUDE.md\n这是一个 TypeScript 电商系统 │ ← userContext
│ # currentDate\nToday's date is 2025-xx-xx │
│ </system-reminder>", │
│ "这个项目是做什么的?" ← 真实用户消息 │
│ ] │
│ }, │
│ ... │
│ ] │
└─────────────────────────────────────────────────────────┘
system参数(systemPrompt+systemContext):放置角色设定、工具说明、git 状态等messages[0].user(userContext):放置CLAUDE.md内容和当前日期,包裹在<system-reminder>标签内,作为第一条"假用户消息"前置到对话历史
为什么这样设计?原因在于 Prompt Cache 的工作原理:
system参数的角色设定文字几乎不变 → 适合缓存(Anthropic API 的cache_control: ephemeral)- git 状态每次可能不同,但只附加到
system末尾 → 不破坏前面已缓存的部分 CLAUDE.md和日期会随时间变化 → 放进messages而非system,不影响system的缓存命中率
9.2.2 三层上下文:getSystemContext vs getUserContext
src/context.ts 导出两个 memoized 异步函数,在每次会话中只执行一次:
// src/context.ts
// 信道 1:追加到 system prompt 末尾
export const getSystemContext = memoize(async () => {
const gitStatus = await getGitStatus() // git branch + status + log
return { gitStatus }
})
// 信道 2:注入到 messages[0] 的 <system-reminder> 内
export const getUserContext = memoize(async () => {
const claudeMd = await getClaudeMds(...) // 四层 CLAUDE.md 合并内容
return {
claudeMd,
currentDate: `Today's date is ${getLocalISODate()}`,
}
})
为什么是 memoize?
memoize 来自 lodash,第一次调用时执行 I/O 并缓存结果,后续调用直接返回缓存。这保证整个会话只执行一次 git status 和文件读取,避免频繁磁盘 I/O。
缓存失效:setSystemPromptInjection() 函数(调试用)会主动清空缓存:
export function setSystemPromptInjection(value: string | null): void {
systemPromptInjection = value
getUserContext.cache.clear?.() // 强制下次重新获取
getSystemContext.cache.clear?.()
}
9.2.3 git 状态的采集
getGitStatus() 并行执行 5 个 git 命令(Promise.all):
const [branch, mainBranch, status, log, userName] = await Promise.all([
getBranch(), // git branch --show-current
getDefaultBranch(), // git symbolic-ref refs/remotes/origin/HEAD
execFileNoThrow(['git', 'status', '--short']),
execFileNoThrow(['git', 'log', '--oneline', '-n', '5']),
execFileNoThrow(['git', 'config', 'user.name']),
])
组装结果:
这是对话开始时的 git 状态快照。注意:此状态是时间点快照,不会在对话中更新。
Current branch: feature/payment
Main branch (通常用于 PR): main
Git user: Zhang San
Status:
M src/cart.ts
?? src/payment.ts
Recent commits:
abc1234 feat: 添加购物车结算功能
def5678 fix: 修复用户登录 token 刷新 bug
截断保护:git status 超过 2000 字符时自动截断,避免因大量未提交文件撑爆 context。
跳过条件:
- 不在 git 仓库内(
getIsGit()返回 false) - 设置了
CLAUDE_CODE_REMOTE环境变量(远程执行模式,减少不必要开销) - 通过
shouldIncludeGitInstructions()被禁用
9.2.4 CLAUDE.md:四层记忆文件系统
CLAUDE.md 是 Claude Code 最重要的"长期记忆载体"。src/utils/claudemd.ts 开头的注释完整描述了加载顺序:
加载顺序(优先级从低到高,后加载的权重更高):
1. Managed Memory /etc/claude-code/CLAUDE.md
└─ 企业管理员下发的全局策略(所有用户共享)
2. User Memory ~/.claude/CLAUDE.md
~/.claude/CLAUDE.local.md
~/.claude/rules/*.md
└─ 用户的个人偏好(跨所有项目)
3. Project Memory ./CLAUDE.md
./.claude/CLAUDE.md
./.claude/rules/*.md
└─ 项目级别说明(提交到 git,团队共享)
4. Local Memory ./CLAUDE.local.md
└─ 私密项目配置(在 .gitignore 中,不提交)
向上穿越目录:getMemoryFiles() 从当前目录开始,沿着目录树向上遍历直到根目录(/),在每一层都尝试读取上述四类文件。这意味着 /home/user/work/myproject/src/utils/ 目录下启动时,会读取 6 个层级的 CLAUDE.md。
@include 指令:CLAUDE.md 内可以用 @path/to/file 引入其他文件(支持相对路径、~/ 家目录路径和绝对路径)。Claude Code 会递归展开这些引用,并进行循环检测。
<!-- CLAUDE.md 示例 -->
# 项目约定
@./docs/api-conventions.md ← 引入 API 规范文档
@./docs/database-schema.md ← 引入数据库设计
paths: frontmatter 过滤:CLAUDE.md 可以添加 frontmatter,只对特定路径生效:
---
paths:
- src/frontend/**
- "*.tsx"
---
# 前端规范
总是使用 Tailwind CSS,不要使用 inline style。
当用户在 src/backend/ 目录操作时,这条规则会被自动过滤掉。
字符数限制:单个 CLAUDE.md 推荐不超过 40,000 字符(MAX_MEMORY_CHARACTER_COUNT),超出时会触发截断警告。
HTML 注释剥离:stripHtmlComments() 函数在加载时自动去除 <!-- ... --> 注释,让 CLAUDE.md 可以用注释写说明而不消耗 token。
claudeMdExcludes 设置:用户可以在 settings.json 里通过 claudeMdExcludes 配置 glob 模式来排除特定的 CLAUDE.md 文件,防止意外加载不可信目录的指令。
9.2.5 System Prompt 的完整组装流程
src/utils/queryContext.ts 中的 fetchSystemPromptParts() 是系统提示的"总装线":
export async function fetchSystemPromptParts({ tools, mainLoopModel, mcpClients, customSystemPrompt }) {
const [defaultSystemPrompt, userContext, systemContext] = await Promise.all([
customSystemPrompt !== undefined
? Promise.resolve([])
: getSystemPrompt(tools, mainLoopModel, ...), // 角色设定 + 工具说明
getUserContext(), // CLAUDE.md + 当前日期
customSystemPrompt !== undefined
? Promise.resolve({})
: getSystemContext(), // git 状态
])
return { defaultSystemPrompt, userContext, systemContext }
}
三路并行获取完成后,在 query.ts 里按两个信道分别组装:
// 信道 1:system 参数
const systemPromptWithContext = appendSystemContext(systemPrompt, systemContext)
// → [...defaultSystemPrompt, "gitStatus: ...\n"]
// 信道 2:messages 前置一条假用户消息
const messagesWithContext = prependUserContext(messages, userContext)
// → [{ role: 'user', content: '<system-reminder>\n# claudeMd\n...\n# currentDate\n...' }, ...realMessages]
appendSystemContext 的实现(src/utils/api.ts):
export function appendSystemContext(systemPrompt, context) {
return [
...systemPrompt,
Object.entries(context)
.map(([key, value]) => `${key}: ${value}`)
.join('\n'),
].filter(Boolean)
}
prependUserContext 的实现(src/utils/api.ts):
export function prependUserContext(messages, context) {
if (Object.entries(context).length === 0) return messages
return [
createUserMessage({
content: `<system-reminder>\n...
${Object.entries(context).map(([key, value]) => `# ${key}\n${value}`).join('\n')}
IMPORTANT: this context may or may not be relevant to your tasks...\n</system-reminder>\n`,
isMeta: true, // 标记为元消息,不计入对话轮次
}),
...messages,
]
}
9.2.6 Static / Dynamic 边界与 Prompt Cache
src/constants/prompts.ts 中定义了一个重要边界标记:
export const SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'
getSystemPrompt() 的返回值按此标记分为两段:
system prompt 数组:
[
"你是 Claude Code...", ← 静态,可全局缓存(scope: 'global')
"# System\n- 所有输出...", ← 静态
"# Doing tasks\n...", ← 静态
...,
"__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__", ← 边界
"# 环境信息\nCWD: /home/user/work", ← 动态,每次都不同
"git log: ...", ← 动态
]
splitSysPromptPrefix()(src/utils/api.ts)按此边界分割,边界前的部分带 cache_control: { type: 'ephemeral' } 发给 Anthropic API,命中缓存可节省大量输入 token 费用。
9.3 源码细节
9.3.1 getSystemPrompt() 的结构
// src/constants/prompts.ts
export async function getSystemPrompt(tools, model, ...): Promise<string[]> {
// 构建静态段(角色设定、工具说明、行为规范)
return [
getSimpleIntroSection(), // "你是 Claude Code..."
getSimpleSystemSection(), // "# System\n..."
getSimpleDoingTasksSection(), // "# Doing tasks\n..."
getActionsSection(), // "# Actions\n..."
getUsingYourToolsSection(), // "# Available tools\n..."
getSimpleToneAndStyleSection(), // "# Tone and style\n..."
getOutputEfficiencySection(), // "# Output efficiency\n..."
SYSTEM_PROMPT_DYNAMIC_BOUNDARY, // === 边界 ===
// 动态段(registry-managed,通过 systemPromptSection() 注册)
...resolvedDynamicSections, // env_info, language, mcp_instructions...
].filter(s => s !== null)
}
每个动态 section 通过 systemPromptSection(name, computeFn) 注册,resolveSystemPromptSections() 并行执行所有 compute 函数,并做结果缓存(同名 section 在一次 query 中只计算一次)。
9.3.2 环境信息段(env_info)
// computeSimpleEnvInfo() 生成的内容示例:
`# Environment
- Working directory: /home/user/work/myproject
- Is directory a git repo: Yes
- Platform: macOS 14.5 (Darwin 23.5.0)
- Today's date: 2025-03-12
- Model: claude-sonnet-4-6
- Claude Code version: 1.x.x`
此段位于动态边界之后,每次可能变化(working directory 在 cd 后改变),因此不加全局缓存标记。
9.3.3 isMeta 标记与消息过滤
prependUserContext 注入的假用户消息带有 isMeta: true 标记。这个标记在多处生效:
- 对话历史展示:终端 UI 不展示
isMeta的消息 - 消息计数:计算"用户发了几条消息"时跳过元消息
- 压缩策略:会话压缩时元消息按特殊规则处理(不影响压缩后的记忆恢复)
9.3.4 CLAUDE_CODE_DISABLE_CLAUDE_MDS 环境变量
// src/context.ts
const shouldDisableClaudeMd =
isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS) ||
(isBareMode() && getAdditionalDirectoriesForClaudeMd().length === 0)
两种情况下完全跳过 CLAUDE.md 加载:
- 显式设置
CLAUDE_CODE_DISABLE_CLAUDE_MDS=1 - 启用了
--bare模式(最小化模式)且没有通过--add-dir显式指定目录
--bare 的语义是"跳过自动发现,但不忽略显式要求"——如果用户用 --add-dir /path/to/docs 显式指定了额外目录,即使在 --bare 模式下也会加载那个目录的 CLAUDE.md。
9.3.5 工作目录感知(git worktree)
getMemoryFiles() 包含一段特殊逻辑处理 git worktree 嵌套场景:
场景:用户在 /repo/.claude/worktrees/feature-x/ 下工作
这是主仓库 /repo/ 内的一个 worktree 目录
问题:向上遍历目录时会同时读到:
- /repo/.claude/worktrees/feature-x/CLAUDE.md (worktree 的)
- /repo/CLAUDE.md (主仓库的)
→ 同一内容被加载两次!
解决:检测到嵌套 worktree 时,跳过主仓库目录中的 Project 类型文件
(CLAUDE.local.md 是 gitignored,只存在于主仓库,仍然加载)
9.4 最小化产出物
代码骨架位于
../chapters/09/src/,参考实现位于../chapters/09/solution/。 前置条件:需要ANTHROPIC_API_KEY环境变量(npm start需要)。
本章要实现什么
在 ../chapters/09/src/context.ts 中完成上下文采集和双信道组装。
接口规范(已提供,不要修改):
export async function getSystemContext(): Promise<Record<string, string>>
// → 采集 git 状态,返回 { gitStatus: string } 或 {}
export async function getUserContext(): Promise<Record<string, string>>
// → 读取 CLAUDE.md + 当前日期,返回 { claudeMd?, currentDate }
export function appendSystemContext(systemParts: string[], context: Record<string, string>): string[]
// → 将 context 追加到 system prompt 数组末尾
export function prependUserContext(messages: Anthropic.MessageParam[], context: Record<string, string>): Anthropic.MessageParam[]
// → 在 messages 开头插入 <system-reminder> 消息
你需要实现:
getSystemContext():并行执行 git 命令(branch/status/log),组装结构化文本,超过 2000 字符截断getUserContext():读取~/.claude/CLAUDE.md和./CLAUDE.md,获取当前日期appendSystemContext():将 context 的 key-value 格式化后追加到 system 数组prependUserContext():若 context 非空,在 messages 开头插入<system-reminder>消息
关键约束:
- 双信道设计:git 状态走
system参数,CLAUDE.md + 日期走messages[0]的<system-reminder> - 不在 git 仓库内时,
getSystemContext()返回{}(不报错)
验收
cd docs/chapters/09
npm install
npm test
卡住时查看 ../chapters/09/solution/context.ts。
/**
* 第 9 章产出物:上下文注入
*
* 在第 8 章基础上,添加:
* 1. getSystemContext() — 读取 git 状态,追加到 system 末尾
* 2. getUserContext() — 读取 CLAUDE.md + 当前日期,前置到 messages[0]
*
* 设计与 Claude Code 保持一致:
* - systemContext → appendSystemContext(system, systemContext)
* - userContext → prependUserContext(messages, userContext)
*/
### 验收步骤