Claude Code 源码分析第 38 课 · 第 08
第 38 课

消息处理

从原始击键到 API 绑定内容块——您的输入在 Claude 看到之前所经历的每一次转换。

01 Overview

您输入的每个提示都会经过仔细分层的管道,然后到达 Anthropic API。 该管道处理图像、斜杠命令、bash 模式、排队、挂钩、附件和多块规范化 - 所有这些都在一个跨三个源文件和大约 1,500 行 TypeScript 的异步瀑布中进行。

覆盖源文件
utils/handlePromptSubmit.tsutils/processUserInput/processUserInput.tsutils/processUserInput/processTextPrompt.tsutils/messages.ts

从高层次来看,这次旅程有 四个概念阶段:

第一阶段

提交并路由

handlePromptSubmit — 验证、退出字处理、排队与立即执行

第二阶段

输入分类

processUserInput — 图像调整大小、斜线/bash/文本分支、钩子执行

第三阶段

消息构建

processTextPrompt + createUserMessage — 建筑类型 UserMessage objects

第四阶段

API标准化

normalizeMessagesForAPI — 扁平化、重复数据删除、剥离虚拟消息

02 端到端管道图

下图追踪了 React 的确切代码路径 onSubmit 推送到 API 请求中的第一条消息的处理程序:

flowchart TD A["User presses Enter\nonSubmit in REPL component"] --> B["handlePromptSubmit()\nhandlePromptSubmit.ts"] B --> C{"queuedCommands\nalready set?"} C -->|"Yes (queue processor)"| EXEC["executeUserInput()\nskip validation"] C -->|"No (direct user input)"| D["Trim input\nFilter orphaned image refs\nexpandPastedTextRefs()"] D --> E{"Exit word?\nexit / quit / :q"} E -->|"Yes"| EXIT["Re-submit as /exit\nor gracefulShutdownSync(0)"] E -->|"No"| F{"Starts with /\nand !skipSlashCommands?"} F -->|"immediate cmd"| IMM["Execute immediately\n(e.g. /config, /doctor)\nsetToolJSX JSX render"] F -->|"no"| G{"queryGuard.isActive\nor externalLoading?"} G -->|"Yes — busy"| QUEUE["enqueue()\ncommandQueue push\nnotifySubscribers()"] G -->|"No — idle"| CMD["Wrap as QueuedCommand\nstartQueryProfile()"] CMD --> EXEC EXEC --> WL["runWithWorkload()\nAsyncLocalStorage workload tag"] WL --> PUI["processUserInput()\nfor each QueuedCommand"] PUI --> HOOKS1["executeUserPromptSubmitHooks()\ncheck blocking / additional contexts"] HOOKS1 --> BASE["processUserInputBase()"] BASE --> IMG["Image blocks:\nmaybeResizeAndDownsampleImageBlock()\nstoreImages()"] IMG --> BRIDGE{"bridgeOrigin &\nstarts with /?"} BRIDGE -->|"Yes"| BSAFE{"isBridgeSafeCommand?"} BSAFE -->|"Yes"| CLEARSKIP["effectiveSkipSlash = false"] BSAFE -->|"No"| BRIDGEMSG["Return error UserMessage\n'not available over Remote Control'"] BRIDGE -->|"No"| ULTRA{"ULTRAPLAN\nfeature flag?"} CLEARSKIP --> ULTRA ULTRA -->|"keyword match"| ULP["processSlashCommand('/ultraplan ...')"] ULTRA -->|"no"| ATTACH["getAttachmentMessages()\nIDE selection, todos, diffs"] ATTACH --> MODE{"mode?"} MODE -->|"bash"| BASH["processBashCommand()\nwrap in BashTool.call()"] MODE -->|"starts with /"| SLASH["processSlashCommand()\nfork subagent or local-jsx"] MODE -->|"prompt"| TEXT["processTextPrompt()"] TEXT --> PROMPT_ID["setPromptId(randomUUID())\nstartInteractionSpan()"] PROMPT_ID --> KW["matchesNegativeKeyword()\nmatchesKeepGoingKeyword()\nlogEvent('tengu_input_prompt')"] KW --> UM["createUserMessage()\n{ type:'user', uuid, timestamp, content }"] UM --> RES["Return { messages[], shouldQuery:true }"] RES --> NORM["normalizeMessagesForAPI()\nflatten multi-block, strip virtuals\nensure tool_result pairing"] NORM --> API["Anthropic SDK call\napi.ts → onQuery()"] style A fill:#22201d,stroke:#7d9ab8,color:#b8b0a4 style API fill:#1a3a1a,stroke:#6e9468,color:#b8b0a4 style QUEUE fill:#2a2a1a,stroke:#b8965e,color:#b8b0a4 style IMM fill:#2a1a3a,stroke:#8e82ad,color:#b8b0a4 style EXIT fill:#2a1a1a,stroke:#c47a50,color:#b8b0a4
03 第一阶段——handlePromptSubmit

