SSE 流式响应方案

53 阅读9分钟

🤖 AI 对话必备:从零打造流畅的 SSE 流式响应方案

作为一个新手,我在实现 AI 对话应用的流式响应功能时,记录了从零开始的学习过程和遇到的问题。希望这篇学习笔记能帮助其他新手快速上手。


📌 为什么 AI 对话需要流式响应?

传统方式的问题

最开始,我使用最简单的 HTTP 请求方式:

// 前端发送请求
const response = await fetch('/api/chat', {
  method: 'POST',
  body: JSON.stringify({ message: '你好' })
})
const data = await response.json()
console.log(data.answer) // 等待完整返回后才显示

问题很明显

  • 用户发送消息后,要等待 3-10 秒才能看到完整回答
  • 体验很差,用户以为卡住了
  • 长文本生成时,等待时间更长

流式响应的优势

后来我改用流式响应,体验明显改善:

用户输入:"介绍一下 Vue 3"

传统方式:等待完整返回 → 一次性显示完整回答
流式方式:开始生成 → 逐字显示 → 用户感觉在实时交流

结论:AI 对话场景下,流式响应能改善用户体验。


🎯 技术选型:为什么选择 SSE?

在开始实现前,我对比了几种方案:

方案对比

方案优点缺点适用场景
WebSocket双向通信复杂度高、需要额外协议实时协作、游戏
轮询简单浪费资源、延迟高低频更新
SSE简单、单向流式仅支持 GETAI 对话、日志推送

我的选择:SSE(Server-Sent Events)

理由:

  1. AI 对话是单向的(服务器 → 客户端),不需要双向通信
  2. 实现简单,浏览器原生支持
  3. 自动重连机制
  4. 基于标准 HTTP,无需额外协议

SSE 的核心思路

SSE 的本质是:服务器保持 HTTP 连接,持续推送数据片段

客户端请求 → 服务器保持连接 → 持续推送数据片段 → 客户端实时接收

关键点:

  • 服务器设置 Content-Type: text/event-stream
  • 数据格式:data: {内容}\n\n
  • 每次推送都是一个独立的消息

🏗️ 项目架构设计

整体思路

在设计架构时,我遵循了"分层解耦"的原则:

┌─────────────┐
│   Vue 3     │  ← 前端:负责 UI 渲染和用户交互
└──────┬──────┘
       │ HTTP POST
       ↓
┌─────────────┐
│  Express    │  ← 后端:负责转发请求和流式处理
└──────┬──────┘
       │ HTTP POST
       ↓
┌─────────────┐
│  AI API     │  ← AI 服务:负责生成内容
└─────────────┘

数据流向

1. 用户输入消息
   ↓
2. 前端发送 POST 请求到 /api/chat/stream
   ↓
3. 后端接收请求,转发到 AI API
   ↓
4. AI API 返回流式数据(逐个 token)
   ↓
5. 后端解析 AI 返回的数据,封装为 SSE 格式
   ↓
6. 前端实时接收 SSE 消息,更新 UI

消息格式设计

为了让前端能正确处理不同类型的数据,我设计了统一的消息格式:

// SSE 消息格式
{
  type: 'chunk' | 'done' | 'error',
  content: '文本内容',
  timestamp: 1234567890
}

// 示例
data: {"type":"chunk","content":"你好","timestamp":1234567890}

data: {"type":"done","content":"","timestamp":1234567890}

data: {"type":"error","content":"生成失败","timestamp":1234567890}

设计思路

  • type 字段区分消息类型,前端根据类型做不同处理
  • content 字段存储实际内容
  • timestamp 用于调试和日志

💻 后端实现思路

第一步:创建流式路由

首先,我需要创建一个支持流式响应的路由:

// routes/ai.ts
router.post('/chat/stream', async (req, res) => {
  // 设置 SSE 响应头
  res.setHeader('Content-Type', 'text/event-stream')
  res.setHeader('Cache-Control', 'no-cache')
  res.setHeader('Connection', 'keep-alive')

  // 后续处理...
})

关键点

  • Content-Type: text/event-stream:告诉浏览器这是 SSE 流
  • Cache-Control: no-cache:禁用缓存
  • Connection: keep-alive:保持连接

第二步:转发到 AI API

接下来,我需要将用户的请求转发到 AI API:

// 核心思路:使用 axios 的流式响应
const response = await axios.post(
  AI_API_URL,
  { message: req.body.message },
  { responseType: 'stream' } // 关键:启用流式响应
)

// 处理流式数据
response.data.on('data', (chunk) => {
  // 解析 AI 返回的数据
  const content = parseAIResponse(chunk)

  // 封装为 SSE 格式
  const sseMessage = `data: ${JSON.stringify({
    type: 'chunk',
    content: content
  })}\n\n`

  // 推送给客户端
  res.write(sseMessage)
})

