源码篇第3章 先开票,后办事——pi-ai

本章导读: 源码路线第 ② 站(核心层)。仍在
pi-ai包,但深入api/lazy.ts和api/transform-messages.ts。这一站要拆开那个让小A发愣的AssistantMessageEventStream:厂商零散的增量事件,是怎么被收拢成循环能直接消费的统一形态的。
源码篇第2章走完,小A终于让模型“出发”了。可 Provider 交回来的东西又一次让他愣住:不是一段完整回答,而是一个 AssistantMessageEventStream。他盯着这个流,问老Z:“我到底该怎么接?等它全部结束,还是一个一个拿?”
“都不对。”老Z说,“源码篇第2章 目录与户口——pi-ai已经完成‘模型对象到 Provider’的分派,但 Provider 交回的不是一个完成的字符串,而是 AssistantMessageEventStream。你现在要核对三件事:异步设置为什么不会阻塞入口;一个真实 adapter 如何把厂商流变成统一事件;取消和异常最终以什么状态向上报告。”
本章证据固定在上游 pi 标签 v1.0.0(提交 a13d35a742c6ef8462812a28fbe1d8c8b7431c32)、@earendil-works/pi-ai 1.0.0。本章挑 packages/ai/src/api/openai-responses.ts 作为一个真实 adapter 来读,这不等于声称所有 Provider 或 API 都采用它的字段和失败策略;也不覆盖所有厂商协议、模型定价规则和 WebSocket adapter,更不把通用 retry 工具的存在写成所有调用都会重试。
返回流之前,先把设置留到稍后
“ModelsImpl.stream 与 streamSimple 都调用 lazyStream,函数立刻把一个可迭代的流交到调用者手里,认证解析、动态模块加载、Provider 调用都在后台进行。”老Z说,“所以拿到流不等于‘请求一定已经开始’,它更不是一个普通的 Promise<AssistantMessage>。”
// packages/ai/src/api/lazy.ts,lazyStream(v1.0.0,节选)
export function lazyStream(model, setup): AssistantMessageEventStream {
const outer = new AssistantMessageEventStream();
setup()
.then((inner) => forwardStream(outer, inner))
.catch((error) => {
const message = createSetupErrorMessage(model, error);
outer.push({ type: "error", reason: "error", error: message });
outer.end(message);
});
return outer;
}小A:“为什么要这样绕一圈?直接等 setup 完成再返回不行吗?”
“逐段看。”老Z指着代码说,“第一行构造 outer,调用点因此同步拿到同一类型的输出,不必区分‘尚在认证’和‘已在接收网络事件’。第二段不 await setup(),而是用 promise continuation 把 forwardStream 接上去,这正是延迟设置:ModelsImpl 里的 requireProvider、applyAuth 和 provider.stream* 都被放进传入的 async 回调。不过‘延迟’是相对同步返回说的,它不承诺等到第一次 for await 才执行,setup() 在函数体里就调用了。”
“第三段把 setup rejection 归一成一个 AssistantMessage:空 content、零 usage、stopReason: "error"、错误文字,随后 push error 事件并 end(message)。于是未知 Provider 或认证失败对消费者呈现为流内错误,而不是调用 Models.stream 时的同步 throw。”
比喻:先开票,后办事
lazyStream 像先给你一张取餐号(outer stream),后厨(setup)慢慢做。做砸了,也是通过“取餐口”告诉你“这单做不了”,而不是在你下单那一刻就摔盘子。
forwardStream 逐个 push 内流事件,迭代结束后若内流带有 .result() 就用它结束 outer。这一规则保留了 adapter 给出的最终消息;别从这里推断任何事件一定包含 text delta。
| 阶段 | 产物 | 正常下一步 | 设置失败时 |
|---|---|---|---|
Models.stream* 返回 | outer stream | 调用者订阅/迭代 | 尚无同步异常 |
setup() | inner async iterable | forwardStream | .catch 创建 error message |
| inner events | start、delta、done/error 等 | 原样推到 outer | adapter 决定事件内容 |
| inner 完结 | result() 或 undefined | outer.end | 由 adapter 的终止行为决定 |
lazy API 是第二层延迟,不是第二种事件协议
小A顺着问:“那 Provider 内部的适配器模块呢?也是懒加载?”
“对,但那是第二层延迟。”老Z打开 lazy.ts:
lazyApi(load, capabilities?) 返回一个 ProviderStreams 对象。其两个流方法都再以 lazyStream 包住 await load() 后的模块方法;v1.0.0 新增的 capabilities 声明这个 API 是否还支持 deferred 响应的取回与取消,本章不展开:
// packages/ai/src/api/lazy.ts,lazyApi(v1.0.0,节选)
const api: ProviderStreams = {
stream: (model, context, options) =>
lazyStream(model, async () => (await load()).stream(model, context, options)),
streamSimple: (model, context, options) =>
lazyStream(model, async () => (await load()).streamSimple(model, context, options)),
};“外层 ModelsImpl 的 lazy stream 覆盖的是‘Provider/认证设置’,这里的 lazy stream 覆盖的是‘适配器模块加载’。两层产出的都是同一种 AssistantMessageEventStream,所以会出现嵌套转发。模块 import cache 是否去重由宿主运行时负责,源码注释确实提到了 host import cache,但没为任意运行环境证明过性能结果。”
“收益是未使用的 adapter 不必在根入口加载;代价是模块加载错误推迟到第一次请求,排错时还得把 auth setup、import setup 和网络请求三层分开看。”
真实 adapter 的一次正常事件流
小A提议:“挑个真实 adapter,看它正常怎么走?”
“看 openai-responses.ts。”老Z说,“它的 stream 在函数开始就创建 AssistantMessageEventStream,再用 resolveTranscript 按 compat 处理 transcript:模型不接受对话中途的 system message 时,把所有 system message 合并成开头一条。然后启动一个 async IIFE。它先建一个 stopReason: "pending" 的 output,成功后才推 start、让 processResponsesStream 更新它,再推 done。工具清单也不再从 context.tools 读,而是用 getDeclaredTools 从 system message 里回放。”
// packages/ai/src/api/openai-responses.ts,stream(v1.0.0,节选)
const { data: openaiStream, response } = await retryProviderRequest(
() => client.responses.create(params, requestOptions).withResponse(),
{ maxRetries: options?.maxRetries, maxRetryDelayMs: options?.maxRetryDelayMs, signal: options?.signal },
);
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
stream.push({ type: "start", partial: output });
await processResponsesStream(openaiStream, output, stream, model, { /* 节选 */ });
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();“注意这段的顺序。”老Z提醒,“请求经过 retryProviderRequest,但 client 配置 maxRetries: 0 关闭 SDK 自带重试;helper 再按自己的 options.maxRetries 控制另一层重试。两者不是同一个参数。重不重试,仍由 options.maxRetries、信号和 helper 的可重试判定决定,别把代码里有这一层读成‘任何错误必重试’。”
“onResponse 在拿到 HTTP response 之后、推 start 之前触发,提供 status 和转换成 record 的 headers。随后的 processResponsesStream 才是上游事件到统一 delta 的实际转换位置;本章不逐个展开它的 OpenAI event case,那些 case 是这一个 adapter 的细节,不是跨 Provider 的契约。”
一个正常序列可读作:
buildParams / createClient
→ retryProviderRequest 发起 Responses 请求
→ 收到 HTTP response,调用 onResponse
→ push start(partial output)
→ processResponsesStream 连续修改 output 并 push text/thinking/tool-call 事件
→ output 得到非 pending、非 aborted/error 的 stopReason
→ push done(message),end()“对 Agent 来说,最关键的不是上游 event 叫什么名字,而是它先看到一个可更新的 partial,最后拿到完整的 AssistantMessage。下一章的 streamAssistantResponse 能把 start 写入上下文、把 delta 转成 message_update,靠的就是这一点。”
transform 与 compat:统一形状不等于抹平差异
小A把两个名字摆在一起:“前面 applyAuth 里有 transformHeaders,这里又有 getCompat,它们是同一回事吗?”
“不是。它们解决的层次不同。”老Z说,“ModelsImpl.applyAuth 中的 transformHeaders 是请求发送前的 Models 专用变换;openai-responses.ts 又会针对模型的 compat 建立默认兼容配置。”
// packages/ai/src/api/openai-responses.ts,getCompat(v1.0.0,节选)
function getCompat(model: Model<"openai-responses">): Required<OpenAIResponsesCompat> {
return {
supportsDeveloperRole: model.compat?.supportsDeveloperRole ?? true,
supportsMidConvoSystemMessages: model.compat?.supportsMidConvoSystemMessages ?? false,
sessionAffinityFormat: model.compat?.sessionAffinityFormat ?? detectSessionAffinityFormat(model),
supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true,
// 其余能力开关已删节
};
}“transformHeaders 的输入是已经合并的 header record,适合宿主加追踪或路由信息;它不会改 adapter 的 message 结构。getCompat 从 model.compat 和 provider/baseUrl 推断能力开关,随后被 resolveTranscript、buildParams、grammar tool 输入等路径使用。同一个 API 名下仍可以有 capability 差异,但默认值是 true 不代表远端一定接受这个能力。”
| 机制 | 发生位置 | 作用对象 | 失败或限制 |
|---|---|---|---|
transformHeaders | ModelsImpl.applyAuth | 已合并 header | 抛错成为 lazy setup error |
onPayload | adapter 组装 params 后 | provider payload | 返回值可替换 params;hook 失败进入 catch |
getCompat | adapter 参数构造前 | Model capability 标志 | 默认值是本地选择,不是服务端证明 |
processResponsesStream | 请求建立后 | 上游事件→统一事件 | 具体字段只适用于该 adapter |
小A指着表里 getCompat 一行问:“那个 sessionAffinityFormat 默认走 detectSessionAffinityFormat,它到底看什么?”
“两处判断。”老Z说,“一处是 model.provider === "openrouter" 或 baseUrl 里含 openrouter.ai,任中其一就选 openrouter,否则默认 openai。这是本地启发式,不是服务端声明。之后 createClient 建会话头时会用到它:有 sessionId 且是 openrouter,就发 x-session-id;是 openai 分支则发 session_id 并附 x-client-request-id。所以 compat 标志确实改变了发送的 header,至于默认值,只代表本地这一侧的选型。”
“那这个分支算错误路径吗?”小A追问。
“更值得记的是 getClientApiKey 的位置差异。stream 里它在 async IIFE 的 try 内,缺 key 抛错会进 catch、变成 error event;streamSimple 则在构造 stream 之前就同步调用它。所以直接调 adapter 的 streamSimple 时,缺 key 是同步 throw,不走流内错误;只有经由 Models 集合的 async setup 闭包,它才被归一成 error event。这也应了上一节那句话:绕过 ModelsImpl,契约要你自己满足。”
abort、错误与不完整流不能被吞掉
小A把担心的情形一口气列了出来:“abort、没结束就断、报错,流中途出问题各怎么处理?”
“真实 adapter 的尾部有三道检查:请求后若 signal.aborted 就抛错;上游流结束仍是 pending 就报‘没有 stop reason’;若 output 已是 aborted/error 也转为错误路径,并带上 output 已有的 errorMessage。一个没有完整终止信息的流,在这里不会被误报为成功。”
// packages/ai/src/api/openai-responses.ts,stream 的收尾与 catch(v1.0.0,节选)
if (options?.signal?.aborted) throw new Error("Request was aborted");
if (output.stopReason === "pending") throw new Error("OpenAI Responses stream ended without a stop reason");
// catch 中:
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
const errorMessage = formatProviderError(
normalizeProviderError(error),
`${model.provider === "openai" ? "OpenAI" : model.provider} API error`,
);
output.errorMessage = /* 订阅额度用尽时追加 ChatGPT 用量页链接 */ errorMessage;
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();“catch 之前还删除了 content block 上仅用于流式解析的 index、partialJson、customInput。这是一个可确认的清理行为,别把它夸张成‘所有中间数据绝不留存’,其他层的日志与 store 都不在这个函数内。”
| 路径 | 触发条件 | 事件/最终 stop reason | 可恢复性边界 |
|---|---|---|---|
| 正常完成 | parser 填好有效 stop reason | done,原 stop reason | 调用者可继续处理工具调用 |
| 主动中止 | AbortSignal 已 aborted | error,aborted | 是否重试由上层决定 |
| 请求/解析异常 | SDK、hook、参数或网络抛错 | error,error | errorMessage 经 formatter 规范化 |
| 上游无终止原因 | 结束仍 pending | error,error | 防止把半截响应当成功 |
| lazy setup 失败 | auth、Provider 或模块加载拒绝 | error,error | 没有进入本 adapter 的正常 start |
小A盯着表问:“中止为什么也用 error event,而不是 done?”
“事件类型在这里表达‘非正常完成通道’,reason 才负责区分 aborted 与 error。”老Z说,“所以消费者不能只看 event type。源码篇第4章 双层循环——pi-agent-core里会看到,loop 依 stop reason 停止,而不是把 aborted 当工具调用为空。”
“那 catch 里合成 errorMessage 时会把 HTTP 状态丢掉吗?我能读到什么?”
“它是 formatProviderError(normalizeProviderError(error), 前缀) 合成的,前缀在 provider 是 openai 时为 ‘OpenAI API error’,否则用 provider ID。normalize 先按 SDK 字段形状探测状态:statusCode、status、$metadata.httpStatusCode、$response.statusCode,按这个顺序取第一个数字。body 只接受普通对象或字符串,类实例和流对象都算没有 body,内容截断到 4000 字符。format 只在同时拿到状态和 body 时才拼出 前缀 (状态): body,否则保留 SDK 自己的 error.message,免得用噪音替换有用文本。消息里含 subscription_sharing_usage_limit_exceeded 时,adapter 还会追加一行 ChatGPT 用量页链接。”
“所以 errorMessage 是显示用的合成字符串,不是结构化错误对象。”老Z总结,“它有前缀,尽力保留状态和 body;消费者要做分支,优先看 stopReason 和事件本身,别去解析这段文本。aborted 与 error 的差别 reason 已经给出,用不着从文本里猜。”
消费方应保存最终消息,而非自行拼接文本
小A想走条捷径:“那我把 text_delta 拼起来,是不是就拿到完整回答了?”
“会丢东西。”老Z说,“adapter 在流里发布 partial event,是为了让消费方及时显示或更新状态。最终的 AssistantMessage 除了文本还有 usage、provider、model、stop reason,以及可能的 tool call content;只把 text_delta 拼成字符串,这些 Agent 下一步要用的信息就全没了。”
| 消费策略 | 能得到什么 | 丢失或风险 |
|---|---|---|
| 只显示 delta | 低延迟文本体验 | tool call、usage、stop reason 不完整 |
| 只等待 result | 完整 message | 没有流式可见性 |
| 同时消费 event 与 result | partial UI 加最终状态 | 必须避免把 partial 当持久化终值 |
| 收到 error 立即忽略 | 可快速返回 | 抹去 aborted 与 provider error 的区别 |
“lazyStream 的 forwardStream 不重写内层 event,所以 event 序列的细粒度语义仍由 adapter 决定。setup failure 会统一合成为 error event,调用者因此能用同一个订阅面处理配置失败;但这不是‘所有失败都有相同 message 文本’的保证,createSetupErrorMessage 用 error.message 或 String(error) 填 errorMessage,adapter 也可能用自己的 formatter。”
“重试要放在消费语义之外看。retryProviderRequest 覆盖的只是本 adapter 发出 Responses 请求的那段 await,它不包围之后的 Agent 工具执行,也不包围 session append。请求已经开始输出才中断的话,能不能重试,取决于调用者是否接受重复生成。对有外部副作用的 tool result,模型请求的重试规则不能直接搬过去,这是通用工程判断,不是 OpenAI Responses adapter 的自动保护。”
收益、代价与未覆盖范围
基于源码可以推断:把 API adapter 统一为事件流,Agent 能在同一套循环里显示文本、思考和 tool-call 增量;Provider 也仍能通过 typed options、compat 和 payload hook 保留差异。代价是错误来源跨多层,一个通用事件类型也自动抹平不了计费、缓存或取消的远端语义。
本章没有验证真实服务是否响应、重试退避的全部算法、processResponsesStream 的每一条事件映射,也没有比较 Anthropic、Google 或 WebSocket adapter。这些需要分别读各实现和在受控凭据下运行,不能由 OpenAI Responses 的代码替代。
小结
这一章回答的是:Provider 交回的那个 AssistantMessageEventStream,到底该怎么接。它不是一个“等全部结束再读”的完整对象,而是一条有生命周期的增量事件流:lazyStream 让入口立即给出统一流句柄,把认证、模块加载的失败统一转换成 error message;adapter 的正常路径是请求、start、事件转换、done,错误路径用 reason 区分 abort 与 error,并拒绝没有 stop reason 的半截流。
- 边界:header transform、payload hook 与 compat 分别在不同层解决不同问题,互不替代。消费者不能只看 event type,还要看
reason:aborted与error是两条不同的恢复路径。
记住: 流是有生命周期的,不是一次返回。增量事件、stop reason、半截流拒绝,这三样合起来定义了“流的终止形态”。循环要依据 stop reason 决定下一步,而不是把 aborted 当成“工具调用为空”。
源码走查
- 阅读
packages/ai/src/api/lazy.ts的createSetupErrorMessage、forwardStream、lazyStream;确认 setup 错误用零 usage 的 assistant message 结束 outer stream。 - 在
packages/ai/src/models.ts搜索lazyStream(model;分别标出stream和streamSimple的 setup 闭包,确认它们都在闭包内调用 Provider。 - 打开
packages/ai/src/api/openai-responses.ts的stream;从retryProviderRequest跟到stream.push({ type: "start",写下 start 出现在 HTTP response 的哪个阶段。 - 搜索
getCompat与buildParams;选择一个 compatibility flag,确认它被用于参数构造,而非由根Models自动决定。 - 在同一
stream的 catch 中检查partialJson删除和stopReason赋值;分别触发信号已 aborted 与普通 Error 时,推演reason的差异。
