面试官:AI 聊天的逐字回复,到底是怎么实现的?

本文从 AI 流式对话的常见场景切入,系统讲解 SSE(Server-Sent Events)的核心概念、设计背景、协议规范与前后端实现,并附带可直接运行的最小 Demo,帮你从原理到实战彻底掌握 HTTP 流式推送。

一、SSE 是什么

用过豆包、ChatGPT 这类 AI 产品的人,对逐字输出的「打字机效果」一定不陌生。不少小伙伴可能会以为这是前端做的模拟打字动画,或是通过 WebSocket 实现的实时推送。

实际上,这类流式输出的核心技术是 SSE(Server-Sent Events,服务端推送事件)。目前大模型流式接口、Agent 任务进度推送等场景普遍采用这项技术。

理解 SSE 最直观的方式,不是先背协议条文,而是打开浏览器抓包,亲眼观察它的运行过程。

以豆包为例,随便发起一个提问,等回复开始逐字输出时,按下 F12 打开开发者工具,切换到网络面板,找到 /chat/completion 请求,点进去再切到 EventStream 标签页。

这时就能看到清晰的事件流界面。左侧是页面上正在逐字渲染的回答内容,右侧则是浏览器自动解析后呈现的结构化 SSE 事件列表。

EventStream 列表里每新增一条记录,页面上就会每多渲染一些文字。这就是 SSE 的核心工作模式:服务端生成一点就推送一点,前端收到一点就渲染一点

把 SSE 和普通 HTTP 请求放在一起对比,两者的表现差异十分明显:

现象 普通 HTTP SSE
请求状态 很快变成 200 OK,Timeline 里一条短竖线 长时间停在 Pending,Timeline 拉得很长
Response 面板 响应一次性完整显示 EventStream 里事件持续追加
Content-Type application/json text/event-stream
连接生命周期 响应返回即结束 连接保持打开,直到回答推完

简单来说,二者的交互模式有本质区别:

  • 普通 HTTP 是「一问一答,响应结束即断开」。客户端发起一次请求,服务端返回完整响应后连接随即关闭,页面拿到全部数据再统一渲染。

  • SSE 是「一次请求,连接保持,分段推送」。客户端仅发起一次请求,连接持续保持,服务端生成一段数据就推送一段,全部内容发送完成后才关闭连接。

Chrome 开发者工具的 EventStream 标签会自动解析原始流式响应,将其呈现为逐条排列的事件列表,正是这种推送模式的直观体现。

SSE 是一种基于 HTTP 的服务端到客户端单向推送协议。 连接建立后,服务端按照标准文本格式持续推送数据,浏览器可逐条接收事件并处理。

它的核心特性可以归纳为三点:

  • 基于 HTTP 协议:复用标准 HTTP 长连接,无需额外协议栈,对防火墙、代理网关天然兼容。

  • 单向数据推送:数据仅从服务端流向客户端,通信方向固定,实现逻辑简单。

  • 事件流形式传输:数据以独立事件为单位分段传输,消息边界清晰,解析处理方便。


二、为什么需要 SSE

前面我们通过浏览器抓包,直观看到了 SSE 的流式推送效果,也对比了它和普通 HTTP 请求的表现差异。此时你可能会产生一个疑问:HTTP 已经是通用的网络协议,为什么还需要专门的 SSE 技术来做推送?

答案藏在 HTTP 协议的原生设计局限里。

HTTP 协议从诞生起就遵循严格的请求 - 响应模型:所有交互必须由客户端主动发起请求,服务端被动处理后返回结果,响应交付后本次交互即宣告结束。

在未收到新请求的前提下,服务端没有任何机制主动向客户端推送数据。

即便 HTTP/1.1 引入了 Keep-Alive 长连接机制,也只是让多个 HTTP 请求可以复用同一条 TCP 连接,减少建连开销,并没有改变「客户端主动请求、服务端被动响应」的核心逻辑。

这套模型可以满足页面加载、数据查询等绝大多数常规场景,但遇到「服务端数据持续产生、需要实时同步给客户端」的需求时,就显得力不从心。

比如大模型逐 token 生成回答、后台任务进度更新、实时日志输出这类场景,服务端不可能等全部内容生成完再一次性返回,用户也无法接受长时间的空白等待。

为了突破这个局限,业界在实践中有三类主流技术路线:用轮询在 HTTP 上模拟推送换 WebSocket 做全双工通信基于 HTTP 标准实现单向推送能力。SSE 正是第三条路线的官方标准实现。

