Go 没有厂商维护的 SDK,但接口是 OpenAI 兼容的 HTTP 接口,用标准库就能直接调。下面这段可以直接 go run 跑通,把 YOUR_KEY 换成后台生成的令牌即可:
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const (
apiKey = "YOUR_KEY"
apiURL = "https://xyuapi.top/v1/chat/completions"
modelID = "deepseek-v3.2-thinking"
)
type message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []message `json:"messages"`
Stream bool `json:"stream,omitempty"`
}
type chatResponse struct {
Choices []struct {
Message message `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
}
func main() {
body, _ := json.Marshal(chatRequest{
Model: modelID,
Messages: []message{{Role: "user", Content: "用一句话解释什么是向量数据库"}},
})
req, _ := http.NewRequest("POST", apiURL, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(resp.Body)
panic(fmt.Sprintf("status=%d body=%s", resp.StatusCode, raw))
}
var out chatResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
panic(err)
}
fmt.Println(out.Choices[0].Message.Content)
}
请求只要三个字段:model、messages、stream。messages 里每条消息的 role 取 system、user、assistant 三种值。响应里的正文在 choices[0].message.content,结束标记在 choices[0].finish_reason。
生产代码建议用 struct,字段拼错编译期就能发现,map[string]any 拼错了只会在运行时报 400。只有在做参数透传、字段不固定时才用 map。
Go 的常见写法是只判断状态码就把 resp.Body 关掉,结果报错信息全丢。上游返回的 400 里通常带有具体原因,比如是哪个参数不被支持、哪个模型名找不到,把 body 一起打进日志,排查能省一半时间。
不想自己拼请求的话,社区库 github.com/sashabaranov/go-openai 能直接用,改一行地址就切到小鱼API:
go get github.com/sashabaranov/go-openai
import (
"context"
"fmt"
openai "github.com/sashabaranov/go-openai"
)
func main() {
config := openai.DefaultConfig("YOUR_KEY")
config.BaseURL = "https://xyuapi.top/v1" // 结尾不能带斜杠
client := openai.NewClientWithConfig(config)
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
Model: "claude-sonnet-4-5-thinking",
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleUser, Content: "写一句电商首页的文案"},
},
})
if err != nil {
panic(err)
}
fmt.Println(resp.Choices[0].Message.Content)
}
config.BaseURL 这里给的是不带端点后缀的根地址,库内部会自己拼 /chat/completions,所以只写到 /v1 为止。手写 HTTP 时要写到完整端点,两种写法别混。
go.mod 里把版本固定住,别用 latest。这类社区库在小版本之间偶尔会改方法签名,本地跑得好好的代码,换台机器重新拉依赖就编译不过。版本固定之后 go mod tidy 的结果在任何机器上都一致,排查问题时也不用先怀疑依赖。
要精确控制超时、重试、逐帧处理 SSE,手写更顺手,出问题能一路断点跟下去。要快速跑通、只用最普通的对话接口,用库更省事。两者可以混用:主流程用手写,脚本类的小工具用库,只要都指向同一个接入地址就行。
流式返回走的是 Server-Sent Events,每行以 data: 开头,最后一行是 data: [DONE]。
func streamChat(prompt string) error {
payload := chatRequest{
Model: modelID,
Stream: true,
Messages: []message{{Role: "user", Content: prompt}},
}
body, _ := json.Marshal(payload)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Accept", "text/event-stream")
client := &http.Client{} // 不用客户端整体超时,交给 context 控制
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(resp.Body)
return fmt.Errorf("status=%d body=%s", resp.StatusCode, raw)
}
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // 单行可能很长,必须放大
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "[DONE]" {
break
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue // 单帧解析失败不影响后续
}
if len(chunk.Choices) > 0 {
fmt.Print(chunk.Choices[0].Delta.Content)
}
}
return scanner.Err()
}
一是 scanner.Buffer 要显式放大。bufio.Scanner 默认单行上限是六十四字节级别,SSE 里带长文本的帧会直接报 token too long,表现为流式跑到一半断掉。二是 data: 后面的空格要 TrimSpace 掉,不同实现的空格数量不一定一样。三是解析失败的帧要跳过而不是中断,中间夹一个心跳帧就把整个流打断是不划算的。
http.Client.Timeout 覆盖的是从发起请求到读完整个响应体的全过程,流式输出持续几分钟时会被中途掐断。正确做法是 Timeout 留空,用 context.WithTimeout 控制整体时长,这样既能限时又不会因为「读得慢」而误杀。
后端做中转时,响应头要设成 text/event-stream,并且每写完一帧就调用 http.Flusher 的 Flush()。不调用 Flush,数据会攒在缓冲区里,前端要等到全部生成完才一次性收到,看起来就像流式失效了。另外记得带上 X-Accel-Buffering: no 这个响应头,让反向代理不要做缓冲。
流式断流不会返回错误,扫描器直接就结束了。判断方法是在循环结束后检查有没有收到过 [DONE]。没收到就说明连接异常结束,此时把已收到的文本拼起来,在 messages 里补一条 assistant 消息带上已生成内容,重新发一次请求让模型接着写。重试上限建议两次。
type APIError struct {
Status int
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("ai api 返回 %d:%s", e.Status, e.Body)
}
func isRetryable(err error) bool {
var ae *APIError
if errors.As(err, &ae) {
return ae.Status == http.StatusTooManyRequests || ae.Status >= 500
}
return true // 网络类错误一般值得重试
}
有了这个类型,上层就能用 errors.As 判断该不该重试。401 和 404 属于请求本身有问题,重试一百次也是同样结果,应该直接往上抛。
func askWithRetry(ctx context.Context, prompt string, maxRetry int) (string, error) {
var lastErr error
for i := 0; i <= maxRetry; i++ {
out, err := askOnce(ctx, prompt)
if err == nil {
return out, nil
}
lastErr = err
if !isRetryable(err) {
return "", err
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(time.Duration(1<<i) * time.Second): // 1s、2s、4s
}
}
return "", fmt.Errorf("重试 %d 次后失败:%w", maxRetry, lastErr)
}
退避里的 select 不能省,直接用 time.Sleep 会让已经被取消的请求继续空转,服务退出时也会卡住。
思考型模型的推理耗时和输出长度强相关,三千字的长回答首字可能要等四十秒。对话类请求给 120 秒,长文生成给 300 秒。把超时设成 30 秒的表现是偶发成功、偶发超时,最费排查时间。
同一台机器、同一条网络实测下来,差别相当大:聊天式的短问题首字一秒多就回来了,整段两秒左右结束;换成需要推理的数学题,首字要等八到十秒,完整回答二十多秒。所以超时值不适合所有任务共用一套,短问答给六十秒,长文生成给三百秒,流式请求干脆交给 context 管总时长。
sem := make(chan struct{}, 4) // 同时在飞的请求不超过 4 个
var wg sync.WaitGroup
for _, p := range prompts {
wg.Add(1)
go func(p string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
out, err := askWithRetry(ctx, p, 2)
if err != nil {
log.Println("失败:", err)
return
}
log.Println(out)
}(p)
}
wg.Wait()
循环变量作为参数传进闭包是必须的,用老版本 Go 时直接在闭包里引用会拿到循环结束后的那个值,表现是所有 goroutine 处理同一条数据。
触发频率限制时要降低并发,而不是换令牌。把并发从二三十压到三四,配合指数退避,绝大多数 429 会消失。并发堆高只会让失败率上升,总吞吐反而更低。
请求量大时复用 http.Client 实例,让连接池生效。每次都 &http.Client{} 新建一个,会导致连接反复建立,延迟明显升高,还容易耗尽本地端口。把 client 做成包级变量或者结构体字段即可。
让模型返回固定结构时,直接在提示词里给出 JSON 示例,然后用 struct 反序列化:
type Item struct {
Name string `json:"name"`
Price float64 `json:"price"`
Tags []string `json:"tags"`
}
模型偶尔会带上 Markdown 代码围栏,稳妥做法是先把首尾的围栏标记去掉再 json.Unmarshal,解析失败时把原始文本记进日志,方便回看是哪一版提示词出了问题。
请求里加 tools 字段,模型返回的 finish_reason 会变成 tool_calls,正文里带的是要调的函数名和参数。Go 这边要把 JSON 参数反序列化成具体类型再执行,执行结果再作为一条 role: tool 的消息追加回去,重新请求一次。整个循环要设最大轮数上限,否则模型可能反复调同一个函数。
| 现象 | 原因 | 处理 |
|---|---|---|
| 401 认证失败 | 令牌带空格,或 Bearer 后面漏了空格 | 检查请求头拼接字符串 |
| 404 模型不存在 | 模型名写错 | 对照模型列表逐字核对 |
| 404 加一段 HTML | 端点路径写错 | 确认是 https://xyuapi.top/v1/chat/completions |
| 中文变问号 | 字符串被按字节截断 | 按 rune 处理,别按 byte 切片 |
| 流式无输出 | 扫描器缓冲区太小 | scanner.Buffer 放大到 1MB |
| 流式中途停住 | 客户端整体超时生效 | http.Client.Timeout 置零,改用 context |
| 连接被拒 | 走了系统代理 | 检查 HTTP_PROXY 环境变量 |
Go 的字符串是字节序列,s[0:10] 取的是十个字节而不是十个字,中文一个字占三个字节,按字节切就会切出半个字符,显示成问号或方块。需要按字数截断时用 []rune(s) 转换后再切。
不少开发机配了 HTTP_PROXY 环境变量,而 Go 的默认传输层会自动读取这个变量,请求被悄悄送到代理上。表现是本地跑得通、部署到服务器却报连接超时,或者反过来。排查方法是把 http.ProxyFromEnvironment 的结果打出来,或者在自定义的 http.Transport 里显式把 Proxy 设成 nil,让请求直连。
多个 goroutine 同时往同一个字符串或者 map 里写,会触发运行时的并发写检测,程序直接崩掉。汇总结果要用 sync.Mutex 保护,或者用带缓冲的 channel 把结果收回来,在主 goroutine 里统一处理。开发阶段用 go run -race main.go 跑一次,这类问题能提前抓出来。
先打印实际发出去的 JSON 请求体,确认 model 字段的值和预期完全一致;再确认没有在结构体 tag 上写错字段名导致字段被丢弃。json:"model" 写成 json:"modelName" 时,发出去的请求里根本没有 model 字段,服务端返回的报错会指向别的地方,很容易带偏方向。
| 模型名 | 单价 | 适合放在哪 |
|---|---|---|
gemini-2.5-pro | 0.031 元/次 | 批量分类、初筛、轻量问答 |
deepseek-v3.2-thinking | 0.049 元/次 | 中文生成、结构化抽取 |
claude-sonnet-4-5-thinking | 0.09 元/次 | 长文输出、多步任务 |
gpt-5.5 | 0.2 元/次 | 高难推理 |
全平台按调用次数计费,和输入输出长度无关。写批量任务时这一点很有用:一次请求里塞进尽可能多的内容,比切成很多次小请求便宜得多。常见的做法是把一百条短任务合成一次请求,让模型返回 JSON 数组,一次调用的钱办一百件事。
一是减少无效调用,相同提问的结果缓存起来,明显不合理的输入在前置环节就拦掉。二是把低价值环节换成便宜模型,比如意图判断用 gemini-2.5-pro,只有真正需要推理的步骤才用 gpt-5.5。
按次计费的好处在这里体现得最明显:把一百条短任务拼成一次请求,让模型返回 JSON 数组,成本就按一次调用算。前提是提示词里把字段和数量约束写清楚,返回后逐条校验字段是否齐全,缺的那几条再补一次小批量重试。整体比一百次单独请求便宜得多,速度也更快。
Go 这边接小鱼API 有两条路:标准库手写 HTTP,可控性更强,流式和重试都能精确控制;用 go-openai 库省事,改一行 BaseURL 就通。真正需要留神的是三个地方:流式的扫描器缓冲区、客户端整体超时对流式的误杀、以及把响应体丢掉导致报错信息不完整。这三处都在上面的代码里处理过了,照着改基本不会再踩。