Java转AI第四课:Spring AI实现流式输出(SSE vs WebSocket)
目录
9. 原生EventSource为什么不适合直接发送复杂POST请求?
10. Spring MVC项目如何使用SseEmitter?
前言
上一篇文章,我们使用Spring AI的ChatClient,把原来调用大模型需要编写的大量HTTP请求代码,缩减成了几行链式调用。
如果还没有看过上一篇,可以先阅读:
📖 Java转AI第三课:Spring AI如何把100行HTTP代码缩减到10行?
上一课中,我们实现的接口大致是这样的:
String answer = chatClient.prompt()
.user(question)
.call()
.content();
代码确实很简单。
但是运行之后,很快就会发现一个问题:
为什么ChatGPT可以一边生成、一边显示,而我们的接口必须等待几十秒,最后一次性返回全部内容?
假设模型生成一篇较长的分析报告需要20秒。
普通接口的体验是:
用户发送问题
↓
页面等待20秒
↓
一次性显示完整回答
在这20秒里,用户看不到任何反馈。
他不知道系统正在处理,还是接口已经卡住。
而ChatGPT的体验是:
用户发送问题
↓
模型生成第一部分内容
↓
前端立即显示
↓
后续内容持续追加
即使完整回答仍然需要20秒,用户可能在第1秒或者第2秒就已经看到第一段内容。
模型并没有变快,但用户感受到的等待时间明显缩短了。
这就是我们这一课要实现的功能:
使用Spring AI、Flux和SSE,让大模型生成的内容实时返回到浏览器,形成类似ChatGPT的打字效果。
本文不会只停留在一个能运行的Demo。
除了最基础的流式输出,我们还会继续处理:
- 如何区分内容、完成和失败事件;
- 前端怎样正确关闭SSE连接;
- 模型调用超时后怎样返回提示;
- Spring MVC项目能不能使用Flux;
- 什么场景应该使用
SseEmitter; - Nginx为什么可能把流式响应变成一次性返回;
- 企业AI项目怎样设计阶段流和结果流。

1. 为什么普通接口不像ChatGPT?
先来看一下上一课中的同步调用:
String answer = chatClient.prompt()
.user(question)
.call()
.content();
这里使用的是:
call()
它表示按照同步方式调用模型。
完整过程是:
Controller接收请求
↓
ChatClient调用大模型
↓
等待模型生成完整答案
↓
获得String
↓
Controller返回响应
只要模型还没有生成完,Controller就无法拿到最终的String。
因此,浏览器看到的通常是:
请求一直处于Pending状态
直到模型生成全部内容,接口才会一次性返回。
这种方式并没有错误。
对于分类、意图识别、结构化数据提取等内容较短的任务,同步调用反而更加简单。
例如:
判断用户意图
提取订单号
生成一个标题
返回一个JSON对象
这些任务可能只需要几百毫秒或者几秒,不一定需要流式输出。
但对于下面这些场景,同步返回的体验就比较差:
- AI聊天;
- 长文本生成;
- RAG知识问答;
- 案件或者合同分析;
- 周报和报告生成;
- Agent长任务执行;
- 多步骤Workflow。
这些任务的共同特点是:
完整结果生成较慢,但前面已经生成的内容可以提前展示。
因此,我们需要把同步调用改成流式调用。
2. 流式输出到底是什么?
很多人第一次看到流式输出,会把它理解成:
模型一个字一个字地返回。
这个理解并不完全准确。
模型服务返回的内容,可能按照Token、词语或者文本片段进行传输。
例如模型最终要返回:
Spring AI可以帮助Java开发者快速接入大模型。
服务端实际收到的片段可能是:
Spring
AI
可以帮助
Java
开发者
快速接入
大模型
。
也可能是:
Spring AI
可以帮助Java开发者
快速接入大模型。
Spring AI会把这些持续到达的内容包装成一个响应流。
后端收到一个片段,就立即向前端发送一个片段。
前端再把所有片段依次拼接起来:
answer += event.data;
最终就形成了我们看到的“打字效果”。
因此,流式输出的本质不是前端播放了一段预设动画,而是:
模型生成多少,服务端就传输多少,前端接收到多少就显示多少。
3. 为什么AI聊天更适合使用SSE?
SSE的全称是:
Server-Sent Events
中文通常翻译为:
服务器发送事件
它允许服务器通过一个持续保持的HTTP连接,不断向浏览器发送事件。
SSE响应的媒体类型是:
Content-Type: text/event-stream
一个最简单的SSE事件格式如下:
data: 第一段内容
data: 第二段内容
data: 第三段内容
每个事件之间使用空行分隔。
SSE还可以指定事件名称:
event: delta
data: 第一段模型内容
event: done
data: [DONE]
前端可以根据不同事件执行不同操作:
eventSource.addEventListener("delta", handleContent);
eventSource.addEventListener("done", handleComplete);
SSE标准就是为服务端持续向页面推送数据设计的,Spring MVC也直接提供了SseEmitter支持。
3.1 AI聊天的通信方向
AI聊天看起来是双向交互:
用户提问 → AI回答
但单次请求的真实通信过程是:
浏览器先发送一次问题
↓
服务器持续返回回答
在生成回答期间,主要的数据方向是:
服务器 → 浏览器
这正好符合SSE单向推送的特点。
3.2 SSE仍然基于HTTP
SSE没有引入一套完全不同的通信协议。
它仍然基于HTTP,因此可以继续使用:
- Controller;
- 网关;
- Cookie;
- Token鉴权;
- 跨域配置;
- Nginx反向代理;
- 日志和调用链追踪。
对于已经使用Spring Boot开发业务系统的Java程序员来说,接入成本相对较低。
3.3 浏览器原生支持EventSource
浏览器提供了EventSource对象,可以直接连接SSE接口:
const eventSource = new EventSource("/api/ai/chat/stream");
不需要额外引入通信框架。
连接建立后,服务器每发送一次事件,前端就能收到一次通知。

