返回笔记首页

AI 对话交互体验优化与稳定性控制

主题配置

场景一:用户连续发消息,旧请求没回来就发新的

用户发了一条消息,AI 还在生成,用户等不及又发了一条,或者修改了问题重新发。这时候就有两个请求同时在跑,两个回答会交叉写入同一个消息气泡,内容乱成一团。

markdown
用户发:消息A  →  请求A 开始
                  chunk1 chunk2 chunk3...
用户发:消息B  →  请求B 开始
                          chunk1 chunk2...

结果:消息A的chunk 和 消息B的chunk 混在一起写进编辑器

场景二:网络抖动,SSE 连接中断

用户在地铁里网络不稳定,AI 生成到一半 SSE 断开了,页面直接卡在"生成中"状态,转圈转个没完,用户不知道是出错了还是还在转,只能刷新页面。


场景三:请求成功但内容是空的,或者服务端返回 500

模型偶尔会返回空内容,或者服务端压力大直接 500,前端如果没有兜底处理,消息气泡就是一片空白,用户完全不知道发生了什么,也不知道能不能重试。

代码演示
javascript
// 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))
vue
<!-- 在 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 直接重发,不需要用户重新输入