query.ts 中 message的处理流程

normalizeMessagesForAPI 逐行分析

文件 : src/utils/messages.ts:1989-2370

scss 复制代码
用户输入 (REPL.tsx)
    → onQuery (2855)
      → onQueryImpl (2661)
        → query() (query.ts:219)
          → queryLoop() (query.ts:241)
            → deps.callModel (query.ts:659 → deps.ts:35 →
  queryModelWithStreaming)
              → queryModel() (claude.ts:1017)
                → normalizeModelStringForAPI(options.model) (claude.ts:1700)

一、函数签名

typescript 复制代码
export function normalizeMessagesForAPI(
  messages: Message[],      // 完整的内部消息数组
  tools: Tools = [],        // 可用工具列表(用于过滤不存在的 tool_reference)
): (UserMessage | AssistantMessage)[]  // 返回只包含 user/assistant 的消息数组

作用 : 将内部 Message[](8 种类型)转换为 API 请求用的消息格式(仅 user + assistant 两种类型),同时执行所有过滤、合并、标准化操作。


二、逐行分析

第 1993-1994 行:构建工具名称集合

typescript 复制代码
const availableToolNames = new Set(tools.map(t => t.name))

为后续过滤不可用工具引用做准备。当工具搜索启用时,需要移除已断开 MCP 服务器的 tool_reference 块。


第 1996-2001 行:重排附件 + 移除虚拟消息

typescript 复制代码
const reorderedMessages = reorderAttachmentsForAPI(messages).filter(
  m => !((m.type === 'user' || m.type === 'assistant') && m.isVirtual),
)

两步操作:

1. reorderAttachmentsForAPI(messages):将附件消息(attachment)上浮,直到遇到 tool_result 或 assistant 消息。确保附件出现在正确的位置,不打断 tool_use/tool_result 配对。

2. 过滤 isVirtual:移除标记为 virtual 的 user 和 assistant 消息。这些消息仅用于 UI 展示(如 REPL 内部工具调用),绝对不能进入 API。


第 2003-2010 行:错误→拦截类型的映射表

typescript 复制代码
const errorToBlockTypes: Record<string, Set<string>> = {
  [getPdfTooLargeErrorMessage()]:        new Set(['document']),
  [getPdfPasswordProtectedErrorMessage()]: new Set(['document']),
  [getPdfInvalidErrorMessage()]:          new Set(['document']),
  [getImageTooLargeErrorMessage()]:       new Set(['image']),
  [getRequestTooLargeErrorMessage()]:     new Set(['document', 'image']),
}

定义每种 API 错误对应的内容块类型。当 API 返回 PDF 太大/加密/无效、图片太大、请求太大等错误时,后续请求需要移除对应的 documentimage 块,否则会反复触发同一错误。


第 2012-2054 行:构建内容剥离映射表

typescript 复制代码
const stripTargets = new Map<string, Set<string>>()

Map<userMessageUUID, Set<blockType>>:记录"哪条用户消息需要剥离哪些类型的 content block"。

内层循环解析(第 2015-2054 行)
ini 复制代码
遍历所有重排后的消息:
  │
  └─ 跳过非合成错误消息 (isSyntheticApiErrorMessage=false)
      │
      └─ 从合成错误消息中提取错误文本
          │
          └─ 在 errorToBlockTypes 中查找对应的 block 类型
              │
              └─ 向前回溯,找到最近的前一条 isMeta 用户消息
                  │
                  ├─ 找到 → 将 block 类型加入 stripTargets[用户消息UUID]
                  └─ 遇到 assistant/非 meta 用户 → 停止回溯

目的是找到"哪条用户消息附带的 PDF/图片导致了 API 错误",之后从那条消息中移除对应的 content block。


第 2056-2075 行:过滤不可进 API 的消息类型

