LLM 抽象层怎么设计?让 AI 应用不被某一个模型厂商绑定
上一篇文章讨论了 AI 应用的配置系统。
配置系统解决的是一个问题:
项目运行时应该使用哪个模型、哪个 API Key、哪个服务地址、哪个超时时间。
但配置系统本身并不负责调用模型。
当配置准备好以后,AI 应用马上会遇到下一个问题:
业务代码应该如何调用大模型?
很多 Demo 会这样写:
fromopenaiimportOpenAI client=OpenAI(api_key="sk-xxx")response=client.chat.completions.create(model="gpt-4o",messages=[{"role":"user","content":"请总结这段文字"}],)print(response.choices[0].message.content)这段代码很直观,也很适合快速验证想法。
但如果项目继续发展,就会出现很多工程问题。
今天使用 OpenAI。
明天可能要切到 DeepSeek。
后天可能要同时支持 Qwen。
某些场景需要流式输出。
某些场景需要更长的超时时间。
某些场景需要记录 token 用量。
某些场景需要在主模型失败时降级到备用模型。
如果业务代码里到处都是某个厂商 SDK 的调用方式,项目就会很难维护。
所以,在 AI Scaffold 这样的工程化脚手架里,需要有一层专门负责模型调用。
这一层就是 LLM 抽象层。
一、为什么不能在业务代码里直接调用模型 SDK?
直接调用模型 SDK 最大的问题,是业务逻辑和基础设施逻辑混在了一起。
业务代码真正关心的通常是:
给模型一段输入。
拿到模型返回的结果。
必要时处理流式输出。
必要时拿到 token 用量。
但具体使用哪个模型厂商、怎么初始化客户端、参数名字叫什么、返回结构是什么,很多时候不应该由业务代码直接关心。
例如 OpenAI 风格的调用可能是:
response=client.chat.completions.create(model="gpt-4o",messages=messages,temperature=0.7,)另一个模型供应商可能是:
response=client.chat(model_name="xxx",prompt=prompt,stream=False,)返回结果也可能不同。
有的结果在response.choices[0].message.content里。
有的结果在response.output_text里。
有的结果会返回一个事件流。
有的结果会额外包含 token 统计。
如果每个业务模块都自己适配这些差异,项目会慢慢变成这样:
ifprovider=="openai":# OpenAI 调用逻辑...elifprovider=="deepseek":# DeepSeek 调用逻辑...elifprovider=="qwen":# Qwen 调用逻辑...短期看,这是最快的写法。
长期看,这是最难维护的写法。
因为模型供应商的差异会扩散到整个项目。
一旦要切换模型,就需要改很多地方。
一旦要增加重试,就需要复制很多逻辑。
一旦要做测试,就很难把模型调用替换成 mock。
所以,模型调用应该集中在一层里。
业务代码不应该直接依赖具体 SDK,而应该依赖一个统一接口。
二、LLM 抽象层到底解决什么问题?
LLM 抽象层不是为了把代码写得更复杂。
它的目标,是把模型供应商差异收敛起来。
一个合理的 LLM 抽象层,至少应该解决几个问题。
第一,统一调用方式。
不管底层是 OpenAI、DeepSeek、Qwen,业务代码都使用同一种调用方法。
例如:
result=llm.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":pass@abstractmethoddefstream_chat(self,messages:list[dict])->Iterable["LLMChunk"]:pass这个接口先表达两个核心能力:
普通调用。
流式调用。
普通调用一次性返回完整结果。
流式调用逐步返回模型生成的片段。
然后定义统一的返回对象。
fromdataclassesimportdataclass@dataclassclassLLMUsage:prompt_tokens:int|None=Nonecompletion_tokens:int|None=Nonetotal_tokens:int|None=None@dataclassclassLLMResponse:content:strmodel:str|None=Noneprovider:str|None=Noneusage:LLMUsage|None=Noneraw:object|None=None@dataclassclassLLMChunk:content:strdone:bool=Falseraw:object|None=None这里有一个细节:
返回对象里保留了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 llm=create_llm()result=llm.chat([{"role":"user","content":"请总结这段内容"}])print(result.content)业务代码不需要知道底层到底是 OpenAI、DeepSeek 还是 Qwen。
这就是 Provider 模式的价值。
六、Factory 应该和配置系统配合
上一篇文章提到,AI 应用需要统一配置系统。
LLM 抽象层应该直接使用这套配置。
例如配置对象里可以有这些字段:
classSettings:llm_provider:str="openai"llm_model:str="gpt-4o"llm_temperature:float=0.7llm_timeout:int=60openai_api_key:str|None=Nonedeepseek_api_key:str|None=Noneqwen_api_key:str|None=NoneFactory 根据配置创建不同 Provider。
fromconfig.settingsimportsettingsfromllm.providers.openai_providerimportOpenAIProviderfromllm.providers.deepseek_providerimportDeepSeekProviderfromllm.providers.qwen_providerimportQwenProviderdefcreate_llm():ifsettings.llm_provider=="openai":returnOpenAIProvider(api_key=settings.openai_api_key,model=settings.llm_model,temperature=settings.llm_temperature,timeout=settings.llm_timeout,)ifsettings.llm_provider=="deepseek":returnDeepSeekProvider(api_key=settings.deepseek_api_key,model=settings.llm_model,temperature=settings.llm_temperature,timeout=settings.llm_timeout,)ifsettings.llm_provider=="qwen":returnQwenProvider(api_key=settings.qwen_api_key,model=settings.llm_model,temperature=settings.llm_temperature,timeout=settings.llm_timeout,)raiseValueError(f"Unsupported LLM provider:{settings.llm_provider}")这样配置系统和 LLM 抽象层就串起来了。
如果想从 OpenAI 切换到 DeepSeek,理论上只需要修改配置:
LLM_PROVIDER=deepseek LLM_MODEL=deepseek-chat DEEPSEEK_API_KEY=your-api-key上层业务代码不需要改。
这就是工程化设计带来的稳定性。
七、Provider 内部应该适配差异,而不是暴露差异
Provider 的职责是把具体厂商 SDK 转换成项目内部统一接口。
例如 OpenAIProvider 可以这样写:
fromopenaiimportOpenAIfromllm.baseimportBaseLLMfromllm.schemaimportLLMResponse,LLMUsageclassOpenAIProvider(BaseLLM):def__init__(self,api_key:str,model:str,temperature:float,timeout:int):self.client=OpenAI(api_key=api_key,timeout=timeout)self.model=model self.temperature=temperaturedefchat(self,messages:list[dict])->LLMResponse:response=self.client.chat.completions.create(model=self.model,messages=messages,temperature=self.temperature,)usage=Noneifresponse.usage:usage=LLMUsage(prompt_tokens=response.usage.prompt_tokens,completion_tokens=response.usage.completion_tokens,total_tokens=response.usage.total_tokens,)returnLLMResponse(content=response.choices[0].message.contentor"",model=self.model,provider="openai",usage=usage,raw=response,)这里业务代码看不到 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]):stream=self.client.chat.completions.create(model=self.model,messages=messages,temperature=self.temperature,stream=True,)foreventinstream:content=event.choices[0].delta.contentor""ifcontent:yieldLLMChunk(content=content,raw=event)yieldLLMChunk(content="",done=True)上层业务不关心底层事件结构。
只关心每次拿到一个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:result=llm.chat(messages)exceptLLMTimeoutError:return"模型响应超时,请稍后再试"exceptLLMRateLimitError:return"当前请求较多,请稍后重试"exceptLLMError:return"模型服务暂时不可用"这样错误处理会更清晰。
同时也方便后续接入日志、告警和降级策略。
十、超时、重试和降级应该放在哪里?
LLM 调用是外部服务调用。
外部服务调用就一定要考虑超时。
如果没有超时控制,一个请求可能卡住很久。
如果没有重试控制,短暂网络波动就会导致用户请求失败。
如果没有降级策略,主模型不可用时整个应用就不可用。
但这些逻辑不应该散落在业务代码里。
更合理的位置,是放在 LLM 抽象层或更上层的模型路由层。
例如第一版可以先在 Provider 初始化时支持 timeout:
OpenAI(api_key=api_key,timeout=timeout)后续再加入简单重试:
defchat_with_retry(llm,messages,retries:int=2):last_error=Nonefor_inrange(retries+1):try:returnllm.chat(messages)exceptLLMTimeoutErroraserror:last_error=errorraiselast_error再往后,可以设计模型路由:
primary_llm=create_llm(provider="openai")fallback_llm=create_llm(provider="deepseek")try:result=primary_llm.chat(messages)exceptLLMError:result=fallback_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 层。
更合理的方式是:
messages=prompt_manager.render(name="summarize_article",variables={"article_text":article_text},)result=llm.chat(messages)LLM 层只关心 messages。
Prompt 层负责生成 messages。
业务层负责决定使用哪个 Prompt。
这样模块边界会更清楚。
后续如果要做 Prompt 版本管理,也不会影响 LLM Provider。
十二、测试时需要 Fake LLM
AI 应用测试有一个很现实的问题:
不能每次测试都真实调用模型。
原因很简单。
真实调用会产生费用。
真实调用速度慢。
真实调用结果不稳定。
真实调用还依赖网络和外部服务状态。
所以 LLM 抽象层应该方便替换成 Fake LLM。
例如:
fromllm.baseimportBaseLLMfromllm.schemaimportLLMResponse,LLMChunkclassFakeLLM(BaseLLM):def__init__(self,content:str="fake response"):self.content=contentdefchat(self,messages:list[dict])->LLMResponse:returnLLMResponse(content=self.content,model="fake-model",provider="fake",)defstream_chat(self,messages:list[dict]):yieldLLMChunk(content=self.content)yieldLLMChunk(content="",done=True)测试业务逻辑时,可以注入 FakeLLM。
deftest_summary_workflow():llm=FakeLLM(content="这是一段摘要")workflow=SummaryWorkflow(llm=llm)result=workflow.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_PROVIDER=openai LLM_MODEL=gpt-4o LLM_TEMPERATURE=0.7 LLM_TIMEOUT=60 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 做成可维护、可复用、可版本化的项目资产。