摘要
大模型回答往往需要一段时间。如果后端等模型完整生成后再一次性返回,用户会长时间面对空白页面;如果直接把模型供应商的原始流转发给前端,又会遇到协议不统一、错误难处理、断线后无法恢复和消息状态不一致等问题。
生产级 AI 后端需要把模型流、业务消息、网络连接和客户端重连设计成一条完整链路。本文以企业智能客服与知识库助手为背景,比较 SSE 和 WebSocket 的适用场景,并设计一个基于 SSE 的流式对话接口,同时讨论:
- SSE、WebSocket 和普通 HTTP 的差异;
- 流式事件的统一格式;
- Spring WebFlux 中如何返回
Flux<ServerSentEvent<?>>; - 如何保存
RUNNING、COMPLETED、FAILED和CANCELLED状态; - 客户端断线后如何通过事件 ID 和消息状态恢复;
- Nginx、网关、心跳、超时和背压;
- 什么时候应该使用 WebSocket;
- 如何让流式接口支持 RAG、工具调用和人工中断。
一、背景与问题
1. 一次性响应的体验问题
非流式接口通常是:
浏览器发送问题
↓
后端调用模型
↓
模型生成完整答案
↓
后端一次性返回
模型生成较长答案时,用户只能等待完整请求结束。流式接口则可以把生成过程拆成多个事件:
请求开始
↓
首个 Token
↓
内容片段
↓
引用信息
↓
完成事件
用户可以更早看到反馈,前端也可以显示“正在检索”“正在生成”“正在调用工具”等状态。
2. 流式接口不是简单地返回字符串
一个生产级流式会话至少需要处理:
| 问题 | 说明 |
|---|---|
| 事件类型 | 内容、状态、引用、错误和完成要区分 |
| 顺序 | 客户端需要知道事件是否重复或丢失 |
| 连接 | 浏览器、网关和模型服务都可能断开 |
| 状态 | 数据库要知道生成是否完成 |
| 取消 | 用户点击停止后,模型调用和后端任务要结束 |
| 重连 | 客户端恢复后不能重复显示整段内容 |
| 审计 | 要保存请求、模型、Token 和错误信息 |
| 代理缓冲 | Nginx 可能把多个片段缓存后一次性转发 |
如果没有统一协议,前端往往只能根据字符串内容猜测当前状态。
3. SSE 和 WebSocket 的定位
SSE 是服务器向客户端持续推送事件的单向通道,建立在 HTTP 之上;WebSocket 是双向通信协议,适合客户端和服务器都需要频繁发送消息的场景。Spring WebFlux 支持响应式 WebSocket,Spring 体系也支持使用 Flux<ServerSentEvent<?>> 返回 SSE。citeturn0search2turn0search8
简单对比:
| 维度 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 服务端到客户端为主 | 双向 |
| 浏览器 API | EventSource 或 fetch 流 | WebSocket |
| 协议 | HTTP text/event-stream | WebSocket |
| 自动重连 | EventSource 有基础机制,应用仍需设计恢复 | 需要应用自行实现 |
| 代理兼容 | 通常更简单 | 需要正确支持 Upgrade |
| 适合场景 | AI 文本生成、进度推送、通知 | 双向协作、实时控制、语音和复杂事件 |
对于普通 AI 聊天,SSE 通常足够;对于实时语音、多人协作和双向 Agent 控制,WebSocket 更合适。
二、核心概念
1. SSE 事件格式
SSE 使用文本事件流,每个事件由字段组成,常见字段包括:
id: evt_1001
event: delta
data: {"text":"你好"}
事件之间使用空行分隔。建议 data 统一使用 JSON,不要让客户端同时解析纯文本和 JSON 两种格式。
2. 建议的事件类型
AI 对话可以设计为:
| 事件 | 作用 |
|---|---|
start | 服务端已经接受请求并创建生成任务 |
status | 检索、工具调用或生成阶段变化 |
delta | 一段新增文本 |
citation | RAG 来源或引用 |
tool_call | 工具调用开始、参数和结果摘要 |
usage | Token 和费用统计 |
error | 发生错误 |
done | 正常完成 |
cancelled | 用户或服务端取消 |
示例:
{
"id": "evt_1002",
"event": "delta",
"conversationId": "c_001",
"messageId": "m_002",
"seq": 2,
"data": {
"text": "根据当前资料,"
},
"createdAt": "2026-09-24T10:00:02+08:00"
}
3. messageId、requestId 和 eventId
三个 ID 解决不同问题:
| ID | 含义 |
|---|---|
requestId | 一次客户端提交请求的幂等标识 |
messageId | 数据库中助手消息的业务标识 |
eventId | 流式事件的唯一标识,用于重连恢复 |
不要只使用一个 ID。客户端重试请求时,应通过 requestId 防止重复创建模型任务;客户端恢复流时,应通过 eventId 找到上次收到的位置。
4. 序列号和幂等
每个流式事件建议包含会话内单调递增的 seq:
start seq=1
status seq=2
delta seq=3
delta seq=4
citation seq=5
done seq=6
客户端收到重复事件时,可以通过 eventId 或 seq 去重。服务端写事件时,需要保证同一个消息的序列号不会重复。
5. 流式消息状态
PENDING
↓
RUNNING
├─ COMPLETED
├─ FAILED
├─ CANCELLED
└─ TIMEOUT
生成开始前创建 RUNNING 消息,生成结束后更新最终状态。连接断开和生成取消是两个不同概念:
- 连接断开:客户端暂时收不到事件;
- 生成取消:服务端主动停止模型任务。
客户端断开时是否自动取消,要根据产品需求决定。
三、工作原理
1. SSE 流式对话链路
客户端 POST /api/conversations/{id}/messages
↓
服务端校验会话和 requestId
↓
保存用户消息和助手占位消息
↓
返回 SSE 连接
↓
发送 start
↓
执行检索 / 工具调用 / 模型生成
↓
发送 delta、citation 和 status
↓
保存最终内容
↓
发送 done
SSE 的连接建立和消息提交可以分成两个接口,也可以使用一个 POST 请求直接返回流。实际项目需要兼顾浏览器 API、鉴权方式和重连策略。
2. 两种常见接口设计
方案 A:POST 直接返回 SSE
POST /messages
请求体:用户问题和 requestId
响应:text/event-stream
优点:
- 一次请求完成提交和接收;
- 可以使用请求体传递复杂参数;
- 适合
fetch读取流。
缺点:
- 原生
EventSource不能发送 POST 请求体; - 重连需要自己重新发起请求;
- 客户端实现比 GET SSE 稍复杂。
方案 B:POST 提交任务,GET 订阅流
POST /messages
↓
返回 messageId / streamId
GET /messages/{messageId}/events
↓
返回 text/event-stream
优点:
- 可用
EventSource; - 任务和订阅解耦;
- 重连更容易使用
Last-Event-ID; - 便于多个客户端订阅同一任务。
缺点:
- 接口数量更多;
- 需要保存和管理事件;
- 需要处理任务已完成但客户端尚未订阅的情况。
生产项目通常更推荐方案 B,尤其是需要断线恢复、历史查看和多端同步时。
3. 断线恢复流程
客户端收到 eventId=evt_1004
↓
网络断开
↓
客户端携带 Last-Event-ID=evt_1004 重连
↓
服务端查询该 message 后续事件
↓
补发 evt_1005 及之后事件
↓
如果已完成,补发 done
↓
如果任务仍在运行,继续订阅实时事件
如果服务端没有保存事件,可以选择重新发送当前完整内容,但客户端必须先清空或按消息版本处理,避免文本重复。
4. WebSocket 链路
WebSocket 适合:
客户端 ⇄ 服务端
├─ 用户消息
├─ 模型片段
├─ 工具确认
├─ 取消任务
├─ 心跳
└─ 多人协作事件
它可以在同一个连接中处理客户端上行和服务端下行,但应用需要自己定义事件协议、鉴权、心跳、重连和订阅关系。
5. 取消生成
取消操作应沿着完整链路传播:
用户点击停止
↓
前端发送 cancel 请求
↓
服务端找到 requestId / messageId
↓
取消 Reactive Subscription 或模型请求
↓
停止工具和后续处理
↓
消息状态改为 CANCELLED
↓
发送 cancelled 事件
只关闭浏览器连接,不一定能停止模型供应商的生成。后端必须保存任务引用,并在取消时调用对应的取消逻辑。
四、实战示例
下面使用 Spring Boot WebFlux 和 Spring AI,设计“提交消息”和“订阅事件”两个接口。
1. Maven 依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
</dependencies>
Spring AI 的 ChatClient 流式调用可以返回 Flux<String>,再由业务层转换成自己的事件模型。SSE 本身使用 text/event-stream,不要直接把供应商原始事件格式暴露给前端。
2. 事件对象
public record StreamEvent(
String id,
String event,
String conversationId,
String messageId,
long seq,
Object data,
Instant createdAt) {
}
public record MessageSubmitRequest(
String content,
String requestId) {
}
public record MessageSubmitResponse(
String conversationId,
String messageId,
String streamId) {
}
生产环境中,事件数据应按事件类型使用不同 DTO,不要把所有内容都定义成 Map<String, Object>,否则前端和日志难以验证。
3. 提交消息
@RestController
@RequestMapping("/api/conversations")
public class ConversationController {
private final ConversationMessageService messageService;
public ConversationController(
ConversationMessageService messageService) {
this.messageService = messageService;
}
@PostMapping("/{conversationId}/messages")
public Mono<MessageSubmitResponse> submit(
@PathVariable String conversationId,
@RequestBody MessageSubmitRequest request,
CurrentUser currentUser) {
return messageService.prepare(
currentUser,
conversationId,
request
)
.map(PreparedMessage::toResponse);
}
}
prepare 方法在短事务内完成:
- 校验会话归属;
- 根据
requestId做幂等; - 保存用户消息;
- 创建
RUNNING的助手消息; - 创建
streamId; - 投递生成任务。
不要在数据库事务中同步等待模型生成。
4. 订阅 SSE
@GetMapping(
value = "/messages/{messageId}/events",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<StreamEvent>> events(
@PathVariable String messageId,
@RequestHeader(value = "Last-Event-ID", required = false)
String lastEventId,
CurrentUser currentUser) {
return eventService.subscribe(
currentUser,
messageId,
lastEventId
).map(event -> ServerSentEvent.<StreamEvent>builder()
.id(event.id())
.event(event.event())
.data(event)
.build());
}
Spring WebFlux 支持将 Flux<ServerSentEvent<?>> 作为响应返回;SSE 端点应设置 MediaType.TEXT_EVENT_STREAM_VALUE。citeturn0search8turn0search11
5. 生成事件流
@Service
public class GenerationEventService {
private final ChatClient chatClient;
private final EventRepository eventRepository;
private final MessageRepository messageRepository;
public Flux<StreamEvent> generate(
PreparedMessage prepared,
String systemPrompt) {
String messageId = prepared.assistantMessageId();
AtomicLong sequence = new AtomicLong(
eventRepository.nextSequence(messageId)
);
Flux<StreamEvent> start = Flux.just(
event("start", messageId, sequence.incrementAndGet(),
Map.of("status", "RUNNING"))
);
Flux<StreamEvent> deltas = chatClient.prompt()
.system(systemPrompt)
.user(prepared.userContent())
.stream()
.content()
.map(text -> event(
"delta",
messageId,
sequence.incrementAndGet(),
Map.of("text", text)
));
Flux<StreamEvent> done = Mono.fromRunnable(() -> {
messageRepository.markCompleted(messageId);
eventRepository.append(
event("done", messageId,
sequence.incrementAndGet(),
Map.of("status", "COMPLETED"))
);
}).thenMany(Flux.empty());
return start.concatWith(deltas)
.concatWith(done)
.onErrorResume(error -> {
messageRepository.markFailed(
messageId,
error.getClass().getSimpleName()
);
return Flux.just(event(
"error",
messageId,
sequence.incrementAndGet(),
Map.of("message", "生成失败")
));
});
}
private StreamEvent event(
String type,
String messageId,
long seq,
Object data) {
return new StreamEvent(
"evt_" + UUID.randomUUID(),
type,
null,
messageId,
seq,
data,
Instant.now()
);
}
}
示例省略了事件持久化细节。生产实现不能只在 map 中创建事件,还要保证事件按顺序写入、重复订阅可补发、完成状态不会被迟到错误覆盖。
6. 前端使用 fetch 读取 SSE
POST 提交加 GET 订阅时,前端可以使用 fetch:
async function readEvents(messageId, lastEventId) {
const response = await fetch(
`/api/conversations/messages/${messageId}/events`,
{
headers: lastEventId
? { "Last-Event-ID": lastEventId }
: {}
}
);
if (!response.ok || !response.body) {
throw new Error("stream connection failed");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const events = buffer.split("\n\n");
buffer = events.pop() ?? "";
for (const raw of events) {
handleSseEvent(raw);
}
}
}
如果使用原生 EventSource,接口通常采用 GET;无论使用哪种方式,都要保存最后收到的 eventId,供断线恢复。
7. 前端处理事件
function handleSseEvent(raw) {
const event = parseSse(raw);
if (!event.id || receivedEventIds.has(event.id)) {
return;
}
receivedEventIds.add(event.id);
lastEventId = event.id;
switch (event.event) {
case "start":
setGenerating(true);
break;
case "delta":
appendAssistantText(event.data.text);
break;
case "citation":
addCitation(event.data);
break;
case "error":
showError(event.data.message);
setGenerating(false);
break;
case "done":
setGenerating(false);
break;
}
}
长时间运行时,客户端不能无限保存所有已接收事件。可以使用消息序列号和最近窗口做去重,或在收到 done 后清理事件 ID 集合。
8. 心跳事件
代理和负载均衡器可能会关闭长时间没有数据的连接。服务端可以发送注释或心跳事件:
Flux<StreamEvent> heartbeat = Flux.interval(Duration.ofSeconds(15))
.map(tick -> event(
"heartbeat",
messageId,
sequence.incrementAndGet(),
Map.of("time", Instant.now())
));
心跳不应该被当成模型内容,也不应该写入最终助手消息。
9. 取消接口
@PostMapping("/messages/{messageId}/cancel")
public Mono<Void> cancel(
@PathVariable String messageId,
CurrentUser currentUser) {
return generationService.cancel(currentUser, messageId);
}
服务层需要:
public Mono<Void> cancel(CurrentUser user, String messageId) {
return repository.findRunningMessage(
user.tenantId(),
user.userId(),
messageId
)
.switchIfEmpty(Mono.error(
new ResponseStatusException(HttpStatus.NOT_FOUND)
))
.flatMap(message -> {
taskRegistry.cancel(message.requestId());
return repository.markCancelled(message.id());
})
.then();
}
取消接口必须校验当前用户有权操作该消息,不能仅凭 messageId 直接取消其他用户的任务。
10. Nginx 配置
SSE 代理需要关闭缓冲:
location /api/conversations/ {
proxy_pass http://ai_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
add_header X-Accel-Buffering no;
}
同时检查云负载均衡、API Gateway 和 WAF 是否有更短的空闲超时或响应缓冲设置。
11. WebSocket 的 Spring WebFlux 示例
当业务需要双向事件时,可以使用 WebSocket:
@Configuration
public class WebSocketConfig {
@Bean
HandlerMapping webSocketMapping(ChatWebSocketHandler handler) {
Map<String, WebSocketHandler> map = Map.of(
"/ws/conversation", handler
);
SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping();
mapping.setOrder(-1);
mapping.setUrlMap(map);
return mapping;
}
}
WebSocket 消息也建议使用统一 JSON 事件:
{
"type": "user_message",
"requestId": "req_001",
"conversationId": "c_001",
"payload": {
"text": "帮我总结这份文档"
}
}
不要因为使用 WebSocket 就跳过消息持久化和权限校验。
五、常见问题与实践建议
1. 为什么前端收到的不是逐字输出
可能原因:
- 模型本身没有流式返回;
- Spring AI 流式接口被错误地收集成完整字符串;
- WebFlux 之外的阻塞代码阻塞事件循环;
- Nginx 或网关开启了缓冲;
- 前端读取逻辑等待完整响应;
- 服务器或代理压缩了响应;
- 事件没有按 SSE 格式发送。
排查时可以从浏览器 Network、应用日志、网关日志和模型调用日志逐层确认首个片段在哪里被延迟。
2. 客户端断开后,模型还在继续生成
连接生命周期不一定等于模型任务生命周期。使用 doOnCancel 或任务注册表监听取消,并将取消信号传递给模型调用:
return modelFlux
.doOnCancel(() -> taskRegistry.cancel(requestId))
.doFinally(signal -> metrics.record(signal));
如果供应商 API 不支持取消,至少要停止后续结果消费,并确保不会继续向已失效的客户端发送。
3. 如何避免重复显示
客户端需要同时处理:
eventId去重;seq顺序检查;- 重连后的补发;
- 已完成消息不再追加;
- 重新生成使用新的
messageId。
服务端也不能因为客户端重连,就重复创建一条助手消息。
4. SSE 是否支持双向通信
SSE 本身是服务端到客户端的事件流。客户端可以通过普通 POST、PUT 或 DELETE 发送消息和取消请求,但不是同一条 SSE 连接上的双向消息。
如果业务需要客户端持续向服务端发送控制事件,WebSocket 更自然。
5. 断线重连应该从哪里恢复
优先使用服务端保存的事件:
Last-Event-ID
↓
查询 message_event
↓
补发未收到事件
↓
继续实时订阅
如果事件只保存在内存,服务重启后无法恢复。至少要保存最终消息状态和最终正文;对关键场景再保存事件流。
6. 是否应该把每个 delta 都写数据库
一般不建议每个 Token 片段都写数据库:
- 写入量巨大;
- 数据库锁和索引压力增加;
- 查询最终消息时没有必要;
- 重放事件的收益有限。
常见方案:
- delta 只通过内存或 Redis 传输;
- 每隔固定时间批量保存;
- 只保存关键状态事件;
- 最终完成时写入完整内容;
- 对需要审计的场景保存压缩后的事件流。
7. 连接超时怎么处理
需要同时检查:
- 浏览器 fetch 超时;
- Spring WebFlux 请求超时;
- Servlet 或 Netty 超时;
- Nginx
proxy_read_timeout; - 云负载均衡空闲超时;
- 模型供应商响应超时。
设置很长的超时不能替代心跳、断线恢复和任务状态持久化。
8. SSE 和 WebSocket 如何选择
建议:
文本生成、RAG 回答、进度推送
→ SSE
语音流、多人协作、双向 Agent 控制
→ WebSocket
普通短回答、无需实时反馈
→ 普通 HTTP
不要为了“实时”而默认引入 WebSocket。复杂协议和连接管理会增加开发与运维成本。
六、进阶思考
1. 统一事件协议
无论使用 SSE 还是 WebSocket,都可以复用同一套业务事件:
transport:
SSE / WebSocket
event:
start / status / delta / citation / tool_call / done
这样前端只需要适配传输层,业务事件和消息状态保持一致。
2. 流式 RAG
RAG 对话可以发送阶段事件:
start
↓
status: RETRIEVING
↓
citation: 命中资料
↓
status: GENERATING
↓
delta
↓
done
不要把内部检索过程中的敏感内容全部暴露给前端。引用事件只返回用户有权限看到的来源摘要。
3. 流式工具调用
Agent 任务可以产生:
tool_call_started
↓
tool_approval_required
↓
tool_call_completed
↓
delta
高风险工具调用需要暂停流,等待用户确认。确认应绑定到具体的工具名称、参数摘要、会话和过期时间,不能使用一个永久有效的“已同意”标志。
4. 多端订阅
当同一个会话同时在 Web、移动端和企业 IM 中打开时,需要将生成任务和订阅者分开:
一个 message generation
├─ Web subscriber
├─ Mobile subscriber
└─ IM subscriber
每个订阅者记录自己的最后事件 ID,不能因为一个客户端断线就取消整个生成任务。
5. 背压和慢客户端
如果模型生成很快,而客户端处理很慢,事件会在内存中堆积。需要考虑:
- 限制每个连接的缓冲;
- 合并短 delta;
- 对慢客户端主动断开并允许恢复;
- 不让一个客户端拖慢整个生成任务;
- 将事件写入 Redis Stream 或消息队列;
- 设置最大消息和最大连接数。
6. 可靠性状态机
建议将消息状态和连接状态分开:
消息状态:
PENDING → RUNNING → COMPLETED / FAILED / CANCELLED
连接状态:
CONNECTED → DISCONNECTED → RECONNECTING → CONNECTED
连接断开不应该直接把消息状态改为失败。只有模型任务真正失败或被取消时,才更新消息最终状态。
7. 观测指标
流式接口至少记录:
| 指标 | 作用 |
|---|---|
| 首 Token 延迟 | 衡量用户首次反馈速度 |
| 总生成耗时 | 衡量模型和系统性能 |
| 事件数量 | 判断片段粒度和传输压力 |
| 重连次数 | 判断网络稳定性 |
| 取消率 | 衡量用户是否等待不住 |
| 错误率 | 判断模型、网关和业务故障 |
| 完成率 | 衡量任务最终成功情况 |
| 断线恢复成功率 | 衡量可靠性 |
将 requestId、messageId 和 eventId 放入日志关联字段,才能从用户请求追踪到模型调用和最终消息。
结论
流式对话接口的核心不是把模型输出拆成小字符串,而是建立一套可恢复、可审计、可取消、可观测的消息事件系统。
本文的关键实践包括:
- 普通文本生成优先考虑 SSE,双向实时交互再使用 WebSocket;
- 用
start、status、delta、citation、error和done统一事件协议; - 使用
requestId做提交幂等,使用messageId管理业务消息,使用eventId支持恢复; - 生成开始前创建
RUNNING助手消息; - 将连接断开和生成取消分开处理;
- 使用事件序列号和
Last-Event-ID实现断线恢复; - 配置网关关闭缓冲并发送心跳;
- 高风险工具调用和提交动作需要人工确认;
- 记录首 Token 延迟、总耗时、重连和取消指标。
完成这套基础链路后,后续可以继续接入多端订阅、流式 RAG、Agent 工具调用和消息事件持久化。