Claude Code源码学习—— Agent Prompt 设计

说明:💡同行为发给 AI 的用户提示词问题

目录

  • Prompt
    • [系统提示词 vs 用户提示词的分工设计](#系统提示词 vs 用户提示词的分工设计)
      • [1. 缓存效率(最关键的工程原因)](#1. 缓存效率(最关键的工程原因))
      • [2. 语义角色对齐](#2. 语义角色对齐)
      • [3. 会话生命周期差异](#3. 会话生命周期差异)
      • [1. API 协议的语义要求](#1. API 协议的语义要求)
      • [2. 项目正在把 MCP 指令移出系统提示词](#2. 项目正在把 MCP 指令移出系统提示词)
      • [3. 缓存降级策略](#3. 缓存降级策略)

Prompt

💡检索所有对于传入给模型接口的 prompt 使用的是纯文本,还是使用了类 xml 格式?使用了 xml 格式的有哪些,以及为什么要这样使用?

XML格式使用:结构化区分消息的"类型"和"内容"

  1. 防止混淆 --- 用户消息里混杂了用户输入、终端输出、系统注入、任务通知等多种来源。XML 标签让模型能明确区分"这是 bash 输出"还是"用户在说话"。例如 <bash-stdout> vs 裸文本的区别。
  2. 可解析性 --- 代码通过 extractTag() 函数从历史消息中反向提取标签内容,用于渲染和逻辑判断(如判断一条消息是否是 bash 命令)。
  3. 与模型的认知对齐 --- Claude 本身在训练中就大量接触 XML 标签格式的 prompt,用 XML 包裹内容能让模型更准确地理解边界。<system-reminder> 就是一个典型例子------告诉模型"这段是上下文,可能相关也可能无关"。
  4. 嵌套结构表达 --- 任务通知 (<task-notification> 内含 <task-id>, <summary> 等子标签) 需要表达层级关系,XML 天然适合。
  5. 安全转义 --- 动态内容通过 escapeXml() 转义后放入标签,避免输出中的 < / > 破坏结构完整性。

总结:系统提示词用纯文本(因为只需线性拼接),用户消息中用 XML 标签(因为需要区分多种消息来源,且需要被模型和代码双向解析)。

typescript 复制代码
export function extractTag(html: string, tagName: string): string | null {
  if (!html.trim() || !tagName.trim()) {
    return null
  }

  const escapedTag = escapeRegExp(tagName)

  // Create regex pattern that handles:
  // 1. Self-closing tags
  // 2. Tags with attributes
  // 3. Nested tags of the same type
  // 4. Multiline content
  const pattern = new RegExp(
    `<${escapedTag}(?:\\s+[^>]*)?>` + // Opening tag with optional attributes
      '([\\s\\S]*?)' + // Content (non-greedy match)
      `<\\/${escapedTag}>`, // Closing tag
    'gi',
  )

  let match
  let depth = 0
  let lastIndex = 0
  const openingTag = new RegExp(`<${escapedTag}(?:\\s+[^>]*?)?>`, 'gi')
  const closingTag = new RegExp(`<\\/${escapedTag}>`, 'gi')

  while ((match = pattern.exec(html)) !== null) {
    // Check for nested tags
    const content = match[1]
    const beforeMatch = html.slice(lastIndex, match.index)

    // Reset depth counter
    depth = 0

    // Count opening tags before this match
    openingTag.lastIndex = 0
    while (openingTag.exec(beforeMatch) !== null) {
      depth++
    }

    // Count closing tags before this match
    closingTag.lastIndex = 0
    while (closingTag.exec(beforeMatch) !== null) {
      depth--
    }

    // Only include content if we're at the correct nesting level
    if (depth === 0 && content) {
      return content
    }

    lastIndex = match.index + match[0].length
  }

  return null
}

系统提示词 vs 用户提示词的分工设计

💡为什么该项目这样考虑哪些内容放系统提示词,哪些内容放用户提示词?

bash 复制代码
System Prompt (system 参数)
  ├─ 归属头 (attribution header)          ← 不缓存
  ├─ CLI 前缀 "You are Claude Code..."    ← org 级缓存
  ├─ 静态指令 (工具使用规范、行为准则等)     ← global 级缓存
  ├─ ── DYNAMIC_BOUNDARY ──               ← 分界线
  └─ 动态内容 (MCP 工具说明等)             ← 不缓存

User Message (messages 参数,前置注入)
  └─ <system-reminder> 包裹:
       ├─ claudeMd  (CLAUDE.md 内容)
       ├─ currentDate (当前日期)
       └─ "可能相关也可能无关" 免责声明

核心设计原则:

1. 缓存效率(最关键的工程原因)

系统提示词支持 Anthropic API 的 prompt caching,有三级缓存 scope:

Scope 含义 缓存命中率
global 跨组织共享 最高(所有用户共享)
org 组织内共享 中等
不缓存 最低(每次请求重新计算 token)
  • 静态指令 (工具规范、行为准则 ~50-70K tokens)放在系统提示词的 boundary ( DYNAMIC_BOUNDARY)之前 ,使用 global 缓存 --- 所有 Claude Code 用户共享一份缓存,成本近乎为零
  • 动态内容(MCP 工具描述等,每个用户不同)放在 boundary 之后,不缓存
  • 归属头(包含用户指纹)不缓存

边界标记 prompts.ts 的注释写得很明确:

shell 复制代码
 * Boundary marker separating static (cross-org cacheable) content from dynamic content.
 * Everything BEFORE this marker in the system prompt array can use scope: 'global'.
 * Everything AFTER contains user/session-specific content and should not be cached.
 
 * 边界标记:用于区分静态内容(可跨组织缓存)与动态内容。
 * 系统提示词数组中,此标记之前的所有内容均可使用 global 作用域。
 * 此标记之后的所有内容包含用户 / 会话专属信息,不应被缓存。

splitSysPromptPrefix 会根据这个 marker 将系统提示词拆分成不同缓存级别的块。

如果把 CLAUDE.md 放进系统提示词,每个用户的 CLAUDE.md 内容不同 → 系统提示词就变成了 per-user → global 缓存彻底失效 → 成本暴增。

2. 语义角色对齐

从 Claude 模型的角度:

  • System = "你是什么角色、你怎么工作" --- 指令性的、稳定的
  • User = "当前情况是什么、用户要什么" --- 情境性的、变化的
shell 复制代码
系统提示词里放:                    用户消息里放:
├─ 你是 Claude Code               ├─ CLAUDE.md(项目自定义指令)
├─ 如何使用工具                    ├─ 当前日期
├─ 行为准则和约束                  ├─ git status 快照
├─ 输出格式要求                    └─ <system-reminder> 免责声明
└─ 全局规范(对所有用户一致)

CLAUDE.md 本质上是用户提供的项目上下文 ,不是 Claude Code 的固有行为规范。把它放在 user message 里并用 <system-reminder> 包裹,同时加上 "this context may or may not be relevant" 的免责声明,让模型知道这是辅助参考 而非强制指令。这降低了 CLAUDE.md 内容与系统指令冲突时的优先级问题。

3. 会话生命周期差异

  • 系统提示词 在 compaction(上下文压缩)后完整保留 ,因为它作为 API 的 system 参数独立于消息历史
  • 用户消息 在 compaction 时会被摘要压缩

这意味着:

  • git status 这种快照型信息 ,放系统提示词里反而不好 --- compaction 后它不会更新,但对话中已经执行了 git 操作,模型看到的 status 是过时的。不过目前项目把它放在了系统提示词里(appendSystemContext),这是一个务实选择 --- git status 虽然会过时,但系统提示词有缓存优势
  • CLAUDE.md 放用户消息里 --- compaction 后会自然被摘要掉,但因为 CLAUDE.md 在每轮对话都通过 prependUserContext 重新注入,所以不会丢失

总结:系统提示词放"对所有用户都一样"的静态指令以最大化 prompt caching 收益;用户消息放"因用户/会话而异"的动态上下文。这是缓存效率、语义正确性、会话生命周期三者的平衡。

💡为什么调用模型API,需要说明这是哪类缓存?

不是在告诉模型,是在控制发给 API 的 cache_control 字段。整个流程是:

shell 复制代码
代码逻辑(客户端)                      API 请求(发到 Anthropic 服务端)
                                                         
splitSysPromptPrefix()                                
  ├─ block.cacheScope = null        →    { text: "..." }   // 无 cache_control
  ├─ block.cacheScope = 'org'       →    { text: "...", cache_control: { type: "ephemeral" } }
  └─ block.cacheScope = 'global'    →    { text: "...", cache_control: { type: "ephemeral", scope: "global" } }

最终发给 API 的请求长这样(简化):

json 复制代码
{
  "system": [
    { "type": "text", "text": "You are Claude Code..." },
    { "type": "text", "text": "<静态工具规范...>", "cache_control": { "type": "ephemeral", "scope": "global" } },
    { "type": "text", "text": "<动态MCP内容...>" }
  ]
}

模型完全看不到 cache_control。这个字段是给 Anthropic 服务端的基础设施用的 --- 服务端根据它决定是否复用已缓存的 KV 状态,避免重复计算那 ~50K tokens 的 attention。

skipGlobalCacheForSystemPrompt 的触发条件是"当前有 MCP 工具存在"(claude.ts:1340-1345)。因为 MCP 工具描述是用户自定义的、放在系统提示词尾部,如果仍然标记 global 缓存,Anthropic 服务端会发现不同用户的全局缓存键不一致 → 缓存失效 → 白打了标记还浪费了一个 cache block 位。所以有 MCP 工具时降级为 org 级缓存。

💡以 MCP 工具为例,既然它是用户定义的,为什么要放在系统提示词尾部,而不是用户提示词(User Prompt)中?

MCP 指令放在系统提示词,而不是用户消息,原因是 Claude API 的工具调用机制

1. API 协议的语义要求

Anthropic API 的设计中,tools 数组里的每个工具只有 namedescriptioninput_schema 三个字段。但 MCP 服务器通常需要给模型额外的使用说明 --- 比如认证方式、调用注意事项、组合使用策略等,这些不适合塞在 description 的几百字里。

这些指令必须放在模型能稳定参考的位置。如果放在用户消息里:

  • compaction 后可能被摘要掉 → 模型忘了 MCP 工具怎么用
  • 每轮重新注入 → 但会被模型当成"新信息"而非"持久规则"

放在系统提示词里,模型把它当作持久行为规范对待,权重和稳定性都更高。

2. 项目正在把 MCP 指令移出系统提示词

看 prompts.ts 的注释:

typescript 复制代码
// When delta enabled, instructions are announced via persisted
// mcp_instructions_delta attachments (attachments.ts) instead of this
// per-turn recompute, which busts the prompt cache on late MCP connect.

MCP 指令被标记为 DANGEROUS_uncachedSystemPromptSection(systemPromptSections.ts),意味着它每轮重新计算,会破坏缓存。

项目已经实现了 mcp_instructions_delta 机制(mcpInstructionsDelta.ts):当 MCP 服务器中途连接/断开时,增量指令作为 attachment(附件消息) 注入,而不是重写系统提示词。这样:

  • 系统提示词保持不变 → 缓存不被破坏
  • MCP 指令通过消息附件传递 → 模型仍然能看到

3. 缓存降级策略

当没有启用 delta 机制时,项目做了缓存降级 --- claude.ts:

typescript 复制代码
// MCP tools are per-user → dynamic tool section → can't globally cache.
const needsToolBasedCacheMarker = 
  useGlobalCacheFeature && 
  filteredTools.some(t => t.isMcp === true && !willDefer(t))

有 MCP 工具时,整个系统提示词从 global 缓存降级为 org 缓存,避免不同用户的 MCP 配置污染全局缓存。

相关推荐
星幻元宇VR2 小时前
VR流动行走平台|让虚拟体验真正“走起来”
科技·学习·安全·vr·虚拟现实
赵侃侃爱分享2 小时前
学习网络安全后首先应该做这些工作
学习·安全·web安全
像一只黄油飞2 小时前
第二章-02-注释
笔记·python·学习·零基础
xiebingsuccess3 小时前
ThingsPanel IoT Platform 学习笔记
笔记·学习
网创联盟,知识导航3 小时前
沐雨云香港直连500M大带宽云主机深度测评
经验分享·学习·测试工具
秋93 小时前
学霸圈公认的 10 种高效学习习惯:从低效到顶尖的底层逻辑
人工智能·学习·算法
墨澜逸客3 小时前
华胥祭坛志---文/墨澜逸客
开发语言·深度学习·学习·百度·php·学习方法·新浪微博
cwplh4 小时前
平衡树学习笔记
数据结构·笔记·学习·算法
爱写代码的小朋友4 小时前
生成式人工智能(AIGC)在中小学生探究式学习中的应用边界与伦理思考
人工智能·学习·aigc