typescript 复制代码
const result: (UserMessage | AssistantMessage)[] = []
reorderedMessages.filter((): _ is UserMessage | AssistantMessage | ... => {
  if (
    _.type === 'progress' ||
    (_.type === 'system' && !isSystemLocalCommandMessage(_)) ||
    isSyntheticApiErrorMessage(_)
  ) {
    return false  // 过滤掉
  }
  return true     // 保留
}).forEach(message => { ... })

过滤规则

消息类型 是否保留 原因
progress ❌ 丢弃 仅 UI 进度展示
system (非 local_command) ❌ 丢弃 系统消息只在 UI 侧存在
system(local_command) ✅ 保留 其他 system 消息(compact_boundary、api_metrics、turn_duration 等)都只在 UI侧展示,不发给模型。但 local_command 记录了用户执行了什么命令、看到了什么输出,模型需要知道这些内容才能理解上下文,所以它被转为 user 消息发给 API。
synthetic API error ❌ 丢弃 上一步已用其内容构建了 stripTargets
user / assistant / attachment ✅ 保留 核心对话内容

剩下的 .forEach() 对每种保留的消息类型做不同处理。


第 2078-2092 行:system(local_command) → 转为 user 消息

typescript 复制代码
case 'system': {
  const userMsg = createUserMessage({
    content: message.content,
    uuid: message.uuid,
    timestamp: message.timestamp,
  })
  const lastMessage = last(result)
  if (lastMessage?.type === 'user') {
    result[result.length - 1] = mergeUserMessages(lastMessage, userMsg)
    return
  }
  result.push(userMsg)
  return
}

local_command 系统消息存储的是本地命令的输出内容(如 /help/compact 等),需要转为 user 消息发给模型。如果上一条也是 user 消息,合并到一起(Bedrock 不支持连续 user 消息)。


第 2094-2199 行:user 消息处理

这是 user 消息处理的完整流程,包含 5 个子步骤:

步骤 1(第 2103-2111 行):剥离 tool_reference 块
typescript 复制代码
let normalizedMessage = message
if (!isToolSearchEnabledOptimistic()) {
  normalizedMessage = stripToolReferenceBlocksFromUserMessage(message)
} else {
  normalizedMessage = stripUnavailableToolReferencesFromUserMessage(
    message, availableToolNames,
  )
}
  • 工具搜索未启用 :移除所有 tool_reference 块(该功能需要 beta 标头)
  • 工具搜索已启用:仅移除已断开连接的 MCP 工具的引用
步骤 2(第 2116-2137 行):剥离错误内容块
typescript 复制代码
const typesToStrip = stripTargets.get(normalizedMessage.uuid)
if (typesToStrip && normalizedMessage.isMeta) {
  const content = normalizedMessage.message.content
  if (Array.isArray(content)) {
    const filtered = content.filter(block => !typesToStrip.has(block.type))
    if (filtered.length === 0) {
      return  // 全部被剥离 → 完全跳过这条消息
    }
    if (filtered.length < content.length) {
      normalizedMessage = { ...normalizedMessage, message: { ...message.message, content: filtered } }
    }
  }
}

使用第 2012-2054 步构建的 stripTargets 映射表。如果某条 meta 用户消息的 PDF/图片曾导致 API 错误,从后续请求中移除对应的 document/image 块。如果所有块都被移除了,整条消息跳过。

步骤 3(第 2159-2185 行):注入 tool_reference 分界文本
typescript 复制代码
if (!checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_toolref_defer_j8m')) {
  const contentAfterStrip = normalizedMessage.message.content
  if (
    Array.isArray(contentAfterStrip) &&
    !contentAfterStrip.some(b => b.type === 'text' && b.text.startsWith(TOOL_REFERENCE_TURN_BOUNDARY)) &&
    contentHasToolReference(contentAfterStrip)
  ) {
    normalizedMessage = {
      ...normalizedMessage,
      message: {
        ...normalizedMessage.message,
        content: [
          ...contentAfterStrip,
          { type: 'text', text: TOOL_REFERENCE_TURN_BOUNDARY },
        ],
      },
    }
  }
}

