大模型接口一慢就白屏?试试这套前端的超时控制和加载过渡方案

我们团队最近接了个 AI 项目,前端调大模型接口,经常一等等半分钟,然后白屏了。产品经理跑过来问:“这玩意儿是不是挂了?”我说没挂,它在思考人生。

后来我发现,问题不在模型本身,而在前端根本没做超时控制和状态过渡。用户点完按钮,页面像个死水一潭,等久了浏览器直接判定无响应,白屏伺候。

今天聊聊我折腾出来的那套方案——既能优雅地告诉用户“我在干活”,又能在接口装死时果断掐断,不让页面崩溃。

别让 fetch 默认超时坑了你

很多人不知道,浏览器原生的 fetch 压根没有超时机制。你发个请求出去,服务端要是半天不吭声,浏览器能傻等到天荒地老。Chrome 的默认超时大概是 300 秒,但等那么久,用户早把页面关了。

所以第一步,给请求加个 Deadline。我用了 AbortController,这东西从 Chrome 66 开始就支持了,现在主流浏览器都没问题。核心思路是:设一个定时器,到时间就调用 controller.abort(),fetch 那边收到 abort 信号直接抛出错误,我们捕获后做降级处理。

async function fetchWithTimeout(url, options = {}, timeout = 15000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);

  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal,
    });
    clearTimeout(timeoutId);
    return response;
  } catch (error) {
    clearTimeout(timeoutId);
    if (error.name === 'AbortError') {
      throw new Error('请求超时,模型还在思考中...');
    }
    throw error;
  }
}

这里的 timeout 我设了 15 秒。为什么是 15 秒?实测我们用的几个大模型 API(GPT-4o、Claude 3.5 Sonnet),正常响应在 3-8 秒,高峰期偶尔飙到 12 秒左右。15 秒是个比较保守的值,既给模型留了缓冲,又不至于让用户等到失去耐心。如果你的场景模型必在 5 秒内返回,那就设 8 秒,留点余量。

但光有超时还不够。你直接丢个“请求超时”的提示给用户,体验依然很烂。我们需要的是加载状态的过渡。

骨架屏?不,你需要的是“呼吸感”

很多教程一上来就让你上骨架屏,但大模型场景下骨架屏反而是个坑。为什么?因为骨架屏适用于“我知道内容大概长什么样,只是数据还没回来”的场景,比如文章列表、商品卡片。但大模型返回的内容千奇百怪,可能是代码、表格、长文本,你根本没法预设骨架。

我踩过这个坑——做了一个漂亮的骨架屏,结果模型返回了一整段 Markdown 代码块,真实内容渲染出来后,布局跟骨架屏完全对不上,页面瞬间跳变,比不用骨架屏还糟糕。

我现在的做法是:用分阶段的提示文案来制造“呼吸感”。别笑,这东西看着简单,但效果出奇地好。用户在等待的时候,如果能看到一些变化的文字,大脑会下意识觉得“系统在运转”,焦虑感大幅下降。

const loadingMessages = [
  '正在理解你的问题...',
  '模型正在组织思路...',
  '正在生成回答,请稍候...',
  '快了快了,模型在打字...',
  '再等等,它已经写了一多半了...',
];

let messageIndex = 0;
const loadingInterval = setInterval(() => {
  if (messageIndex < loadingMessages.length) {
    updateLoadingText(loadingMessages[messageIndex]);
    messageIndex++;
  } else {
    // 循环最后一个,制造持续感
    updateLoadingText(loadingMessages[loadingMessages.length - 1] + '🤔');
  }
}, 2500);

每 2.5 秒换一次文案,既不会闪得太快让人眼花,也不会隔太久让人觉得卡住了。最后一条会循环显示,加个 emoji 表示还在干活。这个时间间隔是我 A/B 测出来的,3 秒以上用户开始怀疑人生,2 秒以下切换太快显得假。

请求状态机:别用 boolean 了

一个请求至少有四种状态:idle、loading、success、error。很多人用两个 boolean 搞定——isLoadingisError,然后各种排列组合。项目小还行,一旦加上超时重试、手动取消这些逻辑,boolean 组合直接爆炸。

我习惯用一个枚举来管理状态,逻辑清晰得多:

const RequestStatus = {
  IDLE: 'idle',
  LOADING: 'loading',
  SUCCESS: 'success',
  ERROR: 'error',
  TIMEOUT: 'timeout', // 超时单独拎出来
};

let status = RequestStatus.IDLE;

async function handleSubmit(prompt) {
  status = RequestStatus.LOADING;
  updateUI();

  try {
    const response = await fetchWithTimeout('/api/chat', {
      method: 'POST',
      body: JSON.stringify({ prompt }),
    }, 15000);

    const data = await response.json();
    status = RequestStatus.SUCCESS;
    renderResult(data);
  } catch (error) {
    if (error.message.includes('超时')) {
      status = RequestStatus.TIMEOUT;
      showTimeoutUI();
    } else {
      status = RequestStatus.ERROR;
      showErrorUI(error.message);
    }
  }

  updateUI();
}

