深入 OpenAI Node SDK:一个请求的奇幻漂流

当你写下 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 个关键架构层:

  1. Resource 层: client.chat.completions 如何绑定到统一的 HTTP 客户端
  2. APIPromise 层: 请求已经发出, 解析却被惰性地延后
  3. SSE 流水线: 原始字节如何穿过三层解码工厂, 变成易用的 AsyncIterable
  4. 分页与重试: 当你 for await 一个列表, 或网络抖了一下时, SDK 如何自动兜底

整篇文章会坚持几个写作原则:

  • 只展示 5~15 行的小代码片段, 每个片段都配合「为什么这样设计?」和「如果换一种方式?」分析
  • 大量使用 Mermaid 图展示数据流与状态机, 让你能在脑中画出完整的请求旅程
  • 用真实场景数值来解释设计决策的现实收益

先放一张总览图, 把整条请求流水线串起来:

graph TD U["用户代码
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/coresrc/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 把这条调用链画出来, 逻辑会更直观:

graph LR U["用户
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 实现
  • 同步的 maxRetriestimeout
  • 一致的 defaultHeadersdefaultQuery

如果有一天你想把 maxRetries 从 2 调成 4, 只需要改一个地方, 所有 Resource 立刻收益。


Chapter 2: 惰性的艺术 ------ APIPromise 延迟开销

上一章我们走到了 OpenAI.request, 发现它返回的不是普通 Promise, 而是 APIPromise。这就是 SDK 做「惰性解析」的关键所在。

2.1 APIPromise 的核心构造

打开 src/core/api-promise.ts:

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 重写了 thencatchfinally:

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);
  });
}

看起来很自然, 但会带来三个问题:

  1. asResponse 再也拿不到未消费的 body, 因为有人已经在构造阶段把它读完了
  2. 想扩展出不同解析策略 (例如惰性流式解析、二进制直通) 会变得困难
  3. 所有请求一发出就开始解析, 即使调用方最终只是 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 客户端是足够的, 但放到这里有两点不适配:

  1. 解析是 eager 的, 难以支持 asResponse 这种只要 Response 不要 body 的用法
  2. 对于流式响应 (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_id
    • Object.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 工厂的入口。

我们可以用一条具体的数据流来展示整个流水线的变换过程:

graph TB B0["原始字节
包含 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 ------ 二进制块累积

src/core/streaming.ts 中:

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;
}

这里有三个关键点:

  1. 统一把各种形态的 chunk (ArrayBuffer、字符串、Uint8Array) 转成二进制
  2. 每次来新 chunk, 就和旧数据拼接成一个大的缓冲区
  3. 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;
}

这段代码展示了三个关键逻辑:

  1. [DONE] 标记收到后设置 done = true, 但循环不会立刻退出------后续的 SSE 事件会被 continue 跳过, 直到 _iterSSEMessages 自然结束
  2. JSON 解析失败时打印原始数据便于调试, 然后抛出异常
  3. 如果 SSE 数据中包含 error 字段, 立刻构造 APIError 抛出

其余的工程细节包括:

  • consumed 标志保证流只能被消费一次, 避免使用者不小心二次迭代
  • AbortController 被保存下来, 一旦消费方提前结束循环 (比如 break), finally 中会 abort 底层 HTTP 请求
  • 较新版本中还有一条 thread.* 事件分支, 用于处理 Realtime/thread 事件流, 与普通 SSE 事件并行处理

为什么这样设计?

流式请求的生命周期, 必须和消费方的控制流紧密绑定。

  • 用户一旦 breakfor 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
  • leftright 队列里存放的是同一批 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 对象同时扮演三种角色:

  1. 单页数据容器 (getPaginatedItems)
  2. 有下一页与否的状态机 (hasNextPagegetNextPage)
  3. 实现了 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 用:

    ts 复制代码
    const firstPage = await client.models.list();
    console.log(firstPage.data.length);
  • 当 AsyncIterable 用, 自动分页:

    ts 复制代码
    for 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.databody.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 把自动分页过程画出来:

graph TB S["for await (item of client.models.list())"] --> PP[PagePromise] PP --> P1[await PagePromise
得到 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;
}

优先级顺序:

  1. x-should-retry 头显式指定, 权重最高
  2. 如果没有该头, 再根据状态码做启发式判断

我们可以画一张简化决策图:

flowchart TB A[收到 Response 或连接错误] --> C{是网络错误?} C -- 是 --> N{还有重试次数?} N -- 否 --> E1[抛出 APIConnectionError
或 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);

这段代码展示了一个清晰的优先级链:

  1. 服务端精准指令 : retry-after-ms (毫秒级) 最优先, OpenAI 特有的头
  2. 标准 HTTP 头 : retry-after 可能是秒数或 RFC 2822 日期
  3. 客户端自适应退避: 没有任何提示时, 用指数退避算法计算

默认退避算法是「0.5 秒起步、指数扩张、上限 8 秒、再乘一个 75% 到 100% 之间的随机 jitter」。具体实现是机械的数学计算, 这里就不展开了。

为什么这样设计?

三个来源: 服务端建议、标准 HTTP 头、以及客户端自身的退避策略。

  • 如果服务端已经通过 retry-after-msretry-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 子类里
  • 为它加上 asResponsewithMeta 等方法
  • 在内部通过惰性解析确保只在需要时做重活

2. 分层流处理换取可组合性

SSE 流水线背后的思想是「把 IO 噪音压缩到最低层」:

  • Layer 1 只关心字节边界
  • Layer 2 只关心换行与字符编码
  • Layer 3 只关心 SSE 协议的字段语义

每一层的输入输出都尽量简单 (Uint8Array、string、ServerSentEvent), 这样你可以在其它项目中选择性复用某几层, 甚至替换某一层的实现而不影响整体结构。

同样的思想也可以用在:

  • WebSocket 消息流
  • 日志采集管道
  • 任意需要从二进制流转成高层事件的场景

3. AsyncIterable 作为通用数据协议

本文出现了三种异步迭代对象:

  • Stream<Item>: SSE 解码后的流
  • AbstractPage<Item>: 单页容器, 同时也是 AsyncIterable
  • PagePromise<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 的形式, 抵达你的应用前端。

相关推荐
liliwoliliwo1 小时前
yolo3 点
人工智能·深度学习
子兮曰1 小时前
AI写代码坑了90%程序员!这5个致命bug,上线就炸(附避坑清单)
前端·javascript·后端
lifallen1 小时前
从零推导 Deep Agent 模式
人工智能·语言模型
XMAIPC_Robot2 小时前
基于RK3588 ARM+FPGA的电火花数控系统设计与测试(三)
运维·arm开发·人工智能·fpga开发·边缘计算
前端架构师2 小时前
我不是狐狸,我是那Harness Engineering
人工智能
俞凡2 小时前
CLAUDE.md 完全指南
人工智能
码路高手2 小时前
Trae-Agent中的设计模式应用
人工智能·架构
百慕大三角2 小时前
pi-mono sdk中文文档
人工智能·ai编程
终端鹿2 小时前
Vue3 核心 API 补充解析:toRef / toRefs / unref / isRef
前端·javascript·vue.js