跳到主要内容

消息归一化:normalizeMessagesForAPI()

🟡 进阶

在 Claude Code 内部,消息以一种富含元数据的格式存储——包含 UUID、时间戳、isVirtual 标志、isMeta 标志等字段,这些字段服务于 UI 渲染、历史管理、权限追踪等内部需求。但 Claude API 只接受干净的 user / assistant 两种角色消息,且有严格的内容结构要求。

normalizeMessagesForAPI() 就是这两个世界之间的桥梁。

为什么需要消息归一化?

Claude Code 的内部消息类型体系非常丰富:

// source/src/types/message.ts(部分)
type Message =
| UserMessage // 用户消息
| AssistantMessage // 助手消息
| SystemMessage // 系统信息(通知、警告等)
| AttachmentMessage // 附件(内存、技能、文件变更等)
| ProgressMessage // 进度更新
| TombstoneMessage // 废弃标记
| ToolUseSummaryMessage // 工具使用摘要
// ...

而 Claude API 只接受:

[
{ "role": "user", "content": [...] },
{ "role": "assistant", "content": [...] },
{ "role": "user", "content": [...] }
]

消息角色必须严格交替,不能有两个连续的 user 消息或 assistant 消息(至少在 Bedrock 等部分 API 兼容层上如此)。此外,SystemMessageAttachmentMessageProgressMessage 等类型根本不是有效的 API 消息类型,需要被过滤或转换。

函数签名

// source/src/utils/messages.ts,第 1989 行
export function normalizeMessagesForAPI(
messages: Message[], // 内部消息列表
tools: Tools = [], // 当前可用工具列表(用于过滤引用)
): (UserMessage | AssistantMessage)[]

核心处理流程

第一步:重排附件消息

// source/src/utils/messages.ts,第 1999 行
const reorderedMessages = reorderAttachmentsForAPI(messages).filter(
// 过滤掉 isVirtual 标记的消息(仅用于 UI 展示)
m => !((m.type === 'user' || m.type === 'assistant') && m.isVirtual),
)

AttachmentMessage 是 Claude Code 特有的类型,用来携带内存文件、技能发现结果、任务通知等附加上下文。reorderAttachmentsForAPI() 会将这些附件"上浮",直到它们遇到一个 tool_resultassistant 消息为止,确保附件始终位于合法的对话位置。

同时,isVirtual 的消息(比如 REPL 内部工具调用的展示消息)会被完全过滤掉,它们只存在于 UI 层,不应进入 API。

第二步:构建错误溯源映射

// source/src/utils/messages.ts,第 2004 行
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 因为 PDF 过大或图片过大等原因报错时,Claude Code 会在消息历史中留下一条合成的 API 错误消息。在下次发送消息时,需要把导致错误的那个 documentimage block 从对应的用户消息中剔除,否则每次都会触发同样的错误。

这段代码向后查找最近的 isMeta 用户消息,记录下需要从其中剔除的 block 类型,存入 stripTargets 映射。

第三步:逐条处理消息

消息列表按类型分别处理:

处理 SystemMessage(本地命令输出)

// source/src/utils/messages.ts,第 2078 行
case 'system': {
// 只有 local_command 类型的 system 消息需要进入 API
// 其他系统消息(通知、警告等)直接被过滤掉
const userMsg = createUserMessage({
content: message.content,
uuid: message.uuid,
timestamp: message.timestamp,
})
const lastMessage = last(result)
if (lastMessage?.type === 'user') {
// 合并到前一条用户消息中,避免连续 user 消息
result[result.length - 1] = mergeUserMessages(lastMessage, userMsg)
return
}
result.push(userMsg)
return
}

/run 命令执行 bash 的输出是 SystemLocalCommandMessage 类型,需要作为用户消息发送给 Claude,让它能看到命令执行结果。

处理 UserMessage

