资讯详情

资讯详情

SSE流式输出实战:Node.js服务端与前端消费全解析

1. 从“等一整段”到“边生成边看”流式输出到底改变了什么如果你最近在对接大模型接口或者自己做过 AI 对话类产品大概率会遇到一个很具体的体验问题用户问了一句话界面转圈转了五六秒然后“啪”地一下整段答案全冒出来。功能上没毛病但用起来就是别扭——用户不知道后台到底是在思考、卡住了、还是已经挂了。而流式输出要解决的恰恰就是这个“等待焦虑”。所谓流式输出说白了就是让服务端不要攒够一整段再发而是生成一点、发一点客户端收到一点就渲染一点。它带来的直接变化有三个第一首字响应时间从“整段生成完”缩短到“第一个 token 生成完”体感快了一个数量级第二用户能实时看到内容在“长出来”心理上会觉得系统在认真干活第三对于长文本场景内存占用和超时风险都显著下降因为不需要在服务端把整段内容缓冲成一个巨大的字符串。而支撑这套机制最常用的技术就是SSEServer-Sent Events服务器推送事件。它基于普通 HTTP 长连接服务端以text/event-stream的 MIME 类型持续向客户端推送文本片段客户端用浏览器原生的EventSource或者fetch的流式读取就能接收。相比 WebSocketSSE 是单向的服务端到客户端协议更轻、实现更简单、天然支持自动重连特别适合“请求一次、持续接收”的大模型对话场景。这篇文章适合三类人看一是正在做 AI 对话产品、被“整段返回”体验困扰的前后端开发者二是想搞明白 SSE 和 WebSocket 到底该怎么选的技术选型者三是已经用上了流式但被“标签返回不完整”“idle timeout”“流中断”这些问题折磨过的实战派。我会从协议原理讲到 Node.js 服务端实现再到前端消费和踩坑排查尽量把每个“为什么”都讲透让你看完能直接抄作业。2. SSE 协议拆开看它凭什么比轮询和 WebSocket 更适合大模型2.1 一次 SSE 连接的完整生命周期很多人以为 SSE 是什么黑科技其实它就是一个不结束的 HTTP 响应。客户端发起一个普通的 GET 请求带上Accept: text/event-stream服务端返回Content-Type: text/event-stream然后保持这个连接不关闭持续往里面写数据。就这么简单。一个标准的 SSE 响应长这样HTTP/1.1 200 OK Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive data: 你好 data: 我是 data: 一个流式片段注意几个关键点。每条消息以data:开头以两个换行符\n\n结尾这是消息的分隔符。服务端每写完一条就 flush 一次客户端就能立刻收到。除了dataSSE 还支持event自定义事件名、id消息 ID用于断线重连时定位、retry重连间隔毫秒数这几个字段。大模型场景里最常用的就是data偶尔用event区分“正文”和“结束信号”。浏览器端的EventSource会自动处理重连连接断了之后它会等retry指定的时间默认约 3 秒再发起新请求并把最后收到的id通过Last-Event-ID请求头发回去。这个机制在普通推送场景很好用但在大模型对话里反而可能帮倒忙——因为对话是有状态的重连后你未必想让它从头再来。所以很多团队会主动eventSource.close()来禁用自动重连改用业务层自己控制。2.2 SSE、WebSocket、轮询三者的取舍逻辑选型这件事最怕的就是“别人用啥我用啥”。我把三者的核心差异列成表你对着自己的场景看维度SSEWebSocket短轮询长轮询通信方向服务端→客户端单向双向客户端主动拉客户端主动拉协议基础纯 HTTP独立协议需升级握手HTTPHTTP实现复杂度低中高极低中自动重连原生支持需自己实现不涉及不涉及代理/网关兼容好就是 HTTP部分网关需额外配置好好适用场景推送、流式生成聊天室、协同编辑、游戏低频状态查询准实时通知大模型对话为什么首选 SSE因为它的数据流向天然就是单向的用户发一次 prompt服务端持续吐 token中间不需要客户端再插话。用 WebSocket 属于杀鸡用牛刀你还要自己维护心跳、重连、消息分帧成本高还不一定稳。而轮询的问题更明显——你根本不知道模型什么时候吐下一个 token轮询间隔设短了浪费请求设长了体验卡顿。提示如果你的场景里用户需要在生成过程中“打断”或“追加指令”那 WebSocket 的双向能力才有价值。纯展示型流式输出SSE 是更省心的选择。2.3 为什么大模型厂商都爱用 SSE 做默认流式协议你去看主流大模型的 API 文档流式接口几乎清一色是 SSE。原因不复杂一是它复用 HTTP 基础设施CDN、负载均衡、鉴权中间件全都能直接套用接入成本低二是它对客户端要求低浏览器原生EventSource就能用移动端、命令行curl也能轻松对接三是它的文本协议对人类友好调试的时候直接看原始报文就知道发生了什么不像二进制帧那样需要专门工具解析。还有一个容易被忽略的点SSE 的背压处理相对简单。服务端生成速度如果快于网络传输数据会在 TCP 缓冲区排队不会像某些双向协议那样把内存撑爆。当然这也意味着你需要关注服务端的写入节奏后面讲 Node.js 实现时会细说。3. Node.js 服务端落地从零写一个能扛住真实流量的 SSE 接口3.1 最小可用实现与它的三个致命缺陷先看一个最朴素的 Node.js SSE 服务端写法用原生http模块const http require(http); const server http.createServer((req, res) { if (req.url /stream) { res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }); let count 0; const timer setInterval(() { count; res.write(data: ${JSON.stringify({ text: 片段${count} })}\n\n); if (count 10) { clearInterval(timer); res.write(event: done\ndata: [DONE]\n\n); res.end(); } }, 500); req.on(close, () clearInterval(timer)); } }); server.listen(3000);这段代码能跑但放到生产环境会出三个问题。第一没有禁用 Nagle 算法和压缩缓冲某些反向代理会攒一批数据再发流式就变成了“批量”。第二没有心跳机制中间任何一层网关Nginx、云负载均衡看到连接长时间没数据就会按 idle timeout 把它掐掉前端就会报stream disconnected before completion: idle timeout waiting for sse。第三没有处理客户端断开如果用户关了页面但服务端还在setInterval就是内存泄漏。3.2 生产级 SSE 接口必须补上的四件事第一件正确设置响应头。除了text/event-stream还要加X-Accel-Buffering: no这是给 Nginx 看的告诉它别缓冲这个响应。同时Cache-Control建议用no-cache, no-transformno-transform能防止某些代理对内容做压缩改写。第二件加心跳。每隔 15 到 30 秒发一个注释行: heartbeat\n\n以冒号开头的行会被 SSE 客户端忽略既能保活连接又不会污染业务数据。心跳间隔要小于你链路里最短的那个 idle timeout一般取 15 秒比较稳妥。第三件监听close事件做清理。无论是req.on(close)还是res.on(close)都要把定时器、上游请求、数据库游标统统释放掉。这一步做不好压测的时候连接数会只增不减。第四件处理写入失败。res.write()在连接已断的情况下会返回false甚至抛错要包一层判断避免进程崩溃。补全之后的核心逻辑大概是这样function setupSSE(req, res) { res.writeHead(200, { Content-Type: text/event-stream; charsetutf-8, Cache-Control: no-cache, no-transform, Connection: keep-alive, X-Accel-Buffering: no, }); res.flushHeaders(); const heartbeat setInterval(() { if (!res.writableEnded) res.write(: ping\n\n); }, 15000); const cleanup () { clearInterval(heartbeat); // 这里释放上游资源abort controller、db cursor 等 }; req.on(close, cleanup); res.on(close, cleanup); return { send(data) { if (res.writableEnded) return false; return res.write(data: ${JSON.stringify(data)}\n\n); }, done() { if (!res.writableEnded) { res.write(event: done\ndata: [DONE]\n\n); res.end(); } }, }; }3.3 把大模型的流式响应“转接”给前端真实场景里你的 Node.js 服务往往不是自己生成内容而是代理大模型厂商的流式接口。这时候要做的是用fetch拿到上游的ReadableStream逐块解析 SSE 报文再转发给自己的客户端。async function proxyStream(prompt, sse) { const controller new AbortController(); const upstream await fetch(https://api.example.com/v1/chat, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.API_KEY}, }, body: JSON.stringify({ prompt, stream: true }), signal: controller.signal, }); const reader upstream.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; try { 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(); // 最后一段可能不完整留到下一轮 for (const line of lines) { if (!line.startsWith(data:)) continue; const payload line.slice(5).trim(); if (payload [DONE]) { sse.done(); return; } try { const json JSON.parse(payload); sse.send({ text: json.choices?.[0]?.delta?.content ?? }); } catch (e) { // 解析失败通常是分块切断导致跳过即可 } } } } finally { controller.abort(); } }这里有个极其关键的细节buffer的处理。网络传输是按字节块来的一个 SSE 消息很可能被切成两半前半段在上一块、后半段在下一块。如果你每收到一块就直接split(\n)然后全部解析就会遇到“标签返回未完整”的问题——JSON 解析报错、内容缺字。正确做法是保留最后一段不完整的行等下一块数据拼上再处理。上面代码里buffer lines.pop()就是干这个的。注意decoder.decode(value, { stream: true })里的stream: true不能省。UTF-8 一个汉字占 3 字节如果正好在字节边界被切断不加这个参数会解出乱码。4. 前端消费流式数据EventSource 和 fetch 到底该用哪个4.1 EventSource 的便利与它的硬伤浏览器原生EventSource用起来确实爽const es new EventSource(/stream); es.onmessage (e) { const data JSON.parse(e.data); appendToChat(data.text); }; es.addEventListener(done, () es.close()); es.onerror (err) console.error(SSE error, err);但它有两个绕不开的限制。第一只能发 GET 请求没法带自定义请求体。而大模型对话通常需要 POST 一个 JSON 过去这就很尴尬。第二不能自定义请求头意味着你没法方便地加Authorization。虽然可以用 Cookie 或者把 token 塞进 query string但前者跨域麻烦后者有泄露风险。所以现在主流的 AI 对话前端基本都改用fetchReadableStream来消费 SSE。它本质上是把 SSE 当成一种文本格式来手动解析换取完全的请求控制权。4.2 用 fetch 手动解析 SSE 的完整写法async function streamChat(prompt, onChunk) { const res await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${token}, }, body: JSON.stringify({ prompt }), }); if (!res.ok || !res.body) throw new Error(stream failed); const reader res.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 events buffer.split(\n\n); buffer events.pop(); for (const evt of events) { const line evt.split(\n).find((l) l.startsWith(data:)); if (!line) continue; const payload line.slice(5).trim(); if (payload [DONE]) return; onChunk(JSON.parse(payload).text); } } }注意这里的分隔符是\n\n消息之间而不是\n行之间。这是 SSE 协议规定的消息边界。同样要保留buffer里最后一段不完整的内容等下一块拼上。这套写法在 Vue、React 里都能直接用配合响应式状态更新就能实现“文字一个个蹦出来”的效果。4.3 标签返回不完整、内容截断的根因与修复“标签返回未完整怎么处理”是搜索里高频出现的问题本质上有三种情况。情况一分块边界切断。就是上面说的一个 JSON 或一个 Markdown 标签被切在两块数据里。修复方式就是缓冲 按完整消息边界切分绝不对半截数据做解析。情况二流提前结束。上游因为超时或错误中断了但客户端没收到[DONE]。这时候要判断如果已经渲染的内容是完整的句子可以保留如果是半截标签比如div没闭合要么丢弃最后一段要么在 UI 上标记“生成中断”。稳妥做法是在服务端捕获上游异常后主动补发一个event: error让前端知道该收尾了。情况三Markdown 渲染器把半截标签吃掉了。流式渲染 Markdown 时**加粗这种没闭合的语法会让渲染器行为异常。解决方案是渲染前对未闭合的标记做临时补全或者干脆在流式过程中用纯文本渲染等[DONE]后再切到 Markdown 渲染。很多成熟产品就是这么做的。5. 那些让你半夜爬起来排查的流式故障5.1 idle timeout连接为什么“莫名其妙”断了stream disconnected before completion: idle timeout waiting for sse这个报错几乎每个做流式的人都见过。它的意思是链路上某一层Nginx、云负载均衡、CDN、甚至客户端在规定时间内没收到任何数据判定连接空闲主动断开。排查思路是从客户端往服务端逐层看超时配置。Nginx 默认proxy_read_timeout是 60 秒云负载均衡常见是 60 或 300 秒有些 CDN 更短。你的心跳间隔必须小于这条链路上最短的超时值。我一般把心跳设成 15 秒基本能覆盖绝大多数网关。还有一个隐蔽的坑大模型“思考”阶段可能长时间不吐 token。比如推理模型在输出前会有一段较长的静默期如果超过心跳间隔连接就危险了。解决办法是服务端在等待上游首个 token 期间也要持续发心跳而不是等有内容才发。5.2 代理缓冲为什么本地好好的上线就变“批量”本地开发直连 Node.js流式效果完美一部署到 Nginx 后面就变成几秒蹦一大段。这十有八九是代理缓冲在作祟。Nginx 默认会缓冲上游响应攒够一定大小或时间才转发给客户端。修复方式是在 Nginx 配置里针对这个 location 关掉缓冲location /api/chat { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_set_header Connection ; proxy_http_version 1.1; chunked_transfer_encoding off; }proxy_buffering off是核心proxy_http_version 1.1配合空的Connection头是为了保持长连接。另外前面提到的X-Accel-Buffering: no响应头是应用层告诉 Nginx 别缓冲双保险。5.3 客户端断开后服务端还在“空转”用户点了“停止生成”或者直接关了标签页但服务端的上游请求还在跑token 还在烧。这是真金白银的浪费。正确做法是把客户端的断开信号传递到上游。在 Node.js 里req.on(close)触发时调用上游fetch的AbortController.abort()上游请求就会立即终止。如果你用的是某些 SDK通常也提供了abort或cancel方法。这一步一定要做否则并发一高账单会让你怀疑人生。5.4 用 curl 快速验证 SSE 接口是否正常排查流式问题时curl是最趁手的工具因为它能让你看到原始字节流不受浏览器和框架干扰curl -N -H Accept: text/event-stream http://localhost:3000/stream-N参数关闭 curl 自己的缓冲让数据实时打印。如果这里能看到数据一行行冒出来说明服务端没问题问题在代理或前端如果这里也是攒一批才出那问题就在服务端或它前面的某一层。这个二分法能帮你快速缩小排查范围。6. 几个容易被忽略但很要命的工程细节6.1 字符编码与多字节切断前面提过 UTF-8 汉字占 3 字节但实际排查时很多人还是会栽。除了TextDecoder的stream: true服务端res.write()时也要确保写入的是完整的字符串Node.js 会自己处理编码。真正危险的是你自己手动按字节切片的场景比如做二进制协议转换时一定要用StringDecoder而不是Buffer.toString()后者在多字节边界会出乱码。6.2 并发连接数与资源上限SSE 是长连接每个在线用户占一个连接。Node.js 单机默认的文件描述符上限、server.maxConnections、以及操作系统的ulimit -n都可能成为瓶颈。上线前务必压测确认单机能扛多少并发流。另外如果你的服务还要连数据库注意数据库连接池不能被长连接占满——流式请求应该尽快释放数据库连接把数据推到内存队列后再慢慢发。6.3 流式场景下的日志与可观测性普通请求打一条日志就够了但流式请求持续时间长你需要记录连接建立时间、首字节时间TTFB、总时长、发送的 chunk 数、是否异常中断。这些指标能帮你定位“是模型慢还是网络慢”。建议给每个流式连接分配一个 trace id从客户端一路透传到上游出问题时能串起来看。6.4 关于 Node.js 版本与环境搜索热词里有一堆node.js 18、node.js 安装、node.js 下载相关的问题说明不少人是刚上手。做流式开发建议用 Node.js 18 及以上版本因为fetch和ReadableStream在 18 里已经稳定可用不需要额外装node-fetch或axios的流式适配。安装就去官网下 LTS 版本装完node -v确认一下。如果你遇到the requested module node:util does not provide an export named这类报错通常是版本太老或者 ESM/CJS 混用导致的升级到 18 并统一模块规范基本能解决。提示node.js v24.21.0 is not yet released这种报错一般是你在package.json的engines字段里写了一个不存在的版本号或者 CI 环境里指定了错误的 Node 版本。检查一下版本约束别写未来版本。流式输出这件事原理不复杂但魔鬼全在细节里。从 SSE 协议的消息边界到 Node.js 的缓冲与心跳再到前端的 fetch 解析和 Markdown 渲染每一环都可能让“丝滑”变成“卡顿”。我自己踩过最深的坑就是早期没做心跳测试环境一切正常一上生产就被网关按 60 秒超时掐断用户看到的就是“回答到一半突然没了”。后来把心跳、缓冲关闭、断开清理这三件事补齐流式才真正稳下来。如果你正准备做流式建议先把这三件事在最小 Demo 里跑通再往上叠业务逻辑能省掉大量返工。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →