阅读大约需 8 分钟

上一篇回顾

上回说到,小刘用工厂模式治好了 if-else 泛滥的毛病。

产品经理再加新模型,他只需要写一行注册代码,5 分钟搞定。

小刘以为可以安心喝咖啡了。

直到老板走进了办公室。


新的噩梦

"小刘啊,"老板笑眯眯的,"OpenAI 的 API 太贵了,一个月烧了两万块。"

小刘心里咯噔一下。

"我听说 Ollama 可以本地跑模型,免费的。你把项目改成 Ollama 吧。"

小刘想了想,这不难啊,工厂模式不是白学的。改个配置就行。

"还有,"老板补充道,"客户 A 说他们公司只能用 OpenAI,客户 B 要用 NVIDIA NIM,客户 C……"

小刘的咖啡突然不香了。


问题在哪?

小刘打开代码,发现问题比想象的严重。

虽然工厂模式解决了"创建哪个对象"的问题,但不同模型的调用方式完全不一样

    
    
    
  # OpenAI 的调用方式
response = openai_client.chat.completions.create(
    model="gpt-4",
    messages=[{"role": "user", "content": prompt}]
)
result = response.choices[0].message.content

# Ollama 的调用方式
response = requests.post(
    "http://localhost:11434/api/generate",
    json={"model": "qwen", "prompt": prompt}
)
result = response.json()["response"]

# NVIDIA NIM 的调用方式
response = nim_client.generate(
    model="llama-3.1-70b",
    prompt=prompt,
    max_tokens=1024
)
result = response.text

三个模型,三种 API,三种响应格式。

小刘的业务代码里到处是这样的判断:

    
    
    
  def generate_story(prompt):
    if settings.LLM_TYPE == "openai":
        # OpenAI 的一套逻辑...
        response = openai_client.chat.completions.create(...)
        return response.choices[0].message.content
    elif settings.LLM_TYPE == "ollama":
        # Ollama 的一套逻辑...
        response = requests.post(...)
        return response.json()["response"]
    elif settings.LLM_TYPE == "nvidia-nim":
        # NVIDIA 的一套逻辑...
        pass

等等,这不是又回到 if-else 地狱了吗?

工厂模式解决的是"创建对象"的问题,但"使用对象"的方式不统一,照样一团乱。

小刘意识到,他需要另一个武器。


充电器的启示

小刘去倒水的时候,看到同事在给 iPhone 充电。

插座是国标三孔,充电器是美版双扁头,中间插着一个转换器。

小刘突然愣住了。

转换器!

不管是什么插头,转换器都能把它变成统一的接口。

代码不也可以这样吗?

不管是 OpenAI、Ollama 还是 NVIDIA NIM,都给它们套一个"转换器",对外提供统一的接口

这就是适配器模式


定义统一接口

小刘回到工位,首先定义了一个"标准插座"——所有模型都要遵守的接口:

    
    
    
  from abc import ABC, abstractmethod

class ModelAdapter(ABC):
    """LLM 适配器的统一接口"""

    @abstractmethod
    def generate(self, messages: list, temperature: float = 0.7) -> str:
        """
        生成回复

        不管底层是什么模型,调用方式都一样:
        - 输入:消息列表
        - 输出:字符串
        """
        pass

    @property
    @abstractmethod
    def model_name(self) -> str:
        """返回模型名称,方便日志和调试"""
        pass

这个基类就像一份"合同":

"不管你是 OpenAI 还是 Ollama,只要继承我,就必须提供 generate() 方法,而且输入输出格式都按我的来。"


给每个模型做转换器

接下来,小刘给每个模型写了一个适配器。

OpenAI 适配器

    
    
    
  class OpenAIAdapter(ModelAdapter):
    """OpenAI 的适配器"""

    def __init__(self, api_key: str, model: str = "gpt-4"):
        self.client = OpenAI(api_key=api_key)
        self._model = model

    def generate(self, messages: list, temperature: float = 0.7) -> str:
        # 把统一格式转成 OpenAI 的格式
        openai_messages = [
            {"role": msg["role"], "content": msg["content"]}
            for msg in messages
        ]

        response = self.client.chat.completions.create(
            model=self._model,
            messages=openai_messages,
            temperature=temperature
        )

        # 把 OpenAI 的响应转成统一格式
        return response.choices[0].message.content

    @property
    def model_name(self) -> str:
        return f"openai/{self._model}"

Ollama 适配器

    
    
    
  class OllamaAdapter(ModelAdapter):
    """Ollama 的适配器"""

    def __init__(self, base_url: str, model_name: str):
        self.base_url = base_url
        self._model_name = model_name

    def generate(self, messages: list, temperature: float = 0.7) -> str:
        # Ollama 不支持 messages 格式,要转成单个 prompt
        prompt = "\n".join([
            f"{msg['role']}: {msg['content']}"
            for msg in messages
        ])

        response = requests.post(
            f"{self.base_url}/api/generate",
            json={
                "model": self._model_name,
                "prompt": prompt,
                "stream": False,
                "options": {"temperature": temperature}
            }
        )

        return response.json()["response"]

    @property
    def model_name(self) -> str:
        return f"ollama/{self._model_name}"

