很多人换模型或换中转站时,看到「兼容 OpenAI 接口」就直接把 base_url 一改,结果跑起来才发现流式少了字段、函数调用不认、system 消息被吞。问题不在你代码写错,而是「兼容」这个词从来没说清兼容到哪一层。这篇把请求、响应、流式、工具调用四条线上的字段拆开,给一份对照表和一套迁移前的自测顺序。
「兼容」其实是分层的,先分清你在赌哪一层
很多人说「这个接口兼容 OpenAI」,其实这句话没什么信息量。兼容是分层的,一个接口可能只兼容了最外面那层壳,里面的字段和事件结构完全是另一套。你要先搞清楚自己在赌哪一层,才知道迁移成本有多大、哪里可能炸。
- 第一层:路径与鉴权格式
- base_url 怎么拼、鉴权头怎么写、常见路径能不能对上。这层最容易做,也最常被拿来当卖点。
- 第二层:请求体字段
- 你实际传进去的字段名、类型、取值范围对方认不认,多出来的字段是报错还是被默默丢掉。
- 第三层:响应体与流式事件结构
- 返回的 JSON 字段、tool_calls 的组织方式、流式 chunk 的切分和结束标志是不是一回事。
多数「兼容」只保证第一层。也就是说,你把 base_url 和 key 换一换,官方 quickstart 那种最简单的单轮补全能跑通,就敢号称兼容。但一个请求跑通和你的业务真能跑通是两回事:多轮上下文怎么带、工具调用返回怎么解析、流式分片怎么拼,这些都在第二、第三层,很多接口在这两层就开始露馅。
| 层级 | 决定什么 | 容易出问题的地方 |
|---|---|---|
| 路径与鉴权 | 能不能连上、请求发不发得出去 | 自定义路径、额外鉴权头、版本前缀 |
| 请求体字段 | 你传的参数对方认不认 | 字段被忽略、类型不匹配、枚举值不认 |
| 响应体与流式 | 返回内容怎么解析 | 字段缺失、tool_calls 结构不同、流式切分不一致 |
- 同一份代码能跑通一个请求,不代表多轮对话能带上历史消息;
- 能拿到普通文本回复,不代表工具调用能正确返回和回填;
- 能收到流式输出,不代表分片和结束标志拼出来的结果和预期一致;
- 第一层过了就以为全过了,是迁移里最常见的误判。
- 别跑官方示例,跑你自己的请求从线上日志里捞出真实用到的那几类调用:多轮、带工具、流式。逐类单独试,别用 quickstart 的 hello world 下结论。
- 逐层核对,而不是一次性全量替换先只改 base_url 和 key 验证第一层;再逐个开请求体字段看有没有被忽略;最后单独测一场流式,把 chunk 拼起来和原文对一遍。
- 把对不上的地方记成清单哪些字段被丢、哪些结构不同、哪些要改写解析逻辑,一条条列出来,作为评估迁移成本和工作量的依据。
什么时候这套分层判断不适用?如果你只是偶尔发一个不关心结构的简单请求,三层差异对你影响不大,能连上就够用;但只要你依赖多轮、工具调用或流式输出中的任何一项,就必须按第三层的标准去验,别停留在第一层。具体到某个中转站实际表现如何,可以去 中转站可信度检测(/zhongzhuan) 对照当时的检测结果,字段级差异建议到 模型与字段资料库(/models) 核对最新记录。
请求侧:这些字段基本都留,但取值域会缩水
先给个整体判断:大部分兼容层对「主干字段」是照单全收的,也就是 model、messages、temperature、max_tokens、stream 这几个。请求能发出去、能返回内容,通常就靠它们撑着。但「照单全收」不等于「原样执行」——字段名留下了,取值域往往被砍过一刀。
| 字段 | 普遍保留情况 | 容易缩水的地方 |
|---|---|---|
| model | 保留 | 名字映射到的实际模型可能不是你以为的那个;写错的回退策略各家不同 |
| messages | 保留 | 角色白名单、role 顺序校验、是否允许空 content,差异很大 |
| temperature | 保留 | 有的只接受整数档位,有的直接忽略这个字段,超范围是报错还是截断说不准 |
| max_tokens | 保留 | 上限被压得很低;有的用另一个字段名兜底,两个都写时谁生效看实现 |
| stream | 保留 | 流式分片格式、结束标志、错误帧可能在流里出现而不是标准 JSON |
默认值是另一处坑。同一个字段你不写,A 家可能按中性值处理,B 家可能按偏保守的值处理,结果就是「提示词没动、输出风格却变了」。所以对比两家时,凡是影响结果的字段都显式写死,别依赖默认值,也别把「没写就没事」当经验。
- 先跑最小请求体只带 model 和一条 user 消息,不带 temperature、max_tokens、stream,确认能通。这一步是为了把「通不通」和「参数对不对」两个问题拆开,避免一上来就被参数报错带偏。
- 一次只加一个字段加完立刻重发一次,看返回是正常内容、还是报错、还是静默变味。加一项测一次,别攒着一起发,否则报错了你也不知道是谁引起的。
- 重点验角色与内容形态单独测 system 角色还在不在、多模态 content 数组能不能传。这两项是兼容层翻车最集中的地方,值得各花一次请求确认。
- 把生效值记下来同一个字段在两家的实际行为写进你的适配笔记,后面封装 SDK 或切换服务商时直接照着改,不用重新试一遍。
- system 角色被降级成 user:语义上能跑,但模型对指令优先级的理解会变,长对话里尤其明显。
- developer 角色常被丢弃或按 system 处理,只测 system 没测 developer 会漏掉。
- content 只认纯字符串:传数组时有的直接报错,有的把数组拍平成一段文本,图片信息就没了。
- temperature 超范围:有的报错,有的截断到边界,有的当没传,三种表现都得试一次才知道。
- max_tokens 缺省:不写时输出长度上限各家不同,长文任务容易被莫名截断。
什么情况下上面这套「逐项加字段」不适用?如果你的调用就是单轮、纯文本、短输出,那 system 和多模态这两块基本碰不到,重点只放在 temperature 和 max_tokens 的取值域上就够。反过来,只要涉及多轮对话、图片输入或长文生成,角色和 content 形态就必须先验,否则后面排查成本会翻倍。具体到某家兼容层当前认哪些字段、取值区间是多少,各家调整快,别背结论,到站内页面按当时的数据核对。
下一步把你要接的那家兼容层,按上面四步跑一遍最小验证;每家实际支持的字段范围和取值域会变,去站内核对当时的对比数据更靠谱。→请求侧:这些字段经常悄悄消失或行为不一致
请求侧最容易踩的坑不是报错,而是「传了没反应」。logprobs、seed、response_format 的严格模式、parallel_tool_calls、frequency_penalty 和 presence_penalty 这几类,很多中转或自建实现直接静默忽略:HTTP 200 返回正常,字段却像没写过一样。判断标准很简单,同一份请求在官方和一个兼容端点各跑一次,把返回体里的对应字段捞出来对比,缺了就是没实现。
| 字段 | 常见兼容表现 | 怎么确认是否真生效 |
|---|---|---|
| logprobs / top_logprobs | 传了不报错,返回里没有 logprobs 字段 | 检查 choices 里有没有 logprobs 对象,没有就是被吞了 |
| seed | 接受参数但不保证复现 | 同参数连发两次,比对文本是否完全一致 |
| response_format 严格模式 | 只认 json_object,schema 校验被忽略 | 故意给一个不满足 schema 的输出,看是否被拦 |
| parallel_tool_calls | 接受布尔值但只回一个 tool_call | 给两个可并行触发的工具,数返回里的 tool_calls 条数 |
| frequency / presence_penalty | 参数存在但输出分布无变化 | 固定 prompt 做多轮采样,看重复度是否随惩罚值变化 |
tool_choice、stop、n 这三处的差异更硬,往往直接改变调用行为。tool_choice 在不少实现里只支持 auto 和 none,指定具体函数名会被降级成 auto;stop 有的只收单个字符串,传数组只取第一个,有的干脆不支持数组;n 大于 1 时,有的只返回一条,有的返回多条但把 usage 算错。这些都属于「结果看着对,其实不对」的类别。
- tool_choice 写具体函数名,看返回的 tool_calls 是不是你指定的那个,被降级会立刻暴露
- stop 传数组,观察输出是否在两个停止词任一处截断,只认第一个就是没吃透
- n 传大于 1 的值,数 choices 长度,顺带看 usage 里的 completion_tokens 是否成比例
- 把这几项和上表里的静默字段放同一份请求里回归,一次性看出哪些是摆设
- 先跑最小请求只留 model、messages 这类必填字段,确认链路通、返回结构完整,作为基线。
- 单个字段加回一次只加一个可疑字段,加完立刻发请求比对返回体,避免多字段互相干扰导致误判。
- 报错与行为不一致时以行为为准很多实现会返回 200 或一句无关的错误文案,但实际没按字段执行;这时不要信文档和报错,只看返回体里对应字段有没有出现、值对不对。
这套字段在不同中转、不同自建实现上的支持矩阵经常变,写代码前建议先到 工具目录(/tools) 找现成的兼容性自测脚本,或者直接翻 指南中心(/guides) 里按端点整理的字段支持清单,用当次实测的结论覆盖记忆里的旧经验。
响应侧:结构对得上,字段常常缺一半
响应体是最容易让人误判的地方。因为只要 id、object、choices、message.content 这几样在,你的客户端代码就能跑通,日志也不报错,很多人就认为「兼容没问题」。但真正决定你成本、重试逻辑、缓存策略的字段,往往不在这一层。
| 响应位置 | 一般兼容情况 | 缺失或走样会踩什么坑 |
|---|---|---|
| id / object / choices | 基本都保留 | 结构对得上,肉眼不容易发现问题 |
| message.content | 基本都保留 | 只看内容的话完全看不出来 |
| finish_reason | 取值集合可能变少或改名 | 重试、截断判断、流式收尾逻辑会误判 |
| usage 细分项 | 常缺缓存写入、缓存读取、推理类 token | 成本核算跟账单对不上 |
| system_fingerprint 等旁路字段 | 经常直接没有 | 做版本对比、灰度排查时没有抓手 |
- 先看 usage 是不是「只有一个总 token 数」,如果只有总数,成本拆分就没得算。
- 再看 usage 里有没有缓存写入、缓存读取、推理 token 这类分项,缺哪项决定你哪部分账算不清。
- 然后核对 finish_reason 的取值集合,有没有你代码里在 switch 的那几个分支。
- 最后看有没有 system_fingerprint 这类旁路字段,缺了不影响调用,但排查问题时会少一条线索。
- 先跑一次真实请求用你的业务 prompt 打一次,把完整响应体原样打印出来,别只看 content。
- 拿 usage 去站内对一次把这次返回的 token 数,到 /usage 和账单侧核一遍,看总量对不对得上、分项是不是缺的。
- 缺 usage 分项时先别自己估先去站内计费口径页面看清楚它到底怎么算,确认口径之后再决定要不要自己记账,否则你记的跟它算的不是一回事。
这套判断不适用于你只用兼容接口做纯文本生成、完全不在意成本拆分和重试精度的场景。但一旦你要做预算控制、多模型对比、或者按缓存命中率优化调用,usage 和 finish_reason 这两处就是必查项。具体差在哪,到 模型资料库(/models) 和 价格与计费(/pricing) 页面按当时的字段说明对,别拿别处的旧结论套。
流式:最容易翻车的一段,事件形状未必一致
流式这段是兼容层里最容易踩坑的地方。标准的 SSE 形状大家都知道:每行以 data: 开头,后面跟一段 JSON,最后来一行 data: [DONE] 收尾。但只要对方不是官方原版,这个形状就未必完整保留。解析端如果按标准形状写死,遇到不守规矩的实现就会卡住或者丢内容。
| 环节 | 常见差异 | 解析端要怎么做 |
|---|---|---|
| 结束信号 | 有的发 [DONE],有的直接关连接 | 收到 [DONE] 或连接关闭都当作结束,别把连接关闭当异常 |
| data 行内容 | 可能出现空行、非 JSON 注释行 | 跳过空 data,解析失败的行先丢弃并计数告警 |
| 字段集合 | 多出没见过的字段 | 按需要读,不认识的字段忽略,不要做全字段强校验 |
| 拼装方式 | 一次一个增量,或整段重发 | 用累加语义而不是覆盖语义去合并 |
- data: 前缀后面可能是空字符串,直接读会解析失败,先判空再解析
- 部分实现会发注释行或心跳行,合法 JSON 里没有这些,按行过滤
- 结束不一定有 [DONE],连接关闭也是合法结束,别把它当成网络错误重试
- 同一个 chunk 里可能有多个 data 行,别默认一行就是一个事件
delta 里到底是增量还是全量
这是第二个高频坑。理想的流式里,每个 chunk 的 delta.content 只带新增的那几个字,客户端拼起来就是完整回复。但有些兼容层图省事,每个 chunk 直接把「到目前为止的全部文本」重发一遍。如果你按增量拼接,结果就会重复;如果按覆盖处理,遇到真增量又会丢前面的内容。稳妥做法是先缓存全文,再看新 chunk 是它的前缀、超集还是纯新增,按对应策略合并。
- 先建缓冲维护一个累积字符串,每个 chunk 都往这个缓冲上合并,不要直接覆盖
- 判断形态看新内容是缓冲的前缀、超集还是全新片段,分别对应全量重发、补全、增量三种情况
- 校验终态流结束后拿缓冲和一次性返回的结果对一下,对不上就说明合并策略选错了
- 留个开关给不同上游留一个「增量/全量」的模式选项,出问题时能快速切换验证
工具调用的 arguments 是拼出来的
流式下的工具调用更麻烦。function.arguments 往往是分段下发的,可能一次只给几个字符,客户端要把同一段调用的多个分片按顺序拼成一个合法 JSON 再解析。分片顺序、index 或 id 的复用方式各家不一样,有的每片都带完整 id,有的只在第一片带。做法是按调用索引分组累积,等流结束后再统一 parse,中途不要尝试解半截 JSON;同时注意 arguments 为空字符串、只带部分字段这些情况,都要能用默认值兜住。
- 前缀判断
- 判断新到内容是否以已有缓冲开头,用来识别全量重发
- 索引分组
- 按工具调用的 index 或 id 把分片归到同一次调用下再拼
- 惰性解析
- arguments 只累积不解析,等流结束再 parse,避免半截 JSON 报错
- 空值兜底
- arguments 或 content 为 null、空串时用默认值,不要直接抛异常
参数口径与 token 计数:同样的值效果不一样
很多人以为兼容 OpenAI 接口就是字段名对得上、请求能返回 200。实际上一组一模一样的参数发过去,换一家后端,输出风格能差出一截:该稳的时候飘、该收敛的时候话多。问题不在字段有没有,而在这些字段的默认值和生效区间各家自己定的,你以为传了个确定的温度,其实只是给了个各家自由发挥的起点。
| 参数 | 你以为的含义 | 换家后可能的差异 |
|---|---|---|
| temperature | 0 最确定、越大越发散 | 有的家默认值不在 0 附近,不传和传 0 是两回事;有的家把区间裁到一半,你传的大值被夹住 |
| top_p | 与 temperature 二选一调 | 有的家两个都生效、互相叠加,有的家只认其中一个,另一个被忽略 |
| max_tokens | 输出上限 | 有的家按输入+输出合计算,有的只算输出,同样的值一边截断一边完整 |
| stop | 命中即停 | 有的家返回文本里仍带着停止串,有的家剔除,下游按位置切分就会错位 |
| seed / 确定性参数 | 固定后结果可复现 | 大多不实现或静默丢弃,传了也不报错,你以为是确定的其实每次都不一样 |
- 先看默认值:不传 temperature / top_p 时,请求体里到底带了什么,很多兼容层会替你补一个默认值
- 再看生效区间:你传 2.0 是被接受、被夹到上限,还是直接报错,三种行为的后果完全不同
- 确认叠加关系:temperature 和 top_p 同时传时谁优先,别按一家的习惯去推另一家
- 检查静默丢弃:不认识的字段是被拒还是被忽略,被忽略最危险,因为没有任何报错提示
- 最后看默认值会不会随模型切换变,同一个接口名下不同模型的默认口径也可能不一致
token 计数是另一笔隐性偏差
分词器不一样,同一段中文切出来的 token 数就可能差一截。你按官方口径估的预算,在兼容层可能偏小也可能偏大:偏小会撞上上下文上限被截断,偏大则白白浪费额度。中英混排、带 Markdown 结构、夹大段 JSON 的 prompt,偏差通常比纯中文更明显,因为切分规则在符号和空格上分歧更大。想核对当下各家的实际计费口径,可以去 价格与计费页(/pricing) 看说明,但具体倍数别记死,各家会调。
- 固定一组用例挑 5 到 10 条覆盖真实场景的请求:短问短答、长文摘要、结构化 JSON 输出、中英混排各来一条,存成固定文件,别每次临时改
- 新旧各跑一遍同一组用例分别打原接口和目标兼容接口,参数原样照搬,不做任何手动补偿,先看默认行为差多少
- 比对长度和格式逐条记录输出字符数、段落数、是否还带原来的 JSON 包裹或代码块标记,重点看下游解析器会不会因为多了前缀或少了字段而报错
- 记录偏差再决定只对确实跑偏的参数做小幅调整,一次只动一个,改完再跑同一组用例;如果怎么调都稳不住,说明是口径差异不是参数问题
这套流程不适用的场景也要说清楚:如果你的调用只是简单的短文本分类或关键词抽取,对风格和长度不敏感,那默认值差异基本不影响你,跑一遍确认不报错就够了。反过来,只要下游有严格的格式解析、有长度上限、或者要靠固定 seed 复现结果,就必须把上面这组用例当成上线前的固定检查项,而不是出了问题再回头补。
迁移前自测清单:按这个顺序跑一遍
迁移兼容接口最忌讳一次把所有功能全打开,报错了也不知道是哪层的锅。建议按下面这个顺序单向推进,每步只做一件事,跑通了再进下一步。判断标准只有三个:有没有报错、关键字段有没有生效、输出能不能被你的代码正常解析。任一步失败就停在那层排查,别带着问题往下走,否则后面出的错都是前面埋的。
- 第一步:只改 base_url 和 key,跑一个非流式纯文本单轮请求把原代码里指向官方的那两个配置换掉,其他一律不动。发一条最简单的单轮消息,不要 system、不要多轮、不要流式、不要工具。验证点:能拿到完整响应体,且 choices 里的文本内容是你预期的那段文字,不是空串也不是报错 JSON。
- 第二步:加 system 消息和多轮上下文在 messages 数组里插入 system 角色,再补两轮历史对话,确认接口接受这种结构。验证点:system 的约束确实影响了输出,多轮上下文没有丢;如果报角色不支持或直接忽略 system,就记下这一层的缺失,先别继续。
- 第三步:开流式,确认结束标志把 stream 打开,观察分片是否正常到达。重点看两件事:一是结束标志有没有按预期出现,二是它出现之后连接是否干净关闭。验证点:你的客户端能正确识别流结束、不会一直挂在那儿等,拼接出来的文本和第一步非流式的结果语义一致。
- 第四步:加工具调用定义一个最简单的工具,让模型返回工具调用结构而不是直接作答。验证点:工具调用的字段名、参数结构、后续回传结果时用的角色和标识,这几处和官方写法是否一致。这一步通常也是兼容差异最集中的地方,字段命名或嵌套层级不同都容易在这里暴露。
- 第五步:喂你最长的真实输入用你业务里实际会出现的、长度最大的那段输入压一次,不要用随手造的短句。验证点:是否被截断、是否报长度相关错误、响应是否超时。这一步是验证上限的,前面四步都过不代表这步能过。
| 步骤 | 要观察什么 | 失败时怎么办 |
|---|---|---|
| 单轮非流式 | 是否报错、文本能不能取到 | 停在配置层,先确认地址和鉴权 |
| system 与多轮 | 角色是否被接受、上下文是否保留 | 停在消息结构层,别急着开流式 |
| 流式 | 结束标志是否出现、连接是否正常收尾 | 停在流式解析层,先修客户端读取逻辑 |
| 工具调用 | 字段名与结构是否和官方一致 | 停在这一层,逐字段比对差异 |
| 最长真实输入 | 是否截断、是否超时 | 停在上限层,先处理长度与超时策略 |
这几步跑下来,最好把每步的三项记录写在同一张表里,失败的那一层标红。这样即使你暂时不修,后面接手的人也知道卡在哪。要注意的是,这套顺序默认你的客户端本身逻辑是对的——如果你自己拼流式或解析工具调用的代码就有 bug,那测出来的「不兼容」可能是误判,这种情况先拿一个官方接口对照跑一遍排除掉。
- 每步只改一个变量,改完立刻验证,不要攒着一起调
- 三项记录缺一不可,只记「通了」等于没记
- 失败就停在那层,跳过继续只会让问题叠加
- 上线前用同一个真实任务,新旧接口各跑一次做对照
- 涉及成本的部分不要凭印象,去站内页面核对当时的数字
常见问题
只改 base_url 就能用吗?
纯文本单轮、非流式的情况下多数时候可以。但只要你用了流式、工具调用、结构化输出或多模态内容,就得逐项验一遍,因为兼容层通常只保证路径和鉴权格式一致,请求体和响应体字段会各自缩水。
为什么接口不报错,但结果明显不对?
常见于「字段传了没生效」:比如设了温度、惩罚项或响应格式,兼容层直接忽略。报错反而好查,静默忽略最难。做法是单个字段加回、看输出是否变化,不变就当作不支持。
usage 拿不全还能算成本吗?
先用你自己记录的字符数做粗估,但别当准数,因为分词器不同。更稳的是去站内的计费口径页面核对当前换算规则,再用真实跑出来的用量反推一次,两者偏差大说明中间有没统计到的计价项。
怎么判断一个站是真兼容还是只做了一半?
看它文档里对字段的说明细不细,以及你实测时工具调用和流式结束标志这两项能不能对上。这两处最费工夫也最容易糊弄,能过基本就够用;相关的检测口径可以到站内核对。
换家之后提示词要不要重调?
大概率要微调。温度和采样默认值不同,输出风格会漂,尤其是要求固定格式的任务。建议留一组固定用例,换家前后各跑一遍做对照,别靠感觉判断。