AI Agent 工程实践(23):Provider 抽象层设计
系列:AI Agent 工程实践
上一篇:第 22 篇《AI Agent 项目应该如何分层》
下一篇:第 24 篇《Tool Registry》
一、开场:一次因为"写死"导致的三天抢修
我见过一个项目,全项目 40 多处 client = OpenAI(api_key=...) 直接写死在业务函数里。某天目标区域的 OpenAI 接口限流,老板说"先切到 DeepSeek 顶一下"。结果三个人花了三天,grep 全项目把 40 多处逐一改成 DeepSeek 的 SDK;改完又发现有个函数用了 OpenAI 特有的 response_format 参数,DeepSeek 不支持,只能返工。
三天,只为了"换一个模型供应商"。
上篇(22)画的那棵目录树里,providers/ 就是专门吃这种痛苦的层。这一篇讲:为什么企业从不 OpenAI() 写死,而是抽象成 Provider → *Provider。
二、问题背景:写死一家,等于把命交出去
两种写法对比:
写死版(Demo 常见):
# 散落在 40 个业务函数里
from openai import OpenAI
client = OpenAI()
resp = client.chat.completions.create(model="gpt-4o", messages=[...])
抽象版(生产常见):
# 全项目只有一处地方认识具体供应商
from providers import get_provider
provider = get_provider("default") # 不关心背后是 DeepSeek 还是 Claude
resp = provider.generate(messages=[...])
差别不在代码行数,在于"模型供应商"这个变化轴被收口到了一层。写死版里,变化轴散落全项目(40 处);抽象版里,变化轴只在 providers/ 和 config/。
三、错误尝试:三种"假抽象"
错误 1:写死具体 SDK
就是上面的 40 处 OpenAI()。最原始但最常见的坑,换供应商 = 全项目重构。
错误 2:用 if/else 硬编码切换
def call_llm(model, messages):
if model == "gpt-4o":
return OpenAI().chat.completions.create(...)
elif model == "deepseek-chat":
return DeepSeek().chat.completions.create(...)
# 加一个新模型?回来改这个函数
比写死进步,但加模型要改所有这种分支函数。抽象不彻底——"选择逻辑"还是散落的。
错误 3:抽象了,但接口不统一
# DeepSeek 返回 .choices[0].message.content
# Claude 返回 .content[0].text
# 调用方被迫:if isinstance(resp, DeepSeekResp): ...
最隐蔽的坑:你以为抽了象,结果调用方还是要判断"这是哪家返回"。抽象的不是对象,是调用契约。 返回格式不归一,换模型依然要改调用方。
四、关键观察:抽象的是"调用契约",不是"对象"
把三种错误归纳,Provider 抽象真正要归一的是三件事:
- 输入归一:
messages(含 system/user/tool 角色)、tools(工具 schema)格式统一,调用方不关心背后怎么转。 - 输出归一:无论 DeepSeek 还是 Claude,都返回统一的
LLMResponse(text=..., tool_calls=...),调用方不判类型。 - 错误归一:超时、限流、内容过滤、网络错误,都映射成统一的
ProviderError子类,调用方一套try/except覆盖所有供应商。
做到这三归一,换模型才真零成本。 调用方代码从第一行到最后一次提交,可能永远不出现任何供应商名字。
五、最终方案:Provider 抽象层长什么样
providers/
├── base.py # LLMProvider 抽象基类:定义 generate() 契约
├── deepseek.py # DeepSeekProvider:实现契约,内部处理格式转换
├── claude.py # ClaudeProvider:同上
├── factory.py # get_provider(name):工厂,读 config 返回实例
└── errors.py # 统一错误类型 ProviderError / Timeout / RateLimit
| 组件 | 职责 | 它封装的"变化轴" |
|---|---|---|
base.py |
定义 generate(messages, tools) -> LLMResponse 契约 |
调用方依赖的"稳定接口" |
deepseek.py |
把 DeepSeek SDK 的出入参映射到契约 | 具体供应商实现 |
claude.py |
把 Claude SDK 的出入参映射到契约 | 具体供应商实现 |
factory.py |
按名字/配置造 provider,注入密钥 | 供应商选择逻辑收口 |
errors.py |
各家的错误 → 统一错误树 | 错误处理归一 |
配套 config/model_routing.yaml:
default: deepseek-chat
fallback: claude-3.5-sonnet
timeout: 30s
providers:
deepseek-chat:
endpoint: https://api.deepseek.com
api_key: ${DEEPSEEK_API_KEY}
claude-3.5-sonnet:
endpoint: https://api.anthropic.com
api_key: ${ANTHROPIC_API_KEY}
换模型 = 改 yaml 的 default,业务代码零改动。
六、看 Provider 抽象(Mermaid)
flowchart TD
CALLER[业务代码 runtime/agent] -->|只依赖契约| BASE["LLMProvider.generate()"]
BASE <|-- DS[DeepSeekProvider]
BASE <|-- CL[ClaudeProvider]
BASE <|-- GLM[...Provider]
FACTORY[get_provider name] --> BASE
CONFIG[model_routing.yaml] --> FACTORY
DS --> ERR[统一错误树 ProviderError]
CL --> ERR
读:CALLER 只指向 LLMProvider 这个抽象,永不直接指向任何 *Provider。新增供应商 = 加一个 *Provider 子类 + 在 yaml 加一项,调用方纹丝不动。
七、代码对比:写死 vs 抽象
写死版(问题):
from openai import OpenAI
client = OpenAI()
resp = client.chat.completions.create(
model="gpt-4o", messages=msgs, tools=tool_schemas
)
name = resp.choices[0].message.tool_calls[0].function.name # 耦合 OpenAI 返回结构
抽象版(生产):
from providers import get_provider
provider = get_provider("default")
try:
resp = provider.generate(messages=msgs, tools=tool_schemas)
name = resp.tool_calls[0].function.name # 统一结构,不关心供应商
except ProviderTimeout:
provider = get_provider("fallback") # 超时自动切 fallback,yaml 配
resp = provider.generate(messages=msgs, tools=tool_schemas)
关键差异:调用方不出现任何供应商名;返回结构统一(resp.tool_calls);超时后从 fallback 取另一个 provider,切换逻辑由配置驱动。
八、设计权衡:抽象到什么程度
| 场景 | 建议 | 理由 |
|---|---|---|
| 只用一家模型、原型验证 | 直接 SDK,不必抽象 | 抽象有成本,没第二个供应商时收益为 0 |
| 1–2 家、要容灾 | base + 2 实现 + factory + yaml |
刚好覆盖"换/容灾"需求 |
| 多供应商、长期维护 | 完整抽象 + 统一错误树 | 每家差异被契约吸收,新人加模型不碰业务 |
反过度工程:不要为了"支持 20 家"设计一套巨型适配层却只用 2 家。抽象到"当前 + 可预见的下一个"即可。但错误归一一定要做——哪怕只用一家,把超时/限流表达成统一异常,未来加供应商时这块不用返工。
九、总结
- ✅ 写死
OpenAI()= 把"换模型"变成全项目重构;40 处散落 = 三天抢修。 - ✅ 三种假抽象:写死 SDK、if/else 硬编码切换、抽象了但接口不统一。
- ✅ 真正抽象的是"调用契约"三归一:输入归一、输出归一、错误归一。
- ✅ 落地:
LLMProvider基类 +*Provider实现 +get_provider工厂 +model_routing.yaml;换模型改 yaml,业务零改。 - ✅ 反过度工程:抽象到"当前+可预见的下一个",但错误归一必做。
下一篇,钻进 tools/ —— 为什么工具不是散落函数,而是 Tool → Registry → Permission → Schema → Executor。(24)
参考资料(带用途说明)
- 本系列(22)AI Agent 项目应该如何分层:本文是(22)九层骨架里
providers/层的展开,讲清它的边界与接口。 - 本系列(21)为什么 Demo 永远变不成生产系统:本文"可替换"支柱的具体落地——换模型零成本。
- 12-Factor Agents(Chroma 团队,公开技术文章):Factor "Unify execution state" 思想,对应本文"输出归一、调用方不判类型"。
- DeepSeek 开放平台文档(platform.deepseek.com):本文
deepseek.py真实对接与endpoint配置来源。 - Anthropic 文档(docs.anthropic.com):本文
claude.py对接来源,说明不同供应商返回结构差异为何需要归一。
本文是 AI Agent 工程实践系列的第 23 篇(第四阶段第三篇)。
系列导航
上一篇:第 22 篇《AI Agent 项目应该如何分层》
下一篇:第 24 篇《Tool Registry》
更多推荐

所有评论(0)