基于 Spring WebFlux + SSE 实现生产级实时对话流式推送
本章带你完成 阶段 2:「流语」实时对话推送引擎 项目: 基于 Spring WebFlux + Reactor 构建一个具备流式响应、打字机效果、性能优化、多渠道适配的实时对话推送引擎。
本章是在第4章“记忆聊天”项目基础上新增“流式 SSE 接口”。建议你先把下面这些文件在 IDE 里对照一遍, 再开始看后面的代码实现。
src/
├── main/
│ ├── java/
│ │ └── ...
│ │ ├── controller/
│ │ │ └── MemoryChatController.java # 本章新增:/api/memory-chat/stream(SSE)
│ │ ├── service/
│ │ │ └── ConversationService.java # 会话历史管理(第4章已有)
│ │ └── dto/
│ │ ├── MemoryChatRequest.java # 请求:sessionId + message(第4章已有)
│ │ └── MemoryChatResponse.java # 响应 DTO(第4章已有)
│ └── resources/
│ └── static/
│ └── data/
│ ├── demo.html # 演示页面(可用于手工体验)
│ └── demo-script.js # 前端交互脚本(可扩展对 SSE 的支持)
└── test/
└── java/
└── ... #(可选)为流式接口补充集成测试MemoryChatController 中新增流式接口,返回 text/event-streamConversationService 做会话历史拼接与保存MemoryChatRequest / MemoryChatResponse| 特性 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 单向(服务器→客户端) | 双向 |
| 协议 | 基于 HTTP | 需要升级协议 |
| 实现难度 | 简单 | 复杂 |
| 适用场景 | 聊天机器人、实时通知 | 在线游戏、协作编辑 |
💡 对于 AI 聊天场景,SSE 是更好的选择。
在 MemoryChatController.java 中添加流式接口:
/**
* 流式聊天接口
*
* POST /api/memory-chat/stream
*/
@PostMapping(value = "/stream", produces = "text/event-stream")
public Flux<String> streamChat(@RequestBody MemoryChatRequest request) {
try {
// 验证请求
request.validate();
log.info("收到流式聊天请求 [会话: {}]: {}",
request.sessionId(), request.message());
// 添加用户消息到历史
conversationService.addUserMessage(
request.sessionId(),
request.message()
);
// 获取对话历史
List<Message> history = conversationService.getHistory(request.sessionId());
// 调用流式 API
Flux<ChatResponse> stream = chatModel.stream(new Prompt(history));
// 使用 AtomicReference 来收集完整回复(线程安全)
AtomicReference<String> fullResponse = new AtomicReference<>("");
return stream
.map(response -> response.getResult().getOutput().getText())
.filter(content -> content != null && !content.isEmpty())
.doOnNext(content -> {
// 累积内容
fullResponse.updateAndGet(current -> current + content);
log.debug("收到流式数据: [{}]", content);
})
.doOnComplete(() -> {
// 保存 AI 回复到历史
String finalResponse = fullResponse.get();
log.info("准备保存 AI 回复: {} 字符", finalResponse.length());
conversationService.addAssistantMessage(
request.sessionId(),
finalResponse
);
log.info("流式回复完成 [会话: {}, 轮次: {}]: {} 字符",
request.sessionId(),
conversationService.getMessageCount(request.sessionId()) / 2,
finalResponse.length()
);
})
.doOnError(error -> {
log.error("流式响应错误", error);
});
} catch (Exception e) {
log.error("处理流式聊天请求时发生错误", e);
return Flux.just("错误: " + e.getMessage());
}
}produces = "text/event-stream" - 指定返回 SSE 格式Flux<String> - 响应式流类型.filter() - 过滤空内容,避免发送无效数据块AtomicReference - 线程安全地收集完整响应doOnComplete() - 流完成时保存到历史data: 前缀!Spring 会自动处理// ❌ 不要手动添加 SSE 格式!
return stream
.map(content -> "data: " + content + "\n\n"); // 错误!// ✅ Spring 自动处理 SSE 格式
return stream
.map(response -> response.getResult().getOutput().getText())
.filter(content -> content != null && !content.isEmpty());
为什么? Spring 的 produces = "text/event-stream"
会自动为每个元素添加 data: 前缀。如果手动添加,会变成
data:data: 内容(双重前缀),导致前端解析错误。
mvn spring-boot:runcurl http://localhost:8080/api/memory-chat/health预期输出:
Memory Chat API is running! 🧠curl -N -X POST http://localhost:8080/api/memory-chat/stream \
-H "Content-Type: application/json" \
-d '{
"sessionId": "test-001",
"message": "用50个字介绍 Spring AI"
}'-N - 禁用缓冲,立即显示数据-N 会等待全部数据后才显示data:Spring
data: AI
data: 是
data:Spring
data: 官方
data:的
data:AI
data:集成
data:框架
...data: 开头(Spring 自动添加)第1轮:告诉 AI 名字
curl -N -X POST http://localhost:8080/api/memory-chat/stream \
-H "Content-Type: application/json" \
-d '{
"sessionId": "user-stream-001",
"message": "我叫小明"
}'第2轮:询问名字(测试记忆)
curl -N -X POST http://localhost:8080/api/memory-chat/stream \
-H "Content-Type: application/json" \
-d '{
"sessionId": "user-stream-001",
"message": "我叫什么名字?"
}'预期:AI 记得名字,并且逐字显示回复
非流式(等待后一次性显示):
time curl -X POST http://localhost:8080/api/memory-chat \
-H "Content-Type: application/json" \
-d '{
"sessionId": "test-002",
"message": "用100个字介绍人工智能"
}'流式(逐字显示):
time curl -N -X POST http://localhost:8080/api/memory-chat/stream \
-H "Content-Type: application/json" \
-d '{
"sessionId": "test-002",
"message": "用100个字介绍人工智能"
}'观察:
前端正确解析 SSE 格式的代码示例:
async function sendMessageStream(message) {
// 创建 AI 消息容器
const aiMessageId = appendMessage('ai', '');
const aiTextDiv = document.getElementById(aiMessageId)
.querySelector('.message-text');
// 添加打字机光标
aiTextDiv.innerHTML = '<span class="typing-cursor">▋</span>';
try {
// 发起流式请求
const response = await fetch(`${API_BASE_URL}/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: message,
sessionId: sessionId
})
});
// 读取流式响应
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullText = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log('流式响应完成');
break;
}
// 解码数据块
const chunk = decoder.decode(value, { stream: true });
console.log('收到数据块:', chunk);
// SSE 格式:data:xxx 或 data: xxx
// 提取实际内容
const lines = chunk.split('\n');
for (const line of lines) {
// 只处理以 "data:" 开头的行
if (line.startsWith('data:')) {
// 提取 data: 后面的内容(可能有空格也可能没有)
let content = line.substring(5); // 跳过 "data:"
// 如果第一个字符是空格,去掉它
if (content.startsWith(' ')) {
content = content.substring(1);
}
// 只处理非空内容
if (content && content.trim()) {
console.log('提取内容:', content);
fullText += content;
// 实时更新显示(保留光标)
aiTextDiv.innerHTML = fullText +
'<span class="typing-cursor">▋</span>';
// 自动滚动到底部
chatMessages.scrollTop = chatMessages.scrollHeight;
}
}
}
}
// 移除光标,显示最终文本
aiTextDiv.innerHTML = fullText;
// 刷新历史记录
setTimeout(() => loadChatHistory(), 500);
} catch (error) {
console.error('流式响应失败:', error);
aiTextDiv.innerHTML = '抱歉,发生了错误:' + error.message;
throw error;
}
}if (line.startsWith('data: ')) { // 要求有空格
const content = line.substring(6); // 跳过 "data: "
}问题:只能解析 data: 内容,无法解析 data:内容
if (line.startsWith('data:')) { // 不要求空格
let content = line.substring(5); // 跳过 "data:"
if (content.startsWith(' ')) {
content = content.substring(1); // 去掉可选的空格
}
}兼容性:
data:内容 - 正确解析data: 内容 - 正确解析完成本章后的项目结构:
src/main/java/com/example/chatbot/
├── AiChatbotApplication.java
├── controller/
│ ├── ChatController.java # 基础聊天
│ └── MemoryChatController.java # 带记忆聊天(含流式接口)⭐
├── dto/
│ ├── ChatRequest.java
│ ├── ChatResponse.java
│ ├── MemoryChatRequest.java
│ └── MemoryChatResponse.java
└── service/
└── ConversationService.java恭喜!你已经完成了 Spring AI 的核心功能学习。 现在你可以构建一个完整的、带记忆的、流式响应的 AI 聊天机器人了!
你已掌握「流语」实时对话推送引擎的开发能力。 接下来,进入阶段 3:「智工」AI业务工具集成平台, 学习如何实现 Function Calling 和工具调用。
A:
A:
doOnError() 捕获A:
A:
produces = "text/event-stream" 会自动添加data:data: 内容A:
curl -N 命令(-N 禁用缓冲)