4. Spring AI的流式调用链
Spring AI的ChatClient同时支持同步调用和流式调用。
同步调用使用:
call()
流式调用使用:
stream()
最常见的两段代码如下。
4.1 同步调用
String answer = chatClient.prompt()
.user(question)
.call()
.content();
返回类型是:
String
4.2 流式调用
Flux<String> answer = chatClient.prompt()
.user(question)
.stream()
.content();
返回类型变成了:
Flux<String>
Spring AI官方文档中,stream().content()返回Flux<String>,还可以通过chatResponse()获得包含模型元数据的Flux<ChatResponse>。
两段代码的核心区别只有一处:
- .call()
+ .stream()
但返回模型已经完全不同:
call().content()
↓
等待完整结果
↓
String
stream().content()
↓
持续接收结果
↓
Flux<String>
4.3 Flux是什么?
Flux来自Project Reactor。
可以先把它简单理解为:
一个未来可能持续产生多个数据的响应序列。
普通的List<String>要求所有数据都准备完成以后,才能得到完整集合。
而Flux<String>可以在每个数据生成后立即向下游发送:
第一个文本片段生成 → 立即发送
第二个文本片段生成 → 立即发送
第三个文本片段生成 → 立即发送
这正是大模型流式输出需要的能力。
Spring AI的StreamingChatModel底层同样通过Flux暴露流式响应。
5. 使用Spring AI实现最简单的流式输出
接下来进入代码部分。
本文继续沿用前三课中的Spring AI项目和大模型配置,不再重复介绍API Key和模型接入过程。
为了直接返回响应流,需要确保项目中包含WebFlux相关能力:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
Spring AI模型Starter继续沿用之前项目中的配置即可。
5.1 创建ChatClient
@Configuration
public class AiConfig {
@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
return builder.build();
}
}
Spring Boot会根据已经配置的ChatModel,自动提供ChatClient.Builder。
我们只需要调用:
builder.build()
即可获得ChatClient。
5.2 编写最简单的流式接口
@RestController
@RequestMapping("/api/ai/chat")
public class AiChatController {
private final ChatClient chatClient;
public AiChatController(ChatClient chatClient) {
this.chatClient = chatClient;
}
@GetMapping(
value = "/simple-stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE
)
public Flux<String> simpleStream(@RequestParam String message) {
return chatClient.prompt()
.user(message)
.stream()
.content();
}
}
核心代码只有:
return chatClient.prompt()
.user(message)
.stream()
.content();
接口通过下面的配置声明返回SSE数据:
produces = MediaType.TEXT_EVENT_STREAM_VALUE
它等价于:
Content-Type: text/event-stream
调用接口:
GET /api/ai/chat/simple-stream?message=请介绍一下Spring AI
浏览器就可以陆续收到模型生成的文本片段。
5.3 为什么Controller可以直接返回Flux?
Spring WebFlux可以识别Flux返回值,并在每个数据生成后写入HTTP响应。
因此我们不需要手动:
- 创建循环;
- 不断刷新输出流;
- 拼接SSE协议格式;
- 判断模型是否生成了新内容。
Spring AI负责把模型响应转换成Flux。
Spring WebFlux负责把Flux中的内容持续写入HTTP响应。
整体调用链是:
大模型流式响应
↓
Spring AI
↓
Flux<String>
↓
Spring WebFlux
↓
text/event-stream
↓
浏览器

