SSE流式输出实战:从Spring Boot后端到前端解析与Nginx配置
搞SSE这个东西最初是因为手头一个AI对话类的Web项目后端大模型生成的文本必须像ChatGPT那样一个字一个字往界面上蹦。我第一反应是想上WebSocket但仔细一想这个场景根本不需要双向通信用户只会发一次问题剩下的全是服务器单向往客户端推数据。后来查了一圈SSEServer-Sent Events就是干这个的Spring Boot原生支持SseEmitter前端用fetch或者EventSource接一下就能用。整趟折腾下来我把后端怎么推、前端怎么接、Nginx怎么配、超时怎么规避这些环节都走了一遍也用curl验过流今天就把这套实战过程完整记录下来。如果你是做JavaWeb前后端分离项目的或者正在用Spring Boot给AI对话、消息推送、实时日志这类场景做流式接口这篇文章能帮你少踩不少坑。1. 项目概述SSE流式输出到底是什么为什么我最终选了它1.1 流式输出的业务场景从“转圈圈”到“逐字蹦”做Web后端的人对“请求-响应”这套模型再熟悉不过了浏览器发一个HTTP请求服务器憋半天把最终结果一次性吐回来。整个过程里用户只能看着进度条或者一个loading动效干等。这种体验在普通CRUD系统里还好但放到AI对话、报表生成、视频转码进度、日志实时查看这类场景里就非常糟糕。拿AI对话来举例大模型生成一篇几百字的回答可能要花十几秒钟。如果走传统请求响应用户点击发送之后界面就得白屏转圈十几秒然后突然蹦出一整段文字中间没有任何反馈。这对用户的耐心是极大的考验尤其是当生成时间超过十秒的时候很多用户会以为是系统卡死了直接刷新页面。换用SSE之后后端每次拿到大模型返回的一小段增量文本就立刻推给前端。用户体验变成了点击发送之后文字像打字机一样一个字一个字地蹦出来。虽然总耗时还是十几秒但用户知道系统在工作等待焦虑被大幅缓解。这个体验差异在AI应用里已经成为标配用户习惯了逐字输出再让他回到转圈等待的模式他是不愿意的。所以流式输出解决的核心问题就是长耗时任务的“感知性能优化”。它没有减少服务器的工作量也没有加快生成速度但通过把等待时间切碎、可视化让用户从被动等待变成了主动观察体验上完全是两个维度。1.2 SSE和WebSocket、轮询拉锯战的选型过程确定了要做流式接下来就是技术方案选型。我当时摆在面前的无非三条路短轮询、WebSocket、SSE。短轮询是最容易想到的前端每隔两三秒去拉一次接口看看有没有新数据。它的缺点是浪费大部分请求都是空转服务器压力大而且哪怕设置1秒轮询还是会存在数据延迟。更关键的是轮询的实时性上限就是轮询间隔做不到真正的“逐字推送”。WebSocket是另一个热门选项网上很多教程都在推。它确实支持全双工通信服务器和客户端可以随时互推消息实时性拉满。但WebSocket有个特点就是“重”首先它需要从HTTP协议升级成WebSocket协议走的是另一套握手流程其次它是双向的意味着客户端和服务器之间要维护心跳、处理连接状态复杂度比普通HTTP请求高不少。很多菜鸟项目把WebSocket拉进来之后还要引入专门的消息中间件来做在线管理一套下来重得不行。SSE的操作系统和浏览器兼容性现在已经很好几乎所有现代浏览器都支持EventSource接口。它的底层还是HTTP协议没有额外握手服务器往客户端单向推送。它的一个杀手级特性是自动重连——浏览器原生支持连接断了会自动尝试恢复还能通过事件ID告知服务器上次收到哪条消息服务器可以接着往后推。这个特性在做消息推送时极其省心。对比项短轮询WebSocketSSE通信方向客户端请求服务器响应全双工双向服务器单向推送协议基础HTTP独立协议需握手升级HTTP自动重连无需要自己写定时器无需业务层实现原生支持实现复杂度低高低实时性取决于轮询间隔最高高可逐条推送适用场景低频轻量查询实时交互型应用聊天、游戏消息通知、AI流式输出、日志推送我最终选SSE就是因为它恰好卡在我这个场景的舒适区里AI对话是单向输出、不需要客户端频繁发消息天然适合单向推送同时又想拥有逐字输出的实时体验还想省去WebSocket那套心跳和重连的复杂度。如果你做的也是这类“服务器单方向推消息”的业务SSE大概率是最短路径。2. 后端实战Spring Boot环境下的核心实现方案2.1 搭建基础环境与依赖引入后端我用的是Spring Boot 2.7.xJDK 8向上一点都行。SSE不需要额外引入什么第三方依赖Spring MVC框架自带的SseEmitter就是干这个的。你只需要保证项目里引入了spring-boot-starter-web即可。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId version2.7.18/version /dependency在Controller层返回类型直接写成SseEmitterSpring会识别它是异步返回值自动把请求挂起来连接不会关闭直到我们在代码里主动调用complete()方法或者超时。有一点需要注意如果你的项目里集成了Spring Security记得把SSE接口路径加入白名单或者在SecurityConfig里放行否则请求会被拦截在登录认证那层根本到不了Controller。我在第一次联调时就被这个问题坑了一次前端一直收到401排查半天才发现是Security把流式接口拦了。2.2 使用SseEmitter实现最简单的流式推送SseEmitter的使用套路非常固定大概是三步创建一个SseEmitter实例在一个独立线程里循环调用send()方法推送数据数据推完了调用complete()关闭连接。这里有个很关键的点send()方法只能从非请求线程调用。如果直接在Controller主线程里调用send()是推不出去的因为请求线程在返回SseEmitter对象后就被Spring释放了。所以必须用线程池或者Async注解开一个异步任务在后台线程里推数据。下面我写一个最基础的示例演示如何每隔一秒推送一条消息RestController RequestMapping(/api/sse) public class SseController { GetMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream() { // 设置超时时间为60秒也就是60秒不推数据就自动断开 SseEmitter emitter new SseEmitter(60_000L); ExecutorService executor Executors.newSingleThreadExecutor(); executor.execute(() - { try { for (int i 0; i 10; i) { emitter.send(SseEmitter.event() .name(message) .data(第 (i 1) 条数据)); Thread.sleep(1000); } emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; } }这个代码的流程很简单接收GET请求创建一个60秒超时的SseEmitter开线程跑一个10次循环每次send一条消息发完complete()。前端用EventSource就可以直接订阅。2.3 用线程池模拟真实业务的逐条推送上面的例子能跑通但有一个明显的问题每个请求都newSingleThreadExecutor()在高并发下会创建大量线程直接把服务器资源打爆。正规做法是把线程池统一管理起来或者直接用Spring的Async。我自己在项目里是配合ThreadPoolTaskExecutor使用专门抽一个线程池处理流式推送Configuration public class AsyncConfig { Bean(sseExecutor) public Executor sseExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(16); executor.setQueueCapacity(100); executor.setThreadNamePrefix(sse-push-); executor.initialize(); return executor; } }Controller改成注入这个线程池RestController RequestMapping(/api/sse) public class SseController { Resource(name sseExecutor) private Executor sseExecutor; GetMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream() { SseEmitter emitter new SseEmitter(60_000L); sseExecutor.execute(() - { try { // 模拟从某个数据源不断拉取数据推给前端 ListString dataList getDataFromDataSource(); for (String data : dataList) { emitter.send(SseEmitter.event().data(data)); Thread.sleep(500); } emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; } }线程池的参数需要结合业务量评估。核心线程数我一般设置成服务器CPU核心数的一半最大线程数视并发量上浮。SSE长连接是很“占连接”的因为每个连接要挂很久如果压测下来并发200那池子至少得能容纳200个任务。这块不能省宁可线程多一点也不能让线程池满导致请求排队。2.4 进阶结合异步任务实现AI agent风格的流式输出前端场景基本跑通之后我又做了当前最流行的AI agent风格的正向流式输出。也就是客户端发来一个POST请求携带用户的问题后端把这个请求转发给大模型或者一个中间服务拿到流式增量结果后再把增量内容逐个推给前端。这个场景下EventSource就撑不住了因为它只支持GET请求。所以接口改成POST前端用fetch配合ReadableStream来接收。PostMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chat(RequestBody ChatRequest request) { SseEmitter emitter new SseEmitter(180_000L); sseExecutor.execute(() - doChat(request, emitter)); return emitter; } private void doChat(ChatRequest request, SseEmitter emitter) { try { // 调用大模型接口拿到流式响应后回调逐段推送 aiService.streamChat(request.getQuestion(), delta - { try { emitter.send(SseEmitter.event().name(delta).data(delta)); } catch (IOException e) { throw new RuntimeException(e); } }); // 推完发送一个结束标记 emitter.send(SseEmitter.event().name(done).data([DONE])); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }这个模式的好处是完整的生成过程被平铺到一条HTTP长连接上客户端收到的每一条data都是一小段增量文本前端可以直接追加显示。我在实际调试中验证从大模型拿到第一个字到全部结束前端基本能保持300-500毫秒一次的推送频率观感已经非常顺滑。需要特别注意的是SseEmitter构造函数的超时时间。AI场景的推理过程往往比较长如果设置太短推理还没结束连接就断了。我实测上一般设置180秒到300秒相当于给大模型留足生成时间。如果你接入的模型特别慢可以把超时时间调更大5分钟也正常。3. 前端实战从fetch流式解析到界面渲染3.1 为什么我不推荐EventSource而是推荐fetch ReadableStream说到前端接收SSE很多人第一反应是EventSource。这个API用起来确实直观代码就几行const evtSource new EventSource(/api/sse/stream); evtSource.onmessage (event) { console.log(收到消息:, event.data); }; evtSource.onerror (err) { console.error(连接错误, err); };它内置了自动重连机制连接断开后浏览器会自动重新发起请求。但它的局限也很硬伤只能发起GET请求没法自定义Header。这意味着如果你想在请求头里带一个Authorization令牌做权限校验或者想通过POST把用户输入消息体发给后端它都办不到。现在主流的AI对话应用用户输入通常都放在POST请求的body里还可能要携带token。这种情况下EventSource直接出局只能用fetchReadableStream手动解析。用fetch接收流式响应本质是把响应体当做一个可读的字节流不断读取数据块再按SSE协议的格式去解析出data:字段。虽然代码量比EventSource大一些但胜在完全可控能自定义请求头、能POST、能随时中断、能自定义重试逻辑。要做AI对话应用这是必须掌握的能力。3.2 前端流式解析代码一步步拆解下面这段代码是我在项目里实际使用的处理了流式解析、按行拆分、数据提取和结束标记判断async function fetchStream(payload, onMessage, onDone, onError) { const response await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload) }); if (!response.ok) { onError(new Error(请求失败: response.status)); return; } 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 }); // 按空行切分一条条消息 let index; while ((index buffer.indexOf(\n\n)) ! -1) { const rawEvent buffer.slice(0, index); buffer buffer.slice(index 2); parseSSEEvent(rawEvent, onMessage, onDone); } } // 处理剩余的尾巴 if (buffer.trim().length 0) { parseSSEEvent(buffer, onMessage, onDone); } } function parseSSEEvent(raw, onMessage, onDone) { const lines raw.split(\n); let data ; for (const line of lines) { if (line.startsWith(data:)) { data line.slice(5).trim(); } else if (line.startsWith([)) { // 兼容某些服务端直接发送的数组格式 data line.trim(); } } if (data) { if (data [DONE]) { onDone(); } else { onMessage(data); } } }这里有几个细节值得单独提一下。用TextDecoder解码时一定要传{ stream: true }。原因是一个中文字符在UTF-8编码下占3个字节数据在网络上传输时会被拆成多个小块如果每次读取都独立解码可能在字符边界处截断导致中文乱码。加了stream: true之后解码器内部会暂存未完成的字节等下一块数据到达时拼上再解就能避免这个问题。按空行切分消息是最标准的方式。SSE协议里每条消息以空行\n\n结尾。但要注意TCP传输的数据可能同时包含多条消息也可能一条消息被拆成两半所以用buffer先缓存再循环切分确保边界处理完整。buffer buffer.slice(index 2)也就是把已处理的消息从缓存中移除保证下轮循环处理的是完整的下一条。parseSSEEvent里对data:前缀的解析我故意没有校验event:字段因为后端在实战中基本都用统一的message事件推送解析时抓data:就够了。如果你后端推的是自定义事件名前端需要用addEventListener(事件名, callback)来订阅而不是通用的onmessage。3.3 心跳保活、重连机制与进度UI的配合SSE的最大痛点是长连接容易中断。浏览器或者中间代理Nginx、网关如果在指定时间内收不到数据就会判定连接空闲主动掐断。所以生产环境的SSE服务几乎都要做心跳保活。心跳的作用就是维持连接活跃。实现方式很简单服务端每隔15到30秒往连接里写一行注释或者一个空事件。SSE协议支持注释行以冒号开头浏览器会忽略它但网络层会认为连接仍然活跃。在后端心跳可以在推送数据的循环里加一个定时任务或者用ScheduledExecutorService配合推送任务一起跑。我这里给一个参考写法SseEmitter emitter new SseEmitter(0L); // 0L表示不超时但生产环境不推荐详细原因见后文 ScheduledExecutorService heartBeat Executors.newSingleThreadScheduledExecutor(); heartBeat.scheduleAtFixedRate(() - { try { emitter.send(SseEmitter.event().comment(heartbeat)); } catch (IOException e) { emitter.completeWithError(e); } }, 15, 15, TimeUnit.SECONDS);前端针对断线要加自动重连逻辑。EventSource自带重连但fetch方案得自己写。我的做法是在onError回调里做一个指数退避重试let retryCount 0; const maxRetry 5; function reconnect() { if (retryCount maxRetry) return; const delay Math.min(1000 * Math.pow(2, retryCount), 15000); setTimeout(() { retryCount; fetchStream(payload, onMessage, onDone, err { onError(err); reconnect(); }); }, delay); }重试要记得做退避不然服务端还没恢复几十个客户端同时打过来又把它压垮了。配合进度UI比如“已生成45个字”这样的实时计数用户感知到的系统状态会更透明即使偶尔推送卡顿一下也不容易误以为是死掉了。4. 项目落地与周边设施搭建4.1 Nginx网关层如何避免流式响应被缓存如果你的项目用了Nginx做反向代理这里有个绕不开的坑Nginx默认会缓冲后端响应等整个响应体都接收完再一次性返回给客户端。这种机制对普通接口没问题但会把SSE的流式特性彻底毁掉——前端收到的仍然是一条聚合后的完整响应根本体验不到逐字输出。解决办法是给SSE接口关闭缓冲并调整超时时间。在Nginx的location或server配置块里加入location /api/ { proxy_pass http://backend-server; proxy_http_version 1.1; proxy_set_header Connection ; proxy_buffering off; proxy_cache off; chunked_transfer_encoding on; proxy_read_timeout 300s; proxy_send_timeout 300s; }proxy_buffering off是核心关闭了代理缓冲后端推送的数据就会被Nginx立即转发给客户端。proxy_cache off防止响应被缓存。chunked_transfer_encoding on确保使用分块传输编码这是SSE得以实现的基础之一。proxy_read_timeout 300s调大代理读取超时时间避免长时间流式推送时被Nginx截断。如果你用的是Spring Boot直接对外暴露端口没有Nginx那后端还要额外在响应头里设置X-Accel-Buffering: no。这个响应头是给Nginx看的告诉它不要缓冲这个响应。在Spring里可以通过拦截器或直接在Controller里加response.setHeader(X-Accel-Buffering, no); response.setHeader(Cache-Control, no-cache); response.setContentType(text/event-stream);我这里踩过一个挺隐蔽的坑当时第一版后端只加了Cache-Control忘了X-Accel-Buffering本地直连后端接口没问题一上测试环境走Nginx就变成了全量返回。折腾了半天才发现是Nginx缓冲在作怪。所以如果你是前后端分离项目中间有Nginx网关这两个配置项必须同时检查。4.2 服务器参数配置与超时时间设置SSE长连接对应用服务器的超时设置非常敏感。我整理了一下需要关注的参数按层级关系排列第一层是应用层超时。Spring Boot里的server.tomcat.keep-alive-timeout默认是keepAliveTimeout如果连接超过这个时间没有数据交互Tomcat会主动关闭连接。对于SSE这个值不能太小建议至少300秒。如果是IDE里启动的还要检查一下IDE的调试器有没有单独的socket超时。在application.yml里配置server: tomcat: keep-alive-timeout: 300s max-keep-alive-requests: 100第二层是代理层超时。Nginx的proxy_read_timeout是等待后端响应的最长时间proxy_send_timeout是发送响应的最长时间。这两个都建议调大。阿里云SLB、腾讯云CLB等云负载均衡器也有类似的无响应超时配置如果前端直连的是域名而不是后端IP中间隔着一层LB也要记得去控制台改。第三层是SseEmitter自身的超时。构造函数传的是毫秒数new SseEmitter(180_000L)表示180秒。我有一个经验不要让SseEmitter超时时间设置得太短尤其是AI应用用户可能会思考很久再发消息后端转发大模型的过程也可能持续好几十秒保守起见设置180到300秒是合理的。如果业务上确实需要无限期连接可以传0L表示不超时。但我不推荐生产环境这么干因为连接长期不释放很考验网关和服务端的连接清理能力。4.3 用curl命令快速验证SSE服务是否正常前后端联调之前先自己用curl验证后端是否真的按流式输出这是省时间的好习惯。官方有个命令可以直接看流式响应curl -N -H Accept: text/event-stream http://localhost:8080/api/sse/stream-N是--no-buffer的意思关闭curl的缓冲让收到的数据立即显示。执行后如果后端正常你会看到数据一条一条地打印出来每隔一秒出现一条而不是等待整个请求结束一次性出现在终端里。这一步是我每次写完SSE接口必做的自测动作因为能快速排除后端的问题把排查范围缩小到前端。如果是POST接口命令稍微变一下curl -N -X POST \ -H Content-Type: application/json \ -H Accept: text/event-stream \ -d {question:你好} \ http://localhost:8080/api/chat/stream如果你的接口是鉴权的加上-H Authorization: Bearer xxx即可。curl验证这一步看着简单但在排查问题时真的特别有用。页面端表现异常你无法确定是后端没推、还是前端解析挂了用curl直连后端接口一眼就能看清后端真实的推送节奏。5. 常见问题与排查实录那些坑我替你踩过了5.1 idle timeout导致连接中断前端收不到后续数据这是我实际遇到频率最高的报错网上类似的报错信息是stream disconnected before completion: idle timeout waiting for sse。现象是前端收到前面几条数据后再过十几秒连接就断了控制台报错。这个问题的根因是空闲超时。不管是Nginx的proxy_read_timeout、云负载均衡的“无响应超时”、还是SseEmitter自身的超时只要连接在设定时间内没有传输任何数据服务端就会认为连接空闲主动关闭。解决办法分三个层面。首先确保后端有心跳机制强制每隔15秒推一次注释行或者空数据这是治本的方法。其次是调大各层级的超时时间至少大于心跳间隔加上推送间隔。最后是确认Nginx的proxy_read_timeout已经调大。这三个层面我在前文都有详细的配置示例照着做基本能解决。还有一个容易忽略的情况如果网络是2G/3G这种弱网或者走了运营商代理连接中断的概率会更大。前端要做好断线重连配合重试逻辑。我在重试时会把用户已经看到的内容拼接到新连接上避免重连后重复显示。5.2 浏览器白屏数据迟迟不打印——编码与flush的坑另一种常见现象是浏览器打开页面之后一直空白什么都不显示过很久才整段蹦出来。这种情况十有八九是响应没有得到正确的即时刷新。排查思路是先看后端有没有手动flush。Java的SseEmitter.send()方法底层会触发响应写出但某些版本或某些代理环境下数据可能被缓冲住了。解决方法是确保响应头设置了Cache-Control: no-cache如果是Nginx确认proxy_buffering off和X-Accel-Buffering: no。另一个隐蔽的坑是字符编码。后端返回的响应头Content-Type如果是text/event-stream;charsetUTF-8前端解析一般没问题。但如果你在Nginx阶段给响应偷偷改了编码或者Spring的produces没写对前端用TextDecoder解码时可能按错误的编码解析中文显示成乱码。我在一次联调中就因为响应头缺了charsetUTF-8导致所有中文全部乱码。后来养成了每个SSE接口都在GetMapping的produces里显式标注MediaType.TEXT_EVENT_STREAM_VALUE的习惯一劳永逸。5.3 前端EventSource断线重连无限循环EventSource有一个“原生自动重连”的特性。如果你的服务端主动complete()结束了连接浏览器端会认为这次是异常断开自动发起重连请求。这就可能造成一个死循环服务端推送完数据后主动关闭浏览器马上又连上来服务端再推完再关浏览器再连……无限循环。这个问题在业务上会造成无效请求刷爆日志。解决办法是在前端收到[DONE]标记后主动调用evtSource.close()手动关闭连接阻止自动重连。配合服务端在正常的完成推送结束后发送一个明确的结束事件前端收到之后再关。如果使用fetch方案reader.read()返回done: true时自然停止循环不会出现这个坑这也是我后来主力使用fetch的原因之一。5.4 连接数暴涨SSE长连接的资源管理SSE是长连接每个在线用户会占用一条HTTP连接。如果业务量上来连接数会快速增长。以Tomcat为例默认最大线程数200如果你有200个用户同时打开AI对话页面每个用户保持一条SSE连接Tomcat的线程池就满了其他普通接口全部得不到响应。这一点在压力测试时暴露特别明显我压测到150个并发连接时后端的普通查询接口已经开始排队了。应对策略有两个方向。第一个是后端的SseEmitter用独立线程池让SSE推送不占用Tomcat处理普通请求的工作线程。前文提到用ThreadPoolTaskExecutor就是这个原因。第二个是给SSE接口加连接数限制在应用层做一个并发计数器超过阈值直接拒绝新连接避免无差别拖垮整个服务。还有一个进阶方案是引入Redis Pub/Sub做SSE广播。如果应用部署了多节点用户的SSE连接落在A机器上但消息推送请求打在B机器上B机上的SseEmitter根本不知道这个用户的存在。这种情况下需要用Redis做一个消息总线A机器订阅频道B机器把消息发布到频道A机器收到后推给指定的SseEmitter。这块涉及分布式设计如果你的项目当前是单体部署暂时不需要考虑但多实例部署时一定会遇到。5.5 参数选型避坑SseEmitter超时时间到底该怎么定很多新手会把SseEmitter的超时时间理解成“接口处理的最长时间”。实际上它是连接空闲的最长时间不是连接总时长。只要一直有数据在传输连接就一直存活不会超时。所以推流任务的执行时间再长只要推送间隔小于超时时间连接就不会断。我最终的经验值是普通消息推送场景30秒到60秒足够AI对话生成场景180秒到300秒如果有日志实时滚动或者监控大屏这种长时间持续推送的需求配合心跳机制超时时间可以设置到几十分钟甚至用0L表示不超时。但0L对网关不友好很多云负载均衡和Nginx对无超时连接有隐含限制所以生产环境还是建议设一个较大的具体值并用心跳维持活跃。我在做压测时还发现SseEmitter超时后服务端会抛一个AsyncRequestTimeoutException。如果你没有兜底处理这个异常会直接打到前端控制台报500。我后来加了一个全局异常处理器把这类异常转换成正常断线重连而不是让用户看到错误提示。6. 最后的实操心得与扩展方向整套SSE流式输出做下来我最大的感受是技术本身不难难的是把链路里每一层的细节都照顾到。后端SseEmitter半天就能跑通但真正稳定支撑线上业务需要把Nginx缓冲、超时时间、心跳保活、前端解析、断线重连、线程池隔离这些环节全部配好。这中间任何一个环节掉链子都会表现为“用户看到的内容不是逐字蹦出来的”或者是“连接时不时断一下”。如果你也是从零开始做SSE我的建议是先别急着写前端第一步用curl把我们后端的流式输出验证通。确认了后端没有问题再开始写前端的ReadableStream解析逻辑这样排查问题时能直接排除一大半变量。前端解析代码我上面给了完整版直接抄就能用但务必注意TextDecoder的stream: true参数和空行切分逻辑这两块是最容易出错的地方。这个项目的扩展方向其实也很多。文章里提到的如果想做多节点SSE推送要引入Redis Pub/Sub做消息广播。如果推送的数据量特别大还可以在SSE前面加一层消息队列让后端不直接推而是由消费者去拉数据再推给前端。要是你的应用后面要支持用户主动取消生成AI场景里的“停止输出”按钮前端需要拿到reader.cancel()来中断读取同时后端要监听emitter.onTimeout或onCompletion来清理线程避免资源泄漏。这些我暂时没有全部做完但代码结构和思路已经想清楚了等后续项目迭代再往里加。