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

# 第 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].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** 异步函数,在每次会话中只执行一次:

```typescript
// 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()` 函数(调试用)会主动清空缓存:
```typescript
export function setSystemPromptInjection(value: string | null): void {
  systemPromptInjection = value
  getUserContext.cache.clear?.()   // 强制下次重新获取
  getSystemContext.cache.clear?.()
}
```

### 9.2.3 git 状态的采集

`getGitStatus()` 并行执行 5 个 git 命令(`Promise.all`):

```typescript
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 会递归展开这些引用,并进行循环检测。

```markdown
<!-- CLAUDE.md 示例 -->
# 项目约定
@./docs/api-conventions.md    ← 引入 API 规范文档
@./docs/database-schema.md   ← 引入数据库设计
```

**`paths:` frontmatter 过滤**:CLAUDE.md 可以添加 frontmatter,只对特定路径生效:

```markdown
---
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()` 是系统提示的"总装线":

```typescript
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` 里按两个信道分别组装:

```typescript
// 信道 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`):
```typescript
export function appendSystemContext(systemPrompt, context) {
  return [
    ...systemPrompt,
    Object.entries(context)
      .map(([key, value]) => `${key}: ${value}`)
      .join('\n'),
  ].filter(Boolean)
}
```

**`prependUserContext` 的实现**(`src/utils/api.ts`):
```typescript
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` 中定义了一个重要边界标记:

```typescript
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()` 的结构

```typescript
// 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)

```typescript
// 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` 环境变量

```typescript
// 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` 中完成上下文采集和双信道组装。

**接口规范**(已提供,不要修改):

```typescript
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()` 返回 `{}`(不报错)

### 验收

```bash
cd docs/chapters/09
npm install
npm test
```

卡住时查看 `../chapters/09/solution/context.ts`。

```typescript
/**
 * 第 9 章产出物:上下文注入
 *
 * 在第 8 章基础上,添加:
 *   1. getSystemContext()  — 读取 git 状态,追加到 system 末尾
 *   2. getUserContext()    — 读取 CLAUDE.md + 当前日期,前置到 messages[0]
 *
 * 设计与 Claude Code 保持一致:
 *   - systemContext → appendSystemContext(system, systemContext)
 *   - userContext   → prependUserContext(messages, userContext)
 */

### 验收步骤