handlePromptSubmit.ts 是面向 React 的入口点。 它接收来自文本输入的原始字符串以及每个 UI 上下文:当前消息列表、中止控制器、IDE 选择状态、排队命令等。

两种执行路径

输入有两种不同的方式到达 executeUserInput:

// Path A — queue processor (commands already validated)
if (queuedCommands?.length) {
  startQueryProfile()
  await executeUserInput({ queuedCommands, ... })
  return
}

// Path B — direct user input
const finalInput = expandPastedTextRefs(input, pastedContents)
// ... validation, queuing checks ...
const cmd: QueuedCommand = { value: finalInput, mode, pastedContents, ... }
await executeUserInput({ queuedCommands: [cmd], ... })

队列处理器路径故意跳过所有验证 - 这些命令在最初排队时进行了验证。 直接输入经过引用扩展、退出字检查、立即命令检测和 queryGuard busy-check.

粘贴参考扩展

当您将文件或文本粘贴到输入中时,Claude 将其存储在数字 ID 下并插入 [Pasted text #N] 令牌作为占位符。 在进行任何进一步处理之前, expandPastedTextRefs 将这些标记替换为实际内容。 图像得到类似的处理,但仅在以下情况下才包含在内: [Image #N] 药丸仍然存在于文本中 - 孤立的图像条目被提前过滤掉:

const referencedIds = new Set(parseReferences(input).map(r => r.id))
const pastedContents = Object.fromEntries(
  Object.entries(rawPastedContents).filter(
    ([, c]) => c.type !== 'image' || referencedIds.has(c.id),
  ),
)

队列守卫

If queryGuard.isActive 为 true(查询正在运行),新输入被放置在模块级别 commandQueue 而不是立即执行。 这是一个普通数组,React 组件通过它读取 useSyncExternalStore,使队列具有反应性,无需任何额外的 Redux/Zustand 机制。

并发细节
队列守卫已保留 before processUserInput 开始,而不是之后。 这可以防止在等待期间第二次提交到达的竞争 processBashCommand or getMessagesForSlashCommand — 两者都在查询“正式”运行之前挂起。

立即命令

已标记的命令 immediate: true 在命令注册表中(例如 /config and /doctor)可以运行 while 查询正在进行中。 它们被检测到 startsWith('/') 检查并通过以下方式发送 setToolJSX — 直接渲染到 Ink 组件树中,而不是发送到 API:

const immediateCommand = commands.find(
  cmd => cmd.immediate && isCommandEnabled(cmd) && cmd.name === commandName
)
if (immediateCommand && immediateCommand.type === 'local-jsx') {
  const impl = await immediateCommand.load()
  const jsx = await impl.call(onDone, context, commandArgs)
  setToolJSX({ jsx, isLocalJSXCommand: true, isImmediate: true })
}
04 第 2 阶段 — 处理用户输入

processUserInput.ts 是主要的分类引擎。 它接收到一个 QueuedCommand的值并根据模式和内容形状将其路由到四个处理程序之一。

图像预处理

首先对数组形式的输入(当 SDK 或 VS Code 发送多模式内容时使用)进行标准化。 每个图像块都经过 maybeResizeAndDownsampleImageBlock 在提取字符串之前:

for (const block of input) {
  if (block.type === 'image') {
    const resized = await maybeResizeAndDownsampleImageBlock(block)
    processedBlocks.push(resized.block)
  } else {
    processedBlocks.push(block)
  }
}
normalizedInput = processedBlocks

粘贴的图像(来自 UI 剪贴板路径)会并行调整大小,以避免在慢速图像上进行序列化:

const imageProcessingResults = await Promise.all(
  imageContents.map(async pastedImage => {
    const resized = await maybeResizeAndDownsampleImageBlock(imageBlock)
    return { resized, originalDimensions, sourcePath }
  })
)

分支开关

图像标准化并加载附件后,三路分支将输入发送到正确的处理器:

// 1. Bash mode
if (mode === 'bash') {
  return processBashCommand(inputString, ...)
}

// 2. Slash command (not from bridge/CCR)
if (!effectiveSkipSlash && inputString.startsWith('/')) {
  return processSlashCommand(inputString, ...)
}

// 3. Regular text prompt (default)
return processTextPrompt(normalizedInput, imageContentBlocks, ...)

Ultraplan 关键字路径

还有一个隐藏的第四个分支:如果 ULTRAPLAN 功能标志打开并且输入(粘贴扩展之前)包含 ultraplan 关键字,它会被静默重写为 /ultraplan <input> 并通过斜杠命令路径路由。 检测针对的是 pre-expansion 专门输入以防止粘贴的内容意外触发它。

桥接/遥控安全

来自 iOS 或 Web 客户端的消息到达时带有 skipSlashCommands: true 以防止意外执行斜杠命令。 这 bridgeOrigin flag 创建一个微妙的覆盖:如果命令被标记 isBridgeSafeCommand(),跳过被清除并且命令正常执行。 如果它是已知但不安全的命令(本地 jsx 或仅限终端),则会立即返回有用的错误,而不是让原始文本像 /config 混淆模型。

用户提示提交挂钩

After processUserInputBase 返回与 shouldQuery: true, 挂号的 UserPromptSubmit 钩子被执行。 钩子可以:

  • Block 完全提示——返回系统警告消息
  • 防止继续 — 将提示保留在上下文中但停止查询
  • 注入额外的上下文 — 附加 AttachmentMessage API调用之前的对象
for await (const hookResult of executeUserPromptSubmitHooks(...)) {
  if (hookResult.blockingError) {
    return { messages: [createSystemMessage(blockingMessage)], shouldQuery: false }
  }
  if (hookResult.preventContinuation) {
    result.messages.push(createUserMessage({ content: 'Operation stopped by hook' }))
    result.shouldQuery = false
    return result
  }
  if (hookResult.additionalContexts) {
    result.messages.push(createAttachmentMessage({ type: 'hook_additional_context', ... }))
  }
}
钩子输出安全
钩子输出上限为 10,000 字符由 applyTruncation()。 任何超出此范围的输出都会被截断通知替换,从而防止失控的挂钩 stdout 使上下文窗口膨胀。
05 第三阶段——消息构建

最终的“正常提示”路径落在 processTextPrompt.ts,这是原始字符串变成类型化字符串的地方 UserMessage 系统的其余部分可以理解。

processTextPrompt — 它的作用

这个函数故意很小——只有大约 100 行。 它的工作是:

  1. 分配一个新鲜的 promptId via setPromptId(randomUUID()) 并启动 OpenTelemetry 交互范围
  2. 发出否定关键字检测分析(matchesNegativeKeyword) 和持续性短语
  3. 组装内容数组 - 首先是文本,然后在下面粘贴图像块
  4. Call createUserMessage() 并返回 { messages, shouldQuery: true }
// Pasted images: text first, images after
if (imageContentBlocks.length > 0) {
  const textContent = typeof input === 'string'
    ? [{ type: 'text', text: input }]
    : input
  const userMessage = createUserMessage({
    content: [...textContent, ...imageContentBlocks],
    uuid, imagePasteIds, permissionMode,
  })
  return { messages: [userMessage, ...attachmentMessages], shouldQuery: true }
}

createUserMessage — 消息工厂

createUserMessage in utils/messages.ts 是所有用户端消息的规范工厂。 它标记在对象上的每个字段都具有含义:

uuid

稳定的身份

randomUUID() 或调用者提供的。 用于倒带、文件历史快照和工具结果配对。

isMeta

从 UI 中隐藏

When true,该消息是模型可见的,但从未显示在记录中。 用于图像元数据、计划任务和系统生成的提示。

origin

Provenance

跟踪消息的来源 — undefined = 人类键盘, task-notification = 计划任务等

permissionMode

安全快照

存储消息创建时的权限模式,以便倒回可以恢复准确的安全级别。

toolUseResult

工具配对

For tool_result 消息,携带工具调用的结构化输出。

imagePasteIds

图像追踪

内容数组中图像的粘贴 ID 的有序列表 - 在标准化中分割消息时使用。

图像元数据就是元消息

每当处理图像时,都会有一个单独的 isMeta 附加包含维度和源路径信息的用户消息。 这给出了模型的空间上下文(例如, "[Image source: /tmp/screenshot.png, 1920x1080]")而不污染可见的转录本:

function addImageMetadataMessage(result, imageMetadataTexts) {
  if (imageMetadataTexts.length > 0) {
    result.messages.push(
      createUserMessage({
        content: imageMetadataTexts.map(text => ({ type: 'text', text })),
        isMeta: true,
      })
    )
  }
  return result
}

综合消息

utils/messages.ts 导出一组常量字符串,用于将合成信号注入对话中。 这些不会直接向用户显示,但会在关键时刻影响模型的行为:

export const INTERRUPT_MESSAGE = '[Request interrupted by user]'
export const CANCEL_MESSAGE =
  "The user doesn't want to take this action right now. STOP what you are doing..."
export const REJECT_MESSAGE =
  "The user doesn't want to proceed with this tool use..."
export const SYNTHETIC_TOOL_RESULT_PLACEHOLDER =
  '[Tool result missing due to internal error]'
训练数据保护
SYNTHETIC_TOOL_RESULT_PLACEHOLDER 还专门导出,以便 HFI(人类反馈接口)提交路径可以检测到它并 reject 包含它的任何有效负载。 如果提交虚假的工具结果,就会毒害训练数据——这是一个明确的安全阀。
06 第四阶段——normalizeMessagesForAPI

在最终消息列表到达之前 api.ts,它通过 normalizeMessagesForAPI — 多通道转换,确保有效负载对于 Anthropic API 有效。

NormalizeMessages 首先做什么

normalizeMessages (与 API 变体不同)将每个多内容块消息拆分为单独的单块消息。 它使用确定性 UUID 派生方案,以便相同的输入始终产生相同的输出 UUID:

// Derive stable UUID from parent UUID + block index
export function deriveUUID(parentUUID: UUID, index: number): UUID {
  const hex = index.toString(16).padStart(12, '0')
  return `${parentUUID.slice(0, 24)}${hex}` as UUID
}

NormalizeMessagesForAPI 通行证

API 规范化函数执行几个不同的过程:

  1. 重新排序附件 - 附件消息冒泡,直到到达工具结果或辅助消息边界
  2. 剥离虚拟消息 — 仅显示消息(isVirtual: true) 绝不能到达 API
  3. 构建错误剥离图 — 将 API 错误类型(PDF 太大、图像太大、请求太大)映射到应从前面的消息中剥离的块类型
  4. 过滤系统/附件消息 — 这些仅适用于 UI,并且不包含在 API 有效负载中
  5. 合并连续的相同角色消息 — Anthropic API 要求严格交替用户/助理轮流
  6. 确保工具结果配对 - 每一个 tool_use 块必须有一个匹配的 tool_result; 孤立的用途得到 SYNTHETIC_TOOL_RESULT_PLACEHOLDER
为什么连续合并很重要
因为挂钩、附件和用户消息都可以添加 user- 在单个管道运行中的角色内容,完全有可能最终得到两个连续的用户消息。 合并过程将它们折叠成具有串联内容块的单个用户消息 - 满足 API 严格的交替轮换要求。
深入探讨——短消息ID方案

Claude Code 使用源自剪切工具参考系统的完整 UUID 的 6 字符 base36“短 ID”。 这些 ID 被注入到 API 绑定消息中,如下所示 [id:abc123] 标签,以便模型可以通过短令牌而不是完整的 36 字符 UUID 来引用特定的过去消息。

export function deriveShortMessageId(uuid: string): string {
  // First 10 hex chars of UUID (dashes removed)
  const hex = uuid.replace(/-/g, '').slice(0, 10)
  // Convert to base36, take 6 chars
  return parseInt(hex, 16).toString(36).slice(0, 6)
}

推导为 deterministic:相同的 UUID 始终会产生相同的短 ID。 这很关键——系统会在每个 API 调用中注入这些标签,因此如果派生是随机的,则每次都会生成不同的标签,从而破坏对先前消息的任何模型引用。

07 消息分类

内部消息类型系统具有比“用户”和“助理”更多的变体。 了解完整的分类法可以解释为什么管道需要如此多的转换过程:

UserMessage

人工输入

Role "user"。 携带内容(字符串或块数组),加上 isMeta、origin、permissionMode、imagePasteIds、toolUseResult。

AssistantMessage

模型响应

Role "assistant"。 包含内容块,包括文本、工具使用和思维块。 携带完整的使用指标。

AttachmentMessage

侧信道上下文

不直接发送到 API。 携带 IDE 选择、挂钩输出、代理提及、内存标头。 在 API 调度之前重新排序并合并。

SystemMessage

仅 UI 信号

Subtypes: api_error, compact_boundary, turn_duration, informational。 完全从 API 有效负载中过滤。

ProgressMessage

流媒体状态

短暂的——工具执行进度的实时更新。 切勿长期保存; 在 API 调用之前进行过滤。

TombstoneMessage

删除标记

将消息标记为从对话中删除。 在压缩/倒带操作期间进行处理。

08 记忆修正提示模式

管道如何注入模型指导的一个微妙但有启发性的示例是内存校正提示。 当启用自动记忆且 GrowthBook 功能门时 tengu_amber_prism 处于活动状态,拒绝和取消消息会附加附言:

const MEMORY_CORRECTION_HINT =
  "\n\nNote: The user's next message may contain a correction or preference. "
  + "Pay close attention — if they explain what went wrong or how they'd "
  + "prefer you to work, consider saving that to memory for future sessions."

export function withMemoryCorrectionHint(message: string): string {
  if (isAutoMemoryEnabled() && getFeatureValue_CACHED('tengu_amber_prism', false)) {
    return message + MEMORY_CORRECTION_HINT
  }
  return message
}

此模式演示了管道如何使用消息内容本身作为引导模型行为的机制 - 不需要特殊的 API 字段。 提示仅通过工具结果块的普通文本内容到达模型。

09 查询分析检查点

分散在整个管道中的是对 queryCheckpoint(label)。 这些是记录关键时刻挂钟时间戳的性能面包屑。 标签给出了时间花费地点的精确地图:

query_process_user_input_start
query_process_user_input_base_start
query_image_processing_start / _end
query_pasted_image_processing_start / _end
query_attachment_loading_start / _end
query_hooks_start / _end
query_process_user_input_base_end
query_file_history_snapshot_start / _end
query_process_user_input_end
绩效洞察
检查点名称揭示了三个最大的潜在延迟源:图像处理(调整大小 + Base64 编码)、附件加载(IDE 选择 + 待办事项列表 + git diff)和钩子执行(外部进程调用)。 所有三个都异步运行,但并非全部都是并行的 - 粘贴图像使用 Promise.all 但附件加载是按顺序进行的 getAttachmentMessages.

要点

  • 每个提交都通过一个单一的规范化 QueuedCommand 形状,以便直接和队列处理器路径共享相同的执行逻辑。
  • 该管道有四个不同的模式分支:bash、斜杠命令、ultraplan 关键字(隐藏)和纯文本提示。
  • 图像已调整大小 twice — 一次在内联块标准化期间,另一次在处理粘贴的 UI 剪贴板内容时 — 两次都是通过并行进行 Promise.all.
  • UserPromptSubmit 挂钩在输入分类之后但在 API 调用之前运行,使外部进程能够阻止、注释或停止任何提示。
  • createUserMessage 是所有用户角色消息的单一工厂; 它是 isMeta flag 是一种将系统生成的上下文置于可见记录之外的机制。
  • normalizeMessagesForAPI 是最终的看门人——它剥离虚拟消息,合并连续的相同角色回合,并确保每个 tool_use 有一个配对的 tool_result.
  • 综合消息常量(INTERRUPT_MESSAGE, CANCEL_MESSAGE等)兼作训练数据保护 — HFI 系统拒绝包含以下内容的有效负载 SYNTHETIC_TOOL_RESULT_PLACEHOLDER.

知识检查

1. 什么是 handlePromptSubmit 何时做 queryGuard.isActive is true 用户提交正常提示?
2. 为什么 ultraplan 关键字检测会运行 preExpansionInput 而不是最终的扩展输入?
3. 目的是什么 SYNTHETIC_TOOL_RESULT_PLACEHOLDER 被出口自 messages.ts?
4. 管道中的哪个点 UserPromptSubmit 钩子执行了吗?
5. 什么是 isMeta: true 在一个 UserMessage 意思是?
0/5