
1. 为什么流式输出是 AI 对话的刚需做过 AI 对话产品的同学应该都有体会用户点下发送按钮之后如果界面要等十几秒才一次性蹦出整段回答那种体验基本等于劝退。尤其是接大语言模型的场景模型生成一段三百字的回答底层往往要跑好几秒甚至十几秒这段时间如果前端一直转圈用户大概率会以为卡死了然后刷新页面重来。流式输出解决的正是这个问题。它的核心思路是模型每生成一小段文本后端就立刻把这一小段推给前端前端拿到就渲染用户看到文字像打字一样一个个冒出来。这种边生成边显示的交互体感上把等待时间从十几秒压缩到了几百毫秒就有反馈是当前 AI 对话产品的标配。在 Spring Boot 生态里做流式 AI 对话主流技术组合是Spring Boot SSE Spring WebFlux Spring AI。SSEServer-Sent Events负责服务端到客户端的单向推送WebFlux 提供响应式非阻塞的流式处理能力Spring AI 则把各家大模型的调用逻辑封装成统一的流式接口。这套组合的好处是不用自己造轮子去处理模型 SDK 的流式回调也不用担心传统 Servlet 线程模型在高并发流式场景下的线程占用问题。这篇文章适合两类人看一类是已经会用 Spring Boot 写普通 REST 接口想给自己的项目加上 AI 对话能力的后端开发另一类是想搞清楚流式到底是怎么流起来的、SSE 和 WebSocket 到底该选哪个的技术负责人。我会从方案选型讲到代码落地再到实际踩过的坑尽量把每个为什么这么选讲透。2. 技术选型SSE、WebSocket 还是轮询2.1 三种方案的本质区别在动手写代码之前先把方案选型这件事想清楚否则后面返工成本很高。AI 对话的流式推送常见有三条路轮询、WebSocket、SSE。轮询是最土的办法前端每隔一秒问一次后端生成完了没。它的致命问题是延迟不可控而且请求量随对话时长线性增长一个用户聊十分钟可能产生几百次请求服务端压力大实时性还差。除非你的场景对实时性要求极低否则不建议。WebSocket 是全双工协议客户端和服务端可以互相主动发消息。它适合聊天室、协同编辑这类双向高频通信的场景。但用在 AI 对话上有点杀鸡用牛刀AI 对话本质是客户端发一次请求服务端持续推一段时间的响应是单向的用不上 WebSocket 的双向能力。而且 WebSocket 需要额外的握手升级、心跳保活、连接管理复杂度明显更高。SSE 则是专门为服务端单向推送设计的。它基于普通 HTTP 协议响应头里声明Content-Type: text/event-stream然后服务端就可以持续往这个连接里写数据前端用EventSource或者fetch的流式读取就能接收。对 AI 对话这种一问一长答的场景SSE 是最贴合的。2.2 为什么最终选 SSE把三者放在一起对比会更清楚维度轮询WebSocketSSE通信方向客户端主动拉双向服务端单向推协议基础HTTP独立协议需升级握手HTTP实现复杂度低高中低实时性差好好断线重连需自己实现需自己实现浏览器原生支持代理/网关兼容好部分网关需特殊配置好普通 HTTP适合场景低频状态查询双向高频通信服务端推送流从表里能看出来SSE 在 AI 对话场景下几乎是量身定做的。它走的是标准 HTTP公司里常见的 Nginx、网关基本不用改配置就能透传浏览器端EventSource自带断线重连实现上比 WebSocket 少一大截连接管理代码。注意SSE 有一个常被忽略的限制——浏览器原生的EventSource只支持 GET 请求没法带请求体。如果你的对话请求需要传一大段 JSON比如带历史消息、系统提示词要么把参数塞进 URL不优雅且有长度限制要么改用fetchReadableStream手动解析 SSE 格式。后者是现在更推荐的做法后面实操部分会讲。2.3 WebFlux 在其中的角色选完 SSE还要决定用哪种编程模型。传统 Spring MVC 是基于 Servlet 的阻塞模型一个请求占一个线程。流式场景下这个线程要一直挂着等模型吐字如果并发上来线程池很快就被占满。虽然用SseEmitter也能在 MVC 里做 SSE但底层还是阻塞的高并发下不划算。Spring WebFlux 是响应式非阻塞的它用少量线程就能撑起大量并发连接。流式 AI 对话里服务端大部分时间在等模型返回下一段这正是响应式模型擅长的——等待期间线程被释放去处理别的请求数据到了再回调。所以WebFlux SSE是流式 AI 对话在 Spring 生态里的黄金组合。至于 Spring AI它把 OpenAI、通义千问、DeepSeek 等模型的调用统一成了ChatClient和StreamingChatModel接口流式调用直接返回FluxString和 WebFlux 天然契合。不用 Spring AI 自己手写 HTTP 调模型 SDK 也行但那样每家模型都要单独适配维护成本高。3. 环境搭建与依赖配置3.1 项目初始化与依赖清单新建一个 Spring Boot 项目版本建议 3.2 以上因为 Spring AI 的正式版对 Spring Boot 3.x 有要求。用 Spring Initializr 生成时勾选 Spring WebFlux或者手动在pom.xml里加依赖。核心依赖如下dependencies !-- WebFlux响应式 Web 框架SSE 的基础 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency !-- Spring AI统一封装大模型调用 -- dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId version1.0.0/version /dependency /dependencies这里有个容易踩的坑不要同时引入spring-boot-starter-web和spring-boot-starter-webflux。两个都在时Spring Boot 默认会启动 Servlet 容器TomcatWebFlux 的响应式能力就废了一半。如果你确实需要 MVC 和 WebFlux 共存比如老项目改造得手动指定用哪个作为主容器配置起来很别扭。新项目直接上 WebFlux 就好。3.2 模型接入配置Spring AI 的配置走application.yml以 OpenAI 兼容接口为例spring: ai: openai: api-key: ${AI_API_KEY} base-url: https://api.openai.com chat: options: model: gpt-4o-mini temperature: 0.7api-key强烈建议用环境变量注入别硬编码在配置文件里提交到代码仓库。base-url这个字段很关键——很多国产模型通义、DeepSeek、智谱等都提供 OpenAI 兼容接口只要把base-url和model换成对应厂商的代码一行不用改就能切换模型。这就是用 Spring AI 而不是自己写 HTTP 调用的最大好处。temperature控制回答的随机性0 到 2 之间值越低回答越确定、越保守值越高越发散。做客服问答这类需要稳定输出的场景建议调到 0.2 到 0.5做创意写作可以调到 0.8 以上。3.3 一个容易被忽略的配置超时流式对话有个隐蔽的坑空闲超时。模型有时候思考时间长或者网络抖动SSE 连接可能几十秒没有数据流动这时候网关或客户端会判定连接超时然后断开前端就会报类似 stream disconnected before completion: idle timeout waiting for sse 的错误。解决办法是在服务端和网关两侧都调大超时时间。WebFlux 侧可以配置server: netty: idle-timeout: 300sNginx 侧如果做了反向代理要加proxy_read_timeout 300s; proxy_buffering off;proxy_buffering off这行特别重要。Nginx 默认会缓冲响应等攒够一定量才发给客户端这会把流式效果彻底破坏掉——用户看到的还是憋一大段再一次性出来。关掉缓冲数据才能实时透传。4. 服务端流式接口实现4.1 Controller 层的写法先看最核心的 Controller。用 WebFlux 的FluxServerSentEventString作为返回类型Spring 会自动帮你按 SSE 格式序列化。RestController RequestMapping(/api/chat) public class ChatController { private final StreamingChatService chatService; public ChatController(StreamingChatService chatService) { this.chatService chatService; } PostMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxServerSentEventString stream(RequestBody ChatRequest request) { return chatService.streamChat(request.getPrompt()) .map(chunk - ServerSentEvent.Stringbuilder() .data(chunk) .build()) .onErrorResume(e - Flux.just( ServerSentEvent.Stringbuilder() .event(error) .data(生成失败 e.getMessage()) .build())); } }几个关键点解释一下。produces MediaType.TEXT_EVENT_STREAM_VALUE声明了这是 SSE 响应浏览器和网关看到这个头就知道要按流式处理。ServerSentEvent是 Spring 提供的封装类可以设置event事件类型、data数据内容、id事件 ID、retry重连间隔等字段。前端可以根据event类型区分是正常内容还是错误信息。onErrorResume是响应式里的错误兜底。流式过程中如果模型调用出错直接抛异常会导致连接异常关闭前端拿到的是个莫名其妙的断流。用onErrorResume转成一个带error事件类型的正常 SSE 消息前端就能优雅地提示用户而不是白屏。4.2 Service 层的流式调用Service 层负责调 Spring AI 的流式接口Service public class StreamingChatService { private final StreamingChatModel chatModel; public StreamingChatService(StreamingChatModel chatModel) { this.chatModel chatModel; } public FluxString streamChat(String prompt) { return chatModel.stream(new Prompt(prompt)) .map(response - { String text response.getResult().getOutput().getContent(); return text null ? : text; }) .filter(text - !text.isEmpty()); } }chatModel.stream()返回的就是FluxChatResponse每个元素是模型吐出的一小段。这里做了两件事一是把ChatResponse里的文本内容抽出来二是过滤掉空字符串。为什么要过滤空串因为模型流式返回时有些 chunk 可能是空的比如只包含元数据如果不过滤前端会收到一堆空事件虽然不影响显示但白白增加网络开销。实操心得不同模型厂商的流式返回粒度不一样。有的按 token 返回一个 chunk 可能就一两个字有的会攒几个 token 一起返回。前端渲染时不要假设每个事件都是完整单词或句子直接追加就行浏览器会自动处理。4.3 会话上下文怎么维护上面是最简版本实际产品里对话是有上下文的——用户问它多少钱你得知道它指的是上一轮聊的那个商品。Spring AI 提供了ChatMemory来管理会话历史。Bean public ChatMemory chatMemory() { return new InMemoryChatMemory(); }然后在构造 Prompt 时把历史消息带上public FluxString streamChat(String sessionId, String prompt) { ListMessage history chatMemory.get(sessionId); Prompt fullPrompt new Prompt( Stream.concat(history.stream(), Stream.of(new UserMessage(prompt))) .toList() ); return chatModel.stream(fullPrompt) .map(...) .doOnNext(chunk - chatMemory.add(sessionId, chunk)); }InMemoryChatMemory只适合单机开发调试生产环境要用 Redis 之类的持久化存储否则服务重启会话就丢了多实例部署时也会出现这次请求打到 A 机器、下次打到 B 机器历史读不到的问题。会话 ID 一般由前端生成并随每次请求带上服务端用它做 key。5. 前端对接与实时渲染5.1 用 fetch 而不是 EventSource前面提过原生EventSource只支持 GET带不了请求体。AI 对话的请求往往是个 JSON所以更推荐用fetch手动读取流。async function streamChat(prompt, onChunk, onDone) { const response await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json }, 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 lines buffer.split(\n); buffer lines.pop(); for (const line of lines) { if (line.startsWith(data:)) { const data line.slice(5).trim(); if (data) onChunk(data); } } } onDone(); }这段代码有几个细节值得说。decoder.decode(value, { stream: true })里的stream: true很关键它告诉解码器后面还有数据别把不完整的多字节字符当错误。中文是三个字节一个字符如果网络分包正好把一个汉字切开了不加这个参数就会解码出乱码。buffer的作用是处理半行问题。SSE 的每条消息以\n\n分隔但网络传输时可能一条消息被切成两半前半段这次收到、后半段下次收到。所以要把最后一段不完整的行留在 buffer 里等下次数据来了再拼。lines.pop()就是取出最后那段可能不完整的部分。5.2 渲染与打字机效果拿到 chunk 之后前端把它追加到消息气泡里就行。React 里大概是这样const [answer, setAnswer] useState(); const handleSend async (prompt) { setAnswer(); await streamChat( prompt, (chunk) setAnswer(prev prev chunk), () console.log(生成完成) ); };这里有个性能细节如果每个 chunk 都触发一次setState模型吐字快的时候一秒可能触发几十次重渲染页面会卡。优化办法是用requestAnimationFrame或者节流把短时间内的多个 chunk 合并成一次渲染。不过对于普通长度的回答直接追加一般也够用等真遇到卡顿再优化不迟。5.3 中断生成怎么做用户经常会在 AI 答到一半时点停止生成。实现上就是中断那个 fetch 请求。用AbortControllerconst controller new AbortController(); fetch(/api/chat/stream, { method: POST, body: JSON.stringify({ prompt }), signal: controller.signal }); // 用户点停止时 controller.abort();调用abort()后前端的reader.read()会抛出一个AbortError捕获它并停止读取即可。服务端这边WebFlux 检测到客户端断开连接后会自动取消上游的Flux订阅模型调用也会随之终止不会继续白白消耗 token。这一点是响应式模型的优势——取消传播是自动的不用手动写清理逻辑。注意有些模型 SDK 在取消时不会立即停止计费可能还会把当前这一小段生成完。如果对成本敏感可以在服务端加一层判断检测到取消信号后主动调用模型的停止接口。6. 常见问题与排查实录6.1 流式变成一次性返回这是最常见的问题八成是缓冲惹的祸。排查顺序如下现象可能原因排查方法前端等很久后一次性收到全部内容Nginx 缓冲未关闭检查proxy_buffering off同上响应头缺少text/event-stream看浏览器 Network 里的 Content-Type同上中间有网关做了聚合绕过网关直连服务端测试内容分块但间隔很大模型本身返回慢换模型或看模型侧日志排查时最有效的办法是逐层剥离先直连 Spring Boot 服务不走 Nginx如果流式正常说明问题在网关如果直连也不流式那就是代码或响应头的问题。这样能快速定位到是哪一层在缓冲。6.2 连接中途断开前面提到的空闲超时是主因。除此之外还有几种情况一是模型生成时间超过网关的读超时二是服务端抛了未捕获的异常导致连接关闭三是客户端网络切换比如手机从 WiFi 切到 4G。应对策略是服务端做错误兜底 客户端做重连。服务端用onErrorResume把异常转成 error 事件让连接正常结束而不是异常断开。客户端在onDone或捕获异常后如果发现回答不完整可以提示用户重新生成。SSE 协议本身支持retry字段指定重连间隔但用 fetch 手动实现时这个字段不生效需要自己写重连逻辑。6.3 中文乱码如果前端收到的中文是乱码检查两个地方一是响应头有没有正确声明charsetUTF-8二是前端TextDecoder有没有用stream: true。前者在 Spring 里一般不用手动设text/event-stream默认就是 UTF-8后者是手动解析时最容易漏的。6.4 并发上不去如果压测发现并发一高就大量超时先确认是不是用了 MVC 的SseEmitter而不是 WebFlux。SseEmitter每个连接占一个 Servlet 线程几百个并发就把线程池占满了。换成 WebFlux 的FluxServerSentEvent后同样的硬件能撑的连接数会高一个数量级。另外检查模型调用有没有做限流。大模型 API 通常有 QPS 限制并发太高会被限流返回 429。生产环境建议在 Service 层加个信号量或令牌桶控制同时进行的模型调用数量超出的请求排队或直接返回当前繁忙。6.5 排查速查表把上面这些整理成一张表出问题时按顺序过一遍排查项检查内容期望结果响应头Content-Typetext/event-stream网关缓冲配置proxy_buffering off网关读超时大于模型最长生成时间容器是否 WebFlux无 spring-boot-starter-web代码返回类型Flux 而非 SseEmitter前端解码方式TextDecoder stream:true模型是否限流无 429 错误7. 生产环境还需要考虑的事7.1 鉴权与限流SSE 连接是长连接鉴权不能只在建立连接时做一次就完事。如果 token 在连接期间过期服务端应该主动关闭连接。实现上可以在Flux里加一个定时检查或者用 WebFlux 的过滤器在订阅时校验。限流方面除了前面说的模型调用限流还要对单用户的并发连接数做限制。一个用户开十个标签页同时对话就是十个长连接不限制的话容易被刷。可以用 Redis 记录每个用户的活跃连接数超过阈值就拒绝新连接。7.2 日志与可观测性流式接口的日志和普通接口不太一样。普通接口记一次请求的入参出参就行流式接口要记录连接建立时间、首字节时间TTFB衡量模型响应速度的关键指标、总时长、生成的总字符数、是否被中断。这些指标对排查性能和成本问题很有用。首字节时间尤其重要。用户感知的快慢主要取决于从发送到看到第一个字的时间而不是整段生成完的时间。如果 TTFB 超过两秒用户就会觉得卡。优化 TTFB 的办法包括用更快的模型、精简系统提示词、把历史消息做摘要而不是全量带上。7.3 成本控制流式对话的 token 消耗比想象中高因为每次请求都要把历史消息重新发一遍给模型。聊了二十轮之后第二十一轮请求可能带着几千 token 的历史成本蹭蹭往上涨。控制办法有几个一是限制历史消息的轮数比如只带最近十轮二是对更早的历史做摘要用一段话概括之前聊了什么三是设置单次回答的最大 token 数防止模型跑飞。Spring AI 的ChatOptions里可以设maxTokens建议根据场景设个合理上限比如问答类设 500长文生成设 2000。7.4 多模型切换与降级生产环境不建议只接一家模型。主模型挂了或者限流了要能自动切到备用模型。Spring AI 的好处是接口统一切换模型基本就是换个ChatModelBean。可以做一个路由层根据配置或健康检查结果决定用哪个模型主模型连续失败几次就临时切到备用过一段时间再切回来。这套东西搭起来之后一个能扛住生产流量的流式 AI 对话后端就成型了。从选型到落地最花时间的往往不是写代码而是排查那些为什么不流式为什么断流的环境问题。把第 6 节那张速查表存下来下次遇到直接对着查能省不少事。