当你写下
await client.chat.completions.create()时, 在第一个 token 到达你手中之前, 究竟发生了什么?从这一行代码出发, 有一整条由 Resource 层、Client 管道、惰性 Promise、SSE 流水线、分页与重试机制拼成的架构河流, 在后台默默流淌。
摘要
这篇文章不是入门教程, 而是一趟源码实景游。我们会沿着一条最常见的流式请求:
ts
const stream = await client.chat.completions.create({
model: 'gpt-5.4-mini',
messages: [{ role: 'user', content: '给我讲个冷笑话' }],
stream: true,
});
从这一行开始, 一路顺流而下, 依次经过 4 个关键架构层:
- Resource 层:
client.chat.completions如何绑定到统一的 HTTP 客户端 - APIPromise 层: 请求已经发出, 解析却被惰性地延后
- SSE 流水线: 原始字节如何穿过三层解码工厂, 变成易用的 AsyncIterable
- 分页与重试: 当你 for await 一个列表, 或网络抖了一下时, SDK 如何自动兜底
整篇文章会坚持几个写作原则:
- 只展示 5~15 行的小代码片段, 每个片段都配合「为什么这样设计?」和「如果换一种方式?」分析
- 大量使用 Mermaid 图展示数据流与状态机, 让你能在脑中画出完整的请求旅程
- 用真实场景数值来解释设计决策的现实收益
先放一张总览图, 把整条请求流水线串起来:
client.chat.completions.create()"] --> R["Resource 层
Chat.Completions"] R --> C[OpenAI Client
post → methodRequest → request] C --> P[APIPromise
惰性解析包装] P --> J{options.stream 为 true?} J -- 否 --> D[defaultParseResponse
JSON 或文本解析] D --> O[普通对象结果
ChatCompletion] J -- 是 --> S1[defaultParseResponse
Stream.fromSSEResponse] S1 --> S2[三层 SSE 流水线
iterSSEChunks → LineDecoder → SSEDecoder] S2 --> ST["Stream<Item>
AsyncIterable"] ST --> U2["用户消费
for await / toReadableStream"]
接下来, 我们就顺着这张图, 一站一站下水。
Chapter 1: 出发 ------ Resource 层分发请求
这一切都从一行业务代码开始:
ts
const stream = await client.chat.completions.create({
model: 'gpt-5.4-mini',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
});
在你的项目里, 这一行可能被包进了 service、controller, 甚至再上面一层的 UI 回调。但在 SDK 内部, 它会被拆成两个角色:
client是一个OpenAI客户端实例chat.completions是挂在客户端上的一个 Resource
1.1 Resource 是怎么来到你手上的?
打开客户端实现 src/client.ts, 接近文件中部的位置, 能看到有关资源实例化的代码:
ts
export class OpenAI {
// 构造函数中配置 apiKey、baseURL、超时等略
completions: API.Completions = new API.Completions(this);
chat: API.Chat = new API.Chat(this);
embeddings: API.Embeddings = new API.Embeddings(this);
files: API.Files = new API.Files(this);
images: API.Images = new API.Images(this);
audio: API.Audio = new API.Audio(this);
// ... 还有二十多个资源字段
}
每一个 Resource 被构造时, 都接收同一个 OpenAI 实例。共用客户端的抽象在 src/core/resource.ts 中定义:
ts
export abstract class APIResource {
protected _client: OpenAI;
constructor(client: OpenAI) {
this._client = client;
}
}
具体到 Chat Completions 资源 src/resources/chat/completions/completions.ts:
ts
export class Completions extends APIResource {
create(body: ChatCompletionCreateParams, options?: RequestOptions) {
return this._client.post('/chat/completions', {
body,
...options,
stream: body.stream ?? false,
});
}
}
可以看到:
- Resource 自己不关心 auth、重试、日志这些通用问题
- Resource 只负责: 路径、参数、以及一些特定的标记, 比如这里的
stream: body.stream ?? false
client.chat.completions.create 这句糖衣, 本质上就是调用 this._client.post('/chat/completions', ...) 的一层语义包装。
为什么这样设计?
关键在于「资源层随 API 规范频繁变化, 而核心管道保持长期稳定」。
- 每个 Resource 的路径、参数、描述来自 OpenAPI, 随 API 迭代经常变
- 认证、重试、流处理、分页这些核心管道, 相对稳定且通用
Stainless 代码生成器会根据 OpenAPI 规范自动生成 Resource 层 (大部分在 src/resources 下), 同时也生成稳定的核心模块 (src/core 与 src/internal)。一旦 OpenAPI 有更新, 只需要重新生成即可------变化集中在资源定义, 而核心管道的架构模式长期保持稳定。
如果换一种方式?
另一种常见做法是: 让每个 Resource 自己 new 一个 HTTP 客户端, 比如:
ts
export class Completions {
private http = new HttpClient({ baseURL: '...' });
create(...) {
return this.http.post('/chat/completions', ...);
}
}
这样虽然看似"更独立", 但会带来:
- 配置碎片化: 想改重试策略, 要在多个 HttpClient 构造处同步修改
- 功能不一致: 某个 Resource 忘记配置
timeout时, 很难在测试中第一时间暴露 - 资源浪费: 大量重复的连接配置、日志器、编码器等实例
OpenAI Node 的做法是让所有 Resource 共用同一个 OpenAI 实例, 既简化配置入口, 也强制所有资源走同一条能力管道。
1.2 调用链: create → post → methodRequest → request → makeRequest
把前面的 create 实现展开看, 调用链大致是这样:
ts
create(body: ChatCompletionCreateParams, options?: RequestOptions) {
return this._client.post('/chat/completions', {
body,
...options,
stream: body.stream ?? false,
});
}
客户端的 HTTP 方法定义在同一文件中:
ts
post<Rsp>(path: string, opts?: PromiseOrValue<RequestOptions>): APIPromise<Rsp> {
return this.methodRequest('post', path, opts);
}
private methodRequest<Rsp>(
method: HTTPMethod,
path: string,
opts?: PromiseOrValue<RequestOptions>,
): APIPromise<Rsp> {
return this.request(
Promise.resolve(opts).then((o) => ({ method, path, ...o })),
);
}
再往下是 request:
ts
request<Rsp>(
options: PromiseOrValue<FinalRequestOptions>,
remainingRetries: number | null = null,
): APIPromise<Rsp> {
return new APIPromise(this, this.makeRequest(options, remainingRetries, undefined));
}
用 Mermaid 把这条调用链画出来, 逻辑会更直观:
create({ stream: true })"] --> R1["Chat.Completions.create"] R1 --> C1[OpenAI.post] C1 --> C2[methodRequest
包装 method/path] C2 --> C3["request
返回 APIPromise"] C3 --> M["makeRequest
构建 URL/Body/Headers
执行 fetch+重试"]
注意 request 返回的是 APIPromise, 它内部持有一个 makeRequest(...) 的 Promise, 但暂时不会去解析响应体。这一层的惰性, 会在下一章详细展开。
1.3 现实世界场景: 三个 Resource 并行的隐形收益
设想一个典型后端接口, 同时需要:
- 从
models获取最新模型列表 - 从
files检查某个 file 是否处理完成 - 发起一次流式对话
业务代码可能长这样:
ts
const client = new OpenAI({
maxRetries: 4,
timeout: 30_000,
});
const [models, file, stream] = await Promise.all([
client.models.list(),
client.files.retrieve(fileId),
client.chat.completions.create({
model: 'gpt-5.4-mini',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
}),
]);
你没有关心连接池、认证头、幂等性重试、日志, 但这三条请求都自动共享了同一套配置:
- 相同的
Fetch实现 - 同步的
maxRetries和timeout - 一致的
defaultHeaders和defaultQuery
如果有一天你想把 maxRetries 从 2 调成 4, 只需要改一个地方, 所有 Resource 立刻收益。
Chapter 2: 惰性的艺术 ------ APIPromise 延迟开销
上一章我们走到了 OpenAI.request, 发现它返回的不是普通 Promise, 而是 APIPromise。这就是 SDK 做「惰性解析」的关键所在。
2.1 APIPromise 的核心构造
ts
export class APIPromise<T> extends Promise<WithRequestID<T>> {
private parsedPromise: Promise<WithRequestID<T>> | undefined;
#client: OpenAI;
constructor(
client: OpenAI,
private responsePromise: Promise<APIResponseProps>,
private parseResponse: (
client: OpenAI,
props: APIResponseProps,
) => PromiseOrValue<WithRequestID<T>> = defaultParseResponse,
) {
super((resolve) => {
resolve(null as any);
});
this.#client = client;
}
}
最微妙的一行是构造函数里的 resolve(null as any):
- 普通 Promise 通常在 executor 中立刻暴露解析逻辑
- 这里却刻意把解析逻辑藏起来, executor 做的是一个 no-op
这意味着: 构造 APIPromise 时不会立即解析响应体。
2.2 真正解析发生在 parse() 里
解析逻辑集中在私有方法 parse 中:
ts
private parse(): Promise<WithRequestID<T>> {
if (!this.parsedPromise) {
this.parsedPromise = this.responsePromise.then((props) =>
this.parseResponse(this.#client, props),
) as any as Promise<WithRequestID<T>>;
}
return this.parsedPromise;
}
APIPromise 重写了 then、catch 和 finally:
ts
override then<TResult1 = WithRequestID<T>, TResult2 = never>(
onfulfilled?: (value: WithRequestID<T>) => TResult1 | PromiseLike<TResult1>,
onrejected?: (reason: any) => TResult2 | PromiseLike<TResult2>,
): Promise<TResult1 | TResult2> {
return this.parse().then(onfulfilled, onrejected);
}
override catch<TResult = never>(
onrejected?: (reason: any) => TResult | PromiseLike<TResult>,
): Promise<WithRequestID<T> | TResult> {
return this.parse().catch(onrejected);
}
override finally(onfinally?: () => void): Promise<WithRequestID<T>> {
return this.parse().finally(onfinally);
}
也就是说:
- 你一旦
await或.then这个 APIPromise, 就会触发parse() parse()再调用defaultParseResponse来决定如何处理响应体- 解析结果会被缓存到
parsedPromise中, 避免重复解析
为什么这样设计?
原则: 请求可以早点发, 但解析应该在真正需要结果时才发生。
- 请求发送早一点没问题, 反正网络耗时是主矛盾
- 解析 JSON / 构造 Stream 是 CPU 开销, 可以推迟到消费时
- 惰性解析还能让「只想拿 Response 不想解析 body」的场景真正省下工作量
如果在构造函数里就解析呢?
假设我们这么实现:
ts
constructor(client, responsePromise, parseResponse) {
super((resolve, reject) => {
responsePromise
.then((props) => parseResponse(client, props))
.then(resolve, reject);
});
}
看起来很自然, 但会带来三个问题:
asResponse再也拿不到未消费的 body, 因为有人已经在构造阶段把它读完了- 想扩展出不同解析策略 (例如惰性流式解析、二进制直通) 会变得困难
- 所有请求一发出就开始解析, 即使调用方最终只是
asResponse或直接丢弃
把解析推迟到 parse() 里, 加上一层缓存, 是为了让同一条网络请求能以多种模式访问, 又避免重复工作。
2.3 三种访问模式: 默认 await / asResponse / withResponse
APIPromise 对调用方暴露了三种访问模式。
模式一: 默认 await, 拿解析后的对象
ts
const completion = await client.chat.completions.create({
model: 'gpt-5.4-mini',
messages: [{ role: 'user', content: 'hi' }],
});
console.log(completion.choices[0].message);
这是绝大多数人使用的模式, 看起来和普通 Promise 完全一样。但底层其实走的是 APIPromise.then → parse → defaultParseResponse 的路径。
模式二: asResponse(), 直接拿 Response
ts
const response = await client.chat.completions
.create({ model: 'gpt-5.4-mini', messages })
.asResponse();
console.log(response.status);
console.log(response.headers.get('x-request-id'));
实现非常简单:
ts
asResponse(): Promise<Response> {
return this.responsePromise.then((p) => p.response);
}
这里完全绕开了 parse(), 不会去读 body, 更不会调用 response.json()。适合你只关心状态码或 headers 的场景。
模式三: withResponse(), 同时拿数据和 Response
ts
const { data, response, request_id } = await client.chat.completions
.create({ model: 'gpt-5.4-mini', messages })
.withResponse();
console.log(data.choices[0].message);
console.log(request_id);
实现:
ts
async withResponse() {
const [data, response] = await Promise.all([
this.parse(),
this.asResponse(),
]);
return {
data,
response,
request_id: response.headers.get('x-request-id'),
};
}
利用 Promise.all 并行等待解析和响应对象, 把用户关心的三件事打包在一起:
- 解析后的数据
- 原始 Response
- X-Request-ID
2.4 _thenUnwrap: 资源层无痛改变返回形状
在资源层内部, 经常会遇到这种需求:
- 后端返回
{ data: [...], object: 'list' } - SDK 想直接返回
data数组, 而不是整个包装对象
如果用普通 Promise, 可能会写出这样代码:
ts
async list(...) {
const page = await this._client.get(...);
return page.data;
}
问题是:
- 返回值类型变成了
Promise<Item[]>, 而不是APIPromise<Item[]> - 调用方失去了
asResponse/withResponse等高级能力
APIPromise 提供了一个 _thenUnwrap 来解决这个问题:
ts
_thenUnwrap<U>(transform: (data: T, props: APIResponseProps) => U): APIPromise<U> {
return new APIPromise(this.#client, this.responsePromise, async (client, props) =>
addRequestID(transform(await this.parseResponse(client, props), props), props.response),
);
}
在资源里可以这样用 (伪代码示意):
ts
return this._client.get(...)
._thenUnwrap((body) => body.data);
好处是:
- 整条链路上保持的是 APIPromise, 调用方仍然可以使用三种访问模式
- 只有在解析阶段才进行字段投影, 不额外创建 Promise 包装
_request_id会重新挂到 transform 之后的结果上
为什么这样设计?
把「数据形状转换」当成解析过程的一部分, 而不是额外再包一层 Promise。
- 资源层保持同步返回, 类型友好
- 解析阶段统一处理 JSON 解析 + 字段投影 + request_id 注入
如果用 Axios 风格拦截器?
很多人熟悉的模式是: 在 Axios 上加 response 拦截器, 在里头统一 return response.data。这对于简单 REST 客户端是足够的, 但放到这里有两点不适配:
- 解析是 eager 的, 难以支持
asResponse这种只要 Response 不要 body 的用法 - 对于流式响应 (SSE), 解析模式完全不同, 用单一拦截器不好区分
APIPromise 把「解析策略」抽象为 parseResponse 函数, 能根据请求选项 (比如 stream 标记) 切换不同实现, 比拦截器更灵活。
2.5 WithRequestID: 在对象上挂一个隐身的 request id
defaultParseResponse 解析 JSON 后会调用 addRequestID:
ts
export function addRequestID<T>(value: T, response: Response): WithRequestID<T> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return value as WithRequestID<T>;
}
return Object.defineProperty(value, '_request_id', {
value: response.headers.get('x-request-id'),
enumerable: false,
}) as WithRequestID<T>;
}
这里有两个细节:
- 只对非数组的对象生效, 避免无意义地给数组挂属性
enumerable: false保证:JSON.stringify不会输出_request_idObject.keys也看不到它
你可以在调试时这样用:
ts
const completion = await client.chat.completions.create({ ... });
console.log(completion._request_id);
这个 id 可以直接用于和 OpenAI 支持团队对齐日志。
2.6 现实场景: 对多个供应商并发竞速时节省的那点解析
想象一个「多供应商竞速」的架构:
- 你同时对三家模型供应商发起 chat 请求
- 谁先返回就用谁的结果
- 另外两家的请求如果慢, 可以丢弃或取消
在 APIPromise 模式下, 你可以这样写:
ts
const p1 = clientA.chat.completions.create(params);
const p2 = clientB.chat.completions.create(params);
const p3 = clientC.chat.completions.create(params);
const winner = await Promise.race([p1, p2, p3]);
由于解析是惰性的:
- 只有那个被
await的 Promise 会真正走完parse() - 剩下的两个 Promise, 如果你从未访问它们的结果, 就不会进行 JSON 解析
如果这两个被丢弃的响应体都很大, 惰性解析就直接省下了两次 JSON 反序列化的 CPU 开销。
Chapter 3: 三层流水线 ------ SSE 解析的精密工厂
当你在 create 里传入 stream: true 时, 请求的下半段旅程会完全不同。
3.1 从 defaultParseResponse 走进 Stream 工厂
在 src/internal/parse.ts 中, 流式解析分支是这样写的:
ts
if (props.options.stream) {
if (props.options.__streamClass) {
return props.options.__streamClass.fromSSEResponse(
response,
props.controller,
client,
props.options.__synthesizeEventData,
) as any;
}
return Stream.fromSSEResponse(
response,
props.controller,
client,
props.options.__synthesizeEventData,
) as any;
}
这行 Stream.fromSSEResponse 就是进入 SSE 工厂的入口。
我们可以用一条具体的数据流来展示整个流水线的变换过程:
包含 data:{...}
和 data:[DONE]"] --> L1[Layer 1
iterSSEChunks
按双换行切 Byte 块] L1 --> CH1[Chunk1
包含第一条 data] L1 --> CH2["Chunk2
包含 [DONE]"] CH1 --> L2[Layer 2
LineDecoder
拆成多行字符串] CH2 --> L2 L2 --> S1["行数组
例如 data:{...}, 空行"] S1 --> L3[Layer 3
SSEDecoder
累积 event/data] L3 --> SE1["ServerSentEvent1
{event:null,data:'{...}'}"] L3 --> SE2["ServerSentEvent2
{event:null,data:'[DONE]'}"] SE1 --> F1["Stream.fromSSEResponse
JSON.parse"] SE2 --> F1 F1 --> U["Stream<Chunk>
用户 for await 消费"]
下面我们拆开三层具体看看。
3.2 Layer 1: iterSSEChunks ------ 二进制块累积
ts
async function* iterSSEChunks(iter: AsyncIterableIterator<Bytes>): AsyncGenerator<Uint8Array> {
let data = new Uint8Array();
for await (const chunk of iter) {
if (chunk == null) continue;
const binaryChunk =
chunk instanceof ArrayBuffer ? new Uint8Array(chunk)
: typeof chunk === 'string' ? encodeUTF8(chunk)
: chunk;
const merged = new Uint8Array(data.length + binaryChunk.length);
merged.set(data);
merged.set(binaryChunk, data.length);
data = merged;
let idx;
while ((idx = findDoubleNewlineIndex(data)) !== -1) {
yield data.slice(0, idx);
data = data.slice(idx);
}
}
if (data.length > 0) yield data;
}
这里有三个关键点:
- 统一把各种形态的 chunk (ArrayBuffer、字符串、Uint8Array) 转成二进制
- 每次来新 chunk, 就和旧数据拼接成一个大的缓冲区
- 用
findDoubleNewlineIndex找到\n\n、\r\r、\r\n\r\n这三种 SSE 消息边界
为什么这样设计?
先在字节层解决边界问题, 再进入文本世界。
- SSE 协议以「双换行」作为消息边界, 但换行的具体字节可能是
\n、\r, 或二者组合 - 如果直接对字符串做 split, 跨 chunk 的多字节字符会带来麻烦
- 在字节层切 chunk, 再交给后面的 LineDecoder 做细粒度换行处理, 能保证跨平台行为一致
如果直接对字符串 split?
最简单的写法可能是:
ts
const text = decoder.decode(chunk, { stream: true });
buffer += text;
const parts = buffer.split('\n\n');
这会遇到:
- UTF8 字符被拆在两个 chunk 中, 第一个 decode 失败或产生半个字符
- Windows 环境下的
\r\n需要额外处理, 稍不注意就遗留\r
多层解码方案正是为了把这些「细节噪音」隔离在底层工具函数里。
3.3 Layer 2: LineDecoder ------ 处理 \r 的暧昧
LineDecoder 的实现位于 src/internal/decoders/line.ts, 其核心的 CR/LF 处理逻辑如下:
ts
while ((idx = findNewlineIndex(this.#buffer, this.#carriageReturnIndex)) != null) {
if (idx.carriage && this.#carriageReturnIndex == null) {
this.#carriageReturnIndex = idx.index;
continue; // 可能是 \r\n 的一半,先等等看
}
const end = this.#carriageReturnIndex != null ? idx.preceding - 1 : idx.preceding;
const line = decodeUTF8(this.#buffer.subarray(0, end));
lines.push(line);
this.#buffer = this.#buffer.subarray(idx.index);
this.#carriageReturnIndex = null;
}
这里主要解决的是一个现实问题:
- 某些环境下, 换行是
\r\n - 字节边界可能把
\r和\n拆在不同 chunk - LineDecoder 通过
#carriageReturnIndex记住上一次看到的\r, 再根据下一次看到的字节决定这是一个完整的\r\n还是孤立的\r
其余的工程细节包括:
decode()方法会将新 chunk 的字节与缓冲区拼接 (concatBytes)flush()方法在流结束时注入一个虚拟换行符, 确保最后不完整的一行也能被吐出
为什么这样设计?
不把换行处理寄托在 runtime 行为上, 而是在用户态清晰实现。
- 所有平台 (Node、Deno、浏览器、Cloudflare Worker) 都统一走同一套逻辑
- UTF8 解码只能作用在完整的一行字节上, 避免半个字符导致的乱码
如果简单地 split('\n') 呢?
可以想象一个简化版本:
ts
buffer += textDecoder.decode(chunk, { stream: true });
const lines = buffer.split('\n');
这样会让:
\r\n的\r被残留在行尾, 需要到处手动 trim- 跨 chunk 的
\r\n无法识别成一个整体
LineDecoder 把这些问题集中处理掉, 让上层逻辑可以假设「世界只有干净的字符串行」。
3.4 Layer 3: SSEDecoder ------ event/data 状态机
紧接着的是 SSEDecoder, 定义在 src/core/streaming.ts:
ts
class SSEDecoder {
private data: string[];
private event: string | null;
private chunks: string[];
constructor() {
this.event = null;
this.data = [];
this.chunks = [];
}
decode(line: string) {
if (line.endsWith('\r')) line = line.slice(0, -1);
if (!line) {
if (!this.event && !this.data.length) return null;
const sse: ServerSentEvent = {
event: this.event,
data: this.data.join('\n'),
raw: this.chunks,
};
this.event = null;
this.data = [];
this.chunks = [];
return sse;
}
this.chunks.push(line);
if (line.startsWith(':')) return null;
let [field, _, value] = partition(line, ':');
if (value.startsWith(' ')) value = value.slice(1);
if (field === 'event') this.event = value;
else if (field === 'data') this.data.push(value);
return null;
}
}
符合 SSE 规范中的几个要点:
event:设置事件名, 可以跨多行 data- 多行
data:用\n拼接 - 空行表示一个事件结束, 此时产出一条
ServerSentEvent - 以
:开头的行为注释, 用于心跳保活, 不会产出事件
3.5 Stream.fromSSEResponse: AsyncIterable + AbortController
前面三层负责把原始字节变成 ServerSentEvent, 真正暴露给用户的是 Stream 类。fromSSEResponse 的核心循环体是这样的:
ts
for await (const sse of _iterSSEMessages(response, controller)) {
if (done) continue;
if (sse.data.startsWith('[DONE]')) {
done = true;
continue;
}
const data = JSON.parse(sse.data);
if (data && data.error) {
throw new APIError(undefined, data.error, undefined, response.headers);
}
yield data;
}
这段代码展示了三个关键逻辑:
[DONE]标记收到后设置done = true, 但循环不会立刻退出------后续的 SSE 事件会被continue跳过, 直到_iterSSEMessages自然结束- JSON 解析失败时打印原始数据便于调试, 然后抛出异常
- 如果 SSE 数据中包含
error字段, 立刻构造APIError抛出
其余的工程细节包括:
consumed标志保证流只能被消费一次, 避免使用者不小心二次迭代AbortController被保存下来, 一旦消费方提前结束循环 (比如break),finally中会abort底层 HTTP 请求- 较新版本中还有一条
thread.*事件分支, 用于处理 Realtime/thread 事件流, 与普通 SSE 事件并行处理
为什么这样设计?
流式请求的生命周期, 必须和消费方的控制流紧密绑定。
- 用户一旦
break掉for await, SDK 就帮你把底层连接断掉, 不浪费流量 - 如果允许多次消费, 要么缓存所有 chunk (内存炸), 要么重新请求 (语义错), 都不理想
如果允许多次消费 Stream?
从语义上讲, AsyncIterable 默认就是一次性的。要允许多次消费, 需要:
- 缓存所有迭代结果
- 每次消费时重放缓存
对于长对话或流式日志来说, 这几乎不可接受。SDK 的选择更务实: 一次性消费, 但提供 tee 来满足「一条流分两路」的需求。
3.6 tee: 一次消费, 两条出口
tee 的实现思路很巧妙:
ts
tee(): [Stream<Item>, Stream<Item>] {
const left: Array<Promise<IteratorResult<Item>>> = [];
const right: Array<Promise<IteratorResult<Item>>> = [];
const iterator = this.iterator();
const makeIter = (queue: Array<Promise<IteratorResult<Item>>>) => ({
next: () => {
if (queue.length === 0) {
const p = iterator.next();
left.push(p);
right.push(p);
}
return queue.shift()!;
},
});
return [
new Stream(() => makeIter(left), this.controller, this.#client),
new Stream(() => makeIter(right), this.controller, this.#client),
];
}
要点是:
- 真正从底层 SSE 流拉数据的只有一个 iterator
left与right队列里存放的是同一批iterator.next()返回的 Promise- 无论哪一侧先调用
next, 都会驱动底层 iterator 前进一步, 并把这个结果分发给左右两侧
这样可以保证:
- 每个 SSE chunk 只解析一次
- 左右两个消费方可以以不同速度读取, 但都能看到完整的流
3.7 toReadableStream: 一行代码桥接到 Web Streams
最后是一个非常实用的桥接方法 toReadableStream:
ts
toReadableStream(): ReadableStream {
const self = this;
let iter: AsyncIterator<Item>;
return makeReadableStream({
async start() {
iter = self[Symbol.asyncIterator]();
},
async pull(ctrl: any) {
try {
const { value, done } = await iter.next();
if (done) return ctrl.close();
const bytes = encodeUTF8(JSON.stringify(value) + '\n');
ctrl.enqueue(bytes);
} catch (err) {
ctrl.error(err);
}
},
async cancel() {
await iter.return?.();
},
});
}
它把 Stream<Item> 转成了按 NDJSON 输出的 ReadableStream:
- 每个 Item 被
JSON.stringify后追加一个换行 - 适合直接作为 HTTP 响应体, 在 Express、Next.js、Cloudflare Worker 中返回
现实场景: 在 Next.js 中转发流式响应
例如, 你可以这样在 Next.js 的 route handler 中使用:
ts
export async function GET() {
const stream = await client.chat.completions.create({
model: 'gpt-5.4-mini',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
});
return new Response(stream.toReadableStream(), {
headers: {
'Content-Type': 'application/x-ndjson',
},
});
}
前端再用标准的 NDJSON 流处理即可, 完全不需要关心 SDK 内部的 SSE 解码细节。
Chapter 4: 无限翻页 ------ AsyncIterable 驱动的分页机器
把视角从流式对话切换到列表请求, 例如:
ts
for await (const model of client.models.list()) {
console.log(model.id);
}
这看起来只是一个 for await 循环, 但背后其实是一台维持游标、自动翻页的状态机。
4.1 AbstractPage: 把分页抽象成可迭代对象
分页抽象定义在 src/core/pagination.ts:
ts
export abstract class AbstractPage<Item> implements AsyncIterable<Item> {
#client: OpenAI;
protected options: FinalRequestOptions;
protected response: Response;
protected body: unknown;
constructor(client: OpenAI, res: Response, body: unknown, options: FinalRequestOptions) {
this.#client = client;
this.options = options;
this.response = res;
this.body = body;
}
abstract nextPageRequestOptions(): PageRequestOptions | null;
abstract getPaginatedItems(): Item[];
hasNextPage(): boolean {
const items = this.getPaginatedItems();
if (!items.length) return false;
return this.nextPageRequestOptions() != null;
}
async getNextPage(): Promise<this> {
const next = this.nextPageRequestOptions();
if (!next) throw new OpenAIError('No next page expected; check `.hasNextPage()` first.');
return await this.#client.requestAPIList(this.constructor as any, next);
}
async *iterPages(): AsyncGenerator<this> {
let page: this = this;
yield page;
while (page.hasNextPage()) {
page = await page.getNextPage();
yield page;
}
}
async *[Symbol.asyncIterator](): AsyncGenerator<Item> {
for await (const page of this.iterPages()) {
for (const item of page.getPaginatedItems()) {
yield item;
}
}
}
}
可以看到, 一个 Page 对象同时扮演三种角色:
- 单页数据容器 (
getPaginatedItems) - 有下一页与否的状态机 (
hasNextPage与getNextPage) - 实现了 AsyncIterable, 可以直接
for await (const item of page)
4.2 PagePromise: 既是 Promise, 又是 AsyncIterable
当你调用 client.models.list() 时, 返回的是 PagePromise:
ts
export class PagePromise<
PageClass extends AbstractPage<Item>,
Item = ReturnType<PageClass['getPaginatedItems']>[number],
> extends APIPromise<PageClass> implements AsyncIterable<Item> {
constructor(
client: OpenAI,
request: Promise<APIResponseProps>,
Page: new (...args: ConstructorParameters<typeof AbstractPage>) => PageClass,
) {
super(
client,
request,
async (c, props) =>
new Page(
c,
props.response,
await defaultParseResponse(c, props),
props.options,
) as WithRequestID<PageClass>,
);
}
async *[Symbol.asyncIterator](): AsyncGenerator<Item> {
const page = await this;
for await (const item of page) {
yield item;
}
}
}
这意味着你有两种使用方式:
-
当普通 Promise 用:
tsconst firstPage = await client.models.list(); console.log(firstPage.data.length); -
当 AsyncIterable 用, 自动分页:
tsfor await (const model of client.models.list()) { console.log(model.id); }
为什么这样设计?
同一个 API 调用, 根据你用的是 await 还是 for await, 暴露不同层次的抽象。
- 如果你只关心第一页,
await就够了 - 如果你要遍历所有记录, 直接
for await, 不需要手动写翻页循环
4.3 CursorPage: 用最后一条记录的 id 做游标
Cursor 分页的实现核心如下:
ts
export class CursorPage<Item extends { id: string }> extends AbstractPage<Item> {
data: Array<Item>;
has_more: boolean;
override hasNextPage(): boolean {
if (this.has_more === false) return false;
return super.hasNextPage();
}
nextPageRequestOptions(): PageRequestOptions | null {
const items = this.getPaginatedItems();
const lastId = items[items.length - 1]?.id;
if (!lastId) return null;
return {
...this.options,
query: { ...maybeObj(this.options.query), after: lastId },
};
}
}
这里的关键逻辑是:
has_more直接来自后端响应, 无需客户端猜测nextPageRequestOptions()用最后一个 item 的id作为after游标, 构造下一页请求
其余的工程细节包括构造函数中的 body.data 和 body.has_more 初始化, 以及 getPaginatedItems() 方法返回 this.data。
配合 Chat Completions 的 list 接口:
ts
list(query: ChatCompletionListParams = {}, options?: RequestOptions) {
return this._client.getAPIList(
'/chat/completions',
CursorPage<ChatCompletion>,
{ query, ...options },
);
}
用 Mermaid 把自动分页过程画出来:
得到 Page1] P1 --> I1[遍历 Page1.getPaginatedItems] I1 --> N1{Page1.hasNextPage?} N1 -- 是 --> G2[Page1.getNextPage
after=最后一个 id] G2 --> P2[Page2] P2 --> I2[遍历 Page2.items] I2 --> N2{Page2.hasNextPage?} N2 -- 否 --> E[结束]
为什么这样设计?
把「游标逻辑」封装在 Page 对象里, 调用方只看到一个统一的 AsyncIterable。
- CursorPage 知道自己是 cursor 风格的分页, 所以用最后一个 item 的 id 拼装 next 请求
- 对调用方来说, 无论是 Page 还是 CursorPage, 统一通过
for await访问
如果用 offset-based 分页?
传统分页 often 写成 page=2&limit=50, 在动态数据场景下容易出问题:
- 列表前部插入或删除元素时, 后续页的数据会变, 造成重复或跳过
- 需要额外维护
total才能知道有没有下一页
cursor 模式通过 after=lastId 来推进游标, 对于经常插入新记录的系统 (比如对话消息、事件流) 会更鲁棒。
4.4 现实场景: 遍历 1 万条任务的常量内存
想象你有一个后台任务队列, 里面有 1 万条微调任务, 你想统计成功率:
ts
let success = 0;
for await (const job of client.fineTuning.jobs.list()) {
if (job.status === 'succeeded') success++;
}
console.log('成功任务数:', success);
得益于 PagePromise + CursorPage 的设计:
- 任意时刻内存中最多只持有一页数据
- 不会把 1 万条任务一次性加载, 内存占用大致是 O(页大小)
这和单纯提供 list(page, limit) 完全不同, 后者很难自然推导到 AsyncIterable 这种写法。
Chapter 5: 安全网 ------ 重试、错误与韧性
到目前为止, 我们一直在看「一切顺利」时的 happy path。现实世界里, 网络抖动、服务端负载、限流都是家常便饭。OpenAI Node 在 Client 层内置了一套相对完善的重试和错误分类机制。
5.1 shouldRetry: 谁说了算?
在 src/client.ts 中:
ts
private async shouldRetry(response: Response): Promise<boolean> {
const h = response.headers.get('x-should-retry');
if (h === 'true') return true;
if (h === 'false') return false;
if (response.status === 408) return true;
if (response.status === 409) return true;
if (response.status === 429) return true;
if (response.status >= 500) return true;
return false;
}
优先级顺序:
x-should-retry头显式指定, 权重最高- 如果没有该头, 再根据状态码做启发式判断
我们可以画一张简化决策图:
或 Timeout] N -- 是 --> R[retryRequest] C -- 否 --> H[读取状态码和头] H --> X{x-should-retry} X -- true --> R X -- false --> E2[不重试
直接抛错] X -- 无 --> S{status 在
408 409 429 5xx?} S -- 是 --> R S -- 否 --> E2
5.2 retryRequest: Retry-After 与指数退避
retryRequest 的核心决策逻辑如下:
ts
// 优先级 1: 服务端明确指定毫秒级等待
const raf = headers?.get('retry-after-ms');
if (raf) timeoutMillis = parseFloat(raf);
// 优先级 2: 标准 HTTP retry-after 头 (秒或日期)
const ra = headers?.get('retry-after');
if (ra && !timeoutMillis) {
timeoutMillis = parseFloat(ra) * 1000 || Date.parse(ra) - Date.now();
}
// 优先级 3: 客户端指数退避策略
if (timeoutMillis === undefined) {
timeoutMillis = this.calculateDefaultRetryTimeoutMillis(retriesRemaining, maxRetries);
}
await sleep(timeoutMillis);
这段代码展示了一个清晰的优先级链:
- 服务端精准指令 :
retry-after-ms(毫秒级) 最优先, OpenAI 特有的头 - 标准 HTTP 头 :
retry-after可能是秒数或 RFC 2822 日期 - 客户端自适应退避: 没有任何提示时, 用指数退避算法计算
默认退避算法是「0.5 秒起步、指数扩张、上限 8 秒、再乘一个 75% 到 100% 之间的随机 jitter」。具体实现是机械的数学计算, 这里就不展开了。
为什么这样设计?
三个来源: 服务端建议、标准 HTTP 头、以及客户端自身的退避策略。
- 如果服务端已经通过
retry-after-ms或retry-after告诉你具体等待时间, 优先遵守 - 否则使用指数退避, 平衡重试速度与后端压力
- 通过 jitter 分散并发客户端的重试时间, 避免集中重试导致二次雪崩
如果不用 jitter?
假设你有 1 千个实例, 都在同一时刻收到 500 错, 且都使用 2^n 秒的固定退避:
- 第一次重试时, 全部客户端在 0.5 秒处打满后端
- 第二次在 1 秒处, 第三次在 2 秒处, 都会形成剧烈峰值
jitter 的目的就是把这些峰值拉平, 让重试分布在一个时间窗口里。
5.3 错误类型: 让 instanceof 成为生产级工具
当重试次数耗尽时, makeStatusError 会创建一个类型化错误:
ts
protected makeStatusError(
status: number,
error: Object,
message: string | undefined,
headers: Headers,
): Errors.APIError {
return Errors.APIError.generate(status, error, message, headers);
}
在 error 模块中, 不同状态码会映射到不同子类, 比如:
BadRequestError(400)AuthenticationError(401)PermissionDeniedError(403)NotFoundError(404)ConflictError(409)RateLimitError(429)InternalServerError(5xx)
客户端把这些错误类型挂在 OpenAI 上, 方便调用方做类型判断:
ts
static RateLimitError = Errors.RateLimitError;
static AuthenticationError = Errors.AuthenticationError;
static APIConnectionError = Errors.APIConnectionError;
// 还有其他几种
所以你可以这样写错误处理逻辑:
ts
try {
const completion = await client.chat.completions.create({ ... });
} catch (err) {
if (err instanceof OpenAI.RateLimitError) {
// 提示用户稍后重试, 或降级到本地模型
} else if (err instanceof OpenAI.AuthenticationError) {
// 检查 apiKey 配置
} else if (err instanceof OpenAI.APIConnectionError) {
// 提示网络问题
} else {
// 日志和兜底
}
}
现实场景: 一个带 retry-after-ms 的 429 与一个普通 500
- 对于 429, 如果响应头里有
retry-after-ms: 1200, SDK 会:- 按 1.2 秒等待重试
- 若多次重试后仍失败, 最终抛出
RateLimitError
- 对于一个没有 Retry-After 的 500:
- 使用指数退避 + jitter 重试若干次
- 最终抛出
InternalServerError
对调用方来说, 唯一需要关心的是:
- 错误类型 (rate limit、auth、网络、内部错误)
- 是否要对某些错误做特别处理 (重试、降级、提示)
Closing: 旅途的收获
我们沿着一条看似简单的请求, 从 client.chat.completions.create 一路向下, 走过了 Resource、APIPromise、SSE 流水线、分页与重试几个关键站点。最后, 把这些设计沉淀成几条可以迁移到其它项目的模式。
1. 惰性求值换取灵活性
- APIPromise 把网络请求与解析过程拆开, 构造时只关心请求何时发出
- 解析放在
parse中, 由then/catch/finally驱动 - 三种访问模式 (默认 await、asResponse、withResponse) 在同一条请求上并存
这种模式非常适合任何需要「既要 raw, 又要 parsed, 还要 meta」的 SDK 或网关层。你可以在自己的项目里模仿:
- 把 HTTP 请求 Promise 包在一个自定义 Promise 子类里
- 为它加上
asResponse、withMeta等方法 - 在内部通过惰性解析确保只在需要时做重活
2. 分层流处理换取可组合性
SSE 流水线背后的思想是「把 IO 噪音压缩到最低层」:
- Layer 1 只关心字节边界
- Layer 2 只关心换行与字符编码
- Layer 3 只关心 SSE 协议的字段语义
每一层的输入输出都尽量简单 (Uint8Array、string、ServerSentEvent), 这样你可以在其它项目中选择性复用某几层, 甚至替换某一层的实现而不影响整体结构。
同样的思想也可以用在:
- WebSocket 消息流
- 日志采集管道
- 任意需要从二进制流转成高层事件的场景
3. AsyncIterable 作为通用数据协议
本文出现了三种异步迭代对象:
Stream<Item>: SSE 解码后的流AbstractPage<Item>: 单页容器, 同时也是 AsyncIterablePagePromise<Page, Item>: 既是 Promise, 又是 AsyncIterable
把这些都统一到 AsyncIterable 的接口上, 带来了几个好处:
- 可以用
for await写出既直观又高效的代码 - 可以方便地写出通用工具函数, 接收任何 AsyncIterable
- 可以很容易和 Node/Web Streams 互相转换
在你自己的系统里, 也可以尝试用 AsyncIterable 作为「数据协议」, 而不是每次都用数组或者回调式 API。
4. 渐进式错误恢复
最后一条模式来自重试与错误体系:
- 首先遵守服务端的显式指令 (
x-should-retry,retry-after-ms) - 其次根据状态码做行业经验性的判断
- 再不行才使用通用的指数退避策略
同时, 用类型化错误 (RateLimitError, AuthenticationError 等) 让调用方可以用 instanceof 做明确分流, 而不是解析模糊的字符串。
这种「渐进式错误恢复」策略同样适用于:
- 调用内部多个微服务时的网关层
- 访问第三方支付、短信、存储服务的客户端库
下一步: 亲自追踪你自己的那条请求
本文只能算一次导览式的「架构游园」。OpenAI Node SDK 里还有很多同样有趣的部分, 比如:
- Azure 适配层: 如何在不改核心 Client 的前提下, 适配另一套 baseURL 与认证方式
- Realtime WebSocket: 相比 SSE, 双向流量又是怎样被抽象的
- 构建系统与多运行时支持: 如何同时支持 Node、Deno、Cloudflare、浏览器直引
如果你对这些感兴趣, 不妨从项目根目录开始, 亲自在源码中 trace 一下你自己的那条请求。
下次当你再写下
await client.chat.completions.create()时, 希望你脑海里浮现的不只是一个黑盒 API 调用, 而是一条结构清晰、层次分明的请求河流: 从 Resource 出发, 穿过 APIPromise 的惰性世界, 在 SSE 流水线中被精密加工, 再经过分页与重试的安全网, 最终以一个个 token 的形式, 抵达你的应用前端。