大模型时代的宠儿
大模型时代的需求
如果你最近关注 AI,特别是像 ChatGPT 这样的大语言模型(LLM),你会发现它们在生成回答时,通常不是一下子给出完整答案,而是一个字一个字或一个词一个词地"吐"出来。
这种"流式"响应(Streaming Response)的用户体验非常好,用户可以更快地看到部分结果,而不是干等很长时间。
比如下图就是 DeepSeek

那么,这种流式响应是怎么实现的呢?很多场景下,用的就是 SSE!
在早期的 web 开发中,实时数据的传输通常需要通过频繁的轮询(polling)来实现。轮询是指客户端定期向服务器发送请求,询问是否有新的数据。这种方式虽然简单,但却带来了很多问题:
- 频繁的请求增加了网络负担;
- 延迟较高,因为每次请求都要经过一定的时间间隔;
- 无法实现真正的实时性,客户端必须等待下次轮询。
为了改善这些问题,WebSocket 和 SSE(Server-Sent Events)应运而生。WebSocket 主要是双向通信协议,而 SSE 则是单向通信协议,专注于从服务器向客户端推送实时数据。
SSE 协议的设计思想非常简单,它利用了 HTTP 协议的持久连接特性,使得服务器可以持续不断地向客户端发送数据。最初,SSE 主要用于浏览器中的实时通知功能,例如社交网络的实时消息推送、股票行情更新等。随着前端技术的发展,SSE 逐渐成为构建实时应用(如聊天室、AI实时回答)的重要技术之一。
为什么 LLM 场景偏爱 SSE?
- 单向通信契合: LLM 的响应本质上是服务器(模型)向客户端(用户界面)单向输出信息。用户发送一个请求,然后等待服务器持续不断地"流"回数据。这正好是 SSE 的强项。
- 轻量级: 相比 WebSocket,SSE 更简单、开销更小。对于只需要服务器向客户端推送数据的场景,SSE 足够用,也更容易实现和维护。
- 基于 HTTP: SSE 运行在标准的 HTTP 协议之上,这意味着它更容易通过现有的网络基础设施(如防火墙、代理),兼容性更好。
- 断线重连: SSE 标准内置了断线重连机制。如果网络不稳定导致连接中断,客户端可以自动尝试重新连接,并可以指定上次接收到的事件 ID,服务器可以根据这个 ID 继续发送后续数据,保证了数据传输的连续性。
SSE 与 WebSocket 的对比
SSE 和 WebSocket 都是为了实现实时通信而设计的协议,但它们各自有不同的特点和适用场景。下面我们用表格来对比两者的区别和优缺点:
特性 | SSE | WebSocket |
---|---|---|
协议类型 | 单向(从服务器到客户端) | 双向(客户端和服务器都可以发送消息) |
建立连接方式 | 基于 HTTP 协议(使用持久化连接) | 基于 TCP 协议(需要升级 HTTP 协议) |
数据传输 | 服务器主动向客户端推送数据 | 客户端和服务器都可以主动发送数据 |
连接保持 | HTTP 持久连接 | 长连接 |
兼容性 | 广泛支持,包括大部分现代浏览器 | 较少浏览器支持,尤其是某些旧版本不支持 |
消息格式 | 以事件流格式传输文本数据 | 二进制或文本数据(如 JSON、文本等) |
传输效率 | 较高,适合数据流式传输 | 较高,适合双向实时通信 |
使用场景 | 服务器推送通知、实时数据流等 | 聊天室、多人在线游戏、双向消息传输等 |
适用性 | 适用于只需单向推送的应用场景 | 适用于需要双向通信的场景 |
可靠性 | 高,因为它是基于 HTTP 协议的 | 高,但需要管理连接和心跳等问题 |
资源消耗对比
资源消耗类型 | SSE | WebSocket |
---|---|---|
连接建立 | 直接通过 HTTP 协议保持长连接,比较简单 | 需要通过 HTTP 协议进行握手并升级到 WebSocket,建立 TCP 长连接 |
服务器端资源消耗 | 服务器保持的是 HTTP 持久连接,虽然每个连接也需要消耗一定资源,但相较 WebSocket 消耗较少。 | 每个 WebSocket 连接都需要服务器保持一个持久连接,这会占用更多内存和文件描述符。特别是高并发情况下,服务器的负担较重。 |
客户端资源消耗 | 客户端仅需要接收数据流,资源消耗相对较低。每次只有数据流的接收,没有额外的双向交互,因此内存和带宽的使用较少。 | 客户端需要维护与服务器的双向连接,通常需要更多的内存和带宽。尤其是在多个 WebSocket 连接并发时,资源消耗较大。 |
带宽消耗 | SSE 通常用于单向数据推送,带宽消耗相对较低,尤其是在数据流较大时,SSE 更能避免不必要的带宽浪费。 | WebSocket 在双向通信的过程中,数据量较大时带宽消耗可能较高,尤其是消息频繁的情况下。 |
连接管理开销 | 相比 WebSocket,SSE 更加简单,不需要客户端和服务器进行双向的数据交换,也不需要复杂的心跳检测机制,管理开销较低。 | 需要管理连接的建立、心跳检测、断开处理等机制。对于高并发连接,可能需要更多的资源来管理和监控每个连接的状态。 |
内存占用 | 由于是单向数据流,服务器和客户端内存占用较小,尤其在不频繁更新数据时,内存占用较少。 | 每个连接都需要维护连接信息和数据缓冲区,尤其是在高并发时,内存占用会显著增加。 |
系统开销 | SSE 使用 HTTP 协议连接,系统开销相对较低,因为它只需要维护持久连接,不涉及复杂的连接管理和双向通信。 | WebSocket 长连接占用服务器的文件描述符、线程和 CPU 资源,尤其是多用户并发时,开销比较大。 |
可伸缩性 | SSE 由于连接简单且单向推送,伸缩性较好,服务器资源的压力相对较小。 | 在高并发的场景中,WebSocket 的伸缩性较差,需要更多的负载均衡和连接池管理。 |
资源清理 | SSE 也需要清理断开连接的客户端,但因为只有单向数据流和自动重连机制,处理起来较为简单。 | 需要定期处理 WebSocket 连接的清理,包括超时检测、连接断开后的资源回收等。 |
兼容性
最后让我们看看 SSE 的浏览器兼容情况
SSE 协议 Demo
上面我们说到 SSE 是一种基于 HTTP 的单向通信技术,客户端通过建立持久连接,允许服务器实时地向客户端推送文本数据流。下面就让我们来实现一个简单的 demo,展示如何在前端和后端实现基于 SSE 的实时数据传输。
bash
.
├── app.js
├── index.html
├── node_modules
│ └── express
└── package.json
后端实现(Node.js 示例)
javascript
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.sendFile(__dirname + '/index.html');
});
// 设置 SSE 路由
app.get('/events', (req, res) => {
// SSE 协议的核心头信息,指示服务器将发送 事件流 数据到客户端。
// 这个值告诉客户端,服务器返回的内容是一个基于事件的流(Event Stream),而不是普通的 HTML 或 JSON 数据。
res.setHeader('Content-Type', 'text/event-stream');
// 防止客户端缓存 SSE 流的响应,确保数据实时更新
res.setHeader('Cache-Control', 'no-cache');
// 保持 HTTP 连接持续活跃,允许服务器和客户端进行长时间的通信
res.setHeader('Connection', 'keep-alive');
// 模拟服务器推送数据
let count = 0;
setInterval(() => {
res.write(`data: 当前时间:${new Date().toISOString()} 计数:${count}\n\n`);
count++;
}, 1000); // 每秒发送一次数据
});
app.listen(3000, () => {
console.log('服务器正在监听 3000 端口...');
});
前端实现
html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>SSE Demo</title>
</head>
<body>
<h1>实时数据推送</h1>
<div id="output"></div>
<script>
// 创建 SSE 连接
const eventSource = new EventSource('/events');
// 监听服务器发送的消息
eventSource.onmessage = function(event) {
const output = document.getElementById('output');
output.innerHTML = event.data;
};
// 监听错误
eventSource.onerror = function() {
console.error('发生错误');
};
</script>
</body>
</html>


