实时流式响应的 SSE 技术实现
实时流式响应的 SSE 技术实现:AI 代码生成器的核心通信机制
业务场景:为什么需要实时反馈
在我们的 AI 代码生成器中,用户通过对话的方式描述需求,AI 会根据描述生成完整的网站代码。但这个过程有几个痛点:
- 生成时间长:AI 生成一个完整网站可能需要 20-60 秒
- 用户体验差:长时间的等待让用户不知道系统是否还在工作
- 无法中断:用户无法知道生成进度,也无法及时发现问题
为了解决这些问题,我们需要实现:
- ✅ 实时显示 AI 生成的文本内容
- ✅ 显示生成进度和状态
- ✅ 支持错误处理和异常中断
- ✅ 良好的用户体验反馈
技术选型:SSE vs WebSocket
在调研阶段,我主要考虑了两种方案:
WebSocket 方案
优点:
- 双向通信能力
- 实时性好
- 支持二进制数据
缺点:
- 复杂度高,需要处理连接管理、心跳检测
- 服务端需要维护连接状态
- 对于单向数据流有些过度设计
SSE 方案
优点:
- 原生支持断线重连
- 实现简单,基于 HTTP 协议
- 天然支持文本流式传输
- 浏览器兼容性好
缺点:
- 只支持服务端到客户端的单向通信
- 不支持二进制数据
考虑到我们的场景主要是服务端向客户端推送 AI 生成的文本内容,SSE 成为了最佳选择。
后端实现:Spring Boot + WebFlux
核心控制器实现
我使用 Spring WebFlux 的响应式编程模型来实现 SSE:
// 来源:src/main/java/com/ustinian/cheeseaicode/controller/AppController.java (第337-366行)
@GetMapping(value = "/chat/gen/code", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@RateLimit(limitType = RateLimitType.USER, rate = 5, rateInterval = 60, message = "AI 对话请求过于频繁,请稍后再试")
public Flux<ServerSentEvent<String>> chatToGenCode(@RequestParam Long appId,
@RequestParam String message,
HttpServletRequest request) {
// 参数校验
ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用ID无效");
ThrowUtils.throwIf(StrUtil.isBlank(message), ErrorCode.PARAMS_ERROR, "用户消息不能为空");
// 获取当前登录用户
User loginUser = userService.getLoginUser(request);
// 调用服务生成代码(流式)
Flux<String> contentFlux = appService.chatToGenCode(appId, message, loginUser);
// 转换为 ServerSentEvent 格式
return contentFlux
.map(chunk -> {
// 将内容包装成JSON对象
Map<String, String> wrapper = Map.of("d", chunk);
String jsonData = JSONUtil.toJsonStr(wrapper);
return ServerSentEvent.<String>builder()
.data(jsonData)
.build();
})
.concatWith(Mono.just(
// 发送结束事件
ServerSentEvent.<String>builder()
.event("done")
.data("")
.build()
));
}
关键技术点解析:
- Media Type 设置:
produces = MediaType.TEXT_EVENT_STREAM_VALUE告诉浏览器这是一个 SSE 响应 - 限流保护:使用
@RateLimit注解防止恶意请求,每用户每分钟最多 5 次请求 - 响应式编程:返回
Flux<ServerSentEvent<String>>实现流式响应 - 数据包装:将每个数据块包装成 JSON 格式,便于前端解析
- 结束事件:使用
concatWith在流结束时发送 "done" 事件
服务层的流式处理
// 来源:src/main/java/com/ustinian/cheeseaicode/service/impl/AppServiceImpl.java (第164-196行)
@Override
public Flux<String> chatToGenCode(Long appId, String message, User loginUser) {
// 1. 参数校验
ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用 ID 不能为空");
ThrowUtils.throwIf(StrUtil.isBlank(message), ErrorCode.PARAMS_ERROR, "用户消息不能为空");
// 2. 查询应用信息
App app = this.getById(appId);
ThrowUtils.throwIf(app == null, ErrorCode.NOT_FOUND_ERROR, "应用不存在");
// 3. 验证用户是否有权限访问该应用,仅本人可以生成代码
if (!app.getUserId().equals(loginUser.getId())) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无权限访问该应用");
}
// 4. 获取应用的代码生成类型(固定,按 DB 存储,不再根据提示词调整)
String codeGenTypeStr = app.getCodeGenType();
CodeGenTypeEnum codeGenTypeEnum = CodeGenTypeEnum.getEnumByValue(codeGenTypeStr);
if (codeGenTypeEnum == null) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "不支持的代码生成类型");
}
// 5. 通过校验后,添加用户消息到对话历史
chatHistoryService.addChatMessage(appId, message, ChatHistoryMessageTypeEnum.USER.getValue(), loginUser.getId());
// 6. 调用 AI 生成代码(流式)
Flux<String> codeStream = aiCodeGeneratorFacade.generateAndSaveCodeStream(message, codeGenTypeEnum, appId);
// 7. 收集 AI 响应内容并在完成后记录到对话历史
return streamHandlerExecutor.doExecute(codeStream, chatHistoryService, appId, loginUser, codeGenTypeEnum);
}
这个方法展现了完整的业务流程:权限校验 → 参数验证 → 调用 AI 服务 → 流式处理 → 记录历史。
流处理器执行器
为了处理不同类型的代码生成流,我设计了一个执行器模式:
// 来源:src/main/java/com/ustinian/cheeseaicode/core/handler/StreamHandlerExecutor.java (第34-43行)
public Flux<String> doExecute(Flux<String> originFlux,
ChatHistoryService chatHistoryService,
long appId, User loginUser, CodeGenTypeEnum codeGenType) {
return switch (codeGenType) {
case VUE_PROJECT -> // 使用注入的组件实例
jsonMessageStreamHandler.handle(originFlux, chatHistoryService, appId, loginUser);
case HTML, MULTI_FILE -> // 简单文本处理器不需要依赖注入
new SimpleTextStreamHandler().handle(originFlux, chatHistoryService, appId, loginUser);
};
}
这里使用了策略模式,根据不同的代码生成类型选择合适的流处理器。VUE_PROJECT 类型需要处理复杂的 JSON 消息格式,而 HTML 和 MULTI_FILE 类型只需要简单的文本处理。
异常处理和连接管理
全局异常处理器
SSE 连接中的异常处理比较特殊,需要将异常转换为 SSE 事件格式:
// 来源:src/main/java/com/ustinian/cheeseaicode/exception/GlobalExceptionHandler.java (第65-83行)
response.setCharacterEncoding("UTF-8");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Connection", "keep-alive");
// 构造错误消息的SSE格式
Map<String, Object> errorData = Map.of(
"error", true,
"code", errorCode,
"message", errorMessage
);
String errorJson = JSONUtil.toJsonStr(errorData);
// 发送业务错误事件(避免与标准error事件冲突)
String sseData = "event: business-error\ndata: " + errorJson + "\n\n";
response.getWriter().write(sseData);
response.getWriter().flush();
// 发送结束事件
response.getWriter().write("event: done\ndata: {}\n\n");
response.getWriter().flush();
// 表示已处理SSE请求
return true;
异常处理的关键设计:
- 自定义事件类型:使用
business-error事件类型,避免与浏览器标准的error事件冲突 - 结构化错误信息:错误信息包含错误码、错误消息等结构化数据
- 优雅关闭:发送错误后立即发送
done事件,通知客户端连接结束
分布式限流机制
为了防止恶意请求和保护系统资源,我实现了基于 Redis 的分布式限流:
// 来源:src/main/java/com/ustinian/cheeseaicode/ratelimiter/aspect/RateLimitAspect.java (第35-47行)
@Before("@annotation(rateLimit)")
public void doBefore(JoinPoint point, RateLimit rateLimit) {
String key = generateRateLimitKey(point, rateLimit);
// 使用Redisson的分布式限流器
RRateLimiter rateLimiter = redissonClient.getRateLimiter(key);
rateLimiter.expire(Duration.ofHours(1)); // 1 小时后过期
// 设置限流器参数:每个时间窗口允许的请求数和时间窗口
rateLimiter.trySetRate(RateType.OVERALL, rateLimit.rate(), rateLimit.rateInterval(), RateIntervalUnit.SECONDS);
// 尝试获取令牌,如果获取失败则限流
if (!rateLimiter.tryAcquire(1)) {
throw new BusinessException(ErrorCode.TOO_MANY_REQUEST, rateLimit.message());
}
}
这个限流机制使用了令牌桶算法,支持按用户、IP、API 等不同维度进行限流。
前端实现:EventSource 接收和处理
EventSource 连接管理
前端使用原生的 EventSource API 来接收 SSE 事件:
// 来源:cheese-ai-code-frontend/src/pages/app/AppChatPage.vue (第276-348行)
const generateCode = async (userMessage: string, aiMessageIndex: number) => {
let eventSource: EventSource | null = null
let streamCompleted = false
try {
const baseURL = request.defaults.baseURL || API_BASE_URL
const params = new URLSearchParams({ appId: appId.value || '', message: userMessage })
const url = `${baseURL}/app/chat/gen/code?${params}`
eventSource = new EventSource(url, { withCredentials: true })
let fullContent = ''
eventSource.onmessage = function (event) {
if (streamCompleted) return
try {
const parsed = JSON.parse(event.data)
const content = parsed.d
if (content !== undefined && content !== null) {
fullContent += content
messages.value[aiMessageIndex].content = fullContent
messages.value[aiMessageIndex].loading = false
scrollToBottom()
}
} catch (error) {
handleError(error, aiMessageIndex)
}
}
eventSource.addEventListener('done', function () {
if (streamCompleted) return
streamCompleted = true
isGenerating.value = false
eventSource?.close()
setTimeout(async () => { await fetchAppInfo(); updatePreview() }, 800)
})
// 处理business-error事件(后端限流等错误)
eventSource.addEventListener('business-error', function (event: Event) {
if (streamCompleted) return
try {
const errorData = JSON.parse((event as MessageEvent).data)
console.error('SSE业务错误事件:', errorData)
// 显示具体的错误信息
const errorMessage = errorData.message || '生成过程中出现错误'
messages.value[aiMessageIndex].content = `❌ ${errorMessage}`
messages.value[aiMessageIndex].loading = false
message.error(errorMessage)
streamCompleted = true
isGenerating.value = false
eventSource?.close()
} catch (parseError) {
console.error('解析错误事件失败:', parseError, '原始数据:', (event as MessageEvent).data)
handleError(new Error('服务器返回错误'), aiMessageIndex)
}
})
eventSource.onerror = function () {
if (streamCompleted || !isGenerating.value) return
if (eventSource?.readyState === EventSource.CONNECTING) {
streamCompleted = true
isGenerating.value = false
// 重要:在连接失败(浏览器保持 CONNECTING)时,显式结束卡片的"正在思考"状态,避免无限转圈
if (messages.value[aiMessageIndex]) {
messages.value[aiMessageIndex].loading = false
}
eventSource?.close()
setTimeout(async () => { await fetchAppInfo(); updatePreview() }, 800)
} else {
handleError(new Error('SSE连接错误'), aiMessageIndex)
}
}
} catch (error) {
handleError(error, aiMessageIndex)
}
}
前端处理的核心要点:
- 状态管理:使用
streamCompleted标志避免重复处理事件 - 凭证传递:
withCredentials: true确保认证信息正确传递 - 多事件监听:分别处理
message、done、business-error事件 - 错误处理:针对不同类型的错误提供不同的处理策略
- 连接状态管理:正确处理连接失败和网络异常情况
性能优化策略
1. 连接复用和管理
虽然 SSE 是一次性连接,但我们通过以下方式优化性能:
// 连接清理逻辑
eventSource?.close()
streamCompleted = true
isGenerating.value = false
确保每次连接使用完毕后及时释放资源。
2. 内容缓冲和批量更新
let fullContent = ''
eventSource.onmessage = function (event) {
// 累积内容
fullContent += content
// 批量更新 UI
messages.value[aiMessageIndex].content = fullContent
}
将接收到的内容片段累积后一次性更新 UI,减少 DOM 操作频率。
3. 自动重连机制
EventSource 原生支持自动重连,当连接断开时会自动尝试重新连接:
eventSource.onerror = function () {
// 检查连接状态,决定是否需要手动处理
if (eventSource?.readyState === EventSource.CONNECTING) {
// 浏览器正在自动重连
}
}
实际效果与用户体验提升
通过实施 SSE 技术,我们实现了显著的用户体验提升:
用户感知改善
- 等待焦虑减少:用户能够实时看到生成进度,不再感到系统"卡死"
- 参与感增强:流式显示让用户感觉到与 AI 的实时交互
- 错误反馈及时:异常情况能够立即展示给用户
技术指标提升
- 用户停留时间增加:用户更愿意等待完整的生成结果
- 异常处理准确率提升:各种网络异常和业务异常都能正确处理
- 系统资源利用率优化:相比轮询方案减少了 80% 的无效请求
技术优化
- 连接池管理:对于高并发场景,考虑实现连接池
- 压缩传输:对于大量文本数据,考虑启用 gzip 压缩
- 断点续传:支持从中断点继续传输,提高可靠性
这次 SSE 技术的实践让我深刻体会到,好的技术方案不一定是最复杂的,而是最适合业务场景的。SSE 虽然简单,但在我们的 AI 代码生成场景中发挥了重要作用,大大提升了用户体验。

浙公网安备 33010602011771号