第 9 章:上下文注入(System Prompt)

System Prompt 是 Agent 的世界观,CLAUDE.md 是它对这个项目的第一印象。


9.1 核心问题

第 8 章的 Agent 已经能安全执行工具,但它对你的项目一无所知:

用户:这个项目用的是什么框架?
Agent:我没有关于这个项目的信息,请告诉我更多细节。

这不对——Claude Code 打开项目后,应该能"看到"当前目录、理解 git 历史、读取项目级别的说明文件。如何把这些上下文安全、高效地送进 LLM?

具体有三个子问题:

  1. 静态上下文CLAUDE.md 这类文件内容要嵌入,但它会变动——何时重新加载?
  2. 动态上下文:git 状态、当前日期每次都不同,如何避免破坏 Prompt Cache?
  3. 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].useruserContext):放置 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 加载:

  1. 显式设置 CLAUDE_CODE_DISABLE_CLAUDE_MDS=1
  2. 启用了 --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> 消息

你需要实现

  1. getSystemContext():并行执行 git 命令(branch/status/log),组装结构化文本,超过 2000 字符截断
  2. getUserContext():读取 ~/.claude/CLAUDE.md./CLAUDE.md,获取当前日期
  3. appendSystemContext():将 context 的 key-value 格式化后追加到 system 数组
  4. 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)
 */

### 验收步骤