6. 前端实现ChatGPT式打字效果
后端已经可以持续返回内容,接下来需要在前端接收SSE事件。
为了方便演示,先使用一个最简单的HTML页面。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Spring AI流式聊天</title>
<style>
body {
max-width: 800px;
margin: 40px auto;
padding: 0 20px;
font-family: Arial, sans-serif;
}
.input-area {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
#question {
flex: 1;
padding: 12px;
font-size: 16px;
}
button {
padding: 0 24px;
cursor: pointer;
}
#answer {
min-height: 200px;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
white-space: pre-wrap;
line-height: 1.8;
}
</style>
</head>
<body>
<h2>Spring AI流式聊天</h2>
<div class="input-area">
<input
id="question"
placeholder="请输入你的问题"
/>
<button onclick="sendMessage()">发送</button>
</div>
<div id="answer"></div>
<script>
let eventSource = null;
function sendMessage() {
const questionInput = document.getElementById("question");
const answerElement = document.getElementById("answer");
const question = questionInput.value.trim();
if (!question) {
alert("请输入问题");
return;
}
if (eventSource) {
eventSource.close();
}
answerElement.textContent = "";
const url =
"/api/ai/chat/simple-stream?message="
+ encodeURIComponent(question);
eventSource = new EventSource(url);
eventSource.onmessage = function (event) {
answerElement.textContent += event.data;
};
eventSource.onerror = function () {
eventSource.close();
eventSource = null;
};
}
</script>
</body>
</html>
最核心的代码是:
eventSource.onmessage = function (event) {
answerElement.textContent += event.data;
};
每收到一个文本片段,就追加到原来的回答后面:
原来的内容 + 新收到的内容
这样就形成了持续输出效果。
6.1 为什么使用textContent?
示例中使用的是:
answerElement.textContent += event.data;
而不是:
answerElement.innerHTML += event.data;
因为模型输出本质上是不可信文本。
如果直接把模型内容写入innerHTML,可能引入XSS风险。
真实项目如果需要渲染Markdown,应该经过:
Markdown解析
↓
HTML安全过滤
↓
页面渲染
不能直接把模型生成的HTML写进页面。
6.2 当前版本还有一个问题
目前的接口虽然可以运行,但它没有明确告诉前端:
回答什么时候正常结束?
回答什么时候发生异常?
EventSource连接在服务端关闭后可能进入错误或者重连处理。
如果前端不能区分“正常结束”和“网络失败”,就容易出现:
- 回答完成后仍然自动重连;
- 页面错误地显示连接失败;
- 同一个问题被重复请求;
- 加载状态一直不结束。
所以,真实项目不能只发送文本内容。
我们还需要设计完整的事件协议