NVIDIA NIM 适配器

    
    
    
  class NVIDIANIMAdapter(ModelAdapter):
    """NVIDIA NIM 的适配器"""

    def __init__(self, api_key: str, model: str = "meta/llama-3.1-70b-instruct"):
        self.client = OpenAI(
            api_key=api_key,
            base_url="https://integrate.api.nvidia.com/v1"
        )
        self._model = model

    def generate(self, messages: list, temperature: float = 0.7) -> str:
        response = self.client.chat.completions.create(
            model=self._model,
            messages=messages,
            temperature=temperature
        )
        return response.choices[0].message.content

    @property
    def model_name(self) -> str:
        return f"nvidia-nim/{self._model}"

业务代码的蜕变

现在,小刘的业务代码变得无比清爽:

    
    
    
  def generate_story(adapter: ModelAdapter, prompt: str) -> str:
    """生成故事 - 完全不关心用的是什么模型"""

    messages = [
        {"role": "system", "content": "你是一个故事创作大师"},
        {"role": "user", "content": prompt}
    ]

    return adapter.generate(messages)

没有 if-else。

没有模型细节。

就是这么干净。


配合工厂模式,效果更佳

还记得上篇的工厂模式吗?小刘把它和适配器模式结合起来:

    
    
    
  class ModelAdapterFactory:
    """适配器工厂"""

    _adapters = {
        "openai": OpenAIAdapter,
        "ollama": OllamaAdapter,
        "nvidia-nim": NVIDIANIMAdapter,
    }

    @classmethod
    def create(cls, adapter_type: str, **kwargs) -> ModelAdapter:
        if adapter_type not in cls._adapters:
            available = ", ".join(cls._adapters.keys())
            raise ValueError(f"不支持: {adapter_type},可用: {available}")
        return cls._adapters[adapter_type](**kwargs)

使用方式:

    
    
    
  # 从配置创建适配器
adapter = ModelAdapterFactory.create(
    settings.LLM_TYPE,      # "openai" / "ollama" / "nvidia-nim"
    **settings.LLM_CONFIG   # api_key, model 等参数
)

# 业务代码完全不变
story = generate_story(adapter, "写一个关于程序员的故事")

切换模型?改配置文件就行:

    
    
    
  # .env
LLM_TYPE=ollama
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=qwen3:32b

5 分钟?太慢了。5 秒钟!


三个细节,决定成败

适配器模式看起来简单,但有几个细节处理不好,会踩大坑。

细节 1:错误要统一包装

不同模型的错误格式不一样:

  • • OpenAI 抛 openai.APIError
  • • Ollama 返回 HTTP 错误
  • • NVIDIA NIM 抛 requests.exceptions.RequestException

业务代码不应该关心这些。小刘在基类里统一处理:

    
    
    
  class ModelAdapter(ABC):
    def generate(self, messages: list, **kwargs) -> str:
        try:
            return self._do_generate(messages, **kwargs)
        except Exception as e:
            # 统一包装成自定义异常
            raise LLMError(
                model=self.model_name,
                message=f"生成失败: {str(e)}",
                original_error=e
            )

    @abstractmethod
    def _do_generate(self, messages: list, **kwargs) -> str:
        """子类实现这个方法"""
        pass

细节 2:日志要带上模型信息

出问题时,第一个问题永远是"用的哪个模型?"

    
    
    
  def generate(self, messages: list, **kwargs) -> str:
    start_time = time.time()

    result = self._do_generate(messages, **kwargs)

    # 自动记录调用信息
    logger.info(
        f"LLM 调用完成 | "
        f"model={self.model_name} | "
        f"耗时={time.time() - start_time:.2f}s | "
        f"输出长度={len(result)}"
    )

    return result

凌晨三点查问题时,你会感谢自己的。

细节 3:测试要能 mock

适配器模式的一大好处是方便测试:

    
    
    
  class MockAdapter(ModelAdapter):
    """测试用的 Mock 适配器"""

    def __init__(self, response: str = "这是模拟回复"):
        self._response = response

    def generate(self, messages: list, **kwargs) -> str:
        return self._response

    @property
    def model_name(self) -> str:
        return "mock"

# 测试代码
def test_generate_story():
    mock_adapter = MockAdapter("从前有个程序员...")
    result = generate_story(mock_adapter, "写个故事")
    assert "程序员" in result

不用真的调 API,不花钱,不等待,测试飞快。


故事的结局

周一早会。

老板:"小刘,模型切换的事情搞得怎么样了?"

小刘:"搞定了。"

老板:"这么快?那客户 A 用 OpenAI,客户 B 用 NVIDIA NIM,客户 C 用 Ollama,都能支持?"

小刘打开配置文件:"您看,改这里就行。"

    
    
    
  # 客户 A
llm_type: openai

# 客户 B
llm_type: nvidia-nim

# 客户 C
llm_type: ollama

老板愣了一下:"就这?"

小刘:"就这。"

老板满意地点点头,转身走了。

小刘端起咖啡,看着窗外的阳光。

适配器 + 工厂,这套组合拳真香。


一张图总结


适配器 vs 工厂:别搞混了

工厂模式 适配器模式
解决什么问题 创建哪个对象 如何使用对象
关注点 实例化过程 接口统一
类比 餐厅点菜(选哪道菜) 充电转换器(统一接口)
一起用 工厂负责创建适配器,适配器负责统一接口

它们是最佳拍档,不是二选一。


下一篇,我们聊聊 AI 返回的 JSON 总是格式错误怎么办。小刘又要头疼了。

敬请期待。


#Python #设计模式 #适配器模式 #LLM #OpenAI #Ollama

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