23 流式输出、事件协议与 SDK 边界深挖
这一章聚焦 Claude Code 里另一个很关键、但很容易被误解成“只是输出格式”的部分:
- streaming output
- 事件协议
- SDK 边界
如果前几章已经把 query loop、message/context assembly、hooks、memory、tool system 这些内部运行机制拆开了,那么这一章要进一步回答:
- Claude Code 内部的消息状态,怎样变成外部可消费的流式事件?
- CLI / SDK / 外部调用方看到的 event protocol,和模型真实上下文之间是什么关系?
system/init、assistant message、tool use、result event 这些协议对象属于 runtime 哪一层?- 为什么 Claude Code 要把内部消息模型与外部 SDK 协议明确分开?
这一章的重点不是重复第 20 章里 message assembly 本体,而是专门讨论:
- 内部运行态消息
- 外部流式协议
- 二者之间的边界转换
flowchart LR
A[内部 query runtime] --> B[streaming emission]
B --> C[SDK 协议映射]
S[system/init] --> C
C --> D[外部消费者]
一、先给出总体判断
如果只基于当前源码做判断,我会把 Claude Code 的流式输出与 SDK 边界概括成:
一套由内部 query/message runtime、增量事件投影、system/init 协议消息、以及 SDK-facing stream contract 共同组成的外部化层;它并不等于模型上下文本身,而是对内部状态推进的结构化对外映射。
更具体地说,可以稳定拆成四层:
- 内部消息与状态推进层:query loop 内部的 message state、assistant/tool/result 流转
- streaming emission 层:在 query 执行过程中持续产出事件
- SDK/protocol mapping 层:把内部对象映射成外部可消费的消息协议
- consumer boundary 层:CLI、SDK 调用方、外部集成只看见协议面,而不是全部内部实现
这个分层很重要,因为它说明 Claude Code 的“流式输出”并不是:
- 直接把模型 token 原封不动吐给外部
- 也不是把内部 message 数组直接 serialize 出去
- 更不是 SDK 和 runtime 共用同一个对象模型
而是:
- 内部状态推进 → 协议映射 → 对外流式投影
二、为什么必须把“内部消息”与“外部协议”分开
1. 内部消息服务于 runtime 推进,外部协议服务于消费端稳定性
Claude Code 的内部 message/state 主要解决的是:
- 当前 query 怎样推进
- assistant / tool / tool_result 怎样串联
- compact boundary 怎样影响历史
- attachments 怎样进入上下文
- recovery / stop hooks 怎样影响后续状态
但外部 consumer 真正关心的通常不是这些内部细节,而是:
- 会话什么时候初始化
- assistant 输出了什么
- 工具调用了什么
- 当前 turn 是否结束
- 如何稳定地增量消费这些变化
因此:
- 内部 message model 更偏 runtime correctness
- 外部 protocol model 更偏 integration stability
这两个目标不同,所以不能强行共用一个对象模型。
2. 直接暴露内部状态会让外部边界失控
如果 CLI/SDK 直接消费内部 message state,会立刻遇到很多问题:
- 内部虚拟消息是否应该暴露?
- synthetic error 是否属于协议?
- compact/snipped 边界是否要透出?
- attachment message 的内部形态是否稳定?
- 某些 bookkeeping message 是否应对外可见?
这些都说明 runtime 内部对象并不天然适合做 public protocol。
Claude Code 把这层边界单独做出来,本质上是在保护:
- 协议稳定性
- 内部实现可演化性
- 外部集成的兼容性
三、system/init 的意义:协议起点,不是模型上下文
1. buildSystemInitMessage(...) 暴露的是 protocol metadata
前面已经看到 src/utils/messages/systemInit.ts 里的:
buildSystemInitMessage(inputs: SystemInitInputs): SDKMessage
这个点非常关键,因为它清楚说明 Claude Code 在 turn / session 对外输出时,会显式构造一类:
system/init
协议消息。
它的意义不是:
- 给模型补充上下文
- 作为 query 的一部分送进模型
- 替代 system prompt
而是:
- 对 SDK/CLI/外部消费者说明当前运行环境、初始化信息、协议信息
因此 system/init 从一开始就告诉我们:
- SDK-visible stream != model-visible context
2. system/init 是 runtime 外化的握手消息
从架构角度看,system/init 更像:
- 外部消费者和 Claude Code runtime 之间的握手起点
它起的作用类似于:
- “下面我要开始发一个结构化事件流了”
- “这是这一轮/这一会话的初始化元信息”
这是一种很成熟的协议设计,因为它避免让消费方必须靠猜测去理解后续消息语义。
四、流式输出到底在流什么
1. 它流的不是“纯 token”,而是结构化运行时事件
很多人会把 streaming 理解成“模型 token 一边出来一边显示”。但从 Claude Code 的整体架构看,更稳妥的理解是:
- 它流的是 runtime event projection
这些事件可能包含:
- assistant 内容增量
- tool use 事件
- tool result 事件
- system/init
- 结束或状态切换相关信号
也就是说,Claude Code 的 streaming 更像:
- 结构化 agent event stream
而不是单纯 token transport。
2. token streaming 只是其中一个子层
模型 token 当然仍然重要,因为 assistant 文本生成本身是流式的。但 Claude Code 运行时不只处理文本:
- 还要处理工具调用
- 还要处理 query 状态推进
- 还要处理 SDK 可见协议对象
- 还要处理 system / assistant / tool 不同语义
因此 token streaming 只是更大 streaming architecture 里的一个局部。
五、QueryEngine 在这里扮演什么角色
1. QueryEngine 是内部 query runtime 与外部协议流之间的上层桥
前面章节已经说明,QueryEngine.submitMessage() 做了几件关键事情:
- 装配 system prompt
- 准备 user/system context
- 发起
query(...) - 产出
buildSystemInitMessage(...) - 将内部 query 产物进一步映射到外部流
这意味着 QueryEngine 不只是“开始一次 query”的入口,它还是:
- internal runtime → external stream protocol 的桥
2. QueryEngine 负责把内部执行包装成可消费的会话流
如果没有 QueryEngine 这一层,外部 SDK 可能必须直接理解:
- query loop 细节
- message normalization 时机
- stop hooks 行为
- tool result 内部格式
- compact 边界后的历史投影
这并不适合作为公开集成接口。
所以 QueryEngine 的另一个重要职责,就是把这些内部复杂性包装成更稳定的流式协议输出。
六、为什么协议消息不能等于 message history
1. history 是 runtime 记忆结构,protocol 是外部观察结构
message history 的职责是:
- 作为后续模型轮次的输入基础
- 承载 assistant/tool/result 的历史关系
- 支持 compact / recovery / replay / follow-up
但 protocol message 的职责是:
- 向外部消费者报告“刚刚发生了什么”
- 让外部系统做显示、日志、集成、自动化处理
所以:
- history 更像内部记忆结构
- protocol 更像外部观察结构
二者天然不是一回事。
2. protocol 允许按消费需求重组信息
为了对外更稳定,协议层通常会:
- 合并内部细节
- 重命名语义
- 补充初始化信息
- 省略内部 bookkeeping
- 把一次内部状态推进投影成多条外部事件
这也是为什么 Claude Code 需要专门的 mapping 层,而不是直接导出 history。
七、tool use / tool result 在协议层的意义
1. 工具调用一旦进入协议层,就不再只是内部执行细节
在 query loop 内部,tool use 是主状态机的一部分;但一旦映射到 SDK/stream protocol,它又变成:
- 外部消费者可观察的 agent 行为事件
这很重要,因为它意味着外部系统可以理解:
- assistant 何时决定调用工具
- 调用了什么工具
- 工具返回了什么
- 何时恢复为 assistant 后续响应
因此 tool use / result 在协议层的价值,不是执行本身,而是:
- 让 agent 的行动过程具备可观察性
2. 这也是为什么 Claude Code 的 streaming 比单纯文本更像 agent protocol
如果流里只有文本,外部系统永远无法可靠理解:
- 哪段文本是模型自然语言
- 哪次停顿是工具调用
- 当前是不是执行中间态
- 什么时候可以把结果视为完整 turn
而结构化 tool event 让这些状态变得可区分。
八、SDK 边界真正保护的是什么
1. 保护 runtime 内部实现可变
只要内部 message/state 没被直接当成 public API,Claude Code 团队就能继续演化:
- history 存储方式
- compact 机制
- attachment 注入形式
- virtual/synthetic messages
- internal bookkeeping shape
而不必立刻破坏 SDK 调用方。
所以 SDK boundary 首先保护的是:
- 内部实现演化自由度
2. 保护外部协议稳定性
相反,外部 SDK 消费方需要的是:
- 可预测的事件类型
- 稳定的消息顺序语义
- 清楚的初始化/结束边界
- 可文档化的消息 contract
这意味着协议层的抽象目标不是“最完整”,而是“最稳定、最可消费”。
3. 保护多消费端适配能力
CLI、桌面端、SDK、外部自动化系统,它们的需求并不完全一样。但只要中间有一层结构化 protocol,就可以:
- 在同一 runtime 之上支持多个消费端
- 各端共享同一事件语义
- 避免每个消费端都直接绑定内部实现
九、流式协议和前面章节的关系
1. 与第 20 章的关系:20 讲“上下文装配”,本章讲“结果外化”
第 20 章的重点是:
- 模型调用前,消息与上下文怎样被拼装
本章的重点则是:
- query 运行之后,内部推进怎样被映射成外部 stream protocol
也就是说:
20解决输入面23解决输出面
2. 与第 22 章的关系:22 讲工具执行,本章讲工具事件如何对外呈现
第 22 章里工具调用属于主状态机的执行面;本章进一步强调:
- 同一工具调用一旦进入 SDK 流,就会变成外部可观察事件
所以:
22讲执行23讲可观察性与协议投影
3. 与 hooks/memory 章节的关系:它们多半属于内部 runtime 机制,不一定直接等价为协议事件
这也有助于理解为什么 Claude Code 没把所有 runtime 机制都暴露成外部事件:外部协议关注的是稳定的消费面,而不是把每个内部 side-channel 都公开。
十、源码链路下几个更稳的判断
1. Claude Code 明确区分了 model-visible context 与 SDK-visible stream
system/init 的存在,以及 QueryEngine 的映射职责,都清楚说明这两者不是同一个平面。
2. 外部 stream 是内部运行时状态的结构化投影,而不是原样导出
这包括 assistant 内容、tool 事件、初始化信息等,都经过协议化处理后才对外暴露。
3. QueryEngine 不只是 query 发起器,也是协议桥接层
它连接了:
- system prompt assembly
- query runtime
- SDK-facing event stream
这是它在整个架构里的一个核心位置。
4. SDK boundary 的价值不在“包装一下”,而在隔离内部演化和外部稳定 contract
这其实是整套 agent runtime 能长期演化的基础之一。
本章小结
如果把这一章压缩成一句话,可以说:
Claude Code 的流式输出与 SDK 边界,不是把内部消息原样吐给外部,而是把 query runtime 的状态推进、assistant/tool 事件和初始化元信息,映射成一套可消费、可集成、可稳定演化的结构化事件协议。
从源码脉络能得出的倾向性结论包括:
system/init属于外部协议握手面,而不是模型上下文本身;- QueryEngine 不只是 query 入口,也是 internal runtime 到 SDK stream 的桥接层;
- 外部事件流是内部状态推进的协议化投影,而不是 history 的直接导出;
- tool use / tool result 在协议层承担的是可观察性职责;
- SDK boundary 的核心价值是隔离内部实现演化与外部消费稳定性。
源码证据索引
src/QueryEngine.ts— query 入口、system prompt 装配、SDK stream 桥接src/utils/messages/systemInit.ts—system/init协议消息构造src/query.ts— assistant/tool/result 的内部推进来源src/utils/messages.ts— internal history / effective history 的消息处理边界