SSE (Server-Sent Events) 在大模型流式输出中的实践与排错指南
先抛一个很现实的问题你打开某个大模型聊天页面输了一段话点击发送之后如果页面要空白转圈十几秒才一次性吐出一整段文字你还愿意用它吗大概率不会。大模型的推理过程天然是“边想边写”一个完整的回答往往要生成几百上千个token花掉几秒甚至几十秒。如果让用户对着空白页面干等体验基本是灾难性的。所以你会发现现在主流的大模型应用几乎无一例外都在用流式输出。而它们底层依赖的传输手段绝大多数情况下就是SSE——Server-Sent Events服务端推送事件。这篇文章我会从协议本身的机制讲起再结合真实对接大模型流式接口的实践经验把SSE的字段格式、断线重连、消息分帧、代理层干扰这些点一次说清最后附上一份生产环境排错清单。如果你正在做AI应用的前后端对接或者单纯想搞明白“为什么大模型接口都是一个字一个字往外蹦”这篇文章应该对你有用。1. 为什么大模型应用都在抢着做“流式输出”1.1 先回到用户视角等消息的十几秒是怎么被消化的大模型生成回答这件事和传统接口返回数据有本质区别。传统接口哪怕再慢数据库查个几百毫秒最多两三秒也就返回了。大模型的推理却是一个“自回归”过程模型每预测一个token都要把前面生成的内容重新作为输入再算一遍这叫逐token生成。一个400字的回答按中文平均每token对应一到两个字来估算可能要生成200到400个token。就算模型推理速度很快整个回答的完整生成时间也往往要几秒到十几秒。如果采用最朴素的HTTP请求-响应方式前端发出一个请求后端等大模型完整生成完整个回答再一次性把全文返回给浏览器那这十几秒里用户能看到的只有一个转圈儿的loading。这不仅仅是“看着着急”的问题。用户等待超过一定时间就会怀疑系统是不是挂了甚至会刷新页面重试结果浪费了算力和请求额度交互上也完全失去了“对话感”。SSE解决的就是这个“一边生成一边发送”的需求。后端每生成一小段内容就立刻通过一条已经建立好的HTTP长连接推送到前端前端收到增量就马上渲染出来。用户看到的是文字一行一行、一段一段地“打”出来响应速度和思维速度对上了等待焦虑自然就消失了。1.2 流式输出带来的额外产品价值除了体感上的改善流式输出的意义其实比“让页面不那么无聊”要深得多。我接过大模型应用之后才发现流式输出能直接决定产品形态能不能成立。最典型的是“边推理边展示”的效果。现在很多AI搜索产品会在模型生成回答的同时把思考过程、引用的资料片段、关键结论都按增量推送出来。用户能看到系统正在检索什么、正在论证什么这种透明度会显著提升用户对结果的信任度。这些能力全部依赖流式输出——你不可能等全部分析完成再一次性把“推理过程”和“答案”一起抛出来那样长连接的意义就没了用户也等不了。流式输出还有一个容易被忽略的价值成本控制。因为回答是一段一段生成的用户如果发现模型理解偏了可以提前停止生成前端关闭连接以后后端的生成任务也随之终止省下了后续token的算力开销。这在批量调用大模型API时尤其重要直接关系到账单上的数字。所以说SSE在大模型应用里不只是“体验优化”它本身就是产品设计的一部分。2. 方案横评轮询、WebSocket、SSE大模型流式输出到底选哪个2.1 四种可能的实时推送方案一次性说透在做流式输出之前你得先理解一个前提前端到底有哪些方式可以“被动”拿到服务端主动推送的数据。并不是只有SSE这一种业内常用的方案还有短轮询、长轮询、WebSocket逐个看一遍你才能理解为什么最后大模型场景普遍选了SSE。短轮询是最笨的办法——前端用setInterval每隔几秒发一个普通HTTP请求问“有新数据吗”。没有就返回空有就拿走。这种方式实现简单但对服务器压力很大而且数据实时性最好也只有“轮询间隔”那么大。对token级别的流式输出来说几秒一刷完全不可接受。长轮询是短轮询的改良版前端发一个请求服务器先不返回等有新数据了才响应响应后前端立刻再发下一个请求形成一种“伪长连接”。它比短轮询实时性好但仍然存在“每次请求都要重新走一遍HTTP握手”的额外开销而且在大流量下连接管理很复杂容易出bug。用来做IM消息推送还行做大模型逐token推送的话每一段都要重建请求太浪费。WebSocket就不用多说了它是真正意义上的全双工通信客户端和服务端可以双向、实时地互发数据。但WebSocket的问题是协议独立于HTTP需要额外握手需要服务端做协议升级需要应对代理、负载均衡、防火墙的兼容问题开发和运维成本都明显比SSE高。如果我只是想让服务器单向地把一串文字推到前端杀鸡用牛刀了。最后是SSE。它是建立在HTTP之上的“服务端单向推送”协议。浏览器用EventSource这个原生API就能直接消费服务器不用引入新的协议栈只要在普通HTTP响应里按SSE规范输出文本就行。不支持双向通信但大模型对话场景压根不需要双向实时通信——客户端只发一条请求剩下时间全是服务器在说话。这就够了。2.2 为什么SSE和大模型对话场景是“天作之合”我把几种方案放在一起对比过结论非常明确短轮询/长轮询实时性差、请求开销大、token粒度不可控WebSocket实时性和双向通信都好但协议复杂度、代理兼容性、运维成本都更高SSE单向推送正中场景痛点基于HTTP天然穿透性好自带断线重连机制浏览器原生支持。在实际对接过程中你还会发现SSE有一个WebSocket很难比的优势它长得就是“一段带格式的文本流”。你可以直接在curl命令里看原始返回可以用任意语言拼字符串可以在网关日志里直接看到内容调试链路非常透明。WebSocket的数据是二进制帧代理层和日志系统天然没那么友好。对于开发者来说这种“肉眼可见”的特性在排错时加分太多。当然如果你的大模型应用需要做“对话中断后由客户端主动续传上下文”这类交互SSE的单向性也不妨碍——客户端要发新请求直接再开一条新的SSE连接就行不需要像WebSocket那样在同一条连接里维护消息ID匹配的状态。SSE在这里的哲学是“每条连接只干一件事”简单到不容易出错。3. SSE协议核心细节从Content-Type到断线自动重连3.1 一条最基础的事件流长什么样SSE的协议规范其实非常简洁。它的核心机制就是把服务器响应定义为text/event-stream这种MIME类型然后响应体里按特定格式写下一行行文本浏览器拿到后会按格式解析成事件触发回调。一个最基本的SSE响应长这样Content-Type: text/event-stream data: 你好 data: 我是AI助手看着是不是有点像换行符分隔的文本对SSE的“帧”就是靠空行来分割的。每两行空行之间算一个事件以data:开头的行表示事件的数据内容。上面这个例子里服务器向客户端推送了两条事件“你好”和“我是AI助手”。浏览器收到后每条事件都会触发一次message回调。这里有个细节容易忽略每条data:后面的空格协议规定可以省略。也就是说data:你好和data: 你好效果一样。但如果你写data: 你好前面有两个空格那解析出来的数据就会把多余的空格保留下来造成文本开头莫名多一个空格。实践中我建议统一用data:加一个空格这个格式后端模板拼接时也固定这个写法避免理解偏差。3.2 data、event、id、retry、冒号注释——五个字段的用法SSE事件的完整格式除了data之外还有四个关键字段完整拼起来是这个样子event: message id: 1 retry: 3000 data: 第一段内容逐个拆开看event:指定自定义事件类型。默认情况下服务器推送的任意内容都会被当成message事件处理。如果服务器想推送一种特殊类型的数据比如“错误信息”“结束标记”“用户ID更新”等就可以在事件前面加event: xxx浏览器端需要用addEventListener(xxx, handler)来监听对应的事件而不是通用message。大模型接口里最常见的自定义事件就是[DONE]结束标记很多平台会把它包装成一个特殊事件。id:给事件设置一个事件ID。这个ID在断线重连时非常关键浏览器会把最后一次收到的事件ID发送给服务端以便服务端决定从哪里续传。实践中最好给每个事件都递增生成一个ID这样断线恢复的粒度就能做到“从最后一条开始”而不是从头重来。retry:告诉浏览器断线后隔多少毫秒重连一次。不写的话浏览器默认是3秒。你可以根据业务需要调比如大模型输出过程中网络抖动频繁就把重试间隔调成1秒如果不想频繁重连打爆后端就调到5秒以上。冒号开头的是注释行。比如: heart-beat这样的行浏览器会直接忽略它。注释在SSE里的一个实用价值是“保活”——如果服务器没什么数据可发就每隔十几秒发一行注释保持连接不被中间代理掐断。很多网关对HTTP连接有空闲超时不活跃超过一定时间就会断开注释行就是最便宜的心跳包。3.3 断线重连是怎么回事SSE的自动重连机制是我个人认为它比WebSocket更合适大模型场景的重要原因。浏览器原生的EventSource在连接断开之后会自动发起重连不需要你写一行重试代码。这个机制如何运作的值得拆开看。当EventSource连接断开时浏览器会记录当前的连接状态然后等待一个时间间隔默认3秒可以由retry字段覆盖再重新发起HTTP请求。重连的请求里会带上Last-Event-ID这个请求头值是当前连接里最后一次收到的SSE事件的id字段。服务端读到这个请求头后就能知道客户端已经收到哪一条事件了然后选择从下一条开始推送。这个机制在实际大模型流式接入中尤其重要。如果你对接的上游大模型接口非常慢某个轮次的回复生成了2分钟这期间用户切走了页面再切回来或者网络发生过短暂断连浏览器就会自动重连并把Last-Event-ID带给后端。后端如果做了缓存就可以把之前的token发完再接着输出用户几乎无感。要注意的是EventSource原生的重连是“从服务器重新开始连接”的逻辑不是“从断点精准续传”。要实现真正的断点续传协议本身只负责提供Last-Event-ID存储和续传逻辑必须你自己在后端实现。很多人以为SSE自带“续传”这是个常见的误解。3.4 HTTP/1.1分块传输与HTTP/2多路复用下的SSE还有一个底层机制需要了解SSE在HTTP/1.1里依赖的是分块传输编码chunked transfer encoding。因为SSE的响应是“永远不断开的流”服务器不可能预先知道Content-Length所以HTTP/1.1下必须用Transfer-Encoding: chunked把数据分成一块一块发出去。你会在Chrome的Network面板里看到SSE请求的响应头里有Transfer-Encoding: chunked这是正常的不是异常状态。到了HTTP/2就更好办了。HTTP/2天生支持多路复用同一域名下的多条SSE连接可以共享一个TCP连接双向传输还不需要分块传输编码。浏览器在HTTP/2环境下跑SSE会有更好的性能和更低的握手开销。不过要注意一点HTTP/2的“帧级流控”在某些实现下会引发SSE数据被缓冲的问题如果发现HTTP/2下SSE的实时性和HTTP/1.1有明显差异可以手动把相关路径降级到HTTP/1.1验证一下这种案例在复杂网关后面确实遇到过。4. 实操在项目里真正把大模型SSE用起来4.1 前端使用EventSource的完整示例前端消费SSE最省事的方式就是直接使用浏览器原生EventSource。用一个代码示例来看const es new EventSource(http://example.com/api/chat-stream, { // 注意EventSource 原生不支持自定义请求头只能通过 URL 传参 withCredentials: false }); es.addEventListener(open, () { console.log(SSE连接已建立); }); es.addEventListener(message, (event) { const data JSON.parse(event.data); if (data.text) { appendToPage(data.text); // 把增量文字渲染到页面上 } }); es.addEventListener(done, () { console.log(流式输出结束); es.close(); }); es.onerror () { // 连接异常时触发EventSource 会自动重连无需手动处理 console.log(SSE连接异常等待自动重连...); };这里有几个实操注意点想强调。EventSource的onmessage和message事件只能处理不指定event类型的数据如果后端在SSE流里发了一个event: done的事件前端必须用addEventListener(done)才能接到不能只靠message回调。很多时候你发现页面输出莫名其妙停了但连接一直没关多半就是这种自定义事件没接住。另外一个坑EventSource原生构造函数只支持GET请求不支持POST。如果大模型接口要求用POST传用户消息直接用EventSource是行不通的。要么把消息塞进URL query参数里让后端读要么走下面要讲的fetch方式。4.2 需要自定义请求头时怎么办fetch ReadableStream真实项目中大模型API往往需要POST请求体、自定义鉴权头这些是EventSource原生做不到的。这时候更通用的做法是用fetch配合ReadableStream手动解析SSE流。代码也很直观async function chatStream(prompt) { const response await fetch(http://example.com/api/chat, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer your-token }, body: JSON.stringify({ prompt }) }); const reader response.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 blocks buffer.split(\n\n); buffer blocks.pop(); for (const block of blocks) { for (const line of block.split(\n)) { if (line.startsWith(data:)) { const data line.slice(5).trim(); if (data [DONE]) { console.log(输出结束); return; } const parsed JSON.parse(data); handleToken(parsed); } } } } }为什么要把value先转成字符串再手动切块因为ReadableStream的read方法是按“字节块”返回的一块数据里面可能包含半条SSE事件也可能包含好几条完整的SSE事件。如果在解码后立刻按行处理就会遇到“JSON解析失败”这种莫名其妙的报错——其实不是后端数据有毛病是你在字节边界上切断了JSON字符串。用buffer保存一段尾部数据等下一块数据到来了再拼接这种做法被叫做“缓冲拼接”我在解析所有流式协议时都会用到。关于TextDecoder的参数{ stream: true }也提醒一下。不加这个参数如果某一块的UTF-8字节恰好跨块截断了比如一个中文汉字三个字节被切成了21解码就会产生乱码字符而且这种乱码在后续块到了之后也不会自动被修正。加了{ stream: true }解码器内部会保留未完成的字节序列等下一块来了一并解码这个细节对中文SSE流尤其重要。4.3 后端如何把一个流式接口转成SSE事件流后端在你的应用里其实扮演的是一个“代理格式转换层”的角色。你的后端从上游大模型API拉取流式响应解析出增量token再封装成标准SSE格式推给你自己的前端。这里我用一个Node.js的HTTP接口做例子展示完整的转发逻辑// 使用 Express 框架 app.post(/api/chat-stream, async (req, res) { // 1. 设置SSE响应头 res.setHeader(Content-Type, text/event-stream; charsetutf-8); res.setHeader(Cache-Control, no-cache, no-transform); res.setHeader(Connection, keep-alive); // 关闭 Node 的默认缓冲保证数据能即时发出去 res.flushHeaders(); // 2. 调用上游大模型流式接口 const upstreamResponse await fetch(http://remote-llm-api.example.com/v1/chat, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer process.env.UPSTREAM_API_KEY }, body: JSON.stringify({ prompt: req.body.prompt }) }); if (!upstreamResponse.ok) { res.write(event: error\ndata: ${JSON.stringify({ error: upstreamResponse.statusText })}\n\n); res.end(); return; } // 3. 读取上游数据流按行解析转换成SSE格式 const reader upstreamResponse.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 lines buffer.split(\n); buffer lines.pop(); for (const line of lines) { if (line.startsWith(data:)) { res.write(data: ${line.slice(5).trimEnd()}\n\n); } } } // 4. 正常结束后发送结束事件 res.write(event: done\ndata: [DONE]\n\n); res.end(); });很多人写这个代理层的时候会犯一个错直接拿上游返回的原始流打给前端。其实不是不行但上游的数据格式不一定是标准SSE可能是几行JSON混合了一些注释文本或者包含你自己的代理层不该暴露的字段。更好的做法是后端把上游数据解析好筛选出必要的token增量再用标准的SSE格式重新封装。这样前端无论对接哪家大模型要处理的协议永远只有一套换上游模型厂商的时候前端代码不用动。4.4 解析增量token时的一个常见误区在对接主流大模型API时你会发现上游返回的流式数据是“多个事件块”每个块里带有完整的增量JSON而增量JSON本身可能包含多种字段。最常见的字段就是delta.content表示这一轮新生成的文本片段。我见过很多初学者在处理增量时直接把整个JSON字符串渲染到页面上结果月亮的文字边上冒出一堆结构括号。正确做法是先识别字段结构再提取文本片段。比如const obj JSON.parse(data); const delta obj.choices?.[0]?.delta?.content || ; if (delta) { appendText(delta); }另外一个常见的误区是“增量vs全量”。大模型流式接口返回的是增量——每一段只包含新生成的那几个字不是把前面的所有内容重复一遍。如果你不小心把每次收到的增量追加到变量A里又把变量A整体渲染一遍页面上的文字就会像复读机一样越叠越多。正确逻辑是维护一个“已展示文本”的变量但渲染只在append位置做原地追加不重新渲染全部内容。这个设计上的差异在实现“打字机效果”时感受最明显。5. 踩坑清单SSE在生产环境里的问题排查实录5.1 问题速查表这一节我整理了自己在生产环境踩过和帮别人排查过的典型问题按症状、可能原因、解决办法整理成表格方便你直接查阅。现象可能原因排查与解决前端迟迟收不到第一条数据代理/网关缓冲了响应体还没把第一个chunk转发过来在Nginx等反向代理里关闭proxy_buffering把X-Accel-Buffering: no加到响应头流输出一会儿就断了页面卡住HTTP空闲超时中间层把没数据的连接断掉了用retry: 15000降低重连频率服务端定期发注释行保活建议每15秒发一次后端日志显示数据发了前端就是没反应响应头没正确设置或Nginx把text/event-stream当作普通响应缓存了用curl直接请求检查返回的Content-Type是不是text/event-stream确认Cache-Control为no-cache文本中间出现乱码字符像“锟斤拷”TextDecoder没有用{ stream: true }跨块解码或者源内容编码不是UTF-8统一UTF-8解码时传入{ stream: true }上游返回GBK内容时先转码断线后重连内容从头开始用户看到重复输出没有实现基于Last-Event-ID的续传逻辑后端为每个事件生成递增id断线重连时读取Last-Event-ID从下一条开始推前半段正常拿到一半数据后超时挂起代理的read timeout太小长连接被误判为超时调大proxy_read_timeout或者确保每15秒内至少有一条心跳数据使用EventSource时报405前端用了GET后端路由要求POSTEventSource只支持GET自定义POST请用fetch ReadableStream解析请求直接报错且无法重试API网关对URL长度有上限消息超过了query参数限制改用POST转发或者把消息放到请求体里再配合fetch读取SSE流这张表基本覆盖了我遇到的大多数SSE接入问题下面挑几个展开细说。5.2 排查方法和验证命令SSE排错的第一步永远是“忽略前端直接用curl看原始响应”。我强烈建议你在写前端代码之前先用下面这个命令验证后端curl -N --no-buffer http://example.com/api/chat-stream \ -H Accept: text/event-stream \ -H Authorization: Bearer your-token \ -X POST \ -d {prompt:你好}注意这里的-N和--no-buffer它们的作用是禁止curl对输出做缓冲让数据一到终端就立刻显示出来。如果不用-Ncurl会在一段数据攒够之后才一次性显示看起来就跟“不是流式”一样造成误判。curl验证通过之后再开浏览器的开发者工具切到Network面板找到对应的请求点开查看两个地方一个是Response Headers里的Content-Type和Cache-Control确认服务器确实按SSE格式返回了另一个是Response Body或者用Preview逐段查看确认数据是一个事件一个事件往下的。如果Network面板显示响应被一口气缓存到了最后才出现说明中间代理层在搞鬼优先排查buf。还有一个非常实用的技巧在主流的浏览器里SSE请求在Network面板中会显示为一种持续pending状态这是正常的、符合预期的。很多新手看到这个请求一直pending就以为它挂住了这是不对的。只要status显示为200而且你看到响应内容在持续追加就说明SSE工作正常。真正的问题信号是请求瞬间就200返回结束了——那说明你收到的是一次性完整响应不是流式。5.3 安全性和入口设计的几个注意点安全这块虽然不直接影响“能不能跑通”但在生产项目里踩一次坑代价极高。最核心的一条永远不要让浏览器直接发请求到大模型上游API。你通过EventSource或者fetch让浏览器请求的应该是你自己后端的代理接口由后端保存上游API密钥。API密钥一旦暴露在浏览器端等于把账本开放给了所有用户这是事故级别的安全问题不是风格问题。如果确实需要精细化权限控制建议后端在返回SSE流之前先校验用户身份和调用额度再决定要不要代理到上游。也可以用短时效token方案客户端先用登录态换取一个1分钟有效的一次性调用凭证然后拿着这个凭证请求代理接口代理接口再凭一次性凭证换取真实的上游响应。不过这属于进阶设计了中小项目用后端统一鉴权就足够。还有一点SSE的URL里不要带敏感参数。因为EventSource原生只支持GET有些人会把Authorization: Bearer xxx直接拼到URL里比如http://example.com/api/chat?token****。SHA这种东西如果不存在还好一旦上了访问日志或者被引用token就泄露了。我的习惯是EventSource只用来请求无状态、无鉴权或短时效的接口需要带用户上下文和鉴权的一律走fetch的方式。6. 一点项目实战经验补充6.1 我在生产项目中形成的一些习惯做了几个大模型流式输出的项目之后我沉淀出几个会一直用下去的习惯顺手分享给你。第一个习惯是“先抓原始流样本再写解析代码”。每次对接新的上游接口我先用curl把完整原始的流式响应存到文件里再逐行阅读这个样本确认data块的结构、结束标记的形态、是否有异常空行。这个步骤能省掉后面80%的调试时间。很多人上来直接写前端解析逻辑结果被上游的不规则格式折磨一晚上。第二个习惯是“在SSE流里预留一个事件序号字段”。我会在后端封装SSE事件时给每个事件追加一个自增的id字段。这不仅让断线重连的续传变得清晰可靠也在排查问题时提供了很大的便利——你可以根据事件ID定位到具体是哪一段输出出了问题。第三个习惯是“始终保留一个非流式的降级开关”。不论前端还是后端我的代码里总会预留一个配置项如果SSE链路上某个环节的兼容性出了问题可以一键切回非流式接口先保证功能可用。流式不是一个非做不可的功能但它一旦坏了用户感知会非常明显。多一个开关多一条后路。6.2 流式输出的“后处理”环节别忽略最后分享一个很多人做完流式输出之后才发现的坑页面里的文字虽然是逐步打印出来的但用户在复制这段文字时会带上你渲染用的DOM元素痕迹比如多余的空白、换行错乱。如果你还要基于流式内容做字数统计、关键词高亮、摘要提取直接操作已经渲染出来的DOM会非常痛苦。更稳的做法是在后端或前端维护一个“最终纯净文本”的变量流式输出的过程中不断把增量token追加到这个变量里所有分析、统计、复制操作都基于这个字符串变量而不是依赖DOM内容。这样用户看到的是打字机动画内部拿到的却是干净完整的文本数据两者互不干扰。我做过的几个项目里凡是省掉这一步的最后都会在复制、搜索、统计这些边角功能上返工一遍。这笔账算下来不如一开始就做对。