系列「企业级 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-anthropic 和 glm-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 的关系
Eino 的 model.ChatModel 接口和我们的 Provider 思路一致------Eino 有 Generate()/Stream(),我们有 Chat()/Stream()。
两者通过 chatModelAdapter 桥接。这个适配器做两件事:
- 入参转换 :把 Eino 的
[]*schema.Message转成我们的ChatRequest(包含 Profile、TenantID、Messages) - 输出转换 :把我们的
<-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 不改业务代码,靠四件事:
- 统一
Provider接口:所有 provider 实现同一接口,Router 按 profile 路由 - Workflow 协议适配 :5 种 workflow 覆盖主流 API 格式,
openai-compat一个搞定大部分 - 配置驱动:大多数 provider 只加一行 yaml profile,路由策略支持主备切换
- Quirks 数据驱动:provider 的输出怪癖用配置+transform 插件修复,不污染 workflow 主体
下一篇:知识库检索 ------ 让 Agent 读懂企业内部文档