系列: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 抽象真正要归一的是三件事:

  1. 输入归一messages(含 system/user/tool 角色)、tools(工具 schema)格式统一,调用方不关心背后怎么转。
  2. 输出归一:无论 DeepSeek 还是 Claude,都返回统一的 LLMResponse(text=..., tool_calls=...),调用方不判类型。
  3. 错误归一:超时、限流、内容过滤、网络错误,都映射成统一的 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》

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