☰
Spring AI 流式输出实战:Flux + SSE 实现打字机对话效果
2026/10/22 4:55:36 网站建设 项目流程

你有没有遇到过这种场景:在聊天页面把问题发出去,按钮转圈半天,好几秒之后答案才“哗”一下整段出来。时间其实不算长,但用户早就开始焦虑了——是不是卡了?要不要重发?我用 Spring AI 做 AI 助手项目时,体验过这种被吐槽“太迟钝”的尴尬。解决办法就是流式输出:后端用 WebFlux 的 Flux 维持一个响应流,基于 HTTP 的 SSE(Server-Sent Events)协议把内容分块推给浏览器,前端配合打字机效果逐字渲染。用户看到的是第一个字几百毫秒内就出现,后续内容像真人打字一样刷刷蹦出来,感知等待时间直接归零。

这篇文章不聊大而全的理论,直接给一套能跑通的方案:Spring AI 流式接口怎么搭、Flux 和 SSE 为什么是天生一对、前端怎么把数据流变成打字机、以及我踩过的那些坑。适合刚接触 Spring AI、想把对话接口做成打字机效果的后端,也适合要对接 SSE 流的前端同学参考。

1. 为什么 AI 回复要“流式”输出而不是等一次性结果

1.1 用户体验的痛点:感知延迟比真实延迟更致命

大模型接口有一个指标叫 TTFT(Time To First Token),也就是从请求发出到收到第一个 token 的时间。这个时间通常在一秒以内,甚至几百毫秒。但如果你用非流式接口,要等整个回答完整生成完才返回,真实等待可能是 3 秒、5 秒、甚至 10 秒。

这中间的差别,用户感知非常明确。等待期间页面要么一直转圈,要么显示一个“加载中”,用户无法判断是系统正在努力工作还是已经挂了。而流式输出的价值在于:它把等待变成了“观看生成过程”,第一块内容出来以后,用户天然觉得系统已经响应了,后续每多出一个字都是在正向反馈。

我之前给一个知识问答类的模拟项目做过改造,接流式之前用户反馈转发率低、问答体验“卡”;接了流式以后几乎没人提等待问题。技术上一行代码的差异,体验上是两个产品。

1.2 主流推送方案横评:轮询、WebSocket 和 SSE

有人会问:要做这种实时推送,为什么不用 WebSocket?这确实是个好问题。我直接说结论:单向推送场景里,SSE 比 WebSocket 更合适。

方案通信方向协议复杂度自动重连适用场景
轮询客户端反复请求最低,但浪费请求天然无状态低频、可接受延迟
WebSocket双向通信高,需要处理握手、帧、心跳需要自己实现聊天、游戏、实时协作
SSE服务端单向推送低,基于 HTTP内置重连AI 流式生成、日志推送、行情推送

AI 对话场景里,核心通信方向就是服务端往客户端推内容,客户端除了最初的请求参数之外几乎没有向服务端持续发消息的需求。即便你需要“停止生成”,也可以通过单独发一个中断请求来实现,完全不需要为此维护一条常驻双向连接。SSE 是单行道的长连接,直接用 HTTP,不需要额外的握手协议和心跳维护成本,浏览器还内置了断线重连机制,天然就是 AI 流式推送的最优解。

1.3 Flux 和 SSE:Spring AI 的天然组合

Flux 是 Project Reactor 里的一个响应式流发布者,通俗理解就是“可以持续产生数据序列的管道”。大模型的流式返回正好也属于这种形态:服务端持续收到 token,持续往管道里放数据。Spring AI 把大模型 SDK 的流式 API 封装成了 Flux ,Controller 方法只要声明返回 Flux,Spring WebFlux 就会在运行时把响应流按 SSE 格式逐块写出。

这样做的另一个好处是背压。消费者处理不过来的时候,Flux 可以协调生产者放慢速度,避免大量 token 堆积在内存里。如果直接用线程池加输出流硬写,遇到流量大的时候极容易内存暴涨,这是响应式方案在流式场景里最实在的一个红利。

2. 项目落地:环境准备与依赖选型

2.1 版本选型:Spring Boot 与 Spring AI 要匹配

如果你现在才开始建项目,我建议直接上 Spring Boot 3.x,最好 3.2 以上。Spring AI 从 1.0 开始 API 思路变得更统一,老的 StreamingChatClient 一类接口逐步收敛到了 ChatClient 里,新项目不应该再纠结旧接口了。

有一点需要特别警惕:流式返回这事依赖 WebFlux 的响应式能力,所以你的 web 技术栈必须是 WebFlux。最省事的做法是只引入 spring-boot-starter-webflux,而不是 spring-boot-starter-web。如果项目里已经用了 starter-web,再引入 WebFlux 会因为同时存在 Spring MVC 和 WebFlux 的自动配置而冲突,启动时会直接报错,或者 WebFlux 的控制器根本不生效。

