第12章:流式响应:为什么回复一个字一个字蹦出来¶
生活类比
你口渴的时候,希望得到的是“马上拧开水龙头就能喝到第一口水”,而不是“等水桶装满 30 秒再一起倒给你”。流式响应就是 AI 世界的水龙头。
这一章要回答的问题
如果等 AI 全部生成完再显示,会发生什么?Claude Code 又是怎么把“模型内部正在生成的半成品”安全地变成终端上的实时输出的?
这一章最关键的一点是:流式响应不是“界面好看一点”的小优化,而是 Claude Code 整个体验和工具编排速度的核心基础设施。
12.1 SSE 事件在 claude.ts 里被拆成了可持续生长的内容块¶
真正处理流式事件的关键逻辑在 services/api/claude.ts。
它收到的不是“一整条最终消息”,而是一连串事件:
message_startcontent_block_startcontent_block_deltacontent_block_stopmessage_deltamessage_stop
flowchart LR
A["message_start"] --> B["content_block_start"]
B --> C["content_block_delta"]
C --> D["content_block_stop"]
D --> E["message_delta"]
E --> F["message_stop"]
style A fill:#e3f2fd,stroke:#1565c0,color:#000
style C fill:#fff8e1,stroke:#f9a825,color:#000
style F fill:#e8f5e9,stroke:#2e7d32,color:#000
message_start 先定框架¶
message_start 到来时,代码会:
- 记录
partialMessage - 计算 TTFT(首字节时间)
- 累加初始 usage
这就像直播开始前,先把摄像机、时间戳、计数器都架好。
content_block_start 按块开槽位¶
Claude Code 不会假设“接下来全是普通文本”。它会根据块类型分别开不同槽位:
tool_useserver_tool_usetextthinking
而且初始化方式不同:
tool_use.input先设为空字符串text.text先设为空thinking.thinking和signature分开攒
这很像仓库管理员先把不同货架分好:生鲜、文具、易碎品不能全堆一块。
content_block_delta 才是真正一点点长出来的部分¶
源码里分别处理了:
input_json_deltatext_deltathinking_deltasignature_delta
也就是说,Claude Code 不只是“边收到边显示文字”,它还在流式拼:
- 工具输入 JSON
- thinking 内容
- thinking 签名
flowchart TD
A["content_block_delta"] --> B{"delta 类型"}
B --> C["text_delta<br/>拼到 text"]
B --> D["input_json_delta<br/>拼到 tool_use.input"]
B --> E["thinking_delta<br/>拼到 thinking"]
B --> F["signature_delta<br/>拼到 signature"]
style A fill:#fff8e1,stroke:#f9a825,color:#000
style D fill:#e8eaf6,stroke:#5e35b1,color:#000
style E fill:#f3e5f5,stroke:#8e24aa,color:#000
源码证据
OpenClaudeCode/src/services/api/claude.ts:1980-2169 详细展示了各种流式事件和 block delta 的拼接逻辑。
12.2 一条 assistant message 其实是“分块结束时”才真正成形的¶
很多人以为模型每吐一个字,Claude Code 就立刻创建一条完整 assistant message。其实不是。
真正的 assistant message 出现在 content_block_stop:
- 取出当前 block
normalizeContentFromAPI(...)- 生成标准化后的
AssistantMessage yield m
也就是说,Claude Code 不是按“字符”建消息,而是按“内容块”建消息。
为什么这样做更稳¶
因为不同块的语义不同:
- 文本块可以直接显示
- thinking 块要特殊处理
- tool_use 块要等 JSON 足够完整
如果不按块,而是按字符生硬推给上层,整个系统会非常混乱。
message_delta 还要回头补 usage 和 stop_reason¶
更微妙的是:assistant message 在 content_block_stop 就 yield 了,但最终:
usagestop_reason
这两个关键信息却是在后面的 message_delta 里才到。
所以源码做了一件很“工程脑”的事:
- 直接原地修改最后那条已创建消息的字段
- 而不是重新创建一个新对象
原因注释写得特别清楚:transcript 写队列拿的是对象引用,如果这里替换对象,延迟落盘时会拿到旧对象。
sequenceDiagram
participant S as 流事件
participant C as claude.ts
participant T as transcript queue
S->>C: content_block_stop
C->>C: 创建 AssistantMessage
C-->>T: 记录对象引用
S->>C: message_delta
C->>C: 原地补 usage / stop_reason
T-->>T: 延迟序列化时拿到最终值
这类细节特别适合学习:真正稳定的流式系统,很多时候胜负就在这些“对象什么时候生成、什么时候补全”的边界处理上。
源码证据
OpenClaudeCode/src/services/api/claude.ts:2171-2248 展示了 content_block_stop 产出消息,以及 message_delta 回填 usage / stop_reason 的过程。
12.3 QueryEngine 一边吃事件,一边把它们变成用户看得懂的东西¶
流式事件不是直接“裸奔到终端”的。QueryEngine 会再接一层:
- 在
message_start时重置当前消息 usage - 在
message_delta时累积 usage 并记录 stop_reason - 在
message_stop时把当前 usage 累加到总 usage - 如果
includePartialMessages开启,还能把原始stream_event再往外吐给 SDK
flowchart LR
A["claude.ts yield stream_event"] --> B["QueryEngine"]
B --> C["累计 usage"]
B --> D["更新 stop_reason"]
B --> E["可选向 SDK 转发原始流事件"]
B --> F["输出 normalize 后的消息"]
style A fill:#ede7f6,stroke:#5e35b1,color:#000
style B fill:#e3f2fd,stroke:#1565c0,color:#000
style F fill:#e8f5e9,stroke:#2e7d32,color:#000
这说明了一个重要架构原则¶
claude.ts 关心的是“怎么从 API 流里拼出正确内容”。
QueryEngine 关心的是“怎么把这些内容纳入整场会话的账本和 transcript”。
职责非常清楚:
- 下层负责还原真实流
- 上层负责管理会话语义
为什么 stream_event 也值得保留¶
因为对 SDK 场景来说,有时候调用者并不只想拿“最终文字”,而是想自己做:
- 实时进度 UI
- 调试面板
- streaming analytics
- 自定义转译层
如果底层不保留这些原始事件,上层扩展空间就会大大变小。
源码证据
OpenClaudeCode/src/QueryEngine.ts:788-826 展示了 QueryEngine 如何吸收并转发 stream_event。
12.4 工具为什么也能“边流边准备”:StreamingToolExecutor¶
Claude Code 的流式体验之所以高级,不是因为它只会一边吐文字,而是因为工具调用也被接到了这条流式链上。
StreamingToolExecutor 的职责是:
- 工具块一出现就排队
- 能并发的工具并发执行
- 不能并发的工具独占执行
- 结果按收到顺序吐回
- 进度消息提前显示
flowchart TD
A["收到 tool_use block"] --> B["safeParse input"]
B --> C{"并发安全 ?"}
C -->|是| D["可与其他 safe 工具并行"]
C -->|否| E["独占执行"]
D --> F["pendingProgress 即时输出"]
E --> F
F --> G["results 缓冲"]
G --> H["按接收顺序 yield"]
style A fill:#ede7f6,stroke:#5e35b1,color:#000
style F fill:#fff8e1,stroke:#f9a825,color:#000
style H fill:#e8f5e9,stroke:#2e7d32,color:#000
并发不是乱跑,而是带约束的¶
StreamingToolExecutor 不会粗暴地把所有工具都扔进 Promise.all。
它先问:
- 输入能不能通过 schema
- 这个工具是不是
isConcurrencySafe - 当前有没有别的非并发安全工具在执行
这很像高速公路收费站:
- 小轿车可以多车道并行
- 超宽货车要单独走
中断和 fallback 也有自己的“善后逻辑”¶
如果遇到:
user_interruptedstreaming_fallbacksibling_error
StreamingToolExecutor 不会静默丢掉,而是会构造 synthetic error message 补齐 tool_result。
这意味着即使在流式过程中出事故,消息历史仍然是闭环的。
为什么 Bash 出错会中断兄弟工具¶
源码里有个非常真实的经验判断:
- 只有 Bash 工具出错时,才会主动取消兄弟工具
- 因为 Bash 命令常常有隐含依赖链
比如:
mkdir失败了- 后面的
cd、ls、npm test多半也没意义
但像 WebFetch、ReadFile 这类工具,彼此失败往往没有因果关系,就没必要全盘中止。
源码证据
OpenClaudeCode/src/services/tools/StreamingToolExecutor.ts:34-151:工具排队与并发规则OpenClaudeCode/src/services/tools/StreamingToolExecutor.ts:153-259:中断原因与 synthetic errorOpenClaudeCode/src/services/tools/StreamingToolExecutor.ts:261-347:执行、进度输出和剩余结果消费
🔭 深水区(架构师选读)
流式响应最容易被低估的地方,是大家往往只把它看成“更快显示首字”的 UI 优化。Claude Code 的源码告诉我们,真正的流式系统至少有三层:
- 协议层流式:SSE 事件如何被拆分和累积
- 语义层流式:什么时候才能说“一条消息已经成立”
- 工具层流式:工具能不能在模型还没说完前就提前进入执行状态
只有三层一起做好,用户才会觉得它“聪明、快、顺”。否则要么是假流式,要么只是闪烁着输出几个字,但系统本体仍然很迟钝。
本章小结
一句话:Claude Code 的流式响应不是简单地“把文字一点点打印出来”,而是从 SSE 事件、消息块拼装、usage 回填,到工具并发和中断补偿,全链路都做成了可持续流动的系统。
关键源码索引
| 证据层 | 文件 | 本章关注点 |
|---|---|---|
| 补全层 | OpenClaudeCode/src/services/api/claude.ts:1980-2169 |
message_start / content_block_delta 等流式事件处理 |
| 补全层 | OpenClaudeCode/src/services/api/claude.ts:2171-2248 |
在块结束时创建消息,并在 message_delta 回填 usage |
| 补全层 | OpenClaudeCode/src/services/api/claude.ts:2299-2303 |
向上层统一吐出 stream_event |
| 补全层 | OpenClaudeCode/src/QueryEngine.ts:788-826 |
QueryEngine 对流式事件的会话级处理 |
| 补全层 | OpenClaudeCode/src/services/tools/StreamingToolExecutor.ts:34-151 |
工具的流式排队与并发 |
| 补全层 | OpenClaudeCode/src/services/tools/StreamingToolExecutor.ts:153-259 |
中断、fallback、兄弟工具取消策略 |
逆向提醒
- ✅ 可信度高:SSE 事件形态、消息分块、tool stream 执行器都能在源码中直接看到
- ⚠️ 要分清层次:assistant message 的创建时机和
usage / stop_reason的最终到达时机不是同一个瞬间 - ❌ 不要误解:流式不只是前端表现层;工具调度和 transcript 一致性也深度依赖流式设计