Claude Code 源码:系统提示词

3 阅读22分钟

Claude Code 源码:系统提示词

导航


目录

第一部分:概念理解

第二部分:核心机制

第三部分:动态内容注入

第四部分:调试与 FAQ


极限场景:用户调用 /commit 时发生了什么

想象这样一个时刻:

用户在对话中途输入 /commit,此时系统需要注入完整的 commit skill 指令(约 2000 tokens)。但对话历史已经占用了 150k tokens,系统提示词的静态部分(角色定义、基础规则)已经通过 Prompt Cache 缓存。

这一刻,系统面临三个并发问题:

  1. 如何注入 2000 tokens 的 skill 内容? — 如果系统提示词是固定的,这 2000 tokens 早就被计入缓存,无法动态替换
  2. 如何保持缓存命中率? — 静态部分(角色定义)不应该因为 skill 注入而失效
  3. 如何在每轮对话中重新决定注入什么? — 下一轮用户可能不再需要 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 },     // ❌ 不缓存
    ]
  }
}

工作原理

  1. 查找边界:在系统提示词数组中找到 DYNAMIC_BOUNDARY 的位置
  2. 分离内容:边界之前的是静态内容,边界之后的是动态内容
  3. 设置缓存作用域
    • 静态内容:cacheScope: 'global' → 全局缓存,所有用户共享
    • 动态内容:cacheScope: null → 不缓存,每次重新计算

缓存命中率对比

场景无 DYNAMIC_BOUNDARY有 DYNAMIC_BOUNDARY
用户调用 /commit整个系统提示词失效只有动态部分重新计算
用户消息变化,记忆选择不同整个系统提示词失效静态部分命中缓存
MCP 工具连接成功整个系统提示词失效静态部分命中缓存

设计动机

  • 静态部分(角色定义、基础规则)占系统提示词的 80%,但几乎不变
  • 动态部分(工具、记忆、skill)占 20%,但每轮都可能变化
  • 通过分界标记,80% 的内容命中缓存,只有 20% 重新计算

每轮对话中的系统提示词重组

极限场景

用户在对话中途调用 /commit,系统需要:

  1. 注入 commit skill 的完整指令(2000 tokens)
  2. 保持静态部分的缓存命中
  3. 下一轮如果用户不再需要 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'
  }
}

关键点

  1. 每轮重组buildSystemPrompt() 在 while 循环内部调用,不是在循环外
  2. 动态依赖:工具列表、记忆、skill 都是根据当前状态动态计算的
  3. 缓存优化:静态部分(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 状态、环境信息(可能变化)

如果只在对话开始时生成一次,这些动态内容无法更新。


动态记忆注入

极限场景

用户在对话中提到"之前的重构",系统需要:

  1. 从 MEMORY.md 索引中找到相关记忆文件
  2. 读取记忆内容,注入到系统提示词
  3. 下一轮如果用户不再提到重构,自动移除该记忆

这要求记忆注入是动态的,而不是一次性加载所有记忆。

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] + [记忆片段] + [工具列表]
                                        ↑ 每轮根据相关性选择

为什么不一次性加载所有记忆?

因为:

  1. 上下文窗口有限:所有记忆可能有几万 tokens,无法全部注入
  2. 相关性动态变化:用户消息的主题变化,需要不同的记忆
  3. 缓存优化:记忆内容在 DYNAMIC_BOUNDARY 之后,不影响静态部分的缓存

Skill 的动态加载

极限场景

用户输入 /commit,系统需要:

  1. 读取 commit skill 文件(约 2000 tokens)
  2. 注入到系统提示词
  3. 下一轮如果用户不再需要 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?

因为:

  1. 上下文窗口有限:所有 skill 可能有几万 tokens
  2. 用户可能不需要:大部分对话不会调用 skill
  3. 缓存优化:skill 内容在 DYNAMIC_BOUNDARY 之后,不影响静态部分的缓存

工具描述的动态注入

极限场景

