最近在写一个需要接入大模型的 Python 项目。
最初的代码非常简单:
ini
from openai import OpenAI
client = OpenAI(
api_key="your-api-key",
base_url="https://example.com/v1"
)
response = client.chat.completions.create(
model="your-model-name",
messages=[
{
"role": "user",
"content": "解释一下什么是事件循环"
}
]
)
print(response.choices[0].message.content)
Demo 跑通没有问题,但需求很快发生了变化:
- 开发环境和生产环境使用不同地址
- 不同任务需要选择不同模型
- 某个模型不可用时需要临时切换
- 流式输出和普通输出都要支持
- 请求失败后不能无脑重试
- 业务代码不应该感知底层客户端
如果继续在每个业务文件中创建 OpenAI 客户端,模型调用逻辑很快就会散落在整个项目里。
这篇文章尝试解决一个具体问题:
如何设计一个简单、可切换、可测试,同时又不过度封装的 LLM 调用层?
一、写死配置会带来什么问题?
下面这种代码在原型阶段很常见:
ini
client = OpenAI(
api_key="sk-xxx",
base_url="https://example.com/v1"
)
response = client.chat.completions.create(
model="model-a",
messages=messages
)
它至少有三个问题。
1. 密钥可能被提交到代码仓库
即使仓库是私有的,也不建议把密钥写进代码。
密钥一旦进入 Git 历史,后续只删除当前文件是不够的。最稳妥的处理方式仍然是立即废弃旧密钥,并重新生成。
2. 环境切换需要修改代码
本地调试、测试和生产环境可能使用不同的接口地址。如果地址写死,每次部署都要修改源文件。
这不仅麻烦,也容易把测试配置带到生产环境。
3. 业务逻辑与具体模型耦合
假设项目中有摘要、翻译和代码分析三个功能:
python
def summarize(text: str):
# 创建客户端
# 指定模型
# 发送请求
pass
def translate(text: str):
# 再创建一次客户端
# 再指定一次模型
# 再发送请求
pass
随着功能增加,鉴权、超时和异常处理会被复制很多次。以后切换模型时,需要逐个文件检查。
因此,第一步不是设计复杂架构,而是把容易变化的部分从业务代码中抽离出来。
二、先把配置移到环境变量
我们先约定三个环境变量:
ini
export AI_API_KEY="your-api-key"
export AI_BASE_URL="https://your-api-provider.example/v1"
export AI_MODEL="your-model-name"
不要在代码中提供"看起来能用"的默认密钥或生产地址。
配置缺失时,程序应该尽早失败,而不是等到第一次请求才暴露问题。
ini
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class AISettings:
api_key: str
base_url: str
default_model: str
timeout: float = 60.0
@classmethod
def from_env(cls) -> "AISettings":
api_key = os.getenv("AI_API_KEY")
base_url = os.getenv("AI_BASE_URL")
default_model = os.getenv("AI_MODEL")
missing = [
name
for name, value in {
"AI_API_KEY": api_key,
"AI_BASE_URL": base_url,
"AI_MODEL": default_model
}.items()
if not value
]
if missing:
names = ", ".join(missing)
raise RuntimeError(f"缺少环境变量:{names}")
return cls(
api_key=api_key,
base_url=base_url.rstrip("/"),
default_model=default_model
)
这里使用 frozen=True,避免程序运行过程中意外修改配置。
rstrip("/") 则可以统一基础地址格式,减少拼接 URL 时产生双斜杠的情况。
三、业务层不应该直接依赖 SDK
如果所有业务函数都直接接收 OpenAI 客户端,那么业务层仍然依赖具体 SDK。
更合适的方式是先定义项目真正需要的能力。
python
from typing import Protocol
class ChatModel(Protocol):
def chat(
self,
prompt: str,
*,
system_prompt: str | None = None,
model: str | None = None
) -> str:
...
这个接口没有暴露 OpenAI SDK 的对象,也没有要求调用方理解 choices、messages 等响应结构。
业务层只知道:
- 输入一段 Prompt
- 可以指定系统提示词
- 可以临时指定模型
- 最终获得字符串
接下来再实现具体客户端。
python
from openai import OpenAI
class OpenAICompatibleChatModel:
def __init__(self, settings: AISettings):
self.settings = settings
self.client = OpenAI(
api_key=settings.api_key,
base_url=settings.base_url,
timeout=settings.timeout,
max_retries=0
)
def chat(
self,
prompt: str,
*,
system_prompt: str | None = None,
model: str | None = None
) -> str:
messages = []
if system_prompt:
messages.append({
"role": "system",
"content": system_prompt
})
messages.append({
"role": "user",
"content": prompt
})
response = self.client.chat.completions.create(
model=model or self.settings.default_model,
messages=messages
)
content = response.choices[0].message.content
if not content:
raise RuntimeError("模型返回了空内容")
return content
注意这里显式设置了:
ini
max_retries=0
这是因为我们准备在应用层实现自己的重试策略。
如果 SDK 内部重试三次,外部代码又重试三次,最坏情况下请求次数可能超出预期。显式关闭其中一层,更容易控制实际行为。
四、为什么不应该对所有错误进行重试?
最简单的重试代码可能长这样:
python
for _ in range(3):
try:
return request()
except Exception:
time.sleep(1)
这段代码的问题不是"不够优雅",而是可能产生错误行为。
以下错误通常不应该直接重试:
- API Key 无效
- 请求参数格式错误
- 模型名称不存在
- 当前账号没有访问权限
- 输入超过上下文限制
这些问题不会因为等待两秒自动恢复。
更适合重试的情况包括:
- 网络连接临时中断
- 请求频率受限
- 服务端暂时返回 5xx
- 网关短暂不可用
因此,重试前必须先对异常分类。
python
import random
import time
from openai import (
APIConnectionError,
APIStatusError,
RateLimitError
)
class RetryableChatModel:
def __init__(
self,
inner: ChatModel,
max_attempts: int = 3
):
self.inner = inner
self.max_attempts = max_attempts
def chat(
self,
prompt: str,
*,
system_prompt: str | None = None,
model: str | None = None
) -> str:
for attempt in range(self.max_attempts):
try:
return self.inner.chat(
prompt,
system_prompt=system_prompt,
model=model
)
except (APIConnectionError, RateLimitError):
if attempt == self.max_attempts - 1:
raise
self._wait(attempt)
except APIStatusError as error:
should_retry = error.status_code >= 500
if not should_retry:
raise
if attempt == self.max_attempts - 1:
raise
self._wait(attempt)
raise RuntimeError("模型请求失败")
@staticmethod
def _wait(attempt: int) -> None:
delay = (2 ** attempt) + random.uniform(0, 0.5)
time.sleep(delay)
等待时间大致为:
第 1 次失败:1~1.5 秒
第 2 次失败:2~2.5 秒
第 3 次失败:不再重试
在指数退避的基础上添加随机抖动,可以减少多个实例同时重试造成的请求尖峰。
五、把模型选择放在业务边界上
完成封装后,业务代码不再需要处理鉴权和响应解析:
ini
settings = AISettings.from_env()
base_model = OpenAICompatibleChatModel(settings)
chat_model = RetryableChatModel(base_model)
result = chat_model.chat(
"请找出下面代码中的并发问题",
system_prompt="你是一名 Python 代码审查工程师。"
)
print(result)
如果某个任务需要单独选择模型,可以在调用边界指定:
ini
result = chat_model.chat(
"把下面内容压缩成三条摘要",
model="another-model-name"
)
这里有一个值得注意的设计选择:
模型名称应该由谁决定?
我的建议是:
- 默认模型由部署配置决定
- 特定任务模型由应用服务层决定
- 不要让普通终端用户直接传入任意模型名称
如果直接把前端传来的 model 原样发送给接口,可能出现:
- 调用了未经过测试的模型
- 使用了成本过高的模型
- 模型能力与业务要求不匹配
- 用户绕过产品限制
更安全的做法是维护一张业务映射:
python
TASK_MODELS = {
"summary": "summary-model",
"coding": "coding-model",
"translation": "translation-model"
}
def get_model_for_task(task: str) -> str:
try:
return TASK_MODELS[task]
except KeyError as error:
raise ValueError(f"不支持的任务类型:{task}") from error
前端只提交任务类型,后端负责选择实际模型。
这样以后切换模型时,也不需要修改客户端或前端代码。
六、如何支持流式输出?
普通请求返回一个字符串,但流式请求更适合返回迭代器。
ini
from collections.abc import Iterator
class OpenAICompatibleChatModel:
def __init__(self, settings: AISettings):
self.settings = settings
self.client = OpenAI(
api_key=settings.api_key,
base_url=settings.base_url,
timeout=settings.timeout,
max_retries=0
)
def stream(
self,
prompt: str,
*,
system_prompt: str | None = None,
model: str | None = None
) -> Iterator[str]:
messages = []
if system_prompt:
messages.append({
"role": "system",
"content": system_prompt
})
messages.append({
"role": "user",
"content": prompt
})
response = self.client.chat.completions.create(
model=model or self.settings.default_model,
messages=messages,
stream=True
)
for chunk in response:
if not chunk.choices:
continue
content = chunk.choices[0].delta.content
if content:
yield content
调用方式:
ini
settings = AISettings.from_env()
chat_model = OpenAICompatibleChatModel(settings)
for text in chat_model.stream(
"解释一下 Python 中的生成器"
):
print(text, end="", flush=True)
这里不建议把所有片段先放进列表再返回,否则流式输出就失去了意义。
如果要接入 FastAPI,可以把迭代器交给 StreamingResponse:
python
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
settings = AISettings.from_env()
chat_model = OpenAICompatibleChatModel(settings)
@app.get("/chat")
def chat(prompt: str):
return StreamingResponse(
chat_model.stream(prompt),
media_type="text/plain; charset=utf-8"
)
真实项目通常会使用 SSE,并处理客户端断开、心跳和结束事件。这里先保留最小实现,重点展示分层方式。
七、抽象之后,测试会简单很多
如果业务代码直接依赖真实模型接口,单元测试会面临几个问题:
- 测试需要网络
- 测试需要真实密钥
- 每次运行可能产生费用
- 模型输出不完全确定
- 上游故障会导致测试失败
有了 ChatModel 接口后,可以创建一个假的实现:
python
class FakeChatModel:
def __init__(self, response: str):
self.response = response
self.last_prompt: str | None = None
def chat(
self,
prompt: str,
*,
system_prompt: str | None = None,
model: str | None = None
) -> str:
self.last_prompt = prompt
return self.response
假设我们有一个摘要服务:
python
class SummaryService:
def __init__(self, model: ChatModel):
self.model = model
def summarize(self, text: str) -> str:
return self.model.chat(
f"请将下面内容压缩成三条摘要:\n{text}",
system_prompt="只返回摘要,不要添加开场白。"
)
测试代码:
python
def test_summary_service():
fake_model = FakeChatModel(
"1. 配置与业务解耦\n"
"2. 对异常分类重试\n"
"3. 通过接口便于测试"
)
service = SummaryService(fake_model)
result = service.summarize("原始长文本")
assert "配置与业务解耦" in result
assert fake_model.last_prompt is not None
assert "原始长文本" in fake_model.last_prompt
这个测试不访问网络,也不关心底层使用什么模型。
这正是抽象调用层最实际的收益之一:它不只是为了"以后可能切换供应商",还可以让业务逻辑变得可测试。
八、哪些内容不应该急着封装?
做模型调用层时,很容易陷入过度设计。
例如,一开始就试图统一:
- 文本对话
- 图片生成
- 视频生成
- Embedding
- 语音合成
- 文件上传
- 工具调用
- 异步任务查询
这些能力的输入和生命周期差异很大。
文本对话通常是同步或流式响应,视频生成可能是:
创建任务 → 返回任务编号 → 轮询状态 → 获取结果
如果强行塞进同一个 generate() 方法,接口会出现大量可选参数,类型也会变得模糊。
更稳妥的方式是按照能力拆分:
kotlin
class ChatModel(Protocol):
...
class EmbeddingModel(Protocol):
...
class ImageGenerator(Protocol):
...
class AsyncVideoGenerator(Protocol):
...
只在业务确实需要时添加新的能力,不要提前构建一个"支持所有模型的终极抽象层"。
九、还需要考虑哪些生产问题?
本文代码可以解决基本的配置、切换与重试,但距离完整生产实现还有一段距离。
至少还应考虑:
可观测性
建议记录:
- 请求使用的模型
- 请求耗时
- 是否发生重试
- 响应状态
- Token 用量
- 请求追踪编号
不要默认记录完整 Prompt,尤其是在其中可能包含用户隐私或业务数据时。
并发限制
超时和重试只能处理失败,无法替代并发控制。
如果流量突然上升,可以通过信号量、任务队列或网关限制同时进行的模型请求数量。
请求幂等
对话请求通常可以安全重试,但带有外部副作用的 Agent 任务不一定可以。
例如,模型调用后可能触发:
- 创建订单
- 发送邮件
- 修改数据库
- 调用支付接口
这种情况下,重试边界必须放在副作用之前,并通过幂等键防止重复执行。
降级策略
降级并不只是"失败后换另一个模型"。
切换模型前还要确认:
- 上下文长度是否足够
- 是否支持工具调用
- 是否支持图片输入
- 输出格式是否一致
- 成本是否仍在可接受范围内
模型降级应该是一条经过测试的业务策略,而不是在异常处理中临时修改一个字符串。
最后
一个可维护的模型调用层不需要很复杂。
本文最终只做了几件事:
- 将密钥、地址和默认模型移到环境变量
- 用协议隔离业务层与具体 SDK
- 只对可能恢复的错误进行有限重试
- 在业务边界控制模型选择
- 分别处理普通响应和流式响应
- 使用假实现测试上层业务逻辑
其中最重要的不是"支持多少模型",而是明确每一层负责什么:
业务层:决定要完成什么任务
应用层:决定使用哪类模型能力
调用层:负责请求、解析和错误转换
配置层:保存地址、密钥和默认模型
如果项目目前只有一处模型调用,直接使用 SDK 完全没有问题。
但当第二个业务模块开始复制相同的客户端创建、消息拼装和异常处理代码时,通常就是抽出调用层的合适时机。