event-stream
在上面的代码中,我们看到有一行很重要的代码
js
res.setHeader('Content-Type', 'text/event-stream');
那么这个 event-stream又是啥呢?跟我们常见的 application/json 又有啥不一样?接下来我们详细解释下。
Event Stream 是 SSE 协议的核心传输格式,它定义了服务器如何向客户端发送事件数据。这种格式简单而高效,主要由以下几个关键部分组成:
基本格式
Event Stream 的数据格式遵循以下规则:
- 行为单位:每条消息由一行或多行文本组成
- 字段格式:每行以字段名开头,后跟冒号,然后是字段值
- 消息分隔 :消息之间用空行(两个连续的换行符
\n\n
)分隔
makefile
field: value\n
field: value\n
\n
标准字段
Event Stream 支持以下几种标准字段:
- data:事件数据的主体内容
- event:事件的类型名称(可选)
- id:事件的唯一标识符(可选,用于断线重连)
- retry:重连时间间隔(毫秒,可选)
一个完整的 event-stream 消息可能如下所示:
vbnet
event: message
id: 12345
data: {"content": "这是一条消息"}
retry: 10000
对于多行数据,每行都以 data:
开头:
kotlin
data: 第一行数据\n
data: 第二行数据\n
data: 第三行数据\n
\n
客户端接收到后,会将它们合并为一个事件,数据内容为:
第一行数据
第二行数据
第三行数据
我们也使用 event
字段可以区分不同类型的事件:
vbnet
event: update
data: {"type": "status", "value": "processing"}
event: complete
data: {"type": "status", "value": "done"}
前端可以针对不同事件类型注册不同的处理函数:
javascript
eventSource.addEventListener('update', function(e) {
console.log('收到更新事件:', JSON.parse(e.data));
});
eventSource.addEventListener('complete', function(e) {
console.log('收到完成事件:', JSON.parse(e.data));
});
断线重连机制
当连接断开时,EventSource 会自动尝试重新连接。服务器可以通过 id
和 retry
字段来控制重连行为:
yaml
id: 1001
data: 消息内容
retry: 5000
id
字段:浏览器会记住最后收到的事件 ID,重连时通过Last-Event-ID
请求头发送给服务器retry
字段:指定重连间隔时间(毫秒)
这种机制确保了即使在网络不稳定的情况下,客户端也能接收到完整的数据流,不会丢失消息。
综上所述,Event Stream 的核心点可以总结为:
- 数据分块:服务器将数据分成多个块(chunks)发送
- 流式传输 :客户端通过
EventSource
API 接收数据流 - 实时解析:浏览器会实时解析接收到的数据,并根据 event-stream 格式转换为事件对象
- 事件触发:当接收到完整消息时,触发相应的事件处理函数
SSE 协议的问题
使用 EventSource 需要注意以下问题:
- 结束标识:服务器端应发送特定的标识来表示数据流的结束,然后前端调用close关闭EventSource。如果不这么做的话,当服务端发送完数据关闭连接后,EventSource默认会自动重新连接。
- 只支持GET:url可以携带一些简单的查询参数,如果要传输复杂的请求体,可以考虑两次请求的方案。先通过普通的HTTP POST/PUT请求,将请求体传送到服务端。服务端将请求体缓存起来,并返回一个能唯一标识的票据,前端最后使用EventSource在url中带上票据,服务端根据票据从缓存里取出请求体。
- 不支持自定义Header:接口如果需要鉴权,无法在Header里定义Authorization请求头,那么建议使用Cookie来标识用户,EventSource请求会携带Cookie。
fetch-event-source
上面我们提到使用 EventSource 有一些局限性,比如只支持 GET 请求、无法自定义请求头等。但是我们回过头去看 DeepSeek 的请求,会发现它并不是 GET,而是 POST 请求,这是为啥呢?
这里给大家推荐一个社区比较好的库 fetch-event-source ,它是由微软开源开源的为了解决上述哪些弊端问题,一个基于 Fetch API 并且具有更强大的 SSE 功能的库。
解决的问题
原生 EventSource API 有以下限制:
- 请求方法受限:只能使用 GET 请求
- 无法传递请求体:必须将所有信息编码在 URL 中,而 URL 在大多数浏览器中限制为 2000 个字符
- 无法自定义请求头:无法添加 Authorization 等自定义头信息
- 重试机制受限:当连接断开时,无法控制重试策略,浏览器会默默尝试几次然后停止
- 无法进行响应验证:没有办法在解析事件流之前对响应进行自定义验证
核心理念
fetch-event-source 的核心理念是提供一个基于 Fetch API 的更灵活的接口,使开发者能够:
-
完全控制请求:支持任何 HTTP 方法、自定义请求头和请求体
javascriptfetchEventSource('/api/sse', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer token' }, body: JSON.stringify({ query: 'some large query' }) });
-
完全控制重试逻辑:允许开发者自定义断线重连策略
javascriptfetchEventSource('/api/sse', { onerror(err) { if (err instanceof FatalError) { throw err; // 停止操作 } else { // 自动重试,也可以返回特定的重试间隔 } } });
-
访问响应对象:在解析事件流之前可以对响应进行验证
javascriptfetchEventSource('/api/sse', { async onopen(response) { if (response.ok && response.headers.get('content-type').includes('text/event-stream')) { return; // 一切正常 } else if (response.status >= 400 && response.status < 500) { throw new Error('客户端错误'); } else { throw new Error('服务器错误,将重试'); } } });
-
与页面可见性 API 集成:当页面隐藏时自动关闭连接,页面可见时再自动重连,减少服务器负担
使用示例
从原来的 EventSource:
javascript
const sse = new EventSource('/api/sse');
sse.onmessage = (ev) => {
console.log(ev.data);
};
改为使用 fetch-event-source:
javascript
import { fetchEventSource } from '@microsoft/fetch-event-source';
await fetchEventSource('/api/sse', {
onmessage(ev) {
console.log(ev.data);
}
});
这个库非常适合在需要更多控制权的场景下使用,比如:
- 需要发送复杂请求体的 LLM 流式响应
- 需要携带认证信息的实时数据流
- 需要自定义重试策略的关键业务场景
- 与 API 网关或代理一起工作时需要更好的错误处理
数据流转图

总结
SSE 协议是一个非常轻量且高效的实时数据推送技术,适用于需要单向数据流的应用场景,尤其是在"大模型"时代,AI 和大数据的实时传输需求中,它以"简单、稳定、易用"的优势,成为前端工程师的新宠。
与 WebSocket 相比,SSE 的实现更简单,兼容性更好,适用于大多数浏览器和现代 Web 应用。如果你追求极致的实时体验,WebSocket 依然不可替代;但如果你要的是"服务器单向推送",SSE 绝对值得一试。
技术没有高低,适合场景才是王道