☰
Spring AI 流式输出实战:Flux + SSE 实现打字机效果
2026/10/12 5:17:22 网站建设 项目流程

1. 从"转圈等待"到"逐字蹦出":流式输出到底解决了什么体验问题

做过对话类应用的人大概都有过这种经历:用户敲完问题点发送,界面立刻转起一个加载圈,然后就是漫长的等待。三秒、五秒、甚至十几秒过去,答案才"啪"地一下整段出现。这三秒里用户在想什么?大概率是在怀疑是不是卡了、是不是没发出去、要不要再点一次。这种体验上的割裂感,是传统"请求-响应"模式在生成式场景里最要命的地方。

大模型生成文本的方式本身就是一个字一个字往外吐的。它并不是先在心里把整段话想好再一次性说出来,而是根据上文逐个预测下一个词元(token),生成一个、拼上去、再生成下一个。既然模型底层就是流式的,那我们在接口层却把它攒成一整块再返回,本质上是在跟模型的天然节奏对着干。流式输出(Streaming)要做的,就是把这个"逐字生成"的过程原原本本地透传给前端,让用户看到文字像打字机一样一个个蹦出来。

在 Java 生态里做这件事,绕不开两个关键词:Spring AI和Project Reactor 的 Flux。Spring AI 是 Spring 官方推出的 AI 应用开发框架,它把各家大模型的调用抽象成了统一的接口,其中流式调用返回的就是一个Flux<String>。而 Flux 是 Reactor 里的响应式流类型,代表"0 到 N 个元素的异步序列"——正好对应"一段一段到达的文本片段"。前端这一侧,浏览器原生的SSE(Server-Sent Events,服务器推送事件)就是承载这种单向持续推送的标准协议。

这三者凑在一起,就构成了标题里说的"Flux + SSE 打字机"方案。它适合谁?适合所有在做对话式 AI 应用、智能客服、代码助手、文档问答的开发者,尤其是用 Java/Spring 技术栈、又不想为了流式输出单独搭一套 Node 服务的团队。读完这篇,你应该能搞清楚:为什么流式比一次性返回体验好这么多、Flux 到 SSE 这条链路每一环在干什么、以及实际落地时那些文档里不会写的坑。

需要先说明一点:下面涉及的具体代码和配置,是基于 Spring AI 与 Reactor 的常见实践做的合理还原,不同版本 API 可能略有出入,思路是通用的,落地时以你项目实际依赖的版本为准。

2. 拆开这条链路:一次流式回答在前后端之间到底走了什么

很多人第一次接触流式输出,会觉得"不就是把返回值从 String 换成 Flux 吗"。真上手才发现,从 Controller 到浏览器,中间隔着好几层,每一层都有自己的脾气。我们先把整条链路摊开看一遍,后面再逐层深入。

2.1 模型侧:token 是怎么一个个产生的

大模型的推理过程是自回归的:给定一段上下文,模型输出一个概率分布,采样出下一个 token,把它接到输入后面,再预测下一个,如此循环。所谓"流式",就是每采样出一个 token(或一小批 token),就立刻通过底层连接推出来,而不是等整个序列生成完。

这里有个容易被忽略的点:token 不等于字。一个中文汉字可能对应一个 token,也可能半个字是一个 token,英文里一个单词可能被切成好几段。所以你在前端看到的"逐字蹦出",严格说是"逐 token 蹦出",有时候会一次蹦出两三个字,有时候一个词被拆成两截先后出现。这不是 bug,是分词机制决定的。理解这一点,后面调打字机动画的节奏时就不会纠结"为什么它有时候一次吐俩字"。

2.2 框架侧:Spring AI 把流式封装成了 Flux

Spring AI 的ChatClient或ChatModel在流式模式下,返回类型是Flux<String>。这个 Flux 是一个"冷"的发布者——你不订阅它,它什么都不做;一旦订阅,它就开始向模型发起请求,并随着 token 到达不断向下游发射元素。

为什么用 Flux 而不是 Java 8 的Stream?因为Stream是同步、拉取式的,你得主动去next();而 Flux 是异步、推送式的,数据到了自动推给你,天然适配"模型什么时候生成完我不知道"这种场景。而且 Flux 支持背压(backpressure)、取消订阅、超时、重试这些操作符,处理网络抖动和用户中途关闭页面这类情况时非常顺手。

2.3 传输侧:SSE 为什么比 WebSocket 更合适

要把服务端持续产生的数据推给浏览器,常见选择有两个:WebSocket 和 SSE。这里选 SSE 是有讲究的。

