1. 从"转圈等待"到"逐字蹦出":流式输出到底改变了什么
如果你用过 ChatGPT 的网页版,一定对那种"文字一个个蹦出来"的体验印象深刻。你问它一个问题,它不会让你干等十几秒然后一次性甩出一大段答案,而是像有人在屏幕后面打字一样,一个字一个字地往外冒。这种体验背后,就是**流式输出(Streaming)**在起作用。
我在做 AI 应用开发的时候,第一版就是最朴素的"请求-等待-返回"模式。用户点发送,前端发一个 HTTP 请求,后端调用大模型接口,等模型把整段话生成完,再一次性返回给前端渲染。功能上没毛病,但体验上很糟糕——模型生成 500 个字可能要 8 到 15 秒,这段时间用户盯着一个转圈的 loading 图标,心里会犯嘀咕:"是不是卡了?是不是没发出去?要不要重发?"结果就是用户频繁刷新、重复提交,后端压力反而更大。
流式输出解决的第一个问题就是感知延迟。注意,它并没有让模型生成得更快,总耗时可能还是 10 秒,但用户在第 0.5 秒就看到了第一个字,心理上会觉得"它在工作了"。这是典型的用交互设计弥补物理延迟的思路。第二个问题是首字节时间(TTFB),对于长文本生成场景,一次性返回意味着 TTFB 等于总生成时间,而流式返回的 TTFB 可以压缩到几百毫秒。
那么技术上怎么实现?核心就是SSE(Server-Sent Events)。它是一种基于 HTTP 的服务器推送技术,允许服务器在一个长连接上持续向客户端发送文本数据。相比 WebSocket,SSE 是单向的(服务器到客户端)、基于纯 HTTP、自带断线重连机制、实现起来简单得多。对于"用户提问、AI 回答"这种典型的单向流场景,SSE 几乎是量身定做的。
这篇文章我会把整套方案拆开讲:SSE 和 WebSocket 到底怎么选、Spring Boot 后端怎么把大模型的流式响应透传给前端、React 前端怎么用EventSource或者fetch流式读取、以及我在实测中踩过的那些坑——比如 idle timeout、代理缓冲、连接断开重连这些让人头疼的问题。适合已经能跑通普通 AI 接口调用、想进一步优化体验的开发者,也适合刚接触 SSE 想找个完整案例的朋友。
2. SSE 与 WebSocket 的选型:为什么 AI 对话场景我更偏向 SSE
2.1 先搞清楚两者的本质差异
很多人一提到"实时推送"就条件反射地想到 WebSocket,觉得 SSE 是"低配版"。这个认知在 AI 对话场景里其实是反的。我先把两者的关键差异列出来,你对照自己的场景一看就明白。
| 维度 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 单向(服务器到客户端) | 双向 |
| 底层协议 | 纯 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(); HttpResponse<Stream<String>> 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 方案:简单但有硬伤
最直觉的做法是用浏览器原生的EventSource:
const es = new EventSource('/api/chat/stream?sessionId=xxx'); 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 更新一次 UI):
const 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方案下用AbortController:
const 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带了charset=UTF-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时带上 id:
emitter.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确认数据能一块块出来,再去接前端。顺序反了的话,前端调半天可能问题根本不在前端。