第 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)
*/
### 验收步骤