← 返回资讯

Go 调用大模型 API 完整示例

2026-06-22

如果你是从 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,里面带 HTTPStatusCodeCodeMessage 三个字段,比标准库那种裸 error 好处理得多:

var apiErr *openai.APIError
if errors.As(err, &apiErr) {
    switch apiErr.HTTPStatusCode {
    case 429:
        // 触发限流,走重试逻辑
    case 401:
        // key 失效,直接告警,不要重试
    default:
        // 其他错误按需处理
    }
}

两种方式对比

维度net/httpgo-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/raterate.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-openaiClient 是并发安全的,可以在整个程序生命周期共享单个实例;标准库 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.WithTimeoutcontext.WithCancel,请求结束记得 defer cancel() 释放资源。如果你图省事把一个全局 context 传给所有请求共用,一旦某个地方误调用了 cancel(),会把所有正在进行中的请求全部取消掉,这种问题排查起来非常隐蔽,因为报错现场看到的只是「莫名其妙全部请求同时失败」,很难第一时间联想到 context 被误共享。


更多接入方案见大模型 API 接入完全指南接入教程专题。Java 版本实现参考Java 调用大模型 API。需要统一多模型入口?申请力达云聚合 API 内测