当消息包含 tool_reference 块时,服务端会渲染为 <functions>...</functions>。如果这些内容在 prompt 尾部,某些模型约 10% 的概率会错误采样到 stop sequence。解决方案:在 tool_reference 块后追加一个 \n\nHuman: 文本块作为明确的轮次边界。

tengu_toolref_defer_j8m 门控:该标志启用时,由后处理 relocateToolReferenceSiblings() 接管,这里跳过注入。

幂等性query.ts 每轮都会调用此函数,已追加的文本块通过 startsWith(TOOL_REFERENCE_TURN_BOUNDARY) 检测并跳过。

步骤 4(第 2187-2195 行):合并连续 user 消息
typescript 复制代码
const lastMessage = last(result)
if (lastMessage?.type === 'user') {
  result[result.length - 1] = mergeUserMessages(lastMessage, normalizedMessage)
  return
}

与上一条 user 消息合并。Bedrock 不支持连续 user 消息(1P API 虽然支持也会自动合并,但显式合并更可靠)。

步骤 5(第 2198 行):追加
typescript 复制代码
result.push(normalizedMessage)

如无合并场景,直接追加到结果数组。


第 2201-2267 行:assistant 消息处理

工具输入标准化(第 2206-2244 行)
typescript 复制代码
const toolSearchEnabled = isToolSearchEnabledOptimistic()
const normalizedMessage: AssistantMessage = {
  ...message,
  message: {
    ...message.message,
    content: message.message.content.map(block => {
      if (block.type === 'tool_use') {
        const tool = tools.find(t => toolMatchesName(t, block.name))
        const normalizedInput = tool
          ? normalizeToolInputForAPI(tool, block.input as Record<string, unknown>)
          : block.input
        const canonicalName = tool?.name ?? block.name

        if (toolSearchEnabled) {
          return { ...block, name: canonicalName, input: normalizedInput }
        }
        // 工具搜索未启用:只保留标准字段,strip 掉 caller 等额外字段
        return {
          type: 'tool_use' as const,
          id: block.id,
          name: canonicalName,
          input: normalizedInput,
        }
      }
      return block  // text/thinking 等非 tool_use block 透传
    }),
  },
}

tool_use block 做三件事:

  1. 工具名规范化 :通过 toolMatchesName 匹配已注册的工具名(处理别名)
  2. 输入标准化normalizeToolInputForAPI 移除工具内部字段(如 ExitPlanModeV2plan 字段)
  3. 工具搜索字段剥离 :未启用工具搜索时,显式构造 只有 type/id/name/input 四个字段的 block,确保不会漏出 caller 等工具搜索专用字段(这些字段存在对话记录中,没有工具搜索 beta 标头发送会导致 API 400 错误)
合并相同 ID 的 assistant 消息(第 2246-2264 行)
typescript 复制代码
for (let i = result.length - 1; i >= 0; i--) {
  const msg = result[i]!
  if (msg.type !== 'assistant' && !isToolResultMessage(msg)) {
    break  // 遇到非 assistant 且非 tool_result 就停止
  }
  if (msg.type === 'assistant') {
    if (msg.message.id === normalizedMessage.message.id) {
      result[i] = mergeAssistantMessages(msg, normalizedMessage)
      return  // 合并后直接返回,不追加
    }
    continue
  }
}
result.push(normalizedMessage)

当 API 响应包含多个 content block 时,normalizeMessages()(早期处理)会将它们拆分为多条独立的 AssistantMessage。这里反过来:找到同一条 API 响应的不同 block,合并回同一条消息

关键设计:只合并 message.id 相同的 assistant 消息 。因为并发 agent(队友)可能交错插入来自不同 API 响应的流式内容 block,它们的 message.id 不同,不应合并。搜索范围限制在最近的连续的 assistant/tool_result 区间,遇到 user 消息就停止。