7. 从“能够运行”升级到“工程可用”
接下来,我们给SSE增加三个事件类型:
| 事件 | 作用 |
|---|---|
delta |
模型生成的文本片段 |
done |
回答正常完成 |
failure |
调用失败或者超时 |
这样前端就能准确判断当前状态。
7.1 返回结构化SSE事件
@Slf4j
@RestController
@RequestMapping("/api/ai/chat")
public class AiStreamController {
private static final Duration STREAM_TIMEOUT =
Duration.ofSeconds(90);
private final ChatClient chatClient;
public AiStreamController(ChatClient chatClient) {
this.chatClient = chatClient;
}
@GetMapping(
value = "/stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE
)
public Flux<ServerSentEvent<String>> stream(
@RequestParam String message) {
if (!StringUtils.hasText(message)) {
return Flux.just(
buildEvent("failure", "问题不能为空")
);
}
Flux<ServerSentEvent<String>> contentStream =
chatClient.prompt()
.system("""
你是一名专业、严谨的AI助手。
请直接回答用户问题。
不要编造无法确认的信息。
""")
.user(message)
.stream()
.content()
.filter(StringUtils::hasText)
.map(content ->
buildEvent("delta", content)
);
return contentStream
.concatWithValues(
buildEvent("done", "[DONE]")
)
.timeout(STREAM_TIMEOUT)
.onErrorResume(
TimeoutException.class,
exception -> {
log.warn(
"AI流式响应超时,message={}",
message
);
return Flux.just(
buildEvent(
"failure",
"模型响应超时,请稍后重试"
)
);
}
)
.onErrorResume(exception -> {
log.error(
"AI流式响应失败,message={}",
message,
exception
);
return Flux.just(
buildEvent(
"failure",
"模型服务暂时不可用,请稍后重试"
)
);
})
.doOnCancel(() ->
log.info("客户端主动取消流式连接")
)
.doFinally(signalType ->
log.info(
"流式请求结束,signal={}",
signalType
)
);
}
private ServerSentEvent<String> buildEvent(
String event,
String data) {
return ServerSentEvent.<String>builder()
.event(event)
.data(data)
.build();
}
}
需要导入的核心类:
import java.time.Duration;
import java.util.concurrent.TimeoutException;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Flux;
7.2 delta事件
每当模型生成一个新的文本片段,就发送:
buildEvent("delta", content)
实际SSE数据类似:
event: delta
data: Spring AI
event: delta
data: 是Spring生态中的
event: delta
data: AI应用开发框架
7.3 done事件
当模型正常生成完成后,追加:
buildEvent("done", "[DONE]")
前端收到done后,就可以:
- 停止加载动画;
- 恢复发送按钮;
- 保存完整会话;
- 主动关闭EventSource;
- 记录本次回答完成。
7.4 failure事件
发生超时或者异常时,返回:
buildEvent(
"failure",
"模型服务暂时不可用,请稍后重试"
)
不要把下面这些内容直接返回给用户:
完整异常堆栈
模型供应商内部错误
API Key信息
网关地址
数据库或者服务地址
详细错误应该记录在服务端日志中。
前端只显示用户能够理解的提示。
7.5 为什么要设置超时?
大模型调用可能因为以下原因长时间没有结束:
- 模型供应商负载过高;
- 网络连接异常;
- Prompt过长;
- 模型正在执行复杂推理;
- 上游服务没有正确关闭响应流;
- Agent或者Tool调用阻塞。
如果没有超时控制,一条连接可能一直占用系统资源。
这里设置:
Duration.ofSeconds(90)
只是一个示例值。
真实项目应该根据业务类型分别配置:
普通聊天:30~90秒
RAG问答:60~120秒
复杂分析:120~180秒
Agent长任务:结合阶段流单独设计
超时时间不应该全部写死在Controller中,可以放入配置文件:
ai:
stream:
timeout-seconds: 90
8. 改造前端事件处理
后端已经有了明确的事件类型,前端也需要分别监听。
let eventSource = null;
let fullAnswer = "";
function sendMessage() {
const question = document
.getElementById("question")
.value
.trim();
const answerElement =
document.getElementById("answer");
if (!question) {
alert("请输入问题");
return;
}
closeCurrentConnection();
fullAnswer = "";
answerElement.textContent = "正在连接模型……";
const url =
"/api/ai/chat/stream?message="
+ encodeURIComponent(question);
eventSource = new EventSource(url);
eventSource.onopen = function () {
answerElement.textContent = "";
};
eventSource.addEventListener(
"delta",
function (event) {
fullAnswer += event.data;
answerElement.textContent = fullAnswer;
}
);
eventSource.addEventListener(
"done",
function () {
closeCurrentConnection();
console.log("回答生成完成");
}
);
eventSource.addEventListener(
"failure",
function (event) {
answerElement.textContent =
fullAnswer
? fullAnswer + "\n\n" + event.data
: event.data;
closeCurrentConnection();
}
);
eventSource.onerror = function () {
if (eventSource) {
closeCurrentConnection();
if (!fullAnswer) {
answerElement.textContent =
"连接中断,请稍后重试";
}
}
};
}
function closeCurrentConnection() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
}
浏览器原生EventSource连接会保持打开状态,调用close()才会主动关闭。
因此,收到done或者failure事件后,一定要执行:
eventSource.close();
否则可能出现无意义的重连和资源占用。
8.1 页面销毁时也要关闭
用户刷新页面或者离开当前页面时,也应该清理连接:
window.addEventListener("beforeunload", function () {
closeCurrentConnection();
});
在Vue或者React中,应在组件卸载阶段执行相同操作。
例如React:
useEffect(() => {
return () => {
eventSourceRef.current?.close();
};
}, []);
8.2 防止重复提交
用户连续点击发送按钮时,应该先关闭上一次连接:
closeCurrentConnection();
更完整的做法是:
发送后禁用按钮
↓
接收流式内容
↓
done或者failure
↓
恢复按钮
这样可以避免同一页面同时建立多条无意义连接。

