导读:AI 生成回答让用户干等?流式输出让文字像打字机一样实时呈现!本文手把手教你用 Spring AI 实现 SSE 流式推送、停止生成控制、结构化 JSON 事件------代码全给你,直接抄进项目!
🎯 前言:流式输出是用户体验的分水岭
用过 ChatGPT 的人都知道,它的回答是一个字一个字"蹦"出来的,而不是等全部生成完才显示。这就是**流式(Streaming)**模式。在 Spring AI 里启用流式,用户等待的焦虑感直接减半,感知延迟从"秒级"降到"毫秒级" 。
本章把流式聊天的完整技术链路彻底讲透:从底层接口到 Web 应用实战,再到结构化事件设计。建议先收藏,跟着代码一步步敲!
📌 核心要点抢先看
- ✅ 流式需要两个前提:模型服务支持 + Spring AI 实现
StreamingChatModel接口 - ✅
ChatClient.stream()返回Flux<ChatResponse>,配合 SSE 推送到浏览器 - ✅ 停止生成 用
Sinks.One<Boolean>+takeUntilOther优雅实现 - ✅
switchMap确保新输入自动切换响应流,避免"消息串台" - ✅ 结构化 JSON 事件让前端不止收到文字,还能拿到 Token 用量、开始/结束信号
🧱 第一节:StreamingChatModel 接口------流式的入口
1.1 为什么返回类型是 Flux?
普通调用 call() 是阻塞式的:发请求 → 等模型生成完 → 收到完整响应。流式调用 stream() 则是:发请求 → 模型每生成一部分就推一部分 → 你实时收到片段。
Spring AI 用 Project Reactor 的 Flux 来表示这种"持续产生的数据流":
csharp
public interface StreamingModel<TReq extends ModelRequest<?>,
TResChunk extends ModelResponse<?>> {
Flux<TResChunk> stream(TReq request);
}
和 Model.call() 的区别:
| 维度 | call() |
stream() |
|---|---|---|
| 返回类型 | 单个 TRes |
Flux<TResChunk> |
| 获取时机 | 全部生成完 | 实时片段 |
| 用户体验 | 白屏等待 | 即时反馈 |
| 适用场景 | 后台任务、短文本 | 聊天对话、长文本生成 |
1.2 StreamingChatModel 的便捷方法
StreamingChatModel 继承自 StreamingModel<Prompt, ChatResponse>,还提供了两个默认方法:
arduino
Flux<String> stream(String message);
Flux<String> stream(Message... messages);
如果你只关心纯文本内容,不需要构造 Prompt 对象,直接用这俩,省事!
1.3 ChatClient 的流式 API
scss
chatClient.prompt().user("你好").stream()
.chatClientResponse() // Flux<ChatClientResponse> --- 最全,含上下文
.chatResponse(); // Flux<ChatResponse> --- 含元数据(Token用量等)
.content(); // Flux<String> --- 纯文本,最简单
💡 选型建议 :需要监控 Token 用量 → 用
chatResponse();只展示文字 → 用content()。
🌐 第二节:实战!流式 Web 应用完整代码
2.1 我们要做什么?
一个简单的网页聊天界面:
- 用户输入文字 → POST 到后端
- 后端调 AI 模型流式生成 → 通过 SSE(Server-Sent Events) 实时推送到浏览器
- 用户可以随时点 "停止" 按钮终止生成
🔑 SSE 是什么? 浏览器原生支持的服务器单向推送协议,基于 HTTP,比 WebSocket 轻量,专门适合这种"服务器持续推文字"的场景。
2.2 核心难题:怎么优雅地停止流?
流一旦启动,模型会持续生成 Token。如果用户点了"停止",我们需要取消订阅、停止接收后续数据 。这里用 Reactor 的 Sinks.One<Boolean> 作为信号量。
ChatResponseHolder:停止信号封装
csharp
public record ChatResponseHolder(Flux<ChatResponse> response,
Sinks.One<Boolean> stopSignal) {
public ChatResponseHolder(Flux<ChatResponse> response) {
this(response, Sinks.one());
}
/**
* 关键:takeUntilOther 持续从流中取元素,
* 直到 stopSignal 发射一个值,立即停止!
*/
public Flux<ChatResponse> chatResponse() {
return response.takeUntilOther(stopSignal.asMono());
}
public void stop() {
stopSignal.tryEmitValue(true);
}
}
原理图解:
scss
Flux<ChatResponse> 持续产生 token ──→ takeUntilOther ──→ 前端实时显示
↑
stopSignal.asMono()
↑
用户点击"停止" → tryEmitValue(true)
2.3 StreamingChatService:业务核心
csharp
public class StreamingChatService {
private final AtomicReference<ChatResponseHolder> chatResponseHolder
= new AtomicReference<>();
private final Flux<ChatResponse> chatResponses;
private FluxSink<String> fluxSink;
public StreamingChatService(ChatClient chatClient) {
// Flux.create 创建一个可外部推送元素的流
Flux<String> chatInputs = Flux.create(sink -> fluxSink = sink);
// switchMap:新输入到达时,自动切换到新的响应流,丢弃旧的!
chatResponses = chatInputs.switchMap(input -> {
var holder = new ChatResponseHolder(
chatClient.prompt().user(input).stream().chatResponse()
);
chatResponseHolder.set(holder);
return holder.chatResponse();
});
}
// 用户发送新消息 → 推入 FluxSink 触发流式生成
public void chat(String input) {
fluxSink.next(input);
}
// 停止当前生成
public void stopGeneration() {
if (chatResponseHolder.get() != null) {
chatResponseHolder.get().stop();
}
}
// SSE 端点订阅这个流
public Flux<ChatResponse> getChatResponses() {
return chatResponses;
}
}
🎯 为什么必须用
switchMap?假设用户连续快速发了两条消息:
- 没有
switchMap:两条消息的响应会混在一起,前端看到文字乱串- 有
switchMap:第二条消息到达时,自动取消第一条的订阅,只展示第二条的结果这就是
switchMap的魔力------自动丢弃旧流,只保留最新!
2.4 StreamingChatController:SSE 端点暴露
kotlin
@RestController
@RequestMapping("/streaming")
public class StreamingChatController {
private final StreamingChatService chatService;
public StreamingChatController(StreamingChatService chatService) {
this.chatService = chatService;
}
// 接收用户输入,触发流式生成
@PostMapping("/chat")
public ResponseEntity<Void> chatInput(@RequestBody ChatRequest request) {
chatService.chat(request.input());
return ResponseEntity.noContent().build();
}
// 停止当前生成
@PostMapping("/stop")
public ResponseEntity<Void> stopGeneration() {
chatService.stopGeneration();
return ResponseEntity.noContent().build();
}
/**
* 核心 SSE 端点:
* produces = TEXT_EVENT_STREAM_VALUE 告诉浏览器这是事件流
*/
@GetMapping(value = "/chatResponse",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamingChat() {
return chatService.getChatResponses()
.map(response -> ServerSentEvent.<String>builder()
.event("message")
.data(response.getResult().getOutput().getText())
.build());
}
}
2.5 前端 HTML:EventSource 接收流
xml
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Spring AI Streaming Chat Client</title>
<style>
#messages { height: 500px; border: 1px solid grey; overflow-y: scroll; }
#controls { margin: 10px 0; }
</style>
<script src="https://unpkg.com/axios@1.6.7/dist/axios.min.js"></script>
<script>
// EventSource 自动建立 SSE 连接,持续监听服务器推送
var eventSource = new EventSource("/streaming/chatResponse");
eventSource.addEventListener("message", (e) => {
var messages = document.getElementById("messages");
var message = document.createElement("p");
message.textContent = e.data;
messages.appendChild(message);
});
function chat() {
var input = document.getElementById("chatInput");
if (input.value) {
axios.post("/streaming/chat", { input: input.value });
}
}
function stop() {
axios.post("/streaming/stop");
}
</script>
</head>
<body>
<h1>Spring AI Streaming Chat Client</h1>
<div id="messages"></div>
<div id="controls">
<input id="chatInput">
<button onclick="chat()">Send</button>
<button onclick="stop()">Stop</button>
</div>
</body>
</html>
运行效果 :访问 http://localhost:8080/streaming.html,输入 "how to cook fish",AI 的文字会像打字一样一行一行出现在页面上。点击 Stop 按钮,生成立刻停止!
📡 第三章:Streaming JSON Events------不止返回文字
3.1 为什么需要结构化事件?
前面示例里,SSE 只推送了 AI 生成的文本。但生产环境中,前端往往还需要:
- Token 用量:这次对话花了多少 Token?
- 开始/结束信号:方便前端做加载动画、滚动到底部
- 会话 ID:用于多轮对话上下文关联
如果把这些信息全塞进纯文本里,前端解析起来很痛苦。更好的方案是:每个 SSE 事件都是一个结构化 JSON,用 event 字段区分类型。
3.2 事件类型设计:Sealed Interface + Java Record
typescript
import com.fasterxml.jackson.annotation.JsonIgnore;
public sealed interface StreamingEvent {
@JsonIgnore
String getEventType();
// ── DeltaEvent:AI 生成的内容片段 ──
record DeltaEvent(String v) implements StreamingEvent {
@Override
public String getEventType() {
return "delta";
}
}
// ── MessageEvent:元数据类事件 ──
sealed interface MessageEvent extends StreamingEvent {
@Override
default String getEventType() {
return "message";
}
String getType();
}
// Token 用量统计
record TokenUsageEvent(String conversationId, int totalTokens)
implements MessageEvent {
@Override
public String getType() {
return "token_usage";
}
}
// 流式传输开始
record MessageStreamingStartEvent(String conversationId)
implements MessageEvent {
@Override
public String getType() {
return "message_streaming_start";
}
}
// 流式传输完成
record MessageStreamingCompleteEvent(String conversationId)
implements MessageEvent {
@Override
public String getType() {
return "message_streaming_complete";
}
}
}
设计亮点:
sealed interface限制实现类,编译器帮你检查有没有漏处理的事件类型JsonIgnore让 Jackson 序列化时忽略getEventType()方法,避免循环event字段用getEventType()区分,data字段用getType()再细分
3.3 控制器:多源拼接流
less
@RestController
@RequestMapping("/streaming_app")
public class StreamingAppChatController {
private final ChatClient chatClient;
public StreamingAppChatController(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<StreamingEvent>> streamingChat(
@RequestBody ChatRequest request) {
var conversationId = UUID.randomUUID().toString();
var totalTokens = new AtomicInteger(0);
return Flux.concat(
// 1. 先发"开始"事件,前端可以展示加载态
Mono.just(new MessageStreamingStartEvent(conversationId)),
// 2. AI 生成的内容流,同时累加 Token 用量
chatClient.prompt().user(request.input()).stream()
.chatResponse()
.doOnNext(response -> {
var usage = response.getMetadata().getUsage();
if (usage != null) {
totalTokens.addAndGet(usage.getTotalTokens());
}
})
.map(response -> new DeltaEvent(
response.getResult().getOutput().getText())),
// 3. 生成结束后发 Token 统计(Mono.defer 延迟求值)
Mono.defer(() -> Mono.just(
new TokenUsageEvent(conversationId, totalTokens.get()))),
// 4. 最后发"完成"事件,前端可以收起加载态
Mono.just(new MessageStreamingCompleteEvent(conversationId))
).map(event -> ServerSentEvent.<StreamingEvent>builder()
.event(event.getEventType()) // SSE 的 event 字段
.data(event) // SSE 的 data 字段(JSON)
.build());
}
}
🔥 关键技术点:
Flux.concat:按顺序拼接多个流,确保事件顺序严格为 开始 → 内容 → 统计 → 完成Mono.defer:延迟创建 Mono ,确保执行到这里时totalTokens已经累加完毕- 如果不写
defer,Mono.just会立即求值,此时totalTokens还是 0!
3.4 前端收到的原始 SSE 流
输入 "hi" 后,浏览器收到的原始事件流长这样:
vbnet
event:message
data:{"conversationId":"c5b37454-c08b-4753-bc65-544b2feb2d79","type":"message_streaming_start"}
event:delta
data:{"v":"Hello"}
event:delta
data:{"v":"!"}
event:delta
data:{"v":" How"}
event:delta
data:{"v":" can"}
event:delta
data:{"v":" I"}
event:delta
data:{"v":" assist"}
event:delta
data:{"v":" you"}
event:delta
data:{"v":" today"}
event:delta
data:{"v":"?"}
event:delta
data:{"v":""}
event:message
data:{"conversationId":"c5b37454-c08b-4753-bc65-544b2feb2d79","totalTokens":19,"type":"token_usage"}
event:message
data:{"conversationId":"c5b37454-c08b-4753-bc65-544b2feb2d79","type":"message_streaming_complete"}
前端处理逻辑:
ini
eventSource.addEventListener("delta", (e) => {
const data = JSON.parse(e.data);
appendText(data.v); // 逐字追加到聊天框
});
eventSource.addEventListener("message", (e) => {
const data = JSON.parse(e.data);
if (data.type === "token_usage") {
showTokenCount(data.totalTokens); // 显示 Token 用量
} else if (data.type === "message_streaming_complete") {
hideLoading(); // 隐藏加载动画
}
});
🧠 总结:流式输出技术全景图
scss
┌─────────────────────────────────────────────────────────────────────┐
│ Spring AI 流式输出架构 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 用户输入 ──POST /chat──→ StreamingChatService.chat(input) │
│ ↓ │
│ FluxSink.next(input) │
│ ↓ │
│ switchMap ──→ ChatClient.prompt().user(input).stream() │
│ ↓ │
│ Flux<ChatResponse> │
│ ↓ │
│ ChatResponseHolder ──→ takeUntilOther(stopSignal) │
│ ↓ │
│ Controller ──→ Flux<ServerSentEvent<String|StreamingEvent>> │
│ ↓ │
│ SSE (TEXT_EVENT_STREAM_VALUE) │
│ ↓ │
│ 浏览器 EventSource │
│ │
│ 停止按钮 ──POST /stop──→ StreamingChatService.stopGeneration() │
│ ↓ │
│ Sinks.One.tryEmitValue(true) │
│ ↓ │
│ takeUntilOther 终止取流 │
│ │
└─────────────────────────────────────────────────────────────────────┘
💬 互动时间
你实现流式输出时踩过哪些坑?
- A :
switchMap没加,消息串台了 - B:停止逻辑不会写,直接关连接太粗暴
- C:SSE 和 WebSocket 选哪个纠结很久
- D:结构化事件设计太复杂,直接用纯文本了
- E:其他问题,评论区聊聊
这篇文章把流式聊天的完整链路都盘清楚了,代码可以直接复制到项目里跑!觉得有用的话,点赞👍、收藏⭐、转发给队友~ 下章预告:结构化输出转换器 + RAG 检索增强,让 AI 不止会聊天,还会乖乖返回 JSON、结合知识库回答! 🚀