适配器模式(Adapter Pattern):原理、场景与 LLM 多厂商工程落地
适用口径:GoF 设计模式原义 +
langchain-core0.3.x /langchain-openai/langchain-anthropic。 本文在原始复盘稿基础上做了 5 处修正 ,其中 3 处是照原稿写会直接AttributeError的硬伤,见第 4 节各子小节。
一、概述
1.1 定义
适配器模式(Adapter Pattern)是一种结构型设计模式,核心目的是:
对多个接口不统一、行为不一致的异构第三方组件,通过包装适配,对外暴露一套统一、稳定、标准化的接口,彻底解耦上层业务与底层异构实现。
通俗理解:插头转换器。不同国家插头不一样(各家大模型),电器(Agent 业务)不改代码,只换转换器(适配器)。
原义补充 :GoF 里适配器有两种实现形态------ 类适配器 (多继承,同时继承 Target 与 Adaptee)与对象适配器 (组合,Adapter 内部持有 Adaptee 实例)。 Python / Java 工程几乎一律用对象适配器(组合优于继承),本文讨论的也是这一种。
1.2 解决的核心痛点
在多厂商、多异构 SDK 场景下,系统天然存在以下问题:
| # | 问题 |
|---|---|
| 1 | 不同厂商入参结构不统一(思考参数、温度、最大生成长度格式各异) |
| 2 | 不同厂商返回结构不统一(思考内容、token 消耗、停止原因字段不同) |
| 3 | 新增厂商需要大量修改业务代码,if-else 爆炸 |
| 4 | 上层业务必须感知底层模型差异,严重耦合、极难维护 |
适配器模式的终极目标:上层业务 0 感知底层厂商差异。
二、模式核心组成(标准三角色)
| 角色 | 定义 | 本项目对应 |
|---|---|---|
| Target(目标抽象接口) | 全系统统一的行为规范,所有适配器必须实现 | BaseLlmAdapter |
| Adaptee(被适配者) | 原生异构、无法直接给业务使用的底层组件 | OpenAI / Anthropic / DeepSeek / 通义千问 各家 SDK |
| Adapter(具体适配器) | 每个厂商一个,把"非标行为"翻译成"标准契约" | OpenAIAdapter / AnthropicAdapter / ... |
Target 的作用:统一入参、出参、行为定义;作为上层业务依赖的唯一标准;强制所有厂商抹平差异。
Adapter 内部只做一件事 :把厂商的"非标行为"翻译成系统的"标准契约"------ 包含参数适配、结构转换、字段映射、异常兼容、元数据归一化。
三、核心设计思想
3.1 单一职责
- 适配器 :只处理当前厂商独有的差异化逻辑
- 公共工厂 / 业务层 :只处理所有厂商共有的通用逻辑
3.2 开闭原则(最重要)
新增模型厂商,只新增适配器,不改动原有任何业务代码。 彻底解决传统 if-else 架构「加一个需求改一堆代码」的问题。
3.3 解耦思想
上层 Agent、对话链路、记忆链路、流式输出完全不依赖具体模型,只依赖统一适配器契约。
四、LLM 项目完整可运行落地示例
4.1 第一步:定义统一适配器契约(基类)
python
from abc import ABC, abstractmethod
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage # ← 修正点:invoke() 返回的是 AIMessage
class BaseLlmAdapter(ABC):
"""LLM 多厂商适配器统一契约。所有厂商必须实现,保证上层业务完全统一。"""
@abstractmethod
def build_model(self, raw_params: dict) -> BaseChatModel:
"""根据原始配置构建模型实例,内部完成厂商独有参数转换、嵌套适配。"""
raise NotImplementedError
@abstractmethod
def extract_metadata(self, message: AIMessage) -> dict:
"""统一元数据归一化:抹平各家返回字段差异。
统一输出:thinking / usage / finish_reason。"""
raise NotImplementedError
4.2 【修正 1】llm.invoke() 返回的是 AIMessage,不是 ChatResult
这是原稿最根本的一处类型错误,且会连锁导致后两个字段全部取不到。
| 调用方式 | 返回类型 | 说明 |
|---|---|---|
model.invoke(...) |
AIMessage |
单条对话,最常用 |
model.generate([messages]) |
ChatResult |
批量 / 多候选,含 generations: List[ChatGeneration] |
原稿 4.3 的调用是 resp = llm.invoke("你好") 然后 adapter.extract_metadata(resp), 但 extract_metadata 的签名标注的是 result: ChatResult------类型标注与实际调用对不上 。 invoke() 的返回值没有 .generations,后面 result.generations[0] 会直接炸。
4.3 【修正 2】usage_metadata 在 AIMessage 上,不在 ChatResult 上
LangChain 官方 AIMessage 参考明确列出:
| 属性 | 位置 |
|---|---|
usage_metadata |
AIMessage (UsageMetadata,跨厂商统一的 token 统计) |
response_metadata |
AIMessage(厂商原始响应:model_name、finish_reason 等) |
additional_kwargs |
AIMessage(厂商私有载荷) |
content / tool_calls |
AIMessage |
而 ChatResult 只有 generations: List[ChatGeneration] 与 llm_output: Optional[dict]------它没有 usage_metadata。
所以原稿的 "usage": result.usage_metadata 会抛 AttributeError。正确写法是 message.usage_metadata。
usage_metadata 的结构(官方口径):
python
{
"input_tokens": 111,
"output_tokens": 544,
"total_tokens": 655,
"input_token_details": {"cache_read": 0},
"output_token_details": {"reasoning": 460}, # 思考模型的推理 token 单独计
}
4.4 【修正 3】finish_reason 在 response_metadata 里,不在 ChatGeneration 上
ChatGeneration 的字段是 text / message / generation_info------没有 finish_reason 属性 。 原稿的 result.generations[0].finish_reason 同样是 AttributeError。
正确来源是 AIMessage.response_metadata["finish_reason"],常见取值:
| 值 | 含义 |
|---|---|
stop |
正常生成完毕 |
length |
达到 max_tokens 或上下文上限,被截断 |
tool_calls |
模型暂停文本生成,转为请求调用工具 |
content_filter |
被安全策略过滤 |
4.5 第二步:不同厂商实现各自适配器(修正后的可运行版)
OpenAI 兼容模型适配器
python
from langchain_openai import ChatOpenAI
from langchain_core.messages import AIMessage
class OpenAIAdapter(BaseLlmAdapter):
def build_model(self, raw_params: dict) -> BaseChatModel:
return ChatOpenAI(**raw_params) # OpenAI 协议标准,无需特殊转换
def extract_metadata(self, message: AIMessage) -> dict:
return {
# DeepSeek / 通义千问等把思考内容放在 reasoning_content
"thinking": message.additional_kwargs.get("reasoning_content", ""),
"usage": message.usage_metadata, # ✅ 修正
"finish_reason": message.response_metadata.get("finish_reason"), # ✅ 修正
}
Anthropic Claude 适配器(非标结构适配)
python
from langchain_anthropic import ChatAnthropic
class AnthropicAdapter(BaseLlmAdapter):
def build_model(self, raw_params: dict) -> BaseChatModel:
"""Claude 独有:平铺的 reasoning_effort 转成嵌套 thinking 结构。"""
params = raw_params.copy()
if "reasoning_effort" in params:
budget = params.pop("reasoning_effort")
params["thinking"] = {"type": "enabled", "budget_tokens": budget}
return ChatAnthropic(**params)
def extract_metadata(self, message: AIMessage) -> dict:
return {
# Claude 的思考以 content block 形式返回,content 可能是 list[dict]
"thinking": self._pick_thinking(message),
"usage": message.usage_metadata,
"finish_reason": message.response_metadata.get("finish_reason"),
}
@staticmethod
def _pick_thinking(message: AIMessage) -> str:
content = message.content
if isinstance(content, list):
return "".join(
b.get("thinking", "") for b in content
if isinstance(b, dict) and b.get("type") == "thinking"
)
return message.additional_kwargs.get("thinking", "")
4.6 【修正 4】Claude budget_tokens 有三条硬约束,原稿没写
Anthropic 官方《Extended thinking》文档给出的约束:
| # | 约束 | 后果 |
|---|---|---|
| 1 | budget_tokens 最小 1024 |
更小的值 API 直接拒绝 |
| 2 | budget_tokens 必须严格小于 max_tokens |
思考 token 计入 max_tokens,必须给最终回答留空间 |
| 3 | 不能与 max_tokens: 0 同用 |
因为违反第 2 条,extended thinking 无法配合 cache pre-warming |
工程建议:max_tokens >= budget_tokens + 4000(给最终输出留缓冲), 常见配法是 50/50:max_tokens=8192 配 budget_tokens=4096。
多轮会话还有一个坑 :开启 thinking 后,Claude 返回的 thinking block 带加密签名 。 下一轮必须原样回传 (顺序一致、签名字节一致),否则报
BadRequestError: ... signature mismatch。所以多轮历史里不能用msg.text()把 thinking 抹平。
4.7 第三步:上层业务统一调用(完全无感知厂商)
python
llm, adapter = factory.create(llm_use, llm_params)
message = llm.invoke("你好") # 返回 AIMessage
meta = adapter.extract_metadata(message) # 自动归一化,无需 if 判断模型
五、适配器模式在本项目解决的具体问题
| # | 问题 | 适配器如何处理 |
|---|---|---|
| 5.1 | Reasoning Contract 推理契约 :thinking=True / reasoning_effort="high" / thinking={"budget":1024} 各家格式不同 |
适配器内部统一翻译,上层只用一套配置 |
| 5.2 | 元数据不统一:思考内容、token 字段各家命名混乱 | 适配器统一剥离、归一化输出 |
| 5.3 | 多厂商 if-else 泛滥 | 厂商越多收益越大,代码越干净 |
六、【修正 5】BaseLlmAdapter 严格说不是「纯 GoF 适配器」
这是原稿在模式定位上值得精准化的一点。
GoF 原义的 Adapter:Adapter 自身实现 Target 接口,客户端直接调用 Adapter 的方法,Adapter 内部转发给 Adaptee。
对照本文的 BaseLlmAdapter:
| 方法 | 真实职责 | 对应模式 |
|---|---|---|
build_model(raw_params) |
返回一个构建好的对象,而非"被调用后转发" | 工厂 / 构建器(Factory / Builder) |
extract_metadata(msg) |
把厂商返回结构转换成统一结构 | 适配器(真正的适配部分) |
也就是说,这个"适配器"实际是三种模式的组合体:
- 适配器模式:抹平厂商返回差异、统一元数据(核心)
- 工厂 / 构建器模式:统一创建入口、统一参数校验、统一限流
- 注册器模式:用 Map 注册表干掉所有 if-else
原稿第 6 节已经点出"三位一体",但没意识到第 4 节的基类本身就夹带了工厂职责 ------ build_model 不是适配,是构建。把这点说清楚,可以避免团队里对"到底在适配什么"的长期争论。
建议的接口切分(如果要做纯粹):
python
class LlmFactory(ABC): # 工厂:负责构建
@abstractmethod
def build_model(self, raw_params: dict) -> BaseChatModel: ...
class MetadataAdapter(ABC): # 适配器:负责归一化
@abstractmethod
def extract_metadata(self, message: AIMessage) -> dict: ...
七、适用场景(严格总结)
满足以下任意场景,必须使用适配器模式:
- 多供应商、多三方异构接口
- 入参、出参结构不统一,需要归一化
- 后续会持续新增供应商,需要低侵入扩展
- 上层业务需要稳定、不受底层迭代影响
八、什么时候不需要适配器(避坑:防止过度设计)
厂商数量 ≤ 3 个、差异极少时,不需要适配器。 此时简单 if-else 代码更短、更好维护、调试更快,抽象反而增加负担。
这正是 Rule of Three(三次法则):第一次直接写,第二次复制容忍,第三次出现重复再抽象。
抽象的时机:出现重复粘贴代码、厂商 ≥ 4 个时再重构。
九、总结
适配器模式的本质:隔离变化、统一契约、收敛差异。
底层各家模型千奇百怪,适配器负责把所有"不标准",统一翻译成系统的"标准"。 最终实现:底层随便扩展,上层永远不动的高可扩展架构。
一句话背诵版:
三角色:Target 定契约、Adaptee 是异构、Adapter 做翻译; 两原则:单一职责分公私、开闭原则加厂商; 一红线:厂商 ≤ 3 别抽象,
invoke回来是AIMessage。