MCP 工具在运行时才连接成功,系统需要:

  1. 检测 MCP 连接状态
  2. 重新生成工具列表
  3. 注入到系统提示词

这要求工具列表是每轮重新生成的,而不是固定的。

工具列表的动态变化

// 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 tokensuserContext 每轮重新发送
长对话(> 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 ===')
}

如何验证动态内容是否正确注入

  1. 启动 Claude Codeclaude-code --dump-system-prompt
  2. 查看输出:找到 __SYSTEM_PROMPT_DYNAMIC_BOUNDARY__ 标记
  3. 验证动态内容
    • 边界之前:角色定义、基础规则(静态)
    • 边界之后:工具列表、记忆、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: 因为:

  1. 上下文窗口有限:所有记忆可能有几万 tokens
  2. 相关性动态变化:用户消息的主题变化,需要不同的记忆
  3. 缓存优化:记忆内容在 DYNAMIC_BOUNDARY 之后,不影响静态部分的缓存

Q4: Skill 和 Tool 有什么区别?

A:

  • Tool 是能力(如 Read、Write、Bash),每轮都注入
  • Skill 是指令(如 commit 的完整流程),用户调用时才注入

Tool 告诉 LLM"你能做什么",Skill 告诉 LLM"怎么做"。


Q5: 如何优化系统提示词的 token 消耗?

A:

  1. 静态部分尽量简洁:因为它会被缓存,但仍然占用上下文窗口
  2. 动态部分按需注入:不要一次性加载所有记忆和 skill
  3. 工具描述简洁明确:避免冗长的描述,只写关键信息

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 是用户的背景信息,不是系统的指令

放在消息列表前的好处:

  1. 语义正确:LLM 会把它当作用户提供的信息,而不是系统规则
  2. 灵活性:可以在每轮对话中更新(如 git 状态变化)
  3. 上下文管理:可以在压缩时被处理(系统提示词不会被压缩)

示例:

消息列表:
[
  { 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-1000git 状态、环境信息
总计~8500-19000动态内容越多,总 token 数越大

这就是为什么需要动态注入——如果把所有可能的内容都放在系统提示词中,可能会超过 50k tokens,严重浪费上下文窗口。


Q11: 动态内容过多会导致上下文窗口过载吗?

A: 会。虽然 DYNAMIC_BOUNDARY 保护了静态部分的缓存,但动态内容仍然占用上下文窗口。

问题场景

  • 用户调用多个 skill(每个 2000 tokens)
  • 选择了大量相关记忆(10+ 个记忆文件)
  • MCP 工具列表很长(50+ 个工具)

Claude Code 的应对策略

  1. 记忆选择的优先级排序

    • 根据用户消息的关键词,计算每个记忆的相关性分数
    • 只选择相关性最高的 3-5 个记忆注入
    • 超过阈值的记忆被丢弃
  2. Skill 的按需加载

    • 只在用户显式调用时注入(如 /commit
    • 下一轮如果不再需要,自动移除
    • 不会预加载所有 skill
  3. 工具列表的精简

    • 只注入当前可用的工具(MCP 连接成功的)
    • 工具描述尽量简洁(避免冗长的 description)
  4. 上下文压缩机制

    • 当对话历史接近上下文窗口限制时,系统会自动压缩早期消息
    • 系统提示词的静态部分不会被压缩(因为它被缓存)

权衡

  • 动态内容越多 → 上下文窗口占用越大 → 模型注意力可能分散
  • 动态内容越少 → 模型可用信息越少 → 可能无法完成任务

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 分界标记,实现了:

  1. 静态/动态分离:80% 的静态内容命中缓存,20% 的动态内容每轮重新计算
  2. 每轮重组:根据用户消息、MCP 连接状态、skill 调用等,动态决定注入什么
  3. 缓存优化:静态部分永远不变,动态部分不影响缓存命中率

这种设计在灵活性性能之间取得了平衡:

  • 灵活性:每轮可以注入不同的记忆、skill、工具
  • 性能:静态部分命中缓存,减少 token 消耗