1. 项目缘起:一个真实且普遍的工程困境

最近在跟几个做AI应用的朋友聊天,发现大家普遍被同一个问题困扰:项目初期,为了快速验证想法,团队可能直接调用了某个大模型厂商的API,比如OpenAI的ChatGPT。随着业务发展,模型选型的需求变得复杂起来——可能需要测试不同模型的性价比,或者为了满足合规要求必须切换到国产模型,又或者想引入某个在特定任务上表现惊艳的开源模型。这时,一个令人头疼的局面就出现了:业务代码里到处散落着对特定厂商SDK的调用,换模型意味着要一行行去改代码、适配接口、调整参数,不仅工作量巨大,还极易引入新的Bug。

这个场景,相信很多一线的开发者和技术负责人都不陌生。我们今天的主题,就是探讨如何系统性地解决这个问题,实现“多LLM供应商支持,且无需修改核心业务代码即可切换模型”。这听起来像是一个美好的愿景,但通过合理的架构设计,是完全可行的。其核心价值在于,将模型供应商的“变”与业务逻辑的“不变”进行解耦,为应用赋予真正的模型选择灵活性与抗风险能力。

2. 核心设计理念:抽象与适配

要实现“不改业务代码换模型”,关键在于引入一个抽象层。这个抽象层定义了你的应用与“大模型”交互的通用契约,而具体的模型供应商(Provider)则负责实现这个契约。这是一种经典的设计模式——策略模式(Strategy Pattern)或适配器模式(Adapter Pattern)在AI工程领域的应用。

2.1 定义统一的“模型”接口

首先,我们需要思考,业务代码到底需要模型做什么?抛开各家厂商五花八门的SDK和参数,其核心操作无非是以下几类:

  1. 文本补全/对话 :给定一段提示词(Prompt),获取模型的文本回复。
  2. 嵌入向量生成 :将文本转换为向量,用于检索、聚类等。
  3. 函数调用/工具调用 :让模型根据提示决定调用哪个工具(函数),并结构化输出参数。
  4. 流式输出 :逐词或逐句返回结果,提升用户体验。

因此,我们可以定义一个非常简洁的顶层接口。以Python为例,可以设计一个 LLMClient 基类:

from abc import ABC, abstractmethod
from typing import Any, AsyncGenerator, Dict, List, Optional

class LLMClient(ABC):
    """大模型客户端抽象基类"""

    @abstractmethod
    async def chat_completion(
        self,
        messages: List[Dict[str, str]],
        model: str,
        temperature: float = 0.7,
        max_tokens: Optional[int] = None,
        **kwargs
    ) -> Dict[str, Any]:
        """聊天补全接口"""
        pass

    @abstractmethod
    async def chat_completion_stream(
        self,
        messages: List[Dict[str, str]],
        model: str,
        temperature: float = 0.7,
        **kwargs
    ) -> AsyncGenerator[str, None]:
        """流式聊天补全接口"""
        pass

    @abstractmethod
    async def create_embedding(
        self,
        input_texts: List[str],
        model: str,
        **kwargs
    ) -> List[List[float]]:
        """创建嵌入向量接口"""
        pass

这个接口就是我们的“宪法”。所有业务代码,无论是处理用户问答的控制器,还是进行知识检索的服务,都只依赖这个 LLMClient 接口进行编程。它们不需要知道背后是GPT-4、Claude还是通义千问。

2.2 实现具体的供应商适配器

接下来,为每一个你想支持的模型供应商,创建一个该接口的具体实现类。

以OpenAI为例:

import openai
from .base import LLMClient

class OpenAIClient(LLMClient):
    def __init__(self, api_key: str, base_url: Optional[str] = None):
        self.client = openai.AsyncOpenAI(api_key=api_key, base_url=base_url)

    async def chat_completion(self, messages, model, temperature=0.7, max_tokens=None, **kwargs):
        response = await self.client.chat.completions.create(
            model=model,
            messages=messages,
            temperature=temperature,
            max_tokens=max_tokens,
            **kwargs
        )
        # 将响应统一转换为内部标准格式
        return {
            "content": response.choices[0].message.content,
            "role": response.choices[0].message.role,
            "model": response.model,
            "usage": dict(response.usage) if response.usage else None
        }

以 Anthropic Claude 为例:

