中小企业更换AI大模型工具,怎样避免重写业务代码?用适配器与契约测试隔离接口差异
中小企业选择AI大模型工具时,通常会比较模型效果、响应速度、数据边界和调用成本。但真正接入业务后,还有一个经常被低估的问题:如果以后更换模型提供方,现有代码需要改多少?
不同模型接口可能使用不同的请求字段、响应结构、结束原因和Token统计方式。如果业务代码直接读取某家接口的 choices、usage 或私有状态值,供应商差异就会扩散到摘要、分类、审核和内容生成的每个模块。
更稳妥的方法,是在业务代码与模型客户端之间增加适配器层,并用同一套契约测试验证每个适配器。业务层只认识自己的 ModelRequest 和 ModelResponse,不认识任何供应商的原始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)
它看起来很短,却同时依赖了四项外部约定:
- 文本位于
choices[0].message.content; - Token位于
usage.total_tokens; - 正常结束状态叫
stop; - 客户端异常和返回缺字段的处理方式固定。
如果另一家接口返回 output.text、usage.input 和 status=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文档。
本文实际验证了六种行为:
- 两个适配器得到相同业务结果;
- 两种结束状态都归一化为
stop; - Token计数均为非负数;
- 空提示词在调用传输层前被拒绝;
- 未知结束原因被拒绝;
- 空输出被拒绝。
本地运行结果:
Ran 6 tests in 0.000s
OK
这里没有调用真实网络,测试的是业务契约与字段转换。真实接入后,还应增加少量集成测试,验证鉴权、超时、限流和SDK实际返回结构。
九、不要把自动降级写成默认行为
有了两个适配器,很容易继续写:
try:
return primary.generate(request)
except Exception:
return backup.generate(request)
这段代码风险很高。权限错误、输入非法、内容被规则阻断,都不应通过切换供应商绕过。即使只是超时,也要考虑第一次请求是否已经完成,只是响应没有返回。
更合理的做法是先分类错误:
本地输入错误 直接拒绝
鉴权或权限错误 停止并告警
内容规则阻断 转人工,不切换
临时网络错误 有限重试
供应商不可用 按业务等级决定是否切换
未知错误 保守停止
切换模型还可能改变输出风格、长度和安全边界。即使通过公共契约,关键任务仍应经过人工审核或更细的业务回归测试。
十、如何选择内部契约的字段
内部契约过薄,业务层仍会绕过适配器读取供应商数据;契约过厚,又会强迫所有模型模拟某一家特有能力。
可以按三层划分:
第一层是所有模型共有的基础能力,例如文本输入、文本输出、结束状态和用量。
第二层是可选能力,例如工具调用、结构化输出和多模态输入。用明确的能力查询或独立接口表达,不要假设所有适配器都支持。
第三层是供应商专属能力,只保留在扩展模块中。如果业务强依赖它,就应承认这部分存在迁移成本,而不是用统一接口制造“完全可替换”的错觉。
适配器能降低耦合,但不能让不同模型的能力、质量和政策变得相同。
十一、适合中小企业的迁移顺序
如果现有代码已经直接依赖某家接口,不需要一次性重写所有模块。
第一步,统计业务代码实际使用了哪些原始字段。
第二步,从一个低风险任务提取 ModelRequest 和 ModelResponse。
第三步,为当前供应商实现第一个适配器,保证现有行为不变。
第四步,用虚拟传输函数建立契约测试,覆盖正常与失败边界。
第五步,再接入第二个适配器,并让它运行同一套测试。
第六步,只有契约和业务回归都通过后,才进行小范围真实请求验证。
对于OPC一人公司,这种设计也能减少多个自动化脚本各自维护模型调用的负担。智能体来了内容品牌关注的AI大模型工具深度运用,并不是频繁更换工具,而是让工具变化停留在可测试的边界内。
结语
中小企业选择适合业务流程的AI大模型工具时,除了比较当前效果,还应评估退出成本。
适配器负责吸收请求字段、响应结构和状态名称的差异;公共数据类定义业务真正需要的最小接口;契约测试保证新适配器不会破坏已有行为。
它不能消除模型之间的能力差异,也不应被用来绕过内容规则和人工审核。但它能把“换模型就重写业务代码”改造成一个范围明确、可以验证的迁移任务。
说明:本文使用AI工具辅助进行结构整理和语言优化,技术逻辑、示例代码和正文内容已由发布者人工审核。
更多推荐


所有评论(0)