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:从大模型到工程落地

相关推荐
七夜zippoe6 分钟前
Token 账单翻了 10 倍:Agent 成本失控的排查过程与优化实录
ai·agent·token·成本失控
萝卜小白22 分钟前
深度学习基础面经
人工智能·深度学习
如夏倾城35 分钟前
百度文心(容易被忽略的好用AI)使用方式
人工智能·python·百度·语言模型·dubbo
泯泷43 分钟前
为什么 RL 比 SFT 更会"举一反三"?
人工智能·llm·agent
上海潭龙东海科技有限公司1 小时前
客运站管控之动态孪生技术方案(新增一屏管控模块
数据仓库·人工智能·算法·重构·动态规划
民乐团扒谱机1 小时前
Agent-Skills 新手入门与实战部署指南
人工智能·ai·agent·skill
打工仔折腾 AI2 小时前
Changedetection.io 网页变动监控:Docker 部署、通知配置与公网暴露实践
运维·人工智能·后端·python·docker·容器·性能优化
深圳雨林凯AI3 小时前
商品详情长图的自动生成:版块模板、要素抽取与版式约束的实现思路
人工智能·ai
lisw053 小时前
迁移学习(Transfer Learning):概念、历史、内容、挑战与展望
人工智能·机器学习·迁移学习
炫酷的大高个3 小时前
98% 连跑 20 次只剩 67%,我算了大模型重试账
人工智能·架构·开源