第 2269-2291 行:attachment 消息处理

typescript 复制代码
case 'attachment': {
  const rawAttachmentMessage = normalizeAttachmentForAPI(message.attachment)
  const attachmentMessage = checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_chair_sermon')
    ? rawAttachmentMessage.map(ensureSystemReminderWrap)
    : rawAttachmentMessage

  const lastMessage = last(result)
  if (lastMessage?.type === 'user') {
    result[result.length - 1] = attachmentMessage.reduce(
      (p, c) => mergeUserMessagesAndToolResults(p, c),
      lastMessage,
    )
    return
  }
  result.push(...attachmentMessage)
  return
}

normalizeAttachmentForAPI() 将内部 Attachment 类型转为 API 可用的 UserMessage[](一个附件可能展开为多条 user 消息)。如果 tengu_chair_sermon 门控开启,对每条消息调用 ensureSystemReminderWrap(将 <system-reminder> 文本块与相邻的 tool_result 合并)。

如果上一条是 user 消息,合并到一起;否则追加到结果数组。


第 2295-2305 行:重定位 tool_reference 文本块

typescript 复制代码
const relocated = checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_toolref_defer_j8m')
  ? relocateToolReferenceSiblings(result)
  : result

tengu_toolref_defer_j8m 门控开启时,将第 2159-2185 步注入的轮次边界文本从 tool_reference 所在的 user 消息移动到后一条不包含 tool_reference 的 user 消息 上。这样可以避免连续两个 user 消息(一个带 tool_reference,一个带文本)的模式------这种模式会误导模型在 tool result 后发射 stop sequence。

门控关闭时,这是一个 noop,由第 2159-2185 步的注入充当 fallback。


第 2307-2311 行:过滤孤儿 thinking-only 消息

typescript 复制代码
const withFilteredOrphans = filterOrphanedThinkingOnlyMessages(relocated)

过滤掉只有 thinking block 的 assistant 消息。这种消息通常是压缩(compaction)切断了流式响应和重试之间的中间消息导致的。如果不移除,连续两条 assistant 消息的 thinking block 签名不匹配会导致 API 400 错误。


第 2313-2325 行:三重后处理清理

typescript 复制代码
const withFilteredThinking = filterTrailingThinkingFromLastAssistant(withFilteredOrphans)
const withFilteredWhitespace = filterWhitespaceOnlyAssistantMessages(withFilteredThinking)
const withNonEmpty = ensureNonEmptyAssistantContent(withFilteredWhitespace)

1. filterTrailingThinkingFromLastAssistant :移除最后一条 assistant 消息末尾的 thinking block。最后一条消息的 thinking block 可能是不完整的(被 max_tokens 截断或在消息处理过程中被剥离),其签名无法通过服务端验证。移除后让消息以 text 或 tool_use 结尾。

2. filterWhitespaceOnlyAssistantMessages :过滤内容只有空白文本的 assistant 消息。顺序很关键:必须先 strip thinking,再过滤空白。否则 [text("\n\n"), thinking("...")] 会先通过空白检查(有非 text block),再 strip 掉 thinking 后变成 [text("\n\n")],API 会拒绝。

3. ensureNonEmptyAssistantContent:确保每条 assistant 消息的 content 不为空。如果经过前两步处理后某条消息的 content 变成了空数组,插入一个占位文本。


第 2327-2338 行:合并 + 合并 system-reminder 文本块

typescript 复制代码
const smooshed = checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_chair_sermon')
  ? smooshSystemReminderSiblings(mergeAdjacentUserMessages(withNonEmpty))
  : withNonEmpty

mergeAdjacentUserMessages:合并上一步可能产生的相邻 user 消息(filterOrphanedThinkingOnlyMessages 移除消息后会导致 user 消息相邻)。

