01为什么需要三个上下文?
当 Claude Code 调用 Anthropic API 时,需要让模型同时知道三件事 —— 你是谁、用户在哪、系统当前如何。 这三条信息职责不同、变化频率不同、缓存策略也不同,因此必须分三路独立管理。
你是谁、该怎么做
类型:string[]
来源:getSystemPrompt()
内容:身份介绍、任务执行规则、工具使用指南、语气风格、记忆机制等模型行为级提示词。
用户的项目环境
类型:{ [k: string]: string }
来源:getUserContext()
内容:项目里的 AGENTS.md / MEMORY.md 全文 + 当前日期。
当前系统状态
类型:{ [k: string]: string }
来源:getSystemContext()
内容:git 分支/状态/最近提交、可选的 cacheBreaker 调试标记。
关键设计决策
三个值最终会去到 API 请求的不同位置:defaultSystemPrompt 和 systemContext
合并到 system 参数;baseUserContext 注入到 messages 数组的最前面,
伪装成一条 isMeta=true 的用户消息。两条独立通道喂给模型,互不重叠。
02四阶段总览
从用户敲下回车的那一刻,到 HTTPS 请求离开你的网卡,三个上下文要经过 4 个阶段、7 次转换。 下图标出每个阶段的入口函数与产出。
三路 Promise.all 并行"] FSP --> A1["defaultSystemPrompt
string[]"] FSP --> A2["baseUserContext
{claudeMd, currentDate}"] FSP --> A3["systemContext
{gitStatus?, cacheBreaker?}"] A1 --> B1["systemPrompt
asSystemPrompt(...)"] A2 --> B2["userContext
+ coordinator 上下文"] A3 --> B3["systemContext
透传不变"] end S1 --> S2 subgraph S2["阶段二 · 传入查询(query → queryLoop)"] direction LR Q["query()"] --> QL["queryLoop()
三个值作为不可变参数"] end S2 --> S3 subgraph S3["阶段三 · 循环内转换(每轮迭代)"] direction TB ASC["appendSystemContext
合并 systemContext → fullSystemPrompt"] PUC["prependUserContext
注入 userContext 到消息列表头"] end S3 --> S4 subgraph S4["阶段四 · API 层(queryModel)"] direction TB AT["追加 attributionHeader / CLISyspromptPrefix
追加 advisor / chrome 指令"] AT --> SP["splitSysPromptPrefix
分块 + 缓存标记"] SP --> NORM["normalizeMessagesForAPI
归一化消息"] end S4 --> API[["anthropic.beta.messages.create"]] style S1 fill:#FFF6F0,stroke:#D77757 style S2 fill:#F3EAFF,stroke:#6B4A8E style S3 fill:#FFF7E6,stroke:#C8862C style S4 fill:#E8F5EE,stroke:#2D8659
图 2.1 · 完整数据流(四阶段全景)
阶段速览表
| 阶段 | 主要入口 | 关键转换 | 三个值的状态 |
|---|---|---|---|
| ① 创建 | QueryEngine.submitMessagefetchSystemPromptParts |
Promise.all 三路并行获取;初步包装 asSystemPrompt;合并 coordinator 上下文。 |
三个值首次成形 |
| ② 传入 | query → queryLoop |
仅创建/复用 Langfuse trace,三个值作为不可变参数透传。 | 不变 |
| ③ 循环转换 | queryLoop 每轮 |
appendSystemContext 把 systemContext 拼到 systemPrompt 末尾;
prependUserContext 把 userContext 注入消息头。 |
fullSystemPrompt 出现,消息列表被改写 |
| ④ API 层 | queryModel in claude.ts |
追加归因头/CLI 前缀/advisor 指令;分块;标 cache_control;归一化消息。 | 转为 TextBlockParam[] + MessageParam[] |
03阶段一 · 数据创建
当用户回车后,QueryEngine.submitMessage() 第一件事是调用 fetchSystemPromptParts()。
这是一次三路并行的"启动取数",目的是压缩首包等待时间。
fetchSystemPromptParts() — 三路并行
// src/utils/queryContext.ts:64-75
const [defaultSystemPrompt, userContext, systemContext] = await Promise.all([
// 第一路:系统提示词(有自定义则跳过)
customSystemPrompt !== undefined
? Promise.resolve([])
: getSystemPrompt(tools, mainLoopModel, additionalWorkingDirectories, mcpClients),
// 第二路:用户侧上下文(始终执行)
getUserContext(),
// 第三路:系统侧上下文(有自定义则跳过)
customSystemPrompt !== undefined ? Promise.resolve({}) : getSystemContext(),
])
为什么自定义 prompt 会让另外两条返回空?
当 SDK 调用方传入 customSystemPrompt 时,语义是"完全替换默认系统提示"。
如果继续拼 systemContext(比如 git 状态),它会附着到自定义提示之上,造成模型困惑 ——
因为本属于默认提示的部分(如"你是 Claude Code")已经不在了,git 状态突然冒出来很奇怪。
所以 customPrompt 模式下,第一路返回 []、第三路返回 {},只保留第二路的 userContext。
3.1 第一路:defaultSystemPrompt 怎么生成
由 getSystemPrompt() 在 src/constants/prompts.ts 生成。返回 string[],每个元素是一个段落。
根据环境变量与 feature flag 选择三种模式之一:
① 简化模式 CLAUDE_CODE_SIMPLE=1
仅一段身份+CWD+日期。脚本/自动化场景使用。
② 主动模式 PROACTIVE/KAIROS
autonomous agent 提示词 + 记忆 + 环境信息 + MCP 指令 + scratchpad。
③ 标准模式(最常用)
分静态/动态两段,中间用 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 分隔,便于缓存分层。
标准模式的内部结构
身份介绍"] SS["getSimpleSystemSection
系统规则"] DT["getSimpleDoingTasksSection
任务执行"] AC["getActionsSection
操作谨慎性"] UT["getUsingYourToolsSection
工具指南"] TS["getSimpleToneAndStyleSection
语气风格"] OE["getOutputEfficiencySection
沟通效率"] SI --> SS --> DT --> AC --> UT --> TS --> OE end BOUNDARY["⫷ SYSTEM_PROMPT_DYNAMIC_BOUNDARY ⫸
边界标记,仅 1P 启用全局缓存时插入"] subgraph DYNAMIC["动态部分(每会话可能不同)"] direction TB SG["session_guidance"] MEM["memory · MEMORY.md 内容"] ENV["env_info_simple
CWD/平台/模型ID"] LANG["language"] MCP["mcp_instructions"] SCR["scratchpad"] FRC["function_result_clearing"] SUM["summarize_tool_results"] SG --> MEM --> ENV --> LANG --> MCP --> SCR --> FRC --> SUM end STATIC --> BOUNDARY --> DYNAMIC style STATIC fill:#E8F5EE,stroke:#2D8659 style DYNAMIC fill:#FFF7E6,stroke:#C8862C style BOUNDARY fill:#FFF6F0,stroke:#D77757,stroke-width:3px
图 3.1 · defaultSystemPrompt 标准模式的段落结构
3.2 第二路:baseUserContext 怎么生成
getUserContext() 在 src/context.ts,返回键值对:
{
claudeMd: "<AGENTS.md / MEMORY.md 拼接后的全文>", // 可选
currentDate: "Today's date is 2026-04-29.", // 始终
}
claudeMd 的生成步骤:
- 检查是否禁用:
CLAUDE_CODE_DISABLE_CLAUDE_MDS=1或--bare模式跳过; getMemoryFiles()递归扫描工作目录,找出全部AGENTS.md和MEMORY.md;filterInjectedMemoryFiles()过滤已经通过其他路径注入的;getClaudeMds()读取并按层级拼接所有文件内容;- 结果缓存到
setCachedClaudeMdContent(),供 auto-mode 分类器复用。
缓存策略:使用 memoize 缓存,同一会话内只算一次。setSystemPromptInjection()(调试用)会清空缓存强制重算。
3.3 第三路:systemContext 怎么生成
getSystemContext() 在 src/context.ts。结构:
{
gitStatus: "This is the git status at the start of the conversation.\nCurrent branch: main\n...",
cacheBreaker: "[CACHE_BREAKER: ...]", // 仅调试模式
}
gitStatus 的生成 —— 5 个 git 命令并行执行:
| 命令 | 用途 | 处理 |
|---|---|---|
git rev-parse --abbrev-ref HEAD | 当前分支 | 直接拼入 |
git remote show origin | grep 'HEAD branch' | 主分支 | 直接拼入 |
git --no-optional-locks status --short | 文件状态 | 限制 2000 字符,超长截断 |
git --no-optional-locks log --oneline -n 5 | 最近 5 条提交 | 直接拼入 |
git config user.name | 用户名 | 直接拼入 |
3.4 阶段一末尾:初步包装与合并
三路结果回到 QueryEngine 后,会做两次轻量转换:
baseUserContext → userContext
合并 coordinator 上下文(feature 关闭时返回空对象)。
const userContext = {
...baseUserContext,
...getCoordinatorUserContext(
mcpClients, scratchpadDir
),
}
defaultSystemPrompt → systemPrompt
三段式包装:custom 覆盖、追加 memoryMechanics、追加 appendSystemPrompt。
const systemPrompt = asSystemPrompt([
...(customPrompt !== undefined
? [customPrompt]
: defaultSystemPrompt),
...(memoryMechanicsPrompt
? [memoryMechanicsPrompt] : []),
...(appendSystemPrompt
? [appendSystemPrompt] : []),
])
asSystemPrompt() 是品牌类型(branded type)包装函数,把 string[] 标记为 SystemPrompt 类型,防止与其他字符串数组混淆。本质仍是 string[]。
04阶段二 · 传入查询
阶段二非常薄。query() 是一个异步生成器,不修改三个上下文值,
只做两件事:建 Langfuse trace,然后 yield* 委托给 queryLoop()。
query() 包装层
export async function* query(params: QueryParams): AsyncGenerator<...> {
// 复用上游 trace(如果是子代理)或新建
const langfuseTrace = params.toolUseContext.langfuseTrace
?? (isLangfuseEnabled() ? createTrace({...}) : null)
const paramsWithTrace = langfuseTrace
? { ...params, toolUseContext: { ...params.toolUseContext, langfuseTrace } }
: params
let terminal: Terminal | undefined
try {
terminal = yield* queryLoop(paramsWithTrace, consumedCommandUuids)
} finally {
if (ownsTrace) endTrace(langfuseTrace, ...)
}
return terminal!
}
queryLoop 的不可变参数 vs 可变状态
不可变参数(整个循环不重赋值)
{
systemPrompt, // 阶段一产出
userContext, // 阶段一产出
systemContext, // 阶段一产出
canUseTool,
fallbackModel,
querySource,
maxTurns,
}
可变状态(每轮迭代可能更新)
{
messages, // 每轮追加 assistant + tool_result
toolUseContext,
autoCompactTracking,
pendingToolUseSummary,
turnCount, // 每轮 +1
transition,
}
05阶段三 · 循环内转换
每轮迭代代表一次"模型调用 → 工具执行 → 附件注入"的完整回合。
消息预处理管线先对 messages 做收敛,然后两次关键合并:
appendSystemContext 拼系统提示词、prependUserContext 注入用户上下文。
5.1 消息预处理管线
每轮开始时,state.messages 经过六道工序,不影响三个上下文值,但会改变最终发送的消息内容:
截取 compact 边界后"] M1 --> M2["applyToolResultBudget
裁剪过长工具输出"] M2 --> M3["snipCompactIfNeeded
HISTORY_SNIP feature"] M3 --> M4["microcompactMessages
缓存编辑"] M4 --> M5["applyCollapsesIfNeeded
CONTEXT_COLLAPSE"] M5 --> M6["autoCompactIfNeeded
token 超限触发"] M6 --> MFQ(["messagesForQuery"]) style MFQ fill:#FFF6F0,stroke:#D77757,stroke-width:2px
图 5.1 · 消息预处理六道管线
5.2 关键合并 ① — appendSystemContext
把 systemContext 拼到 systemPrompt 末尾
// src/utils/api.ts:435-445
export function appendSystemContext(
systemPrompt: SystemPrompt,
context: { [k: string]: string },
): string[] {
return [
...systemPrompt,
Object.entries(context)
.map(([key, value]) => `${key}: ${value}`)
.join('\n'),
].filter(Boolean)
}
["You are Claude Code...", "<system_rules>...", "BOUNDARY", "<tools>..."]
{ gitStatus: "branch: main\n...", cacheBreaker: "[CB: x]" }
["You are Claude Code...", "<system_rules>...", "BOUNDARY", "<tools>...", "gitStatus: branch: main\n...\ncacheBreaker: [CB: x]"]
5.3 关键合并 ② — prependUserContext
把 userContext 注入消息列表最前面
// src/utils/api.ts:447-472
export function prependUserContext(
messages: Message[],
context: { [k: string]: string },
): Message[] {
if (process.env.NODE_ENV === 'test') return messages
if (Object.entries(context).length === 0) return messages
return [
createUserMessage({
content: `<system-reminder>
As you answer the user's questions, you can use the following context:
${Object.entries(context).map(([key, value]) => `# ${key}\n${value}`).join('\n')}
IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.
</system-reminder>`,
isMeta: true, // ← 标记为元消息,不在 UI 显示
}),
...messages,
]
}
为什么用 <system-reminder> 包裹?
这是软注入策略:告诉模型"这些上下文可能相关也可能不相关",让模型自行判断是否引用。 如果直接以系统指令注入,会强制模型关注每一条上下文,反而稀释真正重要的用户输入。 末尾的 IMPORTANT 提示句明确给模型自由 —— "如果不相关,就不要回应它"。
为什么 isMeta=true?
元消息在消息回放、SDK 输出、UI 展示中会被过滤掉,避免重复显示。它对 API 而言是普通用户消息(API 不识别 meta 标记),但对 Claude Code 内部工具链是隐形的。
5.4 调用 deps.callModel — 阶段三的出口
// src/query.ts:627
for await (const message of deps.callModel({
messages: prependUserContext(messagesForQuery, userContext), // ← 注入
systemPrompt: fullSystemPrompt, // ← 已合并
thinkingConfig: toolUseContext.options.thinkingConfig,
tools: toolUseContext.options.tools,
signal: toolUseContext.abortController.signal,
options: { ... },
})) { ... }
06阶段四 · API 层最终转换
最后一站。deps.callModel() 进入 src/services/api/claude.ts,
在这里完成归一化、追加归因、分块、缓存标记,最终通过 Anthropic SDK 发送 HTTPS 请求。
6.1 消息归一化
let messagesForAPI = normalizeMessagesForAPI(messages, filteredTools)
处理四件事:
- tool_use / tool_result 配对一致性 —— 检查每个 tool_use 都有对应 tool_result。
- 剥离不支持字段 —— 如
tool_reference、caller等内部字段。 - 修复孤立消息 —— 为无配对的 tool_use 注入合成错误 tool_result(API 协议要求)。
- 裁剪超量媒体块 —— API 限制每条消息最多 100 个媒体块。
6.2 系统提示词最终拼装
在 fullSystemPrompt 之上,再追加 API 层需要的额外内容:
// src/services/api/claude.ts:1377-1392
systemPrompt = asSystemPrompt([
getAttributionHeader(fingerprint), // ① 归因头(计费追踪)
getCLISyspromptPrefix({...}), // ② CLI 标识前缀
...systemPrompt, // ③ fullSystemPrompt 全部
...(advisorModel ? [ADVISOR_TOOL_INSTRUCTIONS] : []), // ④ Advisor 指令(条件)
...(injectChromeHere ? [CHROME_TOOL_SEARCH_INSTRUCTIONS] : []), // ⑤ Chrome 指令(条件)
].filter(Boolean))
| 追加内容 | 作用 | 何时存在 |
|---|---|---|
getAttributionHeader | 基于首条用户消息计算的归因指纹,API 侧用于计费与追踪 | 始终 |
getCLISyspromptPrefix | 标记请求来源(交互/非交互、是否有 appendSystemPrompt) | 始终 |
...systemPrompt | fullSystemPrompt(已含 systemContext) | 始终 |
ADVISOR_TOOL_INSTRUCTIONS | Advisor 模式启用时的工具说明 | Advisor feature 启用且模型支持时 |
CHROME_TOOL_SEARCH_INSTRUCTIONS | Chrome MCP 工具搜索指令 | Chrome MCP 工具存在且 tool search 启用时 |
6.3 分块 + 缓存标记 — splitSysPromptPrefix
这一步把 string[] 转为 API 需要的 TextBlockParam[],并为每块标记缓存作用域。三种策略:
cache_control: 不设置"] CHECK -->|是| MCP{存在 MCP 工具?} MCP -->|是| ORG["策略一 · MCP 降级
Block 1: attribution(null)
Block 2: CLISysPrefix(org)
Block 3: 其余拼接(org)"] MCP -->|否| BOUND{1P + 边界标记?} BOUND -->|是| GLOBAL["策略二 · 最佳分块
Block 1: attribution(null)
Block 2: CLISysPrefix(null)
Block 3: 静态内容(global)
Block 4: 动态内容(null)"] BOUND -->|否| DEFAULT["策略三 · 默认
Block 1: attribution(null)
Block 2: CLISysPrefix(org)
Block 3: 其余拼接(org)"] style GLOBAL fill:#E8F5EE,stroke:#2D8659,stroke-width:2px style ORG fill:#FFF7E6,stroke:#C8862C style DEFAULT fill:#F8F5F1,stroke:#8B6F5C
图 6.1 · splitSysPromptPrefix 的三种分块策略
策略二(最理想)— 全局缓存命中率最高
仅当 shouldUseGlobalCacheScope() 返回 true(1P provider + 全局缓存允许)且
SYSTEM_PROMPT_DYNAMIC_BOUNDARY 边界标记找到时启用。
Block 3(静态部分)以 scope: 'global' 标记,跨组织共享缓存,
所有 Claude Code 用户共用同一缓存条目,命中率极高。
每个 Block 转为如下结构:
{
type: 'text',
text: block.text,
...(enablePromptCaching && block.cacheScope !== null && {
cache_control: {
type: 'ephemeral',
scope: block.cacheScope, // 'global' or 'org'
}
}),
}
6.4 最终 API 请求结构
anthropic.beta.messages.create({
model: normalizedModel,
system, // ← 分块后的 TextBlockParam[]
messages: messagesForAPI, // ← 归一化后的消息(含 prependUserContext 注入)
tools: allTools, // ← 工具 schema
thinking: thinkingConfig,
betas, // ← beta headers
...
})
07完整实例 · 用户输入"帮我分析这个文件"
来跟一条真实消息走完全程。场景:Mac 上使用 Claude Code REPL,工作目录 /Users/fanhui/Desktop/github/claude-code,
模型 claude-sonnet-4,项目里有 AGENTS.md,是 git 仓库,无 MCP 服务器,无自定义系统提示词。
7.1 阶段一产出:三个原始值
defaultSystemPrompt(部分省略)
[
// ━━━ 静态段落(可 global 缓存)━━━
"You are an interactive agent that helps users with software engineering tasks...",
"# System\n - All text you output outside of tool use is displayed...",
"# Doing tasks\n - The user will primarily request you to perform...",
"# Executing actions with care\n\nCarefully consider the reversibility...",
"# Using your tools\n - Do not use tools when...",
"# Tone and style\n - Only use emojis if the user explicitly requests...",
"# Communicating with the user\n...",
"SYSTEM_PROMPT_DYNAMIC_BOUNDARY", // 边界标记
// ━━━ 动态段落(不缓存或 org 缓存)━━━
"# Session-specific guidance\n...",
"# Memory\n\nThis is the content of the project's memory file...",
"# Environment\n - Primary working directory: /Users/fanhui/...\n - Is a git repository: Yes\n - Platform: darwin...",
"# Language\n\nWhen responding to the user, use the language...",
"# Function result clearing\n...",
"# Summarize tool results\n...",
]
baseUserContext
{
claudeMd: "# AGENTS.md\n\nThis file provides guidance to Codex...\n## Commands\n```bash\nbun install\n...\n```\n...",
currentDate: "Today's date is 2026-04-29.",
}
systemContext
{
gitStatus: `This is the git status at the start of the conversation.
Current branch: main
Main branch: main
Git user: fanhui
Status:
M src/query.ts
M src/QueryEngine.ts
Recent commits:
1173a62 refactor: 统一 log.ts/debug.ts 的测试 mock
7ea69ca fix: 修复 build 过程中的问题`,
}
7.2 阶段三产出:合并后的 fullSystemPrompt 与消息列表
appendSystemContext 把 systemContext 拼为单个字符串追加:
fullSystemPrompt = [
// ... 前面所有段落保持不变 ...
"# Summarize tool results\n...",
// ↓↓↓ 新增:systemContext 单字符串追加 ↓↓↓
"gitStatus: This is the git status at the start of the conversation.\nCurrent branch: main\nMain branch: main\nGit user: fanhui\nStatus:\nM src/query.ts\nM src/QueryEngine.ts\n\nRecent commits:\n1173a62 refactor: ...\n7ea69ca fix: ...",
]
prependUserContext 在消息列表最前面注入元消息:
// 转换前
[ { role: "user", content: "帮我分析这个文件" } ]
// 转换后
[
{
role: "user",
content: `<system-reminder>
As you answer the user's questions, you can use the following context:
# claudeMd
# AGENTS.md
This file provides guidance to Codex...
[...AGENTS.md 全文...]
# currentDate
Today's date is 2026-04-29.
IMPORTANT: this context may or may not be relevant to your tasks...
</system-reminder>`,
isMeta: true,
},
{ role: "user", content: "帮我分析这个文件" },
]
7.3 阶段四产出:API 请求最终结构
POST https://api.anthropic.com/v1/messages
"x-anthropic-billing-header: cc_version=2.1.888; ..."
"You are Claude Code, Anthropic's official CLI for Claude."
"You are an interactive agent that helps users... # System ... # Doing tasks ... # Executing actions with care ... # Using your tools ... # Tone and style ... # Communicating with the user ..."
"# Session-specific guidance ... # Memory ... # Environment - Primary working directory: /Users/fanhui/Desktop/github/claude-code - Is a git repository: Yes - Platform: darwin - You are powered by claude-sonnet-4... # Language ... # Function result clearing ... # Summarize tool results ... gitStatus: Current branch: main M src/query.ts M src/QueryEngine.ts Recent commits: ..."
[
{
role: "user",
content: "<system-reminder>
As you answer the user's questions, you can use the following context:
# claudeMd
# AGENTS.md
[...AGENTS.md 全文...]
# currentDate
Today's date is 2026-04-29.
IMPORTANT: this context may or may not be relevant...
</system-reminder>"
},
{
role: "user",
content: "帮我分析这个文件"
}
]
7.4 模型视角:它到底"看到"了什么?
从模型的视角,信息分两条独立通道:
关键观察
① systemContext(git 状态)出现在 system 参数 Block 4 末尾,不在 messages 中;
② userContext(AGENTS.md + 日期)出现在 messages 第一条,不在 system 中;
③ 用户原始输入是 messages 的第二条;
④ 两条通道互补不重复,模型同时拥有完整画面。
08缓存策略 · 为什么这样切块
Anthropic API 的 prompt cache 有三种作用域:global(跨组织)、org(组织内)、不缓存。
命中缓存可以把 token 成本降低 90%、首字节延迟降到 ~30%。三层切分背后的考量:
| 层级 | 缓存作用域 | 包含什么 | 变化频率 | 缓存价值 |
|---|---|---|---|---|
| 前缀 | global(跨组织共享) |
身份介绍 + 系统规则 + 任务指南 + 工具使用 + 语气风格 + 沟通效率 | 极低(版本更新时变) | ⭐⭐⭐⭐⭐ |
| 动态 | org 或不缓存 |
session_guidance + memory + env_info + MCP 指令 + gitStatus | 中等(会话内稳定,跨会话变化) | ⭐⭐⭐ |
| 尾部 | 不缓存 | advisor 指令 / chrome 指令 | 低 | ⭐ |
SYSTEM_PROMPT_DYNAMIC_BOUNDARY 的作用
这个边界标记的作用是清晰划分静态/动态,让 splitSysPromptPrefix 准确知道在哪一刀切开。
它是字符串字面量 "SYSTEM_PROMPT_DYNAMIC_BOUNDARY",作为数组的一个元素插在静态段落和动态段落之间,
在分块时被识别并移除(不会真的发给模型)。
跨用户、跨会话稳定"] --> B[(SYSTEM_PROMPT_
DYNAMIC_BOUNDARY)] B --> C["动态段落 8+
+ systemContext"] A -.缓存.-> CACHE_G["global cache
所有 Claude Code 用户共享"] C -.不缓存或.-> CACHE_O["org cache
当前组织共享"] style A fill:#E8F5EE,stroke:#2D8659 style B fill:#FFF6F0,stroke:#D77757,stroke-width:3px style C fill:#FFF7E6,stroke:#C8862C style CACHE_G fill:#D5EFE0,stroke:#2D8659 style CACHE_O fill:#FFEFD0,stroke:#C8862C
图 8.1 · 边界标记切分静态/动态
MCP 工具对缓存的影响
为什么 MCP 工具会让全局缓存失效?
因为 MCP 工具的描述是因用户而异的(每个用户接的 MCP 服务器不同,工具数量、名字、描述都不同)。
全局缓存要求内容跨组织相同,MCP 描述破坏了这个前提。所以 skipGlobalCacheForSystemPrompt 会把整个系统提示词降级为 org 级缓存。
你 MCP 工具越多,缓存命中率越低 —— 这是个有意识的权衡。
09多轮对话 · 不变性与变化性
当模型回复包含 tool_use 块时,queryLoop 不会结束,而是进入下一轮迭代。
理解什么变、什么不变,是看懂多轮对话内存模型的关键。
9.1 跨轮不变性矩阵
| 数据 | 跨轮行为 | 说明 |
|---|---|---|
systemPrompt |
不变 | queryLoop 的不可变参数,整个查询循环期间不重赋值 |
userContext |
不变 | 同上。每轮通过 prependUserContext 重新注入,但内容相同 |
systemContext |
不变 | 同上。每轮通过 appendSystemContext 合并到 fullSystemPrompt |
messages |
每轮变化 | 追加 assistant + toolResults + 附件,或被 compact 替换 |
messagesForQuery |
每轮重算 | 经预处理管线(budget → snip → micro → collapse → autocompact) |
9.2 下一轮消息构成
// src/query.ts:1700
const next: State = {
messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
...
}
messages = [user_msg]
+ prependUserContext
= [system-reminder, user_msg] Q->>M: API 调用 ① M-->>Q: assistant + tool_use(Read, "x.ts") Note over Q: 执行 Read 工具 Q->>Q: messages.append(assistant + tool_result) Note over Q: 第 2 轮 · 重新预处理 messages
+ prependUserContext (再次)
userContext 相同,但作为新元素
插在最前面 Q->>M: API 调用 ②(缓存命中前缀) M-->>Q: assistant("文件包含...") Q->>U: 最终回答 Note over Q,M: systemPrompt / userContext / systemContext
在两轮间完全相同 — Anthropic prompt cache
识别重复前缀,避免 token 重复计费
图 9.1 · 双轮 tool_use 流程示意
9.3 compact 后的上下文恢复
当 token 接近上限时,autoCompactIfNeeded 会触发压缩:
// 压缩前
[user1, assistant1, tool_result1, ..., userN, assistantN, tool_resultN]
// 压缩后
[compact_summary, compact_boundary, ...新消息]
三个上下文值不受 compact 影响
压缩只针对消息历史,systemPrompt、userContext、systemContext
始终完整传入。所以 compact 后模型仍能看到:环境信息(CWD、git 状态)、AGENTS.md 项目规则、当前日期。
丢失的只是详细对话历史,被摘要替代。
9.4 附件注入(在 tool_result 之后)
工具执行完成后,进入下一轮前会注入多种附件:
| 附件类型 | 来源 | 说明 |
|---|---|---|
| 队列命令 | getCommandsByMaxPriority() | 排队的用户命令转为附件 |
| Memory 预取 | pendingMemoryPrefetch | 异步预取的相关记忆文件 |
| Skill 发现 | pendingSkillPrefetch | 异步发现的可用技能 |
附件注入顺序很重要
必须先保证 tool_use / tool_result 配对完整,再注入普通附件。否则 API 会报协议错误(孤立 tool_use 或 tool_result)。
10设计要点总结
① 分离关注点
systemPrompt(行为规则)、userContext(项目环境)、systemContext(系统状态)
三者职责清晰,独立生成、独立缓存、独立注入。
② 缓存友好
静态/动态分离 + 边界标记 + 三层缓存(global/org/null), 最大化 prompt cache 命中率,减少 token 与延迟。
③ 跨轮不变
三个上下文值在 queryLoop 整个生命周期内不变, 保证多轮对话间一致性,并让 prompt cache 能识别重复前缀。
④ Compact 安全
压缩只影响消息历史,三个上下文值始终完整传入。 模型在压缩后仍能看到环境信息和项目规则。
⑤ 自定义覆盖
customSystemPrompt 语义上完全替代默认提示,
避免片段错配(比如 git 状态飞到无 Claude Code 身份的提示之上)。
⑥ 软注入策略
userContext 通过 <system-reminder> 包裹并标记 isMeta,
让模型自行判断相关性,不强制关注每条上下文。
核心数据流(一图回顾)
asSystemPrompt()"] BUC --> UC["userContext
+ coordinator"] SP --> ASC{"appendSystemContext"} SC --> ASC ASC --> FSP["fullSystemPrompt"] UC --> PUC{"prependUserContext"} MSGS["messagesForQuery"] --> PUC PUC --> FMSG["最终消息列表"] FSP --> CALL["deps.callModel"] FMSG --> CALL CALL --> ATTR["+ attributionHeader
+ CLISyspromptPrefix
+ advisor / chrome"] ATTR --> SPLIT["splitSysPromptPrefix
分块 + cache_control"] SPLIT --> SYS["system: TextBlockParam[]"] CALL --> NORM["normalizeMessagesForAPI"] NORM --> MSG["messages: MessageParam[]"] SYS --> API[("Anthropic API")] MSG --> API style ASC fill:#FFF6F0,stroke:#D77757,stroke-width:2px style PUC fill:#FFF6F0,stroke:#D77757,stroke-width:2px style API fill:#2A1F1A,color:#fff,stroke:#D77757
图 10.1 · 一图概括:从三路并行取数到 API 调用的全部链路
最后一个洞察
Claude Code 的上下文管理本质上是一个生产者-消费者管线: 三个独立生产者(getSystemPrompt / getUserContext / getSystemContext)并行启动, 中间经过两次轻量合并(appendSystemContext 拼系统、prependUserContext 注入用户), 最后由 API 层完成最后一公里的归一化、分块、缓存标记。 这种"分离 + 延迟合并"的设计让缓存粒度可控、修改局部化、并发取数能并行 —— 你看到的每一个细节,都为这三个目标之一服务。