Spring AI 流式对话实战:用 SSE 让大模型回答逐字输出
为什么需要流式输出
大模型生成文本是逐 token(词元)产生的,但传统的同步 HTTP 接口会等整个回答生成完毕才一次性返回。一个 500 字的回答,模型生成需要 8-10 秒,这段时间用户只能盯着加载动画。
流式输出的核心机制:服务器不等结果全部生成,而是每生成一个 token 就立即推送给客户端,浏览器边接收边渲染,形成"打字机"效果。
| 对比项 | 同步调用 | SSE 流式 |
|---|---|---|
| 首字响应 | 8-10 秒(等全部生成) | 0.3 秒(首个 token 即推) |
| 用户感知 | 长时间空白等待 | 实时打字机效果 |
| 实现复杂度 | ⭐ 简单 | ⭐⭐⭐ 需处理流 |
| 超时风险 | 高(长文易触发网关超时) | 低(持续有数据流) |
💡 流式输出不改变模型的生成速度,只改变用户感知速度。但对体验的提升是决定性的——ChatGPT、文心一言、通义千问全部采用流式。
环境准备
测试环境:JDK 17、Spring Boot 3.2、Spring AI 1.0.0、Ollama 0.5.4,Windows 11,8GB 显存。
-
JDK 17+
-
Spring Boot 3.2+
-
Ollama 本地部署(零成本测试)
-
Maven
# 拉取本地对话模型(约 4.7GB)
ollama pull qwen2.5:7b
# 验证模型可用
ollama run qwen2.5:7b "你好"
<!-- pom.xml 核心:Spring AI BOM + Ollama starter -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Spring AI Ollama 集成,封装了流式调用接口 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Web Flux 提供 Flux 响应式流支持,SSE 必需 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
</dependencies>
# application.yml:配置 Ollama 连接
spring:
ai:
ollama:
base-url: http://localhost:11434
chat:
model: qwen2.5:7b
options:
temperature: 0.7 # 创造性,0-1,越高越发散
num-predict: 500 # 最大生成 token 数
方案一:传统同步调用(对比基准)
先写一个同步接口作为对比基准,理解它的缺陷:
@RestController
@RequestMapping("/api/chat")
public class ChatController {
// OllamaChatModel 由 spring-ai-ollama-starter 自动注入
private final OllamaChatModel chatModel;
public ChatController(OllamaChatModel chatModel) {
this.chatModel = chatModel;
}
/**
* 同步调用:阻塞直到模型生成完整回答
* 问题:500 字回答需等 8-10 秒,期间连接空转
*/
@PostMapping("/sync")
public Map<String, String> syncChat(@RequestBody Map<String, String> body) {
String userMessage = body.get("message");
// call() 是阻塞调用,等全部生成完才返回
String response = chatModel.call(userMessage);
return Map.of("answer", response);
}
}
❌ 实测问题:用 curl 测试,问"解释 Java 的垃圾回收机制",10.2 秒后才返回完整答案。这段时间前端无法给用户任何反馈,体验极差。
方案二:SSE 流式输出(核心方案)
Spring AI 提供了 stream() 方法,返回 Flux<ChatResponse>——一个响应式流,每个 token 到达时触发一次推送。
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.ollama.api.OllamaChatModel;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.Map;
@RestController
@RequestMapping("/api/chat")
public class ChatController {
private final OllamaChatModel chatModel;
public ChatController(OllamaChatModel chatModel) {
this.chatModel = chatModel;
}
/**
* SSE 流式输出:模型每生成一个 token 就推送给前端
*
* 关键点:
* 1. produces = TEXT_EVENT_STREAM_VALUE 声明 SSE 协议
* 2. stream() 返回 Flux<ChatResponse>,非阻塞
* 3. map() 提取每个 token 的文本内容
*/
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestBody Map<String, String> body) {
String userMessage = body.get("message");
return chatModel.stream(userMessage) // 发起流式请求
.map(response -> {
// 每条 response 包含一个 token 的增量内容
String chunk = response.getResult().getOutput().getText();
return chunk == null ? "" : chunk; // 空 chunk 跳过
})
.filter(chunk -> !chunk.isEmpty()) // 过滤空内容
.onErrorResume(e -> Flux.just("\n[错误] " + e.getMessage())); // 异常兜底
}
}
流式调用的数据流是这样的:
用户请求 → Spring AI → Ollama → 模型生成 token1 ↓ Flux 推送 浏览器渲染 token1 模型生成 token2 ↓ Flux 推送 浏览器渲染 token2 (循环直到生成结束)
⚠️ 踩坑点:
stream()必须配合 WebFlux 的Flux使用,不能返回普通 String。如果项目是纯 MVC(无 WebFlux 依赖),需要额外引入spring-boot-starter-webflux,否则会报No supporting bean of type WebClient错误。
添加系统提示词控制风格
实际业务中,模型回答要符合特定角色(客服、技术助手等)。用 Prompt 对象封装系统提示词:
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import java.util.List;
/**
* 带角色设定的流式对话
* SystemMessage 固定模型人设,每次请求复用
*/
@PostMapping(value = "/stream-with-role", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamWithRole(@RequestBody Map<String, String> body) {
String userMessage = body.get("message");
// 系统提示词:固定模型角色,不随用户输入改变
SystemMessage system = new SystemMessage("""
你是一个资深 Java 后端工程师助手。
规则:
1. 回答要简洁,直接给出代码和关键解释
2. 不确定的内容明确说"需要进一步确认"
3. 代码必须标注语言类型
""");
UserMessage user = new UserMessage(userMessage);
// Prompt 把系统提示词和用户输入组合
Prompt prompt = new Prompt(List.of(system, user));
return chatModel.stream(prompt)
.map(response -> {
String chunk = response.getResult().getOutput().getText();
return chunk == null ? "" : chunk;
})
.filter(chunk -> !chunk.isEmpty())
.onErrorResume(e -> Flux.just("\n[错误] " + e.getMessage()));
}
前端对接:接收 SSE 流
后端用 SSE 推送,前端用原生 fetch + ReadableStream 接收(不需要 EventSource,因为请求是 POST):
<!-- index.html:打字机效果前端 -->
<textarea id="input" placeholder="输入问题..."></textarea>
<button onclick="ask()">提问</button>
<div id="output"></div>
<script>
async function ask() {
const message = document.getElementById('input').value;
const output = document.getElementById('output');
output.textContent = ''; // 清空上次结果
// 用 fetch 发起 POST 请求,手动读取流式响应
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: message })
});
// 获取可读流的 reader,逐块读取
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
while (true) {
const { done, value } = await reader.read();
if (done) break;
// SSE 格式:每个数据块以 "data:" 开头
const text = decoder.decode(value, { stream: true });
// 解析出实际内容,拼接到输出区,形成打字机效果
const lines = text.split('\n');
for (const line of lines) {
if (line.startsWith('data:')) {
output.textContent += line.slice(5); // 去掉 "data:" 前缀
}
}
}
}
</script>
💡 为什么不用 EventSource? 原生 EventSource 只支持 GET 请求,无法传 JSON body。用 fetch + ReadableStream 可以发送 POST,并手动解析 SSE 协议格式。
实测数据对比
用同一个问题"解释 Spring Bean 的生命周期"测试两种方案,qwen2.5:7b 本地模型:
| 指标 | 同步 /sync | 流式 /stream | 差异 |
|---|---|---|---|
| 首字响应时间 | 10.2 秒 | 0.3 秒 | 提升 97% |
| 完整答案返回 | 10.2 秒 | 10.5 秒 | 流式略慢(协议开销) |
| 用户等待感知 | 持续空白 | 即时反馈 | 体验质变 |
| 500 字答案 Token 数 | ~520 | ~520 | 模型一致 |
💡 测试环境:JDK 17 + Spring Boot 3.2 + Spring AI 1.0.0 + Ollama qwen2.5:7b,Windows 11,RTX 4060 8GB 显存。每个指标实测 5 次取中位数。
关键结论:流式的总耗时几乎相同,但首字响应从 10.2 秒降到 0.3 秒,提升 97%。用户在 0.3 秒后就开始看到答案逐字出现,不再有"卡死"感。
生产环境注意事项
1. 连接中断处理
用户可能中途关闭页面,流被中断。WebFlux 会自动清理,但建议加超时控制:
import java.time.Duration;
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestBody Map<String, String> body) {
String userMessage = body.get("message");
return chatModel.stream(userMessage)
.map(response -> response.getResult().getOutput().getText() == null
? "" : response.getResult().getOutput().getText())
.filter(chunk -> !chunk.isEmpty())
.timeout(Duration.ofSeconds(60)) // 单 token 间隔超 60s 则中断
.onErrorResume(e -> Flux.just("\n[超时或中断] " + e.getMessage()));
}
2. 并发与资源
Ollama 本地部署默认串行处理请求,并发时会排队。生产环境用 OpenAI/通义等云端 API 无此限制。
| 部署方式 | 并发能力 | 成本 |
|---|---|---|
| Ollama 本地 | 串行,1 个请求排队 | 免费(需 GPU) |
| OpenAI API | 高并发 | 按 token 计费 |
| 通义/文心 API | 高并发 | 按量计费 |
3. Nginx 反向代理配置
生产环境通常前置 Nginx,默认会缓冲响应,导致 SSE 失效。必须关闭缓冲:
# nginx.conf:SSE 必须关闭 proxy_buffering
location /api/chat/stream {
proxy_pass http://backend:8080;
proxy_buffering off; # 关键:关闭缓冲,否则 token 会攒一批才发
proxy_cache off; # 关闭缓存
proxy_set_header Connection ''; # 清除 Connection 头,允许长连接
proxy_http_version 1.1; # SSE 需要 HTTP/1.1
chunked_transfer_encoding on; # 允许分块传输
}
⚠️ 高频踩坑:忘了关
proxy_buffering是 SSE 最常见的故障——前端表现为"卡几秒后一次性出现一大段文字",失去打字机效果。
常见问题
Q: 报错 No supporting bean of type WebClient?
A: 项目缺少 WebFlux 依赖。Spring AI 的
stream()依赖响应式编程,必须在 pom.xml 加入spring-boot-starter-webflux。
Q: 前端收到的数据是一块一块的,不是逐字?
A: 两个原因:① 中间有 Nginx 没关
proxy_buffering;② 前端reader.read()读到的块可能包含多个 token,需要按data:分行解析(见前端代码示例)。
Q: 流式调用和上一篇 Token 缓存能结合吗?
A: 可以。缓存命中时直接返回完整答案(走同步接口),未命中时走流式。两者不冲突,可以按场景选择接口。
Q: 为什么我的首字响应还是几秒?
A: 检查模型加载状态。Ollama 首次调用某模型时需要加载到显存,耗时较长(7B 模型约 5 秒)。预热后首次请求就快了:启动后先发一个空请求
ollama run qwen2.5:7b ""预加载。
总结
-
流式输出的本质:模型边生成边推送,不改变总速度,只改变首字响应时间(10.2 秒 → 0.3 秒,提升 97%)
-
Spring AI 实现核心:
chatModel.stream()返回Flux<ChatResponse>,配合TEXT_EVENT_STREAM_VALUE产出 SSE -
前端接收:用
fetch + ReadableStream手动解析(EventSource 不支持 POST) -
生产必做:Nginx 关闭
proxy_buffering,否则流式失效 -
本地开发用 Ollama 零成本,生产用云端 API 支持高并发
用同一个问题实测,同步调用让用户干等 10 秒,流式让用户 0.3 秒就看到答案。这就是为什么所有主流 AI 产品都采用流式——成本几乎为零,体验提升是质变的。
你的项目里大模型调用是同步还是流式?如果还是同步,最可能卡在哪一步?欢迎评论区交流 👇
参考
更多推荐




所有评论(0)