WebSocket 是全双工的,客户端和服务端都能主动发消息,适合聊天室、协同编辑这种双向高频交互。但我们的场景是"用户问一句,服务端答一段",本质上是单向推送——答案只需要从服务端流向客户端。用 WebSocket 属于杀鸡用牛刀,还得自己处理心跳、重连、消息分帧。

SSE 则是 HTTP 协议之上的轻量方案:客户端发一个普通的 GET/POST 请求,服务端把响应头设成Content-Type: text/event-stream,然后保持连接不关闭,持续往里写data: xxx\n\n格式的文本。浏览器端的EventSource或者 fetch 的流式读取都能直接消费。它自带断线重连(EventSource 默认行为),实现简单,调试时用 curl 就能看到数据流,排查问题特别方便。

对比维度SSEWebSocket
通信方向服务端到客户端单向双向
协议基础HTTP独立协议,需握手升级
实现复杂度低,普通 HTTP 响应即可较高,需管理连接状态
断线重连EventSource 内置需自行实现
调试便利性curl 直接可见需专门工具
适用场景推送、流式生成双向实时交互

对于"打字机"这种单向流式场景,SSE 是更省心的选择。

2.4 渲染侧:前端怎么把碎片拼成打字机

前端拿到 SSE 数据流后,每收到一个片段就追加到当前回答的末尾,触发视图更新。所谓"打字机效果",其实不需要额外做逐字动画——因为数据本身就是一段段来的,你只要老老实实"来一段追加一段",视觉上自然就是打字机的样子。

有些团队会额外加一个 CSS 光标闪烁或者逐字渲染的动画,那是锦上添花。核心的"逐字感"来自数据流的天然节奏,而不是前端硬凑的动画。这一点想通了,实现会简单很多。

3. 后端落地:把 Flux 接到 SSE 上的关键几步

链路清楚了,接下来是动手。后端这块的核心任务就一句话:把 Spring AI 返回的 Flux,通过 SSE 协议稳定地推给前端。听起来简单,但每一步都有细节。

3.1 依赖与返回类型的选择

首先确认你的项目引入了 Spring AI 的对应 starter,以及 WebFlux(注意不是传统的 Spring MVC)。流式输出强烈建议走 WebFlux 技术栈,因为它是响应式的,能天然处理 Flux 这种异步序列;如果你在传统的 Servlet 栈(Spring MVC)里硬做,虽然 Spring MVC 也支持返回Flux或SseEmitter,但线程模型和背压处理会别扭很多。

Controller 的写法大致是这样:

@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> streamChat(@RequestParam String question) { return chatClient.prompt() .user(question) .stream() .content(); }

关键点在于produces = MediaType.TEXT_EVENT_STREAM_VALUE,它告诉 Spring 这个接口返回的是 SSE 流,框架会自动帮你把 Flux 里的每个元素包装成data: xxx\n\n的格式写出去。这一步如果漏了,浏览器收到的就是普通文本,EventSource 会直接报错。

3.2 为什么返回 Flux 而不是 Flux

Spring AI 的流式接口通常能返回更完整的对象(比如带元数据的ChatResponse),但如果你只关心文本内容,直接.content()拿到Flux<String>最省事。返回完整对象的好处是能拿到 token 用量、结束原因等信息,但代价是前端要解析更复杂的结构。

我的建议是:如果只是做打字机展示,返回纯文本就够了;如果后续要做计费、限流、内容审核,那就在服务端把元数据截留下来做处理,推给前端的仍然只保留文本。别把不该给前端看的东西一股脑塞进流里。

3.3 超时、取消与资源释放

这是最容易出事的地方。SSE 连接是长连接,如果用户中途关掉页面,或者网络断了,服务端的 Flux 还在傻傻地往模型要 token,这就是白白烧钱。Reactor 的好处是,当客户端断开时,订阅会被取消,Flux 会收到 cancel 信号,从而停止向模型请求。

但前提是你得让这个取消信号能传下去。如果你在中间做了block()、或者把 Flux 转成了阻塞的队列,取消信号就断了,模型会一直生成到结束。所以整条链路都要保持响应式,不要中途阻塞。

另外要设置合理的超时。模型偶尔会卡住不吐字,这时候不能让连接无限挂着:

return chatClient.prompt() .user(question) .stream() .content() .timeout(Duration.ofSeconds(60)) .onErrorResume(e -> Flux.just("[生成中断,请重试]"));

timeout保证整体不超过 60 秒,onErrorResume保证出错时给用户一个体面的提示,而不是让连接静默断掉、前端一直转圈。

