1. 从一个真实场景说起为什么我们需要流式传输前阵子帮一个朋友排查他做的智能问答页面问题很典型用户问一个问题前端要转圈等七八秒然后“啪”一下整段答案全冒出来。他自己也觉得别扭说看别人家的产品都是字一个一个往外蹦像有人在实时打字。这个“字一个一个往外蹦”的效果背后就是流式传输streaming而支撑它在前端落地的最常见协议就是SSEServer-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 fid: {i}\n yield fdata: {json.dumps(payload, ensure_asciiFalse)}\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_typetext/event-stream, headers{ Cache-Control: no-cache, X-Accel-Buffering: no, }, )FastAPI 的StreamingResponse天然就是流式的你只要保证生成器是异步的、每次 yield 一小块就行。注意ensure_asciiFalse不然中文会被转义成\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 两种方案的取舍维度EventSourcefetch 流请求头自定义不支持支持自动重连内置需自己实现请求方法仅 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 这关基本就过了。除了 NginxCDN、云负载均衡、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 本身很简单难的是它周围那一圈基础设施。把缓冲、超时、心跳、清理这四件事处理好剩下的就是水到渠成。
阅读完成 · 觉得有帮助?