中小企业选择AI大模型工具时,通常会比较模型效果、响应速度、数据边界和调用成本。但真正接入业务后,还有一个经常被低估的问题:如果以后更换模型提供方,现有代码需要改多少?

不同模型接口可能使用不同的请求字段、响应结构、结束原因和Token统计方式。如果业务代码直接读取某家接口的 choicesusage 或私有状态值,供应商差异就会扩散到摘要、分类、审核和内容生成的每个模块。

更稳妥的方法,是在业务代码与模型客户端之间增加适配器层,并用同一套契约测试验证每个适配器。业务层只认识自己的 ModelRequestModelResponse,不认识任何供应商的原始JSON。

本文给出一套只依赖Python标准库的最小实现。为避免把示例伪装成真实云服务调用,文中使用两个本地虚拟传输函数模拟不同返回格式;完整逻辑已经执行6项测试。

一、耦合通常不是从“调用模型”开始的

下面是一段常见业务代码:

raw = vendor_client.generate(prompt)
text = raw["choices"][0]["message"]["content"]
tokens = raw["usage"]["total_tokens"]

if raw["choices"][0]["finish_reason"] == "stop":
    save_result(text)

它看起来很短,却同时依赖了四项外部约定:

  1. 文本位于 choices[0].message.content
  2. Token位于 usage.total_tokens
  3. 正常结束状态叫 stop
  4. 客户端异常和返回缺字段的处理方式固定。

如果另一家接口返回 output.textusage.inputstatus=completed,业务代码就必须修改。更糟的是,类似读取逻辑往往已经散落在多个文件中。

因此,模型切换的核心问题不是如何改一行URL,而是如何阻止供应商的数据结构进入业务层。

二、先定义业务真正需要的最小契约

摘要任务通常只需要提示词、温度和最大输出长度;返回结果只需要正文、结束原因、Token计数和可选请求ID。

from dataclasses import dataclass

@dataclass(frozen=True)
class ModelRequest:
    prompt: str
    temperature: float = 0.2
    max_tokens: int = 512

@dataclass(frozen=True)
class ModelResponse:
    text: str
    finish_reason: str
    input_tokens: int
    output_tokens: int
    provider_request_id: str | None = None

这里没有照搬任何一家接口。字段来自业务真正需要处理的概念。

finish_reason只允许三种标准值:

stop      正常结束
length    达到长度限制
filtered  被安全或业务规则阻断

遇到供应商新增状态时,适配器不能悄悄当成正常结果,而应先映射或拒绝。这样,外部接口变化会在边界层暴露,而不是进入业务流程后才出现异常。

三、使用Protocol约束适配器入口

业务层只要求对象具有一个 generate 方法:

from typing import Protocol

class ModelAdapter(Protocol):
    def generate(self, request: ModelRequest) -> ModelResponse:
        ...

Python的 Protocol 支持结构化子类型。实现类不必继承同一个基类,只要提供符合约定的方法,就可以被静态类型检查工具识别。具体语义可查阅Python typing文档

业务函数因此可以完全脱离供应商:

def business_summary(adapter: ModelAdapter, source: str) -> str:
    response = adapter.generate(ModelRequest(
        prompt=f"只依据原文生成一句摘要:{source}",
        temperature=0,
        max_tokens=100,
    ))
    if response.finish_reason != "stop":
        raise RuntimeError("输出未正常结束,转人工检查")
    return response.text

这个函数不读取原始JSON,也不知道底层客户端使用哪种消息格式。更换模型时,它不应该被修改。

四、输入校验必须发生在网络调用之前

如果空提示词、非法温度或负数长度已经发送到外部接口,系统不仅浪费一次请求,还会把本地参数错误误判为供应商故障。

def validate_request(request: ModelRequest) -> None:
    if not request.prompt.strip():
        raise ValueError("prompt不能为空")
    if not 0 <= request.temperature <= 2:
        raise ValueError("temperature必须在0到2之间")
    if request.max_tokens <= 0:
        raise ValueError("max_tokens必须为正整数")

这里的温度范围是本文内部契约,不代表所有模型都采用相同范围。真正接入时,应先查询目标接口当前文档,再决定内部契约取不同供应商的共同范围,还是由适配器继续转换。

输入校验的目标不是替供应商重复检查,而是让业务错误在自己的边界内稳定失败。

五、适配器A:把output结构转换成标准响应

假设供应商A返回:

{
  "output": {
    "text": "已完成摘要",
    "status": "completed"
  },
  "usage": {
    "input": 12,
    "output": 5
  },
  "request_id": "a-001"
}

对应适配器:

class VendorAAdapter:
    def __init__(self, transport):
        self.transport = transport

    def generate(self, request: ModelRequest) -> ModelResponse:
        validate_request(request)
        raw = self.transport({
            "input": request.prompt,
            "temperature": request.temperature,
            "max_tokens": request.max_tokens,
        })

        result = ModelResponse(
            text=raw["output"]["text"],
            finish_reason={
                "completed": "stop",
                "max_tokens": "length",
                "blocked": "filtered",
            }.get(raw["output"]["status"], "unknown"),
            input_tokens=raw["usage"]["input"],
            output_tokens=raw["usage"]["output"],
            provider_request_id=raw.get("request_id"),
        )
        validate_response(result)
        return result

适配器同时承担请求转换、响应转换和状态归一化,不承担摘要业务逻辑。

六、适配器B:接口完全不同,业务函数仍然不变

供应商B可能要求消息数组,并返回:

{
  "choices": [{
    "message": {"content": "已完成摘要"},
    "finish": "end"
  }],
  "tokens": {
    "prompt": 12,
    "completion": 5
  },
  "id": "b-001"
}

适配器B将其转换为同一种 ModelResponse

class VendorBAdapter:
    def __init__(self, transport):
        self.transport = transport

    def generate(self, request: ModelRequest) -> ModelResponse:
        validate_request(request)
        raw = self.transport({
            "messages": [
                {"role": "user", "content": request.prompt}
            ],
            "sampling": {
                "temperature": request.temperature
            },
            "limit": request.max_tokens,
        })

        result = ModelResponse(
            text=raw["choices"][0]["message"]["content"],
            finish_reason={
                "end": "stop",
                "limit": "length",
                "safety": "filtered",
            }.get(raw["choices"][0]["finish"], "unknown"),
            input_tokens=raw["tokens"]["prompt"],
            output_tokens=raw["tokens"]["completion"],
            provider_request_id=raw.get("id"),
        )
        validate_response(result)
        return result

两家接口字段完全不同,但 business_summary() 无需增加 if vendor == ...。供应商差异被限制在各自适配器内部。

七、响应校验比字段转换更重要

适配器能够构造对象,不等于结果可以进入业务。

def validate_response(response: ModelResponse) -> None:
    if not response.text.strip():
        raise ValueError("模型返回空文本")

    if response.finish_reason not in {
        "stop", "length", "filtered"
    }:
        raise ValueError("未知finish_reason")

    if min(
        response.input_tokens,
        response.output_tokens
    ) < 0:
        raise ValueError("token计数不能为负数")

这段校验解决三个问题:

  • HTTP成功但正文为空,不能视为业务成功;
  • 新增结束状态没有完成映射,必须及时暴露;
  • 用量字段异常时,不能继续生成错误的成本记录。

生产环境还可以验证最大文本长度、请求ID格式、结构化输出Schema和敏感字段,但不要把所有业务规则塞进公共适配器。只有所有模型都必须遵守的规则,才属于公共契约。

八、契约测试不是测试某一家SDK

普通单元测试容易变成“适配器A有一套测试,适配器B再复制一套”。契约测试则定义所有适配器必须共同通过的行为。

