最近两年,国内外大模型厂商如雨后春笋。DeepSeek、Qwen、Gemini、Doubao……每家
在 Go 里接入多个大模型厂商:一个兼容 OpenAI 协议的通用客户端设计
发布时间: 2026-07-03 (18 days ago)
GOAI

最近两年,国内外大模型厂商如雨后春笋。DeepSeek、Qwen、Gemini、Doubao……每家厂商都有自己的 API,如果为每家单独写一套对接代码,维护成本会非常高,而且每次新增厂商都要大改代码。

但有一个好消息:绝大多数主流大模型厂商,都兼容 OpenAI 的 API 协议

这意味着你可以只写一套代码,通过切换 base_urlapi_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_urlapi_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"

这个设计有两个关键点:

  1. Provider 是一个 map,不是硬编码的字段列表。这意味着你新增一个厂商,只需要在 YAML 里加几行配置,代码完全不用动。
  2. 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 接入层。

如果你的项目里也需要同时接入多个大模型厂商,希望这篇文章能给你一个可以直接参考的实现思路。