实际项目中如果你必须保留旧的 MVC 接口,建议拆模块:把 AI 流式接口单独放到一个 WebFlux 服务里,对外统一走网关。别想着在同一应用里两套 web 框架共存,调试成本远高于拆分成本。

2.2 依赖引入与配置模板

以 Spring AI 1.x 为例,核心依赖只需要两段:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>你的Spring AI版本</version> </dependency>

如果要用流式响应,WebFlux 也要在依赖里,starter-webflux 必须存在。一般 starter 会把 WebFlux 相关支持一起拉进来,但为了保险,建议显式加一下:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency>

模型配置我放在 yml 里,用环境变量注入,避免把密钥写死在仓库里:

spring: ai: openai: api-key: ${AI_API_KEY} base-url: ${AI_BASE_URL} chat: options: model: ${AI_MODEL_NAME:gpt-4o-mini} temperature: 0.7

这里 base-url 和 api-key 都走环境变量,不同环境切起来方便。对接其他大模型服务也一样,只要服务商提供 OpenAI 兼容接口,Spring AI 就能直接接,只是配置入口和 model 名字不一样。

2.3 底层 HTTP 客户端怎么选

Spring AI 调用大模型接口,底层需要 HTTP 客户端。三个可选方向:JDK 自带的 HttpClient、OkHttp、以及 Spring 全家桶的 WebClient。

我实测下来,WebClient 在响应式场景里表现最自然,因为它是 Reactor 原生的,没有任何线程切换的别扭感。如果你用 JDK HttpClient 的同步版本,源码里内部线程模型会和 Reactor 不太协调,偶尔会出现连接空闲却不释放的情况。OkHttp 也有类似问题,不是不能用,而是需要额外处理桥接。

Spring AI 通常会自动探测并选择合适的底层实现。如果你完全按默认配置走,一般不会出问题。但一旦发现流式连接超过几十秒被无故掐断,优先检查是不是底层客户端把响应当普通请求处理了。此时显式配置 WebClient 为底层实现,往往能直接解决。

3. 服务端实现:把大模型流变成后端 Flux

3.1 最小可用接口:让 Controller 返回 Flux

先看一个最简单的流式接口。假设我要根据用户传入的提问,直接返回模型生成的文本流:

@RestController @RequestMapping("/api/chat") public class ChatStreamController { private final ChatClient chatClient; public ChatStreamController(ChatClient.Builder builder) { this.chatClient = builder.build(); } @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> stream(@RequestParam("message") String message) { return chatClient.prompt() .user(message) .stream() .content(); } }

核心就两行:prompt 里塞用户消息,然后调用 stream() 拿到 Flux ,再通过 content() 直接提取出文本块。

注意 produces 必须是 text/event-stream。如果漏掉,Spring 不知道你要发 SSE 流,会把返回值当成普通 JSON 处理,前端就会接到一个格式奇怪的响应。

这里还有个老版本接口的适配问题。Spring AI 0.8.x 时代用的是 StreamingChatClient 的 stream() 方法,返回 Flux ,而在 1.x 里统一成了 ChatClient。如果你看网上老教程,代码是跑不起来的,尽量参照你当前依赖版本的官方文档对照修改。

3.2 多轮对话上下文:把历史记录塞进流式请求

单纯一问一答的接口很容易做。真实项目里用户往往要带着上下文连续提问,这时候就要把聊天记录一起传给大模型。Spring AI 提供了 ChatMemory 抽象。最简单的方式是用内存存储:

@Bean public ChatMemory chatMemory() { return new InMemoryChatMemory(); }

在接口签名里带上会话 ID,把 ChatMemory 作为 Advisor 挂到请求上:

@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> stream(@RequestParam("message") String message, @RequestParam("conversationId") String conversationId) { return chatClient.prompt() .user(message) .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) .stream() .content(); }

ChatMemory 会自动帮你在每次请求前把历史消息注入,请求结束后把最新的问答存回去。会话上限可以设置,比如只保留最近 20 条消息,避免历史越积越长导致 token 成本飙升。

要注意的是流式接口里用户部分消息要避免重复累计。我第一次做的时候没搞清 ChatMemory 的存储时机,结果用户消息被存了两次,模型回答明显变得啰嗦。正确做法是确认调用链上 close 之后 ChatMemory 才会持久化消息,不要自己手动再用 chatClient 去补发一条用户消息。

3.3 流结束与连接关闭的控制细节

SSE 流的结束方式很微妙。当大模型生成完毕,Flux 里这套数据流走到 complete,Spring 就会关闭底层 HTTP 连接。对后端来说这个行为很自然,但前端 EventSource 的默认策略是连接断开后自动重连,下一次连接就会重新发起一次请求。

