资讯动态

别再傻傻等大模型吐完整段话了!一文吃透流式输出与 SSE

发布时间:2026/9/16 7:52:00 来源:尧图企业网站定制
你有没有过这种体验问 ChatGPT 一个问题它一个字一个字往外蹦你却觉得很爽这种爽感背后不是特效而是一套扎实的工程实现——SSEServer-Sent Events 流式输出。今天我们就从一段 30 行的 Node.js 代码出发把流式输出、SSE 底层协议、三大关键响应头、EventSource 用法以及它和大模型结构化输出的关系一次性说透。读完你就能用原生 Node 撸一个 SSE 服务3 分钟接入到自己的项目里。一、为什么需要流式输出先说结论大模型的响应天然适合流式因为它是一个字一个字生成的。想象一下水管的场景LLM Server 是水龙头客户端是水杯stream: true就是拧开水龙头的开关不开流式就像水龙头先灌满整个水池再一次性倒给你——你得等十几秒开了流式水就一点一点滴进杯子里边生成边喝。一句话记住流式不是性能优化而是体验优化。它把等待变成了过程。二、HTTP 协议下的两种响应姿势传统的 HTTP 是请求-响应-断开的短连接模型客户端 → GET /api → 服务器 客户端 ← 200 text/html ← 服务器一次性返回 连接关闭这种模式下Content-Type通常是text/plaintext/htmlapplication/json但对于流式场景服务器需要不停地往客户端推数据而且不主动断开。HTTP 协议本身支持这个——只要Content-Type是text/event-stream。也就是说SSE 本质上是 HTTP 长连接 chunked 传输 特定格式约定。三、SSE 到底长啥样SSE全称 Server-Sent Events直译服务器发送事件。它的关键特征特征说明方向服务器 → 客户端单向连接长连接不主动断开格式text/event-stream数据单元data: xxx\n\n注意两个\n一个最小可用的响应头三件套Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive别看就三行每一行背后都有故事缺一个都可能翻车。下一节我们逐个拆开看。四、三大响应头逐行拆解很多人写 SSE 就是抄三段头但真出问题时完全不知道从哪儿查。这一节我们把这三行拆到骨头里。4.1Content-Type: text/event-stream它是什么这是一个 MIME type作用就一句话告诉浏览器别当普通响应处理我是一个 SSE 流。浏览器收到响应后会先看Content-Typetext/html→ 走 HTML 解析器application/json→ 走 JSON 解析器text/event-stream→走 SSE 解析器这才是我们要的浏览器拿到这个头之后会做什么它会启动一套专门的流式解析器规则大致是按行读数据遇到\n换行识别每行前缀data:→ 数据内容event:→ 事件名对应addEventListenerid:→ 消息 ID断线重连时用Last-Event-IDretry:→ 重连间隔毫秒:开头 → 注释用于心跳保活遇到空行\n\n就打包成一条消息触发onmessage这就是为什么服务端必须写data: xxx\n\n而不是data: xxx\n——双换行才是一条消息结束的信号。如果写错了会怎样写成text/plain→ 浏览器不会触发onmessage你只能在 DevTools 的 Network 里看到一堆流式文本写成application/json→ 浏览器不解析EventSource 直接报错少写 charset → 中文可能乱码推荐写法加上编码更保险Content-Type: text/event-stream; charsetutf-8一个冷知识SSE 的规范里对text/event-stream有一些强约束比如不能是application/x-www-form-urlencoded系列因为要防止 XSS 攻击。所以浏览器一旦发现Content-Type是text/event-stream就会严格按规范解析不会宽容处理。一句话text/event-stream是 SSE 的身份证没有它后面的一切都白搭。4.2Cache-Control: no-cache先纠正一个天大的误解很多人以为no-cache意思是完全不缓存。错。指令真实含义no-cache可以缓存但每次使用前必须向服务器验证一下走 ETag / Last-Modifiedno-store真的完全不缓存连磁盘都不写no-transform禁止中间代理压缩、转换内容private/public谁能缓存那问题来了SSE 到底该用no-cache还是no-store答案是no-cache通常。因为我们不是要禁止缓存而是要让中间层别攒数据——我们要的是每次数据一到就立刻转发。为什么 SSE 场景下这行至关重要HTTP 的中间层Nginx、CDN、企业代理默认会对响应做缓冲buffering。什么意思正常情况下Nginx 收到后端流式数据可能会这样处理后端 chunk1 → Nginx 缓存池 后端 chunk2 → Nginx 缓存池 后端 chunk3 → Nginx 缓存池 ... 攒到 4KB / 8KB 或者响应结束 ↓ 一次性发给浏览器结果就是你以为自己在流式输出其实用户看到的还是憋了几秒一次性弹出来。加上Cache-Control: no-cache就是明确告诉这些中间层这是实时数据别缓存收到就转发。更好的写法生产环境建议这样写Cache-Control: no-cache, no-transformno-cache别缓存no-transform别压缩、别转换。因为 gzip 压缩是按块进行的一旦启用流式数据很可能被 gzip 缓冲区吃掉变得不流式了Nginx 场景的额外配置如果你用 Nginx 做反向代理光加响应头还不够还要改 Nginx 配置location /stream { proxy_pass http://backend; proxy_buffering off; # 关闭响应缓冲 proxy_cache off; # 关闭缓存 proxy_set_header Connection ; # 允许长连接 proxy_http_version 1.1; chunked_transfer_encoding on; # 允许分块传输 }只加响应头不改 Nginx是最常见的我明明写了 SSE 为什么不流式的元凶。一句话no-cache不是为了浏览器是为了沿途的每一层中间人。4.3Connection: keep-alive它在说什么HTTP/1.1 里Connection头是逐跳hop-by-hop的意思是这个头只对当前这一跳生效代理不会往下传。keep-alive的作用是告诉对方这个 TCP 连接不要关我还要接着用。对 SSE 来说这是命门——连接一旦关闭推送就断了。那为什么有人不写也没事因为HTTP/1.1 默认就是keep-alive。你不写绝大多数客户端和服务器也会保持连接。那为什么还要写明确意图代码本身就是最好的文档兼容老代理某些老旧网关/负载均衡默认按 HTTP/1.0 处理会主动断开对抗Connection: close如果你上游某个中间件偷偷加了Connection: close显式覆盖能救命HTTP/2 下的坑重要在 HTTP/2 和 HTTP/3 里Connection是被禁止的 hop-by-hop 头。规范要求客户端和服务器忽略甚至拒绝它。也就是说HTTP/1.1 → 加上Connection: keep-aliveOKHTTP/2 → 加上不会报错但没有任何作用HTTP/2 本身就是多路复用长连接某些严格的网关 → 看到 HTTP/2 里带Connection头直接拒绝请求所以如果你上了 HTTPS HTTP/2可以安全地删掉这一行。反向代理的超时问题即使写了keep-alive还有个隐藏敌人代理的读超时。Nginx 默认proxy_read_timeout是 60 秒。如果你的 SSE 在这段时间内一个字节都没发Nginx 会认为连接死了直接断开。解决办法有两个服务端定时发心跳推荐// 每 15 秒发一个 SSE 注释行什么都不做就是保活 const heartbeat setInterval(() { res.write(: heartbeat\n\n); }, 15000); // 别忘了清理 req.on(close, () clearInterval(heartbeat));调大 Nginx 超时proxy_read_timeout 3600s;一句话Connection: keep-alive是 HTTP/1.1 时代的护身符HTTP/2 时代请优雅地舍弃它。4.4 三件套总结响应头作用常见坑Content-Type: text/event-stream让浏览器启用 SSE 解析器写错就完全不触发onmessageCache-Control: no-cache让中间层不缓存、不攒数据只加头不改 Nginx 无效Connection: keep-alive保持 TCP 长连接HTTP/2 下失效甚至被拒结论三行头是 SSE 能不能流起来的命根子。写对是及格理解才叫合格。五、30 行代码手撸一个 SSE 服务先看服务端。我们用 Node 内置的httpfs不引入任何依赖。server.jsconst http require(http); const fs require(fs); const server http.createServer((req, res) { if (req.url /) { // 首页读取 index.html 并流式返回 const readStream fs.createReadStream(./index.html); readStream.on(error, () { res.writeHead(500, { Content-Type: text/plain }); res.end(Internal Server Error); }); res.writeHead(200, { Content-Type: text/html }); readStream.pipe(res); } else if (req.url /stream) { // SSE 接口三大响应头一个都不能少 res.writeHead(200, { Content-Type: text/event-stream; charsetutf-8, Cache-Control: no-cache, no-transform, Connection: keep-alive, }); const words [你, 好, , , 欢, 迎, 了, 解, sse]; let index 0; const timer setInterval(() { if (index words.length) { clearInterval(timer); res.end(); // 数据发完关闭连接 return; } // ⚠️ 关键SSE 格式是 data: xxx\n\n res.write(data: ${words[index]}\n\n); index; }, 1000); } }); server.listen(3000, () { console.log(server is running on port 3000); });几个容易踩的坑我帮你标出来\n\n必不可少SSE 用空行来分隔每一条消息。只写一个\n浏览器不会触发onmessage。不要用res.end()提前关闭一旦断开浏览器会尝试重连反而乱套。Cache-Control: no-cache必须加不然代理层会缓存响应你一个字都收不到。跨域要加Access-Control-Allow-Origin否则EventSource直接报 CORS 错误。index.html!DOCTYPE html html langen head meta charsetUTF-8 titleSSE Demo/title /head body h1SSE DEMO/h1 div idresult/div script const resultEle document.getElementById(result); // 浏览器原生 SSE 客户端EventSource const eventSource new EventSource(http://localhost:3000/stream); // 每当一个 chunk 到达触发 onmessage eventSource.onmessage (e) { console.log(e.data); resultEle.innerHTML e.data; }; /script /body /html跑一下node server.js打开http://localhost:3000你会看到你好, 欢迎了解sse一个字一个字蹦出来。就这么简单。六、EventSourceSSE 的官方客户端浏览器端的EventSource就是专门为 SSE 设计的用起来比fetch还傻瓜const es new EventSource(/stream); es.onmessage (e) { /* 收到数据 */ }; es.onerror (e) { /* 出错/断线 */ }; es.onopen (e) { /* 连接建立 */ }; es.close(); // 主动关闭几个细节值得注意自动重连连接断了浏览器会按默认 3 秒重连这也是为什么服务端要发完主动end否则浏览器会一直尝试重连。只能 GETEventSource 不支持 POST也不支持自定义 header。单向通信只能服务器推客户端。反过来推自己走fetch。自定义事件怎么玩服务端发res.write(event: tool_call\n); res.write(data: ${JSON.stringify(payload)}\n\n);客户端监听es.addEventListener(tool_call, (e) { console.log(工具调用:, JSON.parse(e.data)); });划重点SSE 不是只能给 LLM 用。股票行情、系统日志、任务进度、通知中心……凡是服务器持续有数据要推的场景它都合适。七、SSE 和流式输出是什么关系很多人搞混我帮你理清楚流式输出Streaming 一个概念 —— 数据边生成边发 SSE 一种实现 —— 基于 HTTP 长连接的服务端推送协议 EventSource 一个客户端 API —— 浏览器接收 SSE 的工具三者关系LLM 生成 stream ↓ Node 服务端用 SSE 格式把 token 一段一段写回 ↓ 浏览器 EventSource 收到逐字渲染大模型的stream: true就是这套链路的起点。八、进阶从流式文本到结构化输出流式输出解决了体验问题但工程上还有下一个痛点我怎么知道这段文本是 JSON 还是自然语言我怎么在流式过程中解析它这就要聊到结构化输出Structured Output了。场景一流式返回 JSON大模型边吐 JSON你边JSON.parse肯定炸因为 JSON 还没吐完。常见做法标记法让模型用START...END包裹 JSON流式先缓存遇到END再解析。JSON Schema 约束用 OpenAI 的response_format: { type: json_schema }或兼容实现让模型保证输出合法 JSON。增量解析器引入partial-json、jsonrepair之类的库边流边修补。场景二流式 工具调用Function Calling模型可能先吐一段文字再吐一个tool_calls结构。这时候服务端要做的是转发原始 SSE 事件别自作主张合并客户端按event字段区分event: message/event: tool_call// 服务端更接近生产的写法 res.write(event: message\n); res.write(data: ${JSON.stringify({ content: chunk })}\n\n);EventSource端就能用es.addEventListener(message, ...)和es.addEventListener(tool_call, ...)分别处理。金句流式输出是过程结构化输出是契约。前者让用户爽后者让程序稳。九、踩坑清单建议收藏坑现象解决只写\nonmessage不触发必须是\n\n没设no-cache收到的数据和预期不一致加Cache-Control: no-cache, no-transform前面有 Nginx数据被缓冲逐字变一次性Nginx 加proxy_buffering off;proxy_cache off;长连接 60s 后被断流式莫名中断服务端 15s 发一次: heartbeat\n\n响应没 end浏览器一直重连发完主动res.end()EventSource 跨域CORS 报错服务端加Access-Control-Allow-Origin\r\n混用部分客户端解析异常统一用\n\n分隔HTTP/2 带Connection某些网关拒绝请求HTTP/2 下删掉Connection: keep-alivegzip 压缩流式变成一次性加no-transform或对 SSE 关闭 gzip十、总结最后用三句话收个尾流式输出不是性能优化是体验优化——把等待变成过程。SSE HTTP 长连接 text/event-streamdata: xxx\n\n——三行响应头每一行都有它的使命。EventSource 是浏览器最省心的 SSE 客户端但只支持 GET、只支持单向推送。如果你正在做大模型应用、实时日志、进度条推送SSE 绝对值得你花半小时彻底搞懂。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价