smooshSystemReminderSiblings:将 <system-reminder> 前缀文本块合并到相邻的 tool_result 中。这是为了满足特定服务的格式要求(tengu_chair_sermon 门控)。


第 2340-2343 行:清洗错误 tool_result 内容

typescript 复制代码
const sanitized = sanitizeErrorToolResultContent(smooshed)

无条件执行。将有错误的 tool_result 中的 image 等非文本内容移除。如果在 is_error=true 的 tool_result 中残留了图片内容,恢复会话时会永久返回 400 错误。早期 smooshIntoToolResult 的过滤没覆盖这个情况,所以这里做兜底清洗。


第 2345-2364 行:追加消息 ID 标签

typescript 复制代码
if (feature('HISTORY_SNIP') && process.env.NODE_ENV !== 'test') {
  const { isSnipRuntimeEnabled } = require('../services/compact/snipCompact.js')
  if (isSnipRuntimeEnabled()) {
    for (let i = 0; i < sanitized.length; i++) {
      if (sanitized[i]!.type === 'user') {
        sanitized[i] = appendMessageTagToUserMessage(sanitized[i] as UserMessage)
      }
    }
  }
}

HISTORY_SNIP 功能启用时,在每条 user 消息的末尾追加 [id:uuid] 标签。这使 SnipTool 可以通过消息 ID 精确删除中间消息。测试环境下跳过,因为标签会改变消息内容的 hash,破坏 VCR fixture 查找。 HISTORY_SNIP 功能(ant-only)提供的一个工具。源文件在此代码副本中 不存在,但从使用模式可以还原其设计。


第 2367-2369 行:图片尺寸验证 + 返回

typescript 复制代码
validateImagesForAPI(sanitized)
return sanitized

检查所有 image block 的尺寸是否在 API 限制内。超限的图片会抛出错误,由调用方处理。


三、处理流程总图

sql 复制代码
输入: Message[](8 种类型)
  │
  ├─ ❶ reorderAttachmentsForAPI() → 附件上浮
  ├─ 过滤 isVirtual
  │
  ├─ ❷ 构建 stripTargets(错误内容剥离映射表)
  │
  ├─ ❸ 过滤不可 API 的消息类型
  │   ├─ progress → 丢弃
  │   ├─ system(非 local_command)→ 丢弃
  │   ├─ 合成 API 错误 → 丢弃
  │   └─ 其余保留
  │
  ├─ ❹ 遍历保留的消息,按 type 分支处理:
  │   ├─ system(local_command) → 转为 user,合并相邻 user
  │   ├─ user →
  │   │   ├─ strip tool_reference(未启用搜索时全部移除)
  │   │   ├─ strip 错误内容块(PDF/图片超限)
  │   │   ├─ 注入 tool_reference 分界文本
  │   │   └─ 合并相邻 user
  │   ├─ assistant →
  │   │   ├─ 标准化 tool_use 输入
  │   │   ├─ strip caller 等工具搜索字段
  │   │   └─ 合并相同 API 响应的拆分 block
  │   └─ attachment →
  │       ├─ normalizeAttachmentForAPI()
  │       └─ 合并相邻 user
  │
  ├─ ❺ relocateToolReferenceSiblings(门控)
  ├─ ❻ filterOrphanedThinkingOnlyMessages
  ├─ ❼ filterTrailingThinkingFromLastAssistant
  ├─ ❽ filterWhitespaceOnlyAssistantMessages
  ├─ ❾ ensureNonEmptyAssistantContent
  ├─ ❿ mergeAdjacentUserMessages + smooshSystemReminderSiblings(门控)
  ├─ ⓫ sanitizeErrorToolResultContent
  ├─ ⓬ appendMessageTagToUserMessage(HISTORY_SNIP)
  └─ ⓭ validateImagesForAPI
      │
输出: (UserMessage | AssistantMessage)[] --- 仅 user + assistant 两种类型

