把现有 OpenAI API 接入点切换到另一个宣称兼容的服务,真正的技术风险通常不在“请求发不出去”,而在“响应看起来一样、实际结构或语义不同”。迁移前很多人只验证了第一个 Hello World 请求能返回正文,却没有验证错误响应、流式事件、工具调用返回、字段缺省这几类最容易在真实流量里出问题的路径。

这里给出一套不依赖厂商文档具体描述的验证方法:四层兼容性建模、真实响应结构 diff、错误注入实测、契约测试固化。文中代码都是脚手架,端点路径、字段名、错误码一律以迁移当天拿到的双方官方文档和线上真实响应为准。本文不预先断言 DeepSeek API 的任何一个具体字段或行为,因为那正是迁移方必须在官方文档与真实流量上核实的内容。

先把“兼容”拆成四层契约

“兼容”不是布尔值。两个服务即使都能成功处理同一类对话补全请求,也可能只在某一层兼容:

  • 接入层:base URL、认证头格式、HTTP 方法、路径。
  • 请求契约:顶层参数名、嵌套对象结构、取值范围、枚举取值、流式开关位。
  • 响应契约:正文内容放在哪个字段、字段类型、缺省语义、结束原因与用量的结构。
  • 行为语义:错误码取值范围、限流与重试语义、流式事件边界、工具调用参数编码与回传规则。

迁移方案里真正要做的,不是把网上的“兼容性对照表”当成事实抄一遍,而是逐层用真实请求把新旧两端的行为记录下来做 diff。下面从“如何记录”开始。

第一步:抓基线,把新旧两端的真实响应落盘

先写一个最小抓取函数,把状态码、响应头、JSON 响应体一起保存:

import requests

def capture(base_url: str, api_key: str, payload: dict) -> dict:
    # 路径与认证方式以双方官方文档为准,此处只是演示入口
    resp = requests.post(
        f"{base_url}/chat/completions",
        headers={"Authorization": f"Bearer {api_key}"},
        json=payload,
        timeout=60,
    )
    try:
        body = resp.json()
    except ValueError:
        body = resp.text
    return {
        "status": resp.status_code,
        "headers": dict(resp.headers),
        "body": body,
    }

保存基线时,确认同一个 prompt 在两端的“语义等价”,并核对 payload 里每个字段:哪些是源服务有而目标服务不接受的;哪些枚举取值在目标端会被静默降级而不是报错。这类“请求能成功但语义不同”的差异比 4xx 更危险,因为不会触发告警。

第二步:做结构 diff,而不是值 diff

模型输出有随机性,所以不能比较返回值本身,只能比较“键集合 + 字段类型”。这是整个迁移验证里最关键的一步:

def diff_schema(source, target, path="$"):
    issues = []
    if isinstance(source, dict) and isinstance(target, dict):
        for key in sorted(set(source) | set(target)):
            child = f"{path}.{key}"
            if key not in target:
                issues.append(("source_only", child))
            elif key not in source:
                issues.append(("target_only", child))
            else:
                issues += diff_schema(source[key], target[key], child)
    elif type(source) is not type(target):
        issues.append(("type_mismatch", path, type(source).__name__, type(target).__name__))
    return issues

结果会有三类:

  • source_only:源服务有、目标没有。旧代码依赖的字段在目标端可能缺省,属于破坏性差异,必须逐个处理。
  • target_only:目标多出来的字段。宽松解析下通常安全,但代码如果把响应整体序列化落库,或对响应做严格 schema 校验,仍然会造成问题。
  • type_mismatch:同一字段类型不同。典型差异是字符串与数字、null 与缺省、数组与单对象。

对数组字段不需要对每个元素做 diff,取第一个元素递归即可。比较对象应覆盖普通请求、流式请求、工具调用请求、错误响应四类,不能只抓一个成功响应就收工。

四个必须实测的高风险点

