多 LLM Provider:不改一行业务代码换模型

系列「企业级 AI Agent 实现拆解」第八篇。上一篇讲了工具调用,这篇看怎么做到换 LLM provider 零代码改动。


问题:provider 太多,接口各不相同

部署企业级 Agent 平台,必须面对的现实是:客户对 LLM provider 有各自的要求。有的要求必须用国内合规的模型,有的希望能切换不同模型做性能对比,有的自己部署了私有化模型。

OpenAI、Anthropic、DeepSeek、Qwen、本地 Ollama------每家接口细节都不一样。如果在业务代码里写 if provider == "openai" / if provider == "anthropic",这个 if-else 链会无限增长,而且每次加新 provider 都要改核心代码。


方案:Profile + Router + Workflow + Adapter 四层

整个 LLM 层分四层:

css 复制代码
业务代码
    ↓ ChatRequest{Profile: "deepseek-v3"}
Router.Pick(profile)
    ↓ 返回 Provider 接口
Workflow(openai-compat / anthropic-compat / ...)
    ↓ 协议适配(请求格式、流式解析、token 计数)
Adapter(HTTP 调用具体 provider)
    ↓
Provider API(DeepSeek / Anthropic / Ollama / ...)

Provider 接口是核心,所有 workflow 最终都实现这个接口:

go 复制代码
type Provider interface {
    Name() string
    Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error)
    Stream(ctx context.Context, req ChatRequest) (<-chan StreamChunk, error)
}

业务代码只接触 Provider,不知道下面是哪家。

Router 按 profile 名选 provider:

go 复制代码
type Router interface {
    Pick(ctx context.Context, profile string) (Provider, error)
    // 网关透传模式:raw body 直接转发,不经过 ChatRequest 解析
    RawCall(ctx context.Context, profile string, body []byte, stream bool) (*RawResponse, error)
}

ChatRequest.Profile(比如 "deepseek-v3""claude-prod")是 router 的选择依据。RawCall 是网关透传模式------某些场景(比如 SDK 直接调 API)不需要解析请求,直接转发原始 JSON。

Workflow 是协议适配层。不同 provider 的 API 格式差异(请求体结构、流式 chunk 格式、token 计数方式)都在这一层处理。当前支持 5 种 workflow:

go 复制代码
const (
    AnthropicCompat    Kind = "anthropic-compat"    // Anthropic API 格式
    OpenAICompat       Kind = "openai-compat"       // OpenAI 兼容(大部分国产模型)
    ClaudeSubscription Kind = "claude-subscription" // Claude 订阅模式
    CodexSubscription  Kind = "codex-subscription"  // OpenAI Codex 订阅
    GitHubCopilot      Kind = "github-copilot"      // GitHub Copilot
)

openai-compat 是万能适配器------DeepSeek、Qwen、GLM、Kimi、Ollama 等都兼容 OpenAI 接口格式,走同一个 workflow,零代码。只有接口格式特殊的才需要独立 workflow。


Profile:配置驱动,零代码换 provider

Profile 存在 configs/llm/profiles.yaml(实际配置示例):

yaml 复制代码
profiles:
  - name: deepseek-v3
    workflow: openai-compat
    base_url: https://api.deepseek.com
    model: deepseek-chat
    auth: ${DEEPSEEK_API_KEY}

  - name: claude-prod
    workflow: anthropic-compat
    base_url: https://api.anthropic.com
    model: claude-sonnet-4-6
    auth: ${ANTHROPIC_API_KEY}

  - name: ollama-local
    workflow: openai-compat
    base_url: http://ollama:11434
    model: qwen2.5:72b
    auth: ""

  - name: glm-4
    workflow: openai-compat
    base_url: https://open.bigmodel.cn/api/paas
    model: glm-4
    auth: ${GLM_API_KEY}

# 路由策略:支持 fallback
routing:
  default:
    primary_profile: deepseek-v3
    fallback_profiles: [kimi-via-anthropic, glm-4]
    strategy: sticky

大部分新 provider 只加一行 profile 就好------它们都支持 OpenAI 兼容接口,走 openai-compat workflow,零代码。只有接口格式特殊的(如 Anthropic)才需要独立 workflow。

还有一个实用功能:路由策略routing 配置了主备切换------deepseek-v3 为主,kimi-via-anthropicglm-4 为备。主 provider 挂了自动切到备选,sticky 策略保证同一会话用同一个 provider(避免上下文不兼容)。


Quirks:数据驱动的输出修复

不同 provider 的输出有一些"怪癖"------DeepSeek 偶发把 URL 混进结构化字段,智谱把布尔值序列化成 1/0/'yes'/'no',千问把单值字段包成 {value:x} 嵌套对象,小模型的 JSON 经常带尾逗号,Claude 偶发把 JSON 包在 `` ```json ... `````里。

这些怪癖用数据驱动的方式修复(configs/llm/quirks.yaml):

