本页目录

模型接口与可靠调用

用明确的 HTTP 契约封装 OpenAI Responses,处理超时、重试、状态和可观测性。

L1 · 理解约 20 分钟阅读含示例、练习与验收

建议先读:大模型的工作方式

本页内容

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

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

前置是 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 接口参考

错误分类决定下一步#

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

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

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

环境:Node.js 22;文件名 model-http.mjs;不安装依赖。执行 node model-http.mjs 只运行本地 fixture。真实调用分支保留完整接口代码,但本课程不执行;只有你自行设置 RUN_LIVE=1OPENAI_API_KEYOPENAI_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 原貌,摘要业务需要从中提取文本。响应读取上限默认为六万四千字节,是本例的应用限制,不是供应商默认值。

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 参数参考

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

完整参考实现:重试、取消与失败验证
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('通过:第三次成功、等待中取消、永久错误不重试');

这个实现将 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 创建接口OpenAI 错误代码Node.js AbortSignal 与 fetch。本例有意不提供任何非官方兼容地址。

原有课程整理于 2026-09-10;Node / Electron 扩充于 2026-09-11。示例环境与验证范围以正文为准。
原创中文学习手册,阅读结构参考 Vue 文档;非 Vue 官方教材。
下载本章 Markdown

支持中文和英文全文搜索 · ↑ ↓ 选择 · Enter 打开 · Esc 关闭