企业项目如何统一接入多个大模型?适配器模式实战
系列:Python + FastAPI 大模型应用基础(第 5 篇)
目标:使用 Adapter Pattern(适配器模式)隔离不同模型服务商的接口差异,让业务代码依赖稳定的内部契约,而不是依赖某一家厂商。
1. 为什么企业项目需要统一模型入口
项目早期通常只调用一个模型:
python
response = await client.post(
"https://某个模型服务地址/chat/completions",
json={"model": "某个模型", "messages": messages},
)
随着业务发展,可能出现以下需求:
- 国内和海外环境使用不同模型;
- 高质量任务和低成本任务使用不同模型;
- 某个模型不可用时切换备用服务;
- 文本生成、图片理解和工具调用使用不同模型;
- 为了数据合规,部分业务只能调用指定区域的服务;
- 需要比较多个模型的质量、时延和成本。
如果业务代码直接依赖某家厂商,很快会出现大量判断:
python
# 不推荐:厂商判断逐渐散落到所有业务代码中
if provider == "provider_a":
payload = {"messages": messages}
result = parse_provider_a(...)
elif provider == "provider_b":
payload = {"prompt": build_prompt(messages)}
result = parse_provider_b(...)
elif provider == "provider_c":
payload = {"input": {"text": user_message}}
result = parse_provider_c(...)
这段代码的根本问题不是 if/else 本身,而是业务规则和外部接口格式混在了一起。
2. 从第一性原理拆分稳定与变化
多模型接入中,相对稳定的是业务需求:
text
输入一组对话消息
↓
选择一个允许使用的模型
↓
得到文本、模型标识和用量信息
经常变化的是外部模型接口:
- URL 不同;
- 鉴权请求头不同;
- 请求体字段不同;
- System Message(系统消息)表达方式不同;
- 流式事件格式不同;
- Token 用量字段不同;
- 错误码和限流规则不同。
因此应该建立边界:
text
FastAPI / CRM / 企业微信等业务层
↓
ModelGateway 统一入口
↓
ModelAdapter 统一契约
↙ ↘
兼容式接口适配器 自定义 JSON 接口适配器
↓ ↓
外部模型 A 外部模型 B
业务层只依赖 ModelAdapter,不知道外部请求到底叫 messages、prompt 还是 input。
3. 什么是 Adapter Pattern(适配器模式)
适配器模式用于把一个已有接口转换成调用方期望的接口。
可以类比电源转换插头:
- 墙上的插座规格由外部环境决定;
- 电脑的充电接口由设备决定;
- 转换插头负责处理两者之间的差异;
- 电脑不需要为了每一种插座重新设计主板。
在模型项目中:
- 外部模型 API 相当于不同插座;
- 企业内部统一契约相当于电脑充电口;
- 每个模型 Adapter 相当于转换插头。
4. 本文要实现的目标
我们将实现两个示例适配器:
OpenAICompatibleAdapter:演示常见/chat/completions兼容结构;CustomJSONAdapter:演示完全不同的 JSON 请求和响应结构。
CustomJSONAdapter 是教学用的虚构协议,不代表任何真实厂商。接入真实服务时,必须根据相应官方文档修改。
最终业务调用形式保持一致:
python
result = await gateway.chat(
provider_name="compatible",
user_message="请解释 Python 装饰器。",
temperature=0.2,
)
5. 创建项目
项目结构:
text
multi_model_gateway/
├── app/
│ ├── __init__.py
│ ├── models.py
│ ├── adapters.py
│ ├── gateway.py
│ └── main.py
└── requirements.txt
本文使用 Python 3.10 及以上版本。
requirements.txt:
text
fastapi>=0.115,<1
uvicorn[standard]>=0.30,<1
httpx>=0.27,<1
pydantic>=2.7,<3
安装依赖:
powershell
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
app/__init__.py 是空文件,用来表示 app 是一个 Python 包。
6. 定义企业内部统一契约
新建 app/models.py:
python
from dataclasses import dataclass
from typing import Any, Literal, Protocol, runtime_checkable
class ModelGatewayError(RuntimeError):
"""模型网关对业务层暴露的统一异常。"""
class ProviderNotFoundError(ModelGatewayError):
"""业务请求了未注册或不允许使用的模型服务。"""
class ProviderRequestError(ModelGatewayError):
"""调用外部模型服务失败。"""
Role = Literal["system", "user", "assistant"]
@dataclass(frozen=True)
class ChatMessage:
"""企业内部统一的对话消息。"""
role: Role
content: str
@dataclass(frozen=True)
class ModelCapabilities:
"""声明一个适配器支持哪些能力。"""
supports_streaming: bool = False
supports_tools: bool = False
supports_images: bool = False
@dataclass(frozen=True)
class ChatResult:
"""所有模型适配器都必须返回的统一结果。"""
content: str
provider: str
model: str
usage: dict[str, Any] | None = None
@runtime_checkable
class ModelAdapter(Protocol):
"""所有模型适配器必须遵守的内部契约。"""
provider_name: str
capabilities: ModelCapabilities
async def chat(
self,
messages: list[ChatMessage],
temperature: float,
) -> ChatResult:
"""接收统一消息,返回统一结果。"""
...
为什么使用 Protocol
Protocol 表示结构化接口:一个类只要拥有约定的属性和方法,就可以作为 ModelAdapter 使用,不要求继承某个指定父类。
它的价值是:
- 编辑器和类型检查工具可以发现接口不一致;
- 测试时容易传入 Fake Adapter(模拟适配器);
- 适配器不需要共享复杂的父类实现;
- 业务层只依赖抽象契约。
Protocol 提供的是开发期约束,不等于运行时安全。外部响应仍然必须进行实际校验。
7. 实现两个不同协议的适配器
新建 app/adapters.py:
python
from typing import Any
import httpx
from app.models import (
ChatMessage,
ChatResult,
ModelCapabilities,
ProviderRequestError,
)
def parse_json_object(response: httpx.Response) -> dict[str, Any]:
"""把响应解析成 JSON 对象,并拒绝数组等非对象结果。"""
try:
data = response.json()
except ValueError as exc:
raise ProviderRequestError(
"模型服务返回的内容不是有效 JSON"
) from exc
if not isinstance(data, dict):
raise ProviderRequestError("模型服务返回的 JSON 不是对象")
return data
def check_http_status(response: httpx.Response) -> None:
"""把外部 HTTP 状态转换成稳定的内部错误。"""
status_code = response.status_code
if 200 <= status_code < 300:
return
# 不向业务层返回上游完整响应,避免泄露内部或敏感信息
if status_code == 401:
message = "模型服务鉴权失败"
elif status_code == 429:
message = "模型服务限流或额度受限"
elif status_code >= 500:
message = "模型服务暂时不可用"
else:
message = f"模型服务拒绝请求,状态码:{status_code}"
raise ProviderRequestError(message)
class OpenAICompatibleAdapter:
"""适配常见的 /chat/completions 请求与响应结构。"""
provider_name = "compatible"
capabilities = ModelCapabilities(
# "接口兼容"不代表一定支持流式和工具调用
# 在完成官方文档核对与实际测试前使用保守值
supports_streaming=False,
supports_tools=False,
supports_images=False,
)
def __init__(
self,
http_client: httpx.AsyncClient,
base_url: str,
api_key: str,
model: str,
) -> None:
self.http_client = http_client
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.model = model
async def chat(
self,
messages: list[ChatMessage],
temperature: float,
) -> ChatResult:
"""把内部消息转换成兼容式模型请求。"""
request_url = f"{self.base_url}/chat/completions"
request_headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
request_body = {
"model": self.model,
"messages": [
{"role": item.role, "content": item.content}
for item in messages
],
"temperature": temperature,
}
try:
response = await self.http_client.post(
request_url,
headers=request_headers,
json=request_body,
)
except httpx.TimeoutException as exc:
raise ProviderRequestError("兼容式模型请求超时") from exc
except httpx.HTTPError as exc:
raise ProviderRequestError(
"兼容式模型发生网络异常"
) from exc
check_http_status(response)
data = parse_json_object(response)
try:
content = data["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as exc:
raise ProviderRequestError(
"兼容式模型响应缺少文本字段"
) from exc
if not isinstance(content, str) or not content.strip():
raise ProviderRequestError("兼容式模型返回了空文本")
raw_usage = data.get("usage")
usage = raw_usage if isinstance(raw_usage, dict) else None
# 在 Adapter 边界内转换成企业内部统一结果
return ChatResult(
content=content.strip(),
provider=self.provider_name,
model=self.model,
usage=usage,
)
class CustomJSONAdapter:
"""适配教学用的虚构 /generate JSON 协议。"""
provider_name = "custom"
capabilities = ModelCapabilities(
supports_streaming=False,
supports_tools=False,
supports_images=False,
)
def __init__(
self,
http_client: httpx.AsyncClient,
base_url: str,
api_key: str,
model: str,
) -> None:
self.http_client = http_client
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.model = model
def _messages_to_prompt(self, messages: list[ChatMessage]) -> str:
"""把多角色消息转换成虚构接口需要的单个 prompt。"""
prompt_lines = []
for item in messages:
role_name = item.role.upper()
prompt_lines.append(f"{role_name}: {item.content}")
return "\n".join(prompt_lines)
async def chat(
self,
messages: list[ChatMessage],
temperature: float,
) -> ChatResult:
"""把内部消息转换成自定义 JSON 请求。"""
request_url = f"{self.base_url}/generate"
# 假设该虚构服务使用 X-API-Key,而不是 Bearer Token
request_headers = {
"X-API-Key": self.api_key,
"Content-Type": "application/json",
}
request_body = {
"model_id": self.model,
"prompt": self._messages_to_prompt(messages),
"randomness": temperature,
}
try:
response = await self.http_client.post(
request_url,
headers=request_headers,
json=request_body,
)
except httpx.TimeoutException as exc:
raise ProviderRequestError("自定义模型请求超时") from exc
except httpx.HTTPError as exc:
raise ProviderRequestError(
"自定义模型发生网络异常"
) from exc
check_http_status(response)
data = parse_json_object(response)
# 假设虚构接口的文本位于 output.text
try:
content = data["output"]["text"]
except (KeyError, TypeError) as exc:
raise ProviderRequestError(
"自定义模型响应缺少 output.text"
) from exc
if not isinstance(content, str) or not content.strip():
raise ProviderRequestError("自定义模型返回了空文本")
# 假设该接口使用 metrics 表示用量
raw_metrics = data.get("metrics")
usage = raw_metrics if isinstance(raw_metrics, dict) else None
return ChatResult(
content=content.strip(),
provider=self.provider_name,
model=self.model,
usage=usage,
)
两个 Adapter 的外部请求完全不同,但输入和返回值完全一致。这就是适配器的核心作用。
8. 实现统一模型网关
新建 app/gateway.py:
python
from collections.abc import Iterable
from app.models import (
ChatMessage,
ChatResult,
ModelAdapter,
ProviderNotFoundError,
)
class ModelGateway:
"""管理已注册适配器,并为业务层提供统一调用入口。"""
def __init__(self, adapters: Iterable[ModelAdapter]) -> None:
self._adapters: dict[str, ModelAdapter] = {}
for adapter in adapters:
provider_name = adapter.provider_name.strip()
if not provider_name:
raise ValueError("Adapter 的 provider_name 不能为空")
if provider_name in self._adapters:
raise ValueError(
f"模型服务重复注册:{provider_name}"
)
self._adapters[provider_name] = adapter
def get_adapter(self, provider_name: str) -> ModelAdapter:
"""只允许获取启动时明确注册的模型服务。"""
adapter = self._adapters.get(provider_name)
if adapter is None:
raise ProviderNotFoundError(
f"模型服务未注册或不允许使用:{provider_name}"
)
return adapter
async def chat(
self,
provider_name: str,
user_message: str,
temperature: float = 0.2,
) -> ChatResult:
"""校验业务参数,并通过指定适配器调用模型。"""
cleaned_message = user_message.strip()
if not cleaned_message:
raise ValueError("用户问题不能为空")
if not 0 <= temperature <= 2:
raise ValueError("temperature 必须位于 0 到 2 之间")
adapter = self.get_adapter(provider_name)
messages = [
ChatMessage(
role="system",
content=(
"你是一名严谨的 Python 教师。"
"不确定时请明确说明,不要编造。"
),
),
ChatMessage(role="user", content=cleaned_message),
]
# 业务层不关心适配器如何构造外部请求
return await adapter.chat(
messages=messages,
temperature=temperature,
)
def provider_names(self) -> list[str]:
"""返回当前进程明确注册的服务名称。"""
return sorted(self._adapters)
ModelGateway 不通过用户输入动态导入 Python 类,也不允许用户提交任意 URL。它只从启动时注册的白名单中选择适配器,避免形成 SSRF(服务器端请求伪造)风险。
9. 使用 FastAPI 对外提供统一接口
新建 app/main.py:
python
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from enum import Enum
import httpx
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, field_validator
from app.adapters import CustomJSONAdapter, OpenAICompatibleAdapter
from app.gateway import ModelGateway
from app.models import ModelAdapter, ModelGatewayError
class ProviderName(str, Enum):
"""API 调用者只能从明确允许的模型服务中选择。"""
compatible = "compatible"
custom = "custom"
class ChatRequest(BaseModel):
"""统一聊天接口的请求结构。"""
provider: ProviderName
user_message: str = Field(min_length=1, max_length=4000)
temperature: float = Field(default=0.2, ge=0, le=2)
@field_validator("user_message")
@classmethod
def message_must_not_be_blank(cls, value: str) -> str:
cleaned_value = value.strip()
if not cleaned_value:
raise ValueError("user_message 不能为空白字符串")
return cleaned_value
class ChatResponse(BaseModel):
"""无论调用哪个外部模型,都返回相同结构。"""
content: str
provider: str
model: str
usage: dict[str, object] | None = None
def required_env(name: str) -> str:
"""读取必需环境变量,缺失时阻止服务启动。"""
value = os.getenv(name, "").strip()
if not value:
raise RuntimeError(f"缺少环境变量:{name}")
return value
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""创建共享连接池、适配器和模型网关。"""
timeout = httpx.Timeout(
connect=5.0,
read=60.0,
write=10.0,
pool=5.0,
)
http_client = httpx.AsyncClient(timeout=timeout)
compatible_adapter = OpenAICompatibleAdapter(
http_client=http_client,
base_url=required_env("COMPATIBLE_BASE_URL"),
api_key=required_env("COMPATIBLE_API_KEY"),
model=required_env("COMPATIBLE_MODEL"),
)
adapters: list[ModelAdapter] = [compatible_adapter]
# CustomJSON 是教学协议,因此设置为可选服务
custom_base_url = os.getenv("CUSTOM_BASE_URL", "").strip()
custom_api_key = os.getenv("CUSTOM_API_KEY", "").strip()
custom_model = os.getenv("CUSTOM_MODEL", "").strip()
custom_values = [custom_base_url, custom_api_key, custom_model]
# 只配置一部分字段通常是部署错误,应在启动阶段直接失败
if any(custom_values) and not all(custom_values):
raise RuntimeError(
"CUSTOM_BASE_URL、CUSTOM_API_KEY、CUSTOM_MODEL "
"必须同时配置"
)
if all(custom_values):
adapters.append(
CustomJSONAdapter(
http_client=http_client,
base_url=custom_base_url,
api_key=custom_api_key,
model=custom_model,
)
)
app.state.gateway = ModelGateway(adapters=adapters)
yield
# 所有 Adapter 共享一个 AsyncClient,只需统一关闭一次
await http_client.aclose()
app = FastAPI(
title="企业统一模型网关",
version="1.0.0",
lifespan=lifespan,
)
@app.exception_handler(ModelGatewayError)
async def handle_gateway_error(
request: Request,
exc: ModelGatewayError,
) -> JSONResponse:
"""把内部统一异常转换成稳定的 API 响应。"""
return JSONResponse(
status_code=502,
content={"detail": str(exc)},
)
@app.post("/api/v1/chat", response_model=ChatResponse)
async def chat(
chat_request: ChatRequest,
request: Request,
) -> ChatResponse:
"""通过白名单中的指定适配器调用模型。"""
gateway: ModelGateway = request.app.state.gateway
result = await gateway.chat(
provider_name=chat_request.provider.value,
user_message=chat_request.user_message,
temperature=chat_request.temperature,
)
return ChatResponse(
content=result.content,
provider=result.provider,
model=result.model,
usage=result.usage,
)
设置环境变量:
powershell
$env:COMPATIBLE_BASE_URL = "https://兼容式服务地址/v1"
$env:COMPATIBLE_API_KEY = "替换为真实密钥"
$env:COMPATIBLE_MODEL = "替换为真实模型标识"
# 以下变量是可选项,对应教学用 CustomJSON 协议
# 只有接入真实且符合该结构的服务后才能配置,不能直接照抄
$env:CUSTOM_BASE_URL = "https://自定义服务地址"
$env:CUSTOM_API_KEY = "替换为真实密钥"
$env:CUSTOM_MODEL = "替换为真实模型标识"
如果当前只有一个真实的兼容式模型服务,只设置前三个 COMPATIBLE_* 变量即可,不要设置虚构的 CUSTOM_* 示例值。
启动服务:
powershell
.\.venv\Scripts\python.exe -m uvicorn app.main:app --reload
测试统一接口:
powershell
$body = @{
provider = "compatible"
user_message = "请解释 Python 上下文管理器。"
temperature = 0.2
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri "http://127.0.0.1:8000/api/v1/chat" `
-ContentType "application/json" `
-Body $body
切换模型服务时,业务请求结构不需要改变,只修改白名单内的 provider:
json
{
"provider": "custom",
"user_message": "请解释 Python 上下文管理器。",
"temperature": 0.2
}
10. 使用 Fake Adapter 验证业务层
适配器模式的一个直接收益是业务测试不需要调用真实模型。
python
import asyncio
from app.gateway import ModelGateway
from app.models import (
ChatMessage,
ChatResult,
ModelCapabilities,
)
class FakeAdapter:
"""只在测试中使用,不发送任何网络请求。"""
provider_name = "fake"
capabilities = ModelCapabilities()
async def chat(
self,
messages: list[ChatMessage],
temperature: float,
) -> ChatResult:
# 返回固定结果,使测试稳定、快速且不产生模型费用
return ChatResult(
content=f"测试回答:{messages[-1].content}",
provider=self.provider_name,
model="fake-model",
usage={"total_tokens": 0},
)
async def main() -> None:
gateway = ModelGateway([FakeAdapter()])
result = await gateway.chat(
provider_name="fake",
user_message="什么是 Adapter Pattern?",
)
assert result.provider == "fake"
assert result.model == "fake-model"
assert result.content.startswith("测试回答")
print("统一网关测试通过")
if __name__ == "__main__":
asyncio.run(main())
11. 为什么不能把"兼容"理解成"完全相同"
很多模型服务声称兼容某种常见格式,但兼容程度可能不同:
- 基础对话可用,工具调用不可用;
- 支持
messages,但不支持某些角色; - 支持流式输出,但结束事件不同;
temperature的范围或含义不同;- 图片输入格式不同;
- Token 用量字段缺失;
- 错误码、限流和重试规则不同。
因此,不能因为 URL 结尾都是 /chat/completions 就认为两个服务完全等价。每个 Adapter 都需要契约测试。
12. 为什么需要 Capabilities(能力声明)
如果业务要求工具调用,却路由到不支持工具的模型,失败可能发生在外部 API,也可能发生在模型生成阶段。
能力声明让网关可以在调用前拒绝不可能完成的任务:
python
adapter = gateway.get_adapter("custom")
if not adapter.capabilities.supports_tools:
raise ValueError("当前模型服务不支持工具调用")
随着项目扩大,可以继续声明:
- 是否支持流式输出;
- 是否支持图片;
- 是否支持结构化 JSON;
- 最大上下文长度;
- 数据所在区域;
- 单次调用成本等级。
注意:能力值不能凭猜测填写,应来自官方文档和实际验证。
13. 谁应该决定使用哪个模型
本文允许 API 调用者从白名单中选择 provider,便于教学和测试。但真实企业系统通常不应让普通用户随意选择底层厂商。
更合理的做法是由服务端根据业务场景路由:
text
合同摘要 → 指定高质量模型
内部知识问答 → 指定合规区域模型
简单分类 → 指定低成本模型
图片分析 → 只选择支持视觉的模型
模型路由属于业务策略,Adapter 只负责协议转换。不要把价格、场景和用户权限判断全部塞进 Adapter。
14. 是否应该自动故障切换
模型 A 失败后自动调用模型 B 看似合理,但可能改变:
- 数据传输区域;
- 隐私和合规边界;
- 输出质量;
- 费用;
- Prompt 兼容性;
- 工具调用行为。
因此不能默认"失败就随便换一家"。故障切换必须满足明确的业务许可、数据合规和能力要求,并记录最终使用了哪个模型。
15. 对抗性审查:当前实现还缺少什么
15.1 没有用户身份认证
示例接口只适合本地开发。生产环境必须验证用户身份,并限制用户能够调用的业务能力。
15.2 没有限流和熔断
某个模型持续故障时,网关仍会继续发出请求。下一篇将实现超时、重试、限流和 Circuit Breaker(熔断器)。
15.3 系统提示词仍然是统一模板
真实项目应根据业务场景选择服务端维护的 Prompt 模板,而不是允许普通用户提交任意系统提示词。
15.4 自定义接口的语义可能损失
把 system、user、assistant 拼成一个字符串,并不保证与原生多角色对话等价。接入时必须评测结果,不能只看请求是否成功。
15.5 统一结果可能丢失厂商特性
如果为了统一而只保留文本,工具调用、引用、推理摘要、内容审核结果等能力可能丢失。应为公共能力建立稳定结构,同时保留经过控制的扩展字段,避免退化成"最低公共能力"。
15.6 配置管理仍然较简单
大量模型配置不适合无限增加环境变量。生产系统可使用配置中心或密钥管理服务,但不能把真实密钥保存到代码仓库和普通数据库明文中。
16. 本篇总结
本文完成了多模型统一接入的基础架构:
- 业务层依赖稳定的
ModelAdapter契约; - 每个 Adapter 只处理一家服务的协议差异;
ModelGateway负责注册、选择和调用适配器;- 所有模型返回统一的
ChatResult; ModelCapabilities显式描述能力差异;- FastAPI 只允许选择已注册的模型白名单;
- Fake Adapter 让业务测试不依赖真实模型;
- 兼容式接口仍需根据官方文档和契约测试验证。
适配器模式解决的是"接口变化隔离",并不自动解决稳定性、成本、合规和模型效果问题。
下一篇将构建系统级韧性层,实现超时、有限重试、令牌桶限流和熔断器状态机。
17. 练习题
- 新增第三个 Fake Adapter,并验证重复名称会被拒绝;
- 为
ModelCapabilities增加supports_json_output; - 在网关中增加"需要工具调用"参数,并在请求前检查能力;
- 思考为什么不能让用户提交任意模型 URL;
- 为两个 Adapter 编写相同的契约测试;
- 记录每次请求最终使用的 provider、model、耗时和用量;
- 研究实际模型服务的消息格式,不要直接套用虚构的 CustomJSON 协议。