9. 原生EventSource为什么不适合直接发送复杂POST请求?
前面的示例使用的是:
GET /api/ai/chat/stream?message=用户问题
这是为了让读者最容易看到运行效果。
但真实AI聊天请求通常不只有一个message。
还可能包含:
{
"conversationId": "c_10001",
"message": "分析这份合同",
"model": "reasoning-model",
"knowledgeBaseId": "kb_001",
"enableSearch": true
}
原生EventSource构造器主要接收URL和凭证选项,并没有提供配置请求体的能力。
因此,复杂场景通常有三种做法。
9.1 方案一:参数较少时继续使用GET
例如:
GET /api/ai/chat/stream
?conversationId=c_10001
&message=你好
适合:
- 学习Demo;
- 简单聊天;
- 参数较少;
- 内容不敏感。
不适合:
- Prompt很长;
- 参数很多;
- URL不能出现业务内容;
- 需要复杂请求体。
9.2 方案二:使用fetch读取响应流
浏览器的fetch可以发送POST请求,同时通过Response.body读取流式响应。
基本结构是:
const response = await fetch(
"/api/ai/chat/stream",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "text/event-stream"
},
body: JSON.stringify(request)
}
);
const reader = response.body.getReader();
这种方式可以发送完整JSON请求,但前端需要自行解析响应流和SSE事件边界。
9.3 方案三:先创建任务,再建立SSE连接
企业项目中可以拆成两个接口。
第一步,提交问题并创建任务:
POST /api/ai/chat/tasks
返回:
{
"taskId": "task_202607150001"
}
第二步,通过任务ID建立SSE连接:
GET /api/ai/chat/tasks/task_202607150001/stream
这种方式的优势是:
- POST负责接收复杂参数;
- GET负责建立SSE连接;
- 可以记录任务状态;
- 可以支持断线恢复;
- 可以查询历史结果;
- 可以取消任务;
- 更适合Agent和长时间Workflow。
普通聊天Demo没有必要一开始就设计成任务系统。
但长任务、复杂分析和Agent执行,建议采用任务化设计。
10. Spring MVC项目如何使用SseEmitter?
看到Flux以后,一些Java开发者可能会产生疑问:
我的项目一直使用Spring MVC,难道为了实现流式输出,必须把整个系统改成WebFlux吗?
答案是不需要。
Spring MVC本身支持异步返回和流式响应,也可以使用:
SseEmitter
Spring官方文档中,SseEmitter是ResponseBodyEmitter的子类,专门用于生成符合SSE格式的事件。
10.1 创建SseEmitter接口
@Slf4j
@RestController
@RequestMapping("/api/ai/workflow")
public class AiWorkflowController {
private static final long TIMEOUT = 180_000L;
private final Executor aiExecutor;
public AiWorkflowController(
@Qualifier("aiExecutor") Executor aiExecutor) {
this.aiExecutor = aiExecutor;
}
@PostMapping(
value = "/analysis",
produces = MediaType.TEXT_EVENT_STREAM_VALUE
)
public SseEmitter analysis(
@RequestBody AnalysisRequest request) {
SseEmitter emitter = new SseEmitter(TIMEOUT);
emitter.onTimeout(() -> {
log.warn("AI分析任务超时");
emitter.complete();
});
emitter.onCompletion(() ->
log.info("AI分析连接完成")
);
emitter.onError(exception ->
log.warn(
"AI分析连接异常",
exception
)
);
aiExecutor.execute(() ->
executeAnalysis(request, emitter)
);
return emitter;
}
private void executeAnalysis(
AnalysisRequest request,
SseEmitter emitter) {
try {
sendEvent(
emitter,
"stage",
"正在识别用户意图"
);
// Intent识别
String intent = recognizeIntent(request);
sendEvent(
emitter,
"stage",
"正在获取业务数据"
);
// 查询业务系统、知识库等数据
String context = loadContext(request, intent);
sendEvent(
emitter,
"stage",
"正在生成分析结果"
);
// 调用模型生成最终结果
String result = generateResult(
request,
context
);
sendEvent(
emitter,
"result",
result
);
sendEvent(
emitter,
"done",
"[DONE]"
);
emitter.complete();
} catch (Exception exception) {
log.error("AI分析任务执行失败", exception);
try {
sendEvent(
emitter,
"failure",
"分析任务执行失败,请稍后重试"
);
emitter.complete();
} catch (Exception sendException) {
log.warn(
"发送失败事件异常",
sendException
);
}
}
}
private void sendEvent(
SseEmitter emitter,
String event,
Object data) throws IOException {
emitter.send(
SseEmitter.event()
.name(event)
.data(data)
);
}
}
10.2 SseEmitter更适合什么场景?
SseEmitter非常适合传统Spring MVC项目中的阶段流:
正在识别意图
正在检索知识库
正在查询业务数据
正在执行工具
正在生成分析结果
任务完成
这和单纯的模型Token流不完全相同。
模型Token流主要展示:
模型正在生成的正文
阶段流主要展示:
系统当前执行到哪一步
例如我的AI办案助手使用的就是阶段化SSE设计:
识别用户意图
↓
加载案件信息
↓
检索相关法律知识
↓
调用案件工具
↓
执行综合研判
↓
返回完整结果
这种场景下,系统需要主动控制事件名称、执行阶段、业务状态和最终结果。
SseEmitter通常比简单返回Flux<String>更加直观。
10.3 Flux和SseEmitter不是互相替代
可以按照下面的方式理解:
| 方案 | 更适合的场景 |
|---|---|
Flux<String> |
直接转发模型生成的文本流 |
Flux<ServerSentEvent<?>> |
响应式项目中的结构化SSE |
SseEmitter |
Spring MVC项目、阶段流、命令式Workflow |
| 任务系统+SSE | Agent、长任务、需要恢复和状态查询 |
Spring MVC也可以接收Controller返回的Flux等响应式类型,但底层响应写入仍然是阻塞式的;WebFlux则采用非阻塞I/O。
因此,没有必要为了一个流式接口盲目重构整个项目。
应该根据现有技术栈和业务场景选择。
11. SSE和WebSocket应该怎么选?
提到实时通信,很多人第一反应是WebSocket。
那么AI聊天为什么经常使用SSE,而不是WebSocket?
先看两者的核心区别。
| 对比项 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 服务器向客户端单向推送 | 双向全双工通信 |
| 底层方式 | HTTP长连接 | 独立WebSocket协议 |
| 浏览器API | EventSource | WebSocket |
| 自动重连 | 浏览器原生支持 | 通常需要自己实现 |
| 数据格式 | 主要是文本事件 | 文本和二进制 |
| 接入复杂度 | 较低 | 相对较高 |
| AI流式回答 | 非常适合 | 可以实现但通常没有必要 |
| 即时通信 | 能力有限 | 更适合 |
| 多人协作 | 不适合 | 更适合 |
11.1 使用SSE的典型场景
- AI回答流式输出;
- 模型生成报告;
- 任务进度通知;
- Agent执行阶段;
- 日志实时展示;
- 服务端事件通知。
11.2 使用WebSocket的典型场景
- 即时聊天;
- 多人协作编辑;
- 实时游戏;
- 双向语音;
- 高频状态同步;
- 客户端需要持续向服务端发送数据。
AI问答的一次请求通常是:
用户提交一次问题
服务器持续返回一次回答
并不需要在模型生成过程中持续双向发送数据。
因此,SSE往往已经足够。
不要因为WebSocket看起来更加“实时”,就默认所有AI聊天都必须使用WebSocket。
技术选型应该匹配真实通信模型,而不是选择功能最重的方案。

