最近两年,国内外大模型厂商如雨后春笋。DeepSeek、Qwen、Gemini、Doubao……每家厂商都有自己的 API,如果为每家单独写一套对接代码,维护成本会非常高,而且每次新增厂商都要大改代码。
但有一个好消息:绝大多数主流大模型厂商,都兼容 OpenAI 的 API 协议。
这意味着你可以只写一套代码,通过切换 base_url 和 api_key,同时接入十几家不同的大模型厂商。
这篇文章分享我们在实际项目里的完整实现方案:如何在 Go 里设计一个通用的多厂商 LLM 客户端,支持运行时动态切换模型、连接池复用、以及兜底容错。
一、为什么大家都兼容 OpenAI 协议?
OpenAI 作为大模型领域的先驱,其 Chat Completion API 已经成为事实上的行业标准。核心接口大概是这个样子:
POST https://api.openai.com/v1/chat/completions
{
"model": "gpt-4",
"messages": [{"role": "user", "content": "你好"}]
}
国内外绝大多数厂商都提供了兼容这个协议的接口。区别仅仅在于:
| 厂商 | base_url | model 名称 |
|---|---|---|
| OpenAI | https://api.openai.com/v1 |
gpt-4o |
| Gemini | https://generativelanguage.googleapis.com/v1beta/openai/ |
gemini-2.0-flash |
| DeepSeek | https://api.deepseek.com |
deepseek-chat |
| Qwen(阿里云) | https://dashscope.aliyuncs.com/compatible-mode/v1 |
qwen-plus |
| Doubao(字节跳动) | https://ark.cn-beijing.volces.com/api/v3 |
doubao-pro-4k |
只要 base_url 和 api_key 不同,其余代码完全一样。这就是"兼容协议"带来的最大好处——你只需要维护一套调用逻辑。
在 Go 生态里,github.com/sashabaranov/go-openai 这个库原生支持自定义 base_url,是接入多厂商的最佳基础。
二、统一的配置抽象
第一步是设计一个统一的配置结构体,屏蔽不同厂商之间的差异。
go
// PlatformConfig 统一的大模型接口配置抽象
// 无论哪家厂商,都只需要这三个字段
type PlatformConfig struct {
ApiKey string `yaml:"api_key"`
Model string `yaml:"model"`
BaseURL string `yaml:"base_url"`
}
// LLMConfig 管理所有厂商的配置集合
type LLMConfig struct {
EmbeddingModel string `yaml:"embedding_model"` // 专用于 Embedding 的提供商
Default string `yaml:"default"` // 兜底默认厂商名称
Provider map[string]PlatformConfig `yaml:"provider"` // 厂商名 -> 配置
}
对应的 YAML 配置文件就非常直观:
yaml
llm:
embedding_model: "gemini" # 哪个厂商专门用于向量化
default: "deepseek" # 找不到指定厂商时的兜底
provider:
gemini:
api_key: "your-gemini-api-key"
model: "gemini-2.0-flash"
base_url: "https://generativelanguage.googleapis.com/v1beta/openai/"
deepseek:
api_key: "your-deepseek-api-key"
model: "deepseek-chat"
base_url: "https://api.deepseek.com"
qwen:
api_key: "your-qwen-api-key"
model: "qwen-plus"
base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1"
doubao:
api_key: "your-doubao-api-key"
model: "doubao-pro-4k"
base_url: "https://ark.cn-beijing.volces.com/api/v3"
这个设计有两个关键点:
Provider是一个map,不是硬编码的字段列表。这意味着你新增一个厂商,只需要在 YAML 里加几行配置,代码完全不用动。Default是兜底机制。当某个业务实体指定的厂商名称找不到(配置写错、厂商下线等),系统会自动回落到默认厂商,而不是直接报错崩溃。
三、带兜底的配置查询函数
基于上面的结构,实现一个根据厂商名获取配置的方法:
go
// GetConfigByName 根据提供商名字获取配置
// 如果找不到指定名称,或者名称为空,则返回 Default 兜底
func (l *LLMConfig) GetConfigByName(name string) PlatformConfig {
if cfg, ok := l.Provider[name]; ok && name != "" {
return cfg
}
// 兜底:返回 Default 配置
return l.Provider[l.Default]
}
这个函数虽然只有五行,但它的设计思路很重要:
name != "":防止空字符串被当成合法的 key,查到零值- 找不到时走
Default:系统不因为一个配置错误而中断,这叫优雅降级 - 返回的是值类型(非指针),调用方得到的是独立的副本,不会互相干扰
使用的时候就像这样:
go
// 每个实体可以独立指定它使用哪家厂商的模型
providerName := entity.Provider // 比如 "gemini" 或 "deepseek"
cfg := config.LLM.GetConfigByName(providerName)
// 拿到 apiKey、model、baseURL 之后就可以调用了
result, err := callLLM(ctx, cfg.ApiKey, cfg.BaseURL, cfg.Model, prompt)
四、客户端连接池:解决重复创建的性能问题
有了配置,下一步是创建 HTTP 客户端。但有一个容易被忽视的性能问题:
如果每次调用 LLM 都新建一个 openai.Client,会有大量的连接建立和 TLS 握手开销。
在高并发场景下(比如几十个实体同时调用 LLM),这个开销会非常明显。
解决方案是维护一个全局的客户端连接池——对于同一个 (baseURL, apiKey) 组合,永远复用同一个客户端实例:
go
var (
mu sync.Mutex
clients = make(map[string]*openai.Client)
)
// GetOpenAIClient 获取或创建一个单例客户端
// 对于同一个 baseURL + apiKey 组合,永远返回同一个实例
func GetOpenAIClient(apiKey string, baseURL string) *openai.Client {
mu.Lock()
defer mu.Unlock()
// 用 baseURL 和 apiKey 拼成唯一的 key
key := baseURL + "|" + apiKey
// 如果池子里已经有这个厂商的客户端,直接复用
if cli, ok := clients[key]; ok {
return cli
}
// 没有则新建,并缓存进池子
config := openai.DefaultConfig(apiKey)
if baseURL != "" {
config.BaseURL = baseURL
}
cli := openai.NewClientWithConfig(config)
clients[key] = cli
return cli
}
关键设计决策解析:
为什么用 baseURL + "|" + apiKey 作为 key,而不是只用厂商名?
因为同一个厂商可能有多个账号(比如你同时用了两个 DeepSeek API Key 做负载均衡),用组合 key 可以精确区分。
为什么用 sync.Mutex 而不是 sync.RWMutex?
初始化完成后,池子里的 client 只读不写,理论上用 RWMutex 的读锁更合适。但实际上客户端创建只发生在冷启动阶段(第一次被请求时),之后全是读操作,Mutex 的锁竞争在这个场景里完全可以忽略。保持简单是更好的选择。
五、通用的 Chat 调用函数
有了连接池,再封装一个通用的调用函数就很自然了:
go
// CallChat 通用的 LLM 聊天接口,兼容所有 OpenAI 协议的厂商
func CallChat(ctx context.Context, apiKey, baseURL, model, prompt string) (string, error) {
client := GetOpenAIClient(apiKey, baseURL)
req := openai.ChatCompletionRequest{
Model: model,
// 强制要求返回 JSON 格式(并非所有厂商都支持,需要测试)
ResponseFormat: &openai.ChatCompletionResponseFormat{
Type: openai.ChatCompletionResponseFormatTypeJSONObject,
},
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleUser,
Content: prompt,
},
},
}
resp, err := client.CreateChatCompletion(ctx, req)
if err != nil {
return "", fmt.Errorf("LLM API call failed: %w", err)
}
if len(resp.Choices) == 0 {
return "", fmt.Errorf("LLM returned no choices")
}
return resp.Choices[0].Message.Content, nil
}
把这几层组合在一起,一个完整的调用链就是:
YAML 配置
↓
GetConfigByName("deepseek") → PlatformConfig{ApiKey, Model, BaseURL}
↓
GetOpenAIClient(apiKey, baseURL) → 复用或新建客户端
↓
CallChat(ctx, apiKey, baseURL, model, prompt) → string 返回值
任何一个实体,只需要持有一个 providerName 字符串,就能调用到正确厂商的模型。
六、实际运行中踩过的坑
坑 1:不是所有厂商都支持 ResponseFormat: JSON
ResponseFormatTypeJSONObject 是 OpenAI 的特性,虽然协议兼容,但部分厂商对这个参数的支持不完整。有些厂商会忽略这个参数,有些会报错。
解法: 在 PlatformConfig 里加一个 SupportsJSONMode bool 字段,根据厂商配置决定是否启用这个参数。对于不支持的厂商,改为在 Prompt 里强制要求 JSON 格式(Prompt 工程兜底)。
坑 2:不同厂商的错误码不统一
虽然请求格式兼容,但各家厂商返回的错误码和错误信息字段存在差异。有些会返回标准的 OpenAI 错误格式,有些会返回自定义格式。
解法: 在 CallChat 里统一捕获 error,用 fmt.Errorf("... %w", err) 包装后透出,不在这一层做厂商特定的错误解析。
坑 3:并发场景下的 context 传递
当大量并发调用同时发出时,如果使用同一个顶层 context,一旦某个调用超时,可能会意外取消其他仍在正常运行的调用。
解法: 在每次 CallChat 里创建一个带独立超时的子 context,而不是直接使用传入的父 context:
go
func CallChat(ctx context.Context, ...) (string, error) {
// 为这次调用创建独立的超时,不影响其他并发调用
callCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
resp, err := client.CreateChatCompletion(callCtx, req)
// ...
}
七、扩展方向:这套设计能走多远?
上面的实现已经能覆盖 80% 的使用场景,但还有几个方向可以继续扩展:
1. 负载均衡:同一个厂商配置多个 API Key,每次调用时随机选取,分散请求压力和频控风险。
2. 健康检查 + 自动切换:如果某个厂商连续报错超过阈值(比如 3 次),自动将其标记为"不可用",后续请求自动路由到兜底厂商。
3. 成本统计:在 CallChat 里统计每次调用消耗的 Token 数量(从 resp.Usage 里读取),按厂商维度做成本分析。
4. 动态配置热更新:通过配置中心(比如 Consul、etcd)实现运行时不重启地更新 API Key 或切换模型。
总结
整套方案的核心思想只有一句话:
把所有厂商的差异封装在配置里,对业务代码暴露统一的接口。
从代码量来看,整个多厂商客户端的核心逻辑不超过 80 行 Go 代码。但它带来的好处是长期的:新增一个厂商只需改配置文件,更换某个实体的模型只需改一个字段,系统崩溃时的兜底回落是自动的。
OpenAI 兼容协议的普及,让这件事的工程成本大大降低了。 在 Go 生态里,借助 go-openai 这个库,几十行代码就能构建出一个生产可用的多厂商 LLM 接入层。
如果你的项目里也需要同时接入多个大模型厂商,希望这篇文章能给你一个可以直接参考的实现思路。