SSE流式输出实战:解决LLM前端实时通信与超时问题

发布时间:2026/9/12 13:16:57
SSE流式输出实战:解决LLM前端实时通信与超时问题 做LLM应用的前端尤其接RAG、Agent这类带“思考过程”的接口时你会发现一个逃不掉的坎后端明明在持续输出token前端却像卡死一样只能等结束。这时候你查日志大概率会看到一行 “before completion: idle timeout waiting for sse”。这行字背后恰恰藏着前端实时通信里最容易被忽略的一种姿势——SSEServer-Sent Events。这篇东西不讲大而全的实时通信只聚焦两件事搞懂SSE到底是啥以及怎么用它把大模型的流式输出真正接到前端页面里。我尽量把协议细节、前端解析、服务端透传、鉴权方式、踩坑记录一次说透适合正在做LLM应用前后端联调、或者刚接触流式交互想系统了解一下的开发者。1. 为什么LLM流式输出绕不开SSE先把两种姿势摆上桌1.1 两种实时通信姿势的底层区别前端实时通信这个词看着高端拆到浏览器层面无非就几条路短轮询、长轮询、WebSocket、SSE。短轮询最简单setInterval一分钟打十次接口实时性靠脸皮厚硬撑服务端压力大响应慢的时候体验很差现在基本只在低频场景用。长轮询是短轮询的改进版请求挂住不返回等有数据了再响应但连接频繁重建写起来也不清爽。剩下两条正路就是 SSE 和 WebSocket。WebSocket大家听到得多全双工服务端能推客户端也能随时发适合聊天室、协同编辑、游戏对战这类双向高频交互。SSE则完全反过来协议极其克制只会从服务端往客户端推数据客户端想发消息得走普通HTTP接口两条通道各干各的。LLM 流式输出恰恰就是一个标准的服务端单向推送场景。用户发一句话大模型在服务端一个token一个token地生成前端要做的只是不断地接收、展示。中途不需要往回发任何东西最多发一个“停止生成”的指令这个用普通POST甚至AbortController就能解决。你用一个全双工的WebSocket去跑一个纯下行任务等于开着一辆四驱越野车在漆面平整的赛道上遛弯不是不行是没必要。SSE与WebSocket对比维度SSEWebSocket通信方向服务端到客户端单向全双工承载协议普通HTTPWS/WSS断线重连浏览器内置自动需手动实现自定义HeaderEventSource受限fetch模式可用支持二进制消息不支持支持连接数占用占用一个HTTP连接占用一个TCP连接且有并发数限制实现复杂度服务端简单前端有原生API两端都要处理握手、心跳、粘包1.2 为什么LLM场景更偏爱SSE我在把大模型接进前端项目之前也一度觉得WebSocket才是规范做法。真做了一遍才发现SSE在这个领域反而是更顺手的方案原因可以拆成四点。第一它跑在HTTP上整个链路里所有中间件、网关、CDN、日志系统都不用额外适配。WebSocket是独立协议反向代理要配升级规则负载均衡要处理长连接混合云环境下排查问题多一层复杂度。SSE从客户端到服务端全程就是一个普通GET/POST请求后端框架天然支持出了问题用浏览器开发者工具里的网络面板就能直接看到完整响应流调试体验差距巨大。第二SSE协议本身自带重连机制。EventSource只要连接断开浏览器会按retry字段指定的间隔自动重连还能通过Last-Event-ID把断点续传的游标带回去。这些能力在原生WebSocket里一个都没有全得手搓而且手搓的可靠性大概率不如浏览器内置实现。第三LLM流式输出的数据格式天然是文本。SSE的消息格式本来就是按文本行组织的每条消息以data:开头JSON直接放进去就能用。Agent场景里要区分思考、答题、工具调用等多种事件协议里也预留了event字段一行addEventListener就能分别处理语义非常贴合。第四兼容性比想象中好。现代浏览器都支持EventSource老一点的环境用fetch自己读ReadableStream本质也是SSE只是把“协议内置”换成了“手动解析”兼容范围一下铺开很多。2. 动手前必须吃透的SSE协议细节2.1 SSE消息格式深度拆解SSE标准定义的Content-Type是text/event-stream消息体不是JSON而是一种按行分隔的文本格式。每个字段一行字段名冒号空格字段值一条事件用两个换行符结束。最常见的是data字段长这样data: {content:你好}服务端往响应流里写一行data:后面跟一段数据再写两个 \n浏览器端EventSource就会把它解析成一条message事件。如果一段数据太长可以拆成多行data标准规定这些行会被拼成一条事件data: {content:你 data: 好}解析出来是{content:你好}。这个特性在服务端切分文本时偶尔会用到不过平时我们直接一行一个完整JSON更省事。除了data还有event、id、retry三个可用字段。event指定本条消息的事件类型默认是messageid用于设置消息游标断线重连时会自动放进Last-Event-ID请求头里retry告诉浏览器重连的等待毫秒数。一个比较容易被忽略的细节是注释行以冒号开头的行比如: keep-alive浏览器会直接忽略。这玩意儿是当心跳包用的。很多代理服务器在连接空闲几分钟后会默默断开服务端只要每隔十几秒写一行注释就能让连接一直保持活跃。这个技巧在做LLM流式输出时尤其重要因为模型“思考”时间长的场景可能出现十几秒没有任何data输出中间代理就把流掐断了错误信息就是那句“before completion: idle timeout waiting for sse”。2.2 EventSource的便利与局限前端直接用SSE最省事的办法是new EventSource(url)。默认它用GET请求建立连接onmessage接收数据断线自动重连都是现成的const source new EventSource(/api/llm-stream?id123); source.onmessage (event) { const data JSON.parse(event.data); renderToken(data.content); }; source.onerror (err) { // 连接异常浏览器会自动重连 console.error(SSE connection error, err); };对应地关闭连接调用source.close()就行。但是EventSource有两个天生限制在LLM项目里经常卡脖子。第一是只能用GET方法请求体啥都带不了所有参数都得拼在URL上。如果每次对话要传一长串上下文消息URL会被撑爆而且数据明文暴露在日志里。第二是没法自定义请求头这就导致Authorization这类鉴权头很尴尬。解决方向基本有两个用Cookie让浏览器自动带上或者干脆不走EventSource改用fetch手动解析流。第二个方向更通用因为LLM平台对外提供的API大多是POST接口前端接入时直接调原生fetch把场景彻底打开了。3. 从零实现LLM流式输出的完整链路3.1 前端用EventSource写一个最小可用的流式对话先看一个标准GET场景。比如对接一个自建的服务把对话ID拼在URL上function createSSEConversation(conversationId) { const source new EventSource(/api/chat/stream?conversation_id${conversationId}); source.addEventListener(message, (e) { const payload JSON.parse(e.data); if (payload.done) { source.close(); return; } appendToChatBox(payload.delta); }); source.addEventListener(tool_call, (e) { const tool JSON.parse(e.data); renderToolCall(tool); }); source.onerror () { // 在真实的网络环境里断线并不罕见EventSource自动重连 // 这里只需要在UI上给出提示即可 showReconnectingToast(); }; return source; }这个例子里可以看到event字段的价值普通文本走messageAgent调用工具的信息走tool_call前端各处理各的不用在一个事件里做嵌套判断。3.2 前端用fetch接管POST、鉴权和流式解析当后端接口要求POST方法并且需要Authorization头时EventSource就不够用了。这时候用fetch getReader()手动读流async function streamChat(messages, token, onEvent) { const response await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${token}, }, body: JSON.stringify({ messages, stream: true, }), }); if (!response.ok) { // 这里要单独处理HTTP错误否则下面的read会直接抛错 const errText await response.text().catch(() ); throw new Error(HTTP ${response.status}: ${errText}); } const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { value, done } await reader.read(); if (done) break; // 用TextDecoder的stream模式做增量解码后面会细说 buffer decoder.decode(value, { stream: true }); // SSE消息以空行分隔 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 raw line.slice(5).trim(); if (raw [DONE]) { onEvent({ type: done }); return; } try { const payload JSON.parse(raw); onEvent(payload); } catch (err) { console.warn(parse failed:, raw, err); } } } } } }有几个点值得单独解释。第一buffer decoder.decode(value, { stream: true })这一行看着普通实际是中文流式输出不出乱码的关键。网络流不保证按字符边界分包UTF-8编码的中文字符可能被从中间拆开前半段在这次的chunk里后半段在下次。如果用普通的TextDecoder往上接那个被劈成两半的字符就会直接解析成乱码。加stream: true之后解码器会把不完整的字节序列缓存起来凑齐一个完整字符才输出。项目里凡是做过SSE中文显示的人基本都在这个点上栽过跟头。第二按\n\n切分消息块是因为SSE事件的标准分隔符是空行。有些后端实现不规范只用一个\n前端解析时可以做兼容但最好还是推动后端按标准格式输出不然不同浏览器下的表现可能不一致。第三遇到done或者[DONE]要主动return不要等到连接自然断开。LLM流式输出正常的结束方式是服务端发完最后一个事件后关闭响应流。如果用EventSourceonmessage里收到done时调close用fetch就break掉循环。不处理的话连接会一直挂着直到服务端超时断开容易在页面上残留一堆因为连接关闭触发的错误回调。3.3 服务端Node.js打通SSE输出链路前端写完还得有一个能对接的服务端。用Node.js Express写一个最小SSE接口核心是设置正确的响应头然后向res对象持续写入SSE格式的文本const express require(express); const app express(); app.post(/api/chat/stream, async (req, res) { // 前面可以把 req.body 里的 messages 转发给大模型接口 const modelStream await callLLM(req.body.messages); res.writeHead(200, { Content-Type: text/event-stream; charsetutf-8, Cache-Control: no-cache, no-transform, Connection: keep-alive, X-Accel-Buffering: no, // 针对Nginx的反向代理缓冲关闭 }); res.flushHeaders(); for await (const chunk of modelStream) { const payload JSON.stringify({ content: chunk.text, done: false }); res.write(data: ${payload}\n\n); } res.write(data: ${JSON.stringify({ done: true })}\n\n); res.end(); });这个写法里有一个容易被忽略但影响巨大的隐患中间的代理缓冲。如果前端请求要先经过一层反向代理代理默认可能会做响应缓冲也就是等上游输出攒到一定量再一起转发给客户端。这会导致流式输出变成一坨一坨地出严重时直接卡住直到超时体验非常糟。解决方式是在SSE接口的响应头里显式关掉缓冲Nginx环境是X-Accel-Buffering: no还有些代理服务器认X-Content-Type-Options或者Cache-Control: no-transform。配合flushHeaders()强行把响应头先刷出去浏览器才能第一时间建立SSE连接。另外连接断开时服务端也要有感知。前端关闭页面或者主动取消req的close事件会触发这时候应该终止所有正在进行的生成任务释放资源req.on(close, () { // 终止大模型请求清理定时器等资源 modelStream.destroy?.(); });不处理这个事件的话前端走了服务端还在傻乎乎地生成token写响应浪费算力不说日志里还会出现一堆EPIPE错误。3.4 历史消息与全局上下文想清楚State管理SSE接入LLM后很多前端新手会犯一个错误直接把流式输出target到当前这轮对话的DOM上一轮结束后就什么都不管。等到要做多轮对话时才发现上下文全丢了。正确的做法是让每个web端对话页维护一个状态对象包括messages数组、当前流式渲染的buffer、用户输入的引用等。流式输出过程中每收到一个delta就往当前buffer里追加UI监听buffer的更新做渲染流结束把完整消息push到messages数组里buffer清空。这样对话历史始终是完整可回传的下次请求直接携带messages大模型才能有“记忆”。如果项目里用到RAG增强SSE在这个链路里还有一个额外价值它可以把检索阶段的中间状态实时推给前端。用户发问后模型检索知识库通常需要几秒钟如果前端白等用户会以为系统卡死了。通过SSE推送一条“正在检索知识库”的事件前端就能显示一个过渡状态体感会好很多。3.5 对接Dify这类LLM工具时SSE的格式兼容现在很多项目不直接裸调大模型API而是接Dify这类平台。Dify对外提供的流式接口本身就是SSE格式但它的消息体里除了token内容还会混入一些流程状态字段比如event: message、event: agent_message、event: message_end等。前端接这类接口时最好的姿势是不要只监听message事件而是根据event字段分派处理。可以看一下Dify返回的事件类型大致有节点开始、节点结束、消息生成、消息结束等。不同的event对应不同的渲染状态比如节点开始可以显示loading消息生成就逐字渲染消息结束则清掉loading并关闭流。这里有个经验对接任何现成的LLM平台时先打开浏览器开发者工具看Network面板里流式响应的原始内容。SSE的调试在DevTools里非常直观每一条事件都清清楚楚。先在原始数据层面确认事件类型和字段结构再写前端解析代码能省掉大量来回试错的时间。4. 容易踩的坑与排查技巧实录4.1 “idle timeout waiting for sse”到底是谁在超时这句错误通常不是浏览器抛的而是中间网关或代理服务器抛的。代理会为每条HTTP连接设置一个空闲超时时间如果在这个时间内连接上没有新的数据传输它就会主动断开连接。前端EventSource就表现为连接反复断开重连服务端日志看到客户端直接消失。大模型场景里出现这个提示的典型位置有两个模型思考时间过长以及Agent工具调用链路过长。这两个阶段都可能在几十秒内没有真正的文本输出连接在代理侧判定为“空闲”然后断掉。解决方案有几个层次服务端周期发心跳注释行比如每10秒写一个: keep-alive\n\n让连接一直有数据流动。调大代理服务器的空闲超时配置把代理的read timeout从30秒提到300秒。前端监听error事件时不要立刻报错因为EventSource会自动重连等重连成功后接着渲染即可。我自己排过不少这种问题最有效的一招是把服务端日志和前端EventSource的onerror一起看。如果前端不断重连、后端不断收到新请求大概率是代理层的空闲超时这时候先加心跳就行如果前端正常、后端也没断说明是某个中间节点把流吞了要重点检查缓冲策略。4.2 缓存与缓冲SSE最隐蔽的“假死”SSE接口的响应头如果没设置对可能被浏览器或中间的代理服务器缓存。现象是前端EventSource明明连上了但长时间收不到数据或者收到的数据是积压后一次性迸出来的“假流式”。HTTP缓存这块SSE接口必须加上Cache-Control: no-cache有条件再加no-transform。no-trransform的用途是禁止代理服务器对响应做任何形式的内容转换和缓冲在流式里非常关键。做过一次线上案例接口在公网演示时正常走到某些内部网络就变成等几秒然后一次性出全量结果。最后定位是内网网关上开了响应压缩和缓冲SSE的流式变成了“攒满一个压缩块再发”解决方式就是在响应头里同时禁用压缩和缓冲。顺带一提如果开启了GzipSSE的流式体验也会变差因为压缩本身就是成块处理的服务端最好不要对流式响应做压缩。4.3 EventSource无法带Header的鉴权困境怎么破EventSource用GET和标准请求头导致一个经典难题token放哪里。方案一放URL上。简单粗暴但token会进访问日志而且URL有长度限制聊几轮下来上下文全塞URL里不现实不推荐。方案二用Cookie。EventSource请求同源时会自动带Cookie服务端用会话Cookie来鉴权即可。缺点是要处理CSRF风险而且跨域时Cookie的SameSite策略容易被卡住。方案三就是前面讲的用fetch自己解析。fetch完全可以带Authorization头、走POST只要你手动解析流式响应。这个方案实际项目中用最多代价只是EventSource的内置自动重连需要自己补。方案三的自动重连逻辑可以简单实现定义一个connectChat函数内部用fetch建流出错时用setTimeout递归调用connectChat。递归前先检查一下是否有AbortController取消过避免用户主动关闭后还在无限重连。4.4 并发、取消与错误恢复这些细节一起到位才算稳浏览器对同一域名的HTTP/1.1并发连接数有硬性限制一般是6个。EventSource会长期占用其中一个如果页面还开着多个SSE连接再加上轮询等请求很容易把连接池占满导致其他接口全部排队。解决方向是控制同时存在的SSE连接数量页面跳转或组件卸载时务必close掉。取消生成是另一个高频场景。用户点击“停止生成”时EventSource可以调closefetch方式可以调abortController.abort()。需要注意的是abort之后前端要清理状态把当前半截内容保留还是丢弃得看产品需求但至少不能让状态停留在“正在生成”的loading里。错误恢复方面网络抖动、服务端重启都会导致SSE连接中断。EventSource自带重连fetch方案需要自己写重试逻辑。重试时要注意带上Last-Event-ID或有会话ID保证服务端能知道是从哪里断的避免重复渲染已经渲染过的内容。最后补一个并发场景的坑如果短时间内发起多个对话请求服务端可能同时开启多个大模型调用。不是所有的模型API都支持高并发有些免费的或受限的模型会对调用频率做限制表现就是前几个正常后面突然开始报4300或者限流错误。前端需要在请求层做并发控制比如一个对话会话同时只允许一个流式请求在跑切换会话前先取消旧请求。常见问题速查现象可能原因排查方向连接反复断开重连代理空闲超时、无心跳服务端加注释心跳调大代理超时内容一次性全出代理缓冲、响应被压缩加X-Accel-Buffering: no禁用压缩中文偶尔乱码分包导致字符截断TextDecoder加stream模式EventSource收不到自定义事件事件名监听错误addEventListener(工具名)而不是onmessage页面卡顿、接口排队连接池被SSE占满及时close控制并发点击停止后仍继续生成未调用abort用AbortController控制fetchHTTP 401鉴权失败EventSource不能带Header改用fetch或Cookie方案写在最后的一点体会我实际做LLM前端接入时第一版老老实实用的WebSocket后来因为网关、心跳、粘包等问题调试了整整一天。换成SSE之后代码少了三分之一问题排查反而变得透明了浏览器DevTools里能看到完整事件流每一个数据块的时间点都清清楚楚。从此我自己的项目里凡是纯下行推送的实时场景一律优先SSE这也是这篇文章想传递的核心取舍思路——不是WebSocket不够好而是SSE在很多场景下是更匹配需求的那把钥匙。另外想强调一个习惯拿到任何一个大模型流式接口先用curl或者Postman看原始返回确认事件字段的分隔符、结尾标记、错误码格式再动前端代码。大多数联调问题根源都在对原始协议形态的想象和实际不匹配上。把这个习惯养成之后你再回头看“before completion: idle timeout waiting for sse”这种报错就会自然地想到心跳、缓冲、代理三层问题逐个排查就很流畅了。