接口对不上还硬改适配器模式当翻译层关键词设计模式、结构型模式、适配器模式、Python、大模型工程目录一、新组件说另一种方言你的代码怎么办二、适配器模式在中间加一层「翻译」三、Python 的鸭子类型让适配器还能更轻四、对象适配器还是类适配器组合优先五、大模型工程里适配器为什么到处都是六、三条记住就行一、新组件说另一种方言你的代码怎么办你辛辛苦苦把一套系统搭在干净接口之上业务代码只管调用analyze(text)拿回一个情感分析结果。某天需求来了——团队想试一家新厂商的模型或者要接入三年前写的那个遗留打分模块。麻烦在于新来的这位说的是另一种方言一个返回{choices:[{message:{content:...}}]}另一个只认infer(prompt)回的是{completion:...}。两边都不能改一个是第三方 SDK一个是没人敢动的旧代码。最直接的反应是在业务里散一堆分支if vendor A ... elif vendor B ...。两家勉强撑得住到第五家就崩了而且每接一个新后端核心代码都得再改一遍。更隐蔽的代价在测试上因为核心逻辑里直接揉了厂商调用你想给核心写单测要么真联网打厂商接口要么把那一坨分支整个 mock 掉——结果测的不是业务逻辑是分支本身。这种「让核心去学新方言」的写法问题不在当下能跑而在它把「具体厂商」和「业务决策」焊死在了一起。供应商从两家变五家时每一次新增都是一次对核心的侵入式修改而侵入式修改又是 bug 最喜欢的温床。更干净的做法是反过来别让核心去学新方言而是在中间塞一个翻译。这个翻译就是适配器模式Adapter。适配器属于 GoF 的结构型模式定义很朴素把一个类的接口转换成客户期望的另一个接口让本来因为接口对不上而没法合作的类能一起干活。注意关键词是「转换接口」而不是「改接口」——被适配的那一方原样不动动的是中间这层。把这句记牢后面所有判断都不会偏适配器是保护双方、只改中间层的模式不是去动任何一方的内部实现。一个很实用的判断标准当你想「改一改被适配者让它适配我」时先问它能不能改。第三方 SDK 改了下次升级就被覆盖遗留模块改了没人敢签字发布——这两类从源头就排除了「改源码」这条路适配器几乎是唯一干净的选项。反过来如果那个不兼容的接口是你自己两周前写的与其在外面套适配器不如直接把接口改顺适配器反而成了多余的间接层。所以适配器不是「接口不对就无脑套」而是「不能改、又必须接」时的那条缝。还有个容易忽略的视角分支式接入让核心同时背负「业务规则」和「厂商差异」两件事开发者每次读核心代码都得在脑子里切换上下文。适配器把「厂商差异」单独抽走之后核心终于只剩业务规则可读性和可测性一起回来。界面名义上接上了心智模型却更乱了——这正是该用适配器而不是硬改的信号。举个最小化的反例核心里写if model gpt: client OpenAI(...) elif model claude: client Anthropic(...) elif model local: client Ollama(...)然后后面每一处调用都带同样的判断。三家看着还行加到第八家时这段判断已经散在十几个函数里改一家要翻遍全仓库。适配器把这段「判断 转换」收进一个类核心永远只写provider.generate_completion(...)换厂商只是换掉那个类核心里一个if都不用留。二、适配器模式在中间加一层「翻译」适配器模式把职责拆成四个角色各管一摊Target目标接口客户端期望的契约比如「要会 quack 和 fly」。它是客户说出来的「我想要的样子」由适配器去实现。Adaptee被适配者已有但接口不兼容的类比如一只只会 gobble 和短途飞的火鸡。它是被保护的对象原则上不该为别人改自己。Adapter适配器实现 Target内部持有 Adaptee 的引用把 Target 的调用翻译成 Adaptee 能懂的调用。它是一层纯粹的「翻译」不新增业务语义。Client客户端只认 Target 接口从头到尾不认识 Adaptee。图一适配器的四个角色——客户端只认接口适配器专职翻译被适配者保持原样不被污染最经典的例子是「让火鸡伪装成鸭子」。鸭子接口要求quack()和fly()火鸡只有gobble(n)和fly_to()而且飞不远。适配器把quack()转成火鸡的gobble(3)把fly()转成连飞五次来凑鸭子一趟的距离classDuck:defquack(self):returnQuack!deffly(self):returnIm flying!classTurkey:defgobble(self,n):returngobble *ndeffly_to(self):returnIm flying a short distanceclassTurkeyAdapter:# 实现 Duck 接口但内部把调用翻译给 Turkeydef__init__(self,turkey):self.turkeyturkeydefquack(self):returnself.turkey.gobble(3).strip() (适配器把咯咯叫翻译成了呱呱叫)deffly(self):trips[self.turkey.fly_to()for_inrange(5)]return / .join(trips) (火鸡飞不远适配器连飞5次凑够鸭子一趟的距离)defclient_use(duck):# 客户端只认 Duck 接口不关心背后是鸭还是火鸡print(quack -,duck.quack())print(fly -,duck.fly())client_use(Duck())# 真鸭子client_use(TurkeyAdapter(Turkey()))# 火鸡套上适配器跑出来的输出两端走的是同一个client_use但行为被翻译到位了真鸭子 quack - Quack! fly - Im flying! 火鸡套上适配器后 quack - gobble gobble gobble (适配器把咯咯叫翻译成了呱呱叫) fly - Im flying a short distance / ... (连飞5次凑够鸭子一趟的距离)适配器真正的价值在这里显形被适配的Turkey一行没改客户端的client_use一行没改唯一的改动点是新增了一个「翻译层」。将来再要接入一只企鹅、一只蝙蝠都只是再写一个适配器核心代码永远不动。容易和它搞混的两个模式顺带分清装饰器Decorator是给同一个接口「加职责」接口本身不变比如给一个Quack套上「带回声的 Quack」门面Facade是给一堆复杂子系统一个「简化入口」接口不一定是被迫转换而是主动收敛复杂度。适配器不一样——它是被迫的因为两边接口已经对不上、又都不能改才需要在中间翻译。分辨它们只看一点有没有「把 A 的接口翻译成 B 期望的接口」这层被迫的转换有才是适配器。顺带把「翻译」这件事拆开看它通常包含三件小事方法名映射把gobble当quack用、参数重整火鸡一次飞不远适配器就循环五次凑距离、返回值重整把{completion: ...}抽成纯文本。适配器里最容易被忽略的是第三件——被适配者返回的结构往往比你要的「胖」翻译不仅是换名字还要把多余字段剥掉、把缺的字段补上。拿火鸡那例说客户端要的是「叫一声 飞一趟」两个干净动作适配器负责把火鸡「咯咯叫 N 次 短途飞五次」这种原始动作翻译成客户端无感的统一行为。写适配器时最值得花心思的就是这层返回值的归一化它直接决定上游能不能真的「无感」。还有一层更深的判断适配器擅长翻译「形状」方法名、参数个数、返回结构不擅长翻译「语义」。如果两边的接口不只是长得不一样而是根本表达不同的事——比如一边要「同步阻塞调用」另一边只有「异步流式返回」——硬套适配器会把语义强行抹平调用方迟早踩坑。这种时候该谈的是协议升级或接口重新设计而不是加一层翻译。适配器解决的是「说同一种事但方言不同」不是「根本不是一回事」。顺带澄清一个常见误用有人把「为了测试而抽的接口」也当成适配器其实那叫「依赖倒置」适配器是依赖倒置之后、用来填两边接口落差的那个具体实现。先有「核心只依赖抽象」的设计才有「用适配器接具体实现」的落点反过来一上来就堆适配器、核心却直接依赖具体类适配器几乎从不需要。当然适配器也不是多多益善。一个常见反模式是给「自己完全掌控、且只有一个调用点」的类也套一层适配器理由是「万一以后要换呢」。这种「防御性间接层」在没发生之前只是噪音它没解决任何当前的不兼容还多了一次跳转让人读代码多绕一道。判断该不该写回到最朴素的准绳——现在就有「不能改又对不上」的双方吗有才写没有就先直连。三、Python 的鸭子类型让适配器还能更轻到了 Python 这儿适配器有了一个别的语言给不了的小特权鸭子类型。所谓鸭子类型就是「只要走路像鸭子、叫起来像鸭子那就当它是鸭子」——解释器不在乎一个对象是不是真的继承了某个 Target 基类只在乎它有没有那个方法。这意味着很多时候你根本不需要先定义一个正式的 Target 接口适配器只要「长得像」客户端期望的样子就行。下面两个类没有任何共同父类但因为都有get_products()客户端就能一视同仁地遍历它们classSupermarketOne:defget_products(self):return{apples:0.2,oranges:0.3}classSupermarketTwo:defget_fruit(self):return[(apples,0.19)]defget_meat(self):return[(lamb,6.17)]classSupermarketTwoAdapter:# 没有继承任何 Target只是凑齐了 get_products 这个方法def__init__(self,s2):self.s2s2defget_products(self):returndict(self.s2.get_fruit()self.s2.get_meat())defshop(source):return%s - %s%(type(source).__name__,source.get_products())forsin[SupermarketOne(),SupermarketTwoAdapter(SupermarketTwo())]:print(shop(s))输出很直白SupermarketOne - {apples: 0.2, oranges: 0.3} SupermarketTwoAdapter - {apples: 0.19, lamb: 6.17}两个类没有共同父类但因都有get_products()客户端一视同仁。这就是鸭子类型给适配器减的「重量」你省掉了先声明接口、再让人去 implements 的那套仪式。写个内部脚本接两个数据源这种写法又快又省心。不过轻归轻别走极端鸭子类型也有暗坑。两个八竿子打不着的类可能恰好都叫save()但一个把数据落盘、一个把 ORM 改动刷进数据库——名字一样副作用天差地别。如果你只靠鸭子类型把它们塞进同一个列表循环调用save()文件确实写了、事务却悄悄提交了这种 bug 不报错、只在生产环境偶发。举个具体的FileStore.save()是序列化落盘Session.save()是提交事务调用方若只看方法名就当成同类埋下的就是静默故障。所以「方法名凑齐就当成同类」在边界处要格外小心这正是 ABC / Protocol 存在的意义把「长得像」升级成「契约上确实是一类」。更现代一点Python 的typing.Protocol给了第三条路结构化子类型。你用Protocol描述「要有 get_products 这个方法」既保留了鸭子类型「不用显式继承」的轻又能在静态检查阶段就发现「某个类其实没凑齐方法」。它的好处是「结构性子类型」——只要类长这样就算实现不用改类的继承树去强行认祖归宗。结论很实用快速原型、内部脚本鸭子类型让适配器零负担要进长期维护的库或团队代码用 ABC 或 Protocol 把接口钉住既保留适配器的灵活又给后来人一张地图。用法长这样你声明class ProductSource(Protocol): def get_products(self) - dict: ...然后SupermarketOne即便不继承它只要方法有同样的签名静态检查器就认它是ProductSource。比起硬继承 ABCProtocol 不污染类的继承树特别适合给第三方类「补一张契约」——你改不了人家的类又想给它贴个接口标签Protocol 正好补上这块。四、对象适配器还是类适配器组合优先GoF 其实给了适配器两种写法差别只在「适配器怎么够到被适配者」对象适配器靠组合适配器内部持有一个 Adaptee 实例委托它干活。类适配器靠多继承适配器同时继承 Target 和 Adaptee直接调用父类方法。下面这段把两种都写出来了Python 的多继承让类适配器也能成立classTarget:defrequest(self):raiseNotImplementedErrorclassAdaptee:defspecific_request(self):returnAdaptee 的特定能力classObjectAdapter(Target):# 对象适配器组合def__init__(self,adaptee):self.adapteeadapteedefrequest(self):return组合转发 - self.adaptee.specific_request()classClassAdapter(Target,Adaptee):# 类适配器多继承defrequest(self):return继承直调 - self.specific_request()print(ObjectAdapter(Adaptee()).request())# 组合转发 - Adaptee 的特定能力print(ClassAdapter().request())# 继承直调 - Adaptee 的特定能力两者都能跑出结果但取舍很清楚图二对象适配器靠组合、类适配器靠多继承选型的天平明显倾向组合对象适配器胜在灵活一个适配器能同时适配Adaptee及其所有子类测试时把 Adaptee 换成 mock 也毫无压力。类适配器在编译期就绑死在某个具体 Adaptee 上换子类就废了——假如后来Adaptee出了个SpecialAdaptee子类你得再写一个SpecialClassAdapter而对象适配器只要把SpecialAdaptee实例传进去就完了。补充一个工程视角对象适配器还顺带解决了「适配 Adaptee 全家」的问题。因为组合持有的是 Adaptee 类型的引用传进去一个子类实例也能正常工作适配器本身一行不用改而类适配器因为继承关系在类定义时就焊死遇到子类只能新写一份。项目里被适配的对象一旦有继承体系组合的优势会被进一步放大。还有个跨语言的事实在 Java、C# 这类没有多继承的语言里类适配器压根不是可选项组合是唯一路径。这也解释了为什么 GoF 虽列出两种工业界却几乎一边倒地用对象适配器——不是因为类适配器「错」而是它的收益能覆写 Adaptee在大多数场景里抵不过它的代价绑死具体类、无法覆盖子类、不可测。所以默认答案永远是对象适配器只有当你确实需要覆写 Adaptee 内部行为、又确定不会换子类时才值得为多继承那份耦合买单。把这条当成肌肉记忆选型时基本不会错。五、大模型工程里适配器为什么到处都是讲到这你大概已经发现适配器在大模型工程里根本不是「偶尔用用」的偏门技巧而是把一堆接口各异的模型和服务接进统一业务的家常手段。它在大模型开发里的位置很明确——你是写应用的人真正要对接的模型、向量库、检索源天天在换适配器就是那层让你「换底层不换上层」的缓冲。它和「门面」「代理」这些结构型模式常被一起提起但在大模型工程里它的出场频率最高原因很直白这一行技术迭代太快模型、向量库、检索后端半年一换而你的业务需求没那么爱变。凡是「变的比不变的多」的接缝都该用适配器护住不变的那一侧。场景一统一多家模型 Provider 接口。这是最典型的一处。OpenAI、Anthropic、本地 Ollama、各家兼容网关调用方式和返回结构全不一样有的用choices[0].message.content有的用completion入参一个叫messages一个叫prompt连抛错类型都不同。正确做法是抽一个BaseAIProvider接口每个厂商写一个适配器把各自 SDK 归一化。下面的代码能直接跑背后是两套不同形状的假客户端fromabcimportABC,abstractmethodclassBaseAIProvider(ABC):abstractmethoddefgenerate_completion(self,messages):...abstractmethoddefget_model_info(self):...class_MockOpenAIClient:defcreate(self,messages):return{choices:[{message:{content:OpenAI 兼容接口的回复}}]}classOpenAICompatibleAdapter(BaseAIProvider):def__init__(self,client):self.clientclientdefgenerate_completion(self,messages):# 内部是 choices[].message.content 形状对外只给纯文本returnself.client.create(messages)[choices][0][message][content]defget_model_info(self):return{provider:openai-compatible}class_MockLocalClient:definfer(self,prompt):return{completion:本地模型接口的回复,meta:{tok:12}}classLocalModelAdapter(BaseAIProvider):def__init__(self,client):self.clientclientdefgenerate_completion(self,messages):# 内部是 completion 形状入参也叫 promptreturnself.client.infer(messages[-1][content])[completion]defget_model_info(self):return{provider:local}defapp_call(provider,msg):# 业务核心只认 BaseAIProvider不关心背后是谁returnprovider.generate_completion(msg)msg[{role:user,content:hi}]print(app_call(OpenAICompatibleAdapter(_MockOpenAIClient()),msg))print(app_call(LocalModelAdapter(_MockLocalClient()),msg))输出证实两次业务调用接口完全一致背后却是两套不同形状的 SDK。统一的红利不止「换模型不动核心」这一条——路由和兜底也能建在同一层上便宜模型打头阵出错再 fallback 到更稳的模型这套逻辑只跟BaseAIProvider打交道跟具体厂商无关各家 SDK 抛的不同异常也在适配器里归一化成同一个错误类型业务侧一把try兜住而不是在每个调用点写不同的异常分支。举个具体的OpenAI 的 SDK 抛openai.APIError本地模型框架可能抛自己的RuntimeError业务侧若直接except这两类等于又把厂商差异吸回核心。在适配器里统一成ProviderError核心只except ProviderError一处兜底厂商再怎么换异常类型都不用动上层。值得记一笔的是现在国内外大量厂商都提供 OpenAI 兼容协议很多时候你改个 Base URL 和 Key 就行连适配器都不用写。这套思路不是凭空来的。主流的 Code Agent 在做多模型接入时普遍把「OpenAI 兼容协议」当成默认接口只要厂商提供兼容网关改个 Base URL 和 Key 就能接根本不必写适配器只有那些形态特殊的自研后端才需要单独写一个适配器。换句话说行业的事实标准是「尽量让被适配者说同一种方言实在不行再写翻译」——适配器是兜底不是第一选择。认清这一点你才不会因为「听说适配器好」就到处套而是只在异构兜底处下手。场景二把 LLM 当作可替换的「基础设施」。在六边形架构里领域层定义端口接口具体实现是适配器。比如把「文本情感分析」定义成一个 portOpenAI 的实现是一个适配器某天想换成传统机器学习模型甚至写死规则领域层完全无感fromabcimportABC,abstractmethodclassTextAnalyzer(ABC):# 领域端口业务只认这个abstractmethoddefanalyze(self,text:str)-str:...classOpenAISentimentAnalyzer(TextAnalyzer):# LLM 适配器def__init__(self,client):self.clientclientdefanalyze(self,text:str)-str:respself.client.chat.completions.create(modelgpt-4o-mini,messages[{role:user,content:f分析情感{text}}])returnresp.choices[0].message.contentclassRuleBasedAnalyzer(TextAnalyzer):# 换掉 LLM 也行领域层无感defanalyze(self,text:str)-str:return正面if好intextelse负面把 LLM 当基础设施意味着你的代码始终掌控流程拼好提示词、发请求、解析结果模型只是个被调用的黑盒没有记忆、没有自主权。一旦哪天发现 LLM 太贵或不稳用RuleBasedAnalyzer这样的非 LLM 适配器顶上调用方完全无感。反过来如果你要的是「模型自己决定调哪个工具、走哪条路」那就超出了适配器的范畴该上 Agent 编排了——适配器管的是「接口翻译」不管「行为自治」。这也给选型提了个醒当你纠结「要不要把这段逻辑抽成适配器」时先确认被封装的是不是「无自主权的被调用方」。是适配器正解如果封装对象要反过来驱动你的流程、自己做决策那它更像协作者甚至主控方适配器的边界就不合适了。场景三统一向量库接口。Chroma、Milvus、pgvector 的建库、写入、查询 API 各不相同。在 RAG 链路里你通常不希望检索逻辑跟具体向量库绑死。用一个VectorStore适配器把各家similarity_search抹平成统一签名哪天从本地 Chroma 迁到云端 Milvus只换适配器上层检索代码原封不动classVectorStore(ABC):abstractmethoddefsimilarity_search(self,query:str,k:int)-list:...classChromaAdapter(VectorStore):def__init__(self,client):self.clientclientdefsimilarity_search(self,query,k):returnself.client.query(query,n_resultsk)[documents][0]classMilvusAdapter(VectorStore):def__init__(self,client):self.clientclientdefsimilarity_search(self,query,k):return[hit.entity.get(text)forhitinself.client.search(query,limitk)]这一层适配器的回报在迁移时最明显。很多团队早期用本地 Chroma 跑通原型业务起来后迁到云端 Milvus 扛并发——如果没有VectorStore这层适配所有client.query(...)的调用点都得改有了它只换一个适配器实例检索逻辑纹丝不动。把「换底层不换上层」落到代码上就是这个感觉。更进一步如果某段时间内要双写两个向量库做灰度适配器也能让上层无感地同时写两家、只读一家。场景四把不同检索源接进同一个 RAG 链。真实 RAG 往往混合向量检索、BM25 关键词检索、甚至实时网页抓取。每一种都是一套自己的接口。给它们各自包一层Retriever适配器统一成retrieve(query) - List[Document]上游的「重排 拼提示词」逻辑就能用同一套代码消费所有来源再做融合排序classRetriever(ABC):abstractmethoddefretrieve(self,query:str)-list:...classVectorRetriever(Retriever):def__init__(self,store):self.storestoredefretrieve(self,query):returnself.store.similarity_search(query,k3)classWebRetriever(Retriever):def__init__(self,api):self.apiapidefretrieve(self,query):returnself.api.search(query)# 上游融合排序逻辑只认 Retriever 接口不关心背后是向量还是网页融合检索hybrid search之所以能成立前提就是所有来源被归一化成了同一个retrieve契约。向量负责语义召回、BM25 负责关键词精确命中、网页负责时效性——三者返回格式天差地别但进了Retriever适配器后上游的重排模块只看到一摞统一的Document。没有这层翻译融合排序代码会被三种返回结构撕得支离破碎。实际项目里适配器通常不是在调用点随手new出来的而是交给一个工厂或依赖注入容器按配置挑选。比如配置里写provider: local启动时工厂就LocalModelAdapter一个实例注入给业务要切到云端改配置即可连代码都不用碰。适配器解决「接口翻译」工厂/注入解决「哪个适配器被装上」两者配合才是生产里这套模式的完整形态。只写适配器不接管装配调用点还是会散落if/elif——那就白忙了。把这四个场景连起来看你会发现它们说的是同一件事在大模型应用里「会变的那一侧」模型、向量库、检索源永远在被适配「不变的那一侧」你的业务核心靠接口稳坐钓鱼台。适配器就是那条让两侧解耦的缝。一个统一适配层的样子长这样图三业务核心只认统一接口换模型只是换一个适配器实例核心代码一行不动判断到底要不要为某个后端写适配器看下面这张矩阵比凭感觉靠谱图四决策口径——能否改源码 × 是否要对接多个异构实现两条都偏左上方时适配器收益最大一句话收束这四个场景凡是「接口对不上、又不能改、还可能要接好几个」的地方就先抽接口、再写适配器凡是「接口能改、只有一处用到」的地方直接重构更省事不必硬套。最后补一句代价免得用过头适配器本身是一层间接意味着底层 SDK 一旦改接口你得同步改对应的适配器翻译层不会自动跟着变——它把「厂商差异」收拢到一个文件也把「维护点」收拢到同一个文件这是好事但别误以为它零成本。更忌讳的是「适配器的适配器」为了接 A 写了适配器为了接 B 又在 A 的适配器外再包一层链路一长出问题时你根本分不清错在哪一层。一条原则够用每个被适配的后端只对应一个适配器翻译只发生一次上游永远只看到统一接口。六、三条记住就行适配器转的是接口不是被适配者的实现。第三方 SDK 和遗留模块原样保留所有「翻译」都发生在中间那一层核心业务和旧代码两边都不用为对方改自己。记住它和装饰器、门面的区别只有「被迫把 A 接口翻译成 B 期望的接口」才是适配器加职责是装饰器收复杂度是门面。也别指望适配器翻译语义差异——只翻译形状不翻译「根本不是一回事」。Python 里优先对象适配器能用鸭子类型就别先立接口。快速场景靠「方法凑齐」就能即插即用要进长期维护的库再用 ABC 或typing.Protocol把契约钉死。类适配器靠多继承只在必须覆写 Adaptee 行为时才考虑而且在没有多继承的语言里它根本不存在。对象适配器还能顺带覆盖 Adaptee 的整个子类家族组合优势在继承体系里会被放大。大模型工程里适配器是换底层不换上层的缓冲。统一多模型 Provider、向量库、RAG 检索源时先抽接口、再写适配器凡是不兼容又必须接的异构后端都是它的主场。路由、兜底、错误归一化也都能建在这层之上而不是散进业务核心。这层缓冲越厚你切换底层、接入新厂商的成本就越低业务核心也越经得起模型圈的快速迭代。
