迁移大模型 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 的行为。
还需要验证的部分
具体的字段路径、流式帧结构、错误码集合,必须以两个平台当前版本的文档和一次真实调用为准。本文刻意没有给出一张静态对照表:这类表一旦被抄进代码,就会在下一次任一侧变更后成为新的静默失败源。
可以确定的顺序是先写契约测试,再写适配器,最后才切流。测试先行的意义不在于覆盖率,而在于让「我们还不知道什么」变成一条会失败的断言。