1. 从一个真实场景说起:为什么我们需要流式传输
前阵子帮一个朋友排查他做的智能问答页面,问题很典型:用户问一个问题,前端要转圈等七八秒,然后“啪”一下整段答案全冒出来。他自己也觉得别扭,说看别人家的产品都是字一个一个往外蹦,像有人在实时打字。这个“字一个一个往外蹦”的效果,背后就是流式传输(streaming),而支撑它在前端落地的最常见协议,就是SSE(Server-Sent Events)。
先把这三个词的关系理清楚,不然后面全是糊涂账。流式传输是一种数据传输的思想:数据不再攒成一大块一次性发完,而是切成很多小块,边产生边发送,接收方边收边处理。SSE则是 HTTP 体系下实现这种“服务器持续往客户端推数据”的一种具体协议,全称 Server-Sent Events,浏览器原生支持,用起来比 WebSocket 轻得多。而streaming这个词在不同语境下含义略有差别,有时候指后端模型逐 token 生成,有时候指网络层分块传输,本文会把这两层都讲透。
这篇内容适合谁看?如果你正在做 AI 对话类产品、实时日志面板、股票行情、进度推送、消息通知这类“服务器主动、持续、单向推数据”的功能,那 SSE 基本是你绕不开的方案。如果你只是听说过 streaming 但没真正手写过,或者写完发现“怎么不生效”“怎么被缓冲了”“怎么断线不重连”,那这篇就是写给你的。我会从设计思路讲到协议细节,再到能直接抄的代码和踩坑记录,尽量让你看完就能上手。
需要先明确一个边界:SSE 是单向的,服务器推给客户端,客户端不能通过这条连接反向发消息。如果你的场景需要双向实时通信(比如协同编辑、游戏对战),那该用 WebSocket 就用 WebSocket,别硬套 SSE。选型这件事,后面第 2 节会专门展开讲。
2. 方案选型:SSE、WebSocket、轮询到底怎么选
很多人一上来就问“SSE 和 WebSocket 哪个好”,这个问题本身就问错了。没有哪个绝对好,只有哪个更贴合你的场景。我一般会从四个维度来判断:通信方向、实时性要求、实现成本、基础设施兼容性。把这四个维度摆出来,答案通常自己就浮出来了。
2.1 四种常见方案的横向对比
先把候选方案列全,别只盯着 SSE 和 WebSocket,轮询和长轮询在很多团队里依然是主力。
| 方案 | 通信方向 | 实时性 | 实现成本 | 兼容性 | 典型场景 |
|---|---|---|---|---|---|
| 短轮询 | 客户端拉 | 差(取决于间隔) | 极低 | 极好 | 低频状态查询 |
| 长轮询 | 客户端拉(挂起) | 中 | 中 | 好 | 兼容老环境的推送 |
| SSE | 服务器单向推 | 好 | 低 | 好(HTTP 体系) | AI 流式输出、通知、日志 |
| WebSocket | 双向 | 极好 | 中高 | 好 | 聊天、协同、游戏 |
短轮询就是前端定时器每隔几秒发一次请求,简单粗暴,但延迟高、无效请求多。长轮询是请求发出去后服务器先挂着不返回,有数据才返回,返回后客户端立刻再发一个,实时性比短轮询好,但每个连接都占着服务器资源。SSE 则是客户端发一次请求,服务器保持这条连接不关,持续往里写数据。WebSocket 是另起一套协议,握手后全双工。
2.2 为什么 AI 流式输出几乎都选 SSE
这里要重点说说 AI 场景,因为这是当下 SSE 最火的应用土壤。大模型生成回答是逐 token 产出的,第一个 token 可能几百毫秒就出来了,但整段回答要好几秒。如果等整段生成完再返回,用户就要干等;如果用流式,第一个 token 一出来就推给前端,用户立刻看到字在动,体感快了好几倍。
那为什么不用 WebSocket 做这件事?因为 AI 对话本质上是“用户发一次、服务器回一串”,是典型的单向推送,根本用不上双向。用 WebSocket 属于杀鸡用牛刀,还要额外维护心跳、重连、协议升级,运维和调试都更麻烦。SSE 直接跑在 HTTP/HTTPS 上,复用现有的鉴权、网关、负载均衡、日志体系,几乎零额外基础设施成本。这就是它在这个场景胜出的核心原因。
提示:选型时先问自己“客户端需不需要在这条连接上主动发消息”。如果不需要,优先考虑 SSE;如果需要,再上 WebSocket。
2.3 SSE 的协议本质:它就是一段特殊的 HTTP 响应
很多人觉得 SSE 神秘,其实它一点都不神秘。它就是一个普通的 HTTP 请求,只不过服务器返回的响应头里带了Content-Type: text/event-stream,并且不主动关闭连接,而是一段一段地往响应体里写符合特定格式的文本。浏览器识别到这个 Content-Type 后,就不会等整个响应结束,而是边收边触发事件。
理解这一点非常关键,因为它解释了很多“玄学问题”。比如为什么 SSE 会被某些代理服务器缓冲?因为代理看到的是一个还没结束的 HTTP 响应,它可能想攒够一定大小再转发。比如为什么 SSE 默认只能同源或需要 CORS?因为它就是个 HTTP 请求,跨域规则照旧。把 SSE 当成“一个不结束的 HTTP 响应”来看,很多问题就顺了。
3. SSE 协议细节拆解:数据格式与关键字段
协议这块必须讲清楚,不然你写出来的东西“看起来能跑”,一出问题就抓瞎。SSE 的数据格式其实非常简单,就是纯文本,按行组织,用两个换行符分隔一个完整事件。
3.1 数据帧的基本格式
一个标准的 SSE 消息长这样:
event: message data: 你好,这是一条消息 id: 1001 retry: 3000 data: 第二条消息规则拆开看:
- 每一行是
字段名: 值的形式,冒号后面建议跟一个空格(规范里空格会被忽略,但习惯上加上)。 data是最核心的字段,表示消息内容。可以有多行data,它们会被拼接起来,中间用换行符连接。event指定事件类型,前端可以用addEventListener('message')或自定义事件名监听。不写默认是message。id是这条消息的编号,浏览器会记住它,断线重连时通过Last-Event-ID请求头发回给服务器,用于续传。retry告诉浏览器断线后隔多少毫秒重连,单位是毫秒。- 一个事件以空行结束,也就是连续两个
\n。这是最容易写错的地方,少一个换行,前端就收不到。
3.2 那些必须记住的格式坑
我见过太多人栽在格式上,这里集中列一下:
- 换行符必须是
\n\n。如果你在 Windows 环境下拼字符串用了\r\n\r\n,大多数情况浏览器也能认,但为了稳妥统一用\n。 data里的内容不能直接包含裸换行。如果消息本身有多行,要拆成多个data:行,浏览器会自动用\n拼回来。- 冒号后没内容也是合法的,表示该字段值为空。以冒号开头的行是注释,常用来做心跳保活,比如发一个
: keep-alive\n\n。 - 字段名大小写敏感,别写成
Data:。
注意:心跳注释行(以
:开头)不会触发任何事件,但能防止连接被中间设备判定为空闲而掐断。长连接场景强烈建议加。
3.3 浏览器端的 EventSource 行为
浏览器提供了EventSource这个原生对象来消费 SSE,用起来极其简单:
const es = new EventSource('/api/stream'); es.onmessage = (e) => { console.log('收到:', e.data); }; es.onerror = (err) => { console.error('出错了', err); }; // 监听自定义事件 es.addEventListener('progress', (e) => { console.log('进度:', e.data); });EventSource有几个默认行为你要心里有数:它会自动重连,默认间隔大约 3 秒,服务器可以通过retry字段调整;重连时会带上Last-Event-ID;连接状态可以通过es.readyState查看(0 连接中、1 已连接、2 已关闭)。这些默认行为是优点也是坑,后面排查章节会细说。
4. 后端实现:从零写一个能跑的 SSE 服务
光讲协议不够,得能跑起来。我用 Node.js 和 Python 各写一个最小可运行版本,再讲生产环境要注意什么。选这两个语言是因为它们生态里做流式最顺手,其他语言思路完全一致。
4.1 Node.js 版本:Express 实现
const express = require('express'); const app = express(); app.get('/api/stream', (req, res) => { // 关键响应头 res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); // 关键:禁用 Nginx 缓冲 res.flushHeaders(); // 立即把响应头发出去 let id = 0; const timer = setInterval(() => { id++; res.write(`id: ${id}\n`); res.write(`data: ${JSON.stringify({ text: '第' + id + '条', ts: Date.now() })}\n\n`); if (id >= 10) { clearInterval(timer); res.write('event: done\ndata: end\n\n'); res.end(); } }, 1000); // 客户端断开时清理资源,这一步千万别漏 req.on('close', () => { clearInterval(timer); res.end(); }); }); app.listen(3000);这段代码里有几个点值得单独拎出来说。res.flushHeaders()是必须的,否则响应头可能被 Node 攒着不发,前端迟迟进不了连接状态。X-Accel-Buffering: no是给 Nginx 看的,告诉它别缓冲这个响应,这是生产环境最常见的“本地好好的、上线就不流式”的元凶。req.on('close')里的清理同样关键,不然客户端一断,定时器还在跑,内存和 CPU 就这么漏掉了。
4.2 Python 版本:FastAPI 实现
from fastapi import FastAPI from fastapi.responses import StreamingResponse import asyncio, json, time app = FastAPI() async def event_generator(): for i in range(1, 11): payload = {"text": f"第{i}条", "ts": time.time()} yield f"id: {i}\n" yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" await asyncio.sleep(1) yield "event: done\ndata: end\n\n" @app.get("/api/stream") async def stream(): return StreamingResponse( event_generator(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "X-Accel-Buffering": "no", }, )FastAPI 的StreamingResponse天然就是流式的,你只要保证生成器是异步的、每次 yield 一小块就行。注意ensure_ascii=False,不然中文会被转义成\uXXXX,虽然前端能解析,但调试时看着难受。
4.3 对接大模型流式输出的关键处理
真正做 AI 产品时,后端往往是把上游模型的流式输出“转发”给前端。这里有个常见做法:上游返回的是一行行 JSON(比如每行一个 chunk),你需要把它转成 SSE 格式再推给前端。
// 伪代码:把上游流转换成 SSE for await (const chunk of upstreamStream) { const text = chunk.choices?.[0]?.delta?.content || ''; if (text) { res.write(`data: ${JSON.stringify({ delta: text })}\n\n`); } } res.write('event: done\ndata: [DONE]\n\n'); res.end();这里要特别注意**背压(backpressure)**问题。如果前端消费慢,而后端一直往连接里写,缓冲区会越堆越大。Node 里res.write()会返回一个布尔值,返回false时说明缓冲区满了,应该暂停写入,等drain事件再继续。这个细节在低并发时看不出来,高并发时就是内存暴涨的根源。
5. 前端消费:EventSource 与 fetch 流式读取
前端消费 SSE 有两条路:用原生EventSource,或者用fetch手动读流。两者各有适用场景,选错了会给自己添堵。
5.1 EventSource 的适用与局限
EventSource最大的优点是简单,几行代码就能跑,还自带重连。但它有个硬伤:不能自定义请求头。这意味着你没法在请求头里塞Authorization: Bearer xxx做鉴权。很多团队的做法是把 token 放 URL 参数里,但这又带来 token 泄漏到日志的风险。
所以EventSource适合:鉴权靠 Cookie、或者内网、或者对安全要求不高的场景。如果你的接口必须用 Bearer Token,那基本就得走fetch方案。
5.2 fetch + ReadableStream 方案
fetch方案能完全控制请求头,代价是要自己处理流解析和重连。
async function streamChat(prompt) { const resp = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, }, body: JSON.stringify({ prompt }), }); const reader = resp.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 parts = buffer.split('\n\n'); buffer = parts.pop(); // 最后一段可能不完整,留到下次 for (const part of parts) { const line = part.split('\n').find(l => l.startsWith('data:')); if (line) { const data = line.slice(5).trim(); if (data === '[DONE]') return; handleChunk(JSON.parse(data)); } } } }这段代码里最容易被忽略的是buffer的处理。网络传输是分块的,一个 SSE 事件可能被拆到两个 chunk 里,所以你必须用一个缓冲区把不完整的部分留下来,等下一块拼上再解析。我见过不少人直接对每个 chunk 做split('\n\n'),结果偶尔丢字、偶尔报 JSON 解析错误,根源就在这里。decoder.decode(value, { stream: true })里的stream: true也是同理,防止多字节的中文字符被从中间截断。
5.3 两种方案的取舍
| 维度 | EventSource | fetch 流 |
|---|---|---|
| 请求头自定义 | 不支持 | 支持 |
| 自动重连 | 内置 | 需自己实现 |
| 请求方法 | 仅 GET | 任意 |
| 解析复杂度 | 低 | 中 |
| 适用场景 | 简单推送 | 需鉴权/传参的 AI 对话 |
我的经验是:做 AI 对话这类需要 POST 传参、需要 Bearer 鉴权的场景,直接上fetch方案,别在EventSource上折腾。做通知、日志这类 GET 就够的场景,EventSource省心。
6. 生产环境避坑:那些文档不会告诉你的问题
这一节是全文最值钱的部分。前面讲的都是“理想情况”,但真实上线后,问题几乎都出在中间链路上。我把这些年踩过的坑整理成一张速查表,再逐条展开。
6.1 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 本地流式,上线变一次性 | 中间层缓冲 | 检查 Nginx/网关缓冲配置 |
| 连接几秒后自动断 | 超时设置 | 调大 read timeout |
| 中文乱码或截断 | 编码/分块 | 检查 charset 和 buffer 处理 |
| 断线不重连 | 连接被正常关闭 | 检查是否发了res.end() |
| 事件收不到 | 格式错误 | 检查\n\n结尾 |
| 内存持续上涨 | 未清理定时器 | 检查 close 事件处理 |
6.2 Nginx 缓冲:头号杀手
这是最高频的问题,没有之一。Nginx 默认会对代理的响应做缓冲,它看到你的响应还没结束,就想攒一攒再转发,结果流式效果全没了。解决办法是在 Nginx 配置里针对这个 location 关掉缓冲:
location /api/stream { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_set_header Connection ''; proxy_http_version 1.1; chunked_transfer_encoding off; proxy_read_timeout 3600s; }proxy_buffering off是核心,proxy_read_timeout调大是为了防止长连接被判定超时。proxy_http_version 1.1配合Connection ''是为了用上 chunked 传输。这几行配下来,Nginx 这关基本就过了。除了 Nginx,CDN、云负载均衡、API 网关也都可能有类似缓冲,思路一样:找到“缓冲”开关关掉。
6.3 超时与心跳
长连接最怕被中间设备判定为空闲然后掐断。除了调大各层超时,更主动的做法是定期发心跳。SSE 里发一个注释行就行:
const heartbeat = setInterval(() => { res.write(': ping\n\n'); }, 15000);15 到 30 秒发一次比较合适,太频繁浪费带宽,太稀疏起不到保活作用。记得在close事件里clearInterval(heartbeat)。
6.4 断线重连与续传
EventSource会自动重连,但fetch方案不会,你得自己写。重连时如果想让服务器从断点继续,就要利用Last-Event-ID。服务器端每条消息都带上递增的id,客户端重连时浏览器(EventSource 场景)会自动带上这个头,服务器读到后从对应位置继续推。fetch方案则需要你自己把最后收到的 id 存下来,重连时手动带上。
提示:续传功能对“不能丢消息”的场景(如订单状态、支付回调)很重要,对 AI 对话这种“丢了重新生成也行”的场景可以简化处理。
6.5 并发与资源释放
每个 SSE 连接都占着一个服务器连接和一个文件描述符。如果客户端异常退出(比如直接关浏览器),服务器不一定立刻感知,连接可能挂很久。所以服务端一定要设置合理的超时,并且在close事件里彻底清理定时器、监听器、上游连接。我见过一个服务因为没清理上游模型连接,跑一天就 OOM 了,排查了半天才发现是 SSE 的锅。
7. 我个人的几条实操心得
最后分享几个纯经验性的东西,都是文档里不会写、但实际很管用的。
第一,调试 SSE 别用浏览器 Network 面板的普通视图。Chrome 的 Network 里,SSE 请求的响应是实时刷新的,但如果你看的是“Response”标签,有时候它会把内容攒着显示,让你误以为没流式。更靠谱的方式是打开EventStream标签页(Chrome 较新版本有),或者直接用curl -N在命令行看原始输出,-N参数禁用 curl 自己的缓冲,能看到最真实的流。
第二,给流式接口单独设一个路径前缀,比如/api/stream/*,这样在 Nginx、网关、监控上都能针对性地配置,不会影响普通接口。混在一起配,很容易顾此失彼。
第三,前端一定要处理“流中途出错”的情况。网络抖动、服务器重启都可能让流断在半路,用户看到的就是半句话。我的做法是给每个流式请求加一个状态标记,正常收到[DONE]才算完成,否则提示“回答中断,点击重试”。这个小细节对用户体验影响很大。
第四,别在流式连接里做重业务逻辑。SSE 连接生命周期可能很长,如果你在里面查数据库、调外部接口,一旦出错整个连接就废了。更好的做法是业务逻辑在别处算好,SSE 只负责把结果推出去,职责单一,出问题也好定位。
这套东西我从最早的轮询一路做到现在的流式,最大的感受是:SSE 本身很简单,难的是它周围那一圈基础设施。把缓冲、超时、心跳、清理这四件事处理好,剩下的就是水到渠成。