AI 流式对话与实时聊天平台:SSE 解码竞态、有状态会话隔离与背压取消的 Vue3 + Spring AI 生产级实战
注:
博客:
一、项目架构与核心设计决策
1.1 整体架构
┌──────────────────────────────────────────────────────────────┐
│ 前端 (Vue3 + EventSource/fetch) │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │聊天面板 │ │会话列表 │ │流式渲染 │ │取消/续传│ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
└──────────────────────────────────────────────────────────────┘
│ SSE (text/event-stream)
┌──────────────────────────────────────────────────────────────┐
│ 后端 (Spring Boot 3.x + WebFlux + Spring AI 1.1) │
│ ┌──────────────────────────────────────────────────────────┐│
│ │ 流式生命周期 (Stream Lifecycle) ││
│ │ token 事件 → 心跳事件 → 完成事件 / 取消(doOnCancel) ││
│ │ 有状态会话(Redis/ChatMemory) → 并发闸门 → 幂等续接 ││
│ └──────────────────────────────────────────────────────────┘│
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │流式输出 │ │会话管理 │ │记忆压缩 │ │并发控制 │ │
│ │Flux │ │Redis │ │摘要+窗口│ │信号量 │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
└──────────────────────────────────────────────────────────────┘
│
┌──────────────────────────────────────────────────────────────┐
│ MySQL(消息审计/摘要) Redis(会话状态/并发闸门/续传游标) │
└──────────────────────────────────────────────────────────────┘
1.2 核心功能
- SSE 流式输出 :
ChatClient.stream().content()返回Flux<String>,WebFlux 自动处理text/event-stream帧格式,首字延迟从秒级压缩到毫秒级 - 有状态流式会话:对话上下文存在 Redis(短期)与 MySQL(审计/摘要),进程重启、多实例部署不丢上下文
- token 级流式渲染:前端以缓冲区 + 事件边界切分解析 SSE,逐 token 渲染,保留换行与前导空格
- 心跳保活 :服务端每 15 秒发送
heartbeat事件,防止 Nginx/Tomcat 空闲超时断连 - 取消生成 :前端
AbortController中断请求,后端doOnCancel感知并释放上游 AI 连接 - 并发闸门:同一会话同时只允许一个流式生成,防止用户连续发送导致多路流交错写入
- 断线续接 :每次流式调用分配
invocationId,客户端断线后可携带该标识续接,服务端做幂等去重 - 记忆压缩:滑动窗口 + 摘要压缩,长对话 token 消耗从线性增长降到可控范围
1.3 四个根本性工程问题
第一个问题:前端 SSE 解析的"解码竞态"。
fetch 的 ReadableStream 返回的 chunk 不保证与 SSE 事件边界对齐 。一个 data: 事件可能被拆到两个 chunk 中,也可能多个事件挤在一个 chunk 里。如果对每个 chunk 直接 split('\n') 并逐行判断 startsWith('data:'),被拆开的事件会被截断,被合并的事件会丢失边界。正确做法是维护一个缓冲区 ,按 \n\n 切分完整事件,剩余的半截留在缓冲区等待下一个 chunk。
第二个问题:token 边界处的"空白丢失"。
流式输出的每个 token 是模型生成的最小片段 ,它可能是一个单词、一个标点、一个空格,甚至是一个换行符。如果在拼接或传输过程中对 token 调用 trim(),换行和前导空格会被吃掉,Markdown 的段落分隔和代码缩进全部失效。这是流式聊天最高频的"隐形 bug"------文本看起来"有",但格式全乱。token 必须原样拼接 ,格式化(marked 渲染)只能在完整消息或渲染时做,绝不能在 token 层面做。
第三个问题:流式会话的"有状态"与"无状态"的边界。
WebFlux 本身是无状态的,但流式对话的上下文是有状态 的。如果把对话历史存在 ChatSession 实体的内存列表中,进程重启就丢失,多实例部署时不同实例看到的历史不一致。生产级方案必须把会话状态外置到 Redis(热数据)和 MySQL(审计/摘要),ChatMemory 只作为读穿缓存的逻辑层,而不是唯一存储。
第四个问题:取消与背压的缺失。
用户点击"停止生成"时,如果只是前端断开连接而后端继续跑完,模型调用费用照付、连接资源不释放。后端必须通过 doOnCancel 感知客户端断开,取消上游 Flux 订阅,释放 AI 连接。同时,如果前端渲染速度跟不上 token 到达速度,需要有背压策略(丢弃中间 token 或合并渲染),而不是无限缓冲。
类比讲解 :流式对话像现场直播的字幕员。解码竞态是"字幕员把一句话拆成两半发出去,观众看到断句错乱";token 空白丢失是"字幕员把换行删了,所有台词挤成一行";有状态会话是"字幕员的笔记本放在自己桌上,换一个演播室就接不上上一场";取消缺失是"导演喊停了,字幕员还在对着空演播室继续打字"。
二、后端实现
2.1 依赖配置
xml
<properties>
<java.version>21</java.version>
<spring-ai.version>1.1.4</spring-ai.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
必须使用 WebFlux,而非 Spring MVC 。WebFlux 的 Flux 返回方式天然处理 SSE 帧格式,且 Spring MVC 的 SseEmitter 路径上某些 HTTP 消息转换器可能引入缓冲,导致"数据不是逐 token 到达,而是一次性吐出"。ChatClient.stream().content() 返回的 Flux<String> 直接作为控制器返回值即可,Spring WebFlux 自动完成 text/event-stream 编码。
2.2 流式服务:完整生命周期
一次流式对话的生命周期包含五类事件:token(增量内容)、heartbeat(保活)、complete(正常结束)、error(异常)、cancelled(用户取消)。服务端必须把这五类事件都显式建模,而不是只发 token 然后靠连接关闭隐式表达结束。
java
@Slf4j
@Service
@RequiredArgsConstructor
public class StreamingChatService {
private final ChatClient chatClient;
private final ChatMemory chatMemory;
private final SessionStateRepository sessionState;
private final ConcurrencyGate concurrencyGate;
private static final Duration HEARTBEAT_INTERVAL = Duration.ofSeconds(15);
/**
* 流式对话:token + 心跳 + 完成/取消/错误 全生命周期
*/
public Flux<ServerSentEvent<String>> stream(StreamRequest request) {
String conversationId = request.conversationId();
String invocationId = request.invocationId() != null
? request.invocationId()
: UUID.randomUUID().toString();
// 1. 并发闸门:同一会话同时只允许一个生成
return concurrencyGate.tryAcquire(conversationId)
.flatMapMany(acquired -> {
if (!acquired) {
return Flux.just(ServerSentEvent.<String>builder()
.event("error")
.data("当前会话已有进行中的生成,请先停止或等待完成")
.build());
}
// 2. 持久化 invocation,用于断线续接与幂等
sessionState.beginInvocation(conversationId, invocationId);
// 3. token 流
Flux<ServerSentEvent<String>> tokenStream = chatClient.prompt()
.user(request.message())
.advisors(spec -> spec.param(
ChatMemory.CONVERSATION_ID, conversationId))
.stream()
.content()
.map(token -> ServerSentEvent.<String>builder()
.event("token")
.data(token) // 原样透传,绝不 trim
.build())
.doOnComplete(() -> sessionState
.completeInvocation(conversationId, invocationId))
.doOnCancel(() -> {
// 客户端断开:取消上游订阅,释放 AI 连接
log.info("流式生成被取消: conversationId={}, invocationId={}",
conversationId, invocationId);
sessionState.cancelInvocation(
conversationId, invocationId);
})
.doFinally(signal -> concurrencyGate.release(conversationId));
// 4. 心跳:与 token 流合并,15 秒一次
Flux<ServerSentEvent<String>> heartbeat =
Flux.interval(HEARTBEAT_INTERVAL)
.map(tick -> ServerSentEvent.<String>builder()
.event("heartbeat")
.data("ping")
.build())
.takeUntilOther(tokenStream.ignoreElements());
// 5. 合并:token 优先,心跳在空闲时穿插
return Flux.merge(tokenStream, heartbeat)
.concatWith(Flux.just(ServerSentEvent.<String>builder()
.event("complete")
.data(invocationId)
.build()))
.onErrorResume(e -> {
log.error("流式生成失败", e);
return Flux.just(ServerSentEvent.<String>builder()
.event("error")
.data("生成失败,请稍后重试")
.build());
});
});
}
}
心跳的必要性 :Nginx 的默认 proxy_read_timeout 是 60 秒,Tomcat 的默认连接超时是 30 秒。如果模型生成过程中出现较长的静默(例如推理模型在"思考"阶段不吐 token),连接会被中间层当作空闲连接回收。15 秒一次的心跳远小于这些超时值,保证连接始终被认定为活跃。
doOnCancel 与取消语义 :当客户端调用 AbortController.abort() 断开连接时,WebFlux 会取消下游订阅,doOnCancel 被触发。此时取消上游 Flux 订阅,AI 连接随之释放,避免"用户已经走了、模型还在跑"的浪费。
2.3 并发闸门:同一会话的串行化
同一会话如果同时存在两个流式生成,会出现 token 交错写入、上下文互相污染、消息顺序错乱。并发闸门用 Redis 的 SET NX 实现会话级互斥:
java
@Component
@RequiredArgsConstructor
public class ConcurrencyGate {
private final ReactiveStringRedisTemplate redis;
private static final Duration TTL = Duration.ofMinutes(10);
public Mono<Boolean> tryAcquire(String conversationId) {
return redis.opsForValue().setIfAbsent(
"chat:gen:lock:" + conversationId, "1", TTL);
}
public Mono<Void> release(String conversationId) {
return redis.delete("chat:gen:lock:" + conversationId).then();
}
}
与"每个请求一个线程"的差异 :WebFlux 是事件循环模型,不需要线程池来隔离请求,但业务语义上的互斥仍然需要显式实现。并发闸门锁的是"会话",不是"连接"------用户可以在多个会话中并行生成,但同一会话内必须串行。
2.4 会话状态:Redis 热数据 + MySQL 审计
ChatMemory 是逻辑层 ,ChatMemoryRepository 是存储层 。生产环境使用 JDBC 持久化,保证进程重启不丢历史。但流式的"进行中"状态(invocationId、是否已取消、断线游标)不适合放在 ChatMemory 中,需要单独的 Redis 结构。
java
@Component
@RequiredArgsConstructor
public class SessionStateRepository {
private final ReactiveStringRedisTemplate redis;
public Mono<Void> beginInvocation(String conversationId, String invocationId) {
return redis.opsForHash().putAll(
"chat:invocation:" + conversationId,
Map.of("invocationId", invocationId,
"status", "RUNNING",
"startedAt", Instant.now().toString())
).then();
}
public Mono<Void> completeInvocation(String conversationId, String invocationId) {
return redis.opsForHash().put(
"chat:invocation:" + conversationId, "status", "COMPLETED"
).then();
}
public Mono<Void> cancelInvocation(String conversationId, String invocationId) {
return redis.opsForHash().put(
"chat:invocation:" + conversationId, "status", "CANCELLED"
).then();
}
}
2.5 记忆压缩:滑动窗口 + 摘要
MessageWindowChatMemory 的默认行为是"保留最近 N 条消息,丢弃更早的"。这在短对话中没问题,但超过 20 轮后,早期上下文(如用户在第一轮说的约束条件)会被丢弃,导致后续回答偏离。生产级方案是滑动窗口 + 摘要压缩:窗口外的旧消息不直接丢弃,而是先让模型生成一段摘要,把摘要作为一条特殊的系统消息保留在窗口头部。
java
@Service
@RequiredArgsConstructor
public class CompressingChatMemory implements ChatMemory {
private final ChatMemory delegate; // MessageWindowChatMemory
private final ChatClient summarizer;
private final MessageSummaryRepository summaryRepo;
private static final int SUMMARY_TRIGGER = 20;
@Override
public List<Message> get(String conversationId) {
List<Message> window = delegate.get(conversationId);
summaryRepo.findLatest(conversationId)
.map(MessageSummary::getContent)
.ifPresent(summary -> window.add(0,
new SystemMessage("【早期对话摘要】" + summary)));
return window;
}
@Override
public void add(String conversationId, List<Message> messages) {
delegate.add(conversationId, messages);
List<Message> all = delegate.get(conversationId);
if (all.size() > SUMMARY_TRIGGER) {
// 将窗口外的消息压缩为摘要
List<Message> overflow = all.subList(0, all.size() - SUMMARY_TRIGGER);
String summary = summarizer.prompt()
.user("将以下对话压缩为一段简要摘要,保留关键约束和事实:\n"
+ render(overflow))
.call()
.content();
summaryRepo.save(new MessageSummary(conversationId, summary));
}
}
@Override
public void clear(String conversationId) {
delegate.clear(conversationId);
summaryRepo.deleteByConversationId(conversationId);
}
}
注意 Spring AI 的版本演进 :ChatMemory 在 2.1 版本将被 Session API 取代,Session API 内置了事件溯源和上下文压缩能力,并且"压缩时保证完整轮次要么全保留要么全丢弃",避免 MessageWindowChatMemory 可能出现的"保留了工具调用但丢掉了工具结果"的断裂。当前基于 1.1.x 的实现应在设计上预留迁移空间------把压缩逻辑封装在独立的 CompressingChatMemory 中,而非散落在业务代码里,未来替换为 Session API 时成本最低。
2.6 断线续接:invocationId 幂等
流式生成过程中,客户端可能因网络切换、页面刷新而断线。如果没有续接机制,用户只能重新发送问题,而服务端可能已经生成了大半内容。invocationId 的作用是让客户端在重连时携带同一个标识,服务端判断该调用是否已完成、是否仍在运行。
java
@GetMapping(value = "/stream/{invocationId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> resume(@PathVariable String invocationId,
@RequestParam String conversationId) {
return sessionState.getStatus(conversationId)
.flatMapMany(status -> switch (status) {
case "COMPLETED" -> Flux.just(ServerSentEvent.<String>builder()
.event("complete").data(invocationId).build());
case "CANCELLED" -> Flux.just(ServerSentEvent.<String>builder()
.event("cancelled").data(invocationId).build());
case "RUNNING" -> Flux.just(ServerSentEvent.<String>builder()
.event("error")
.data("该调用仍在运行中,请等待完成或新开会话")
.build());
default -> Flux.empty();
});
}
续接不等于"从断点继续吐 token" 。流式 token 是不可重放的------AI 连接已经断开,无法从中途继续。invocationId 的正确语义是幂等告知:告诉客户端"你上次的调用是什么状态",客户端据此决定是展示已完成的内容、重新发送、还是提示用户。这比"假装还能接着流"更诚实,也更可靠。
2.7 REST 控制器
java
@RestController
@RequestMapping("/api/chat")
@RequiredArgsConstructor
public class ChatController {
private final StreamingChatService chatService;
@PostMapping(value = "/stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> stream(
@RequestBody @Valid StreamRequest request) {
return chatService.stream(request);
}
public record StreamRequest(
@NotBlank String conversationId,
String invocationId,
@NotBlank String message) {}
}
三、前端实现
3.1 SSE 解析:缓冲区 + 事件边界
前端不使用 EventSource,而是使用 fetch + ReadableStream,因为 EventSource 只支持 GET 请求,且无法自定义请求头、无法携带复杂的请求体。fetch 的流式读取需要手动处理 SSE 帧格式,核心是缓冲区:
ts
interface SSEEvent {
event: string
data: string
}
const parseSSE = (buffer: string, chunk: string) => {
const combined = buffer + chunk
const blocks = combined.split('\n\n')
const remaining = blocks.pop() ?? '' // 最后一块可能不完整,留到下次
const events: SSEEvent[] = []
for (const block of blocks) {
let event = 'message'
const dataLines: string[] = []
for (const line of block.split('\n')) {
if (line.startsWith('event:')) {
event = line.slice(6).trim()
} else if (line.startsWith('data:')) {
// 只去掉 data: 后的一个前导空格,保留内容本身的空白
const raw = line.slice(5)
dataLines.push(raw.startsWith(' ') ? raw.slice(1) : raw)
}
}
if (dataLines.length > 0) {
events.push({ event, data: dataLines.join('\n') })
}
}
return { events, remaining }
}
为什么不能对 data 做 trim() :SSE 规范中 data: 后的第一个空格是分隔符,需要去掉;但内容中的换行、缩进、首尾空格都是 token 的一部分,trim() 会破坏 Markdown 格式。上面的实现只去掉一个分隔空格,其余原样保留。
3.2 流式发送与取消
ts
const streamChat = async (message: string, conversationId: string) => {
const controller = new AbortController()
abortRef.value = controller
isStreaming.value = true
streamingContent.value = ''
try {
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
conversationId,
invocationId: currentInvocationId.value,
message
}),
signal: controller.signal
})
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
const { events, remaining } = parseSSE(buffer, decoder.decode(value, { stream: true }))
buffer = remaining
for (const evt of events) {
switch (evt.event) {
case 'token':
streamingContent.value += evt.data // 原样拼接,不 trim
break
case 'complete':
isStreaming.value = false
messages.value.push({
role: 'assistant',
content: streamingContent.value,
createdAt: new Date().toISOString()
})
streamingContent.value = ''
break
case 'error':
isStreaming.value = false
ElMessage.error(evt.data)
break
case 'heartbeat':
// 心跳只用于保活,前端可选择静默处理或刷新"生成中"指示
break
}
}
}
} catch (e: any) {
if (e.name === 'AbortError') {
// 用户主动取消,保留已生成的部分内容
if (streamingContent.value) {
messages.value.push({
role: 'assistant',
content: streamingContent.value + '\n\n*[已停止生成]*',
createdAt: new Date().toISOString()
})
}
} else {
ElMessage.error('连接异常')
}
} finally {
isStreaming.value = false
abortRef.value = null
}
}
const stopGeneration = () => {
abortRef.value?.abort()
}
TextDecoder 的 { stream: true } :多字节字符(如中文)的 UTF-8 编码可能被拆到两个 chunk 中。{ stream: true } 让 decoder 保留不完整的字节序列,在下一个 chunk 到达时拼接解码,避免出现乱码。
3.3 渲染:流式期间与完成后的差异
流式期间逐 token 拼接的内容是不完整的 Markdown ,直接交给 marked 渲染可能导致代码块未闭合、列表格式闪烁。生产级做法是:流式期间使用纯文本 + 保留换行 的渲染方式,完成后才用 marked 渲染最终 Markdown。
vue
<template>
<div class="msg-text">
<div v-if="msg.streaming" class="streaming-plain">
{{ msg.content }}
</div>
<div v-else v-html="renderMarkdown(msg.content)" />
</div>
</template>
<script setup lang="ts">
import { marked } from 'marked'
import DOMPurify from 'dompurify'
const renderMarkdown = (content: string) =>
DOMPurify.sanitize(marked.parse(content) as string)
</script>
<style scoped>
.streaming-plain {
white-space: pre-wrap; /* 保留换行与空格 */
word-break: break-word;
}
</style>
white-space: pre-wrap 是流式期间保留换行的关键------{``{ msg.content }} 在普通 div 中会把换行折叠成空格,加了这个样式才能让 token 中的 \n 真正显示为换行。
四、生产级扩展
4.1 Nginx 配置:关闭缓冲与延长超时
即使服务端正确流式输出,Nginx 的默认 proxy_buffering 会把响应缓冲到完整后再发给客户端,导致"流式变批量"。必须在 location 中显式关闭:
nginx
location /api/chat/ {
proxy_pass http://backend;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
proxy_http_version 1.1;
}
proxy_read_timeout 应设为大于模型最长生成时间(如 5 分钟),并配合服务端心跳,确保长生成不被中间层切断。
4.2 可观测性
| 指标 | 采集方式 | 告警阈值 |
|---|---|---|
chat.stream.ttft |
首 token 到达耗时 | p95 > 3s 需检查模型或网络 |
chat.stream.duration |
端到端生成耗时 | p95 > 业务 SLA |
chat.stream.cancel.rate |
用户取消比例 | 突增说明生成质量或速度下降 |
chat.stream.error.rate |
流式错误率 | > 2% 需排查上游模型 |
chat.concurrency.rejected |
并发闸门拒绝数 | 持续 > 0 说明用户连续发送频繁 |
chat.heartbeat.missed |
心跳发送失败次数 | > 0 说明连接已断开但未清理 |
4.3 安全边界
conversationId归属校验 :每次流式请求必须校验conversationId属于当前用户,防止通过猜测 ID 读取他人对话。- token 内容过滤 :流式 token 直接透传给前端,如果模型输出包含 HTML/脚本,前端
v-html之前必须经过DOMPurify清洗。 - 取消的幂等性 :
doOnCancel可能被多次触发(连接断开、超时、客户端主动取消),cancelInvocation必须是幂等的,不能因为重复调用而报错。
五、总结
本文构建了一个生产级 AI 流式对话平台,核心设计决策如下:
| 能力 | 实现方案 | 关键收益 |
|---|---|---|
| 流式输出 | WebFlux + ChatClient.stream().content() 返回 Flux<String> |
Spring 自动处理 SSE 帧,无缓冲 |
| 心跳保活 | Flux.interval(15s) 合并进 token 流 |
防止 Nginx/Tomcat 空闲超时断连 |
| 取消生成 | 前端 AbortController + 后端 doOnCancel |
释放 AI 连接,停止计费 |
| 解码健壮性 | 前端缓冲区 + \n\n 事件边界切分 + TextDecoder({stream:true}) |
解决 chunk 与事件不对齐、多字节字符断裂 |
| token 完整性 | data: 后只去一个分隔空格,绝不 trim() |
保留换行与代码缩进,Markdown 不塌陷 |
| 会话有状态 | ChatMemory + JDBC 持久化 + Redis 运行态 |
重启不丢上下文,多实例一致 |
| 记忆压缩 | 滑动窗口 + 摘要注入窗口头部 | 长对话 token 可控,早期约束不丢失 |
| 并发控制 | Redis SET NX 会话级闸门 |
同一会话不出现 token 交错 |
| 断线续接 | invocationId + 状态查询 |
客户端重连后可幂等恢复,不盲目重发 |
核心认知 :流式聊天的难点不在"调通 stream()",而在让流在不可靠的网络和中间件中完整、有序、可取消地到达用户 。解码缓冲区解决"到达的 chunk 不完整",token 原样透传解决"到达的内容被篡改",心跳解决"连接被中间层回收",doOnCancel 解决"用户走了资源还在跑",并发闸门解决"同一会话多路交错"。五者叠加,流式对话才从一个"逐字打印的效果"变成一个"可运营的实时系统"。