yaml 复制代码
quirks:
  - name: strip-markdown-links
    phase: post_response
    pattern: '\[(.+?)\]\((.+?)\)'
    transform: regex_replace
    replace: '$1'
    reason: DeepSeek 偶发把 URL 混进结构化字段

  - name: normalize-bool
    phase: post_response
    transform: zhipu_bool
    reason: 智谱把 bool 序列化成 1/0/'yes'/'no'

  - name: flatten-nested-objects
    phase: post_response
    transform: qwen_flatten
    reason: 千问偶发把单值字段包成 {value:x} 嵌套对象

  - name: strip-trailing-commas
    phase: post_response
    transform: json_repair
    reason: 小模型 JSON 输出经常带尾逗号

  - name: extract-from-codeblock
    phase: post_response
    transform: codeblock_unwrap
    reason: Claude 偶发把 JSON 包在 ```json ... ```里

Repair 层在 workflow 返回之前应用 transform,调用方拿到的是"标准格式"。每个 transform 是一个实现了 Transform 接口的 Go 函数:

go 复制代码
type Transform interface {
    Name() string
    Apply(input string, params map[string]string) (string, error)
}

新 provider 有新怪癖,加一行 quirk 配置 + 一个 transform 函数,不改 workflow 主体。


ChatRequest 的验证前移

发往 provider 之前,必须调 Validate()

go 复制代码
func (r ChatRequest) Validate() error {
    if r.Profile == ""        { return ErrChatNoProfile }
    if len(r.Messages) == 0   { return ErrChatNoMessages }
    if r.TenantID == ""       { return ErrChatNoTenantID }
    if r.Temperature < 0 || r.Temperature > 2 { return ErrChatTemperatureRange }
    if r.MaxTokens < 0 || r.MaxTokens > 200_000 { return ErrChatMaxTokensOverflow }
    return nil
}

TenantID 必填------它是计费和 RLS(行级安全)的维度。空 TenantID 发出去的请求无法归属到租户,计费就乱了。MaxTokens 不能为负数------负数没有意义,应该是配置错误。


跟 Eino 的关系

Einomodel.ChatModel 接口和我们的 Provider 思路一致------Eino 有 Generate()/Stream(),我们有 Chat()/Stream()

两者通过 chatModelAdapter 桥接。这个适配器做两件事:

  1. 入参转换 :把 Eino 的 []*schema.Message 转成我们的 ChatRequest(包含 Profile、TenantID、Messages)
  2. 输出转换 :把我们的 <-chan StreamChunk 转成 Eino 的 schema.StreamReader[*schema.Message](用 schema.Pipe 桥接)
scss 复制代码
Eino Graph 节点调 chatModelAdapter.Stream()
    │
    ▼
chatModelAdapter 把 Eino 消息转成 ChatRequest
    │
    ▼
Router.Pick("deepseek-v3") → 选出 Provider
    │
    ▼
Workflow(openai-compat).Stream() → HTTP 调 DeepSeek API
    │
    ▼
Repair 层应用 Quirks transform
    │
    ▼
StreamChunk channel → schema.Pipe → Eino StreamReader
    │
    ▼
Eino Graph 节点拿到 LLM 的流式输出

两层分工:Eino 管 ReAct 图的编排(调 LLM、调工具、分支路由),我们管 LLM 的路由选择、协议适配和输出修复。Eino 不知道下面是 DeepSeek 还是 Anthropic,我们不知道上面是 ReAct 循环还是单次调用。


小结

换 LLM provider 不改业务代码,靠四件事:

  1. 统一 Provider 接口:所有 provider 实现同一接口,Router 按 profile 路由
  2. Workflow 协议适配 :5 种 workflow 覆盖主流 API 格式,openai-compat 一个搞定大部分
  3. 配置驱动:大多数 provider 只加一行 yaml profile,路由策略支持主备切换
  4. Quirks 数据驱动:provider 的输出怪癖用配置+transform 插件修复,不污染 workflow 主体

下一篇:知识库检索 ------ 让 Agent 读懂企业内部文档

相关推荐
leeyi2 小时前
工具调用:Agent 的手和眼
llm·agent
leeyi2 小时前
SSE 实时推流 —— Token 怎么一个个蹦出来
后端·agent
凌奕2 小时前
微信小程序接入微信 AI:让用户"说一句话"就能下单
微信·微信小程序·agent
leeyi2 小时前
Hook 系统:插件化安全护栏怎么设计
llm·agent
Nicander2 小时前
去除中文写作AI味的Skill:write-like-human-zh
agent
leeyi2 小时前
ReAct 循环的 50 行 Go 实现,逐行拆解
后端·agent
leeyi2 小时前
HITL:让人类随时叫停 AI,并且能优雅地继续
后端·agent
hixiong1232 小时前
C# Tokenizers.DotNet测试工具
开发语言·人工智能·llm
AKAMAI2 小时前
当OpenClaw遇见Linode:一键部署7×24h云端AI助理
云计算·agent