response-format-parity-across-models · ZH · 2026-10-13

换模型后返回体形状一样吗:逐字段对照六个后端

换厂商时最容易出问题的不是回答内容,而是响应体的字段形状。本文按 finish_reason、usage、logprobs、message 附加字段四个维度,逐项对照 Claude、GPT、DeepSeek、Qwen、GLM、Kimi 在 OpenAI 兼容接口下的常见差异,给出解析器容错写法与本地自测清单,帮助你在聚合站里安全切换模型。

为什么换模型会先崩在解析器上

多数聚合站对外暴露的是 OpenAI 风格的 /v1/chat/completions。也就是说,请求形状被统一了,但响应体的语义细节并没有被完全统一。

统一的部分通常是骨架:

  • id、object、created、model
  • choices[] 数组
  • choices[0].message.role 和 .content
  • choices[0].finish_reason
  • usage.prompttokens / completiontokens / total_tokens

不统一的部分往往藏在值域、可空性和附加字段里。换模型的当天,代码里最容易炸的是下面四类地方:

  1. 对 finish_reason 做穷举 switch,遇到新值直接抛异常。
  2. 直接把 usage 的字段当必填整数用,遇到流式或缓存场景为 null。
  3. 假定 logprobs 要么全有要么全无。
  4. 读取 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 直接访问。

一个稳妥的读取顺序:

  1. 做空值检查:message 是否为空对象。
  2. 取 content,若为 null 则回退到工具调用或推理字段。
  3. 工具调用统一走一个归一化函数,把 arguments 尝试 JSON.parse,失败则保留原始字符串。
  4. 附加字段全部按“可选、可能不存在”处理。

六个后端的对照思路(而不是对照表)

不同厂商的实现会随版本变化,与其背一张会过期的表,不如建立一套检查维度,每接入一个新后端就跑一遍:

  • 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 的附加字段全部按可空处理,你的解析器就能在六个后端之间稳定切换,而不需要为每家写一套分支。