从设计者的角度理解源码 -- Claude Code 的消息机制设计
引言
本篇,作为《从设计者的角度理解源码》系列的第三篇,试图回答一个看似简单的问题:Claude Code 的消息系统为什么长成现在这个样子?
如果你翻过源码,你会发现 messages.ts 有 3000+ 行,里面塞满了 isMeta、isVirtual、isCompactSummary、toolUseResult、origin 之类的标记字段,还有一个 16 参数的 createUserMessage 工厂函数。第一反应可能是:这是不是胡乱硬编码的?
但如果你从设计者的角度出发,沿着"我遇到了什么问题 → 我当时怎么想的 → 我做了什么选择"这条路走一遍,你会发现每一个标记字段、每一种消息类型都有一个被逼出来的故事。
核心矛盾只有一句话:Anthropic API 只认 user 和 assistant 两种 role,但 Claude Code 内部需要 6 大类 20+ 种消息。
这个矛盾产生的"语义缺口",就是整个消息机制设计的原动力。
本文的核心隐喻:消息机制的本质是一套翻译系统。三个角色(人、LLM、System)说着三种"语言",消息类型就是翻译官——每种翻译官负责一条边界,把一种语言翻译成另一种。
本篇按认知递进展开:先讲为什么有这么多类型,以及这些类型在什么约束下工作(倒逼与边界),然后讲流转与基础设施(生命周期、容器、容错),最后用一个完整案例串联所有概念,提炼设计原则。
一、消息类型的诞生——主循环倒逼与边界约束
哎,messages.ts 为什么有 3000 多行?这是胡乱堆出来的吗?
不是。它经历了四个阶段,每个阶段都有明确的触发原因——最核心的驱动力是主循环(queryLoop)的运行需求。
阶段 1:只有 user/assistant(主循环还很简单)
↓
阶段 2:主循环各阶段倒逼消息类型(复杂性增长)
↓ 转折点:所有类型要流进同一个 normalizeMessagesForAPI
阶段 3:normalizeMessagesForAPI 成为"收敛点"
↓ 问题:UserMessage 上的标记字段越来越多(7+ 个)
阶段 4:意识到需要类型体系,但已无法重构
↓ 结果:6 大类 + 标记字段的混合体
哎,最初的消息系统长什么样?
最初,Claude Code 的主循环很简单:用户输入 → 调 API → 拿到 assistant 回复 → 显示给用户。这个循环只需要两种消息:
// 最初的 messages 数组
[
{ role: 'user', content: '帮我写个Hello World' },
{ role: 'assistant', content: '好的,我来写...' }
]
API 只认 user 和 assistant 两种 role,主循环也只需要处理这两种消息。没有工具调用,没有压缩,没有中断,没有hook——一切都够用。
哎,主循环变复杂了,消息类型怎么就跟不上了?
随着功能增加,主循环从"用户输入→API调用→显示回复"的简单循环,演变成了6个阶段的复杂循环。每个阶段在执行过程中都产生了对特定消息类型的需求,而这些需求是 API 的两种 role 无法满足的。
第一个倒逼:工具结果必须配对(阶段3:API调用+工具执行)
当主循环开始支持工具调用时,遇到了第一个硬约束:API 要求每个 tool_use 必须有对应的 tool_result。用户说"帮我读文件",模型返回 tool_use: Read,系统执行工具后,必须把结果塞进 messages 数组。
但问题是:工具结果不是人说的话,不能伪装成 { role: 'user' }。如果直接塞进去,模型会困惑:"这是用户说的吗?"
// 问题场景:工具结果混进用户消息
[
{ role: 'user', content: '帮我读文件' },
{ role: 'assistant', content: null, tool_use: { id: 'toolu_aaa', name: 'Read', input: { file_path: 'main.ts' } } },
{ role: 'user', content: '文件内容:export function main() {...}' } // 这是工具结果,不是用户说的!
]
有了:我可以给 user 消息加个标记,告诉系统"这不是人说的,是系统注入的"——isMeta: true。这是第一个标记字段的诞生。
第二个倒逼:压缩事件需要通知UI(阶段1:上下文压缩管道)
当主循环加入上下文压缩管道时,压缩事件("正在压缩..."、"压缩完成")需要通知UI显示进度。但这些事件不能发给API(会浪费token),也不能伪装成用户消息。
有了:既然只给 UI 看,那就不走 API 翻译,直接用一个独立类型——SystemMessage。
第三个倒逼:流式回退需要撤销已显示的消息(阶段3:API调用+工具执行)
当主循环支持流式输出时,遇到了新问题:模型输出到一半出错了(529限流、网络断开),已经推入UI的AssistantMessage需要标记"作废"。
有了:UI 已经显示了,那就发个"撤销指令",告诉 UI "删掉 uuid=xxx 的消息"——TombstoneMessage。
第四个倒逼:hook状态需要注入上下文(阶段5:Stop hooks)
当主循环加入hook系统时,hook执行结果("文件被编辑了"、"工具执行失败")需要注入上下文,让模型知道发生了什么。但hook结果不是人说的话,也不是工具返回值。
有了:给 UI 用结构化数据,给 API 时翻译成自然语言——用一个独立类型承载,AttachmentMessage。
哎,主循环各阶段倒逼出了这么多消息类型,API 只认两种 role,怎么发给API?
这就是 normalizeMessagesForAPI(messages.ts L2457-2844)的作用——它是所有消息类型的"收敛点"。不管你是什么类型(UserMessage、AssistantMessage、SystemMessage、AttachmentMessage、ProgressMessage、TombstoneMessage),最终都要流进这个函数,翻译成API能认的格式。
有了:normalizeMessagesForAPI 的 switch-case 就是主循环6阶段的"投影"——每个 case 对应一个阶段产生的消息类型。每加一种新消息类型,这个 switch 就多一个 case。
// src/utils/messages.ts L2457 — normalizeMessagesForAPI
export function normalizeMessagesForAPI(
messages: Message[],
tools: Tools = [],
): (UserMessage | AssistantMessage)[] {
// 第一步:filter 阶段——progress / 非 local_command 的 system / 合成错误消息 直接过滤掉
// (tombstone 和 isVirtual 消息也在此阶段被排除)
.filter(m => !(m.type === 'progress' || (m.type === 'system' && !isLocalCommand) || isSyntheticApiErrorMessage(m)))
.forEach(message => {
switch (message.type) {
case 'system': // local_command → 转成 user 消息(L2561)
case 'user': // 清理标记、合并相邻 user(L2578)
case 'assistant': // 合并同 ID 消息(L2680)
case 'attachment': // normalizeAttachmentForAPI 转成 user(L2746)
// progress / tombstone → 在 filter 阶段就被排除了,不进 switch
}
})
// 第二步:后处理——relocateToolReferenceSiblings / filterOrphanedThinkingOnlyMessages / ...
}
代码里的 LEGACY_ATTACHMENT_TYPES 列表就是证据——有些类型已经被删了,但翻译器还必须认得它们(兼容旧会话 --resume)。
哎,主循环各阶段倒逼出了这么多需求,为什么不直接发明新类型,而是在 UserMessage 上加标记字段?
在 API 只认两种 role 的约束下,标记字段是弥补类型缺口的最轻量方案。好处是改动量小、兼容性好;代价是 UserMessage 变成万能容器,16 个参数的工厂函数越来越难理解。
语义缺口公式:
API 类型数(2 种:user / assistant)
vs
主循环各阶段需求(6 大类 20+ 种)
=
语义缺口(用标记字段填补)
标记字段的数量 ≈ API 类型数与主循环各阶段需求之间的"语义缺口"。缺口越大,标记越多。
每个标记字段的诞生,都能追溯到主循环某个具体阶段的一个不可合并的需求。isMeta 区分"人说的"和"系统注入的";isVirtual 区分"给UI看的"和"给API看的";isCompactSummary 区分"压缩摘要"和"普通消息"...
哎,如果 API 支持更多类型呢?
有了:类型体系不是"设计"出来的,而是"追认"出来的——几百个独立决策被 normalizeMessagesForAPI 收敛成了一个类型体系。如果 API 支持更多类型,大部分标记字段都可以消失。
反事实推理:
- 如果 API 支持 6 种类型(加上 tool_result / system / attachment)→ 标记字段减半
- 如果 API 支持 20 种类型 →
normalizeMessagesForAPI可以删掉
但 API 不会改。这是外部约束,不是设计选择。整个系统是在"两种 role"这个硬约束下的最优解。
哎,主循环各阶段倒逼出了这么多消息类型,API 只认两种 role,这不矛盾吗?
矛盾。但这个矛盾不是主循环造成的,而是 API 的外部约束。要理解这个约束,先看 Claude Code 里面有几种"角色":
三种(加一个嵌套角色):
| 角色 | 能理解什么 | 不能理解什么 |
|---|---|---|
| 人(Human) | 自然语言、进度条、错误提示 | JSON、tool_use 块、结构化元数据 |
| LLM(Anthropic API) | user/assistant 两种 role 的文本 | 不认结构化元数据、不认 isMeta 等标记 |
| System(内部逻辑) | 结构化数据、状态标记、优先级 | 不能"理解"自然语言 |
| Agent(可嵌套) | 自己的上下文和工具,继承父级的链路追踪 | 不看主循环完整历史,只看自己的任务消息 |
Agent 不是第四个独立角色,而是"System 里套了一个 LLM 循环"——它同时扮演 System(执行工具)和 LLM(调 API)两个角色,但有自己的上下文隔离。
三个角色两两组合,产生六条消息边界。每条边界因为"语言不通"而需要翻译:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 人 │ ←──→ │ System │ ←──→ │ LLM │
│ (Human) │ │ (内部逻辑)│ │ (Anthropic API) │
└──────────┘ └──────────┘ └──────────┘
| 编号 | 边界 | 说什么 | 翻译成什么 |
|---|---|---|---|
| ① | 人→LLM | 用户说的话 | UserMessage |
| ② | LLM→人 | 模型的回复 | AssistantMessage |
| ③ | System→LLM | 元信息(文件编辑记录等) | AttachmentMessage → UserMessage |
| ④ | LLM→System | 工具调用意图 | ToolUseBlock → StreamingToolExecutor |
| ⑤ | 人→System | 物理动作(按 Esc、输入命令) | QueuedCommand |
| ⑥ | System→人 | 系统事件(压缩、报错) | SystemMessage |
有了:消息类型 = 谁→告诉谁→说什么 × 过什么边界。换一个角色对,即使边界相同,内容格式也不同;换一个边界,即使角色对相同,传输机制也不同。
同一个语义在不同边界下变成不同类型:"文件被编辑了",System→LLM = AttachmentMessage(结构化,暂存后翻译);System→人 = SystemMessage(edited_files)(UI 渲染)。不同语义在同一边界下变成同一类型:"用户说了X",人→LLM = UserMessage;"工具返回了X",System→LLM = UserMessage(API 只认 user role)。
角色和边界搞清楚了,六种消息大类就是六条边界的"翻译官"。每种类型的诞生,都是因为某条边界上需要一个翻译。
UserMessage -- 被 API 协议逼出来(边界 ①③)
API 只认 user 和 assistant 两种 role。但需要塞给 LLM 的内容远不止"用户说的话"——工具返回结果、压缩摘要、系统元信息、中断通知,全都要伪装成 { role: "user" }。
所以有了 createUserMessage,一个 15 参数的"万能构造器"(messages.ts L657-720):
// src/utils/messages.ts L657
export function createUserMessage({
content, // 文本或 ContentBlockParam[]
isMeta, // "这条不是人说的,是系统注入的"
isVisibleInTranscriptOnly, // "这条只在 transcript 里显示,不发 API"
isVirtual, // "这条只给 UI 看,别发 API"
isCompactSummary, // "这条是压缩摘要"
summarizeMetadata, // 压缩元数据(压缩了多少条、方向)
toolUseResult, // "这条是工具返回值"
mcpMeta, // MCP 协议元数据(不发给模型,只传给 SDK 消费者)
uuid, timestamp, imagePasteIds,
sourceToolAssistantUUID, // tool_result 消息:匹配 tool_use 的助手消息 UUID
permissionMode, // 消息发送时的权限模式(用于回退恢复)
origin, // 消息来源(undefined = 人工输入)
}: { ... }): UserMessage
哎,15 个参数?这是一次性设计出来的吗?
不是。每个参数都有一个被逼出来的 PR:
有了:15 个参数不是有人设计了 15 个场景,是 15 个场景逼出了 15 个参数。
某个 PR:模型把工具结果当成人话回复了 → 加 isMeta: true
某个 PR:压缩摘要混进了工具结果 → 加 isCompactSummary: true
某个 PR:虚拟消息被误发到 API → 加 isVirtual: true
某个 PR:子代理需要区分消息来源 → 加 origin: { kind: 'task-notification' | ... }
UserMessage 上的标记字段数量,近似等于 API 两种 role 与内部 20+ 种语义需求之间的"语义缺口"。
标记字段的 PR 驱动积累有一个经典案例:
isHumanTurn判断函数(messagePredicates.ts L1-8)。四个 PR(#23977, #24016, #24022, #24025)独立修了同一个 bug——只检查type==='user'把工具结果也算成了人类输入。最终被抽成一个三条件函数:// tool_result messages share type:'user' with human turns; the discriminant // is the optional toolUseResult field. Four PRs (#23977, #24016, #24022, // #24025) independently fixed miscounts from checking type==='user' alone. export function isHumanTurn(m: Message): m is UserMessage { return m.type === 'user' && !m.isMeta && m.toolUseResult === undefined }
AssistantMessage -- 被 API 响应逼出来(边界 ②)
API 返回的消息天然是 assistant role。SSE 流中的碎片(text delta / tool_use delta)拼接成完整的 AssistantMessage。
特殊情况——合成 AssistantMessage:API 出错时,系统合成一条假的 AssistantMessage,让错误自然流进 ReAct 循环,而不是硬打断(messages.ts L628-651):
// src/utils/messages.ts L628
export function createAssistantAPIErrorMessage({
content, apiError, error, errorDetails,
}: { content: string; apiError?: ...; error?: ...; errorDetails?: string }): AssistantMessage {
return baseCreateAssistantMessage({
content: [{ type: 'text', text: content === '' ? NO_CONTENT_MESSAGE : content }],
isApiErrorMessage: true, // 标记:这是合成错误消息,UI 用红色渲染
apiError, error, errorDetails,
})
}
// 底层 baseCreateAssistantMessage 设置 model: '<synthetic>', stop_reason: 'stop_sequence'
SystemMessage -- 被 UI 渲染逼出来(边界 ⑥)
14 种子类型(messages.ts L4980+),每种对应 UI 里一种独立的渲染样式。关键约束:永远不进 API。 如果发给 API,模型会困惑且浪费 token。
compact_boundary / microcompact_boundary → 压缩边界标记
api_error / api_metrics → API 错误与性能指标
stop_hook_summary / permission_retry → Hook 与权限
local_command → 斜杠命令回显(唯一会进 API 的子类型)
informational / bridge_status / ... → 状态通知
AttachmentMessage -- 被元信息注入逼出来(边界 ③)
约 60 种 attachment 子类型(attachments.ts L440-717)。同一条信息在 API 和 UI 里需要不同格式:AttachmentMessage 保留结构化数据(给 UI 用),normalizeAttachmentForAPI(messages.ts L4147-4979)时才翻译成自然语言(给 API 用):
AttachmentMessage(edited_files): { files: ["src/main.ts", "src/util.ts"] }
↓ normalizeAttachmentForAPI
UserMessage: "The following files were recently edited:\n- src/main.ts\n- src/util.ts"
TombstoneMessage -- 被流式回退逼出来
模型流式输出过程中出错(529 限流、网络断开),已推入 UI 的 AssistantMessage 需要标记"作废"。TombstoneMessage 是一条"撤销指令"——告诉 UI "删掉 uuid=xxx 的消息"。
QueuedCommand -- 被多输入源逼出来(边界 ⑤)
QueuedCommand 和 Message 是两套独立的类型系统:
QueuedCommand(路由阶段) Message(语义阶段)
├─ value: string ├─ type: 'user' | 'assistant' | ...
├─ mode: 'prompt'|'bash'|... ├─ message: { role, content }
├─ priority: 'now'|'next'|'later' ├─ isMeta / isVirtual / toolUseResult
├─ agentId: AgentId ├─ uuid / timestamp
├─ origin: MessageOrigin └─ ...
└─ bridgeOrigin: boolean
dequeue 点是两套类型的交汇处:路由属性(priority、agentId)完成使命后蒸发,只有语义相关属性(isMeta、origin)被传递到 Message 上。
倒逼与边界搞清楚了,那消息在整个系统里是怎么流转的?
二、消息在 Query Loop 中的一生
哎,消息类型搞清楚了,那消息在整个系统里是怎么流转的?
Claude Code 的核心是一个 ReAct 循环(Think → Act → Observe),消息就在这个循环里诞生、流转、消亡。
query.ts 的文件头注释(query.ts L1-126)描述了一个完整的 6 阶段结构。外层 while(true) 在 L657,needsFollowUp 在 L1080 初始化为 false,检测到 tool_use 时在 L1493 设为 true。内层 while(needsFollowUp) 只是阶段 4 的一部分:
while(true) 主循环(每轮)
├─ 阶段 1:State 解构 + buildQueryConfig(只读快照)
├─ 阶段 2:5 级上下文压缩管道(ToolResultBudget → SnipCompact → MicroCompact → ContextCollapse → AutoCompact)
├─ 阶段 3:API 调用准备(normalizeMessagesForAPI,6 大类 → 2 种 API 格式)
├─ 阶段 4:API 调用 + 工具执行 ← 内层 while(needsFollowUp) 在这里
├─ 阶段 5:错误恢复链(413 → 折叠 → 压缩 → 放弃)
└─ 阶段 6:后处理 / state = next / continue 或 break
哎,为什么 State 要一次性替换而不是逐个字段改?
有了:引用不变才能缓存命中——所以 State 必须一次性替换,不能逐个字段改。
State 类型有 9 个字段(messages、sessionId、systemMessages 等),每个 continue 站点通过 state = { ... } 一次性写入。messages 数组引用不变 → normalizeMessagesForAPI 可基于引用比较做缓存 → 相同消息数组无需重新序列化。可变修改会让每个字段的变化都触发整个 API 请求重建。一次性替换比逐个字段修改更"贵"(每次创建新对象),但换来了缓存稳定性。
// src/query.ts — 核心循环(简化)
while (needsFollowUp) {
// Think:调 API,拿到 AssistantMessage
for await (const message of callModel(messagesForQuery)) {
yield message // text 块实时推给 UI
for (const toolBlock of msgToolUseBlocks) {
streamingToolExecutor.addTool(toolBlock, message) // tool_use 入队
}
for (const result of streamingToolExecutor.getCompletedResults()) {
yield result.message // 已完成的工具结果抢跑 yield
toolResults.push(...)
}
}
// Act:等待剩余工具完成
for await (const result of streamingToolExecutor.getRemainingResults()) {
yield result.message
toolResults.push(...)
}
// Observe:合并结果,判断是否继续
messagesForQuery = [...messagesForQuery, ...assistantMessages, ...toolResults]
needsFollowUp = hasToolUseBlocks
}
同一条 AssistantMessage 里的 text 块和 tool_use 块走不同的消费路径:
callModel → AssistantMessage
├── text 块 → yield → UI 实时渲染(用户看到字一个一个蹦出来)
└── tool_use 块 → StreamingToolExecutor → 并发执行
哎,API 返回的是碎片流,怎么变成完整消息的?
SSE 流中每个事件只携带一小块信息:
event 1: { type: 'content_block_start', index: 0, content: { type: 'text' } }
event 2: { type: 'content_block_delta', index: 0, delta: { text: '我来' } }
event 3: { type: 'content_block_delta', index: 0, delta: { text: '帮你' } }
event 4: { type: 'content_block_stop', index: 0 }
event 5: { type: 'content_block_start', index: 1, content: { type: 'tool_use', id: 'toolu_aaa', name: 'Read' } }
event 6: { type: 'content_block_delta', index: 1, delta: { input: '{"file_path":"' } }
event 7: { type: 'content_block_delta', index: 1, delta: { input: 'src/main.ts"}' } }
event 8: { type: 'content_block_stop', index: 1 }
handleMessageFromStream(query.ts L3155-3355)负责拼接:
有了:text 块每收到 delta 就立刻 yield(用户看到字一个一个蹦出来),tool_use 块等 JSON 完整后才交给执行器(不能拿半截 JSON 去执行工具)。组装时机不同,这就是双线输出的根源。
- text 块:每收到 delta 就立刻 yield 给 UI
- tool_use 块:等 JSON 完整后才交给 StreamingToolExecutor
messagesForQuery(全局历史) — 本轮开始时传给 API,本轮内不可变
assistantMessages(模型输出) — 本轮内 AssistantMessage 累积
toolResults(工具结果) — 本轮内工具结果暂存
哎,为什么要分三个数组?直接往 messagesForQuery 里塞不行吗?
有了:messagesForQuery 已经传给 API 了,你不能往里塞新东西——所以本轮的新结果暂存到 toolResults,等本轮结束后一次性合并。
messagesForQuery 是只读快照,assistantMessages 和 toolResults 是本轮的写缓冲。
哎,多个工具并发执行,结果的顺序怎么保证?
StreamingToolExecutor(StreamingToolExecutor.ts)用两个精巧的设计解决:
- 自驱动:每个工具执行完毕后,
promise.finally(() => { void this.processQueue() })(L576-582)自动触发下一个,不需要外部轮询 - 双出口:
getCompletedResults()(L595-624):同步 Generator,不等待("有就拿走,没有拉倒")——在 SSE 流的每个事件后调用,快工具不被慢工具卡住getRemainingResults()(L641-678):异步 Generator,Promise.race等待——SSE 流结束后调用,确保所有工具都完成
SSE 事件流: event1 → event2 → event3 → ... → 流结束
↓ ↓ ↓ ↓
getCompleted 有就拿 有就拿 有就拿 getRemaining
Results() 走 走 走 Results()
↓
等所有工具完成
每个工具从入队到产出结果,经历四个状态:
queued → executing → completed → yielded
↘
errored → completed(错误也是"完成",必须有 tool_result)
哎,为什么 errored 也走 completed?
因为 API 协议要求每个 tool_use 必须有 tool_result——不管工具成功还是失败,都必须给模型一个结果。所以 errored 状态直接进入 completed,产出一个 is_error: true 的 tool_result。
哎,如果 tool_use 有了但 tool_result 还没来,会怎样?
API 直接报错。tool_use/tool_result 必须配对是硬约束。
三种场景会产生"孤儿":
- 流式回退:API 返回了 tool_use,但 SSE 流中途出错,工具还没执行就被丢弃
- API 异常 catch:API 返回了 tool_use,但后续处理抛异常
- 用户中断:API 返回了 tool_use,但用户按了 Ctrl+C
补全策略:为每个孤儿 tool_use 生成合成 is_error tool_result(content: '[Tool result missing due to internal error]'),满足配对约束。
// src/query.ts L307 — yieldMissingToolResultBlocks
function* yieldMissingToolResultBlocks(
assistantMessages: AssistantMessage[],
errorMessage: string,
) {
for (const assistantMessage of assistantMessages) {
const toolUseBlocks = assistantMessage.message.content.filter(
content => content.type === 'tool_use',
) as ToolUseBlock[]
for (const toolUse of toolUseBlocks) {
yield createUserMessage({
content: [{ type: 'tool_result', content: errorMessage, is_error: true, tool_use_id: toolUse.id }],
toolUseResult: errorMessage,
sourceToolAssistantUUID: assistantMessage.uuid,
})
}
}
}
// 调用点:模型回退时(L1595)、API 致命错误时(L1691)、流式中止时(L1741)
诞生:createUserMessage / createAssistantMessage / createSystemMessage / ...
↓
暂存:toolResults 数组(本轮内)
↓
合并:messagesForQuery(本轮结束时)
↓
翻译:normalizeMessagesForAPI(每次 API 调用前)
↓
消费:API(LLM 看到)/ UI(人看到)/ 系统处理器(内部控制)
↓
持久化:transcript 文件(会话记录)
↓
压缩/折叠:旧消息被摘要替换或遮蔽
↓
消亡:被压缩替换后永久丢失
消息在循环里流转,那流转过程中存在哪?出错了又怎么办?
三、传输容器与容错机制
第一部分:六种传输容器
哎,消息发出来之后,到底存在哪?
这取决于三个问题:
- 需要持久化吗? 跨进程/跨时间 → 文件信箱;同进程内 → 内存容器
- 生产者和消费者节奏匹配吗? 匹配 → yield 直传;不匹配 → 需要队列缓冲
- 有多个生产者/消费者吗? 有 → 需要并发控制;没有 → 直传
三个问题问完,六种容器自然浮现。用一棵决策树来选:
需要跨进程/跨时间?
├─ 是 → 文件信箱(proper-lockfile + 锁内重读 + ENOENT 容错)
└─ 否 → 生产者和消费者节奏匹配?
├─ 是 → 有多个生产者?
│ ├─ 是 → toolResults 数组(攒批合并)
│ └─ 否 → yield 直传(零存储,一对一同步)
└─ 否 → 需要并发执行?
├─ 是 → StreamingToolExecutor(自驱动 + 双出口 + 保序屏障)
└─ 否 → 命令队列(三级优先级,订阅+拉取)
加上 SSE 流(API 响应专用,单生产者多消费者,拉取式背压),凑齐六种:
| 容器 | 持久化 | 多生产者 | 等待方式 | 适用场景 |
|---|---|---|---|---|
| 命令队列 | 否 | 是 | 订阅+拉取 | 多输入源排队 |
| SSE 流 | 否 | 否 | 拉取式背压 | API 响应 |
| toolResults | 否 | 是 | 攒批合并 | 本轮内暂存 |
| 执行器队列 | 否 | 是 | 自驱动调度 | 工具并发执行 |
| 文件信箱 | 是 | 是 | 轮询+拉取 | 跨进程通信 |
| yield 直传 | 否 | 否 | 直传同步 | 实时输出 |
命令队列的三级优先级:
now — 立刻中断(abortController.abort()),不需要等任何东西
next — 等当前工具完成,下一轮 API 调用前处理
later — 等整个 turn 结束后处理(默认给系统通知用,确保用户输入不被饿死)
消费时机:
// now → REPL.tsx L2196-2212:Escape 键处理,4 个分支
if (focusedInputDialog === 'tool-permission') {
toolUseConfirmQueue[0]?.onAbort(); // 拒绝权限弹窗
} else if (focusedInputDialog === 'prompt') {
abortController?.abort('user-cancel'); // 中断 prompt 输入
} else if (activeRemote.isRemoteMode) {
activeRemote.cancelRequest(); // 远程模式取消
} else {
abortController?.abort('user-cancel'); // 默认:中断 API 调用
}
// next → query.ts L2483:阶段 6 附件处理
const queuedCommandsSnapshot = getCommandsByMaxPriority(
sleepRan ? 'later' : 'next', // Sleep 工具运行后提升到 later
).filter(cmd => {
if (isSlashCommand(cmd)) return false
if (isMainThread) return cmd.agentId === undefined
return cmd.mode === 'task-notification' && cmd.agentId === currentAgentId
})
// later → queueProcessor.ts:processQueueIfReady() → dequeue()
文件信箱的并发安全:proper-lockfile 文件锁 + 锁内重读(read-modify-write 竞态解法)+ ENOENT 容错。控制面与数据面通过 isStructuredProtocolMessage() 分流——13 种协议消息走控制面(机器精确路由),自由文本走数据面(交给 LLM 理解)。
第二部分:四条容错恢复链
正常流转靠容器,那异常流转呢?API 返回 413、模型挂了、流式输出到一半断了——这些场景需要另一套机制。Claude Code 有四条恢复链,每条都按"便宜的先试、贵的兜底"排列。
哎,API 返回 413(上下文超限)了怎么办?
有了:便宜的先试——先试试能不能不花钱解决(collapse_drain_retry),实在不行再花最小代价(reactive_compact_retry),最后交给人。
413 恢复链在 query.ts L1858-1977,三层恢复,代价递增:
// 恢复路径 1:collapse_drain_retry(零 API 成本)— L1858-1891
if (feature('CONTEXT_COLLAPSE') && contextCollapse &&
state.transition?.reason !== 'collapse_drain_retry') { // 防死循环守卫
const drained = contextCollapse.recoverFromOverflow(messagesForQuery, querySource)
if (drained.committed > 0) {
state = { ..., transition: { reason: 'collapse_drain_retry', committed: drained.committed } }
continue // 重试——大概率还是 413(展开后更大了)
}
}
// 恢复路径 2:reactive_compact_retry(零额外 API 成本)— L1914-1977
// 读 session-memory.md,用预写好的摘要替换旧消息
// hasAttemptedReactiveCompact 标志确保只尝试一次
// 恢复路径 3:放弃
// yield lastMessage → 告诉用户"上下文超限,请手动 /compact"
哎,模型挂了(529 限流 / 429 超频)怎么办?
两层重试,每层解决不同的问题:
API 返回 529 / 429
│
├─ 第 1 层:withRetry(同模型重试 3 次)
│ 等待退避时间 → 用同一个模型重试
│ → 解决:临时限流、网络抖动
│
└─ 第 2 层:FallbackTriggeredError(跨模型切换)
切换到备用模型(如 Sonnet → Haiku)
→ 解决:模型完全不可用
哎,切换模型不就是换个 API 参数吗?为什么要"整体重试"?
有了:thinking 签名、tool_use ID、executor 状态全都不能复用——三样东西同时作废,只能从头来。
- thinking 签名失效:不同模型的 thinking 格式不同,旧的 thinking 不能传给新模型
- tool_use ID 作废:旧模型生成的 tool_use ID 不能传给新模型(新模型不知道这些 ID)
- StreamingToolExecutor 状态作废:旧 executor 里还有正在执行的工具,但新模型不认识它们
所以必须丢弃旧 executor → 清空累积数组 → 创建全新 executor → 从头重试。
哎,流式输出到一半,模型切换了怎么办?
这是最复杂的恢复场景。TombstoneMessage 的创建代码在 query.ts L1349-1355:
// src/query.ts L1349
if (streamingFallbackOccured) {
// 为孤儿消息发出 tombstone,以便它们从 UI 和记录中移除。
// 这些部分消息(尤其是 thinking 块)具有无效签名,
// 会导致"thinking blocks cannot be modified"API 错误。
for (const msg of assistantMessages) {
yield { type: 'tombstone' as const, message: msg }
}
// 然后:清空 assistantMessages / toolResults / toolUseBlocks
// 丢弃旧 StreamingToolExecutor,创建全新的
}
收到 tombstone 后,handleMessageFromStream(messages.ts L3533-3561)调用 onTombstone 回调,将对应的原始消息从 UI 消息列表中移除。
有了:UI 已经显示了旧模型的回复,那就发个撤销指令——TombstoneMessage,让 UI 撤回已显示但最终无效的内容。
SSE 流正在输出(用户已经看到了部分文字)
↓ 模型切换触发
├→ 为已推入 UI 的 AssistantMessage 生成 TombstoneMessage
│ → 告诉 UI "删掉 uuid=xxx 的消息"(撤销已显示的内容)
├→ 清空 assistantMessages 和 toolResults 数组
├→ 丢弃旧 StreamingToolExecutor(正在执行的工具全部放弃)
├→ 创建全新 StreamingToolExecutor
└→ 从头重试(新模型不知道旧模型说过什么)
哎,模型输出太长被截断了怎么办?
有了:和 413 一样的逻辑——便宜的先试(提升限制),贵的兜底(断点续传),实在不行交给人。
max_output_tokens 恢复在 query.ts L2008-2076,常量 MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3(L358):
// 第 1 级:max_output_tokens_escalate(L2014-2040)
// 提升限制 8k → 64k(ESCALATED_MAX_TOKENS),直接重试,无需额外消息
state = { ..., maxOutputTokensOverride: ESCALATED_MAX_TOKENS,
transition: { reason: 'max_output_tokens_escalate' } }
continue
// 第 2-3 级:max_output_tokens_recovery(L2043-2072)
// 注入续传提示:"Output token limit hit. Resume directly — no apology, no recap..."
// maxOutputTokensRecoveryCount 递增,最多 3 次
state = { ..., transition: { reason: 'max_output_tokens_recovery', attempt: N } }
continue
// 第 4 级:恢复耗尽(L2075-2076)
// yield lastMessage → 告诉用户"输出超限"
基础设施讲完了,用一个完整案例把前面所有概念串起来。
四、用户中断——一个完整的案例研究
哎,用户按了 Ctrl+C,系统到底发生了什么?
这是理解消息机制设计哲学的最好切入点。中断不是"一个命令",而是两层机制同时工作。
// REPL.tsx L2196-2218 — Escape 键处理,4 个分支
if (focusedInputDialog === 'tool-permission') {
toolUseConfirmQueue[0]?.onAbort(); // 拒绝权限弹窗
setToolUseConfirmQueue([]);
} else if (focusedInputDialog === 'prompt') {
for (const item of promptQueue) {
item.reject(new Error('Prompt cancelled by user'));
}
setPromptQueue([]);
abortController?.abort('user-cancel'); // 中断 prompt 输入
} else if (activeRemote.isRemoteMode) {
activeRemote.cancelRequest(); // 远程模式取消
} else {
abortController?.abort('user-cancel'); // 默认:中断 API 调用
}
setAbortController(null) // 清除 controller,防止后续 Escape 看到过期的 aborted signal
AbortController.abort() 是一个广播信号,一旦调用,所有正在监听 signal 的地方同时收到通知:
abortController.abort('user-cancel')
├→ API SSE 流断开(立刻停止接收 token)
├→ 工具执行检查 signal.aborted(立刻停止)
├→ StreamingToolExecutor 清空队列
└→ 权限对话框关闭
// 流式中止(query.ts L1763):toolUse: false
if (toolUseContext.abortController.signal.reason !== 'interrupt') {
yield createUserInterruptionMessage({ toolUse: false })
}
// 工具执行中止(query.ts L2396):toolUse: true
if (toolUseContext.abortController.signal.reason !== 'interrupt') {
yield createUserInterruptionMessage({ toolUse: true })
}
// 'interrupt' 类型的中止(排队命令触发)跳过中断消息,
// 因为后续排队的用户消息已提供足够上下文
中断文本注入 messages 数组,给下一轮 LLM 看。
哎,中断是命令,为什么要走 LLM?直接停不就行了吗?
如果只做第一层不做第二层:操作停了,但 LLM 不知道为什么停了。下一轮用户说"算了,只改第一个文件",LLM 看到的历史是:
assistant: "我来帮你编辑文件..."(含 tool_use: Edit)
user: [tool_result: is_error=true](没有任何说明)
user: "算了,只改第一个文件"
LLM 的困惑:Edit 失败了?为什么失败?要不要重试?
有了:第一层管"停"——abortController.abort() 立刻停止所有操作。第二层管"记"——createUserInterruptionMessage 把中断原因写进 context,给下一轮对话用。
有了中断文本:
assistant: "我来帮你编辑文件..."(含 tool_use: Edit)
user: [tool_result: content="The user doesn't want to proceed... STOP and wait"]
user: "算了,只改第一个文件"
LLM 理解:用户主动拒绝了 Edit,文件没被修改,不要重试,直接处理新指令。
| 常量 | 文案 | 语义 |
|---|---|---|
INTERRUPT_MESSAGE(L368) | [Request interrupted by user] | "打断你"(Ctrl+C,粗暴中断) |
CANCEL_MESSAGE(L372) | The user doesn't want to take this action right now. STOP what you are doing and wait... | "先别做"(UI 取消按钮,语气温和) |
REJECT_MESSAGE(L385) | The user doesn't want to proceed with this tool use. The tool use was rejected... STOP and wait... | "不同意"(权限拒绝,明确否定) |
每种中断的文案都写得格外明确,甚至有点啰嗦。为什么?一次写清楚比多轮确认更省 token。
省 token 的写法:
user: "[Rejected]"
LLM: "Why was it rejected? Should I retry?"
user: "Don't retry"
LLM: "What should I do instead?"
总 token:4 轮对话 ≈ 200+ tokens
直接写清楚的写法:
user: "The user doesn't want to proceed... STOP and wait for the user to tell you how to proceed."
总 token:1 轮 ≈ 50 tokens
1. 用户手动拒绝(REJECT_MESSAGE)— [L385](../../src/utils/messages.ts#L385)
→ 带理由版本:REJECT_MESSAGE_WITH_REASON_PREFIX + 用户理由
→ 不带 DENIAL_WORKAROUND_GUIDANCE(用户自己知道怎么办)
2. 子 Agent 被拒绝(SUBAGENT_REJECT_MESSAGE)— [L389](../../src/utils/messages.ts#L389)
→ 结尾不同:"Try a different approach"(子 Agent 不能"等用户")
→ 触发场景:abort 时、权限弹窗不可用时、Leader 拒绝时
3. 自动拒绝(AUTO_REJECT_MESSAGE / DONT_ASK_REJECT_MESSAGE)— [L407-412](../../src/utils/messages.ts#L407-L412)
→ 拼接 DENIAL_WORKAROUND_GUIDANCE(没有用户在场,需要指引模型合理变通)
DENIAL_WORKAROUND_GUIDANCE(L399-405)的核心内容:
- 可以用其他工具合理替代(如用
head替代cat) - 不可以恶意绕过(如用测试命令执行非测试操作)
- 如果真需要这个权限,停下来向用户解释
哎,系统怎么区分"真正的人类输入"和"协议文本"?
用白名单匹配。isSyntheticMessage(messages.ts L474-491)检查消息的 content 第一个块的文本是否在 5 种常量中:
// src/utils/messages.ts L474
export const SYNTHETIC_MESSAGES = new Set([
INTERRUPT_MESSAGE, // '[Request interrupted by user]'
INTERRUPT_MESSAGE_FOR_TOOL_USE, // '[Request interrupted by user for tool use]'
CANCEL_MESSAGE, // "The user doesn't want to take this action..."
REJECT_MESSAGE, // "The user doesn't want to proceed with this..."
NO_RESPONSE_REQUESTED, // 'No response requested.'
])
export function isSyntheticMessage(message: Message): boolean {
return (
message.type !== 'progress' &&
message.type !== 'attachment' &&
message.type !== 'system' &&
Array.isArray(message.message.content) &&
message.message.content[0]?.type === 'text' &&
SYNTHETIC_MESSAGES.has(message.message.content[0].text)
)
}
匹配到 → 合成消息(不显示给用户、不发给远程 Bridge)。没匹配到 → 真正的人类输入。
哎,为什么不把这些中断原因做成结构化信号,而要用自然语言文本?
有了:用自然语言文本代替结构化信号——LLM 没有持久状态,messages 数组是唯一记忆。所有事件都翻译成自然语言,让 LLM 成为唯一的行为决策者。
因为 Claude Code 的 LLM 没有持久状态。每轮 API 调用,它只能看到 messages 数组。如果中断不写进 messages,LLM 就像一个"失忆的人"。
传统软件用状态机管理行为——if (state === INTERRUPTED) { doX() }。Claude Code 用上下文管理行为——把状态写进 messages,让 LLM 自己推理下一步。
好处:不需要为每种事件组合硬编码行为。LLM 能处理任意事件组合(中断+拒绝+新指令+工具失败...)。
代价:需要把事件讲得足够清楚(所以文案才那么"啰嗦"),否则 LLM 会误解。
这个模式在整个系统中无处不在:用户中断 → 自然语言文本;权限拒绝 → 自然语言文本;API 错误 → 合成假回复;压缩完成 → SystemMessage;工具失败 → tool_result 错误信息。所有事件都翻译成自然语言,让 LLM 成为唯一的行为决策者。
案例讲完了,最后提炼几条可迁移的设计原则。
五、设计原则——三个核心问题
第一个问题:怎么选类型和容器?
不要先设计消息类型再编码。先问"这条信息要从 A 到 B,A 和 B 语言通不通"——不通就需要一个翻译层,这就是消息类型。这就是边界驱动类型原则。
选容器也有标准方法:问三个问题——需要持久化吗?生产者和消费者节奏匹配吗?有多个生产者/消费者吗?三个问题的答案直接指向最合适的容器。比如 API 响应不需要持久化、单生产者、需要背压控制 → SSE 流(HTTP 的单向流式协议,服务端持续推送事件,客户端逐条接收)。
在 API 类型约束下,用标记字段弥补语义缺口比发明新类型更轻量。但标记字段超过 7+ 个时,就该考虑收敛为类型体系了。这就是标记 vs 新类型的取舍。
第二个问题:怎么处理状态和异常?
Claude Code 的核心是一个 ReAct 循环——Think(调 API)→ Act(执行工具)→ Observe(合并结果,判断是否继续)。这个循环本身没有持久状态,每轮 API 调用只能看到 messages 数组。
所以所有事件都翻译成自然语言,塞进 context,让 LLM 在下一轮能基于完整信息做判断。这就是用上下文代替状态机——整个消息系统的核心设计模式。
异常处理遵循代价梯度:便宜的先试,贵的兜底。上下文管理的 5 级过滤(ToolResultBudget → SnipCompact → MicroCompact → ContextCollapse → AutoCompact)、413 恢复链(折叠 → 压缩 → 放弃)、模型回退(同模型重试 → 跨模型切换)都遵循这个原则。用户中断时,AbortController.abort() 是 JavaScript 的内置 API,一旦调用,所有监听 signal 的操作同时收到通知并停止——这是"立刻停"的第一层机制。
第三个问题:怎么组织代码?
有了:三个问题覆盖了所有核心决策——边界驱动解决"类型从哪来",代价梯度解决"异常怎么处理",上下文优先解决"状态怎么传递"。
机机交互的精确协议(权限请求、状态同步)不能靠 LLM 理解,必须用结构化 type 字段精确路由。自由文本走数据面,结构化协议走控制面,井水不犯河水。这就是控制面与数据面分离。
query loop 本身只有 20 行核心逻辑。所有复杂性都在外围的 12 个扩展模块里——每个模块解决一个边界情况,防止循环因某种原因中断。这就是核心简单外围复杂。