1. 轮询

做过实时需求的同学,第一个想到的方案大概率是轮询。既然服务端不会主动发,那就让前端隔几秒就查一次有没有新数据。

这种方案完全基于普通 HTTP 实现,不需要任何额外协议支持,开发成本极低。但它的缺陷也同样十分突出:

  • 无效开销大:网络面板里刷刷刷一堆请求,大部分都是空结果,带宽和服务端资源都浪费在无效查询上;

  • 延迟和成本天然矛盾:想更新快就得缩短轮询间隔,间隔一短请求量就上去了;想省资源就得拉长间隔,实时性又下来了,两头没法兼顾;

轮询本质是「用客户端的勤快弥补 HTTP 不能主动推送的短板」,属于妥协方案,仅适合对实时性要求不高的低频场景。

2. WebSocket

想做真正的主动推送,另一个大家耳熟能详的方案是 WebSocket。

它先通过一次 HTTP 请求完成握手升级,服务端返回 101 Switching Protocols 之后,就切换成了 WebSocket 协议,变成一条双向通信通道。连接不断开的话,客户端和服务端想什么时候发消息就什么时候发,不受请求 - 响应的限制。

这种双向对等的通信能力,非常适合即时通讯、多人协同编辑、实时对战游戏这类双方都需要高频收发消息的场景。但对应的落地成本也显著更高:

  • 协议栈独立:WebSocket 是独立于 HTTP 的完整协议体系,网关、负载均衡、防火墙、CDN 都需要单独做适配。

  • 运维复杂:鉴权设计、运维监控、问题排查的复杂度也远高于普通 HTTP 服务。

回到 AI 对话场景,交互模式其实是典型的「单请求、长响应」:用户只需要发送一次 Prompt,后续全程都是服务端单向输出生成结果,客户端几乎没有上行数据。

这种单向流式场景,使用 WebSocket 相当于用双向通信的重型工具去解决单向推送的轻型需求,协议能力冗余,运维成本偏高,投入产出比并不理想。

3. SSE

SSE 填补了轮询与 WebSocket 之间的空白:它不更换底层协议,完全在 HTTP 原生框架之内,实现了标准化的服务端单向推送能力。

客户端只需发起一次普通的 HTTP 请求,连接建立后就持续保持打开状态,服务端生成一段数据就推送一段,直到全部内容发送完成,再正常关闭连接。

比起轮询,它不用反复建连、反复发请求,没有那么多无效开销;比起 WebSocket,它不用引入一套新的协议体系,HTTP 现有的鉴权、网关、安全策略都能直接复用,落地和维护成本低很多。

这种轻量的单向流式特性,和大模型的生成模式天然高度匹配。大模型逐 token 生成内容的过程,本身就是持续的流式输出;用户提交一次请求后,服务端全程单向返回数据,这也是 OpenAI、豆包等主流大模型厂商,都选择 SSE 作为流式接口底层方案的核心原因。

4. 方案对比

我们可以用一张表快速对比几种方案的核心差异:

技术方案 协议基础 通信模型 网络特征 典型适用场景 落地与运维成本
普通 HTTP HTTP 请求 - 响应,无主动推送 请求快速完成,响应一次性返回 静态资源加载、常规数据查询 最低
轮询 HTTP 客户端定时拉取(伪推送) 大量重复短请求,多数为空响应 低频非实时状态刷新
WebSocket 独立 WebSocket 协议 全双工双向通信 ws/wss 协议,双向数据帧传输 即时通讯、协同编辑、实时游戏
SSE HTTP 服务端单向真实推送 单条 HTTP 长连接,响应体持续增长 AI 流式输出、任务进度推送

基于以上对比,可以做出以下选型逻辑:

  • 常规的数据查询、状态刷新,对实时性没那么高要求的,普通 HTTP 就够了,别为了「看起来高级」平白增加技术复杂度;

  • 核心需求就是服务端单向持续输出,比如流式对话、日志实时查看、进度条推送,优先选 SSE,实现简单、生态成熟,踩坑也少;

  • 只有当业务真的需要两边高频互发消息的时候,再考虑上 WebSocket。

放到 AI 应用这个场景里结论就更直接了:绝大多数流式需求都是服务端单向吐数据,SSE 基本是当前最均衡、最划算的选择。


三、SSE 协议

搞懂了 SSE 的定位和适用场景,再看它的协议设计会发现格外简洁。它没有复杂的二进制封包,全程基于 UTF-8 纯文本,仅靠换行和空行做消息边界,理解和实现的门槛都很低。