12. 流式输出最常见的几个问题
代码在本地能够流式返回,不代表部署到服务器以后仍然能够正常运行。
下面这些问题,在真实项目中非常常见。
12.1 问题一:本地是流式,经过Nginx后变成一次性返回
现象是:
本地调用正常逐段显示
部署后等待很久
最后一次性显示全部内容
常见原因是Nginx对上游响应进行了缓冲。
Nginx官方文档说明,关闭代理缓冲后,响应会在接收到时同步传递给客户端,而不是尝试先读取完整响应。
可以针对流式接口关闭缓冲:
location /api/ai/ {
proxy_pass http://ai-app:8080;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 180s;
proxy_send_timeout 180s;
}
也可以由上游响应设置:
X-Accel-Buffering: no
实际配置应结合现有网关和Nginx版本验证。
12.2 问题二:接口有数据,但前端一直不显示
需要检查以下内容:
响应Content-Type是否为text/event-stream
Controller是否真的返回Flux或SseEmitter
中间网关是否缓冲响应
前端是否逐段读取
服务端是否等全部内容拼接后才返回
错误写法:
StringBuilder result = new StringBuilder();
chatClient.prompt()
.user(message)
.stream()
.content()
.doOnNext(result::append)
.blockLast();
return result.toString();
虽然调用了:
stream()
但后面又使用:
blockLast()
等待整个流结束。
最终还是一次性返回。
这相当于把流式调用重新变回了同步调用。
12.3 问题三:回答完成以后接口重复请求
原生EventSource具有连接恢复机制。
如果服务端关闭连接,而前端没有判断这是正常结束,浏览器可能尝试重新连接。
解决方法是发送明确的完成事件:
event: done
data: [DONE]
前端收到后主动执行:
eventSource.close();
12.4 问题四:浏览器关闭后,后端任务仍然继续执行
浏览器页面关闭,并不意味着所有上游模型请求都一定能够立即终止。
需要在后端关注:
.doOnCancel(...)
.doFinally(...)
对于复杂任务,还应该设计:
- 任务取消状态;
- 上游请求取消;
- Tool调用中断;
- 资源释放;
- 未完成任务清理;
- Token成本记录。
不能只关闭前端页面,却让后台继续执行一个高成本模型任务。
12.5 问题五:长时间没有Token,连接被网关断开
有些模型会长时间推理后才返回第一段内容。
Agent执行Tool时,也可能几十秒没有正文输出。
这时,网关或者负载均衡可能认为连接已经空闲。
可以设计心跳事件:
event: heartbeat
data: ping
前端收到后不展示,只用于维持连接和更新连接状态。
Spring官方文档也建议流式连接定期发送数据,以便发现客户端断开并维持连接活动。
12.6 问题六:每个Token都写数据库
不要在每次收到文本片段时执行:
写MySQL
写Redis
记录完整日志
更新会话表
模型一次回答可能产生大量片段。
每个片段都写数据库会带来:
- 大量数据库请求;
- 锁竞争;
- IO放大;
- 日志膨胀;
- 响应速度下降。
更合理的方式是:
流式阶段:在内存中拼接完整回答
↓
回答完成:一次性保存最终结果
或者按较大批次异步落库。
12.7 问题七:直接记录完整Prompt和回答
AI请求日志中可能包含:
- 用户隐私;
- 企业内部文档;
- 案件信息;
- 身份信息;
- API返回数据;
- Tool执行结果。
生产环境不应该默认记录完整Prompt和完整回答。
建议记录:
traceId
conversationId
userId
业务场景
模型名称
Token数量
首包耗时
总耗时
执行结果
失败类型
正文是否记录,需要根据业务安全要求进行脱敏、截断或者完全关闭。
13. 企业AI项目中的流式输出应该怎样设计?
一个简单聊天机器人,返回模型正文即可。
但真实企业AI项目的执行链路可能是:
用户问题
↓
权限校验
↓
意图识别
↓
查询业务数据
↓
RAG检索
↓
Tool调用
↓
模型分析
↓
结果校验
↓
返回用户
如果整个过程只展示模型最后生成的Token,用户仍然会在前面的业务阶段长时间等待。
因此,企业项目通常需要区分两类流。
13.1 第一类:模型内容流
用于展示模型正在生成的正文:
delta:根据当前案件信息……
delta:综合相关法律规定……
delta:建议重点核查以下内容……
13.2 第二类:业务阶段流
用于展示系统当前执行状态:
stage:正在识别问题类型
stage:正在检索企业知识库
stage:正在获取案件信息
stage:正在调用时间线工具
stage:正在生成分析结果
13.3 统一事件协议
可以为整个AI系统设计统一的SSE事件:
| 事件 | 含义 |
|---|---|
connected |
连接建立 |
stage |
业务执行阶段 |
delta |
模型文本片段 |
result |
最终结构化结果 |
citation |
引用来源 |
usage |
Token和耗时 |
done |
正常完成 |
failure |
执行失败 |
heartbeat |
心跳 |
一次完整的AI请求可能是:
event: connected
data: {"traceId":"trace_001"}
event: stage
data: {"name":"INTENT","message":"正在识别用户意图"}
event: stage
data: {"name":"RETRIEVAL","message":"正在检索相关知识"}
event: delta
data: 根据当前检索结果,
event: delta
data: 可以从以下三个方面进行分析。
event: citation
data: {"documentId":"doc_001","title":"业务规范"}
event: usage
data: {"totalTokens":1820,"latencyMs":12600}
event: done
data: [DONE]
这样做的价值不只是页面更好看。
它还可以支持:
- 前端统一处理不同AI业务;
- 展示Workflow执行阶段;
- 记录Agent调用链;
- 返回RAG引用来源;
- 统计模型Token;
- 定位失败阶段;
- 对接统一AI Gateway;
- 构建调用可观测性。