import anthropic
from .base import LLMClient

class AnthropicClient(LLMClient):
    def __init__(self, api_key: str):
        self.client = anthropic.AsyncAnthropic(api_key=api_key)

    async def chat_completion(self, messages, model, temperature=0.7, max_tokens=None, **kwargs):
        # 注意:Claude的消息格式可能与OpenAI略有不同,需要转换
        # 例如,将OpenAI格式的messages转换为Claude可接受的格式
        converted_messages = self._convert_messages(messages)
        response = await self.client.messages.create(
            model=model,
            messages=converted_messages,
            temperature=temperature,
            max_tokens=max_tokens,
            **kwargs
        )
        # 再次统一输出格式
        return {
            "content": response.content[0].text,
            "role": "assistant",
            "model": response.model,
            "usage": {"input_tokens": response.usage.input_tokens, "output_tokens": response.usage.output_tokens}
        }

关键经验 :适配器层最重要的职责是“抹平差异”。不同供应商的SDK在参数命名、默认值、响应结构上都有差异。适配器的任务就是接受一套统一的输入,转换成供应商特定的格式发起请求,再将供应商的响应转换回统一的输出格式。这个过程可能会损失一些供应商独有的高级特性,但保证了核心功能的一致性。

3. 架构落地:工厂模式与配置驱动

有了抽象接口和具体实现,我们还需要一个“创建者”来根据配置,动态地提供正确的客户端实例。这里最适合使用工厂模式。

3.1 构建模型工厂

创建一个 LLMClientFactory ,它根据一个配置标识(如 provider )来实例化对应的客户端。

from typing import Union
from config import settings # 假设你的配置从settings模块读取

class LLMClientFactory:
    _clients = {}

    @classmethod
    def get_client(cls, provider: str = None, **kwargs) -> LLMClient:
        """获取指定供应商的LLM客户端实例"""
        provider = provider or settings.DEFAULT_LLM_PROVIDER

        # 简单的缓存,避免重复创建
        cache_key = f"{provider}:{hash(frozenset(kwargs.items()))}"
        if cache_key not in cls._clients:
            if provider == "openai":
                from .adapters.openai_client import OpenAIClient
                api_key = kwargs.get('api_key') or settings.OPENAI_API_KEY
                base_url = kwargs.get('base_url') or settings.OPENAI_BASE_URL
                cls._clients[cache_key] = OpenAIClient(api_key=api_key, base_url=base_url)
            elif provider == "anthropic":
                from .adapters.anthropic_client import AnthropicClient
                api_key = kwargs.get('api_key') or settings.ANTHROPIC_API_KEY
                cls._clients[cache_key] = AnthropicClient(api_key=api_key)
            elif provider == "azure_openai":
                from .adapters.azure_openai_client import AzureOpenAIClient
                # Azure OpenAI需要额外的配置如api_version, endpoint等
                cls._clients[cache_key] = AzureOpenAIClient(**kwargs)
            elif provider == "qwen": # 以阿里通义千问为例
                from .adapters.dashscope_client import DashScopeClient
                api_key = kwargs.get('api_key') or settings.DASHSCOPE_API_KEY
                cls._clients[cache_key] = DashScopeClient(api_key=api_key)
            else:
                raise ValueError(f"Unsupported LLM provider: {provider}")
        return cls._clients[cache_key]

3.2 配置驱动的业务代码

现在,你的业务代码可以完全与具体供应商解耦。例如,一个问答服务:

# service/chat_service.py
from llm_client_factory import LLMClientFactory

class ChatService:
    def __init__(self):
        # 从配置或上下文(如用户级别配置)决定使用哪个provider
        self.provider = self._determine_provider()

    def _determine_provider(self):
        # 这里可以实现复杂的逻辑,例如:
        # - 根据环境变量(生产/测试)
        # - 根据用户订阅的套餐(套餐A用GPT-3.5,套餐B用GPT-4)
        # - 根据当前负载自动降级到备用供应商
        # - 根据任务类型选择专用模型(创意写作用Claude,代码生成用CodeLlama)
        return "openai" # 简化示例

    async def answer_question(self, user_query: str, context: str = None):
        # 1. 通过工厂获取客户端,业务代码不感知具体是哪个供应商
        client = LLMClientFactory.get_client(provider=self.provider)

        # 2. 构建统一的Prompt消息
        messages = [
            {"role": "system", "content": "你是一个乐于助人的助手。"},
            {"role": "user", "content": f"基于以下背景:{context}\n\n请回答:{user_query}"}
        ]

        # 3. 调用统一的接口
        response = await client.chat_completion(
            messages=messages,
            model="gpt-3.5-turbo", # 这里的model名也可以是配置的一部分
            temperature=0.8
        )

        return response["content"]

