别再把模型地址写死了:用 Python 设计一个可切换的 LLM 调用层

最近在写一个需要接入大模型的 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 的对象,也没有要求调用方理解 choicesmessages 等响应结构。

业务层只知道:

  • 输入一段 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 任务不一定可以。

例如,模型调用后可能触发:

  • 创建订单
  • 发送邮件
  • 修改数据库
  • 调用支付接口

这种情况下,重试边界必须放在副作用之前,并通过幂等键防止重复执行。

降级策略

降级并不只是"失败后换另一个模型"。

切换模型前还要确认:

  • 上下文长度是否足够
  • 是否支持工具调用
  • 是否支持图片输入
  • 输出格式是否一致
  • 成本是否仍在可接受范围内

模型降级应该是一条经过测试的业务策略,而不是在异常处理中临时修改一个字符串。

最后

一个可维护的模型调用层不需要很复杂。

本文最终只做了几件事:

  1. 将密钥、地址和默认模型移到环境变量
  2. 用协议隔离业务层与具体 SDK
  3. 只对可能恢复的错误进行有限重试
  4. 在业务边界控制模型选择
  5. 分别处理普通响应和流式响应
  6. 使用假实现测试上层业务逻辑

其中最重要的不是"支持多少模型",而是明确每一层负责什么:

复制代码
业务层:决定要完成什么任务
应用层:决定使用哪类模型能力
调用层:负责请求、解析和错误转换
配置层:保存地址、密钥和默认模型

如果项目目前只有一处模型调用,直接使用 SDK 完全没有问题。

但当第二个业务模块开始复制相同的客户端创建、消息拼装和异常处理代码时,通常就是抽出调用层的合适时机。

相关推荐
天天进步20155 小时前
Python全栈项目--协同办公平台
开发语言·python
GEOshijie1235 小时前
GEO服务商算法适配承诺怎么验收?48小时响应的合同化考核方案
人工智能·python
卷无止境6 小时前
FastAPI 前端托管全攻略:从静态文件到大型全栈项目架构
后端·python
QQ5416451216 小时前
【驿帮查件助手python开源】开源聚合查件机器人,实现 24 类驿站批量查件取代短信通知。技术方案分享
python·机器人·开源·驿站查件机器人
卷无止境6 小时前
当FastAPI项目开始"膨胀",代码该往哪儿放
后端·python
用户8356290780516 小时前
Python设置PowerPoint幻灯片背景的方法
后端·python
wang_yb6 小时前
Python 中 10 个最常用的统计函数
python·databook
databook6 小时前
Python 中 10 个最常用的统计函数
python·数据分析
艾琳_lab7 小时前
Python环境配置:pip镜像设置
python