Go 调用大模型 API 完整示例
如果你是从 Python 或 Node 转过来写 Go 服务端,第一次接大模型 API 大概率会踩两个坑:一是把 http.Client 每次请求都 new 一遍,高并发下连接池被打爆、connection reset by peer 满天飞;二是流式接口用 bufio.Scanner 硬解析 SSE,一遇到超长 JSON 行就 bufio.Scanner: token too long 直接崩掉。这两个坑我在给 Go 后端服务接入模型网关时都踩过,本文把标准库和 SDK 两条路的实现、坑位、并发和重试策略都写清楚,跟着做基本不会再翻车。
Go 调用大模型 API 最轻量的方式是标准库 net/http,生产项目推荐使用 sashabaranov/go-openai——它封装了完整的 Chat Completions、流式和 Embeddings 接口,且与所有 OpenAI 兼容平台配合只需改 BaseURL。两者不是二选一的关系:小工具、CLI 脚本用 net/http 图省事完全够用;只要涉及生产环境的并发调用、流式渲染、错误重试,go-openai 省下来的封装成本远超那一行 go get。
环境准备
# 初始化模块
go mod init myapp
# 安装 go-openai(可选,推荐生产使用)
go get github.com/sashabaranov/go-openai
export OPENAI_API_KEY="sk-xxx"
export OPENAI_BASE_URL="https://api.lidayun.com/v1"
这里有个新手常翻的车:如果你是用 .env 文件加某个第三方加载库写入这两个变量,一定要检查值末尾有没有被带进换行符或空格。os.Getenv 不会帮你 TrimSpace,一个尾随的 \n 拼进 Authorization 头里,服务端校验签名失败,返回的还是笼统的 401,你对着密钥本身看半天都看不出问题——排查时养成习惯,先 fmt.Printf("%q\n", apiKey) 把值原样打出来,用 %q 而不是 %s,空白字符会原形毕露。
方式一:标准库 net/http(零依赖)
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type ChatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
MaxTokens int `json:"max_tokens"`
}
type ChatResponse struct {
Choices []struct {
Message Message `json:"message"`
} `json:"choices"`
}
var (
apiKey = os.Getenv("OPENAI_API_KEY")
baseURL = func() string {
if v := os.Getenv("OPENAI_BASE_URL"); v != "" {
return v
}
return "https://api.openai.com/v1"
}()
httpClient = &http.Client{Timeout: 60 * time.Second}
)
func chat(messages []Message, model string) (string, error) {
payload, _ := json.Marshal(ChatRequest{
Model: model,
Messages: messages,
MaxTokens: 1024,
})
req, _ := http.NewRequest("POST", baseURL+"/chat/completions", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return "", fmt.Errorf("API error %d: %s", resp.StatusCode, body)
}
var result ChatResponse
if err := json.Unmarshal(body, &result); err != nil {
return "", err
}
return result.Choices[0].Message.Content, nil
}
func main() {
reply, err := chat([]Message{{Role: "user", Content: "用 Go 实现并发爬虫"}}, "gpt-4o-mini")
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(reply)
}
上面这段代码看着简单,有几处设计是刻意为之,不是随手写的:
httpClient定义成包级变量而不是每次请求new一个http.Client{}。http.Client内部持有一个Transport,默认的http.DefaultTransport会做连接池复用(MaxIdleConnsPerHost默认只有 2)。如果你在处理 HTTP 请求的 handler 里每次都新建 client,等于每次都新建连接池,TCP 握手和 TLS 握手的开销会成倍叠加,QPS 稍微上去一点响应时间就跟着抖。生产上如果并发调用量大,记得把Transport.MaxIdleConnsPerHost调到 100 甚至更高,具体值要结合你的下游并发数压测出来,不要照抄别人的数字。Timeout: 60 * time.Second是给整个请求(含建连、发送、接收)设的硬上限,而不是每个阶段单独计时。如果模型响应慢导致超时,你在日志里会看到context deadline exceeded或者Client.Timeout exceeded while awaiting headers——两者含义不同:前者通常是你自己用context.WithTimeout显式设的超时触发,后者是http.Client.Timeout本身触发,排查时先看清楚是哪种,才知道该调哪个参数。resp.StatusCode != 200这一判断是最容易漏细节的地方:大模型网关常见的错误码除了 200 还有 400(请求体格式错,比如messages传了空数组)、401(key 无效或额度耗尽,注意有些平台额度耗尽也返 401 而不是更直观的 402)、429(触发限流,通常响应头里会带Retry-After秒数)、500/502/503(下游模型服务本身抖动)。当前代码把这些错误码统一包成一个fmt.Errorf,能跑通但不利于程序自动重试——429 和 500 应该重试,400 和 401 重试了也没用,纯属浪费配额,正式项目里建议把resp.StatusCode单独返回给调用方做分支处理。
方式二:go-openai SDK(推荐)
package main
import (
"context"
"fmt"
"os"
openai "github.com/sashabaranov/go-openai"
)
func main() {
cfg := openai.DefaultConfig(os.Getenv("OPENAI_API_KEY"))
if base := os.Getenv("OPENAI_BASE_URL"); base != "" {
cfg.BaseURL = base
}
client := openai.NewClientWithConfig(cfg)
resp, err := client.CreateChatCompletion(
context.Background(),
openai.ChatCompletionRequest{
Model: openai.GPT4oMini,
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleUser, Content: "解释 goroutine 和 channel"},
},
MaxTokens: 1024,
},
)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(resp.Choices[0].Message.Content)
}
openai.NewClientWithConfig(cfg) 内部其实也是包了一层 http.Client,只是把连接池、超时这些细节都替你管好了,所以你只用管业务逻辑。有个容易被忽略的点:client 本身应该在程序启动时初始化一次,全局复用,不要在每次请求里都 NewClientWithConfig——道理和上面 net/http 那节一样,都是为了不重复建连接池。如果你的服务是多租户(不同用户配不同的 BaseURL 或 key),可以按租户维度缓存 client 实例(比如用 sync.Map 存一份),而不是每个请求都新建。
err 的类型判断也值得多说一句。go-openai 出错时返回的通常是 *openai.APIError,里面带 HTTPStatusCode、Code、Message 三个字段,比标准库那种裸 error 好处理得多:
var apiErr *openai.APIError
if errors.As(err, &apiErr) {
switch apiErr.HTTPStatusCode {
case 429:
// 触发限流,走重试逻辑
case 401:
// key 失效,直接告警,不要重试
default:
// 其他错误按需处理
}
}
两种方式对比
| 维度 | net/http | go-openai |
|---|---|---|
| 外部依赖 | 无 | go get 一个包 |
| 流式 SSE | 需手动解析 | 内置 CreateChatCompletionStream |
| 类型安全 | 自定义 struct | 完整 OpenAI 类型 |
| 错误处理 | 手动判断状态码 | 结构化 *openai.APIError |
| 适用场景 | 极简脚本 / 无依赖部署 | 生产服务 |
选哪个不用纠结太久,给个实操判断标准:如果你的代码要跑在对二进制体积、依赖树有严格要求的环境(比如某些 CI 镜像、边缘设备、或者公司内部有依赖审查流程),net/http 零依赖的优势是实打实的,SDK 升级也不用等第三方;反过来只要是常规的后端服务、需要流式渲染给前端、需要结构化错误重试,直接上 go-openai,自己写的 SSE 解析代码大概率没有官方维护的健壮,尤其是断线重连、多字节字符跨 chunk 截断这些边界情况,别人已经踩过的坑没必要自己再踩一遍。
流式输出
stream, err := client.CreateChatCompletionStream(
context.Background(),
openai.ChatCompletionRequest{
Model: openai.GPT4oMini,
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleUser, Content: "写一首关于 Go 的短诗"},
},
Stream: true,
},
)
if err != nil {
panic(err)
}
defer stream.Close()
for {
chunk, err := stream.Recv()
if err != nil {
break // io.EOF 表示结束
}
fmt.Print(chunk.Choices[0].Delta.Content)
}
fmt.Println()
stream.Recv() 这个循环里,err != nil 就直接 break 掉,代码注释写了 io.EOF 表示结束,但生产代码这里要留个心眼:io.EOF 才是正常结束,如果是网络中断、下游服务重启导致的连接异常关闭,Recv() 返回的可能是别的 error(比如 unexpected EOF 或者底层的网络错误),语义上和「模型正常说完话」完全不是一回事。如果你这里全部一刀切当正常结束处理,用户看到的现象就是回复莫名其妙截断了一半,还以为是模型自己不说了,日志里却什么错误都没留下。稳妥的写法是判断一下:
if err != nil {
if err == io.EOF {
break // 正常结束
}
fmt.Println("\nstream error:", err) // 异常中断,记录日志/上报
break
}
流式接口还有个东西容易被忽略:chunk.Choices 在某些边界情况下可能是空切片(比如只返回了 usage 统计信息的收尾 chunk),直接 chunk.Choices[0] 会 panic。生产代码一定要先判断 len(chunk.Choices) > 0 再取下标,这个坑我见过至少两个团队上线后才在压测中暴露出来。
并发调用与限流
批量处理场景(比如离线跑一批文档做摘要)经常需要开多个 goroutine 并发调用 API,但不能无脑 for 循环里 go func()——大模型网关基本都有并发数和 QPS 限制,瞬间打过去几百个请求,大概率直接触发 429,甚至可能被临时封禁。用带缓冲的 channel 做一个简单的并发池,把并发度控制在网关允许的范围内:
func batchChat(prompts []string, concurrency int) []string {
results := make([]string, len(prompts))
sem := make(chan struct{}, concurrency) // 控制并发数
var wg sync.WaitGroup
for i, p := range prompts {
wg.Add(1)
sem <- struct{}{} // 占一个名额,满了就阻塞在这里
go func(idx int, prompt string) {
defer wg.Done()
defer func() { <-sem }() // 释放名额
reply, err := chat([]Message{{Role: "user", Content: prompt}}, "gpt-4o-mini")
if err != nil {
results[idx] = "error: " + err.Error()
return
}
results[idx] = reply
}(i, p)
}
wg.Wait()
return results
}
concurrency 具体设多少没有万能值,取决于你接入的网关允许的并发上限,先从 3~5 这种保守值起步,观察 429 出现的频率再逐步往上调,比一上来就猜一个大数字稳妥得多。如果对并发精度要求更高(比如要精确控制每秒请求数而不只是并发数),可以引入 golang.org/x/time/rate 的 rate.Limiter,按 QPS 而不是并发数来限流,两者不是一回事:并发数控制的是「同时有多少个请求在飞」,QPS 限制的是「每秒发起多少个新请求」,网关文档写的是哪种限制,你就该用哪种方式去对齐。
重试与退避
429 和 5xx 这类瞬时错误,加个指数退避重试基本能把成功率拉到一个能接受的水平,不加重试的话,稍微有点网络抖动整个批处理任务就得从头重跑:
func chatWithRetry(messages []Message, model string, maxRetries int) (string, error) {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
backoff := time.Duration(1<<uint(attempt)) * time.Second // 1s, 2s, 4s...
time.Sleep(backoff)
}
reply, err := chat(messages, model)
if err == nil {
return reply, nil
}
lastErr = err
// 只对可重试的错误重试,401/400 这类重试了也没用
if !isRetryable(err) {
return "", err
}
}
return "", fmt.Errorf("重试 %d 次后仍失败: %w", maxRetries, lastErr)
}
func isRetryable(err error) bool {
msg := err.Error()
return strings.Contains(msg, "429") || strings.Contains(msg, "500") || strings.Contains(msg, "502") || strings.Contains(msg, "503")
}
这里的 isRetryable 用字符串匹配状态码是个简化写法,方便你先跑起来;正式项目里前面 chat 函数最好把状态码作为结构化字段返回(而不是塞进 error 字符串里),判断起来更可靠,也不怕上游改了错误文案的措辞导致字符串匹配失效。退避时间也别无脑封顶,建议给最大重试次数设一个明确上限(比如 3~5 次),并且给单个请求整体加一个 context.WithTimeout,避免退避加重试把一次调用的总耗时拖得用户根本等不起。
成本与 token 预估
批量调用之前最好能大致估一下这次任务要花多少钱,不然等账单出来才发现预算超了就晚了。Go 生态里没有 OpenAI 官方 tiktoken 那么权威的库,pkoukk/tiktoken-go 是社区里用得比较多的一个移植版本,够日常预估用:
go get github.com/pkoukk/tiktoken-go
enc, _ := tiktoken.GetEncoding("cl100k_base")
tokens := enc.Encode(prompt, nil, nil)
fmt.Println("预估 token 数:", len(tokens))
拿到 token 数之后乘以你接入平台当前的单价就是预估成本,注意单价会变,具体以你所在平台当前公示的价格为准,不要把某次查到的数字直接写死进代码当常量用。如果只是想要一个粗略数量级、不追求精确,中文一个汉字大约算 1.5~2 个 token,英文一个单词大约算 1.3 个 token,这个经验值拿来做个量级估算够用,正式计费判断还是要用 tiktoken 或者直接看接口返回的 usage 字段——这才是网关侧真实计费的口径。
常见问题
context deadline exceeded 错误怎么办? 模型响应时间与 max_tokens 正相关,60 s 超时对大多数场景够用;若生成内容很长,可调大到 120 s 或改用流式(流式首 token 响应快)。
多个 goroutine 共享同一个 client 安全吗? go-openai 的 Client 是并发安全的,可以在整个程序生命周期共享单个实例;标准库 http.Client 同样并发安全。
如何切换到 DeepSeek 或其他兼容平台? 只需将 cfg.BaseURL 改为目标平台的 /v1 端点,model 字段改为该平台的模型名,其余代码无需修改。
返回内容乱码或中文变成一堆问号? 先确认你打印输出的终端本身是不是 UTF-8 编码(Windows 下的 cmd.exe 默认是 GBK,PowerShell 也不一定是 UTF-8),Go 程序输出的字符串本身是 UTF-8,问题往往出在终端渲染而不是代码。如果是写进文件后乱码,检查一下有没有中途用 []byte 按固定长度截断字符串——中文一个字符在 UTF-8 里占 3 个字节,按字节数截断很容易把一个汉字从中间切断,正确做法是用 []rune(s) 转成 rune 切片再按字符数截取。
报错 context length exceeded 或者类似「输入超出上下文长度限制」怎么办? 这是把 messages 历史堆得太长(多轮对话没做裁剪,或者一次塞了整篇长文档)触发的,模型自身的上下文窗口是硬限制,没法用重试绕过去。常见处理方式:一是做历史消息裁剪,只保留最近 N 轮加一个摘要;二是长文档场景改用检索增强(先做向量检索取相关片段再拼进 prompt),而不是把全文一股脑塞进去;三是如果平台同时提供了长上下文版本的模型,评估一下是否值得换,但要注意长上下文模型通常单价更高,别为了图省事默认全用大窗口模型。
多个请求之间要不要复用同一个 context.Context? 不要。context 应该按请求粒度创建,一个请求一个 context.WithTimeout 或 context.WithCancel,请求结束记得 defer cancel() 释放资源。如果你图省事把一个全局 context 传给所有请求共用,一旦某个地方误调用了 cancel(),会把所有正在进行中的请求全部取消掉,这种问题排查起来非常隐蔽,因为报错现场看到的只是「莫名其妙全部请求同时失败」,很难第一时间联想到 context 被误共享。
更多接入方案见大模型 API 接入完全指南与接入教程专题。Java 版本实现参考Java 调用大模型 API。需要统一多模型入口?申请力达云聚合 API 内测。