切换模型供应商时,你只需要做什么?

  1. 修改配置文件中的一个值(例如 DEFAULT_LLM_PROVIDER = "qwen" )。
  2. 确保新供应商的API Key等配置已正确设置。
  3. 业务代码 ChatService 一行都不用改。

4. 进阶挑战与精细化设计

上述方案解决了基础问题,但在实际生产环境中,我们还会面临更多复杂场景。

4.1 模型参数的标准化与映射

不同供应商对同一概念的参数命名可能不同。例如,控制输出随机性的参数,OpenAI叫 temperature ,而有的平台可能叫 top_p randomness 。我们的统一接口需要做出决策。

方案一:以主流供应商为基准,在适配器内部做转换。 这是比较务实的做法。比如我们以OpenAI的参数集作为“标准”,在 AnthropicClient chat_completion 方法内部,将传入的 temperature 映射为Claude SDK所需的 temperature 。对于不支持的参数,可以记录日志或忽略。

方案二:定义一套完全中立的参数集。 这更彻底,但工作量也更大。我们需要定义一套与所有供应商都不同的参数名(如 creativity determinism ),然后在每个适配器里编写复杂的映射逻辑。除非有极强的定制化需求,否则方案一在大多数情况下更优。

实操心得 :不要追求100%的参数兼容。优先保证核心参数( model , messages , temperature , max_tokens , stream )的一致性。对于一些供应商特有的高级功能(如OpenAI的 function calling , Anthropic的 tool use ),可以考虑在统一接口上提供扩展点,或以可选参数的形式提供,并在文档中明确说明其供应商特异性。

4.2 流式输出的统一处理

流式输出(Streaming)是提升用户体验的关键。不同供应商的流式响应格式千差万别。

  • OpenAI : 返回一个异步生成器,每次yield一个 ChatCompletionChunk 对象,内容在 delta.content 里。
  • Anthropic : 返回一个流,数据格式是Server-Sent Events (SSE),需要解析 data: 后的JSON。
  • Azure OpenAI : 与OpenAI类似,但响应头或URL可能不同。

我们的统一流式接口 chat_completion_stream 需要返回一个 AsyncGenerator[str, None] ,即每次只yield纯文本的片段。这就要求适配器内部处理所有复杂的流解析逻辑,最终只吐出用户关心的“文本块”。

# 在OpenAIClient中的实现示例
async def chat_completion_stream(self, messages, model, temperature=0.7, **kwargs):
    stream = await self.client.chat.completions.create(
        model=model,
        messages=messages,
        temperature=temperature,
        stream=True,
        **kwargs
    )
    async for chunk in stream:
        if chunk.choices[0].delta.content is not None:
            yield chunk.choices[0].delta.content

# 在业务层的调用则完全一致
async for chunk in client.chat_completion_stream(messages, model):
    # 将chunk发送到前端或进行其他处理
    print(chunk, end='', flush=True)

4.3 错误处理与重试策略的统一

不同API的错误码和异常类型各不相同。网络超时、速率限制(429)、鉴权失败(401)、模型过载(503)等,都需要被统一捕获和处理。

我们可以在抽象基类或工厂类中封装一个通用的请求装饰器,实现:

  1. 异常转换 :将 openai.APIError anthropic.APIError 等转换为自定义的 LLMProviderError 或其子类(如 LLMRateLimitError LLMAuthenticationError )。
  2. 重试机制 :对于可重试的错误(如网络错误、速率限制),采用指数退避策略进行自动重试。
  3. 降级策略 :当主供应商持续失败时,自动切换到备用的供应商。
import asyncio
from functools import wraps
import random

