LLM 抽象层怎么设计?让 AI 应用不被某一个模型厂商绑定 上一篇文章讨论了 AI 应用的配置系统。配置系统解决的是一个问题项目运行时应该使用哪个模型、哪个 API Key、哪个服务地址、哪个超时时间。但配置系统本身并不负责调用模型。当配置准备好以后AI 应用马上会遇到下一个问题业务代码应该如何调用大模型很多 Demo 会这样写fromopenaiimportOpenAI clientOpenAI(api_keysk-xxx)responseclient.chat.completions.create(modelgpt-4o,messages[{role:user,content:请总结这段文字}],)print(response.choices[0].message.content)这段代码很直观也很适合快速验证想法。但如果项目继续发展就会出现很多工程问题。今天使用 OpenAI。明天可能要切到 DeepSeek。后天可能要同时支持 Qwen。某些场景需要流式输出。某些场景需要更长的超时时间。某些场景需要记录 token 用量。某些场景需要在主模型失败时降级到备用模型。如果业务代码里到处都是某个厂商 SDK 的调用方式项目就会很难维护。所以在 AI Scaffold 这样的工程化脚手架里需要有一层专门负责模型调用。这一层就是 LLM 抽象层。一、为什么不能在业务代码里直接调用模型 SDK直接调用模型 SDK 最大的问题是业务逻辑和基础设施逻辑混在了一起。业务代码真正关心的通常是给模型一段输入。拿到模型返回的结果。必要时处理流式输出。必要时拿到 token 用量。但具体使用哪个模型厂商、怎么初始化客户端、参数名字叫什么、返回结构是什么很多时候不应该由业务代码直接关心。例如 OpenAI 风格的调用可能是responseclient.chat.completions.create(modelgpt-4o,messagesmessages,temperature0.7,)另一个模型供应商可能是responseclient.chat(model_namexxx,promptprompt,streamFalse,)返回结果也可能不同。有的结果在response.choices[0].message.content里。有的结果在response.output_text里。有的结果会返回一个事件流。有的结果会额外包含 token 统计。如果每个业务模块都自己适配这些差异项目会慢慢变成这样ifprovideropenai:# OpenAI 调用逻辑...elifproviderdeepseek:# DeepSeek 调用逻辑...elifproviderqwen:# Qwen 调用逻辑...短期看这是最快的写法。长期看这是最难维护的写法。因为模型供应商的差异会扩散到整个项目。一旦要切换模型就需要改很多地方。一旦要增加重试就需要复制很多逻辑。一旦要做测试就很难把模型调用替换成 mock。所以模型调用应该集中在一层里。业务代码不应该直接依赖具体 SDK而应该依赖一个统一接口。二、LLM 抽象层到底解决什么问题LLM 抽象层不是为了把代码写得更复杂。它的目标是把模型供应商差异收敛起来。一个合理的 LLM 抽象层至少应该解决几个问题。第一统一调用方式。不管底层是 OpenAI、DeepSeek、Qwen业务代码都使用同一种调用方法。例如resultllm.chat(messages)第二统一返回结构。业务代码不需要关心不同 SDK 的原始返回格式而是拿到一个统一的结果对象。例如print(result.content)print(result.model)print(result.usage)第三统一配置入口。模型供应商、模型名称、API Key、超时时间、温度参数都从配置系统读取。业务模块不应该自己读取环境变量。第四统一错误处理。超时、限流、鉴权失败、网络失败、模型不可用都应该有统一的异常类型或处理策略。第五支持流式输出。很多 AI 应用需要边生成边展示所以抽象层应该支持普通调用和流式调用。第六支持扩展新模型。当项目需要接入新的模型供应商时最好只新增一个 Provider而不是修改所有业务模块。第七方便测试。测试时可以使用 Fake LLM 或 Mock LLM不必真实调用外部模型服务。这些能力放在一起就构成了 AI 应用工程化里的模型调用底座。三、不要把 LLM 抽象层设计成万能框架设计 LLM 抽象层时容易走向另一个极端一开始就想支持所有模型、所有参数、所有调用模式。这会让抽象层变得很重。对于脚手架项目来说更好的做法是先抓住最核心的能力。例如第一版只需要支持普通对话调用。流式对话调用。基础参数传入。统一返回结果。基础错误处理。新 Provider 扩展能力。暂时不需要一开始就把所有高级能力都塞进去。例如批量推理、函数调用、多模态输入、结构化输出、缓存、路由和降级都可以作为后续扩展。抽象层的价值不是一开始覆盖所有场景。而是先建立一个稳定边界上层业务不直接依赖模型厂商。底层模型供应商可以替换和扩展。只要这个边界建立起来后续功能就可以逐步生长。四、一个简单的 LLM 接口应该长什么样在 Python 项目里可以先定义一个基础接口。例如fromabcimportABC,abstractmethodfromtypingimportIterableclassBaseLLM(ABC):abstractmethoddefchat(self,messages:list[dict])-LLMResponse:passabstractmethoddefstream_chat(self,messages:list[dict])-Iterable[LLMChunk]:pass这个接口先表达两个核心能力普通调用。流式调用。普通调用一次性返回完整结果。流式调用逐步返回模型生成的片段。然后定义统一的返回对象。fromdataclassesimportdataclassdataclassclassLLMUsage:prompt_tokens:int|NoneNonecompletion_tokens:int|NoneNonetotal_tokens:int|NoneNonedataclassclassLLMResponse:content:strmodel:str|NoneNoneprovider:str|NoneNoneusage:LLMUsage|NoneNoneraw:object|NoneNonedataclassclassLLMChunk:content:strdone:boolFalseraw:object|NoneNone这里有一个细节返回对象里保留了raw字段。这是为了在需要排查问题时还能拿到原始响应。但业务代码默认不应该依赖raw。如果业务代码大量读取raw说明抽象层没有真正隔离底层差异。五、Provider 是 LLM 抽象层的核心LLM 抽象层通常可以分成两部分统一接口。具体 Provider。统一接口定义项目内部怎么调用模型。Provider 负责适配具体模型供应商。例如项目结构可以这样设计llm/ ├── __init__.py ├── base.py ├── schema.py ├── factory.py └── providers/ ├── __init__.py ├── openai_provider.py ├── deepseek_provider.py └── qwen_provider.py其中base.py放基础接口。schema.py放统一返回结构。factory.py根据配置创建具体 LLM 实例。providers/放不同模型供应商的适配代码。这样业务代码只需要这样写fromllm.factoryimportcreate_llm llmcreate_llm()resultllm.chat([{role:user,content:请总结这段内容}])print(result.content)业务代码不需要知道底层到底是 OpenAI、DeepSeek 还是 Qwen。这就是 Provider 模式的价值。六、Factory 应该和配置系统配合上一篇文章提到AI 应用需要统一配置系统。LLM 抽象层应该直接使用这套配置。例如配置对象里可以有这些字段classSettings:llm_provider:stropenaillm_model:strgpt-4ollm_temperature:float0.7llm_timeout:int60openai_api_key:str|NoneNonedeepseek_api_key:str|NoneNoneqwen_api_key:str|NoneNoneFactory 根据配置创建不同 Provider。fromconfig.settingsimportsettingsfromllm.providers.openai_providerimportOpenAIProviderfromllm.providers.deepseek_providerimportDeepSeekProviderfromllm.providers.qwen_providerimportQwenProviderdefcreate_llm():ifsettings.llm_provideropenai:returnOpenAIProvider(api_keysettings.openai_api_key,modelsettings.llm_model,temperaturesettings.llm_temperature,timeoutsettings.llm_timeout,)ifsettings.llm_providerdeepseek:returnDeepSeekProvider(api_keysettings.deepseek_api_key,modelsettings.llm_model,temperaturesettings.llm_temperature,timeoutsettings.llm_timeout,)ifsettings.llm_providerqwen:returnQwenProvider(api_keysettings.qwen_api_key,modelsettings.llm_model,temperaturesettings.llm_temperature,timeoutsettings.llm_timeout,)raiseValueError(fUnsupported LLM provider:{settings.llm_provider})这样配置系统和 LLM 抽象层就串起来了。如果想从 OpenAI 切换到 DeepSeek理论上只需要修改配置LLM_PROVIDERdeepseek LLM_MODELdeepseek-chat DEEPSEEK_API_KEYyour-api-key上层业务代码不需要改。这就是工程化设计带来的稳定性。七、Provider 内部应该适配差异而不是暴露差异Provider 的职责是把具体厂商 SDK 转换成项目内部统一接口。例如 OpenAIProvider 可以这样写fromopenaiimportOpenAIfromllm.baseimportBaseLLMfromllm.schemaimportLLMResponse,LLMUsageclassOpenAIProvider(BaseLLM):def__init__(self,api_key:str,model:str,temperature:float,timeout:int):self.clientOpenAI(api_keyapi_key,timeouttimeout)self.modelmodel self.temperaturetemperaturedefchat(self,messages:list[dict])-LLMResponse:responseself.client.chat.completions.create(modelself.model,messagesmessages,temperatureself.temperature,)usageNoneifresponse.usage:usageLLMUsage(prompt_tokensresponse.usage.prompt_tokens,completion_tokensresponse.usage.completion_tokens,total_tokensresponse.usage.total_tokens,)returnLLMResponse(contentresponse.choices[0].message.contentor,modelself.model,provideropenai,usageusage,rawresponse,)这里业务代码看不到 OpenAI SDK 的细节。业务代码只拿到LLMResponse。如果以后切换到 DeepSeekProvider只要 DeepSeekProvider 也返回LLMResponse上层代码就不需要变化。Provider 内部可以有差异。Provider 对外应该尽量一致。八、流式输出也应该统一很多 AI 应用需要流式输出。例如聊天机器人、写作助手、代码生成工具都希望模型边生成边展示。如果不做抽象不同供应商的流式接口差异会直接进入业务层。所以 LLM 抽象层需要提供统一的流式接口。例如forchunkinllm.stream_chat(messages):print(chunk.content,end)OpenAIProvider 内部可以这样适配fromllm.schemaimportLLMChunkdefstream_chat(self,messages:list[dict]):streamself.client.chat.completions.create(modelself.model,messagesmessages,temperatureself.temperature,streamTrue,)foreventinstream:contentevent.choices[0].delta.contentorifcontent:yieldLLMChunk(contentcontent,rawevent)yieldLLMChunk(content,doneTrue)上层业务不关心底层事件结构。只关心每次拿到一个LLMChunk。这样无论后续是 WebSocket、SSE 还是命令行输出都可以基于统一的流式接口实现。九、错误处理不能到处写 try except模型调用经常会失败。常见原因包括API Key 未配置。API Key 无效。网络超时。请求频率过高。模型名称错误。上下文长度超限。供应商服务不可用。如果每个业务模块都自己写try except错误处理会非常分散。LLM 抽象层可以定义统一异常。例如classLLMError(Exception):passclassLLMAuthError(LLMError):passclassLLMTimeoutError(LLMError):passclassLLMRateLimitError(LLMError):passclassLLMProviderError(LLMError):passProvider 内部负责把具体 SDK 异常转换成这些统一异常。业务层只需要处理项目内部异常。例如try:resultllm.chat(messages)exceptLLMTimeoutError:return模型响应超时请稍后再试exceptLLMRateLimitError:return当前请求较多请稍后重试exceptLLMError:return模型服务暂时不可用这样错误处理会更清晰。同时也方便后续接入日志、告警和降级策略。十、超时、重试和降级应该放在哪里LLM 调用是外部服务调用。外部服务调用就一定要考虑超时。如果没有超时控制一个请求可能卡住很久。如果没有重试控制短暂网络波动就会导致用户请求失败。如果没有降级策略主模型不可用时整个应用就不可用。但这些逻辑不应该散落在业务代码里。更合理的位置是放在 LLM 抽象层或更上层的模型路由层。例如第一版可以先在 Provider 初始化时支持 timeoutOpenAI(api_keyapi_key,timeouttimeout)后续再加入简单重试defchat_with_retry(llm,messages,retries:int2):last_errorNonefor_inrange(retries1):try:returnllm.chat(messages)exceptLLMTimeoutErroraserror:last_errorerrorraiselast_error再往后可以设计模型路由primary_llmcreate_llm(provideropenai)fallback_llmcreate_llm(providerdeepseek)try:resultprimary_llm.chat(messages)exceptLLMError:resultfallback_llm.chat(messages)不过对于脚手架早期版本来说不一定要马上实现复杂路由。先把接口边界设计好后面扩展会更自然。十一、Prompt 和 LLM 抽象层要保持边界有些项目会把 Prompt 拼接逻辑写进 LLM Provider。这通常不是一个好习惯。LLM Provider 应该负责模型调用。Prompt 管理应该属于 Prompt 层或业务层。例如不要这样设计llm.summarize_article(article_text)llm.generate_title(topic)llm.extract_keywords(content)这些方法看起来方便但它们把业务语义塞进了 LLM 层。更合理的方式是messagesprompt_manager.render(namesummarize_article,variables{article_text:article_text},)resultllm.chat(messages)LLM 层只关心 messages。Prompt 层负责生成 messages。业务层负责决定使用哪个 Prompt。这样模块边界会更清楚。后续如果要做 Prompt 版本管理也不会影响 LLM Provider。十二、测试时需要 Fake LLMAI 应用测试有一个很现实的问题不能每次测试都真实调用模型。原因很简单。真实调用会产生费用。真实调用速度慢。真实调用结果不稳定。真实调用还依赖网络和外部服务状态。所以 LLM 抽象层应该方便替换成 Fake LLM。例如fromllm.baseimportBaseLLMfromllm.schemaimportLLMResponse,LLMChunkclassFakeLLM(BaseLLM):def__init__(self,content:strfake response):self.contentcontentdefchat(self,messages:list[dict])-LLMResponse:returnLLMResponse(contentself.content,modelfake-model,providerfake,)defstream_chat(self,messages:list[dict]):yieldLLMChunk(contentself.content)yieldLLMChunk(content,doneTrue)测试业务逻辑时可以注入 FakeLLM。deftest_summary_workflow():llmFakeLLM(content这是一段摘要)workflowSummaryWorkflow(llmllm)resultworkflow.run(很长的文章内容)assertresult.summary这是一段摘要这样测试就不会依赖真实模型服务。这也是抽象层非常重要的价值。它不仅让生产环境可切换也让测试环境可控制。十三、LLM 抽象层在 AI Scaffold 里的位置在 AI Scaffold 里LLM 抽象层可以作为核心模块之一。项目结构可以这样安排app/ ├── main.py ├── workflows/ │ └── summary_workflow.py ├── prompts/ │ └── summary_prompt.md ├── llm/ │ ├── __init__.py │ ├── base.py │ ├── schema.py │ ├── factory.py │ ├── exceptions.py │ └── providers/ │ ├── openai_provider.py │ ├── deepseek_provider.py │ └── qwen_provider.py ├── config/ │ └── settings.py └── tests/ └── test_summary_workflow.pyCLI 创建项目时可以默认生成llm/base.py llm/schema.py llm/factory.py llm/exceptions.py llm/providers/同时在.env.example里给出模型相关配置LLM_PROVIDERopenai LLM_MODELgpt-4o LLM_TEMPERATURE0.7 LLM_TIMEOUT60 OPENAI_API_KEY DEEPSEEK_API_KEY QWEN_API_KEY这样开发者生成项目后就能很清楚地知道模型配置在哪里。模型调用在哪里。业务代码应该依赖什么接口。新增模型供应商应该改哪个位置。这比把所有模型调用写在业务函数里更适合长期维护。十四、总结LLM 抽象层是 AI 应用工程化里非常关键的一层。它解决的不是“怎么调用某一个模型 API”。它解决的是“如何让整个项目不被某一个模型厂商绑定”。一个合理的 LLM 抽象层至少应该做到业务代码不直接依赖具体模型 SDK。使用统一的chat和stream_chat接口。使用统一的返回结构。Provider 内部适配不同厂商差异。Factory 根据配置系统创建具体模型实例。错误处理、超时、重试可以逐步集中管理。测试环境可以使用 Fake LLM。后续新增模型供应商时不影响上层业务代码。对于 AI Scaffold 来说配置系统决定使用哪个模型。LLM 抽象层决定如何调用这个模型。当这两层建立起来以后项目就不再是简单的 Prompt Demo而是开始具备真正的工程化基础。下一篇文章可以继续讨论 Prompt 管理为什么 Prompt 不应该散落在业务代码里以及如何把 Prompt 做成可维护、可复用、可版本化的项目资产。