「OpenAI 兼容」这四个字的实际含义是:请求和响应的形状兼容。
它不保证参数都支持、不保证错误码一致、不保证约束相同。而这些差异不会写在文档里——你只能一个一个撞上去。
下面是接一个企业网关时实际撞到的,加上流式解析里三个几乎一定会踩的点。
一、可能被强制流式
400 <厂商自定义错误码>"Non-stream chat request is currently not supported"这个网关根本不支持非流式。所有请求一律 stream: true。
一旦被强制流式,你就必须自己写 SSE 解析——哪怕你的场景(比如后台批量任务)根本不需要逐字输出。
我们的处理是按 Content-Type 自适应:
const ct = res.headers.get('content-type') ?? '';if (ct.includes('text/event-stream')) { return await consumeSse(res); // 内部聚合完再返回}return await res.json(); // 常规 JSON 路径原样保留这样切回任何常规中转只改一个 baseUrl,代码零改动——这条回退保障在换供应商时值回票价。
同时 llmChat() 的签名保持不变(内部聚合完才返回),已有的调用点一个都不用改;另外加一个 llmChatStream() 给需要逐字出字的地方用。
二、函数名不能含点号
这条最难查。
{ "name": "kb.search" }400 <厂商自定义错误码>param: "" ← 空串,不告诉你哪个字段错OpenAI 的规范里 function name 只允许 ^[a-zA-Z0-9_-]{1,64}$,点号不合法。宽松的实现会放过,严格的实现直接拒,而且不告诉你是哪个字段。
解法是在出口做一层 wire 映射,内部命名一个字不动:
const toWire = (n: string) => n.replace(/\./g, '__'); // kb.search → kb__searchconst fromWire = (n: string) => n.replace(/__/g, '.');内部那套 <域>.<能力> 的名字已经被快捷键、编排器、调试钩子广泛引用,全局重命名的代价远大于加两行映射。
内部标识符和协议标识符是两回事,中间要留一层映射。 不留的话,任何一个下游的字符集约束都会逼你做一次全局重命名。
三、有些参数可以省,别为它改 schema
实测下来 tool_choice 可以完全不传,parameters: { type: 'object', properties: {} } 也合法(无参工具)。
这类「能不能省」的问题,花五分钟实测比读半小时文档准——文档往往描述的是上游 OpenAI 的行为,而你打的是中转。
四、流式解析的三个坑
这三个和网关无关,是 SSE 本身的性质,但几乎一定会踩。
① usage 挂在最后一个「内容为空」的 chunk 上
for (const chunk of chunks) { const delta = chunk.choices?.[0]?.delta; if (!delta?.content) continue; // ✗ 这一行会把 usage 跳过去 out += delta.content;}token 用量统计在最后一个 delta.content 为空的 chunk 上。按「没内容就跳过」写,你永远拿不到用量。
if (chunk.usage) usage = chunk.usage; // ✓ 先收 usage,再判内容if (delta?.content) out += delta.content;② chunk 会被切在半行、甚至半个字符中间
TCP 不保证按你的语义边界切。一个 SSE 事件可能跨两个 chunk,一个 UTF-8 多字节字符也可能被切开。
两件事都要做:
const decoder = new TextDecoder();let buffer = '';
for await (const bytes of stream) { // ★ stream:true —— 让 decoder 自己缓住半个字符,不要吐出替换字符 buffer += decoder.decode(bytes, { stream: true });
// ★ 按行切,最后一行可能不完整,留在 buffer 里等下一个 chunk const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; for (const line of lines) handleLine(line);}少了 { stream: true },中文会随机变成 �——而且只在网络慢的时候出现,本地测永远测不到。
③ tool_calls 是分片到达的
不是一个完整的对象,而是按 index 拆片:函数名通常在首片,arguments 的 JSON 字符串会被拆成好几片。
const acc = new Map<number, { name: string; args: string }>();
for (const tc of delta.tool_calls ?? []) { const cur = acc.get(tc.index) ?? { name: '', args: '' }; if (tc.function?.name) cur.name = tc.function.name; // 首片给名字 if (tc.function?.arguments) cur.args += tc.function.arguments; // 后续片拼参数 acc.set(tc.index, cur);}// 全部收完才 JSON.parse(cur.args)中途 JSON.parse 必然失败——它拿到的是半个 JSON。
五、超时要按真实分布定,而且要用「空闲超时」
我们的超时从 30 秒调到了 60 秒。依据是日志里的真实耗时分布,不是拍脑袋:手动跑单次就有 16.5 秒(已经过半),而批量连续调用会被限流排队,极易破 30 秒。
改超时之前先查一遍真实耗时。你猜的那个值通常是「感觉上合理」,而不是「覆盖了 P99」。
更重要的是类型要对:
// ✗ 总时长超时:长回答会被误杀setTimeout(() => ctrl.abort(), TOTAL_TIMEOUT);
// ✓ 空闲超时:每来一个 chunk 就重置let idle = setTimeout(() => ctrl.abort(), IDLE_TIMEOUT);onChunk(() => { clearTimeout(idle); idle = setTimeout(() => ctrl.abort(), IDLE_TIMEOUT); });流式场景下,「总共花了多久」不是健康指标,「多久没有新内容了」才是。用总时长超时,一个正常的长回答会被当成超时——我们的批量任务报过一整批「请求超时」,全是这类误判。
配套还加了失败重试(2 次,间隔 1.5s / 3s 递增)——递增是为了给限流让路,固定间隔的重试只会撞在同一堵墙上。
六、最贵的一个:模型下架时不报「模型不存在」
这一条的症状和真因隔了四层,而且报错文案指向的方向是错的。
故障链:
内置兜底的模型名被网关关停 → 网关不返回 404,也不说「模型不存在」,而是把请求静默路由到某个推理型模型 → 首次请求 700 tokens:只吐思考过程,正文一个字没写 → 我们的自愈逻辑判定「推理型模型额度不够」,放宽到 4000 重试 → 仍然只有思考过程(因为根本不是额度问题) → 报错:「模型只输出了思考过程,请调高单次生成上限,或换用非推理型模型」照着这个报错去调参数,只会更慢更贵,而且永远修不好。 真因是模型名已经失效了。
排查口诀:
日志里实际使用的模型名,在不在你配置的候选集里? 不在 → 说明走的是内置兜底值 → 先怀疑它是不是已经下架了,别照着报错文案调参数。
顺带翻出来的两个隐患
① 为什么会落到兜底。 云端配置的总开关被手动关掉后忘了恢复,于是模型解析一路落空:本地配置(空)→ 云端配置(空)→ 兜底常量。
现在那个总开关加了 error 级告警——「一个会让系统悄悄降级的开关」必须在降级时出声。
② 兜底模型名有两份硬编码。 两个文件里同名同值各写一份,而真正被引用的是后者。改前者根本不生效——这是典型的「改了没用」型 bug 温床。
现已合并成一处 re-export。加新兜底值只改一个地方。
七、模型清单没法自动同步,就别假装能
实测下来这个网关没有可用的模型列表接口,额度消耗也抓不到。
所以模型清单是手抄的。手抄的表会过期——上面那个故障就是这个风险兑现的样子。
既然没法自动同步,就要在别的地方加防线:
- 模型名走云端配置下发,改清单不用发版;
- 解析落到兜底时打
error级日志(而不是静默接受); - 报错文案里带上实际使用的模型名,让人一眼看出走的是哪个。
拿不到的信息就别装作拿得到。承认它是手工维护的,然后给手工维护配一个「失效时出声」的机制,比假装自动化安全得多。
回头看,这些坑分两类:
一类是「不兼容」——强制流式、函数名规则。它们会明确报错,撞一次就知道了,修起来也快。
另一类是「静默降级」——模型下架被路由到别的模型、usage 被跳过、UTF-8 被切坏。它们不报错,只是结果慢慢变得不对。
第二类才是真正花时间的。 接任何一个中转、网关、代理的时候,值得先问一句:这东西在「我要的东西没有」的时候,会明确报错,还是会给我一个差不多的替代品?