返回《Claude Code 架构设计指南》

消息的生命周期

IT周瑜

主循环里流动的消息,和 Anthropic API 要求的消息格式,两者长得并不一样。程序内部有一套自己的 Message 类型,除了 user 和 assistant,还有 attachment 附件、progress 进度、system 系统等十来种。这套类型服务于终端界面和会话记录,比如 progress 消息只在界面上显示工具运行到哪一步,system 消息承载本地命令的输出。发请求前,所有这些要被规整成 API 认识的样子,干这件事的函数叫 normalizeMessagesForAPI,住在 src/utils/messages.ts。

先看一轮交互在内部长什么样。用户敲一句话,产生一条 user 消息。模型开始流式回复,一个回合的回复在内部会被拆成多条 assistant 消息,thinking 块一条,每个 tool_use 块又各是一条。工具执行完,结果包装成 tool_result 块,挂在一条 user 角色的消息下回灌。注意这个角色安排,tool_result 在 API 协议里属于 user 轮,模型视角下相当于用户说话了。每个 tool_use 带唯一 id,配对的 tool_result 用同一个 id 引用它,一进一出严格成对。

循环里实际发生的消息序列 user 用户提问 assistant thinking 块 assistant tool_use 块 带唯一 id 执行后 user tool_result 引用同一 id normalizeMessagesForAPI 一条 assistant API 消息 thinking + tool_use 合并在 同一条消息的 content 里 一条 user API 消息 tool_result 挂在 user 角色下 配对约束 每个 tool_use 必须有同 id 的 tool_result 中断或被拒时 代码会合成占位 tool_result 保证配对完整 UI 专用 system 消息在发送前被剥掉 不进 API
图 3-1 内部消息序列在发送前被归并成 API 要成的对偶结构

归一化的第一步是过滤。progress 消息直接丢弃,system 消息里只有本地命令输出那一类要转成 user 消息保留,标记为 isVirtual 的纯展示消息一律剥掉。附件消息被展开成若干条 user 消息,合并进相邻的用户轮。接着做合并,连续两条 user 消息要合成一条,代码注释解释了原因,Bedrock 不接受连续的 user 轮,第一方 API 虽然接受也会自己合,不如发送前统一处理。assistant 侧按消息 id 合并,同一轮流式产生的 thinking 条和 tool_use 条,id 相同就并入同一条消息的 content 数组,这正是上一章 thinking 三规则要求的形态。

归一化还有一串兜底工序,每一道都对应真实踩过的坑。孤立的 thinking-only assistant 消息要过滤掉,注释说它们多半来自压缩切片,留着会造成相邻 assistant 消息的 thinking 签名对不上,API 直接回 400。最后一条 assistant 消息尾部若挂着 thinking 块要剥掉。只剩空白字符的消息要清理。所有图片在发送前还要过一遍尺寸校验。这些工序的排列顺序在注释里有专门说明,先剥尾部 thinking 再滤空白消息,反过来会留下一条只剩换行的消息被 API 拒收。

配对约束的兜底在更靠近发送的地方。claude.ts 里有个函数叫 ensureToolResultPairing,发请求前扫一遍消息,发现某个 tool_use 没有对应的 tool_result,就补一条占位结果,占位文本是个专门常量。这个防御针对的场景包括用户中途按 Esc 打断、工具执行被否决、会话从旧版本记录恢复。API 对配对的要求是硬性的,缺半个对子整个请求都发不出去,宁可补一条占位说明。

会话恢复是消息模型的另一个考点。每条消息带 uuid,会话以 jsonl 文件落盘,一行一条。跨进程恢复时从文件读回消息列表,归一化管线把当年界面上五花八门的内部类型重新压回 API 形态。上一章提到的 HFI 严格模式在 State 里有个开关,打开后配对检查从自动修复改成直接抛错,让训练数据收集场景尽早发现坏轨迹,不给模型看伪造的 tool_result。