换模型后返回体形状一样吗:逐字段对照六个后端
换厂商时最容易出问题的不是回答内容,而是响应体的字段形状。本文按 finish_reason、usage、logprobs、message 附加字段四个维度,逐项对照 Claude、GPT、DeepSeek、Qwen、GLM、Kimi 在 OpenAI 兼容接口下的常见差异,给出解析器容错写法与本地自测清单,帮助你在聚合站里安全切换模型。
为什么换模型会先崩在解析器上
多数聚合站对外暴露的是 OpenAI 风格的 /v1/chat/completions。也就是说,请求形状被统一了,但响应体的语义细节并没有被完全统一。
统一的部分通常是骨架:
id、object、created、modelchoices[]数组choices[0].message.role和.contentchoices[0].finish_reasonusage.prompttokens/completiontokens/total_tokens
不统一的部分往往藏在值域、可空性和附加字段里。换模型的当天,代码里最容易炸的是下面四类地方:
- 对
finish_reason做穷举switch,遇到新值直接抛异常。 - 直接把
usage的字段当必填整数用,遇到流式或缓存场景为null。 - 假定
logprobs要么全有要么全无。 - 读取
message上某个厂商特有的附加字段(如 reasoning、tool_calls),换后端后拿到undefined。
下面按维度逐个对照。
finish_reason:值域比想象中宽
finish_reason 表示这一次生成为什么停下来。常见的值有:
stop:模型自然结束或命中停止序列。length:达到max_tokens或上下文上限。toolcalls/functioncall:模型要求调用工具。content_filter:被内容策略截断。
跨后端时要注意的几点:
- 不是每个后端都会用满这套枚举。 有的可能只区分
stop与length,过滤器命中时直接返回stop,或者返回一个你不认识的新值。 - 工具调用阶段的取值可能不同。 老接口用
functioncall,新接口用toolcalls,同一个聚合站下不同上游可能分别出现。 - 流式场景会出现
null。 SSE 的中间 chunk 通常是finish_reason: null,只有最后一个 chunk 带真实值。
建议的解析写法:
- 用
stop/length/ 工具类 / 其他 四档做分支,而不是给每个可能值写一个 case。 - 未知值就记日志并落到默认分支,不要把未知当异常。
- 流式里先判断
finish_reason != null再决定是否收尾。
usage:别把 token 计数当强类型
usage 是计费和限流的核心,但它的可用性取决于调用形态:
- 非流式:通常一次返回完整 usage。
- 流式:很多实现默认不返回 usage;需要请求侧显式开启(类似
streamoptions.includeusage的开关)才会在末尾补一个只带 usage 的 chunk。 - 部分厂商会附带细分字段,例如缓存命中的 token、推理 token 等,字段名各家不一。
跨后端时的坑:
- 字段可能是
undefined、null或0,三者含义不同,但很多语言的默认值会把它们混在一起。 - 同一段文字在不同后端的 token 化方式不同,
total_tokens不一定等于你按字数估算的结果。 - 聚合站通常按官方价乘一个系数计费,所以你看到的 usage 是计费输入,不要拿它去反推上游的原始计数。
建议:
- 把 usage 当成可选对象,先判空再取值,缺省时记
unknown而不是0。 - 如果是自己的账单系统,把本地估算和返回的 usage 分别存列,方便对账。
logprobs:最容易被忽略的可选能力
logprobs 用于拿到每个 token 的对数概率,常见于分类、置信度评估、评测脚本。它在跨后端场景下最不稳定:
- 支持与否不一致。 有的后端根本不实现;有的只在非流式下实现;有的对流式支持有限。
- 开启方式不同。 常见开关是
logprobs: true加上top_logprobs: N,但 N 的取值范围各后端不同。 - 结构层级固定但内容可空。 一般是
choices[i].logprobs.content[],每项含 token 及其 top 候选;在某些场景里content可能是null。
实践建议:
- 不要在业务主链路上依赖 logprobs。把它当作诊断/评测功能,独立开关。
- 代码里对
logprobs == null和logprobs.content == null都做处理。 - 需要跨后端做概率对比时,先确认两边 token 化一致,否则数值不可比。
message 附加字段:差异最密集的地方
choices[0].message 至少保证有 role 和 content,但附加字段就因后端而异:
tool_calls:结构大体一致(id、type、function.name、function.arguments),但有的后端在参数里返回字符串、有的返回对象,需要按字符串优先解析。- 推理类字段:一些模型会把思维链或推理摘要放在独立字段里,字段名可能是
reasoningcontent、reasoning之类,不是所有后端都返回。如果你直接用message.reasoningcontent拼 UI,换模型后会拿到undefined。 refusal、annotations等字段属于可选扩展,缺失是正常的。content可能是null:当模型只产出工具调用或只有推理内容时,content可能为空。别用content.length直接访问。
一个稳妥的读取顺序:
- 做空值检查:
message是否为空对象。 - 取
content,若为null则回退到工具调用或推理字段。 - 工具调用统一走一个归一化函数,把 arguments 尝试
JSON.parse,失败则保留原始字符串。 - 附加字段全部按“可选、可能不存在”处理。
六个后端的对照思路(而不是对照表)
不同厂商的实现会随版本变化,与其背一张会过期的表,不如建立一套检查维度,每接入一个新后端就跑一遍:
- Claude 系:Anthropic 原生协议与 OpenAI 兼容层在字段命名上有差异,工具调用和停止原因尤其需要确认。
- GPT 系:OpenAI 自身是这套形状的基准,但版本间也在加字段,比如输出侧的结构化内容。
- DeepSeek 系:推理内容是显式独立字段的典型场景,
content与推理字段要分开处理。 - Qwen 系:工具调用与多模态输入的字段在不同版本里演进较快,注意参数编码。
- GLM 系:工具调用与流式分片行为需要实测确认。
- Kimi 系:长上下文场景常见,注意
finish_reason命中长度上限的频率更高。
在聚合站里,这六家都可能通过同一个 API key 调用,所以解析器必须在同一套代码里对所有形状都容错,而不是按模型名分支。
一份可落地的自测清单
接入或切换模型时,用下面这套最小用例跑一遍,比读文档更快:
- 普通问答:确认
content非空,finish_reason为stop。 - 强制截断:把
maxtokens设小,确认finishreason为length。 - 流式:确认中间 chunk 的
finish_reason为null,末尾收尾正常;确认 usage 是否开启后才有。 - 工具调用:确认
tool_calls结构、arguments类型,以及此时content是否为null。 - 不支持的参数:尝试传
logprobs,观察是报错、忽略还是降级返回。 - 空响应:制造一次内容为空但请求成功的场景,确认下游不会崩。
把这几条写成一个脚本,每次新增模型时跑一遍,比在线上救火便宜得多。
小结
换模型时,回答质量是概率问题,响应体形状是确定性问题。把 finish_reason 当作开放枚举、把 usage 和 logprobs 当作可选对象、把 message 的附加字段全部按可空处理,你的解析器就能在六个后端之间稳定切换,而不需要为每家写一套分支。