本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。
当前进度:阶段二 Go 调用大模型 · 第 6 课(13)
使用聊天产品时,回答往往是一段段出现的。用户不用等到整篇内容生成完,就能开始阅读。这就是流式输出。
这一课使用 Go 标准库读取 SSE 响应,理解网络数据块、SSE 事件和模型文本分块之间的关系,并实现一个不依赖第三方 SDK 的流式客户端。
一、本课学习目标
学完本课,你应该能够:
- 区分普通响应与流式响应。
- 理解 SSE 事件的基本格式。
- 用
bufio.Scanner按事件边界读取流。 - 从
delta.content提取增量文本。 - 识别
[DONE]、心跳和异常断流。 - 理解流式输出在生产环境中的资源与错误处理问题。
二、流式输出解决了什么
普通模式通常要等模型生成完整回答,再一次性返回:
text
发送请求 → 等待完整生成 → 返回整段文本
流式模式会持续返回生成中的增量:
text
发送请求 → 收到片段 → 收到片段 → 收到片段 → 流结束
它改善的是首字延迟和用户等待感受,不一定减少总生成时间、总 Token 数或费用。模型仍然需要完成推理和生成。
常见应用场景包括:
- 聊天窗口实时显示回答。
- 长文、代码和摘要逐步展示。
- Agent 执行过程的状态反馈。
- 用户可以在生成过程中决定是否继续等待。
三、SSE 是什么
开启流式请求后,模型服务通常使用 SSE 风格的事件流。示意格式如下:
text
data: {"choices":[{"delta":{"content":"你好"},"index":0}]}
data: {"choices":[{"delta":{"content":","},"index":0}]}
data: {"choices":[{"delta":{"content":"世界!"},"index":0}]}
data: [DONE]
这只是格式示意,不是实际响应。
需要区分三个层次:
- TCP/HTTP 数据块:网络层读到的任意字节片段。
- SSE 事件:由空行分隔的一条完整事件。
- 模型文本分块:事件 JSON 中的
delta.content。
一次网络读取可能只得到半个事件,也可能一次得到多个事件。因此不能假设"每次 Read 就是一条完整 JSON"。
SSE 客户端至少要处理:
data:数据行。- 以空行结束一个事件。
- 以
:开头的心跳或注释。 [DONE]结束标记。- EOF 时仍未收到完整结束信号的情况。
四、完整流式客户端
下面的程序发送 stream: true 请求,并按 SSE 事件边界解析增量文本:
go
package main
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
apiURL = "https://api.deepseek.com/chat/completions"
defaultModel = "deepseek-flash"
)
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type ChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Stream bool `json:"stream"`
}
type StreamChunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
}
func readSSE(output io.Writer, body io.Reader) error {
scanner := bufio.NewScanner(body)
scanner.Buffer(make([]byte, 4096), 1024*1024)
var dataLines []string
done := false
processEvent := func() error {
if len(dataLines) == 0 {
return nil
}
data := strings.Join(dataLines, "\n")
dataLines = nil
if data == "[DONE]" {
done = true
return nil
}
var chunk StreamChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return fmt.Errorf("解析 SSE JSON 失败: %w", err)
}
for _, choice := range chunk.Choices {
if choice.Delta.Content != "" {
fmt.Fprint(output, choice.Delta.Content)
}
}
return nil
}
for scanner.Scan() {
line := scanner.Text()
if line == "" {
if err := processEvent(); err != nil {
return err
}
if done {
return nil
}
continue
}
if strings.HasPrefix(line, ":") {
continue
}
if strings.HasPrefix(line, "data:") {
value := strings.TrimPrefix(line, "data:")
value = strings.TrimPrefix(value, " ")
dataLines = append(dataLines, value)
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("读取响应流失败: %w", err)
}
if err := processEvent(); err != nil {
return err
}
if !done {
return errors.New("流提前结束,未收到 [DONE]")
}
return nil
}
func streamModel(apiKey, model, prompt string, output io.Writer) error {
requestData := ChatRequest{
Model: model,
Messages: []ChatMessage{
{
Role: "user",
Content: prompt,
},
},
Stream: true,
}
body, err := json.Marshal(requestData)
if err != nil {
return fmt.Errorf("序列化请求失败: %w", err)
}
req, err := http.NewRequest(
http.MethodPost,
apiURL,
bytes.NewReader(body),
)
if err != nil {
return fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("发送请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
errorBody, readErr := io.ReadAll(
io.LimitReader(resp.Body, 1<<20),
)
if readErr != nil {
return fmt.Errorf(
"HTTP %d,读取错误响应失败: %w",
resp.StatusCode,
readErr,
)
}
return fmt.Errorf(
"HTTP 错误:status=%d,响应=%s",
resp.StatusCode,
strings.TrimSpace(string(errorBody)),
)
}
return readSSE(output, resp.Body)
}
func run() error {
apiKey := strings.TrimSpace(os.Getenv("DEEPSEEK_API_KEY"))
if apiKey == "" {
return errors.New("DEEPSEEK_API_KEY 未设置")
}
model := strings.TrimSpace(os.Getenv("DEEPSEEK_MODEL"))
if model == "" {
model = defaultModel
}
fmt.Println("模型回答:")
if err := streamModel(
apiKey,
model,
"请用 Go 代码举例解释 goroutine,并分点说明。",
os.Stdout,
); err != nil {
return err
}
fmt.Println()
return nil
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "流式调用失败:", err)
os.Exit(1)
}
}
运行:
powershell
$env:DEEPSEEK_API_KEY = "你的真实API_KEY"
go run .
回答会随着服务端事件逐步打印。分块大小、速度和是否出现心跳事件由网络与服务端调度决定,不能作为判断模型质量的依据。
五、逐段理解实现
1. 为什么要发送 stream: true
json
{
"stream": true
}
这是 API 请求参数,用来告诉服务端返回事件流。
Accept: text/event-stream 是客户端对响应类型的期望,不能代替请求体中的 stream。
2. 为什么不能直接 JSON Decode 一次
流式响应不是一个完整 JSON 文档,而是多个事件。每个事件内部才有可解析的 JSON。
text
SSE 流
├─ data: JSON
├─ data: JSON
└─ data: [DONE]
因此需要先识别协议边界,再解析事件数据。
3. 为什么空行很重要
SSE 使用空行分隔事件。一个事件可能包含多行 data:,所以代码先把这些行收集到 dataLines,遇到空行时再统一处理。
把每次 scanner.Scan() 都当成一条 JSON,是典型的流式解析错误。
4. 为什么使用 delta.content
普通响应使用 choices[0].message.content,流式响应使用 choices[].delta.content 表示本次新增内容。
某些事件只包含角色、结束原因或其他元数据,不一定有文本,因此不能假设每个事件都输出字符。
5. 为什么 [DONE] 必须先判断
[DONE] 不是 JSON。如果直接交给 json.Unmarshal,会得到解析错误。
EOF 前没有收到 [DONE] 时,代码返回错误,而不是把部分回答当成完整成功结果。
六、生产环境还需要处理什么
| 问题 | 处理方向 |
|---|---|
| 用户关闭页面 | 将请求取消传播到模型调用 |
| 单行事件过大 | 设置 Scanner 上限并返回明确错误 |
| 流中途断开 | 标记为中断,保留部分内容供上层决策 |
| 服务端错误 | 读取有限错误体,记录状态码 |
| 隐私数据 | 日志不直接记录完整 Prompt 和回答 |
| Tool Calling | 先收集完整工具参数,校验后再执行 |
| 背压 | 限制生产速度或客户端缓冲,避免内存持续增长 |
示例使用客户端总超时,但它不会自动理解"用户已经关闭聊天页"。下一篇多轮对话之后,会使用 context.Context 把上游取消信号传递到模型请求。
七、本课总结
- 流式输出主要改善首字延迟,不会自动减少总 Token 或总生成时间。
- SSE 由事件组成,网络读取边界不等于事件边界。
delta.content是增量文本,不能假设一个事件对应一个 Token。- 空行结束事件,
[DONE]表示协议结束。 - 没有收到正常结束标记时应标记为未完成。
- 真实服务还要考虑取消、资源限制、日志脱敏和异常断流。
八、课后练习
练习 1:两个内容字段
普通响应和流式响应分别从哪里读取模型文本?
参考答案 :普通响应通常读取
choices[0].message.content;流式响应读取choices[].delta.content,并把多个增量按顺序拼接。
练习 2:为什么不能逐行解析 JSON
为什么不能每读到一行 data: 就立即执行 json.Unmarshal?
参考答案 :SSE 事件可能包含多行
data:,必须在空行处结束事件后再处理。网络读取和行读取都可能切在事件中间,直接解析半条数据会导致失败。
练习 3:异常断流
已经打印部分回答,但连接结束且没有收到 [DONE],应该怎样标记?
参考思路:标记为中断或未完成,不能当成完整成功。可以保留已收到的内容供前端展示,但上层应知道结果可能不完整,并根据业务决定是否重试;重试可能产生额外费用。
下一篇:Go AI Agent 实战 14|多轮对话与上下文历史管理
专栏:Go 开发者学 AI Agent:从大模型到工程落地