🤖 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 | 简单、单向流式 | 仅支持 GET | AI 对话、日志推送 |
我的选择:SSE(Server-Sent Events)
理由:
- AI 对话是单向的(服务器 → 客户端),不需要双向通信
- 实现简单,浏览器原生支持
- 自动重连机制
- 基于标准 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)
})
设计思路:
- 使用 axios 的
responseType: 'stream'获取流式数据 - 监听
data事件,每次收到数据就推送给客户端 - 将 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()
})
设计思路:
- 监听
close事件,客户端断开时停止处理 - 监听
error事件,发生错误时通知客户端 - 使用
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()
}
}
设计思路:
- 使用
AbortController支持取消请求 - 使用
fetch+ReadableStream读取流式数据 - 封装
connect和disconnect方法,简化使用
第二步:在组件中使用
在 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()
}
设计思路:
- 创建 SSE 实例,管理连接生命周期
- 根据
type字段处理不同类型的消息 - 提供停止功能,用户可以随时中断
🚨 遇到的问题与解决
在开发过程中,我遇到了一些问题,这里记录下来供参考:
问题 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)
}
}
}
设计思路:
- 使用缓冲区存储未完成的数据
- 按行分割消息
- 只处理完整的消息,未完成的保留到下次
问题 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>
优化点:
- 使用
v-memo指令,只在依赖项变化时重新渲染 - 使用节流限制 DOM 更新频率(100ms)
- 在
done事件中立即清理定时器,避免延迟 - 使用
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
}
实现思路:
- 在
catch块中捕获连接错误 - 排除
AbortError(用户主动取消的情况) - 设置最大重试次数(3 次)
- 每次重试延迟时间加倍(2 秒、4 秒、6 秒),使用指数退避策略
- 保存当前连接参数,重连时使用相同参数
- 在
disconnect()方法中清理重连定时器
为什么这样设计?
- 不依赖心跳机制,直接在连接失败时重连,更简单直接
- 指数退避策略避免频繁重连导致服务器压力过大
- 排除主动取消,用户点击停止按钮时不应该触发重连
- 有限重试,最多重试 3 次,避免无限重连浪费资源
📊 学习总结
通过这次实现,我学到了很多:
核心要点
-
流式响应是 AI 对话的必备功能
- 显著提升用户体验
- 降低用户等待焦虑
-
SSE 是最适合 AI 对话的方案
- 实现简单,浏览器原生支持
- 单向流式,符合 AI 对话场景
-
部署时需要特别注意
- Nginx 缓冲配置(如果使用 Nginx)
- 超时时间设置
- 错误处理和重连机制
-
持续优化性能
- 心跳机制保持连接
- 自动重连提升稳定性
- 节流渲染降低 CPU 占用
未来可以尝试的方向
-
支持多模态
- 图片、语音的流式传输
- 丰富 AI 对话场景
-
智能缓存
- 缓存常见问题的回答
- 减少重复调用
-
个性化优化
- 根据用户习惯调整生成速度
- 提供个性化体验
📚 参考资源
希望这篇学习笔记能帮助其他新手快速上手 SSE 流式响应!如果有任何问题,欢迎交流讨论。 🚀