第一节我们在 EventStream 面板里看到的那些结构化数据,拆解到底就是一行行约定好格式的响应文本。

1. 报文格式

SSE 本质是一种特殊格式的 HTTP 响应。服务端首先要设置正确的响应头,浏览器才会将其识别为事件流:

  • Content-Type: text/event-stream:浏览器识别 SSE 响应的核心标识,是必填字段。

  • Cache-Control: no-cache:禁止代理、CDN 等中间节点缓存响应,避免流式内容被缓冲导致无法实时推送。

  • Connection: keep-alive:维持 HTTP 长连接,保证数据流可以持续传输。

SSE 的报文格式非常简单,核心只有两条:

  1. 每行一条字段,格式为 字段名: 字段值,以换行符\n结尾;

  2. 连续两个换行\n\n(即一个空行),代表一条完整的消息结束,浏览器会立即触发对应的事件回调。

标准一共定义了 5 种字段,覆盖了正文、事件类型、断线续传、重连配置、心跳保活的需求:

字段 说明 示例
data: 消息正文 。单条消息可包含多行 data:,最终会拼接成完整内容 data: Hello
event: 自定义事件类型 。前端可按事件名分别监听;不写则默认触发 message 事件 event: progress
id: 消息唯一 ID 。断线重连时浏览器会通过 Last-Event-ID 请求头带给服务端 id: 4102
retry: 重连间隔。告知浏览器在连接断开后,等待多少毫秒再进行重连尝试。 retry: 3000
: 注释/心跳。以冒号开头的行会被浏览器忽略。常用于发送心跳包,防止网关超时断连。 : ping

报文示例 1:一条标准的消息

bash 复制代码
event: message
id: 1
data: {"role": "assistant", "content": "你好"}

浏览器解析后,会触发一个名为 message的事件,event.data的值为 {"role": "assistant", "content": "你好"}

报文示例 2:多行 Data 拼接

如果内容较长,可以拆分为多行 data: 提升可读性。浏览器会自动将所有行拼接,行与行之间自动插入一个换行符:

bash 复制代码
data: {
data:   "role": "assistant",
data:   "content": "Hello World"
data: }

拼接后的完整内容为:{ "role": "assistant", "content": "Hello World" },开发者只需进行一次 JSON.parse即可。

2. 客户端对接方式

浏览器消费 SSE 流主要有两种方案:原生 EventSource API,以及手动解析流式响应。

(1)原生 EventSource API

浏览器提供了原生的 EventSource 接口,内置了报文解析、自动重连、事件分发能力,无需手动逐行拆包,基础场景使用成本极低。

js 复制代码
// 建立 SSE 连接
const es = new EventSource("/stream");

// 连接建立成功
es.onopen = () => {
  console.log("SSE 连接已建立");
};

// 监听默认 message 事件
es.onmessage = (event) => {
  console.log("收到消息:", event.data);
};

// 监听自定义事件,对应服务端的 event: progress
es.addEventListener("progress", (event) => {
  console.log("任务进度:", event.data);
});

// 连接异常时触发
es.onerror = () => {
  console.log("连接异常,浏览器会自动尝试重连");
};

但原生 API 存在两个明确的限制,也是绝大多数生产环境不直接使用它的核心原因:

  1. 只支持 GET 请求,无法使用 POST 发送请求体,复杂参数只能拼接在 URL 中,对于需要传递大量参数的大模型接口并不友好。

  2. 无法自定义请求头,不支持添加 Authorization 之类的自定义请求头,难以实现标准的 Token 鉴权。

(2)手动解析

为了突破原生 API 的限制,现代前端项目(包括豆包、OpenAI 的官方 Web SDK)普遍采用 fetch 配合 ReadableStream 手动解析 SSE 格式。

核心思路是:通过 fetch 获取响应的可读流,分块读取二进制数据并解码为文本,按换行符拆分逐行识别字段,遇到空行时将累积的字段组装为完整事件抛出。

以下是一个简化版的实现示例,仅处理核心的 data 字段:

