迁移大模型 API 时,真正贵的地方不是改字段名。字段名写错通常会直接抛异常或返回 400,几分钟内就能定位。代价高的是那些请求成功、响应也解析了、但语义已经变了的情况:内容被截断却当成正常结束、系统提示的地位发生变化、用量字段少了一层导致成本口径悄悄漂移。这类问题不报错,只会在几天后以「输出质量变差」或「账单对不上」的形式出现。

所以适配层的首要目标不是抹平差异,而是把差异变成显式、可检测、可回滚的东西。

先分类,再动手

在写任何兼容代码之前,先把两侧差异归到固定几类,每类对应一个必须回答的问题。漏掉一类,就留下一个静默失败面。

请求侧需要确认:消息数组的组织方式,以及消息内容是否允许结构化块(文本、图像、工具结果混排);角色的取值集合,是否存在系统级角色、系统提示能否多次出现、工具结果以什么角色回传;工具或函数声明的位置与嵌套层级,以及模型返回调用时参数是结构化对象还是字符串;采样与截断参数的支持面——目标端不支持的参数是被忽略、被报错,还是被赋予不同语义。

响应侧需要确认:结束原因的取值集合,以及「正常结束」「达到长度上限」「触发工具调用」「被安全策略拦截」是否被区分;多候选的返回形态,是否存在固定的候选数组、数组长度是否可能大于 1;用量统计的层级与粒度,输入与输出是否分开、是否区分缓存命中部分。

流式侧需要确认:增量是 delta 还是全量快照,客户端该 append 还是 replace;结束信号由哪个帧承载,是否一定存在;用量是否在流内返回,如果不返回,客户端就得自己计数。

错误侧需要确认:HTTP 状态码与业务错误码是否为两层,错误定位信息落在哪个字段;限流是否携带可解析的等待时间;参数错误是否区分「不支持该参数」与「参数值非法」。

这一节的产物不是文档,而是一张表:左列是差异类别,右列是「我们这一侧必须确认的取值」。表里空着的格子,就是尚未验证的假设。

字段路径外置,而不是散在代码里

最省事的写法是 if provider == "a" else。它的问题不是难看,而是把字段名和业务逻辑绑死了:任何一侧文档变更,都得通读全部调用代码,回归范围不可控。

换一种做法:适配层对外只暴露一套内部模型,所有 Provider 差异收敛到一份「路径映射表 + 能力声明」。

from dataclasses import dataclass, field
from typing import Any, Mapping

@dataclass(frozen=True)
class NormalizedResult:
    text: str
    finish_reason: str  # 白名单之外一律归为 unknown
    tool_calls: tuple = ()
    usage: Mapping[str, int] = field(default_factory=dict)  # 缺失 = {},绝不补 0
    raw: Mapping[str, Any] = field(default_factory=dict)    # 原始响应,始终保留

映射表用路径字符串描述,不进代码:

# 每个 Provider 一份;路径取值以该平台当前文档为准,由契约测试锁定
request:
  messages: "..."
  role: "..."
  content: "..."
  tools: "..."
response:
  text: "..."
  finish_reason: "..."
  tool_calls: "..."
  usage: "..."
stream:
  delta_path: "..."
  done_signal: "..."
  usage_in_stream: "..."

解析器本身与 Provider 无关:

def get_path(payload, path):
    cur = payload
    for seg in path.split("."):
        if isinstance(cur, list) and seg.isdigit():
            idx = int(seg)
            if idx >= len(cur):
                return None
            cur = cur[idx]
        elif isinstance(cur, dict) and seg in cur:
            cur = cur[seg]
        else:
            return None
    return cur

关键在缺失时的行为:返回 None,不抛异常,也不回退到某个默认值。上层拿到 None 必须做一次显式决策——要么标记「该 Provider 不提供此字段」,要么归为白名单外的 unknown。默认值是这里最危险的东西,它会让一个长期为 0 的用量字段半年后才被发现。

契约测试:把未知变成可检测

适配器写完不算完,需要一组不变量测试跑在真实端点或录制回放上。测试要能捕捉语义变化,而不只是检查状态码。几条可以起步的不变量:

结束原因白名单。任何响应归一化后,finish_reason 必须落在内部白名单内;落在白名单外不抛异常,而是打点并归为 unknown。截断被误判为正常完成,是最常见的静默失败。

计费口径分离。usage 缺失时输出 missing,而不是 0。把「没有这个字段」和「这个字段是 0」区分开,成本对账才有意义。

流式与非流式一致性。同一输入下,流式拼接结果应与非流式结果一致。这条有明确边界:采样参数非确定、或两侧实现路径不同时会失败,此时应降级为固定用例的人工抽样,而不是直接删掉这条断言。

工具调用结构。调用参数必须能被反序列化为结构化对象;如果返回的是字符串,适配层要么解析要么显式报错,不能原样透传。

错误分类映射。把 HTTP 状态码与业务错误码映射到内部枚举(限流、配额、参数、鉴权、服务端、内容策略),并对每个枚举断言重试策略。分类错了,重试会变成放大故障。

把这些拆成一张核对表,比记在脑子里可靠:

维度 静默失败的表现 必须断言的东西
结束原因 截断被当成正常完成 白名单 + 取值分布打点
角色映射 系统提示地位变化,输出风格漂移 固定用例的抽样对比
工具调用 参数以字符串回传 schema 校验
用量 字段缺失被补成 0 missing 与 0 分离
流式 结束帧缺失,靠超时收尾 收到显式结束信号才可落库
错误 限流被归类为参数错误 枚举映射 + 重试单测

灰度方式决定回滚成本

适配层的价值在于:两个 Provider 在调用方代码里只差一份配置。但这只降低了改动成本,验证成本要单独设计。

影子请求是性价比较高的一步:把同一份输入同时发给目标端点,只记录不返回,用结束原因分布、空响应率、工具调用成功率、用量差异这几个指标判断是否具备切流条件。注意影子请求会产生真实的计费与配额消耗,这部分要提前算进预算。

切流粒度建议精确到单个业务场景,而不是一个全局开关。不同场景对结束原因和工具调用的敏感度差异很大,用同一个开关会掩盖局部问题。

回滚开关必须能在不改代码、不发版的情况下生效。如果回滚需要重新部署,这个适配层就没有真正降低风险。

适配层不该做的事

三种常见越界。第一,用提示词模拟目标端缺失的能力,比如把结构化调用降级成文本协议再解析,失败率会变成业务侧不可观测的噪声。第二,吞掉未知字段,raw payload 必须原样保留一段时间,否则排查线上问题时手上只剩归一化后的残骸。第三,把重试、超时、降级策略写进 Provider 适配器,它们属于调用方策略层,与字段映射混在一起后,改一个重试次数就可能影响另一家 Provider 的行为。

还需要验证的部分

具体的字段路径、流式帧结构、错误码集合,必须以两个平台当前版本的文档和一次真实调用为准。本文刻意没有给出一张静态对照表:这类表一旦被抄进代码,就会在下一次任一侧变更后成为新的静默失败源。

可以确定的顺序是先写契约测试,再写适配器,最后才切流。测试先行的意义不在于覆盖率,而在于让「我们还不知道什么」变成一条会失败的断言。