用过 ChatGPT 的同学一定对那个"一个字一个字往外蹦"的效果印象深刻。很多人以为这是前端做的动画,其实它来自模型 API 本身的流式响应能力。如果你还在用同步调用让用户干等 10 秒才看到完整回复,那这篇文章就是为你准备的。

本文将带你完整实现一个生产可用的流式对话系统:从 Spring AI 的 Flux<String> 基础,到 SSE 推送到前端,再到多轮记忆、异常处理和性能选型。全程有代码、有测试命令、有前端示例,跟着做就能直接搬进你的项目。

一、同步 vs 流式:体验天差地别
传统同步调用:
String content = chatClient.prompt()
.user("写一首关于春天的诗")
.call()
.content(); // 等到完整结果才返回
用户要等模型生成完所有文字(比如 10 秒)才一次性看到结果。而流式调用:
Flux<String> stream = chatClient.prompt()
.user("写一首关于春天的诗")
.stream()
.content(); // 返回一个热流,每生成几个字就推一次
即使总耗时依然是 10 秒,用户在第 0.5 秒就能看到第一个字,体验完全不在一个档次。Spring AI 基于 Reactor 的 Flux 让这件事变得异常简单。

二、Server-Sent Events(SSE):最适合 Web 的流式方案
后端有了 Flux,前端怎么接收?SSE (Server-Sent Events)是最佳选择:浏览器原生支持(EventSource API),无需 WebSocket 或额外库,数据格式就是普通的 HTTP 流。

2.1 Spring MVC 下的 SSE 接口
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
@RestController
@RequestMapping("/api/stream")
public class StreamController {
private final ChatClient chatClient;
public StreamController(ChatClient.Builder builder) {
this.chatClient = builder
.defaultSystem("你是一个 Java 技术助手,回答详细且有条理。")
.build();
}
@GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestParam String message) {
return chatClient.prompt()
.user(message)
.stream()
.content();
}
}
produces = MediaType.TEXT_EVENT_STREAM_VALUE 是关键,Spring 会自动将 Flux 的每个元素以 SSE 格式写入响应流。
💡 测试命令
curl "http://localhost:8080/api/stream/chat?message=解释一下JVM内存模型"你会看到文字一句一句地出现在终端。
三、流式 + 多轮对话:带 conversationId 的流式多轮对话