js 复制代码
async function connectSSE() {
    const response = await fetch('/api/chat', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Authorization': 'Bearer your_token' // 支持自定义Header
        },
        body: JSON.stringify({ prompt: 'Hello' }) // 支持携带请求体
    });

    const reader = response.body.getReader();
    const decoder = new TextDecoder('utf-8');
    let buffer = '';

    while (true) {
        const { done, value } = await reader.read();
        if (done) break;
		// 将新的二进制块解码为文本,追加到缓冲区
        buffer += decoder.decode(value, { stream: true });
        // 按换行拆分所有完整行
        const lines = buffer.split('\n');
        // 最后一行可能不完整,放回缓冲区等待后续内容
        buffer = lines.pop();

        lines.forEach(line => {
            if (line.startsWith('data: ')) {
                const data = line.substring(6);
                if (data === '[DONE]') {
                    console.log('Stream finished');
                    return;
                }
                // 解析并处理业务数据
                const payload = JSON.parse(data);
                console.log('收到数据:', payload);
            }
        });
    }
}

这种方式完全不受原生 API 的限制,支持任意请求方法、自定义请求头、携带请求体,适配性极强。对应的代价是,自动重连、异常重试、断点续传、完整事件解析等能力都需要业务层自行实现。

目前绝大多数大模型前端 SDK,都是基于手动解析的方案封装的。

3. 连接生命周期

一条 SSE 连接从建立到结束,分为正常关闭和异常断线两种情况,处理逻辑各不相同。

(1)正常关闭

SSE 连接的正常结束,通常由服务端或客户端主动触发:

  • 服务端主动结束:服务端把所有数据推送完毕后,结束响应流,连接自然断开。比如大模型回答生成完成,后端停止写入响应体,执行完 handler 后连接就会关闭。

  • 客户端主动关闭 :前端收到业务约定的结束标记,或者用户主动点击 "停止",调用 es.close() 即可断开连接。

这里补充一个行业通用约定:很多接口会在最后推送一条 data: [DONE] 来标识流结束。这并不是 SSE 标准的一部分,纯粹是业务层的约定,OpenAI 等主流厂商也都采用这种方式。

从协议标准来看,服务端直接结束响应就代表流终止;增加结束标记,是为了让前端明确区分 "正常结束" 和 "异常断线",避免将正常收尾误判为连接错误。

(2)断线与自动重连

网络波动、网关超时、服务端异常都可能导致连接意外断开,而原生重连机制也是 SSE 相比普通轮询的核心优势之一。

如果使用原生 EventSource,浏览器内置了自动重连逻辑:断线后会等待一段时间,自动发起新的请求重建连接,无需业务代码手动处理。

重连有两个配套的标准机制:

  1. 可配置的重连间隔 :默认重连间隔由浏览器决定(不同浏览器略有差异,通常在 3 秒左右),服务端可以通过 retry: 字段主动指定重连的等待时长。

  2. 断点续传能力 :如果每条消息都携带了 id: 字段,重连时浏览器会自动在请求头中加入 Last-Event-ID,将最后一次成功收到的消息 ID 回传给服务端。服务端可以据此从断点之后继续推送,实现不丢消息的续传。

当然,断点续传并非所有场景都需要。比如大模型对话场景,断线后上下文很难完美续接,通常直接让用户重新生成即可;但在日志实时推送、任务进度同步这类场景,续传能明显提升用户体验。

需要注意的是,自动重连是原生 EventSource 的内置能力。如果采用手动解析的方案,重连逻辑、断点续传都需要业务层自行实现。


四、实战:Go 后端 + JS 前端

讲完协议规范,我们用一个最小可运行的 Demo 把 SSE 跑起来,直观验证前面讲的流式特性。

整个 Demo 基于 Go 标准库实现,不需要引入任何第三方依赖,前端用原生 API 消费流,复制就能直接运行。

1. 项目结构

整个项目结构非常简单,一个 Go 后端文件加两个前端静态文件:

bash 复制代码
sse-demo/
├── go.mod           # Go 模块声明
├── main.go          # 服务端:提供 SSE 流式接口 + 静态页面服务
└── static/
    ├── index.html   # 前端页面
    └── app.js       # 消费 SSE 流的逻辑

2. 服务端实现

SSE 服务端的核心逻辑只有三步:设置标准响应头 → 逐段写入 data: 事件 → 每次写入后强制刷新缓冲区

golang 复制代码
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"time"
)

