Go 语言调用小鱼API:从最小示例到流式输出

Go 调用 AI API 的最小可用代码

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)
}

请求体与响应体的关键字段

请求只要三个字段:modelmessagesstreammessages 里每条消息的 rolesystemuserassistant 三种值。响应里的正文在 choices[0].message.content,结束标记在 choices[0].finish_reason

用 struct 还是 map

生产代码建议用 struct,字段拼错编译期就能发现,map[string]any 拼错了只会在运行时报 400。只有在做参数透传、字段不固定时才用 map

出错时一定要把响应体读出来

Go 的常见写法是只判断状态码就把 resp.Body 关掉,结果报错信息全丢。上游返回的 400 里通常带有具体原因,比如是哪个参数不被支持、哪个模型名找不到,把 body 一起打进日志,排查能省一半时间。

用 go-openai 库更省事

不想自己拼请求的话,社区库 github.com/sashabaranov/go-openai 能直接用,改一行地址就切到小鱼API:

go get github.com/sashabaranov/go-openai

用 ClientConfig 换地址

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 的结果在任何机器上都一致,排查问题时也不用先怀疑依赖。

与手写 HTTP 怎么取舍

要精确控制超时、重试、逐帧处理 SSE,手写更顺手,出问题能一路断点跟下去。要快速跑通、只用最普通的对话接口,用库更省事。两者可以混用:主流程用手写,脚本类的小工具用库,只要都指向同一个接入地址就行。

流式输出:手写 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.FlusherFlush()。不调用 Flush,数据会攒在缓冲区里,前端要等到全部生成完才一次性收到,看起来就像流式失效了。另外记得带上 X-Accel-Buffering: no 这个响应头,让反向代理不要做缓冲。

断流之后怎么续写

流式断流不会返回错误,扫描器直接就结束了。判断方法是在循环结束后检查有没有收到过 [DONE]。没收到就说明连接异常结束,此时把已收到的文本拼起来,在 messages 里补一条 assistant 消息带上已生成内容,重新发一次请求让模型接着写。重试上限建议两次。

超时、重试与错误处理

把 HTTP 错误包装成可判断的类型

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 的正确处理方向

触发频率限制时要降低并发,而不是换令牌。把并发从二三十压到三四,配合指数退避,绝大多数 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-pro0.031 元/次批量分类、初筛、轻量问答
deepseek-v3.2-thinking0.049 元/次中文生成、结构化抽取
claude-sonnet-4-5-thinking0.09 元/次长文输出、多步任务
gpt-5.50.2 元/次高难推理

按次计费对 Go 服务的影响

全平台按调用次数计费,和输入输出长度无关。写批量任务时这一点很有用:一次请求里塞进尽可能多的内容,比切成很多次小请求便宜得多。常见的做法是把一百条短任务合成一次请求,让模型返回 JSON 数组,一次调用的钱办一百件事。

成本控制的两个着力点

一是减少无效调用,相同提问的结果缓存起来,明显不合理的输入在前置环节就拦掉。二是把低价值环节换成便宜模型,比如意图判断用 gemini-2.5-pro,只有真正需要推理的步骤才用 gpt-5.5

用一次请求批处理多条任务

按次计费的好处在这里体现得最明显:把一百条短任务拼成一次请求,让模型返回 JSON 数组,成本就按一次调用算。前提是提示词里把字段和数量约束写清楚,返回后逐条校验字段是否齐全,缺的那几条再补一次小批量重试。整体比一百次单独请求便宜得多,速度也更快。

小结

Go 这边接小鱼API 有两条路:标准库手写 HTTP,可控性更强,流式和重试都能精确控制;用 go-openai 库省事,改一行 BaseURL 就通。真正需要留神的是三个地方:流式的扫描器缓冲区、客户端整体超时对流式的误杀、以及把响应体丢掉导致报错信息不完整。这三处都在上面的代码里处理过了,照着改基本不会再踩。

相关阅读

🚀 想要立即使用?来小鱼API体验全系列AI模型

查看全部产品

支持Gemini / Claude / GPT / Grok / DeepSeek · 国内直连 · 支付宝/微信