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 太大/加密/无效、图片太大、请求太大等错误时,后续请求需要移除对应的 document 或 image 块,否则会反复触发同一错误。
第 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 做三件事:
- 工具名规范化 :通过
toolMatchesName匹配已注册的工具名(处理别名) - 输入标准化 :
normalizeToolInputForAPI移除工具内部字段(如ExitPlanModeV2的plan字段) - 工具搜索字段剥离 :未启用工具搜索时,显式构造 只有
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_use 与 tool_result 成对出现,修复不匹配(孤儿 tool_use、孤儿 tool_result、重复 ID、断流等),防止 API 400 错误。
stripExcessMediaItems 源码分析
作用是限制消息数组中的媒体项数量(图片 + 文档),超过 limit 则从最早的媒体开始剥离。
流程:
-
计数 --- 遍历所有消息,统计
image和document类型的content block数量(包括嵌套在tool_result内部的),得到总媒体数 -
计算差值 ---
toRemove = total - limit,如果未超限则原样返回 -
从旧到新剥离 --- 再次遍历消息,按顺序从前往后过滤媒体块,每移除一个
toRemove--,直到减到0(即只剥离最早的、多余的媒体,保留最新的)