func streamHandler(w http.ResponseWriter, r *http.Request) {
	// 断言支持 Flush 接口,用于强制刷新缓冲区
	flusher, ok := w.(http.Flusher)
	if !ok {
		http.Error(w, "streaming is not supported", http.StatusInternalServerError)
		return
	}

	// 设置 SSE 标准响应头
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("Connection", "keep-alive")

	// 模拟大模型逐 token 生成,每 100ms 推送一个字符
	text := "Hello SSE! 这是最简单的流式推送演示。"
	for i, ch := range text {
		// 将业务数据序列化为 JSON
		payload, _ := json.Marshal(map[string]any{
			"index": i,
			"token": string(ch),
		})
		// 写入一条完整事件:data: 内容 + 两个换行
		fmt.Fprintf(w, "data: %s\n\n", payload)
		// 强制刷新缓冲区,立即将数据发送给客户端
		flusher.Flush()
		// 模拟生成延迟,方便观察流式效果
		time.Sleep(100 * time.Millisecond)
	}

	// 推送结束标记,业务约定流结束
	fmt.Fprintf(w, "data: [DONE]\n\n")
	flusher.Flush()
}

func main() {
	// 注册 SSE 流式接口
	http.HandleFunc("/stream", streamHandler)
	// 托管静态页面,直接通过根路径访问前端
	http.Handle("/", http.FileServer(http.Dir("./static")))

	log.Println("SSE demo: http://localhost:8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

几个细节说明:

  1. 必须手动 Flush

    Go 的 http.ResponseWriter 默认带有输出缓冲区,数据攒满一定量才会真正发送给客户端。SSE 要求实时推送,因此每次写入事件后都要调用 Flush() 强制刷新,否则数据会全部攒到最后一次性返回,看不到流式效果。这是新手最容易踩的坑。

  2. 响应头缺一不可

  • Content-Type: text/event-stream 是浏览器识别 SSE 响应的核心依据;

  • Cache-Control: no-cache 防止代理、CDN 缓冲响应;

  • Connection: keep-alive 维持长连接。

  1. DONE 是业务约定

    末尾推送的 data: [DONE] 不属于 SSE 标准,只是行业通用的结束标记,用来让前端明确区分 "正常结束" 和 "异常断线"。

3. 前端实现

最简 Demo 我们用浏览器原生 EventSource 实现,无需额外依赖,对应第三部分讲的原生 API 用法。

index.html 页面结构:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>SSE 演示</title>
</head>
<body>
    <h3>SSE 流式输出效果:</h3>
    <div id="output" style="font-size: 16px; line-height: 1.6;"></div>
    <script src="./app.js"></script>
</body>
</html>

app.js 流消费逻辑:

js 复制代码
const output = document.getElementById('output');
// 建立 SSE 连接
const es = new EventSource("/stream");

// 监听消息事件
es.onmessage = (event) => {
    // 收到结束标记,关闭连接
    if (event.data === "[DONE]") {
        es.close();
        return;
    }
    // 解析 JSON 并追加文字
    const payload = JSON.parse(event.data);
    output.textContent += payload.token;
};

// 连接异常处理
es.onerror = () => {
    console.log("连接异常");
    es.close();
};

4. 运行与验证

在项目根目录执行 go run main.go 启动服务。

浏览器打开*http://localhost:8080*即可看到效果。

跑起来之后,建议对照前面的理论做两件事,加深理解:

  1. 看页面表现:观察文字逐字输出的打字机效果,和普通页面一次性渲染形成直观对比。

  2. 看网络面板 :打开开发者工具「网络」面板,找到 /stream 请求:

  • 观察状态:请求会一直处于 Pending 状态,直到所有内容推送完成才结束;

  • 切换到「EventStream」标签页:可以看到逐条推送的事件,和第一部分豆包抓包的表现一致;

  • 对比 index.htmlapp.js 等普通请求:快速完成、响应一次性返回,直观体会两种交互模式的差异。


五、总结

说到底,SSE 的本质可以浓缩为三句话:

  1. 它是构建在 HTTP 长连接之上的单向文本流,无需额外协议支持;

  2. 报报文格式极其朴素,仅由 data: 行与空行构成,打开浏览器 Network 面板就能肉眼逐条调试;

  3. 真正决定最终体验的从来不是协议本身,而是缓冲、Flush、超时这些容易被忽略的工程细节 ------ 能不能流畅呈现「打字机效果」,往往就卡在这些地方。

如果想把概念彻底吃透,不妨把文中的 Demo 在本地跑通,对着浏览器抓包观察事件流的推送过程。动手调试一次,远比只读十篇概念文章印象深刻。

如果觉得这篇文章对你有帮助,欢迎点赞、推荐、转发。我是一枫,我们下篇文章见。

关注【一枫说码】,获取更多实战导向的 AI 技术文章。