四、涉及的辅助函数

函数 位置 作用
reorderAttachmentsForAPI messages.ts 附件上浮,不打断 tool_use/tool_result 配对
isSyntheticApiErrorMessage messages.ts 判断是否为 API 错误消息
isSystemLocalCommandMessage messages.ts 判断是否为本地命令系统消息
mergeUserMessages messages.ts 合并两条 user 消息的 content
mergeAssistantMessages messages.ts 合并两条 assistant 消息的 content block
stripToolReferenceBlocksFromUserMessage messages.ts 移除所有 tool_reference 块
stripUnavailableToolReferencesFromUserMessage messages.ts 移除已断连工具的 tool_reference
normalizeToolInputForAPI messages.ts 标准化工具输入参数
normalizeAttachmentForAPI attachments.ts 附件→UserMessage\[\] 转换
ensureSystemReminderWrap messages.ts 确保 system-reminder 文本包装
relocateToolReferenceSiblings messages.ts 重定位 tool_reference 文本块
filterOrphanedThinkingOnlyMessages messages.ts 过滤孤儿 thinking-only 消息
filterTrailingThinkingFromLastAssistant messages.ts 移除最后一条 assistant 的尾部 thinking
filterWhitespaceOnlyAssistantMessages messages.ts 过滤空白内容消息
ensureNonEmptyAssistantContent messages.ts 确保 content 不为空
mergeAdjacentUserMessages messages.ts 合并相邻 user 消息
smooshSystemReminderSiblings messages.ts 合并 system-reminder 文本到 tool_result
sanitizeErrorToolResultContent messages.ts 清洗错误 tool_result 的图片内容
appendMessageTagToUserMessage messages.ts 追加消息 ID 标签
validateImagesForAPI messages.ts 验证图片尺寸限制

ensureToolResultPairing 源码分析

文件 : src/utils/messages.ts:5133-5460

功能 : 确保消息序列中 tool_usetool_result 成对出现,修复不匹配(孤儿 tool_use、孤儿 tool_result、重复 ID、断流等),防止 API 400 错误。


stripExcessMediaItems 源码分析

作用是限制消息数组中的媒体项数量(图片 + 文档),超过 limit 则从最早的媒体开始剥离。

流程:

  1. 计数 --- 遍历所有消息,统计 imagedocument 类型的 content block 数量(包括嵌套在 tool_result 内部的),得到总媒体数

  2. 计算差值 --- toRemove = total - limit,如果未超限则原样返回

  3. 从旧到新剥离 --- 再次遍历消息,按顺序从前往后过滤媒体块,每移除一个 toRemove--,直到减到 0(即只剥离最早的、多余的媒体,保留最新的)

相关推荐
解决问题2 小时前
query.ts 请求参数字段详解
claude
星之尘10213 小时前
Claude Code 安装与 MiniMax 配置指南
ai·agent·claude·minimax·vibe coding
码农小旋风3 小时前
Vibe Coding 工具对比:Cursor、Windsurf、Claude Code 哪款更适合你
gpt·chatgpt·claude
DO_Community3 小时前
Claude Code 的开源替代方案:用 OpenCode + DigitalOcean 实现模型自由
人工智能·开源·agent·claude·deepseek
winlife_15 小时前
在 Unity 里用 AI 做游戏:funplay-unity-mcp 从安装到第一次让 AI 改场景
人工智能·游戏·unity·ai编程·claude·mcp
ZzT18 小时前
给 Claude Code 装个 profiler:每个工具调用慢在哪,瀑布流时间线里一眼看见
人工智能·github·claude
周公19 小时前
Claude code使用第三方算力安装配置过程
claude·qwen·claude code·open claw
Nayxxu21 小时前
Claude API 企业落地路线图:POC、灰度、监控、缓存、上线
人工智能·claude
解决问题1 天前
流式输出管线深度分析
claude