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

作者:袖梨 2026-07-04

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

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

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

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


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

整个 LLM 层分四层:

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

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

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:

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:

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(实际配置示例):

profiles:
  - name: deepseek-v3
    workflow: openai-compat
    base_url: 
    model: deepseek-chat
    auth: ${DEEPSEEK_API_KEY}  - name: claude-prod
    workflow: anthropic-compat
    base_url: 
    model: claude-sonnet-4-6
    auth: ${ANTHROPIC_API_KEY}  - name: ollama-local
    workflow: openai-compat
    base_url: 
    model: qwen2.5:72b
    auth: ""  - name: glm-4
    workflow: openai-compat
    base_url: 
    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):

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 函数:

type Transform interface {
    Name() string
    Apply(input string, params map[string]string) (string, error)
}

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


ChatRequest 的验证前移

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

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 的关系

Eino 的 model.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 桥接)
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 主体

相关文章

精彩推荐