多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

SSE流式输出实战:Spring Boot与React实现AI对话逐字返回

SSE流式输出实战:Spring Boot与React实现AI对话逐字返回 1. 从转圈等待到逐字蹦出流式输出到底改变了什么如果你用过 ChatGPT 的网页版一定对那种文字一个个蹦出来的体验印象深刻。你问它一个问题它不会让你干等十几秒然后一次性甩出一大段答案而是像有人在屏幕后面打字一样一个字一个字地往外冒。这种体验背后就是**流式输出Streaming**在起作用。我在做 AI 应用开发的时候第一版就是最朴素的请求-等待-返回模式。用户点发送前端发一个 HTTP 请求后端调用大模型接口等模型把整段话生成完再一次性返回给前端渲染。功能上没毛病但体验上很糟糕——模型生成 500 个字可能要 8 到 15 秒这段时间用户盯着一个转圈的 loading 图标心里会犯嘀咕是不是卡了是不是没发出去要不要重发结果就是用户频繁刷新、重复提交后端压力反而更大。流式输出解决的第一个问题就是感知延迟。注意它并没有让模型生成得更快总耗时可能还是 10 秒但用户在第 0.5 秒就看到了第一个字心理上会觉得它在工作了。这是典型的用交互设计弥补物理延迟的思路。第二个问题是首字节时间TTFB对于长文本生成场景一次性返回意味着 TTFB 等于总生成时间而流式返回的 TTFB 可以压缩到几百毫秒。那么技术上怎么实现核心就是SSEServer-Sent Events。它是一种基于 HTTP 的服务器推送技术允许服务器在一个长连接上持续向客户端发送文本数据。相比 WebSocketSSE 是单向的服务器到客户端、基于纯 HTTP、自带断线重连机制、实现起来简单得多。对于用户提问、AI 回答这种典型的单向流场景SSE 几乎是量身定做的。这篇文章我会把整套方案拆开讲SSE 和 WebSocket 到底怎么选、Spring Boot 后端怎么把大模型的流式响应透传给前端、React 前端怎么用EventSource或者fetch流式读取、以及我在实测中踩过的那些坑——比如 idle timeout、代理缓冲、连接断开重连这些让人头疼的问题。适合已经能跑通普通 AI 接口调用、想进一步优化体验的开发者也适合刚接触 SSE 想找个完整案例的朋友。2. SSE 与 WebSocket 的选型为什么 AI 对话场景我更偏向 SSE2.1 先搞清楚两者的本质差异很多人一提到实时推送就条件反射地想到 WebSocket觉得 SSE 是低配版。这个认知在 AI 对话场景里其实是反的。我先把两者的关键差异列出来你对照自己的场景一看就明白。维度SSEWebSocket通信方向单向服务器到客户端双向底层协议纯 HTTP/HTTPS独立协议需 HTTP 升级握手数据格式文本UTF-8文本 二进制断线重连浏览器自动重连需自己实现代理/网关兼容好就是普通 HTTP部分代理会拦截升级请求实现复杂度低中高适用场景通知、日志、AI 流式输出聊天室、协同编辑、游戏关键点在于AI 对话的数据流是单向的。用户的问题通过一个普通的 POST 请求发出去答案通过流式通道回来。你根本不需要 WebSocket 的双向能力。用 WebSocket 就像为了送一封信专门修了一条双向铁路能力过剩还增加维护成本。2.2 SSE 的协议细节别只会用不会看SSE 的响应体格式其实非常简单就是一系列以特定字段组成的文本块。一个标准的 SSE 消息长这样data: 你好 data: 我是 data: AI 助手 data: [DONE]每个消息以\n\n两个换行结尾表示结束。常用的字段有四个data:消息内容可以多行event:自定义事件类型前端可以监听特定事件id:消息 ID用于断线重连时告诉服务器从哪继续retry:重连等待毫秒数响应头必须包含Content-Type: text/event-stream并且通常要设置Cache-Control: no-cache和Connection: keep-alive。这几个头如果漏了浏览器可能不会按 SSE 处理或者中间代理会缓存住数据导致你看不到流。提示SSE 默认只支持文本。如果你的数据里有二进制内容需要先 Base64 编码。AI 场景基本都是文本所以这点不用太担心。2.3 什么时候该果断换 WebSocket也不是说 SSE 万能。如果你的场景需要客户端频繁主动推送比如用户边打字边让 AI 感知、多人协同编辑或者需要传输音频流、二进制文件那 WebSocket 更合适。还有一种情况是你要在同一个连接上做多路复用SSE 每个连接只能对应一个流开太多连接浏览器会有并发限制HTTP/1.1 下同域名通常 6 个。我的经验判断法则是数据流向单一、以文本为主、需要简单可靠选 SSE需要双向、二进制、低延迟交互选 WebSocket。AI 对话 90% 的情况落在前者。3. Spring Boot 后端把大模型的流透传出去3.1 整体链路设计后端在整个链路里扮演的是中间人角色接收前端请求调用大模型比如 OpenAI 兼容接口把模型返回的流式数据一块块转发给前端。这里有个关键决策——是让后端自己解析再重新组装 SSE还是直接把上游的流原样透传我两种都试过。自己解析再组装的好处是可以在中间做加工比如过滤敏感词、统计 token、拼接业务字段坏处是多一层解析、多一层出错可能而且如果上游本身就是 SSE 格式解析再组装纯属脱裤子放屁。原样透传的好处是简单、延迟低、上游格式变了也不用改代码坏处是前端拿到的就是上游的原始格式。我的建议是如果上游已经是 SSE 格式优先透传如果需要注入业务数据比如消息 ID、会话 ID用自定义 event 包一层。下面我按透传方案来讲这是最省事也最稳的。3.2 用 WebFlux 还是 MVC这是个真问题Spring Boot 里做流式输出第一个要面对的就是选 WebFlux 还是 Spring MVC。很多人一看到流式响应式就冲 WebFlux 去了但我要泼盆冷水如果你的项目本来就是 Spring MVC别为了一个流式接口把整个技术栈换掉。Spring MVC 从 5.0 开始就支持ResponseBodyEmitter和SseEmitter完全可以做流式输出。区别在于Spring MVC SseEmitter基于 Servlet 异步每个连接占用一个线程直到完成。适合并发量不大几百到几千的场景代码直观团队上手快。WebFlux Flux基于 Reactor 和 Netty非阻塞一个线程能扛很多连接。适合高并发场景但学习曲线陡调试麻烦和阻塞式代码比如 JDBC混用容易踩坑。我实测下来对于一个中等规模的 AI 应用Spring MVC 的SseEmitter完全够用。下面给一个基于 MVC 的实现RestController RequestMapping(/api/chat) public class ChatController { private final ChatService chatService; public ChatController(ChatService chatService) { this.chatService chatService; } PostMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamChat(RequestBody ChatRequest request) { // 超时时间设为 0 表示不超时或设一个合理值如 5 分钟 SseEmitter emitter new SseEmitter(5 * 60 * 1000L); emitter.onCompletion(() - log.info(SSE 完成, sessionId{}, request.getSessionId())); emitter.onTimeout(() - { log.warn(SSE 超时, sessionId{}, request.getSessionId()); emitter.complete(); }); emitter.onError(e - log.error(SSE 异常, e)); chatService.streamToEmitter(request, emitter); return emitter; } }注意produces MediaType.TEXT_EVENT_STREAM_VALUE这行它等价于设置Content-Type: text/event-stream是 SSE 能被浏览器识别的必要条件。3.3 调用上游模型并转发数据块服务层要做的事情是发起对上游模型的流式请求拿到数据块后通过emitter.send()推给前端。这里我用 Java 11 的HttpClient配合BodyHandlers.ofLines()来演示因为它对流式读取支持得比较自然Service public class ChatService { private final HttpClient httpClient HttpClient.newHttpClient(); public void streamToEmitter(ChatRequest request, SseEmitter emitter) { // 放到独立线程避免阻塞请求线程 CompletableFuture.runAsync(() - { try { String body buildUpstreamBody(request); HttpRequest upstreamReq HttpRequest.newBuilder() .uri(URI.create(https://api.example.com/v1/chat/completions)) .header(Content-Type, application/json) .header(Authorization, Bearer apiKey) .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponseStreamString response httpClient.send(upstreamReq, HttpResponse.BodyHandlers.ofLines()); response.body().forEach(line - { if (line.startsWith(data: )) { String payload line.substring(6); if ([DONE].equals(payload.trim())) { emitter.send(SseEmitter.event().data([DONE])); } else { // 原样转发或解析后重新包装 emitter.send(SseEmitter.event().data(payload)); } } }); emitter.complete(); } catch (Exception e) { log.error(转发失败, e); emitter.completeWithError(e); } }); } }几个关键点必须强调第一一定要放到独立线程。SseEmitter的send是异步的但如果你在请求线程里同步等待上游响应请求线程会被占住Servlet 容器的线程池很快就被打满。用CompletableFuture.runAsync或者配置一个专门的线程池都行。第二emitter.send()可能抛 IOException。当客户端提前断开用户关页面、切网络send会失败。这时候要捕获异常并停止后续发送否则会一直往一个死连接里写数据浪费资源。第三上游返回的data:行可能包含空行分隔。SSE 协议里空行是消息分隔符ofLines()会把空行也读出来。如果你直接转发空行前端可能解析出错。稳妥做法是判断line.isEmpty()就跳过。3.4 超时、心跳与连接保活SSE 连接是长连接中间任何一环Nginx、负载均衡、防火墙都可能在空闲一段时间后把连接掐掉。我踩过最典型的一个坑就是模型思考时间比较长中间有 30 秒没数据结果连接被代理断开前端报stream disconnected before completion: idle timeout waiting for sse。解决办法有两个层面。后端层面定期发送心跳注释行或空事件保持连接活跃// 每 15 秒发一次心跳 ScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(); ScheduledFuture? heartbeat scheduler.scheduleAtFixedRate(() - { try { emitter.send(SseEmitter.event().comment(keep-alive)); } catch (IOException e) { // 连接已断取消心跳 } }, 0, 15, TimeUnit.SECONDS);comment发送的是以:开头的行SSE 规范里这是注释前端不会触发onmessage但能保持 TCP 连接活跃。代理层面Nginx 需要专门配置。默认情况下 Nginx 会缓冲响应导致你明明后端在流式发送前端却要等全部结束才收到。必须关掉缓冲location /api/chat/stream { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ; proxy_buffering off; proxy_cache off; chunked_transfer_encoding off; proxy_read_timeout 300s; }proxy_buffering off是重中之重漏了它你会怀疑人生——后端日志显示数据一块块发出去了前端就是不动。4. React 前端两种流式读取方案与它们的坑4.1 EventSource 方案简单但有硬伤最直觉的做法是用浏览器原生的EventSourceconst es new EventSource(/api/chat/stream?sessionIdxxx); es.onmessage (event) { if (event.data [DONE]) { es.close(); return; } setAnswer(prev prev event.data); }; es.onerror (err) { console.error(SSE 错误, err); es.close(); };EventSource的好处是自动重连、代码极简。但它有个致命硬伤只支持 GET 请求不能自定义请求头不能带请求体。而 AI 对话通常需要 POST 一个 JSON 请求体包含消息内容、模型参数还要带 Authorization 头。这就把EventSource卡死了。变通方案是把参数塞到 URL query 里但消息内容长了 URL 会超长而且把用户输入暴露在 URL 里也不合适。所以生产环境我基本不用EventSource改用下面的fetch方案。4.2 fetch ReadableStream生产环境首选fetch配合response.body.getReader()可以手动读取流完全掌控请求方法、请求头和请求体async function streamChat(message, onChunk, onDone) { const response await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json, Accept: text/event-stream, }, body: JSON.stringify({ message, sessionId: xxx }), }); if (!response.ok) { throw new Error(HTTP ${response.status}); } 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 }); // 按 SSE 消息分隔符切分 const parts buffer.split(\n\n); buffer parts.pop(); // 最后一段可能不完整留到下次 for (const part of parts) { const lines part.split(\n); for (const line of lines) { if (line.startsWith(data: )) { const data line.slice(6); if (data [DONE]) { onDone(); return; } onChunk(data); } } } } onDone(); }这段代码有几个细节值得展开说。decoder.decode(value, { stream: true })的stream: true参数很关键。一个 UTF-8 中文字符占 3 个字节如果网络分片刚好把一个汉字切成两半不加这个参数就会解码出乱码。stream: true会让TextDecoder保留不完整的字节序列等下一块数据来了再拼。buffer 的处理逻辑是防丢数据的关键。网络返回的 chunk 边界和 SSE 消息边界不一定对齐一个消息可能被切成两个 chunk也可能两个消息挤在一个 chunk 里。所以要用一个 buffer 累积按\n\n切分最后一段不完整的留在 buffer 里等下次。我见过太多人直接对每个 chunk 做split结果偶尔丢字或者出现半截 JSON排查半天。onChunk里更新 React 状态要注意性能。如果每个字都setState高频更新会让 React 疯狂重渲染长回答会卡。我的做法是用一个 ref 累积文本配合requestAnimationFrame或者节流比如每 50ms 更新一次 UIconst bufferRef useRef(); const rafRef useRef(null); const handleChunk (text) { bufferRef.current text; if (!rafRef.current) { rafRef.current requestAnimationFrame(() { setAnswer(bufferRef.current); rafRef.current null; }); } };4.3 中断、重试与错误处理用户点了停止生成怎么办fetch方案下用AbortControllerconst controller new AbortController(); fetch(/api/chat/stream, { signal: controller.signal, ... }); // 用户点停止 controller.abort();abort之后reader.read()会抛AbortError捕获它并静默处理即可不要弹错误提示。错误处理上我建议区分几类网络错误fetch直接 reject、HTTP 错误response.ok为 false、流中断读到一半done但没收到[DONE]。第三类最隐蔽用户会看到回答戛然而止。我的做法是记录已接收的内容如果没收到[DONE]就标记为未完成给用户一个重新生成的按钮而不是自动重试——自动重试可能导致重复内容。5. 那些让我熬夜的坑完整排查链路复盘5.1 现象后端日志正常前端一动不动这是我最开始遇到的坑印象最深。后端日志清清楚楚打印着每个数据块都send成功了前端onmessage就是不触发。我一开始怀疑是前端代码问题把fetch换成EventSource试还是一样。排查思路是这样的先在浏览器开发者工具的 Network 面板看这个请求。发现请求一直处于 pending 状态Response 里什么都没有。这就说明数据卡在了中间某一环没到浏览器。接着我在本地直接访问后端接口绕过 Nginx用curl -N http://localhost:8080/api/chat/stream发现数据是能一块块出来的。这就定位到了问题在 Nginx。最后查 Nginx 配置发现proxy_buffering默认是on。Nginx 会把后端的响应缓冲起来攒够一定大小或者连接结束才发给客户端。对于流式场景这就是灾难。加上proxy_buffering off;之后问题立刻解决。提示如果你用的是云厂商的负载均衡或者 API 网关也要检查它们是否有类似的响应缓冲配置。很多网关默认开启缓冲需要手动关闭。5.2 现象跑到一半报 idle timeout这个就是前面提到的stream disconnected before completion: idle timeout waiting for sse。触发条件是模型思考时间过长中间没有数据输出。比如用户问了一个复杂问题模型在生成第一个 token 前要处理很久或者中间遇到需要停顿的推理。排查时我先确认了不是代码问题——在本地环境用同样的输入偶尔能复现说明和网络链路有关。然后我抓包看发现连接是在空闲约 60 秒后被对端发 RST 断开的。60 秒这个数字很典型是很多代理和负载均衡的默认空闲超时。解决方案是双管齐下后端加心跳前面讲过同时把各层代理的read_timeout调大。Nginx 的proxy_read_timeout默认 60s我调到了 300s。云负载均衡那边也把空闲超时从 60s 调到 300s。改完之后再没出现过这个报错。这里有个经验心跳间隔要小于链路中最小的那个超时值。比如最小超时是 60s心跳设 15s 就很安全。别设成 55s网络稍微抖一下就被断了。5.3 现象中文乱码偶尔出现锟斤拷这个坑前面提过原因但排查过程值得说。现象是大部分中文正常偶尔冒出乱码。我一开始以为是编码问题检查了后端Content-Type带了charsetUTF-8前端也声明了 UTF-8都没问题。后来仔细看乱码出现的位置发现都在 chunk 边界附近。用console.log打印每个 chunk 的字节长度发现有些 chunk 的字节数不是 3 的倍数中文 UTF-8 是 3 字节。这就实锤了一个汉字被切成了两个 chunk前端各自解码就乱了。修复就是前面说的decoder.decode(value, { stream: true })。这个参数的作用是让解码器记住上次没解完的字节。改完之后乱码彻底消失。5.4 现象并发几个请求后后面的全部卡住这个坑和 Servlet 线程模型有关。我最初把上游调用写在了请求线程里同步等待结果每个 SSE 连接都占着一个 Tomcat 工作线程。Tomcat 默认最大线程 200但实际并发几十个长连接就把线程池耗得差不多了新请求排队等不到线程。排查时用jstack看线程栈发现大量线程阻塞在httpClient.send上。定位很清楚。修复方案是把上游调用挪到独立线程池请求线程发完SseEmitter就返回。这样 Tomcat 线程能快速释放长连接只占用少量资源。如果并发量真的很大上万连接那就得上 WebFlux Netty 了但那是另一个量级的架构决策。6. 让流式体验更稳的几个工程细节6.1 消息 ID 与断线续传SSE 协议支持id字段配合Last-Event-ID请求头可以实现断线续传。原理是服务器给每个消息编号客户端断线重连时浏览器自动带上最后收到的 ID服务器从这个 ID 之后继续发。实现上后端在send时带上 idemitter.send(SseEmitter.event().id(String.valueOf(seq)).data(payload));但要注意续传需要服务器端保存已发送的消息否则断线后你也不知道该从哪继续。对于 AI 对话我的做法是把已生成的完整回答存到 Redis 或数据库重连时根据Last-Event-ID从缓存里取后续内容。如果没做这个存储续传就无从谈起只能让用户重新生成。6.2 背压别让快生产者拖垮慢消费者如果模型生成速度很快而前端渲染慢比如在低端手机上数据会在缓冲区堆积。SSE 本身没有背压机制emitter.send是发了就不管。堆积严重时内存会涨。我的处理方式是加一个简单的限流如果emitter的待发送队列超过阈值就暂停从上游读取等前端消费得差不多了再继续。Spring 的SseEmitter没有直接暴露队列长度但可以通过控制上游读取节奏来间接实现——比如每发 N 条就Thread.sleep一小会儿或者用信号量控制。对于大多数 AI 应用模型生成速度本身不快每秒几十个 token前端渲染压力不大这个问题不突出。但如果你做的是批量日志推送或者高频数据流就得认真对待。6.3 安全与鉴权SSE 连接是长连接鉴权不能只在建立连接时做一次就完事。我的做法是建立连接时校验 token同时给连接设置一个最大存活时间比如 30 分钟到期强制断开让客户端重新鉴权。这样即使 token 泄露攻击窗口也有限。另外EventSource不支持自定义请求头所以如果用EventSource方案token 只能放 URL 里这有泄露风险会进浏览器历史、服务器日志。这也是我推荐fetch方案的另一个原因——可以正常带Authorization头。6.4 前端渲染的细节Markdown 与代码高亮AI 返回的内容通常是 Markdown 格式流式渲染时如果每来一个字就重新解析整个 Markdown性能会很差而且代码块在没闭合时会渲染错乱。我的做法是流式过程中先用纯文本展示保留换行等[DONE]之后再一次性解析成 Markdown 渲染。这样既保证了流式的流畅感又避免了半截 Markdown 的渲染问题。如果一定要边流边渲染 Markdown那就用增量解析库并且对未闭合的代码块做特殊处理比如临时补上闭合标记。这个复杂度不低除非产品强需求否则不建议。7. 我在这套方案上的一些个人体会整套方案跑通并上线之后我最大的感受是流式输出的难点不在流本身而在链路上每一环的配合。后端代码可能就几十行但 Nginx 一个配置、代理一个超时、前端一个解码参数任何一个没处理好整个体验就崩了。所以调试这类问题时一定要有全链路的视角从浏览器 Network 面板到后端日志到代理配置一层层排查别死磕某一层。另外一个体会是关于选型的克制。我见过一些团队为了做流式输出直接上 WebFlux WebSocket 消息队列架构图很漂亮但维护成本高得吓人一个新人接手要学半个月。而实际上他们的并发量用 Spring MVC SSE 绰绰有余。技术选型要匹配真实需求别为了先进而先进。最后分享一个我常用的小技巧在开发阶段我会写一个极简的 HTML 页面直接连后端 SSE 接口不经过任何前端框架。这样能快速判断问题出在后端还是前端。如果这个裸页面能正常流式显示那问题就在 React 那边如果裸页面也不行就往 Nginx 和后端查。这个最小复现的思路帮我省了大量排查时间。如果你正准备给自己的 AI 应用加上流式输出建议先把后端和 Nginx 这条链路调通用curl -N确认数据能一块块出来再去接前端。顺序反了的话前端调半天可能问题根本不在前端。
返回列表