Go AI Agent 实战 13|大模型流式输出 Streaming 的实现

本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。

当前进度:阶段二 Go 调用大模型 · 第 6 课(13)

上一篇:HTTP 状态码、API 错误处理与日志

使用聊天产品时,回答往往是一段段出现的。用户不用等到整篇内容生成完,就能开始阅读。这就是流式输出。

这一课使用 Go 标准库读取 SSE 响应,理解网络数据块、SSE 事件和模型文本分块之间的关系,并实现一个不依赖第三方 SDK 的流式客户端。

一、本课学习目标

学完本课,你应该能够:

  1. 区分普通响应与流式响应。
  2. 理解 SSE 事件的基本格式。
  3. 用 bufio.Scanner 按事件边界读取流。
  4. 从 delta.content 提取增量文本。
  5. 识别 [DONE]、心跳和异常断流。
  6. 理解流式输出在生产环境中的资源与错误处理问题。

二、流式输出解决了什么

普通模式通常要等模型生成完整回答,再一次性返回:

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 把上游取消信号传递到模型请求。

七、本课总结

  1. 流式输出主要改善首字延迟,不会自动减少总 Token 或总生成时间。
  2. SSE 由事件组成,网络读取边界不等于事件边界。
  3. delta.content 是增量文本,不能假设一个事件对应一个 Token。
  4. 空行结束事件,[DONE] 表示协议结束。
  5. 没有收到正常结束标记时应标记为未完成。
  6. 真实服务还要考虑取消、资源限制、日志脱敏和异常断流。

八、课后练习

练习 1:两个内容字段

普通响应和流式响应分别从哪里读取模型文本?

参考答案 :普通响应通常读取 choices[0].message.content;流式响应读取 choices[].delta.content,并把多个增量按顺序拼接。

练习 2:为什么不能逐行解析 JSON

为什么不能每读到一行 data: 就立即执行 json.Unmarshal?

参考答案 :SSE 事件可能包含多行 data:,必须在空行处结束事件后再处理。网络读取和行读取都可能切在事件中间,直接解析半条数据会导致失败。

练习 3:异常断流

已经打印部分回答,但连接结束且没有收到 [DONE],应该怎样标记?

参考思路:标记为中断或未完成,不能当成完整成功。可以保留已收到的内容供前端展示,但上层应知道结果可能不完整,并根据业务决定是否重试;重试可能产生额外费用。


下一篇:Go AI Agent 实战 14|多轮对话与上下文历史管理

专栏:Go 开发者学 AI Agent:从大模型到工程落地

相关推荐
IT研究室1 小时前
最新计算机毕业设计选题推荐-基于深度学习的面部关键特征识别与检测-运行部署-文档指导-深度学习-YOLO
人工智能·深度学习·yolo·课程设计
初阳7851 小时前
【基于Qt5仿音乐播放器(三)】——音乐管理
1024程序员节
硅谷秋水1 小时前
AdaRSI 论文分析
人工智能·机器学习·语言模型·机器人
CRMEB系统商城1 小时前
Redis + 消息队列 + 连接池:CRMEB PRO 性能优化的三驾马车
大数据·数据库·人工智能·小程序·php·1024程序员节
怪我冷i1 小时前
Rust 语言测试的组织结构
1024程序员节
星恒随风1 小时前
Linux网络编程基础(二):彻底理解局域网通信、MAC与IP地址、数据封装、端口号、网络字节序及Socket接口
linux·网络·笔记·学习·tcp/ip·1024程序员节
wanglong37131 小时前
Keil编译后自动重命名HEX文件
单片机·1024程序员节
Dawson Zhu1 小时前
【CUDA编程入门③】经典算子实战:Reduce归约——从朴素实现到高性能优化的演进之路
人工智能·语言模型·架构·aigc·agi
微三云 - 廖会灵 (私域系统开发)1 小时前
从业务模式到系统架构:拆解社区商业平台「物业2.0」的三个技术改造点
1024程序员节