所以比较稳的方式是在服务端给一个明确的结束标记。比如把返回结构改成小对象,除了文本内容,还带一个 end 字段:

@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<String>> stream(@RequestParam("message") String message) { return chatClient.prompt() .user(message) .stream() .content() .map(chunk -> ServerSentEvent.builder(chunk).build()) .concatWithValues(ServerSentEvent.builder("[DONE]").build()); }

前端收到 [DONE] 之后主动调用 close(),不再依赖连接断开。这样既能明确结束状态,又不会让 EventSource 反复重连。

4. 前端打字机:把 Flux 流变成逐字蹦出来的效果

4.1 EventSource 接收流数据的正确写法

前端接收 SSE 最直接的 API 是原生 EventSource。用起来很简单:

const es = new EventSource('/api/chat/stream?message=' + encodeURIComponent(message)); let fullText = ''; es.onmessage = (event) => { const chunk = event.data; if (chunk === '[DONE]') { es.close(); return; } fullText += chunk; }; es.onerror = () => { // 这里一般是被网络断掉或服务端关闭连接 es.close(); };

有几个容易踩的坑。原生 EventSource 只能发 GET 请求,而且不能自定义请求头。如果你的接口需要鉴权,比如 Header 里带登录 token,EventSource 是无能为力的。常见方案是 token 放到 query 参数里,或者用 fetch 的 ReadableStream 自己解析 SSE 格式。

另一个坑是事件格式。Spring 默认把每个 Flux 元素写成一个 data 字段,多个元素之间用空行分隔。EventSource 会自动解析 data 字段,所以你拿到的 event.data 就是后端每一项内容。如果后端用 ServerSentEvent 包过,还可能有 event 字段,那前端就要用 addEventListener 监听自定义事件名,而不是 onmessage。

4.2 打字机实现:拆字缓冲与增量渲染

很多人拿到 chunk 以后直接塞进 DOM,这其实不对。传过来的 chunk 不一定是单个字,有可能一个 chunk 就是半句话甚至完整一句话。如果直接拼接文本到页面,效果就是“一句一句跳出来”,根本没有打字机的感觉。

正确做法是把所有内容先放进一个“总缓冲字符串”,渲染层用一个定时器,按固定频率从缓冲里拿一个字符更新到页面。我常用的写法参考下面这样:

let fullText = ''; let displayLength = 0; let timerId: number | null = null; es.onmessage = (event) => { const chunk = event.data; if (chunk === '[DONE]') { es.close(); return; } fullText += chunk; startTyping(); }; function startTyping() { if (timerId) return; timerId = window.setInterval(() => { displayLength++; outputEl.textContent = fullText.slice(0, displayLength); if (displayLength >= fullText.length && /** 流已结束 */) { clearInterval(timerId); timerId = null; } else if (displayLength >= fullText.length) { // 流还没结束,只是暂时没有新内容,重置下次继续 clearInterval(timerId); timerId = null; } }, 20); }

这里的关键点是:如果 fullText 已经全部显示,但流还没结束,就要把定时器停下来,等新 chunk 来的时候再重新启动。否则定时器每 20 毫秒空转一次,浪费性能。

另一种更细腻的做法是维护一个“待显示队列”,收到新块时把字符串拆成单个字符 push 进队列,渲染层每帧从队列头部取一个字符渲染。这样打字节奏更均匀,不会出现大 chunk 一次性刷屏的情况。

4.3 中文、标点与英文混排的处理

做打字机效果最尴尬的地方,就是中文里突然夹着一个英文单词。逐字渲染会把一个单词拆成 f-l-o-w,看起来非常业余。

一个简单有效的策略是:分词处理。中文按字拆分,英文按单词拆。具体规则不复杂:

function tokenize(text: string): string[] { const tokens: string[] = []; const segmenter = /([a-zA-Z0-9]+)/g; let match; let lastIndex = 0; while ((match = segmenter.exec(text)) !== null) { if (match.index > lastIndex) { tokens.push(...text.slice(lastIndex, match.index).split('')); } tokens.push(match[0]); lastIndex = match.index + match[0].length; } if (lastIndex < text.length) { tokens.push(...text.slice(lastIndex).split('')); } return tokens; }

渲染时每 20 毫秒取一个 token,中文一个字一个字出现,英文一个单词一个单词出现。标点符号也不需要额外处理,中文逗号、句号按字拆开即可,打字机在句尾顿一下的感觉更真实。

还有一个我踩过的坑:如果你把整个文本用 textContent 直接替换,用户没法中途选中文字,也没法定位比较长的回答。更好的做法是保留一个最终展示容器和一个临时打字状态,等流结束后把完整文本重新渲染一遍,同时把之前因打字效果导致的频繁 DOM 更新收掉。

5. 常见问题与排查实录

5.1 EventSource 断线重连,导致请求重复消费

这是流式接口最常见的问题。后端 Flux 正常结束后关闭连接,前端 EventSource 以为是网络异常,按默认策略发起重连,又触发一次大模型请求。

解决方式我已经在前面提过:服务端发送明确结束标记,前端收到标记后手动 close()。另外还要检查后端异常情况,如果整个 Flux 在中途出错,EventSource 也会断线重连,此时前端可以维护一个“是否已收到结束标记”的布尔状态,没收到就重连,收到就关闭。

调试时可以直接用浏览器的 Network 面板看 SSE 请求,Response 里能看到一行行 data 内容,重点关注最后是不是有 [DONE] 标记。如果后端处理异常抛错,Flux 走 onError,SSE 响应会直接断掉,这种反而是正常的,你应该在前端提示错误而不是重连。

5.2 中文乱码、半个字符与字符集陷阱

SSE 流对字符集非常敏感。Spring 默认输出 UTF-8,但如果你在前置网关、Nginx 或者某些代理层把编码强制转成了别的内容,或者响应头里的 charset 被干掉了,前端拿到的中文就会出现乱码。

排查思路是看响应头 Content-Type。正常应该是 text/event-stream;charset=UTF-8。如果成了 text/event-stream,很多浏览器会按默认编码处理,APIs 不同表现也不同。

另外要注意不要在后端自己做 substring 之类的切割。Flux 的元素是由 Spring AI 内部按底层协议拆分的,它在模型返回的字符边界上切分,理论上是安全的多字节边界。但如果你在 map 操作里对文本做人为截断,就可能把多字节字符拦腰截断,传到前端变成半个字符。遇到这种情况,前端可以维护一个“残次字符缓冲”,解析失败时暂存,下一块补全后再合并。

5.3 网关对 SSE 缓冲,导致首字迟迟不出现

如果项目部署在内网通过 Nginx 反代出去,有一个很经典的坑:Nginx 默认开启了响应缓冲,SSE 流会被攒住,直到缓冲区满才一起吐给用户,效果就是打字机变成大喘气,而且第一个字永远迟迟不出现。

Nginx 层面关掉缓冲:

location /api/chat/stream { proxy_buffering off; proxy_cache off; proxy_set_header Connection ''; proxy_http_version 1.1; chunked_transfer_encoding off; proxy_buffers 8 4k; proxy_read_timeout 10m; }

同时在后端响应头里加一个X-Accel-Buffering: no,这是给 Nginx 的一个显式信号,告诉它这个接口不要缓冲。符号安全起见,我建议代码里直接设置这个响应头,免得把 Nginx 配置改了但忘记生效。

部署在多云负载均衡环境里的还有一点:如果负载均衡器空闲超时是 30 秒,而大模型生成时间超过 30 秒,连接会被负载均衡切断。处理办法是把空闲超时调到几分钟,或者服务端在空闲时发一条 SSE 注释行作为“心跳”,因为注释事件不会被前端当成消息,却能让连接活跃。

5.4 高并发下的背压与内存问题

WebFlux 的背压机制做得很好,但如果使用姿势不对,内存一样会爆。最典型的错误是在 Flux 的 map 操作里把每条数据都塞进一个全局 List 保存,并发一高,内存立刻吃紧。

如果确实需要完整内容做后续处理(比如记录到数据库),建议在流结束后统一落库,而不是边流边存大集合。可以用 doOnComplete 回调,在 Flux 走完以后把数据交给一个异步任务持久化。不要在链路上做阻塞操作,这会直接毁掉响应式性能。

生产环境我一般会对单次接口的流式内容长度做一个 maxTokens 上限,模型参数里限制单次回答长度,同时在后端对 Flux 做 takeUntil 或 take 操作,防止异常情况下模型无限输出导致连接不关闭。

6. 一点实践体会

这个项目做下来,我的体会是:流式输出的难度不在某个单点技术,而在“全链路都要为流服务”。从后端 Flux 的写法,到 SSE 协议格式,再到前端 EventSource 和打字机渲染,任何一环不符合流式思维,都会回到“干等三秒”的老路。

给我个人留下最深印象的坑,一是 EventSource 的重连机制,二是 Nginx 的缓冲。这两件事单独拎出来都是常规知识,但组合在一起,在你意想不到的时候悄悄坑人。排查这类问题时,别光盯着后端代码,把抓包工具拿出来看一眼响应头和流格式,往往比盲改代码快很多。

如果你现在已经有一个能用但有点迟钝的 AI 接口,我的建议是把回参改成 Flux 试试。改动量其实很小:Controller 方法返回类型改成 Flux,加上 produces 类型,前端那边换个接收方式,打字机效果加上,整体体验立刻不一样。而且 Spring AI 的这套响应式封装,会让后续做多模态、Agent 这类复杂流式交互时也省很多事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询