3.4 一个常被忽略的坑:代理和缓冲

本地测试一切正常,一上生产就变成"憋一大段才出来",这种情况十有八九是中间有反向代理或网关在缓冲响应。Nginx 默认会缓冲上游响应,导致 SSE 数据被攒起来一起发。解决办法是在对应 location 里关掉缓冲:

location /chat/stream { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_set_header Connection ''; proxy_http_version 1.1; chunked_transfer_encoding off; }

proxy_buffering off是核心,其他几项配合着保证连接以 chunked 方式持续传输。这个坑我在不止一个项目里踩过,现象就是"本地飞快、线上卡顿",排查半天最后发现是网关干的。

4. 前端消费:EventSource、fetch 流与打字机渲染的取舍

后端把流推出来了,前端怎么接、怎么渲染,同样有讲究。这里主要说三种消费方式,以及各自的适用场景。

4.1 EventSource:最省事但有局限

浏览器原生的EventSource是消费 SSE 最直接的方式:

const es = new EventSource('/chat/stream?question=' + encodeURIComponent(q)); es.onmessage = (event) => { appendToAnswer(event.data); }; es.onerror = () => { es.close(); };

它的优点是简单、自带重连。但有两个硬伤:一是只能发 GET 请求,问题内容只能塞在 URL 里,长问题会超长;二是不能自定义请求头,如果你的接口需要鉴权 token,就没法通过 header 传。所以 EventSource 适合问题短、无需鉴权的简单场景。

4.2 fetch + ReadableStream:更灵活的主流选择

要发 POST、要带鉴权头,就得用fetch配合流式读取:

const response = await fetch('/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify({ question: q }) }); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // 按 SSE 格式解析出完整的 data 段 const parts = buffer.split('\n\n'); buffer = parts.pop(); for (const part of parts) { if (part.startsWith('data:')) { appendToAnswer(part.slice(5).trim()); } } }

这段代码里有个关键细节:不能假设每次read()拿到的就是一个完整的事件。网络传输是按字节流来的,一个 SSE 事件可能被拆到两次 read 里,也可能两次事件挤在一次 read 里。所以必须维护一个 buffer,按\n\n分隔符切分,最后一段不完整的留在 buffer 里等下次。这个"粘包/拆包"处理是流式解析的必修课,漏了就会出现文字错乱或丢字。

4.3 打字机渲染:追加而非重绘

渲染逻辑本身很简单,核心是"追加":

function appendToAnswer(text) { answerEl.textContent += text; scrollToBottom(); }

但有两个体验细节值得注意。一是自动滚动:内容变长后要自动滚到底部,但用户如果手动往上翻看历史,就别强行把他拽回来,否则很烦人。判断方法是看当前滚动位置是否接近底部,接近才自动滚。二是 Markdown 渲染:如果回答里有代码块、列表,边流边渲染 Markdown 会出问题——因为一个代码块可能只来了一半,渲染器会把它当成普通文本。常见做法是流式过程中先按纯文本显示,等流结束后再做一次完整的 Markdown 渲染。

4.4 光标与节奏:别过度设计

很多教程会教你加一个闪烁的光标,或者用定时器逐字"补间"动画。我的经验是:光标可以加,逐字补间动画慎用。因为数据本身已经是流式的,你再叠一层动画,容易出现"数据早到了、动画还在慢慢放"的割裂感,用户会觉得卡。老老实实"来一段显示一段",配合一个简单的光标,效果反而最自然。

5. 实测中的意外:那些让打字机"卡壳"的真实问题

前面讲的都是顺理成章的路径,但真跑起来,问题往往出在意想不到的地方。这一节我把实际遇到过的几个典型故障和排查过程完整还原一下,方便你对照。

5.1 现象:文字成批出现,不是逐字

第一次上线后,测试同学反馈"有时候一次蹦出一大段,不是一个个字"。排查思路是这样的:

先确认后端是不是真的在流式发。用 curl 直接打后端接口:

curl -N http://localhost:8080/chat/stream?question=你好

-N参数关闭 curl 自己的缓冲。如果 curl 里能看到文字一个个出现,说明后端没问题,问题在前端或中间层。如果 curl 里也是一大坨,那问题在后端或模型调用。

结果 curl 显示是逐字来的,那问题就在前端。检查发现前端用了某个 HTTP 库,它默认会把响应整体缓冲后再交给回调。换成原生 fetch 的流式读取后,问题消失。结论:消费端一定要用支持流式读取的方式,别用会自动缓冲的封装库。

5.2 现象:生产环境比本地慢很多

本地逐字流畅,线上却要等好几秒才出第一个字。这种"本地好、线上差"的问题,八成是中间层。按这个顺序排查:

  1. 先看是不是网关/代理缓冲——前面说的proxy_buffering off。
  2. 再看是不是 CDN 或负载均衡在攒包。
  3. 最后看模型服务本身的首 token 延迟(TTFT)是不是变长了。

我们那次是网关缓冲,关掉后首字延迟从 3 秒降到几百毫秒。首 token 延迟是流式体验的关键指标,用户感知的"快"主要来自第一个字多快出现,而不是整体生成多快。

5.3 现象:用户关页面后模型还在跑

监控发现有些请求在用户早已离开后还在消耗 token。原因是链路中间有一处用了阻塞式收集,导致取消信号传不到模型侧。改成全程响应式后,客户端断开时 Flux 收到 cancel,模型请求随之终止。这一点直接关系到成本,尤其是高并发场景,不处理的话账单会很难看。

5.4 现象:中文偶尔出现乱码或半个字

这是字节流解码的问题。TextDecoder在解码多字节字符时,如果一次 read 正好切在一个汉字的中间,直接decode会得到乱码。解决办法是解码时带上{ stream: true }参数,它会让解码器把不完整的字节缓存起来,等后续字节到了再一起解。这个参数不加,中文场景几乎必踩。

现象可能原因排查手段解决方向
成批出现消费端缓冲curl -N 对比换流式读取
线上慢代理缓冲逐层绕过测试关闭 proxy_buffering
关页仍消耗取消信号中断查阻塞调用全程响应式
中文乱码解码未流式观察半个字TextDecoder stream 模式

6. 让流式更稳更省的几个进阶思路

基础跑通之后,还有一些优化空间,能让这套方案在生产环境更扛造。

6.1 首 token 延迟才是体验命门

用户对"快"的感知,几乎全部集中在第一个字出现的时间上。整体生成 10 秒但首字 0.3 秒出现,用户会觉得"挺快";整体只要 3 秒但首字憋了 2 秒,用户会觉得"卡"。所以优化重点应该放在降低首 token 延迟:精简 prompt、减少上下文长度、选择响应更快的模型、把不必要的预处理挪到流开始之前。别把时间花在优化整体生成速度上,先盯首字。

6.2 断线续传与状态保持

SSE 断线后,EventSource 会自动重连,但重连后是从头发还是接着发?默认是从头,因为服务端不知道你收到哪了。如果回答很长,重连后重复推送会很浪费。可以在事件里带上递增的 id,前端记录已收到的最大 id,重连时通过Last-Event-ID头告诉服务端从哪继续。Spring 这边可以用ServerSentEvent包装元素来设置 id。这个机制在长回答场景下很有价值。

6.3 限流与并发保护

流式连接是长连接,一个用户占一个连接,并发一高,连接数会迅速堆积。要在网关或应用层做并发限制,比如单用户最多同时开 N 个流、全局连接数上限、超时强制断开。否则一次流量高峰就可能把连接池打满,拖垮整个服务。

6.4 内容安全与合规过滤

流式输出有个特殊难点:内容是一段段来的,你没法在推送前对整段做完整审核。常见做法是边流边做增量检测,发现敏感内容立即中断流并替换为提示。这需要在 Flux 中间插入一个过滤操作符,对每个片段做检查。虽然增量检测不如整段检测准确,但在流式场景下是必要的折中。

7. 我在这套方案上踩过之后总结的几条经验

流式输出这东西,原理不复杂,难的是把链路上每一环都照顾到。我自己趟下来,最深的体会是:问题几乎从来不出在"流式"本身,而是出在链路的某个中间环节偷偷做了缓冲或阻塞。所以排查时永远先怀疑中间层,用 curl 从后端一路往前端测,很快就能定位。

另外,别小看首 token 延迟和取消信号这两件事。前者决定用户觉得快不快,后者决定你的成本高不高。很多团队把功能做出来了,却在这两点上吃亏。把timeout、onErrorResume、响应式取消这些细节补上,整套方案才算真正能上生产。

最后分享一个小技巧:调试流式接口时,在服务端每个片段发出前打一行带时间戳的日志,前端也记录每个片段到达的时间。两边一对,就能清楚看到延迟到底发生在模型生成、网络传输还是前端渲染,比盲猜高效得多。这套日志我在好几个项目里都留着,出问题时省了大量排查时间。

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

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

立即咨询