def retry_with_backoff_and_fallback(primary_provider, fallback_providers):
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            providers = [primary_provider] + fallback_providers
            for i, provider in enumerate(providers):
                client = LLMClientFactory.get_client(provider=provider)
                try:
                    # 将client作为参数传递给原函数,这里需要调整原函数签名以接受client
                    # 为简化示例,我们假设func能通过某种方式使用正确的client
                    return await func(*args, **kwargs, client=client)
                except (LLMRateLimitError, LLMTimeoutError) as e:
                    if i == len(providers) - 1:
                        raise # 所有供应商都试过了,抛出异常
                    wait_time = (2 ** i) + random.uniform(0, 1) # 指数退避
                    print(f"请求失败,{wait_time:.2f}秒后尝试备用供应商 {providers[i+1]}")
                    await asyncio.sleep(wait_time)
                except LLMProviderError as e:
                    # 其他不可重试错误,直接抛出或记录日志
                    raise
            raise RuntimeError("所有供应商均不可用")
        return wrapper
    return decorator

4.4 成本、延迟与监控的聚合

当应用同时使用多个模型供应商时,监控变得至关重要。我们需要一个统一的监控层,来收集每次调用的关键指标:

  • 供应商和模型名
  • 请求/响应Token数 (用于成本计算)
  • 请求延迟
  • 成功/失败状态

可以在抽象基类的每个方法里加入装饰器或切面(Aspect),在请求前后记录这些信息,并发送到监控系统(如Prometheus、StatsD)或日志。

# 一个简化的监控装饰器
def monitor_llm_call(func):
    @wraps(func)
    async def wrapper(self, *args, **kwargs):
        start_time = time.time()
        model = kwargs.get('model', 'unknown')
        provider = self.__class__.__name__
        try:
            result = await func(self, *args, **kwargs)
            latency = (time.time() - start_time) * 1000 # 毫秒
            # 记录成功指标
            record_metrics(provider, model, "success", latency, result.get("usage"))
            return result
        except Exception as e:
            latency = (time.time() - start_time) * 1000
            # 记录失败指标
            record_metrics(provider, model, "failure", latency)
            raise
    return wrapper

# 在基类方法上应用
class LLMClient(ABC):
    @monitor_llm_call
    @abstractmethod
    async def chat_completion(self, ...):
        pass

有了这些聚合数据,你就能清晰地回答:哪个供应商在什么时间段延迟最低?哪个模型的性价比最高?从而为动态路由和成本优化提供数据支持。

5. 动态路由与智能调度

这是多供应商架构的“终极形态”。我们不满足于静态配置,而是希望系统能根据实时情况智能地选择最合适的供应商和模型。这需要一个“路由层”(Router)。

路由决策可以基于多种因素:

  • 成本 :优先选择每百万Token成本更低的模型。
  • 性能 :根据历史监控数据,选择当前延迟最低的供应商。
  • 功能 :根据任务类型路由(代码生成 -> CodeLlama, 创意写作 -> Claude)。
  • 负载均衡 :在多个同质化的供应商/API Key间轮询,避免触发单一账户的速率限制。
  • 故障转移 :当首选供应商不可用时,自动切换到备用。

路由层可以是一个简单的规则引擎,也可以是一个复杂的机器学习模型。一个基于规则的路由器示例:

class LLMRouter:
    def __init__(self, strategy: str = "cost"):
        self.strategy = strategy
        self.metrics_store = MetricsStore() # 假设有一个存储历史指标的对象

    async def get_client_for_task(self, task_type: str, budget: float = None) -> LLMClient:
        candidates = [
            {"provider": "openai", "model": "gpt-3.5-turbo", "cost_per_1k_input": 0.0015, "cost_per_1k_output": 0.002},
            {"provider": "openai", "model": "gpt-4", "cost_per_1k_input": 0.03, "cost_per_1k_output": 0.06},
            {"provider": "anthropic", "model": "claude-3-haiku", "cost_per_1k_input": 0.00025, "cost_per_1k_output": 0.00125},
            {"provider": "qwen", "model": "qwen-max", "cost_per_1k_input": 0.02, "cost_per_1k_output": 0.02},
        ]

        # 根据策略过滤和排序候选
        if self.strategy == "cost":
            filtered = [c for c in candidates if task_type != "complex_reasoning" or c["model"] != "gpt-3.5-turbo"]
            sorted_candidates = sorted(filtered, key=lambda x: x["cost_per_1k_input"] + x["cost_per_1k_output"])
        elif self.strategy == "performance":
            # 基于历史平均延迟排序
            for c in candidates:
                c["avg_latency"] = self.metrics_store.get_avg_latency(c["provider"], c["model"])
            sorted_candidates = sorted(candidates, key=lambda x: x.get("avg_latency", float('inf')))
        elif self.strategy == "task_based":
            if task_type == "code_generation":
                sorted_candidates = [c for c in candidates if "code" in c["model"].lower()] + candidates
            elif task_type == "creative_writing":
                sorted_candidates = [c for c in candidates if c["provider"] == "anthropic"] + candidates
            else:
                sorted_candidates = candidates

        # 选择最优候选,并通过工厂获取客户端
        best_choice = sorted_candidates[0]
        return LLMClientFactory.get_client(
            provider=best_choice["provider"],
            model=best_choice["model"] # 注意:这里需要工厂支持传入model,或客户端支持多模型
        )