流式输出不影响多轮对话。Spring AI 的 MessageChatMemoryAdvisor 搭配 MessageWindowChatMemory 可以轻松实现带会话 ID 的流式记忆。
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
@RestController
@RequestMapping("/api/stream/conversation")
public class StreamConversationController {
private final ChatClient chatClient;
private final MessageWindowChatMemory chatMemory;
public StreamConversationController(ChatClient.Builder builder) {
this.chatMemory = MessageWindowChatMemory.builder().maxMessages(10).build();
this.chatClient = builder.defaultSystem("你是一个 Java 技术助手").build();
}
@GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestParam String message,
@RequestParam(defaultValue = "default") String conversationId) {
return chatClient.prompt()
.user(message)
.advisors(MessageChatMemoryAdvisor.builder(chatMemory)
.conversationId(conversationId)
.build())
.stream()
.content();
}
}
测试:
# 第一轮:告诉AI名字
curl "http://localhost:8080/api/stream/conversation?message=我叫大王&conversationId=user001"
# 第二轮:询问记忆(同一个conversationId)
curl "http://localhost:8080/api/stream/conversation?message=你还记得我叫什么吗&conversationId=user001"
AI 会记得之前说过的话,而且所有回复都是流式的。
四、WebFlux 版本:更高性能的响应式写法
如果你的项目已经用了 Spring WebFlux,代码会更加简洁(框架对 Flux 是原生支持)。只需将 spring-boot-starter-web 替换为 spring-boot-starter-webflux:
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
@RestController
@RequestMapping("/api/stream")
public class WebFluxStreamController {
private final ChatClient chatClient;
public WebFluxStreamController(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
@GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chat(@RequestParam String message) {
return chatClient.prompt()
.user(message)
.stream()
.content()
.doOnNext(chunk -> System.out.print(chunk))
.doOnComplete(() -> System.out.println("\n[完成]"));
}
}
WebFlux 下 Flux 直接作为响应体,框架自动处理背压和 SSE 格式转换。
五、流式输出时拿完整响应

很多场景下,我们既希望实时流式推送给用户,又希望在后端把完整的模型回复存下来(比如记录到数据库)。这俩需求完全不冲突------利用 doOnNext 和 doOnComplete 即可:
@RestController
@RequestMapping("/api/stream/save")
public class StreamSaveController {
private static final Logger log = LoggerFactory.getLogger(StreamSaveController.class);
private final ChatClient chatClient;
public StreamSaveController(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
@GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatAndSave(@RequestParam String message,
@RequestParam(defaultValue = "default") String conversationId) {
StringBuilder fullResponse = new StringBuilder();
return chatClient.prompt()
.user(message)
.stream()
.content()
.doOnNext(fullResponse::append) // 累积每一个chunk
.doOnComplete(() -> saveToDatabase(conversationId, message, fullResponse.toString()));
}
private void saveToDatabase(String conversationId, String question, String answer) {
log.info("保存对话 {} - 问题: {} - 回复长度: {}", conversationId, question, answer.length());
// 这里调用你的数据库服务
}
}
测试:
curl "http://localhost:8080/api/stream/save?message=用三句话介绍Spring AI&conversationId=session001"
你会看到流式输出打印在前端,同时后端日志里也会输出保存记录。
六、错误处理:别让异常破坏流式体验
流式调用有个容易踩坑的地方:一旦开始推送数据,就无法再返回 HTTP 4xx/5xx 状态码。正确的做法是把错误信息也作为一条流数据推给前端,然后优雅结束流。
@RestController
@RequestMapping("/api/stream/safe")
public class StreamSafeController {
private final ChatClient chatClient;
public StreamSafeController(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
@GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestParam String message) {
return chatClient.prompt()
.user(message)
.stream()
.content()
.timeout(Duration.ofSeconds(30)) // 30秒超时
.onErrorResume(TimeoutException.class,
e -> Flux.just("[响应超时,请重试]"))
.onErrorResume(e -> {
log.error("流式调用出错: {}", e.getMessage());
return Flux.just("[抱歉,生成过程中出现错误,请稍后重试]");
});
}
}
这样,无论超时还是模型异常,前端都能收到明确的错误文本,流也会正常关闭。
七、前端完整示例(带输入框 + 停止按钮)
光有后端还不够,前端用 EventSource 几行代码就能对接。下面是一个功能完整的 HTML 示例,支持:
-
流式追加显示
-
自动滚动到底部
-
停止按钮中断流
-
自动生成会话 ID(支持多轮)
<!DOCTYPE html> <html> <head> <title>AI 流式对话</title> <style> body { font-family: sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; } #output { white-space: pre-wrap; border: 1px solid #ddd; padding: 15px; min-height: 200px; border-radius: 8px; background: #f9f9f9; margin-top: 10px; } .controls { display: flex; gap: 10px; margin-top: 10px; } input { flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 4px; } button { padding: 8px 16px; border-radius: 4px; cursor: pointer; border: none; } #sendBtn { background: #4CAF50; color: white; } #stopBtn { background: #f44336; color: white; display: none; } </style> </head> <body>Spring AI 流式对话
等待输入...<button id="sendBtn" onclick="sendMessage()">发送</button> <button id="stopBtn" onclick="stopStream()">停止</button><script> let eventSource = null; const conversationId = 'user_' + Date.now();function sendMessage() {
const input = document.getElementById('input');
const output = document.getElementById('output');
const message = input.value.trim();
if (!message) return;if (eventSource) eventSource.close(); output.textContent = ''; document.getElementById('sendBtn').disabled = true; document.getElementById('stopBtn').style.display = 'inline-block'; const url = `/api/stream/chat?message=${encodeURIComponent(message)}&conversationId=${conversationId}`; eventSource = new EventSource(url); eventSource.onmessage = (event) => { output.textContent += event.data; output.scrollTop = output.scrollHeight; }; eventSource.onerror = () => onStreamEnd(); input.value = '';}
function stopStream() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
onStreamEnd();
}function onStreamEnd() {
</body> </html>
document.getElementById('sendBtn').disabled = false;
document.getElementById('stopBtn').style.display = 'none';
eventSource = null;
}
</script>
将上述 HTML 放在 src/main/resources/static/index.html,启动 Spring Boot 后访问 http://localhost:8080 即可体验。
八、Spring MVC vs WebFlux:到底怎么选?

| 场景 | 推荐 |
|---|---|
| 已有 Spring MVC 项目,只是加 AI 功能 | Spring MVC + Flux 返回值(本文主要演示的) |
| 新项目,全栈响应式编程 | Spring WebFlux |
| 需要高并发流式连接(数千个同时) | Spring WebFlux |
| 简单 CRUD + AI 功能 | Spring MVC 完全够用 |
注意:Spring MVC 也可以返回 Flux,框架会自动处理流式写入,不一定要全栈切到 WebFlux。