Claude Code 源码:系统提示词
导航
- 🧠 概念派? → 什么是系统提示词 — 它决定了 LLM 的行为边界
- ⚙️ 机制派? → DYNAMIC_BOUNDARY — 如何在缓存和动态之间平衡
- 🔄 流程派? → 每轮重组 — queryLoop 如何动态注入上下文
目录
第一部分:概念理解
第二部分:核心机制
第三部分:动态内容注入
第四部分:调试与 FAQ
极限场景:用户调用 /commit 时发生了什么
想象这样一个时刻:
用户在对话中途输入
/commit,此时系统需要注入完整的 commit skill 指令(约 2000 tokens)。但对话历史已经占用了 150k tokens,系统提示词的静态部分(角色定义、基础规则)已经通过 Prompt Cache 缓存。
这一刻,系统面临三个并发问题:
- 如何注入 2000 tokens 的 skill 内容? — 如果系统提示词是固定的,这 2000 tokens 早就被计入缓存,无法动态替换
- 如何保持缓存命中率? — 静态部分(角色定义)不应该因为 skill 注入而失效
- 如何在每轮对话中重新决定注入什么? — 下一轮用户可能不再需要 commit skill,但需要其他记忆片段
这三个问题,分别对应系统提示词的三个设计:DYNAMIC_BOUNDARY 分界标记、静态/动态分离、每轮重组机制。
后面的每一节,都是这个极限场景的一个答案。
什么是系统提示词
系统提示词(System Prompt)是发送给 LLM 的第一条消息,它定义了 AI 的角色、能力和行为规则。在 Claude Code 中,系统提示词决定了:
- 角色定义:你是 Kiro,一个 AI 助手和 IDE
- 能力边界:你可以读写文件、执行命令、调用工具
- 行为规则:如何与用户交互、如何处理错误、如何管理上下文
- 动态上下文:当前可用的工具列表、相关记忆、用户上下文
┌─────────────────────────────────────┐
│ System Prompt (系统提示词) │
├─────────────────────────────────────┤
│ 静态部分: │
│ - 角色定义("你是 Kiro...") │
│ - 基础规则("不要创建 README...") │
│ - 工具使用指南 │
├─────────────────────────────────────┤
│ DYNAMIC_BOUNDARY (分界标记) │
├─────────────────────────────────────┤
│ 动态部分(每轮重新计算): │
│ - 工具列表(MCP 工具可能运行时可用)│
│ - 记忆片段(根据用户消息选择) │
│ - Skill 指令(用户调用时才注入) │
│ - 用户上下文(git 状态、环境信息) │
└─────────────────────────────────────┘
为什么不是一次性生成?
因为动态内容会变化:
- 用户调用
/commit时需要注入 commit skill - 用户提到"之前的重构"时需要注入相关记忆
- MCP 工具可能在运行时才连接成功
如果系统提示词是固定的,这些动态内容无法注入。
三层结构:静态 + 边界 + 动态
系统提示词由三层组成:
| 层级 | 内容 | 何时生成 | 是否缓存 |
|---|---|---|---|
| 静态前缀 | 角色定义、基础规则、工具使用指南 | 对话开始时 | ✅ 命中 Prompt Cache |
| DYNAMIC_BOUNDARY | 分界标记 __SYSTEM_PROMPT_DYNAMIC_BOUNDARY__ | 对话开始时 | 标记位置 |
| 动态上下文 | 工具列表、记忆、skill、用户上下文 | 每轮重新计算 | ❌ 不缓存 |
代码位置
// src/constants/prompts.ts
export const SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'
export function buildSystemPrompt(/* ... */): string[] {
return [
// --- 静态内容(可缓存)---
getSimpleIntroSection(outputStyleConfig), // 角色定义:"你是 Kiro..."
getSimpleSystemSection(), // 系统说明:工具使用、会话管理
getSimpleDoingTasksSection(), // 任务执行指南
getActionsSection(), // 执行动作的注意事项
getUsingYourToolsSection(enabledTools), // 工具使用规范
getSimpleToneAndStyleSection(), // 语气和风格
getOutputEfficiencySection(), // 输出效率要求
// === 边界标记 - 不要移动或删除 ===
...(shouldUseGlobalCacheScope() ? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY] : []),
// --- 动态内容(每轮重新计算)---
...resolvedDynamicSections, // 💡 包含记忆、skill、工具、用户上下文等
].filter(s => s !== null)
}
为什么需要分界标记?
因为 Prompt Cache 的工作原理:
- 缓存是前缀匹配的:只有完全相同的前缀才能命中缓存
- 如果动态内容混在静态内容中,每次变化都会导致缓存失效
- 通过 DYNAMIC_BOUNDARY 分界,静态部分永远不变,动态部分每轮重新计算
DYNAMIC_BOUNDARY 的缓存优化
极限场景
用户在对话中途调用 /commit,系统需要注入 2000 tokens 的 skill 内容。如果没有 DYNAMIC_BOUNDARY:
❌ 错误方案:动态内容混在静态内容中
[角色定义] + [commit skill] + [基础规则] + [工具列表]
↑ 每次变化都导致整个系统提示词失效
有了 DYNAMIC_BOUNDARY:
✅ 正确方案:静态和动态分离
[角色定义] + [基础规则] + [BOUNDARY] + [commit skill] + [工具列表]
↑ 这部分永远不变,命中缓存 ↑ 这部分每轮重新计算,不缓存
缓存作用域
// src/utils/api.ts
function buildSystemPromptBlocks(systemPrompt: string[]): SystemPromptBlock[] {
const boundaryIndex = systemPrompt.findIndex(
s => s === SYSTEM_PROMPT_DYNAMIC_BOUNDARY
)
if (boundaryIndex !== -1) {
const staticBlocks: string[] = []
const dynamicBlocks: string[] = []
for (let i = 0; i < systemPrompt.length; i++) {
const block = systemPrompt[i]
if (!block || block === SYSTEM_PROMPT_DYNAMIC_BOUNDARY) continue
if (i < boundaryIndex) {
staticBlocks.push(block) // 💡 在边界之前 → 全局缓存
} else {
dynamicBlocks.push(block) // 💡 在边界之后 → 不缓存
}
}
return [
{ text: staticBlocks.join('\n\n'), cacheScope: 'global' }, // ✅ 缓存
{ text: dynamicBlocks.join('\n\n'), cacheScope: null }, // ❌ 不缓存
]
}
}
工作原理:
- 查找边界:在系统提示词数组中找到
DYNAMIC_BOUNDARY的位置 - 分离内容:边界之前的是静态内容,边界之后的是动态内容
- 设置缓存作用域:
- 静态内容:
cacheScope: 'global'→ 全局缓存,所有用户共享 - 动态内容:
cacheScope: null→ 不缓存,每次重新计算
- 静态内容:
缓存命中率对比
| 场景 | 无 DYNAMIC_BOUNDARY | 有 DYNAMIC_BOUNDARY |
|---|---|---|
用户调用 /commit | 整个系统提示词失效 | 只有动态部分重新计算 |
| 用户消息变化,记忆选择不同 | 整个系统提示词失效 | 静态部分命中缓存 |
| MCP 工具连接成功 | 整个系统提示词失效 | 静态部分命中缓存 |
设计动机:
- 静态部分(角色定义、基础规则)占系统提示词的 80%,但几乎不变
- 动态部分(工具、记忆、skill)占 20%,但每轮都可能变化
- 通过分界标记,80% 的内容命中缓存,只有 20% 重新计算
每轮对话中的系统提示词重组
极限场景
用户在对话中途调用 /commit,系统需要:
- 注入 commit skill 的完整指令(2000 tokens)
- 保持静态部分的缓存命中
- 下一轮如果用户不再需要 commit skill,自动移除
这要求系统提示词不是一次性生成的,而是每轮对话都重新组装。
queryLoop 中的重组逻辑
// src/query/query.ts (伪代码)
async function* queryLoop(params: QueryParams) {
let needsFollowUp = true
while (needsFollowUp) {
// 💡 每轮都重新组装系统提示词
const systemPrompt = await buildSystemPrompt({
// 1. 动态获取工具列表(MCP 工具可能在运行时才可用)
tools: getAvailableTools(),
// 2. 根据用户消息选择相关记忆
memory: selectRelevantMemory(userMessage),
// 3. 如果用户调用了 skill,加载 skill 内容
skill: loadSkillIfRequested(userMessage),
// 4. 获取用户上下文(git 状态、环境信息)
userContext: await getUserContext(),
})
// 5. 调用 Claude API
const response = await callClaude({
system: systemPrompt, // 💡 每轮都是新的系统提示词
messages: conversationHistory,
})
// 6. 处理响应,决定是否继续循环
needsFollowUp = response.stop_reason === 'tool_use'
}
}
关键点:
- 每轮重组:
buildSystemPrompt()在 while 循环内部调用,不是在循环外 - 动态依赖:工具列表、记忆、skill 都是根据当前状态动态计算的
- 缓存优化:静态部分(DYNAMIC_BOUNDARY 之前)命中缓存,动态部分重新计算
动态内容注入对比表
| 注入内容 | 注入时机 | 注入位置 | 是否缓存 | 代码位置 |
|---|---|---|---|---|
| 角色定义 | 对话开始时 | DYNAMIC_BOUNDARY 之前 | ✅ 命中 | src/constants/prompts.ts |
| 基础规则 | 对话开始时 | DYNAMIC_BOUNDARY 之前 | ✅ 命中 | src/constants/prompts.ts |
| 工具列表 | 每轮重新生成 | DYNAMIC_BOUNDARY 之后 | ❌ 不命中 | src/tools/ |
| 记忆片段 | 每轮根据相关性选择 | DYNAMIC_BOUNDARY 之后 | ❌ 不命中 | src/memdir/memdir.ts |
| Skill 指令 | 用户调用时才注入 | DYNAMIC_BOUNDARY 之后 | ❌ 不命中 | src/skills/ |
| User Context | 每轮更新 | 消息列表前 | ❌ 不命中 | src/utils/api.ts |
为什么每轮都重组?
因为动态内容的依赖关系:
- 工具列表依赖 MCP 连接状态(可能在运行时变化)
- 记忆片段依赖用户消息的关键词(每轮不同)
- Skill 指令依赖用户是否调用了 skill(可能只在某一轮)
- User Context依赖 git 状态、环境信息(可能变化)
如果只在对话开始时生成一次,这些动态内容无法更新。
动态记忆注入
极限场景
用户在对话中提到"之前的重构",系统需要:
- 从 MEMORY.md 索引中找到相关记忆文件
- 读取记忆内容,注入到系统提示词
- 下一轮如果用户不再提到重构,自动移除该记忆
这要求记忆注入是动态的,而不是一次性加载所有记忆。
Memory Prefetch vs 动态选择
| 阶段 | 时机 | 作用 |
|---|---|---|
| Memory Prefetch | 对话开始时 | 预加载 MEMORY.md 索引,准备记忆文件列表 |
| 动态选择 | 每轮对话 | 根据用户消息的关键词,选择相关记忆注入 |
// src/memdir/memdir.ts (伪代码)
function selectRelevantMemory(userMessage: string): string[] {
// 1. 读取 MEMORY.md 索引
const index = readMemoryIndex()
// 2. 根据用户消息的关键词,选择相关记忆文件
const relevantFiles = index.filter(file =>
isRelevant(file.description, userMessage) // 💡 相关性判断
)
// 3. 读取记忆内容,注入到系统提示词
return relevantFiles.map(file => readMemoryFile(file.path))
}
相关性判断逻辑:
- 提取用户消息的关键词(如"重构"、"认证"、"性能")
- 与 MEMORY.md 中每个记忆的
description字段匹配 - 选择匹配度最高的 N 个记忆文件(通常 3-5 个)
注入位置
记忆内容注入到 DYNAMIC_BOUNDARY 之后,作为 system context:
[角色定义] + [基础规则] + [BOUNDARY] + [记忆片段] + [工具列表]
↑ 每轮根据相关性选择
为什么不一次性加载所有记忆?
因为:
- 上下文窗口有限:所有记忆可能有几万 tokens,无法全部注入
- 相关性动态变化:用户消息的主题变化,需要不同的记忆
- 缓存优化:记忆内容在 DYNAMIC_BOUNDARY 之后,不影响静态部分的缓存
Skill 的动态加载
极限场景
用户输入 /commit,系统需要:
- 读取 commit skill 文件(约 2000 tokens)
- 注入到系统提示词
- 下一轮如果用户不再需要 commit,自动移除
这要求 skill 是用户调用时才注入的,而不是预加载所有 skill。
Skill vs Tool 的区别
| 类型 | 定义 | 注入时机 | 位置 |
|---|---|---|---|
| Tool | 能力(如 Read、Write、Bash) | 每轮都注入 | 工具列表 |
| Skill | 指令(如 commit 的完整流程) | 用户调用时才注入 | DYNAMIC_BOUNDARY 之后 |
// src/skills/skill-loader.ts (伪代码)
function loadSkillIfRequested(userMessage: string): string | null {
// 用户输入 "/commit" 时
if (userMessage.startsWith('/commit')) {
// 读取 skill 文件内容
return readSkillFile('commit.md') // 💡 约 2000 tokens
}
return null
}
Skill 的内容结构:
Skill 文件包含完整的工作流指令,例如 commit skill:
# Commit Skill
## 步骤 1:运行 git status
查看所有未跟踪文件和修改...
## 步骤 2:运行 git diff
查看具体的代码变更...
## 步骤 3:分析变更
总结变更的性质(新功能/修复/重构)...
## 步骤 4:生成 commit message
遵循项目的 commit 规范...
## 步骤 5:创建 commit
使用 git commit 命令...
这些指令会被注入到系统提示词的动态部分,引导模型按照规范执行 commit 流程。
注入位置
Skill 内容注入到 DYNAMIC_BOUNDARY 之后:
[角色定义] + [基础规则] + [BOUNDARY] + [commit skill] + [工具列表]
↑ 用户调用时才注入
为什么不预加载所有 skill?
因为:
- 上下文窗口有限:所有 skill 可能有几万 tokens
- 用户可能不需要:大部分对话不会调用 skill
- 缓存优化:skill 内容在 DYNAMIC_BOUNDARY 之后,不影响静态部分的缓存
工具描述的动态注入
极限场景
MCP 工具在运行时才连接成功,系统需要:
- 检测 MCP 连接状态
- 重新生成工具列表
- 注入到系统提示词
这要求工具列表是每轮重新生成的,而不是固定的。
工具列表的动态变化
// src/tools/tool-registry.ts (伪代码)
function getAvailableTools(): Tool[] {
const tools: Tool[] = []
// 1. 内置工具(Read、Write、Bash 等)
tools.push(...getBuiltinTools())
// 2. MCP 工具(可能在运行时才可用)
const mcpClients = getMCPClients()
for (const client of mcpClients) {
if (client.type === 'connected') { // 💡 只有连接成功的才注入
tools.push(...client.tools)
}
}
return tools
}
MCP 工具的动态性:
MCP(Model Context Protocol)工具是外部服务提供的工具,连接状态可能在运行时变化:
对话开始时:
- MCP Server A: 未连接 ❌
- MCP Server B: 已连接 ✅
→ 只注入 Server B 的工具
对话中途:
- MCP Server A: 连接成功 ✅
- MCP Server B: 已连接 ✅
→ 下一轮注入 Server A + Server B 的工具
这就是为什么工具列表必须每轮重新生成——静态的工具列表无法反映 MCP 连接状态的变化。
工具列表的缓存优化
虽然工具列表在 DYNAMIC_BOUNDARY 之后(不属于全局永久缓存),但 Claude API 的 Ephemeral Cache 机制仍然可以短暂命中:
- 连续两轮工具列表相同 → Ephemeral Cache 命中(5 分钟内)
- MCP 连接状态变化 → 工具列表变化,缓存失效
- 新增/移除工具 → 工具列表变化,缓存失效
这意味着在稳定的 MCP 连接状态下,工具列表的 JSON 序列化开销可以被临时缓存减少,但它仍然不属于全局永久缓存(因为它在 DYNAMIC_BOUNDARY 之后)。
为什么不在三层结构表格中标记为"缓存"?
因为表格中的"是否缓存"列指的是 Prompt Cache 的全局永久缓存(cacheScope: 'global'),而 Ephemeral Cache 是 API 层面的临时优化,不属于系统提示词的缓存设计。工具列表的缓存命中是"副作用",不是"设计目标"。
工具描述如何影响 LLM 的工具选择
每个工具有两个关键字段:
interface Tool {
name: string
description: string // 💡 告诉 LLM 这个工具是干什么的
inputSchema: JSONSchema // 💡 告诉 LLM 这个工具需要什么参数
}
LLM 根据 description 决定是否调用这个工具:
- 如果 description 写得不清楚,LLM 可能不会调用
- 如果 description 写得太宽泛,LLM 可能过度调用
工具排序对 Prompt Cache 的影响
工具列表的顺序会影响缓存命中率:
- 如果工具顺序每轮都变化,动态部分的内容也会变化
- 为了优化缓存,工具列表应该按名称排序,保持顺序稳定
// 💡 按名称排序,保持顺序稳定
tools.sort((a, b) => a.name.localeCompare(b.name))
userContext 和 systemContext
userContext:在消息列表前注入
// src/utils/api.ts
function prependUserContext(
messages: Message[],
userContext: string
): Message[] {
return [
{
role: 'user',
content: userContext, // 💡 git 状态、环境信息等
},
...messages,
]
}
使用场景:
- git 状态(当前分支、未提交的文件)
- 环境信息(操作系统、shell 类型)
- 项目信息(是否是 git 仓库、工作目录)
为什么放在消息列表前?
- 因为这些信息是用户的上下文,不是系统的指令
- LLM 会把它当作用户提供的背景信息
- 示例:
消息列表:
[
{ role: 'user', content: 'Current branch: main\nUncommitted files: 3' }, ← userContext
{ role: 'user', content: '帮我提交代码' }, ← 用户消息
{ role: 'assistant', content: '我看到你有 3 个未提交的文件...' }
]
userContext 的生命周期和缓存
关键特性:
- 每轮更新:userContext 在每轮对话中重新生成(如 git 状态可能变化)
- 非持久化缓存:userContext 作为消息列表的一部分,不属于 Prompt Cache 的缓存范围
- 会被压缩:当对话历史接近上下文窗口限制时,早期的 userContext 会被压缩或丢弃
Token 成本影响:
| 场景 | Token 消耗 | 说明 |
|---|---|---|
| 短对话(< 10 轮) | 每轮 ~500 tokens | userContext 每轮重新发送 |
| 长对话(> 50 轮) | 早期 userContext 被压缩 | 系统自动压缩早期消息 |
| git 状态频繁变化 | 每轮 userContext 不同 | 无法利用 Ephemeral Cache |
为什么不放在系统提示词中?
如果 userContext 放在系统提示词的动态部分:
- ✅ 优点:可以利用 Ephemeral Cache(如果连续两轮 git 状态相同)
- ❌ 缺点:语义不正确(系统提示词是"指令",不是"用户的背景信息")
- ❌ 缺点:无法在压缩时被处理(系统提示词不会被压缩)
Claude Code 选择将 userContext 放在消息列表前,是为了保持语义正确性,即使这意味着无法利用缓存。在长对话中,早期的 userContext 会被自动压缩,从而控制 Token 成本。
systemContext:在系统提示词后追加
// src/utils/api.ts
function appendSystemContext(
systemPrompt: string[],
systemContext: string
): string[] {
return [
...systemPrompt,
systemContext, // 💡 追加到系统提示词末尾
]
}
使用场景:
- 动态规则(如"当前处于 plan 模式")
- 临时指令(如"使用 verbose 输出")
- 会话特定的行为约束
为什么放在系统提示词后?
- 因为这些信息是系统的指令,不是用户的输入
- LLM 会把它当作行为规则
- 示例:
系统提示词:
[ "你是 Kiro,一个 AI 助手...", "你可以读写文件、执行命令...", DYNAMIC_BOUNDARY, "当前可用工具: Read, Write, Bash...", "当前处于 plan 模式,只能使用只读工具" ← systemContext]
两者的区别
| 类型 | 位置 | 角色 | 示例 |
|---|---|---|---|
| userContext | 消息列表前 | 用户的背景信息 | git 状态、环境信息 |
| systemContext | 系统提示词后 | 系统的行为规则 | plan 模式、verbose 输出 |
调试系统提示词
--dump-system-prompt flag
claude-code --dump-system-prompt
这个 flag 会在对话开始时,打印实际发送给 Claude API 的系统提示词。
代码位置
// src/entrypoints/cli.tsx
if (flags['dump-system-prompt']) {
console.log('=== System Prompt ===')
console.log(systemPrompt.join('\n\n'))
console.log('=== End System Prompt ===')
}
如何验证动态内容是否正确注入
- 启动 Claude Code:
claude-code --dump-system-prompt - 查看输出:找到
__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__标记 - 验证动态内容:
- 边界之前:角色定义、基础规则(静态)
- 边界之后:工具列表、记忆、skill(动态)
示例输出:
=== System Prompt ===
You are Kiro, an AI assistant and IDE...
[角色定义和基础规则]
__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__
Available tools:
- Read: Reads a file from the local filesystem
- Write: Writes a file to the local filesystem
[工具列表]
Memory:
- User Profile: 中文开发者,深度学习 Claude Code 源码
[记忆片段]
=== End System Prompt ===
常见问题 FAQ
Q1: 为什么不把所有内容都放在静态部分?
A: 因为动态内容会变化:
- MCP 工具可能在运行时才连接成功
- 用户消息的主题变化,需要不同的记忆
- 用户可能只在某一轮调用 skill
如果都放在静态部分,每次变化都会导致整个系统提示词失效,缓存命中率为 0。
Q2: DYNAMIC_BOUNDARY 可以移动吗?
A: 不可以。代码中有明确的警告:
// === BOUNDARY MARKER - DO NOT MOVE OR REMOVE ===
...(shouldUseGlobalCacheScope() ? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY] : []),
如果移动边界标记,会导致:
- 缓存逻辑失效(
buildSystemPromptBlocks依赖边界位置) - 静态/动态分离失效(边界之前/之后的内容会混淆)
Q3: 为什么记忆不是一次性加载所有?
A: 因为:
- 上下文窗口有限:所有记忆可能有几万 tokens
- 相关性动态变化:用户消息的主题变化,需要不同的记忆
- 缓存优化:记忆内容在 DYNAMIC_BOUNDARY 之后,不影响静态部分的缓存
Q4: Skill 和 Tool 有什么区别?
A:
- Tool 是能力(如 Read、Write、Bash),每轮都注入
- Skill 是指令(如 commit 的完整流程),用户调用时才注入
Tool 告诉 LLM"你能做什么",Skill 告诉 LLM"怎么做"。
Q5: 如何优化系统提示词的 token 消耗?
A:
- 静态部分尽量简洁:因为它会被缓存,但仍然占用上下文窗口
- 动态部分按需注入:不要一次性加载所有记忆和 skill
- 工具描述简洁明确:避免冗长的描述,只写关键信息
Q6: 系统提示词的静态部分包含哪些内容?
A: 静态部分(DYNAMIC_BOUNDARY 之前)包含:
- 角色定义:"你是 Kiro,一个 AI 助手和 IDE"
- 基础规则:文件操作规范、代码质量要求
- 工具使用指南:如何正确使用 Read、Write、Edit 等工具
- 语气和风格:如何与用户交互
- 输出效率要求:简洁、直接、避免冗余
这些内容在整个会话中几乎不变,所以可以缓存。
Q7: 动态部分的内容会影响缓存吗?
A: 不会影响静态部分的缓存。
Prompt Cache 的工作原理是前缀匹配:
- 静态部分(DYNAMIC_BOUNDARY 之前)是固定的前缀,命中缓存
- 动态部分(DYNAMIC_BOUNDARY 之后)每轮变化,但不影响前缀的缓存
这就是为什么 DYNAMIC_BOUNDARY 的位置如此重要——它是缓存的分界线。
Q8: 如果 MCP 工具在对话中途断开连接会怎样?
A: 下一轮对话时,工具列表会自动更新:
第 N 轮:
- getAvailableTools() 检测到 MCP Server A 已断开
- 工具列表中移除 Server A 的工具
- 系统提示词的动态部分更新
第 N+1 轮:
- 模型看到的工具列表中没有 Server A 的工具
- 如果模型尝试调用这些工具,会收到"工具不存在"的错误
这是动态工具列表的优势——能够实时反映外部服务的状态。
Q9: 为什么 userContext 放在消息列表前,而不是系统提示词中?
A: 因为 userContext 是用户的背景信息,不是系统的指令。
放在消息列表前的好处:
- 语义正确:LLM 会把它当作用户提供的信息,而不是系统规则
- 灵活性:可以在每轮对话中更新(如 git 状态变化)
- 上下文管理:可以在压缩时被处理(系统提示词不会被压缩)
示例:
消息列表:
[
{ role: 'user', content: 'Current branch: main' }, ← userContext
{ role: 'user', content: '切换到 dev 分支' },
{ role: 'assistant', content: '好的,我会切换到 dev 分支' },
{ role: 'user', content: 'Current branch: dev' }, ← 更新后的 userContext
{ role: 'user', content: '提交代码' }
]
Q10: 系统提示词的总 token 数大约是多少?
A: 取决于动态内容的多少:
| 部分 | Token 数 | 说明 |
|---|---|---|
| 静态部分 | ~5000-8000 | 角色定义、基础规则、工具使用指南 |
| 工具列表 | ~2000-5000 | 取决于可用工具数量(内置 + MCP) |
| 记忆片段 | ~1000-3000 | 取决于选择的记忆数量(通常 3-5 个) |
| Skill 指令 | 0 或 ~2000 | 只在用户调用 skill 时注入 |
| 用户上下文 | ~500-1000 | git 状态、环境信息 |
| 总计 | ~8500-19000 | 动态内容越多,总 token 数越大 |
这就是为什么需要动态注入——如果把所有可能的内容都放在系统提示词中,可能会超过 50k tokens,严重浪费上下文窗口。
Q11: 动态内容过多会导致上下文窗口过载吗?
A: 会。虽然 DYNAMIC_BOUNDARY 保护了静态部分的缓存,但动态内容仍然占用上下文窗口。
问题场景:
- 用户调用多个 skill(每个 2000 tokens)
- 选择了大量相关记忆(10+ 个记忆文件)
- MCP 工具列表很长(50+ 个工具)
Claude Code 的应对策略:
-
记忆选择的优先级排序:
- 根据用户消息的关键词,计算每个记忆的相关性分数
- 只选择相关性最高的 3-5 个记忆注入
- 超过阈值的记忆被丢弃
-
Skill 的按需加载:
- 只在用户显式调用时注入(如
/commit) - 下一轮如果不再需要,自动移除
- 不会预加载所有 skill
- 只在用户显式调用时注入(如
-
工具列表的精简:
- 只注入当前可用的工具(MCP 连接成功的)
- 工具描述尽量简洁(避免冗长的 description)
-
上下文压缩机制:
- 当对话历史接近上下文窗口限制时,系统会自动压缩早期消息
- 系统提示词的静态部分不会被压缩(因为它被缓存)
权衡:
- 动态内容越多 → 上下文窗口占用越大 → 模型注意力可能分散
- 动态内容越少 → 模型可用信息越少 → 可能无法完成任务
Claude Code 的设计目标是在"足够的上下文"和"不过载"之间找到平衡。
动态裁切的实现细节:
// 记忆选择的相关性计算(伪代码)
function selectRelevantMemory(userMessage: string, memoryIndex: Memory[]): Memory[] {
const scores = memoryIndex.map(memory => ({
memory,
score: calculateRelevance(memory.description, userMessage) // 💡 关键词匹配 + 语义相似度
}))
// 按相关性排序,只取前 5 个
return scores
.sort((a, b) => b.score - a.score)
.slice(0, 5)
.map(item => item.memory)
}
这种动态裁切机制确保了即使用户有大量记忆文件,系统提示词的动态部分也不会无限膨胀。
总结
系统提示词不是一次性生成的固定文本,而是 queryLoop 每轮对话都会重新组装的动态上下文。通过 DYNAMIC_BOUNDARY 分界标记,实现了:
- 静态/动态分离:80% 的静态内容命中缓存,20% 的动态内容每轮重新计算
- 每轮重组:根据用户消息、MCP 连接状态、skill 调用等,动态决定注入什么
- 缓存优化:静态部分永远不变,动态部分不影响缓存命中率
这种设计在灵活性和性能之间取得了平衡:
- 灵活性:每轮可以注入不同的记忆、skill、工具
- 性能:静态部分命中缓存,减少 token 消耗