场景一:用户连续发消息,旧请求没回来就发新的
用户发了一条消息,AI 还在生成,用户等不及又发了一条,或者修改了问题重新发。这时候就有两个请求同时在跑,两个回答会交叉写入同一个消息气泡,内容乱成一团。
用户发:消息A → 请求A 开始
chunk1 chunk2 chunk3...
用户发:消息B → 请求B 开始
chunk1 chunk2...
结果:消息A的chunk 和 消息B的chunk 混在一起写进编辑器
场景二:网络抖动,SSE 连接中断
用户在地铁里网络不稳定,AI 生成到一半 SSE 断开了,页面直接卡在"生成中"状态,转圈转个没完,用户不知道是出错了还是还在转,只能刷新页面。
场景三:请求成功但内容是空的,或者服务端返回 500
模型偶尔会返回空内容,或者服务端压力大直接 500,前端如果没有兜底处理,消息气泡就是一片空白,用户完全不知道发生了什么,也不知道能不能重试。
代码演示
// composables/useAIRequest.js
// 统一管理 AI 请求的生命周期
import { ref, readonly } from 'vue'
// 请求状态枚举,分级展示不同 UI
const RequestStatus = {
IDLE: 'idle', // 空闲
CONNECTING: 'connecting', // 连接中(发出请求还没收到响应)
STREAMING: 'streaming', // 流式输出中
DONE: 'done', // 完成
ERROR: 'error', // 出错
CANCELLED: 'cancelled', // 用户主动取消
}
const MAX_RETRY = 3 // 最大重试次数
const RETRY_DELAY = 1000 // 基础重试间隔(指数退避)
const EMPTY_TIMEOUT = 5000 // 超过 5 秒没有任何 chunk,判定为超时
export function useAIRequest() {
const status = ref(RequestStatus.IDLE)
const statusText = ref('') // 展示给用户的文字提示
const error = ref(null)
// 当前活跃请求的 controller
// 用 ref 存,外部可以监听它的变化
let activeController = null
let retryCount = 0
let emptyTimer = null // 空内容超时定时器
// ==================== 核心:发起请求 ====================
async function send(messages, callbacks = {}) {
const { onChunk, onDone, onError } = callbacks
// 【并发控制】:新请求来了,先把上一个请求干掉
cancel()
activeController = new AbortController()
retryCount = 0
await _doRequest(messages, { onChunk, onDone, onError })
}
async function _doRequest(messages, { onChunk, onDone, onError }) {
_setStatus(RequestStatus.CONNECTING, '正在连接...')
error.value = null
try {
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
signal: activeController.signal,
})
// HTTP 层面的错误(500、429 等)
if (!response.ok) {
throw new RequestError(`服务异常 (${response.status})`, response.status)
}
_setStatus(RequestStatus.STREAMING, 'AI 正在回答')
// 【空内容超时】:开始计时,超过 5 秒没有 chunk 就认为超时
_startEmptyTimer(() => {
throw new RequestError('响应超时,请重试', 'TIMEOUT')
})
const reader = response.body.getReader()
const decoder = new TextDecoder()
let hasContent = false // 标记是否收到过有效内容
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value, { stream: true })
const lines = text.split('\n').filter(l => l.startsWith('data: '))
for (const line of lines) {
const raw = line.slice(6)
if (raw === '[DONE]') break
try {
const data = JSON.parse(raw)
const chunk = data.choices?.[0]?.delta?.content || ''
if (chunk) {
hasContent = true
_clearEmptyTimer() // 收到内容,清除超时计时
onChunk?.(chunk)
}
} catch {
// 忽略单条解析异常,不中断整个流
}
}
}
// 【兜底】:整个流结束但没有任何内容
if (!hasContent) {
throw new RequestError('未收到有效回答,请重试', 'EMPTY')
}
_setStatus(RequestStatus.DONE, '')
onDone?.()
} catch (err) {
_clearEmptyTimer()
// 【主动取消】:不算错误,不重试
if (err.name === 'AbortError' || status.value === RequestStatus.CANCELLED) {
return
}
// 【可重试的错误】:网络抖动、服务端 5xx、超时
if (_shouldRetry(err) && retryCount < MAX_RETRY) {
retryCount++
const delay = RETRY_DELAY * 2 ** (retryCount - 1) // 指数退避:1s 2s 4s
_setStatus(RequestStatus.CONNECTING, `连接中断,${retryCount} 秒后重试...`)
await sleep(delay)
// 检查在等待期间用户有没有主动取消
if (status.value === RequestStatus.CANCELLED) return
return _doRequest(messages, { onChunk, onDone, onError })
}
// 【最终失败】:超出重试次数或不可重试的错误
const errMsg = _getErrorMessage(err)
_setStatus(RequestStatus.ERROR, errMsg)
error.value = errMsg
onError?.(errMsg)
}
}
// ==================== 取消请求 ====================
function cancel() {
if (activeController) {
_setStatus(RequestStatus.CANCELLED, '')
activeController.abort()
activeController = null
}
_clearEmptyTimer()
retryCount = 0
}
// ==================== 工具函数 ====================
function _setStatus(s, text) {
status.value = s
statusText.value = text
}
function _startEmptyTimer(onTimeout) {
_clearEmptyTimer()
emptyTimer = setTimeout(onTimeout, EMPTY_TIMEOUT)
}
function _clearEmptyTimer() {
if (emptyTimer) {
clearTimeout(emptyTimer)
emptyTimer = null
}
}
// 判断是否值得重试
function _shouldRetry(err) {
if (err.code === 'TIMEOUT') return true // 超时,重试
if (err.code === 'EMPTY') return true // 空内容,重试
if (err.status >= 500) return true // 服务端错误,重试
if (err.status === 429) return true // 限流,重试
if (err.status === 401) return false // 鉴权失败,不重试
if (err.status === 400) return false // 请求参数错误,不重试
return true // 其他默认重试
}
// 把技术性错误转成用户能看懂的文字
function _getErrorMessage(err) {
const map = {
'TIMEOUT': '响应超时,请检查网络后重试',
'EMPTY': '未收到有效回答,请重新提问',
429: '请求太频繁,请稍后再试',
500: '服务暂时不可用,请稍后重试',
503: '服务维护中,请稍后重试',
}
return map[err.code] || map[err.status] || '出了点问题,请重试'
}
return {
status: readonly(status),
statusText: readonly(statusText),
error: readonly(error),
send,
cancel,
RequestStatus,
}
}
// 自定义错误类,带 status 和 code 字段方便判断
class RequestError extends Error {
constructor(message, statusOrCode) {
super(message)
this.name = 'RequestError'
typeof statusOrCode === 'number'
? this.status = statusOrCode
: this.code = statusOrCode
}
}
const sleep = ms => new Promise(r => setTimeout(r, ms))
<!-- 在 ChatWindow 里使用 -->
<template>
<div class="chat-window">
<div class="message-list">
<!-- 消息列表... -->
</div>
<!-- 【状态分级 UI】:根据不同状态展示不同提示 -->
<div class="status-area">
<!-- 连接中:转圈 + 文字 -->
<div v-if="status === RequestStatus.CONNECTING" class="status-connecting">
<span class="spinner" />
{{ statusText }}
</div>
<!-- 流式输出中:光标闪烁 + 停止按钮 -->
<div v-else-if="status === RequestStatus.STREAMING" class="status-streaming">
<span class="cursor-blink" />
<button class="btn-stop" @click="handleStop">停止生成</button>
</div>
<!-- 出错:错误提示 + 重试按钮 -->
<div v-else-if="status === RequestStatus.ERROR" class="status-error">
<span class="icon-error">⚠️</span>
{{ error }}
<button class="btn-retry" @click="handleRetry">重试</button>
</div>
</div>
<!-- 输入区:生成中禁用发送,但可以输入 -->
<div class="input-area">
<textarea
v-model="inputText"
placeholder="输入问题..."
@keydown.enter.prevent="handleSend"
/>
<button
@click="handleSend"
:disabled="!inputText.trim() || status === RequestStatus.CONNECTING"
>
发送
</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useAIRequest } from '@/composables/useAIRequest'
import { useConversation } from '@/composables/useConversation'
const { status, statusText, error, send, cancel, RequestStatus } = useAIRequest()
const { messages, addMessage, buildContext } = useConversation()
const inputText = ref('')
let lastMessages = [] // 记录最后一次发送的 messages,用于重试
async function handleSend() {
const text = inputText.value.trim()
if (!text) return
inputText.value = ''
addMessage('user', text)
const streamingMsg = addMessage('assistant', '')
const contextMessages = await buildContext(text)
lastMessages = contextMessages // 保存,重试用
await send(contextMessages, {
onChunk(chunk) {
streamingMsg.content += chunk
},
onDone() {
// 正常结束,不需要额外处理
},
onError(errMsg) {
// 把空的 assistant 消息替换成错误提示
streamingMsg.content = ''
streamingMsg.isError = true
}
})
}
// 用户点停止
function handleStop() {
cancel()
}
// 用户点重试:用上次的 messages 重新发
async function handleRetry() {
if (!lastMessages.length) return
// 找到最后那条空的 assistant 消息,清空重用
const lastAssistant = [...messages.value].reverse().find(m => m.role === 'assistant')
if (lastAssistant) {
lastAssistant.content = ''
lastAssistant.isError = false
}
await send(lastMessages, {
onChunk(chunk) {
if (lastAssistant) lastAssistant.content += chunk
},
})
}
</script>
三个场景对应的解法总结:
场景一:并发请求内容混乱
→ send() 调用时先执行 cancel() 把旧请求 abort 掉
→ 保证同一时刻只有一个活跃请求
场景二:网络中断卡在"生成中"
→ 5 秒空内容超时检测,抛出 TIMEOUT 错误
→ 指数退避自动重试(1s → 2s → 4s),最多 3 次
→ 重试期间 UI 显示"连接中断,x 秒后重试",用户感知到在处理
场景三:返回空内容 / 500 报错
→ hasContent 标记,流结束但没收到内容,抛 EMPTY 错误
→ 把技术性错误码翻译成用户能看懂的文字
→ 展示重试按钮,用 lastMessages 直接重发,不需要用户重新输入