# 模型接口与可靠调用

## 从“请求能通”到“可以依赖”

一个演示只要请求成功就能展示文字；一个产品还要解释请求为何失败、能不能重试、取消后是否仍在计费，以及上游返回成功时任务是否真的完成。本章用于搭建模型服务的最小可靠边界。达标后，你应能写出服务端调用封装，向前端暴露稳定的业务状态，而不是把供应商的所有字段直接透传。

前置是 Node 异步函数、HTTP 状态码和环境变量。此处选择 OpenAI 官方 `POST https://api.openai.com/v1/responses`，请求体字段按该端点理解。某厂商宣称“兼容 OpenAI”只意味着实现了某部分兼容行为，不能据此假设它支持相同事件、状态和错误语义。

## 请求和响应要按契约阅读

请求头包含 Bearer 授权和 JSON 内容类型。密钥只由服务端持有，浏览器只能调用你自己的后端。前端构建变量通常会被打包进静态资源，名字带 SECRET 并不构成保密措施。日志也不应该打印授权头、完整用户资料或未经脱敏的模型上下文。

本章用到的请求字段是 model、instructions、input、max_output_tokens 和 store。model 明确选择有访问权限的型号；instructions 放本次应用规则；input 是输入文本或输入项集合；max_output_tokens 限制生成预算；store 控制该响应是否为后续访问而保存，并不应被解释为对所有数据处理环节的全面承诺。具体数据处理要求需要另查对应条款。