// source/src/utils/messages.ts,第 2094 行
case 'user': {
// 剔除不可用工具的 tool_reference block
let normalizedMessage = message
if (!isToolSearchEnabledOptimistic()) {
normalizedMessage = stripToolReferenceBlocksFromUserMessage(message)
} else {
normalizedMessage = stripUnavailableToolReferencesFromUserMessage(
message,
availableToolNames,
)
}

// 剔除导致 PDF/图片错误的内容 block
const typesToStrip = stripTargets.get(normalizedMessage.uuid)
if (typesToStrip && normalizedMessage.isMeta) {
// 过滤掉对应类型的 block
const filtered = content.filter(
block => !typesToStrip.has(block.type),
)
// ...
}

// 如果前一条也是 user 消息,合并(Bedrock 兼容)
const lastMessage = last(result)
if (lastMessage?.type === 'user') {
result[result.length - 1] = mergeUserMessages(lastMessage, normalizedMessage)
return
}
result.push(normalizedMessage)
return
}

关键操作:合并连续 user 消息。这是 Bedrock 兼容性的必要处理,因为 Bedrock 不支持连续的同角色消息,而 1P API 虽然会自动合并,但客户端主动合并可以减少不必要的 API 错误。

处理 AssistantMessage

// source/src/utils/messages.ts,第 2201 行
case 'assistant': {
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
// ...
}
return block
}),
},
}
// ...
}

assistant 消息的处理主要是对 tool_use block 里的 input 进行归一化——某些工具(如 ExitPlanModeV2)在内部存储了额外字段(如 plan),这些字段不应该发送给 API。normalizeToolInputForAPI() 负责清理这些内部字段。

处理 AttachmentMessage

附件消息(AttachmentMessage)会被转换为用户消息,其 attachment 字段被序列化为文本内容插入对话历史。这让 Claude 可以读取到附件携带的信息(比如内存文件内容、任务通知等)。

一个完整的转换示例

假设内部消息历史是这样的:

[
UserMessage { content: "帮我读取 README.md", isMeta: false },
AssistantMessage {
content: [
{ type: "text", text: "好的,我来读取文件。" },
{ type: "tool_use", id: "tu_01", name: "Read", input: { file_path: "README.md" } }
]
},
UserMessage {
content: [
{ type: "tool_result", tool_use_id: "tu_01", content: "# README\n..." }
],
isMeta: true // 工具结果是 meta 消息
},
SystemMessage { type: "local_command", content: "$ ls\nREADME.md" },
AttachmentMessage { attachment: { type: "memory", content: "记忆内容..." } }
]

经过 normalizeMessagesForAPI() 后变为:

[
{ role: "user", content: [{ type: "text", text: "帮我读取 README.md" }] },
{ role: "assistant", content: [
{ type: "text", text: "好的,我来读取文件。" },
{ type: "tool_use", id: "tu_01", name: "Read", input: { file_path: "README.md" } }
]},
{ role: "user", content: [
// tool_result 和 system 消息合并为一条 user 消息
{ type: "tool_result", tool_use_id: "tu_01", content: "# README\n..." },
{ type: "text", text: "$ ls\nREADME.md" },
{ type: "text", text: "[内存附件内容...]" }
]}
]

消息类型过滤规则总结

内部消息类型处理方式
UserMessage保留,合并连续项,剔除无效 block
AssistantMessage保留,归一化 tool_use input
SystemMessage (local_command)转换为 UserMessage
SystemMessage (其他)完全过滤,不发送 API
AttachmentMessage转换为 UserMessage 的内容
ProgressMessage完全过滤
TombstoneMessage完全过滤
isVirtual = true 的消息完全过滤

为什么不在存储时就规范化?

你可能会问:为什么不直接用 API 格式存储消息?

因为 Claude Code 的消息历史肩负多重职责:

  1. UI 渲染:需要区分工具进度、系统通知等类型
  2. 历史管理:需要 UUID 追踪、时间戳排序
  3. 权限追踪:需要 isMeta 区分用户输入和系统生成
  4. 压缩决策:需要元数据判断哪些消息可以被压缩

把这些关注点统一到 API 格式里会导致格式臃肿且难以维护。normalizeMessagesForAPI() 作为一个纯转换函数,每次调用时按需转换,保持了内部格式和 API 格式的清晰分离。

📄source/src/utils/messages.tsL1989-2260查看源码 →