业务代码调用路由器,而不是直接调用工厂:

router = LLMRouter(strategy="cost_based")
client = await router.get_client_for_task(task_type="general_qa")
response = await client.chat_completion(...)

6. 实战中的坑与最佳实践

在实施这套架构的过程中,我踩过不少坑,也总结出一些让系统更稳健的经验。

坑一:幻觉中的“统一” 最初,我试图设计一个能100%覆盖所有供应商所有功能的超级接口。结果发现,这几乎是不可能的,而且会让接口变得极其臃肿。 最佳实践是“最小化统一接口” 。只抽象出80%业务场景需要的核心功能。对于供应商特有的高级功能,提供“逃生舱”机制——允许业务代码在知晓供应商类型的情况下,直接调用底层SDK的特定方法。这比强行统一而扭曲设计要好。

坑二:配置管理的复杂性 当你有多个供应商、每个供应商有多个API Key、每个Key还有不同的速率限制和可用模型时,配置管理会变得混乱。 建议使用专门的配置管理服务或数据库表来存储这些信息 ,而不是散落在环境变量或配置文件中。可以为每个“模型终端”定义一个配置项,包含供应商、模型名、API Key、Base URL、优先级、成本系数等。

坑三:测试的挑战 如何测试多供应商的适配器?Mock是一个方法,但无法覆盖真实API的行为差异。 建立一套“集成测试沙盒”至关重要 。这套测试会用真实的API Key(可以是低权限的测试Key)调用每个供应商的适配器,验证基本功能(聊天、嵌入)是否工作,响应格式是否符合预期。这套测试不需要频繁运行,但每次添加新供应商或更新SDK时都必须跑一遍。

坑四:版本升级的连锁反应 模型供应商的SDK会升级,接口可能变化。你的适配器可能因此突然失效。 将每个供应商适配器及其依赖的SDK版本锁死在一个独立的虚拟环境或Docker层中 ,是一个隔离风险的好办法。同时,在监控中设置针对每个供应商接口调用失败率的告警,以便第一时间发现问题。

最佳实践:从第一天开始设计 如果你正在启动一个新的AI应用项目,即使初期只打算用一个供应商,也强烈建议在第一天就引入这个抽象层。成本极低(多写一个接口和适配器),但带来的长期灵活性是巨大的。这符合“依赖倒置”原则——高层业务模块不依赖低层细节(具体SDK),二者都依赖于抽象。

最佳实践:建立模型目录(Model Catalog) 维护一个内部的模型目录,记录每个可用模型的基本信息:供应商、模型标识符、上下文长度、是否支持函数调用、输入/输出成本、最佳适用场景等。这个目录可以作为路由器和前端UI的数据源,让团队所有人都清楚有哪些“武器”可用。

实现“多LLM供应商支持,不改业务代码换模型”,本质上是一场关于软件架构和工程规范的实践。它要求我们超越“快速调用API”的脚本思维,用设计模式、抽象层和配置化来构建健壮、可扩展的AI应用基础设施。虽然前期投入会多一些,但当你需要在GPT-4、Claude和国产模型之间轻松切换,或者因为某个供应商服务中断而自动故障转移时,你会庆幸当初做了这个决定。这套架构不仅提升了系统的技术弹性,更重要的是,它赋予了产品团队和业务决策者模型选择的自由,让技术更好地服务于业务目标。

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