class ContractTest(unittest.TestCase):
    def adapters(self):
        return [
            VendorAAdapter(fake_a),
            VendorBAdapter(fake_b),
        ]

    def test_same_business_result(self):
        for adapter in self.adapters():
            with self.subTest(
                adapter=type(adapter).__name__
            ):
                result = business_summary(
                    adapter, "原文"
                )
                self.assertEqual(
                    result, "已完成摘要"
                )

unittest中的 subTest 适合让多个适配器运行相同行为断言;命令行运行方式和测试组织方法可参考Python unittest文档

本文实际验证了六种行为:

  1. 两个适配器得到相同业务结果;
  2. 两种结束状态都归一化为 stop
  3. Token计数均为非负数;
  4. 空提示词在调用传输层前被拒绝;
  5. 未知结束原因被拒绝;
  6. 空输出被拒绝。

本地运行结果:

Ran 6 tests in 0.000s
OK

这里没有调用真实网络,测试的是业务契约与字段转换。真实接入后,还应增加少量集成测试,验证鉴权、超时、限流和SDK实际返回结构。

九、不要把自动降级写成默认行为

有了两个适配器,很容易继续写:

try:
    return primary.generate(request)
except Exception:
    return backup.generate(request)

这段代码风险很高。权限错误、输入非法、内容被规则阻断,都不应通过切换供应商绕过。即使只是超时,也要考虑第一次请求是否已经完成,只是响应没有返回。

更合理的做法是先分类错误:

本地输入错误       直接拒绝
鉴权或权限错误     停止并告警
内容规则阻断       转人工,不切换
临时网络错误       有限重试
供应商不可用       按业务等级决定是否切换
未知错误           保守停止

切换模型还可能改变输出风格、长度和安全边界。即使通过公共契约,关键任务仍应经过人工审核或更细的业务回归测试。

十、如何选择内部契约的字段

内部契约过薄,业务层仍会绕过适配器读取供应商数据;契约过厚,又会强迫所有模型模拟某一家特有能力。

可以按三层划分:

第一层是所有模型共有的基础能力,例如文本输入、文本输出、结束状态和用量。

第二层是可选能力,例如工具调用、结构化输出和多模态输入。用明确的能力查询或独立接口表达,不要假设所有适配器都支持。

第三层是供应商专属能力,只保留在扩展模块中。如果业务强依赖它,就应承认这部分存在迁移成本,而不是用统一接口制造“完全可替换”的错觉。

适配器能降低耦合,但不能让不同模型的能力、质量和政策变得相同。

十一、适合中小企业的迁移顺序

如果现有代码已经直接依赖某家接口,不需要一次性重写所有模块。

第一步,统计业务代码实际使用了哪些原始字段。

第二步,从一个低风险任务提取 ModelRequestModelResponse

第三步,为当前供应商实现第一个适配器,保证现有行为不变。

第四步,用虚拟传输函数建立契约测试,覆盖正常与失败边界。

第五步,再接入第二个适配器,并让它运行同一套测试。

第六步,只有契约和业务回归都通过后,才进行小范围真实请求验证。

对于OPC一人公司,这种设计也能减少多个自动化脚本各自维护模型调用的负担。智能体来了内容品牌关注的AI大模型工具深度运用,并不是频繁更换工具,而是让工具变化停留在可测试的边界内。

结语

中小企业选择适合业务流程的AI大模型工具时,除了比较当前效果,还应评估退出成本。

适配器负责吸收请求字段、响应结构和状态名称的差异;公共数据类定义业务真正需要的最小接口;契约测试保证新适配器不会破坏已有行为。

它不能消除模型之间的能力差异,也不应被用来绕过内容规则和人工审核。但它能把“换模型就重写业务代码”改造成一个范围明确、可以验证的迁移任务。

说明:本文使用AI工具辅助进行结构整理和语言优化,技术逻辑、示例代码和正文内容已由发布者人工审核。

更多推荐