导读:想要真正掌握 AI 对话的流式接口?本文将带你深入理解从 HTTP 请求到 SSE 数据解析的每一个环节!
作为前端开发者,理解流式接口的完整工作流程至关重要。这不仅有助于调试问题,还能让你设计出更优雅的数据处理方案。
一、流式接口的完整生命周期
让我们通过一个详细的流程图来理解整个流程:
┌─────────────────┐ ┌──────────────────┐ ┌────────────────────┐
│ 前端发起请求 │───>│ 服务端流式响应 │───>│ 数据解析与实时展示 │
└─────────────────┘ └──────────────────┘ └────────────────────┘
│ │ │
▼ ▼ ▼
配置请求参数和 Server-Sent Events 逐块更新UI状态
AbortController (SSE) 数据流推送 和最终处理
二、请求配置详解
javascript
const sendMessage = async (message, conversationId, signal) => {
const response = await fetch(`${API_BASE_URL}/chat-messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
// 🔴 关键参数:告诉服务端需要流式响应
response_mode: 'streaming',
// 对话上下文
conversation_id: conversationId,
query: message,
// 其他业务参数
inputs: {},
user: USER_ID,
files: []
}),
// 🔴 关键配置:支持请求中止
signal: signal
})
}
专业解读:
response_mode: 'streaming'
:这是最重要的参数,告诉服务端不要一次性返回完整响应,而是保持连接并持续发送数据块signal
:使用 AbortController 的 signal,允许用户在生成过程中取消请求- 对话上下文 :
conversation_id
用于维持多轮对话的上下文连贯性
三、响应数据处理:深入理解 Server-Sent Events
服务端返回的是 Server-Sent Events (SSE) 格式:
javascript
// 服务端返回的数据格式示例:
// data: {"event": "message", "answer": "Hello", "message_id": "123"}
// data: {"event": "message", "answer": " World", "message_id": "123"}
// data: {"event": "message_end", "message_id": "123"}
const reader = response.body?.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break // 流结束
const chunk = decoder.decode(value, { stream: true })
const lines = chunk.split('\n')
for (const line of lines) {
if (line.startsWith('data:')) {
try {
// 🔴 解析 JSON 数据
const data = JSON.parse(line.slice(5))
processStreamData(data)
} catch (e) {
console.warn('解析失败:', e, '原始数据:', line)
}
}
}
}
数据格式专业解析:
字段 | 类型 | 说明 |
---|---|---|
event |
string | 事件类型:message (消息块)、message_end (消息结束) |
answer |
string | 当前数据块的内容(可能是几个字或一个词) |
message_id |
string | 消息唯一标识,用于关联同一消息的多个数据块 |
task_id |
string | 任务唯一标识,用于查询状态或取消任务 |
四、Vue3 完整实现与状态管理
vue
<template>
<div class="chat-container">
<!-- 消息列表 -->
<div v-for="message in messages" :key="message.id">
<div :class="['message', message.role]">
{{ message.content }}
<span v-if="message.isStreaming" class="streaming-cursor">|</span>
</div>
</div>
<!-- 输入区域 -->
<div class="input-area">
<input
v-model="inputText"
@keyup.enter="handleSend"
:disabled="isLoading"
/>
<button @click="handleSend" :disabled="isLoading">
{{ isLoading ? '生成中...' : '发送' }}
</button>
<button v-if="isLoading" @click="handleCancel" class="cancel-btn">
停止生成
</button>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
// 🔴 状态定义
const messages = ref([])
const inputText = ref('')
const isLoading = ref(false)
const abortController = ref(null)
// 🔴 核心流式处理函数
const processStreamResponse = async (response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
if (!response.body) {
throw new Error('响应体不可读:浏览器可能不支持 ReadableStream')
}
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let accumulatedMessage = {
id: null,
content: '',
role: 'assistant',
isStreaming: true
}
// 🔴 创建初始消息条目
messages.value.push(accumulatedMessage)
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
console.log('🚩 流式传输完成')
break
}
// 🔴 解码并处理数据块
const chunk = decoder.decode(value, { stream: true })
await processDataChunk(chunk, accumulatedMessage)
}
} finally {
// 🔴 流处理结束,更新状态
if (accumulatedMessage.id) {
accumulatedMessage.isStreaming = false
}
reader.releaseLock()
}
}
// 🔴 数据块处理逻辑
const processDataChunk = async (chunk, accumulatedMessage) => {
const lines = chunk.split('\n').filter(line => line.trim())
for (const line of lines) {
if (!line.startsWith('data:')) continue
try {
const rawData = line.slice(5).trim()
if (!rawData) continue
const data = JSON.parse(rawData)
// 🔴 处理不同事件类型
switch (data.event) {
case 'message':
// 累积消息内容
accumulatedMessage.content += data.answer
// 记录消息ID(首次出现时设置)
if (data.message_id && !accumulatedMessage.id) {
accumulatedMessage.id = data.message_id
}
break
case 'message_end':
console.log('✅ 消息生成完成:', accumulatedMessage.content)
break
case 'task_start':
console.log('🎯 任务开始:', data.task_id)
break
case 'error':
console.error('❌ 服务端错误:', data.error)
throw new Error(data.error)
default:
console.log('📨 未知事件类型:', data.event, data)
}
} catch (parseError) {
console.warn('⚠️ 解析数据失败:', parseError, '原始数据:', line)
}
}
}
// 🔴 发送消息主函数
const handleSend = async () => {
if (!inputText.value.trim() || isLoading.value) return
const userMessage = inputText.value.trim()
inputText.value = ''
isLoading.value = true
// 添加用户消息
messages.value.push({
id: Date.now().toString(),
content: userMessage,
role: 'user'
})
// 🔴 创建中止控制器
abortController.value = new AbortController()
try {
const response = await fetch(`${API_BASE_URL}/chat-messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`
},
body: JSON.stringify({
query: userMessage,
response_mode: 'streaming',
conversation_id: getConversationId(),
user: USER_ID
}),
signal: abortController.value.signal
})
await processStreamResponse(response)
} catch (error) {
if (error.name === 'AbortError') {
console.log('⏹️ 用户取消请求')
// 在流式消息中标记为被中断
const lastMessage = messages.value[messages.value.length - 1]
if (lastMessage.role === 'assistant') {
lastMessage.content += '(生成已中断)'
lastMessage.isStreaming = false
}
} else {
console.error('❌ 请求失败:', error)
messages.value.push({
id: Date.now().toString(),
content: `抱歉,请求失败: ${error.message}`,
role: 'assistant',
isStreaming: false
})
}
} finally {
isLoading.value = false
abortController.value = null
}
}
// 🔴 取消请求
const handleCancel = () => {
if (abortController.value) {
abortController.value.abort()
}
}
</script>
五、React 完整实现与状态管理
jsx
import { useState, useRef, useCallback } from 'react'
export function ChatComponent() {
const [messages, setMessages] = useState([])
const [inputText, setInputText] = useState('')
const [isLoading, setIsLoading] = useState(false)
const abortControllerRef = useRef(null)
// 🔴 处理数据块的回调函数
const processDataChunk = useCallback((chunk, updateMessage) => {
const lines = chunk.split('\n').filter(line => line.trim())
lines.forEach(line => {
if (!line.startsWith('data:')) return
try {
const rawData = line.slice(5).trim()
if (!rawData) return
const data = JSON.parse(rawData)
switch (data.event) {
case 'message':
updateMessage(prev => ({
...prev,
content: prev.content + (data.answer || ''),
id: prev.id || data.message_id
}))
break
case 'message_end':
console.log('消息流结束')
break
default:
console.log('其他事件:', data.event)
}
} catch (error) {
console.warn('解析数据行失败:', error)
}
})
}, [])
// 🔴 发送消息
const handleSend = async () => {
if (!inputText.trim() || isLoading) return
const userMessage = inputText.trim()
setInputText('')
setIsLoading(true)
// 添加用户消息
const userMsg = {
id: `user-${Date.now()}`,
content: userMessage,
role: 'user'
}
setMessages(prev => [...prev, userMsg])
// 创建初始的助手消息(用于流式更新)
const assistantMsgId = `assistant-${Date.now()}`
const initialAssistantMsg = {
id: assistantMsgId,
content: '',
role: 'assistant',
isStreaming: true
}
setMessages(prev => [...prev, initialAssistantMsg])
abortControllerRef.current = new AbortController()
try {
const response = await fetch(`${API_BASE_URL}/chat-messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`
},
body: JSON.stringify({
query: userMessage,
response_mode: 'streaming',
conversation_id: getConversationId(),
user: USER_ID
}),
signal: abortControllerRef.current.signal
})
if (!response.ok) {
throw new Error(`请求失败: ${response.status} ${response.statusText}`)
}
if (!response.body) {
throw new Error('响应体不可读')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
// 🔴 更新对应的助手消息
setMessages(prev => {
const newMessages = [...prev]
const assistantMsgIndex = newMessages.findIndex(
msg => msg.id === assistantMsgId
)
if (assistantMsgIndex !== -1) {
const currentMsg = { ...newMessages[assistantMsgIndex] }
processDataChunk(chunk, (updateFn) => {
Object.assign(currentMsg, updateFn(currentMsg))
})
newMessages[assistantMsgIndex] = currentMsg
}
return newMessages
})
}
} finally {
reader.releaseLock()
// 🔴 流结束,更新状态
setMessages(prev => prev.map(msg =>
msg.id === assistantMsgId
? { ...msg, isStreaming: false }
: msg
))
}
} catch (error) {
if (error.name === 'AbortError') {
console.log('请求被用户取消')
setMessages(prev => prev.map(msg =>
msg.id === assistantMsgId
? { ...msg, content: msg.content + '(已中断)', isStreaming: false }
: msg
))
} else {
console.error('请求错误:', error)
setMessages(prev => [...prev, {
id: `error-${Date.now()}`,
content: `请求失败: ${error.message}`,
role: 'assistant',
isStreaming: false
}])
}
} finally {
setIsLoading(false)
abortControllerRef.current = null
}
}
const handleCancel = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
}
return (
<div className="chat-container">
{messages.map(message => (
<div key={message.id} className={`message ${message.role}`}>
<div className="message-content">
{message.content}
{message.isStreaming && <span className="cursor">|</span>}
</div>
</div>
))}
<div className="input-area">
<input
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSend()}
disabled={isLoading}
placeholder="输入消息..."
/>
<button onClick={handleSend} disabled={isLoading || !inputText.trim()}>
发送
</button>
{isLoading && (
<button onClick={handleCancel} className="cancel-btn">
停止生成
</button>
)}
</div>
</div>
)
}
六、核心要点总结
🔴 请求阶段关键点:
- 必须设置
response_mode: 'streaming'
- 使用
AbortController
支持用户取消 - 传递
conversation_id
维持对话上下文
🔴 响应处理关键点:
- 使用
ReadableStream
逐块读取数据 - 正确解码 UTF-8 数据(特别是中文)
- 按行分割并过滤
data:
前缀 - 安全地 JSON 解析每一行数据
🔴 状态管理关键点:
- 实时更新 UI 显示流式内容
- 正确处理消息 ID 关联
- 区分不同的事件类型
- 完善的错误处理和中断机制
🔴 性能优化建议:
- 使用防抖减少过于频繁的 UI 更新
- 及时释放 Reader 锁
- 合理处理内存,避免长时间对话的内存泄漏
通过深入理解这些细节,你就能真正掌握流式接口的精髓,打造出体验优秀的 AI 对话应用!
希望这份详细的解析对你有帮助!如果觉得有用,欢迎点赞收藏~ 🚀