第一,错误与重试语义。 至少用无效凭证和触发限流两种方式各打一次目标服务,记录状态码、响应体里的错误码字段、限流相关响应头的名字。然后用这些记录反过来校准重试代码:重试条件是按状态码还是按错误码,退避是否消费服务端返回的重试等待时间。不校准的后果是:目标服务用 429 表达限流,而旧重试逻辑只认 5xx,限流流量会全部穿透到业务层。

第二,流式输出。 用 requests 的 stream=True 把原始字节按行存下来,先不要解析。核对三件事:事件是按空行还是按行边界切分;正文增量在哪个字段;整个流如何结束。真实业务里最常见的坑是:目标服务已经以 200 开始响应,业务错误却编码在流中间的某个事件里,而不是 HTTP 状态码。

第三,工具调用。 设计一个必然触发工具调用的测试 prompt,核对以下行为:请求里声明的工具定义 schema 是否被校验;返回中的工具调用参数是转义 JSON 字符串还是结构化对象;是否可能出现多个候选调用;调用结果回传时角色与字段放在哪一层。

第四,null 与缺省语义。 目标服务在“内容为空”和“字段未定义”两种场景下,可能一个返回 null、一个直接省略键。旧代码里所有硬编码读取的字段路径都要重新对照真实输出过一遍,尤其是消息正文与结束原因这类被业务直接依赖的路径。

第三步:把兼容性固化成契约测试

结构 diff 只给出“一次对比”的结论。要防止目标服务后续升级悄悄破坏兼容性,需要把验证放进 CI:

  1. 从源服务多次调用中归纳一份最小契约 schema,只包含业务真正使用的字段;
  2. 保存固定 prompt 下的真实响应作为 golden fixture;
  3. 每次对新服务响应做两件事:校验它满足最小契约,并与 golden 做结构 diff;
  4. 对 target_only 类型的新增字段维护白名单,出现白名单之外的键就失败。
# tests/test_contract.py
import json
import os

import requests
from jsonschema import validate

def load_contract_schema(path="contracts/chat_completion.json"):
    with open(path, encoding="utf-8") as f:
        return json.load(f)

def test_response_contract():
    base_url = os.environ["TARGET_BASE_URL"]
    api_key = os.environ["TARGET_API_KEY"]
    payload = {
        "model": os.environ["MODEL"],
        "messages": [{"role": "user", "content": "ping"}],
    }
    resp = requests.post(
        f"{base_url}/chat/completions",
        headers={"Authorization": f"Bearer {api_key}"},
        json=payload,
        timeout=60,
    )
    assert resp.status_code == 200
    validate(resp.json(), load_contract_schema())

即使 prompt 与采样参数固定,两次生成的正文值也不可能相同,所以契约测试只对类型和键做断言,对正文值一律不比。工具调用与错误响应需要各自独立的 fixture,不能共用普通请求的契约。

落地手法:先收口,再灰度

最小改动迁移不等于在业务代码里原地替换 base_url。更稳妥的做法是把所有直接 HTTP 调用收敛到一个 client 适配模块,请求构造和响应解析各保留一份实现;业务侧只依赖这个适配模块暴露的最小数据对象。这样目标服务多出来的字段不会泄漏进业务代码,重试与限流逻辑也可以只在一个地方按实测结果调整。

灰度顺序建议:非流式普通请求 → 流式请求 → 工具调用 → 定时批量任务。每一步都以上一阶段的契约测试通过为前提。最后把一段真实业务流量完整切过去运行一段时间,观察错误码分布、超时率、限流触发频次与源服务基线的差异。

这里真正值得强调的是:任何“官方文档宣称兼容”的说法都只配作为起点。可以进入生产的兼容性证据是真实响应——响应结构 diff 清零或差异全部被评审接受、错误语义测试通过、流式事件结构一致、工具调用端到端行为一致。在这些证据齐全之前,最安全的工程假设是目标服务的错误语义与限流行为全部未知;宁可保守重试,也不要照搬源服务的重试策略。

迁移完成后,把源服务响应样例、目标服务真实响应样例、差异评审记录三份文件一起归档。下次任何一方服务端升级,这三份文件就是回归测试的基准。