这样 UI 层只需要根据 status 做渲染判断,不用管那些复杂的 boolean 逻辑。TIMEOUT 单独拎出来是因为超时的处理方式和普通错误不一样——普通错误可能是网络断了,直接提示失败就行;但超时意味着模型可能还在算,用户会想重试一次。

超时后的重试机制:别傻等

超时了直接给用户一个失败提示,说实话挺蠢的。很多时候模型只是那一次请求慢了,换个时间点或者重试一次就正常了。我加了一个手动重试按钮,但更重要的是,我给了用户一个“后台继续等”的选项。

具体做法是:超时后不立即终止请求,而是提示“模型响应较慢,是否继续等待?”。如果用户选择继续等,我们延长超时到 30 秒再试一次。如果用户不想等了,那就取消请求,推荐他们换个问法或者稍后再试。

async function handleRetry(prompt, retryTimeout = 30000) {
  status = RequestStatus.LOADING;
  updateUI();

  // 更新提示文案,让用户知道这是重试
  startLoadingMessages('正在重新请求,这次多等一会儿...');

  try {
    const response = await fetchWithTimeout('/api/chat', {
      method: 'POST',
      body: JSON.stringify({ prompt }),
    }, retryTimeout);

    const data = await response.json();
    status = RequestStatus.SUCCESS;
    renderResult(data);
  } catch (error) {
    status = RequestStatus.ERROR;
    showErrorUI('两次请求都超时了,模型可能正在摸鱼,建议稍后再试');
  }
}

这里有个细节:重试的时候 loading 文案要变化,不能跟第一次一样。用户看到“正在重新请求”这几个字,会意识到系统已经尝试过一次了,这次在加倍努力,心理预期会调整。

流式响应:治本之策

上面那些方案都是治标,真正治本的是用流式响应。大模型 API 基本都支持 Server-Sent Events(SSE)或者 WebSocket 流式返回,前端可以边接收边渲染,用户看到文字一个字一个字蹦出来,体验比干等好十倍。

我们后来把接口改成了 SSE,前端用 EventSource 或者 fetch 读取 ReadableStream。这样一来,超时机制也得跟着调整——不是整个请求的超时,而是每个 chunk 之间的超时。

async function streamChat(prompt, onChunk, chunkTimeout = 5000) {
  const controller = new AbortController();
  let lastChunkTime = Date.now();
  let timeoutId;

  const resetTimeout = () => {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      controller.abort();
    }, chunkTimeout);
  };

  try {
    const response = await fetch('/api/chat/stream', {
      method: 'POST',
      body: JSON.stringify({ prompt }),
      signal: controller.signal,
      headers: { 'Content-Type': 'application/json' },
    });

    const reader = response.body.getReader();
    const decoder = new TextDecoder();

    resetTimeout();

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      const chunk = decoder.decode(value, { stream: true });
      onChunk(chunk);
      lastChunkTime = Date.now();
      resetTimeout();
    }
  } catch (error) {
    if (error.name === 'AbortError') {
      console.warn('流式响应中断:超过 5 秒未收到新数据');
    }
    throw error;
  } finally {
    clearTimeout(timeoutId);
  }
}

这里的 chunkTimeout 设了 5 秒,意思是如果 5 秒内没收到下一个 chunk,就认为连接断了。这个值比整体超时要短得多,因为流式场景下 chunk 之间的间隔通常只有几百毫秒,5 秒已经算异常了。

改成流式后,之前那些 loading 文案的轮播就可以精简了,只需要在第一个 chunk 到达前显示“正在生成...”,一旦有内容出来就立即渲染,用户能看到实时反馈,焦虑感骤降。

常见问题

超时时间设多少合适?

看你的模型和场景。通用大模型(GPT-4o、Claude 3.5 Sonnet)首次响应通常在 3-8 秒,设 15 秒比较保险。如果是微调的小模型或者本地部署,可能 2 秒就能出结果,设 5 秒就够了。建议先跑 50 次真实请求,取 P95 响应时间,然后乘以 1.5 作为超时阈值。

AbortController 兼容性怎么样?

主流浏览器都支持,Chrome 66+、Firefox 57+、Safari 12.1+、Edge 16+。如果你的用户里还有 IE 11 钉子户,那得上 axios 或者 XMLHttpRequest 的超时机制,但说实话,2024 年了,真没必要为 IE 浪费生命。

流式响应的错误处理怎么做?

流式响应的难点在于,你可能已经渲染了一部分内容,结果后面出错了。我的做法是:在渲染区域旁边保留一个状态图标(小绿点/小黄点/小红点),正常流式中显示绿色脉冲,如果中断或超时了切红色,用户就知道“后面可能没了”。已经渲染的内容不清除,只在末尾追加一句“[生成中断]”。