把调用方从一家模型服务切到另一家,最容易踩的坑不是“连不上”,而是调用返回 200,但上游的解析逻辑悄悄失效:拿不到结束原因、拿不到用量、工具调用的参数拼不出来、限流被当成业务错误重试了十次。

本文不引用任何一方的官方文档原文,也不给出具体的字段对照表——那部分必须以各自官方文档为准。这里只讨论迁移与兼容的工程方法。

迁移工作可以拆成三层,风险并不相同:

  1. 传输与鉴权层:请求能否建立、身份如何携带。
  2. 请求语义层:同一个意图(最长输出、停止条件、工具定义)在两侧如何表达。
  3. 响应语义层:状态码、结束原因、用量、流式分片的含义是否一致。

一句话结论:协议能通,不代表语义兼容。适配层真正要解决的是后两层。

不要把某一方的字段当作内部标准

常见做法是“以 A 的请求体为准,写个函数转成 B”。后果是内部所有业务代码都隐式绑定了 A 的字段名,第二次切换时成本并不会下降。

更稳的做法是先定义一层与任何一家都无关的内部契约,再做双向映射。下面是一段示意,字段名是本文自定义的,不对应任何一家的实际字段:

type ChatRequest = {
  messages: Msg[];
  maxOutputTokens?: number;   // 内部命名
  stopSequences?: string[];
  temperature?: number;
  tools?: ToolSpec[];
  stream?: boolean;
};

interface Provider {
  name: string;
  caps: ProviderCaps;
  toWire(req: ChatRequest): unknown;      // 内部 → 厂商
  fromWire(raw: unknown): ChatResult;     // 厂商 → 内部
  classifyError(raw: unknown): InternalError;
}

这样做换来的不是代码更短,而是换供应商时改动能收敛到 toWire / fromWire / classifyError 三个点,业务层只认 ChatRequestChatResult

不过这里有个成本判断:如果只是一次性的单点替换,也不打算做双供应商降级,直接改调用点往往比长期维护适配层更便宜。适配层的收益来自“多供应商路由、降级、灰度”这些需求,而不是来自抽象本身更优雅。

能力差异要显式声明,不要靠试

两家接口对同一组能力的支持面很难完全重合,例如工具调用、结构化输出、流式场景下的工具调用、并行工具调用、图像输入等。建议给每个 Provider 维护一份显式的能力声明:

type ProviderCaps = {
  streaming: boolean;
  toolCalling: boolean;
  parallelToolCalls: boolean;
  streamToolCalling: boolean;
  jsonMode: boolean;
};

调用前先检查 caps,而不是发出去等报错。对不支持的参数,有三种处理方式,必须明确选一种并统一执行:

  • 直接抛错(推荐用于影响正确性的能力);
  • 降级到近似行为,同时打点告警;
  • 静默丢弃(最危险,会让线上行为与预期悄悄偏移)。

静默丢弃之所以危险,是因为它不会在测试里失败,只会在某个业务指标上缓慢体现出来。

请求侧:把差异收进映射表

请求侧需要逐项核对的维度包括:鉴权信息所在的 header 名称与前缀、是否还需要额外的组织或项目标识、模型标识的命名、消息角色的取值范围、系统提示的承载位置、以及采样参数的可选值域。

这些差异不建议硬编码成 if-else,用一张配置表驱动更好维护:

const REQUEST_MAP = {
  maxOutputTokens: { providerA: '?', providerB: '?' }, // 以各自官方文档为准填入
};

这里刻意留空,是提醒映射表的具体字段名必须来自两侧官方文档,不要凭印象填。写错一个字段名,接口通常不会报错,而是用默认值静默跑完——这类问题在联调阶段几乎发现不了。

流式响应:跨分片边界才是真问题

流式接口大多基于 SSE。有三个通用问题必须处理:

分片切断。 一次网络读取不等于一个完整事件。必须把数据缓冲到事件分隔符再解析,剩余部分留在缓冲区等下一个分片。直接在单个 chunk 上做 JSON.parse,是线上最常见的事故来源。

增量拼接。 流式场景下,工具调用的参数往往分多次到达。必须全部收完再解析成 JSON,边收边 parse 必然失败。

结束与用量。 结束标记的形态、结束原因的取值、用量统计出现的时机,两侧可能不同。适配层应在流结束时统一产出一个内部事件(例如 done,携带归一化后的结束原因与 usage),业务侧只处理这一种形态。

另外,首字节超时和空闲超时要分开设置。流式场景下用总时长做超时判断,会把长回答误杀。

错误映射:按类别,不按字面错误码

把上游的状态码直接透传给业务层,是迁移后“重试风暴”的常见来源——数字在不同服务之间的语义并不保证一致。

建议先定义一份内部分类,再为每个 Provider 实现 classifyError

  • AUTH:认证或鉴权失败,不可重试,需要告警;
  • RATE_LIMIT:可退避重试;
  • QUOTA:配额耗尽,重试无意义;
  • INVALID_REQUEST:请求非法,不可重试,通常属于代码缺陷;
  • CONTEXT_TOO_LONG:需要在上层做截断或摘要,不是网络问题;
  • CONTENT_FILTER:业务语义,通常不可重试;
  • SERVER_ERROR / TIMEOUT:可重试。

重试策略至少区分“可重试”与“不可重试”两档,并使用指数退避加抖动。如果响应中带有服务端给出的等待时间提示,应优先遵循它,而不是用本地退避覆盖。

响应归一化:枚举和可选字段最容易翻车

fromWire 里建议固定做四件事:

  • id 与模型名:内部生成的 id 与厂商返回的 id 分开保存,方便对账;
  • 结束原因:映射成内部枚举,并对未知取值保留 default 分支加告警。上游 switch 遇到陌生值不报错、直接走空分支,是最难排查的一类问题;
  • usage:字段命名、是否一定有值、是否包含细分项,都需要逐项核对,不能假定存在;
  • 原始响应:脱敏后保留一份,排障时价值极高。

切换、灰度与验证

配置外置,按请求路由,先灰度再全量。验证手段上,下面两种最有效:

影子流量。 同一个内部请求同时发往两侧,比较归一化之后的结构(结束原因、工具调用是否完整、usage 是否存在),而不是比较文本内容。文本不同不代表迁移失败,结构不同才是。

录制回放。 把脱敏后的真实请求与响应存成 fixture,作为适配层的回归测试集。适配层每次改动都跑一遍,比人工比对可靠得多。

上线前的核对清单

  • 鉴权信息的位置与格式是否确认?缺失时会返回什么错误?
  • 每个用到的请求参数,在目标侧的实际字段名与取值范围是否逐条核对过?
  • 不支持的参数走的是哪条分支:报错、降级还是丢弃?
  • 流式解析是否处理了跨分片、增量拼接与结束事件?
  • 错误分类是否覆盖了认证、限流、配额、上下文超长、内容过滤?
  • 哪些错误会被重试?退避策略与最大次数是多少?
  • 结束原因是否映射到内部枚举?未识别取值是否有告警?
  • usage 字段是否一定存在?缺失时所在代码路径是否安全?
  • 是否有影子流量或回放测试覆盖核心调用路径?

这份清单的价值不在于它有多全,而在于它把“迁移”从一次性的改代码,变成了可验证的工程动作。适配层真正的成本从来不在写那三个函数,而在维护映射表,以及处理那些不报错、但语义已经变了的情况。