实时流式响应的 SSE 技术实现

实时流式响应的 SSE 技术实现:AI 代码生成器的核心通信机制

业务场景:为什么需要实时反馈

在我们的 AI 代码生成器中,用户通过对话的方式描述需求,AI 会根据描述生成完整的网站代码。但这个过程有几个痛点:

  1. 生成时间长:AI 生成一个完整网站可能需要 20-60 秒
  2. 用户体验差:长时间的等待让用户不知道系统是否还在工作
  3. 无法中断:用户无法知道生成进度,也无法及时发现问题

为了解决这些问题,我们需要实现:

  • ✅ 实时显示 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()
            ));
}

关键技术点解析:

  1. Media Type 设置:produces = MediaType.TEXT_EVENT_STREAM_VALUE 告诉浏览器这是一个 SSE 响应
  2. 限流保护:使用 @RateLimit 注解防止恶意请求,每用户每分钟最多 5 次请求
  3. 响应式编程:返回 Flux<ServerSentEvent<String>> 实现流式响应
  4. 数据包装:将每个数据块包装成 JSON 格式,便于前端解析
  5. 结束事件:使用 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;

异常处理的关键设计:

  1. 自定义事件类型:使用 business-error 事件类型,避免与浏览器标准的 error 事件冲突
  2. 结构化错误信息:错误信息包含错误码、错误消息等结构化数据
  3. 优雅关闭:发送错误后立即发送 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)
  }
}

前端处理的核心要点:

  1. 状态管理:使用 streamCompleted 标志避免重复处理事件
  2. 凭证传递:withCredentials: true 确保认证信息正确传递
  3. 多事件监听:分别处理 message、done、business-error 事件
  4. 错误处理:针对不同类型的错误提供不同的处理策略
  5. 连接状态管理:正确处理连接失败和网络异常情况

性能优化策略

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 技术,我们实现了显著的用户体验提升:

用户感知改善

  1. 等待焦虑减少:用户能够实时看到生成进度,不再感到系统"卡死"
  2. 参与感增强:流式显示让用户感觉到与 AI 的实时交互
  3. 错误反馈及时:异常情况能够立即展示给用户

技术指标提升

  • 用户停留时间增加:用户更愿意等待完整的生成结果
  • 异常处理准确率提升:各种网络异常和业务异常都能正确处理
  • 系统资源利用率优化:相比轮询方案减少了 80% 的无效请求

技术优化

  1. 连接池管理:对于高并发场景,考虑实现连接池
  2. 压缩传输:对于大量文本数据,考虑启用 gzip 压缩
  3. 断点续传:支持从中断点继续传输,提高可靠性

这次 SSE 技术的实践让我深刻体会到,好的技术方案不一定是最复杂的,而是最适合业务场景的。SSE 虽然简单,但在我们的 AI 代码生成场景中发挥了重要作用,大大提升了用户体验。

posted @ 2025-09-09 17:21  你小志蒸不戳  阅读(228)  评论(0)    收藏  举报