Spring AI 流式输出:Flux + SSE 打字机,回答不再干等三秒
作者:鱼宵 | Spring AI 实战精通营 · 第 5 篇
前阵子帮同事调一个 AI 客服页面:用户点"提问"之后,页面要白白地干等三秒多,整段回答才一口气蹦出来。有个用户没忍住,连点了三次——后来翻后台日志,同一个问题请求了三遍。问题出在哪?前四节课我们写的 call() 是阻塞式:模型把整段回答生成完之前,一个字都不给你。ChatGPT 那种字一个接一个往外蹦的打字机效果,Spring AI 里只要把 call() 换成 stream() 就行。
这篇文章带你在本机把这个效果跑出来,顺手把 SSE、Flux、背压说清。代码全在仓库 lesson-05/ 目录,clone 下来照着命令重跑,五分钟后你的浏览器也能打出打字机——尤其后头那两道挑战题,不亲手删一行注解跑一遍,你真体会不到 produces 为什么不能省。
一、核心原理:阻塞等整段,还是边写边出?
先把两个词解释清楚,面试都要考。
Flux:Reactor 响应式库里的类型,表示"0 到 N 个元素的异步序列"。你就当它是一根会陆续吐出字符串的水管——模型吐出一小段,水管就往下游流一段,订阅它就能逐个收到。
SSE(Server-Sent Events,服务器单向推流):HTTP 的一种响应格式。响应头写上 Content-Type: text/event-stream,服务器保持这条 TCP 连接不断开,按 data: 内容\n\n 的格式一段一段往浏览器写。
1. call() 和 stream(),差在"第一口"
前四课的 .call() 是阻塞的:请求发出去,线程卡在那等,直到模型把整段回答全部生成完,一次性返回。.stream() 是流式的:模型生成一小段(几个字、几个 token)就立刻推出来一段,不等整段完成。
类比接水:call() 像把水龙头开最大但拿个桶接着——桶没接满之前你一口都喝不到;stream() 像直接对着水龙头喝——水一滴一滴出来,你第一口瞬间就有了。总时间差不多,但"第一口等待"从几秒降到几百毫秒,这就是流式改善体感的全部秘密。
| call() | stream() | |
|---|---|---|
| 返回类型 | String | Flux<String> |
| 什么时候吐字 | 整段生成完才返回 | 第一个 token 就开始推 |
| 用户看到的画面 | 空白几秒 → 整段蹦出 | 字一个接一个蹦 |
| 首字延迟 | ≈ 全部生成时间 | ≈ 几百毫秒 |
2. SSE 和 WebSocket,别上来就选重的
这个对比面试常问,一张表说清:
| SSE | WebSocket | |
|---|---|---|
| 协议 | 就是 HTTP | 独立的新协议 |
| 方向 | 单向,只能服务器推浏览器 | 全双工,双向 |
| 浏览器接入 | new EventSource() 一行,自动重连 | 要自己管握手和连接 |
| 适合场景 | AI 回答"问一次听一段" | 聊天室、实时协同 |
类比电台广播:你拧开收音机(EventSource)就开始听,电台(服务器)一边录一边播;广播是单向的,你没法在收音机里跟主持人插话。AI 回答就是"问一次、听一段",SSE 刚好够用,主流 AI 产品全用它,别过度设计上 WebSocket。
3. 错误写法 vs 正确写法:produces 漏写就全白搭
// 错误写法:少了 produces,流式变成"憋大招"
@GetMapping("/chat/stream")
public Flux<String> stream(String msg) { ... }
少写 produces = MediaType.TEXT_EVENT_STREAM_VALUE,Spring 检测不到这是 SSE 流,会把 Flux 当普通对象——等它跑完攒成一个 JSON 数组一次性返回。你看到的现象就是"流式坏了",所有字攒一坨才出来。正确写法见第三节代码。
4. 背压,一句话版
模型吐字快、前端渲染慢怎么办?背压就是下游向上游喊一句"慢点,我跟不上"的机制,上游收到后自己控制节奏。面试一句话答:背压是消费方对生产方的反向调速,防止灌太快把下游淹了。 本课代码一个背压处理都不用写,知道概念即可。
二、动手:十分钟打出打字机
环境:Windows + JDK 17 + Maven 3.9+,会
@RestController就行,不需要预先会 Reactor。
第 1 步:30 秒检查环境。
java -version # 期望 True
[bool][Environment]::GetEnvironmentVariable('DEEPSEEK_API_KEY') # 期望 True
Get-NetTCPConnection -LocalPort 8097 -State Listen -ErrorAction SilentlyContinue # 无输出=端口空闲
第 2 步:编译 + 启动。
cd spring-ai-journey\lesson-05
$env:JAVA_HOME="C:\Program Files\Java\jdk-17" # Maven 必须跑在 JDK 17 上
mvn clean install -DskipTests # 结尾看到 BUILD SUCCESS
mvn spring-boot:run # 看到 Tomcat started on port 8097 即成功
第 3 步:浏览器体验打字机。
直接打开 http://localhost:8097/(静态页由 Spring Boot 从 static/ 目录自动托管),点"提问",看回答逐段出现。
第 4 步:命令行看 SSE 原始帧(推荐,最直观)。
$q = [uri]::EscapeDataString('用一句话介绍你自己')
curl.exe -N "http://localhost:8097/chat/stream?msg=$q"
-N 关掉 curl 的输出缓冲逐帧打印。少了它,输出被 curl 攒着最后才一起吐,你会误以为流式坏了——这是命令行验证流式的第一大坑。
三、关键代码:两个接口,只差两行
工程是个标准 Spring Boot 项目,真正要看的就四处:yml、Controller、启动类、网页那几行 JS。
第一段:application.yml——端口 8097,回答上限 300 token。
server:
port: 8097 # 端口按课程分配表:spring-ai 系列 lesson-05 = 8097
spring:
ai:
openai:
base-url: ${LLM_BASE_URL:https://api.deepseek.com} # 环境变量优先,默认 DeepSeek(兼容 OpenAI 流式协议)
api-key: ${DEEPSEEK_API_KEY} # Key 只从环境变量读,文件里永远没有明文
chat:
options:
model: ${LLM_MODEL:deepseek-chat} # 默认 deepseek-chat
max-tokens: 300 # 回答稍长一点,才看得出逐字吐出
temperature: 0.7
第二段:StreamChatController.java——一阻塞一流式,两个接口做对照。
package com.springai.lesson05;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
/**
* 流式聊天控制器:本课唯一的 HTTP 入口。
* 两个接口做对照(本课的教学设计):
* GET /chat/block?msg=... —— 老写法 call():阻塞等整段,一次性返回 String
* GET /chat/stream?msg=... —— 新写法 stream():返回 Flux<String>,逐字吐出
*/
@RestController
public class StreamChatController {
private final ChatClient chatClient;
// ChatClient.Builder 由 starter 自动装配,背后是 yml 里 spring.ai.openai.* 配置
public StreamChatController(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
/** 阻塞对照接口:和第 1~4 课写法一模一样,点了明显"卡住"几秒再整段蹦出来 */
@GetMapping("/chat/block")
public String block(@RequestParam("msg") String msg) {
return chatClient.prompt()
.user(msg)
.call() // 阻塞:等模型把整段回答生成完
.content();
}
/**
* 流式接口:produces = text/event-stream 是 SSE 的标志。
* stream().content() 返回 Flux<String>——模型每吐出一小段,Flux 就往下游发射一次;
* Spring MVC 检测到返回值是 Flux 且 produces=text/event-stream,
* 会自动把每个元素序列化成 "data: 你好" 帧推给浏览器。
*/
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream(@RequestParam("msg") String msg) {
return chatClient.prompt()
.user(msg)
.stream() // ★ 只把 call() 换成 stream()
.content(); // ★ 返回值从 String 变成 Flux<String>
}
}
看出来了吗?从阻塞改流式,业务代码只动两处:call() 换 stream(),返回值 String 换 Flux<String>,接口补一个 produces。Spring AI 内部对 DeepSeek 请求自动带上 stream=true,逐 chunk 解析流式响应。
还有个很多人不知道的点:Flux 不一定非要配 WebFlux。本课跑在 Tomcat servlet 容器上——Spring MVC 检测到返回值是 Flux 且 produces=text/event-stream,会用异步方式逐帧写回,别上来就引 WebFlux。
第三段:Lesson05Application.java——标准启动类。
package com.springai.lesson05;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Spring AI 实战精通营 · 第 5 课:流式输出。
* 启动后访问:
* 网页 http://localhost:8097/ —— 极简聊天页,打字机效果
* 接口 GET http://localhost:8097/chat/stream?msg=... —— SSE 流式接口
* 对照 GET http://localhost:8097/chat/block?msg=... —— 阻塞接口(憋大招)
*/
@SpringBootApplication
public class Lesson05Application {
public static void main(String[] args) {
SpringApplication.run(Lesson05Application.class, args);
}
}
第四段:网页打字机的本体——就这几行 JS。
// EventSource 是浏览器原生 SSE 客户端,只支持 GET——正好配我们的接口
var es = new EventSource('/chat/stream?msg=' + encodeURIComponent(msg));
es.onmessage = function (e) {
output.innerText += e.data; // 每来一帧追加一段 = 打字机效果
};
es.onerror = function () { es.close(); }; // 出错主动关,防止无限重连
new EventSource(url) 自动发 GET、自动剥掉 data: 前缀、断线自动重连;出错时不关,页面会反复刷请求——onerror 里记得 es.close()。
四、实测输出:同一问题,两种等法
以下是 2026-10-05 本机真实运行(DeepSeek 实测)。先看 SSE 流式接口,curl -N 逐帧现场:
$q = [uri]::EscapeDataString('用一句话介绍你自己')
curl.exe -N "http://localhost:8097/chat/stream?msg=$q"
data:我是
data:Deep
data:Se
data:ek
data:,
data:由
data:深度
data:求
data:索
data:公司
data:创造的
data:AI
data:助手
data:,
data:随时
data:准备
data:用
data:热情
data:细腻
data:的方式
data:帮你
data:解答
data:问题
data:、
data:处理
data:任务
data:!
再看阻塞对照接口,同一个问题:
$q = [uri]::EscapeDataString('用一句话介绍你自己')
Invoke-RestMethod "http://localhost:8097/chat/block?msg=$q"
整段约 1.17 秒后一次性返回:
我是DeepSeek,由深度求索公司创造的AI助手,随时准备用热情细腻的方式帮你解答问题、提供建议!
对比体感:block 接口那 1.17 秒里页面是全白的;stream 接口几百毫秒就吐出了第一帧"我是"。回答越长差距越扎眼——写 500 字的回答时,block 要干等十几秒,stream 第一句话早就出来了。
五、挑战题:改一改,看看会怎样
- ⭐ 堵一下 produces:故意把
StreamChatController里/chat/stream方法上的produces = MediaType.TEXT_EVENT_STREAM_VALUE删掉,重启再调接口,观察返回变成了什么格式(JSON 数组?),然后改回来。答案就在源码和本课第一节"错误写法"那段。 - ⭐ 换个长问题:把问题换成"详细介绍 Spring Bean 生命周期",分别 curl
-N调 stream、用Measure-Command包一下 block 接口,对比首帧时间差。回答越长,两个接口的体感差距越明显——跑一次比读十遍记得牢。 - ⭐⭐ 看截断长什么样:把问题故意设成超长(让模型生成顶到 yml 里
max-tokens: 300的上限),curl 盯着流看中途被截断时是什么表现——是突然停在半句,还是会吐一个结束帧?跑出来的发现记评论区。
六、生产环境进阶:三个加分项
1. 流式不省 token,只省等待。 模型按生成总量计费,流式改的是"什么时候让用户看到第一个字",不是"生成了多少字"。别跟老板说上了流式能降成本——降体感焦虑是真的。
2. EventSource 只支持 GET。 你想 POST 提问?不行,SSE 规范就是 GET,问题长就 URL 编码塞 query(本课做法)。
3. 别上来就引 WebFlux。 本课用的就是 Tomcat servlet 容器,Flux 流式照样跑;真要做聊天室这种双向协同,再评估 WebSocket。
七、面试回答模板
面试官:call() 和 stream() 的本质差别是什么?为什么流式能改善体感?
一句话:call() 阻塞等整段生成完才返回,stream() 返回 Flux 边生成边推。展开:模型吐一个 token 就推一帧,首字延迟从"全部生成完"降到"第一个 token 出来";总生成时间差不多,但用户不用盯着白屏干等。类比接水——桶接满才喝 vs 对着水龙头喝。业务代码只动两处:call 换 stream、String 换 Flux。(指向本课第一、三节 / lesson-05 的 StreamChatController)
追问:SSE 是什么?它和 WebSocket 怎么选?
一句话:SSE 是 HTTP 的单向推流,响应头 text/event-stream,按
data: 内容\n\n往客户端写。展开:它单向、就是 HTTP、EventSource 原生支持还自动重连;WebSocket 全双工,适合聊天室/实时协同。AI 回答这种"问一次听一段",SSE 就够用。(指向本课第一节对比表)
追问:返回 Flux 一定要用 WebFlux 吗?
不一定。servlet 版 Spring MVC 检测到返回值是 Flux 且 produces=text/event-stream,会异步逐帧写回——本课就是跑在 Tomcat(spring-boot-starter-web)上的。别上来就引 WebFlux。(指向本课第三节)
追问:什么是背压?一句话。
背压是消费方对生产方的反向调速,生产快消费慢时下游喊"慢点",防止灌太快把下游冲垮;Flux 响应式流原生支持。(指向本课第一节)
八、总结表
| 坑 | 现象 | 解法 |
|---|---|---|
| produces 漏写 | Flux 被攒成 JSON 数组一次返回 | 流式接口必加 produces = text/event-stream |
| curl 不加 -N | 输出被缓冲,像"流式坏了" | 命令行验证流式必须 curl.exe -N |
| EventSource 用 POST | 接口 404 / 连不上 | SSE 规范就是 GET,问题走 query 编码 |
| onerror 不 close | 模型答完后页面反复重连刷请求 | 回调里 es.close() |
| 以为 servlet 不能流式 | 盲目引 WebFlux 徒增复杂度 | Tomcat 也能推 Flux,别上来就重武器 |
| 端口占用 | Port 8097 was already in use | Get-NetTCPConnection -LocalPort 8097 查占用 |
九、关于这个系列
本文是「Java 后端实战精通营」系列第 5 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。
👉 Spring AI 实战精通营(10 课):gitee.com/j67mk2/spri…
- 本文对应源码位置:
lesson-05/(最小 Web 工程,内含StreamChatController双接口对照 +static/index.html打字机页面)
系列文章一览(按发布顺序):
| 篇 | 主题 |
|---|---|
| 1 | Spring AI 初体验:配好 yml 就能聊,ChatClient 四步链式调用 |
| 2 | Spring AI 提示词模板:{变量} 参数化 + few-shot,一条提示词反复用 |
| 3 | Spring AI 结构化输出:entity() 把模型回答解析成 JavaBean,别再手撕 JSON |
| 4 | Spring AI 工具调用:@Tool 让大模型自己查订单查库存 |
| 5 | Spring AI 流式输出:Flux + SSE 打字机,回答不再干等三秒 |
| 6 | Spring AI 多模态:给大模型一双眼睛,图片它也能看懂 |
| 7 | Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步 |
| 8 | Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话 |
| 9 | Spring AI Advisor 编排:记忆 + 工具 + RAG 三合一,一个接口全搞定 |
| 10 | Spring AI 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,十课收官 |
下一篇预告:《Spring AI 多模态:给大模型一双眼睛,图片它也能看懂》——前五课模型只会读文字、吐文字,下一课升级成"发一张图给它,让它描述画面、提取图上的字"。你会碰到 base64 和图片 URL 两种喂法,也会亲眼看到:拿 deepseek-chat 去传图,模型直接报错说自己不会看图。
跑完有任何报错,把终端输出发评论区,一起排查。
标签建议:SpringAI、流式输出、SSE 摘要建议(≤256 字):点 AI 页面干等三秒、整段回答才蹦出来?那是 call() 阻塞调用的锅。本文把 Spring AI 的 call() 换成 stream(),返回 Flux,配 produces=text/event-stream 就是 SSE 流式,浏览器 EventSource 几行 JS 打出打字机效果。逐行拆解两个接口对照,curl 实测首帧延迟差,附 produces 漏写、curl 缺 -N 等真实踩坑和面试回答模板,源码已开源 lesson-05 可 clone 直接跑。