流式响应怎样变成可消费事件
从 AssistantMessageEvent 协议、EventStream 队列与 lazy setup 追到 OpenAI Responses 事件映射,并说明 Agent loop 如何维护 partial 与最终消息。
不同模型的流式响应不是一串同构文本。有的事件携带 reasoning item,有的分开发送工具名与参数,有的直到终态才给 usage。Pi 对上的协议因此围绕一条正在变化的 AssistantMessage:先 start,再发送 text、thinking、toolcall 三组 start/delta/end,最后只能以 done 或 error 结束。
一条消息,两种消费方式
AssistantMessageEventStream 是这份协议的运行时容器。普通事件要么交给正在等待的消费者,要么进入内存队列;终态事件会结算 result()。这里没有把失败变成 Promise rejection:error 事件携带一条 stopReason 为 error 或 aborted 的最终 assistant message,消费者既能迭代它,也能从 result() 取回它。
这份实现偏向协议适配,而非网络背压器:生产者快于消费者时,事件会留在内存数组中。它也不把“迭代结束”自动解释成成功;一个合格 producer 应先发终态,或在 end(result) 时显式提供结果。否则只等待 result() 的调用方没有可结算对象。类型契约因此要求请求、模型和 runtime 失败都编码进返回流,而不是调用后再同步抛出。
sequenceDiagram
accTitle: Provider 流到 Agent 消息的事件序列
accDescr: 适配器持续更新同一条 partial message,并通过统一事件让 Agent 写入、替换和最终结算上下文
participant P as Provider stream
participant A as API adapter
participant E as EventStream
participant L as Agent loop
P->>A: 原始 item / delta / terminal
A->>E: start(partial)
E->>L: message_start
loop 内容增量
A->>E: text/thinking/toolcall delta
E->>L: message_update(partial)
end
A->>E: done(message) 或 error(error)
E->>L: message_end(final)
异步准备也遵守同一出口。Models 返回流时,凭据解析或动态 import 可能还没完成;lazyStream() 先同步交出外层 stream,再转发内层事件。setup 抛错时,它构造零 usage 的错误消息,推入 error 并结束流。调用方不需要为“请求前失败”和“响应中失败”维护两套消费模型。
因此 start 并非绝对的第一个可观察事件。以 Responses 适配器为例,它在 client 建立、payload hook 执行并取得 HTTP response 之后才 push start;认证或连接准备失败时,消费者可能直接收到 error。UI 若把“没收到 start”当作仍在等待,就会漏掉已经结束的请求。可靠判断应看终态事件或 result(),而不是假设固定的首事件。
原始流必须落到明确终态
以 OpenAI Responses 为例,适配器先创建 stopReason: pending 的输出对象。processResponsesStream() 用 output index 维护 reasoning、text 和 tool call slot,收到增量时原地更新 block 并推送 Pi 事件;终态再补 usage、成本与 stop reason。若原始流在 response.completed、response.incomplete 或失败事件之前 EOF,它会抛错,外层 catch 将当前 partial 清理后作为错误终态发出,不能伪装成一次成功完成。
工具参数还有一段只属于流式解析的临时状态。Responses 适配器会累计 partialJson,tool call 完成时解析成 arguments 并删除 scratch buffer;异常出口也清理这些字段。持久化的 AssistantMessage 因而记录可重放结果,而不是某一厂商事件流的半成品内部结构。
Agent loop 收到 start 后把 partial 放入 context,增量到来时替换末条消息并发出 message_update;遇到任一终态,则以 response.result() 的最终对象替换 partial 并发出 message_end。这一步属于 Agent 状态更新,不再关心原始 API 事件名。
Agent 只消费归一化事件
只读重建协议可运行:
repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
git -C "$repo" show v0.83.0:packages/ai/src/types.ts |
nl -ba | sed -n '493,513p'
git -C "$repo" show v0.83.0:packages/ai/src/api/openai-responses-shared.ts |
nl -ba | sed -n '416,485p;715,754p'
依赖已安装时,可在 packages/ai 下运行 node ../../node_modules/vitest/dist/cli.js --run test/openai-responses-terminal-event.test.ts,确定性覆盖提前 EOF 与终态映射。下一章把视角横向拉开:Anthropic、Google、OpenAI 的双向格式差异如何藏在同一接口后面。