响应里的 output 是项目数组，不能假设第一项一定是最终文本。它可能包含消息、工具调用或其他项目；消息内容也可能包含不同类型。SDK 提供的 output_text 是便利访问方式；使用原始 HTTP 时应遍历 output 中的 message，再读取 content 中的 output_text。还要先处理 status、error、incomplete_details。HTTP 200 只表示传输层收到了响应，并不保证生成完整。[Responses 接口参考](https://developers.openai.com/api/reference/typescript/resources/responses/methods/create)

## 错误分类决定下一步

四百类错误不能一律重试。参数不受支持、格式不合法或上下文过长，需要修改输入；密钥无效或权限不足，需要修复配置。四百二十九既可能代表速率限制，也可能涉及额度，应检查错误体，不能用无限重试掩盖账号问题。五百类或短暂网络故障才通常适合有限次数退避。[官方错误说明](https://developers.openai.com/api/docs/guides/error-codes)

重试还会消耗时间和可能的费用。连接断开时，客户端未拿到结果不等于服务端未执行；对于普通生成，可以接受有限的重复成本，但不能把同样策略直接套在转账或提交订单上。总时限、尝试次数和单次超时需要同时受控。用户主动取消通常应立即终止，而不是换一条连接继续重试。

## 完整示例：默认离线，显式开启真实接入

环境：Node.js 22；文件名 `model-http.mjs`；不安装依赖。执行 `node model-http.mjs` 只运行本地 fixture。真实调用分支保留完整接口代码，但本课程不执行；只有你自行设置 `RUN_LIVE=1`、`OPENAI_API_KEY`、`OPENAI_MODEL` 后才会访问付费服务。

```js
import assert from 'node:assert/strict';
import { setTimeout as sleep } from 'node:timers/promises';

let mockAttempts = 0;
const live = process.env.RUN_LIVE === '1';

// fixture 只模拟状态与结构，不具备生成能力。
async function transport(url, options) {
  if (live) return fetch(url, options);
  mockAttempts += 1;
  if (mockAttempts === 1) {
    // 代理可能返回 HTML 错误页；不能要求失败响应一定是 JSON。
    return new Response('<html><body>Service Unavailable</body></html>', {
      status: 503, headers: { 'Content-Type': 'text/html' },
    });
  }
  return new Response(JSON.stringify({
    id: 'resp_fixture',
    status: 'completed',
    output: [{ type: 'message', content: [
      { type: 'output_text', text: '工单摘要：用户无法收到验证码。' },
    ] }],
    usage: { input_tokens: 20, output_tokens: 12 },
  }), { status: 200, headers: { 'x-request-id': 'fixture-2' } });
}

async function summarize(input) {
  if (live && (!process.env.OPENAI_API_KEY || !process.env.OPENAI_MODEL)) {
    throw new Error('真实调用需要密钥和已确认可用的模型名');
  }
  const started = Date.now();
  const totalMs = 12000;
  for (let attempt = 0; attempt < 3; attempt += 1) {
    const remaining = totalMs - (Date.now() - started);
    if (remaining <= 0) throw new Error('超过总时限');
    let response;
    try {
      response = await transport('https://api.openai.com/v1/responses', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          ...(live ? { Authorization: 'Bearer ' + process.env.OPENAI_API_KEY } : {}),
        },
        signal: AbortSignal.timeout(Math.min(4000, remaining)),
        body: JSON.stringify({
          model: live ? process.env.OPENAI_MODEL : 'fixture-only',
          instructions: '将工单概括为一句中文，不推测原因。',
          input,
          max_output_tokens: 512,
          store: false,
        }),
      });
    } catch (error) {
      // 此教学封装无用户取消入口；连接错误和单次超时有限重试。
      if (attempt === 2) throw error;
      await sleep(live ? 200 * 2 ** attempt + Math.random() * 100 : 1);
      continue;
    }
    // 先按 HTTP 状态决定是否重试，HTML 错误页不进入成功响应解析。
    const transient = [500, 502, 503, 504].includes(response.status);
    if (transient && attempt < 2) {
      // 不需要这个错误体，释放流后再退避；取消流失败不改变重试分类。
      try { await response.body?.cancel(); } catch {}
      await sleep(live ? 200 * 2 ** attempt + Math.random() * 100 : 1);
      continue;
    }
    if (!response.ok) {
      // 最终错误可能是 JSON、HTML、空内容或已中断的流，保留 HTTP 状态。
      let errorCode = 'unknown';
      try {
        const errorBody = await response.json();
        if (typeof errorBody?.error?.code === 'string') errorCode = errorBody.error.code;
      } catch {}
      throw new Error('上游错误 ' + response.status + ' / ' + errorCode);
    }
    let body;
    try { body = await response.json(); }
    catch { throw new Error('上游响应无效：成功状态未返回可解析的 JSON'); }
    if (!body || typeof body !== 'object' || Array.isArray(body)) {
      throw new Error('上游响应无效：JSON 根节点必须是对象');
    }
    if (body.status !== 'completed') {
      throw new Error('生成未完成：' + (body.incomplete_details?.reason ?? body.status));
    }
    if (!Array.isArray(body.output)) throw new Error('上游响应无效：缺少 output 数组');
    const text = body.output
      .filter(item => item.type === 'message')
      .flatMap(item => item.content ?? [])
      .filter(part => part.type === 'output_text')
      .map(part => part.text).join('');
    if (!text.trim()) throw new Error('响应没有可用文本，需检查拒绝或其他输出类型');
    return { text, requestId: response.headers.get('x-request-id'), usage: body.usage };
  }
  throw new Error('重试已耗尽');
}

const result = await summarize('我已经点了三次发送，手机还是没有验证码。');
if (!live) assert.equal(mockAttempts, 2, 'HTML 503 后应该重试一次并成功');
console.log(result.text);
console.log('requestId=' + result.requestId);
```

预期输出为一行工单摘要和 `requestId=fixture-2`。第一次模拟调用收到 HTML 格式的五百零三错误页，第二次收到 JSON 成功响应；离线断言要求恰好调用两次。把所有响应改为 HTML 五百零三，第三次后应得到保留 HTTP 状态的“上游错误 503 / unknown”，而不是 JSON 解析异常；把第一次改为 HTML 四百零一，应立即停止。成功状态如果返回 HTML，会得到明确的无效响应错误。修改成功 fixture 的 status 为 incomplete，会观察到“生成未完成”错误；删除文本项目则触发空输出检查。

代码第一段将传输依赖集中在一个函数中，因此业务解析逻辑在离线与真实模式下相同。循环限制总时长与尝试次数；HTTP 临时错误先按状态分类并释放响应体，再进入退避，连接异常走单独分支。不可重试或次数耗尽的错误，读取错误体失败时仍保留 HTTP 状态；只有成功响应才必须通过 JSON 解析和对象检查。解析部分不使用数组位置猜测类型。最后返回稳定的小对象，使 UI 不依赖上游复杂结构。该示例的传输超时覆盖 fetch；生产还应对大响应体读取设置独立大小限制与读取时限，并为取消信号建立传递链。

这里的 summarize 是专门返回文本摘要的业务包装器，不接受 tools，也不会保留完整 output；它不能直接承担工具循环的模型适配接口。通用接口应另定义 createResponse({input, tools})，保留完整供应商响应，再由摘要包装器按需提取文本。工具调用章采用这个独立边界，并允许只有工具调用项而没有最终文本的中间响应。

## 进一步理解可靠性边界

真实系统应把错误变成自有错误码，例如 MODEL_TIMEOUT、MODEL_RATE_LIMITED、MODEL_BAD_RESPONSE。前端根据错误类别提供恢复入口：超时允许重新生成，权限错误引导重新登录自己的业务系统，账号配置错误提示联系维护人员。不能把供应商堆栈原样展示给用户。

幂等性和追踪号也不能混淆。requestId 帮助定位某次调用；业务 operationId 标识用户的一次操作。为了重试而重新生成 requestId 很正常，但同一次下单操作必须维持相同业务标识。需要在日志中关联用户请求、模型尝试和工具执行，同时只记录定位问题所需的脱敏内容。

本章没有实现流式输出，因为先理解完整响应更容易区分错误。流式场景中，首字出现后仍可能失败。界面需要同时拥有 partial、completed、failed、cancelled 等状态，不能因已经渲染几行文本就标记成功。代理层还需要正确转发取消，避免用户关掉页面后后台继续生成。

超时大小应由产品目标和实测决定。总结一条短工单与分析大型文档具有不同耗时分布；用统一的两秒超时可能造成大量无效重试。记录首字延迟、总耗时、输入输出用量、状态分类与重试次数，比记录一条“接口慢”更有用。

## 深入请求生命周期：同一次失败发生在不同层

把调用画成时间轴时，至少分出排队、建立连接、收到响应头、读取响应体、解析协议、业务校验六段。fetch 返回 Response 时通常意味着响应头可用，响应体仍可能在网络上。因而“fetch 已成功”之后的 response.json 还可能等待、断流或解析失败。这里的因果关系很重要：收到头部后的连接错误不再是“完全没收到响应”，但仍不能证明模型生成是否已经计费或完成。

反向代理还有自己的行为。上游模型服务没有及时响应时，代理可能生成 HTML 五百零三页面；身份网关可能返回登录页；网络设备可能在成功状态里插入错误说明。HTTP 状态是第一层分类信息，内容类型是提示，实际解析结果是另一层证据。不能因为请求带了 Accept: application/json，就相信每条失败路径都返回 JSON。前面的修正正是先处理可重试状态，再容错读取最终错误体。

错误记录应让维护者区分这几段，但不需要把原始页面完整发给用户。可以记录 status、contentType、requestId、attempt、elapsedMs 和自有 errorCode。若确实需要保存错误摘要，应限制长度、脱敏并控制访问；直接把整个响应体写进通用日志，可能保存登录表单、用户输入或代理诊断信息。用户界面只显示稳定的恢复方式，详细诊断留给有权限的运维入口。

## 两个超时与一个取消信号

单次超时限制一次尝试占用资源的时间；总截止时间限制一次用户操作连同排队和重试能持续多久。假设每次允许四秒、最多三次，并不意味着总耗时严格为十二秒，因为中间还要退避和处理响应。如果用户只愿意等十秒，应在每轮开始时计算剩余额度，并把它传给连接与响应体读取，而不是每次都重新获得完整四秒。

用户取消与系统超时虽然都可以通过 AbortSignal 表达，业务含义却不同。超时可能允许提示重试，用户取消应尽快结束当前任务，不能自动启动下一次尝试。为了避免把两者混淆，可以保存取消原因，并在 catch 中先检查用户信号，再判断是否属于允许重试的传输故障。AbortSignal.any 合并信号时，只负责把最先中止的信号传播出去，不替你决定错误文案和重试策略。

Promise.race 也不能保证底层请求停止。它只是让外层先得到一个结果；若超时分支胜出，但网络任务没有收到取消信号，任务仍可能继续占连接、消耗费用或最终完成。真正可取消的实现需要底层操作接受 signal，并在读取流时响应它。CPU 密集循环不主动检查信号也不会被打断，这类任务要考虑分段让出执行或独立工作线程。

## 示例二：把完整响应适配器与摘要包装分开

保存为 `response-adapter.mjs`，Node.js 22.22+，无需安装，执行 `node response-adapter.mjs`。本例的 transport 是注入的离线函数；URL 与请求字段对应 OpenAI Responses，但代码没有调用 fetch。它示范完整响应如何被两种业务消费：工具循环需要 output 原貌，摘要业务需要从中提取文本。响应读取上限默认为六万四千字节，是本例的应用限制，不是供应商默认值。

```js response-adapter.mjs
import assert from 'node:assert/strict';

async function readJsonLimited(response, maxBytes = 64000) {
  if (!response.body) throw new Error('EMPTY_BODY');
  const reader = response.body.getReader();
  const parts = [];
  let bytes = 0;
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      bytes += value.byteLength;
      if (bytes > maxBytes) {
        await reader.cancel();
        throw new Error('BODY_TOO_LARGE');
      }
      parts.push(value);
    }
  } finally {
    reader.releaseLock(); // 无论成功或失败都释放本地读锁。
  }
  const joined = new Uint8Array(bytes);
  let offset = 0;
  for (const part of parts) { joined.set(part, offset); offset += part.byteLength; }
  try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(joined)); }
  catch { throw new Error('INVALID_JSON_BODY'); }
}

async function createResponse({ input, tools = [], transport, maxBodyBytes = 64000 }) {
  if (typeof transport !== 'function') throw new Error('TRANSPORT_REQUIRED');
  const response = await transport('https://api.openai.com/v1/responses', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ model: 'fixture-only', input, tools, store: false }),
  });
  if (!response.ok) {
    // 此例只演示协议适配；HTTP 重试交给上一示例的传输策略层。
    try { await response.body?.cancel(); } catch {}
    throw new Error('UPSTREAM_HTTP_' + response.status);
  }
  const body = await readJsonLimited(response, maxBodyBytes);
  if (body?.status !== 'completed' || !Array.isArray(body.output)) {
    throw new Error('INVALID_RESPONSE_ENVELOPE');
  }
  return body; // 不删除 reasoning、function_call 或其他供应商输出项。
}

function asSummary(response) {
  const text = response.output.filter(x => x?.type === 'message')
    .flatMap(x => Array.isArray(x.content) ? x.content : [])
    .filter(x => x?.type === 'output_text' && typeof x.text === 'string')
    .map(x => x.text).join('');
  if (!text.trim()) throw new Error('SUMMARY_TEXT_REQUIRED');
  return text;
}

const fixture = {
  status: 'completed',
  output: [{ type: 'function_call', name: 'lookup', call_id: 'c1', arguments: '{}' }],
};
let sent;
const result = await createResponse({
  input: '查询工单',
  tools: [{ type: 'function', name: 'lookup', parameters: { type: 'object' } }],
  transport: async (_url, options) => {
    sent = JSON.parse(options.body);
    return new Response(JSON.stringify(fixture), { status: 200 });
  },
});
assert.equal(sent.tools[0].name, 'lookup');
assert.equal(result.output[0].call_id, 'c1');
assert.throws(() => asSummary(result), /SUMMARY_TEXT_REQUIRED/);
console.log('完整工具响应保留；摘要包装器单独拒绝无文本结果');
```

第一层读取函数按字节计数，避免先把巨大响应完整放进内存再判断长度。TextDecoder 的 fatal 选项让无效 UTF-8 成为明确错误，而不是悄悄替换字符；但这也意味着它比宽松解码更严格，接入时要确定上游协议确实承诺 UTF-8。reader.releaseLock 只释放读取所有权，超限时还要 cancel 才表示不继续消费内容。

第二层适配器验证的是供应商响应合同，不判断“摘要有没有写好”。它保留工具调用项，使下游可以执行并回传结果。第三层 asSummary 才要求文本。这样，纯工具响应在通用层是成功，在特定摘要业务中是不可用结果；相同数据可以有不同业务判断，但不能把其中一个判断写死在所有调用的共同底层。

这里没有给 transport 默认实现，是为了让依赖显式可见。把它换成真实 fetch 时，还需从受控服务端加入授权头、传递取消信号和超时策略，并使用实际可用的 model；fixture-only 不能发给真实模型端点。不要把测试运输层的默认空密钥和假模型名误认为线上配置。

## 参数缺省值必须归属于某一层

读文档时区分三个默认值：供应商在字段省略时的行为、SDK 的客户端重试与超时默认值、你自己业务包装器的默认值。它们可能叠加。例如 SDK 已经重试两次，你的外层又重试三轮，实际尝试次数就可能远超产品预期。采用原始 fetch 或 SDK 之前，先明确谁拥有重试策略，避免每层都“帮忙保证可靠”。

本例 createResponse 的 tools 默认空数组，maxBodyBytes 默认六万四千；前面的 summarize 显式给输出预算五百一十二，并设 store 为 false。这些是教材选定的配置，不是对所有型号的服务端默认值作承诺。对于 temperature、reasoning、输出上限等能力，应按确定的型号查文档并用小请求验证，不能把文档示例中的数值当成默认规则。[Responses 参数参考](https://developers.openai.com/api/reference/typescript/resources/responses/methods/create)

previous_response_id、conversation 与手工回传 input 是不同的会话状态管理方式。你需要选一种明确的主路径，并知道历史由谁保存、权限变化如何影响后续读取、重试是否会重复加入输入。使用服务器保存的响应 ID，不意味着本轮应用指令必然自动继承，也不意味着所有相关 token 都免费。需要无状态工具循环时，显式维护输入项目更易观察，但应用也承担历史裁剪与敏感数据管理责任。

## 退避、限流和并发控制不是一回事

退避解决单次失败后什么时候重试；限流控制单位时间的请求或 token 消耗；并发限制控制同时在飞的任务数。只做退避，仍可能让一千个用户在同一时刻发出第一波请求；只限制并发，短请求也可能迅速超过每分钟额度。应从应用流量和供应商配额出发组合这些机制，而不是选一个名词当作全部方案。

指数退避中加入随机抖动，是为了让同时失败的客户端错开重试。Retry-After 可能表达等待秒数或 HTTP 日期，需要按服务端约定解析，并设置合理上限。若等待时间已经超过本次业务截止时间，可以返回排队或稍后重试，而不是让 HTTP 连接无限等待。额度耗尽与临时速率限制的恢复方式不同，必须根据错误码识别，不能单凭状态四百二十九做永久循环。

并发槽位要在 finally 里释放，否则一次解析异常就可能永久减少可用容量。排队中的任务还没访问上游，也应该能被用户取消。对交互请求可以拒绝过长队列并返回清楚状态；对后台批处理可保存任务后异步执行。选择哪种取决于用户是否必须守着页面等待结果。

## 练习：实现可取消、可预测的读取重试器

练习要求不是再包一个 catch。请实现最多三次尝试、用户取消后不再尝试、等待期间可以取消、不可重试错误立即抛出，并允许测试注入等待函数。保留原错误对象，使上层能读取状态和请求号。下面答案是完整独立程序，保存为 `retry-policy.mjs`，Node.js 22.22+，不安装依赖，执行 `node retry-policy.mjs`。

<details><summary>完整参考实现：重试、取消与失败验证</summary>

```js retry-policy.mjs
import assert from 'node:assert/strict';
import { setTimeout as delay } from 'node:timers/promises';

async function retryRead(operation, {
  maxAttempts = 3,
  signal,
  wait = (ms, signal) => delay(ms, undefined, { signal }),
} = {}) {
  if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 5) {
    throw new RangeError('INVALID_ATTEMPT_LIMIT');
  }
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    signal?.throwIfAborted();
    try {
      return await operation({ attempt, signal });
    } catch (error) {
      // 用户取消优先，不能被暂时错误标记覆盖。
      signal?.throwIfAborted();
      if (error?.retryable !== true || attempt === maxAttempts) throw error;
      await wait(100 * 2 ** (attempt - 1), signal);
    }
  }
}

let calls = 0;
const waits = [];
const value = await retryRead(async () => {
  calls++;
  if (calls < 3) throw Object.assign(new Error('BUSY'), { retryable: true });
  return '完成';
}, { wait: async ms => { waits.push(ms); } });
assert.equal(value, '完成');
assert.deepEqual(waits, [100, 200]);

const stop = new AbortController();
const cancelled = new Error('USER_CANCELLED');
calls = 0;
await assert.rejects(() => retryRead(async () => {
  calls++;
  throw Object.assign(new Error('BUSY'), { retryable: true });
}, {
  signal: stop.signal,
  wait: async (_ms, signal) => { stop.abort(cancelled); signal.throwIfAborted(); },
}), error => error === cancelled);
assert.equal(calls, 1);

const denied = new Error('FORBIDDEN');
calls = 0;
await assert.rejects(() => retryRead(async () => { calls++; throw denied; }),
  error => error === denied);
assert.equal(calls, 1);
console.log('通过：第三次成功、等待中取消、永久错误不重试');
```

</details>

这个实现将 retryable 视为可信适配层赋予的分类，不直接相信供应商响应体自带的同名属性。operation 接收 signal，但仍需要把它继续传给真实 I/O；只在重试器检查取消，无法中断已经开始却忽略信号的工作。测试用注入 wait 精确触发取消，避免依赖真实毫秒调度造成偶发失败。

继续练习时，把总截止时间加入配置：在第一次执行前确定 deadline，等待前检查剩余时间，实际操作的超时也不得超过剩余量。不要把 deadline 放在循环内部重新计算，那会让每次重试都获得新预算。上线验收还应在代理返回 HTML、响应体中途断开、密钥错误和用户关闭页面四种场景中观察相同分类规则是否成立。

## 把供应商错误转换成前端可以恢复的状态

不要让 Vue 组件直接根据供应商错误文本判断按钮状态。例如上游返回“authentication failed”通常是你服务端的模型配置失效，不是网站用户登录过期；如果前端据此把用户踢回登录页，重新登录也不会解决问题。业务后端应把外部鉴权失败映射成服务配置不可用，把自己的会话失效映射成需要重新登录，两者必须有不同业务码与告警负责人。

成功返回也应包含本次尝试的身份。前端维护 messageId 和 attemptId，当用户重试时保留消息身份并生成新的尝试标识。旧请求如果稍后返回，只能更新它所属的尝试，不能覆盖新结果。网络调用层可以原样透传本方 requestId；模型 responseId 是供应商资源身份，不能直接替代你的消息与用户归属判断。

若后端采用异步任务，创建任务接口可以返回 accepted 和 taskId，此时前端应显示排队或处理中。不能把 HTTP 二百或二百零二统一解释为“生成完成”，也不能因为页面轮询暂时失败就把后台任务标成失败。任务执行状态保存在服务端，前端连接状态只是观察通道状态；重新连接后应该查询同一 taskId，恢复已有结果。

前端取消按钮也需要清楚的文字合同。对于纯文本生成，取消通常意味着停止接收并尝试停止后端生成；对于已经开始的业务工具，取消观察不代表撤销操作。界面可以显示“已停止等待，正在确认操作结果”，而不是保证“什么都没有发生”。这些状态必须由后端明确给出，不能让前端靠超时长短猜测。

## 用一次故障复盘检验调用层的分工

假设日志显示同一用户点击一次“生成摘要”，五秒后报错，账单却有三次调用。先查本方 operationId 下有多少 attempt，再查每次请求使用的是原始 fetch 还是带自动重试的 SDK。如果应用日志只有一次，但供应商记录有三次，可能是客户端库内部重试；如果应用已经记录三次，继续检查每次开始的原因，避免把正常的显式重试与库行为重复叠加。

接着查看错误发生阶段。若响应头已返回五百零三，且 contentType 是 text/html，应进入前面定义的临时 HTTP 错误策略；若状态是二百，随后读流断开，则属于结果未完整获取。两种场景都可能需要重试，但原请求是否执行完成、是否能按 responseId 取回、是否有可复用结果，需要由所选端点和你的存储策略决定，不能仅用“网络错误”概括。

最后看恢复动作是否符合产品目标。如果一条短工单可以允许再生成一次，有限重试是合理选择；如果输入是数百页报告，重新调用的成本显著，保存任务状态并允许后台取回结果可能更合适。这里的取舍不是简单追求“成功率最高”，还要考虑用户等待、重复费用、数据保留与实现复杂度。明确一次任务的成本预算后，错误处理才能做出一致选择。

验收日志应能从本方请求追踪到每次上游尝试，却不要求保存全部提示正文。可以保存输入大小、内容摘要哈希、提示版本和模型配置，用受权限控制的样例库重现问题；涉及敏感资料时，不应为了调试方便默认长期保存全文。排错能力来自足够的关联信息与可复现实验，而不是尽可能多地收集用户内容。

## 本章交付边界

完成上述实验后，你能交付的是一个有清楚接口边界的调用层，而不是已经证明某个真实账号的吞吐与费用。离线 fixture 证明错误分支和数据流；真实接入还需验证所选型号参数、代理读取超时、并发上限和监控字段。把这两类证据分别记录，后续换 SDK 或供应商时就知道哪些契约需要重跑，不必凭记忆重建整个调用过程。

## 三个自测问答

1. 问：HTTP 200 为什么仍可能失败？答：生成可能不完整、被拒绝或没有业务需要的内容。
2. 问：为什么四百二十九不直接永久重试？答：它需要区分速率、额度及产品排队策略，重复请求可能不能解决原因。
3. 问：网络超时后能说服务端没有执行吗？答：不能；结果未知与未执行是两个状态。

## 本章示例验证记录

已离线运行 HTML 503 后成功的重试、完整响应适配器和可取消重试策略。验证纯工具响应不会被适配器误拒、摘要包装器单独拒绝无文本、永久错误不重试及等待期间取消。真实 OpenAI 请求、账户限流与线上超时未联调。

## 官方资料

查阅 [Responses 创建接口](https://developers.openai.com/api/reference/typescript/resources/responses/methods/create)、[OpenAI 错误代码](https://developers.openai.com/api/docs/guides/error-codes) 和 [Node.js AbortSignal 与 fetch](https://nodejs.org/api/globals.html)。本例有意不提供任何非官方兼容地址。