从设计者的角度理解源码-ClaudeCode的消息机制设计

王大爷 2026年09月13日 9次浏览

从设计者的角度理解源码 -- Claude Code 的消息机制设计

引言

本篇,作为《从设计者的角度理解源码》系列的第三篇,试图回答一个看似简单的问题:Claude Code 的消息系统为什么长成现在这个样子?

如果你翻过源码,你会发现 messages.ts 有 3000+ 行,里面塞满了 isMetaisVirtualisCompactSummarytoolUseResultorigin 之类的标记字段,还有一个 16 参数的 createUserMessage 工厂函数。第一反应可能是:这是不是胡乱硬编码的?

但如果你从设计者的角度出发,沿着"我遇到了什么问题 → 我当时怎么想的 → 我做了什么选择"这条路走一遍,你会发现每一个标记字段、每一种消息类型都有一个被逼出来的故事。

核心矛盾只有一句话:Anthropic API 只认 userassistant 两种 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 只认 userassistant 两种 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?

这就是 normalizeMessagesForAPImessages.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 只认 userassistant 两种 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 用),normalizeAttachmentForAPImessages.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)L657needsFollowUpL1080 初始化为 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 }

handleMessageFromStreamquery.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)用两个精巧的设计解决:

  1. 自驱动:每个工具执行完毕后,promise.finally(() => { void this.processQueue() })(L576-582)自动触发下一个,不需要外部轮询
  2. 双出口
    • 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_resultcontent: '[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 文件(会话记录)
  ↓
压缩/折叠:旧消息被摘要替换或遮蔽
  ↓
消亡:被压缩替换后永久丢失

消息在循环里流转,那流转过程中存在哪?出错了又怎么办?


三、传输容器与容错机制

第一部分:六种传输容器

哎,消息发出来之后,到底存在哪?

这取决于三个问题:

  1. 需要持久化吗? 跨进程/跨时间 → 文件信箱;同进程内 → 内存容器
  2. 生产者和消费者节奏匹配吗? 匹配 → yield 直传;不匹配 → 需要队列缓冲
  3. 有多个生产者/消费者吗? 有 → 需要并发控制;没有 → 直传

三个问题问完,六种容器自然浮现。用一棵决策树来选:

需要跨进程/跨时间?
├─ 是 → 文件信箱(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 状态全都不能复用——三样东西同时作废,只能从头来。

  1. thinking 签名失效:不同模型的 thinking 格式不同,旧的 thinking 不能传给新模型
  2. tool_use ID 作废:旧模型生成的 tool_use ID 不能传给新模型(新模型不知道这些 ID)
  3. 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 后,handleMessageFromStreammessages.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_MESSAGEL368[Request interrupted by user]"打断你"(Ctrl+C,粗暴中断)
CANCEL_MESSAGEL372The user doesn't want to take this action right now. STOP what you are doing and wait..."先别做"(UI 取消按钮,语气温和)
REJECT_MESSAGEL385The 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
  • 不可以恶意绕过(如用测试命令执行非测试操作)
  • 如果真需要这个权限,停下来向用户解释

哎,系统怎么区分"真正的人类输入"和"协议文本"?

用白名单匹配。isSyntheticMessagemessages.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 个扩展模块里——每个模块解决一个边界情况,防止循环因某种原因中断。这就是核心简单外围复杂