设计思路

  1. 使用 axios 的 responseType: 'stream' 获取流式数据
  2. 监听 data 事件,每次收到数据就推送给客户端
  3. 将 AI 返回的数据封装为 SSE 格式

第三步:错误处理

流式响应的错误处理比较特殊,因为连接可能随时中断:

// 监听连接关闭
req.on('close', () => {
  // 客户端断开连接,停止处理
  console.log('Client disconnected')
})

// 监听错误
response.data.on('error', (error) => {
  // 发送错误消息
  res.write(`data: ${JSON.stringify({
    type: 'error',
    content: error.message
  })}\n\n`)

  // 结束响应
  res.end()
})

设计思路

  1. 监听 close 事件,客户端断开时停止处理
  2. 监听 error 事件,发生错误时通知客户端
  3. 使用 res.end() 正确结束响应

🎨 前端实现思路

第一步:封装 SSE 客户端

为了方便复用,我封装了一个 SSE 客户端类:

// utils/sse.ts
class SSEClient {
  private controller: AbortController

  async connect(url: string, options: SSEOptions) {
    // 创建 AbortController 用于取消请求
    this.controller = new AbortController()

    // 发送 POST 请求
    const response = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(options.data),
      signal: this.controller.signal
    })

    // 获取响应流
    const reader = response.body?.getReader()

    // 读取数据
    while (true) {
      const { done, value } = await reader!.read()
      if (done) break

      // 解析 SSE 消息
      const message = this.parseSSEMessage(value)
      options.onMessage?.(message)
    }
  }

  disconnect() {
    // 取消请求
    this.controller.abort()
  }
}

设计思路

  1. 使用 AbortController 支持取消请求
  2. 使用 fetch + ReadableStream 读取流式数据
  3. 封装 connectdisconnect 方法,简化使用

第二步:在组件中使用

在 AI 对话组件中,使用封装好的 SSE 客户端:

// views/ai-chat/index.vue
const sseClient = new SSEClient()

const startChat = async (message: string) => {
  await sseClient.connect('/api/chat/stream', {
    data: { message },
    onMessage: (msg) => {
      if (msg.type === 'chunk') {
        // 追加内容到聊天界面
        chatContent.value += msg.content
      } else if (msg.type === 'done') {
        // 保存到历史记录
        saveMessage()
      }
    }
  })
}

const stopChat = () => {
  sseClient.disconnect()
}

设计思路

  1. 创建 SSE 实例,管理连接生命周期
  2. 根据 type 字段处理不同类型的消息
  3. 提供停止功能,用户可以随时中断

🚨 遇到的问题与解决

在开发过程中,我遇到了一些问题,这里记录下来供参考:

问题 1:前端解析数据失败

问题:偶尔出现 JSON 解析错误。

原因:SSE 消息可能被拆分成多个数据包。

解决方案:增加缓冲机制

let buffer = ''

const parseSSEMessage = (chunk: Uint8Array) => {
  const text = decoder.decode(chunk)
  buffer += text

  // 按行分割
  const lines = buffer.split('\n')
  buffer = lines.pop() || '' // 保留未完成的行

  // 处理完整的消息
  for (const line of lines) {
    if (line.startsWith('data: ')) {
      const json = line.slice(6)
      return JSON.parse(json)
    }
  }
}

设计思路

  1. 使用缓冲区存储未完成的数据
  2. 按行分割消息
  3. 只处理完整的消息,未完成的保留到下次

问题 2:取消请求后服务器仍在处理

问题:用户点击停止后,服务器仍在处理 AI 请求。

原因:客户端断开连接,但服务器没有及时停止。

解决方案:监听连接关闭事件

req.on('close', () => {
  // 停止 AI API 请求
  aiRequest?.abort()
  console.log('Client disconnected, stopping AI generation')
})

优化点

  • 及时释放资源
  • 避免浪费 AI API 调用次数
  • 提升服务器性能

🌐 网上搜集到的常见问题

以下是一些网上常见的 SSE 部署问题,虽然我在项目中没有遇到,但值得了解:

问题 1:Nginx 缓冲导致流式失效

问题:部署到生产环境后,流式响应变成了批量推送。

原因:Nginx 默认会缓冲响应数据,直到达到一定大小才发送。

解决方案:禁用 Nginx 缓冲

location /api/chat/stream {
    proxy_pass http://backend;
    proxy_buffering off;  # 禁用缓冲
    proxy_cache off;     # 禁用缓存
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    chunked_transfer_encoding on;
}

⚡ 尝试的优化

在实现基本功能后,我尝试了一些优化方案:

优化 1:DOM 渲染优化

问题:在 AI 流式输出时,频繁更新 DOM 导致页面卡顿,特别是在长文本生成时。

解决方案:结合 v-memo 和节流优化

<template>
  <!-- 使用 v-memo 优化消息列表渲染 -->
  <div 
    v-for="msg in messages" 
    v-memo="[msg.content, isLoading]" 
    :key="msg.id"
    :class="['message', { 'is-typing': isLoading && msg.isLast }]"
  >
    {{ msg.content }}
  </div>
</template>

<script setup>
let updateTimer: number | null = null
const UPDATE_INTERVAL = 100 // 每 100ms 更新一次 DOM

const onMessage = (msg) => {
  if (msg.type === 'chunk') {
    // 更新数据
    currentMessage.value += msg.content
    
    // 节流更新 DOM
    if (!updateTimer) {
      updateTimer = window.setTimeout(() => {
        // DOM 更新逻辑
        updateTimer = null
      }, UPDATE_INTERVAL)
    }
  } else if (msg.type === 'done') {
    // 立即清理定时器
    if (updateTimer) {
      clearTimeout(updateTimer)
      updateTimer = null
    }
    
    // 使用 nextTick 确保 DOM 更新后再滚动
    nextTick(() => {
      scrollToBottom()
    })
  }
}

// 在组件卸载时清理定时器
onUnmounted(() => {
  if (updateTimer) {
    clearTimeout(updateTimer)
    updateTimer = null
  }
})
</script>

优化点

  1. 使用 v-memo 指令,只在依赖项变化时重新渲染
  2. 使用节流限制 DOM 更新频率(100ms)
  3. done 事件中立即清理定时器,避免延迟
  4. 使用 nextTick 确保 DOM 更新后再执行滚动操作

思路:结合 Vue 的 v-memo 指令和节流机制,减少不必要的 DOM 更新,提升渲染性能。

优化 2:自动重连

问题:网络波动导致连接中断,用户体验差。

解决方案:连接失败时自动重连

// 在 SSEClient 类中
private retryCount = 0
private maxRetries = 3
private reconnectTimer: number | null = null
private currentUrl: string = ''
private currentData: any = null
private currentOptions: SSEOptions | null = null

async connect(url: string, data: any, options: SSEOptions) {
  // 保存当前连接参数,用于重连
  this.currentUrl = url
  this.currentData = data
  this.currentOptions = options

  try {
    // 连接逻辑...
    const response = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    })
    
    // 连接成功,重置重试次数
    this.retryCount = 0
    options.onOpen?.()
    
    // 处理流式数据...
  } catch (error: any) {
    // 忽略 AbortError(用户主动取消)
    if (error.name !== 'AbortError') {
      options.onError?.(error)
      
      // 自动重连逻辑
      if (this.retryCount < this.maxRetries) {
        this.retryCount++
        console.log(`🔄 连接失败,${2 * this.retryCount}秒后重试 (${this.retryCount}/${this.maxRetries})`)
        
        // 延迟重试(每次重试延迟时间加倍,避免雪崩)
        this.reconnectTimer = window.setTimeout(() => {
          this.connect(this.currentUrl, this.currentData, this.currentOptions!)
        }, 2000 * this.retryCount)
      } else {
        console.error('❌ 已达到最大重试次数,停止重连')
      }
    }
  }
}

disconnect() {
  // 清除重连定时器
  if (this.reconnectTimer) {
    clearTimeout(this.reconnectTimer)
    this.reconnectTimer = null
  }
  // 取消请求
  this.controller?.abort()
  // 重置重试次数
  this.retryCount = 0
}

实现思路

  1. catch 块中捕获连接错误
  2. 排除 AbortError(用户主动取消的情况)
  3. 设置最大重试次数(3 次)
  4. 每次重试延迟时间加倍(2 秒、4 秒、6 秒),使用指数退避策略
  5. 保存当前连接参数,重连时使用相同参数
  6. disconnect() 方法中清理重连定时器

为什么这样设计?

  • 不依赖心跳机制,直接在连接失败时重连,更简单直接
  • 指数退避策略避免频繁重连导致服务器压力过大
  • 排除主动取消,用户点击停止按钮时不应该触发重连
  • 有限重试,最多重试 3 次,避免无限重连浪费资源

📊 学习总结

通过这次实现,我学到了很多:

核心要点

  1. 流式响应是 AI 对话的必备功能

    • 显著提升用户体验
    • 降低用户等待焦虑
  2. SSE 是最适合 AI 对话的方案

    • 实现简单,浏览器原生支持
    • 单向流式,符合 AI 对话场景
  3. 部署时需要特别注意

    • Nginx 缓冲配置(如果使用 Nginx)
    • 超时时间设置
    • 错误处理和重连机制
  4. 持续优化性能

    • 心跳机制保持连接
    • 自动重连提升稳定性
    • 节流渲染降低 CPU 占用

未来可以尝试的方向

  1. 支持多模态

    • 图片、语音的流式传输
    • 丰富 AI 对话场景
  2. 智能缓存

    • 缓存常见问题的回答
    • 减少重复调用
  3. 个性化优化

    • 根据用户习惯调整生成速度
    • 提供个性化体验

📚 参考资源


希望这篇学习笔记能帮助其他新手快速上手 SSE 流式响应!如果有任何问题,欢迎交流讨论。 🚀