14. 一次完整流式请求的执行过程
现在把本文内容连接起来。
用户发送问题:
请解释Spring AI中的ChatClient
前端建立连接:
new EventSource(
"/api/ai/chat/stream?message=..."
);
Spring Boot接收请求:
@GetMapping(
value = "/stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE
)
Spring AI调用模型:
chatClient.prompt()
.user(message)
.stream()
.content();
模型持续生成内容:
Spring AI
是Spring生态中的
AI应用开发框架
后端将内容转换成事件:
event: delta
data: Spring AI
前端持续拼接:
fullAnswer += event.data;
模型生成完成:
event: done
data: [DONE]
前端关闭连接:
eventSource.close();
最终完整链路是:
用户输入
↓
EventSource建立SSE连接
↓
Spring Boot接收请求
↓
ChatClient.stream()
↓
大模型持续生成
↓
Flux持续发出文本片段
↓
Spring发送SSE事件
↓
前端逐段追加
↓
收到done事件
↓
关闭连接并保存会话
总结
这一课,我们完成了Spring AI聊天机器人中非常重要的一次升级:
一次性返回
↓
流式返回
核心代码其实非常简单:
chatClient.prompt()
.user(message)
.stream()
.content();
但真正把流式输出用于项目,还需要继续处理:
text/event-stream响应类型;Flux<String>流式数据;EventSource前端连接;delta、done、failure事件;- 连接主动关闭;
- 模型调用超时;
- 用户取消请求;
- Nginx代理缓冲;
- 心跳和长连接;
- 阶段流和模型内容流;
- Token、耗时和调用链记录。
这也是AI Demo和真实AI应用之间的区别。
Demo关注的是:
能不能把模型内容显示出来?
真实项目还要继续考虑:
连接是否会被正确关闭,异常是否能够感知,长任务是否能够追踪,模型成本是否能够统计,系统经过网关和Nginx以后是否仍然可以稳定运行?
对于Java开发者来说,Spring AI最有价值的地方,不只是减少了几行调用模型的代码。
它让我们可以继续使用熟悉的Spring生态,把模型能力接入现有的Controller、网关、业务服务、日志、监控和部署体系。
模型负责生成内容。
Spring负责把这份不确定的生成能力,接入一个可以管理、可以观测、可以治理的企业系统。
下一篇预告
流式输出解决了AI回答的实时体验问题。
但现在的聊天机器人仍然存在一个明显问题:
用户:我叫小明。
AI:你好,小明。
用户:我刚才说我叫什么?
AI:我不知道。
原因是大模型接口本身通常是无状态的。
每次请求结束以后,模型并不会自动记住之前的对话。
下一篇文章,我们将继续实现:
Java转AI第五课:Spring AI实现多轮对话记忆,让AI真正记住上下文
下一课会重点介绍:
- 大模型为什么没有记忆;
ChatMemory是什么;- 如何根据
conversationId隔离会话; - MessageWindowChatMemory如何控制上下文窗口;
- 对话记录应该放内存、Redis还是数据库;
- 为什么不能把所有历史消息全部发送给模型;
- 企业项目中的短期记忆、摘要记忆和长期记忆。
📚 推荐专栏
🤖 AI 技术专栏
从 0 到 1 学习 AI 应用开发,持续分享 Spring AI、RAG、Agent、企业级 AI 项目实战。
👉 https://blog.csdn.net/qupengkun/category_13184360.html
🚀 AI 转型日记
记录一名 10 年 Java 开发者从传统后端转向 AI 应用开发的全过程。
👉 https://blog.csdn.net/qupengkun/category_13183497.html
🧩 AI 开源项目实战
持续分享Java AI业务项目、AI Coding效率工具和工程治理实践。
👉 https://blog.csdn.net/qupengkun/category_13189373.html
👨💻 关于作者
QCoding
专注 AI 应用开发与 Java 技术实践。
持续分享 Spring AI、RAG、Agent、企业级 AI 项目实战、架构设计与职业成长。
更多推荐





所有评论(0)