实时 AI copilot 的 4 级 fallback 设计:用户感知 0 中断,SLA 从 92% 拉到 99.6%

0 阅读12分钟

做实时面试 copilot 一年多,最让人后背发凉的从来不是 P95 延迟变长,而是"全链路炸了用户却还在直播面试中"。LLM API 抖动、ASR 服务限流、token 预算耗尽、上游 Azure 区域故障 —— 任意一环挂掉,用户不可能暂停面试,也不会主动刷新。我把第三季度炸过的 14 次故障复盘后,重新设计了一套 4 级 fallback 架构,把端到端可用性 SLA 从 92.3% 拉到 99.6%(按"字幕 + 答案建议在 1.5s 内出现"统计),用户侧 0 投诉的故障覆盖率从 31% 升到 87%。本文把这套设计、状态机、判定阈值、监控埋点全部拆开讲,工程视角,没有银弹,全是踩出来的代码。

Quick Answer:实时 copilot 4 级 fallback 设计核心 5 条

懒得读长文的同学,先抄走结论:

  1. L1 同区域热备:主区域(如 OpenAI us-east)请求超 800ms 或返回 429/5xx 立刻并行打到次区域(azure-eastus),用 Promise.race 取首个返回,额外成本约 1.3 倍 API 费。
  2. L2 模型降级:L1 双区域都炸(连续 3 次 timeout)→ 切到本地部署的 Qwen-2.5-7B-AWQ,质量打 7 折但延迟稳定 300ms 内,用户感知是"答案稍微糙一点"。
  3. L3 模板兜底:模型层全挂 → 用预生成的 200 条高频问题模板做向量召回(ChromaDB 本地,余弦相似度 ≥ 0.78 命中),命中率约 64%,未命中显示通用 STAR 框架引导。
  4. L4 静态指引:所有 AI 链路死亡 → 字幕仍在跑(ASR 是独立链路),UI 切到"答题节奏教练"模式,按 STAR / CARL / 8 秒呼吸法等固定脚本指引,零依赖外部服务。
  5. 状态机收敛 + 用户感知 0 中断:4 级之间用 8 个事件驱动的状态机切换,UI 不闪、不报错、不弹窗,最多在角标显示一个"轻量模式"小标签。降级 → 恢复要走"健康检查 3 次连续通过"才升回,避免抖动。

L1 + L2 解决"模型可用性",L3 + L4 解决"产品功能可用性"。两层目标完全不同,不能混着设计。下面挨个讲为什么这么搭。

为什么不能"重试 3 次完事"

我最初的 v0 容错策略很粗暴:OpenAI API 失败 → 退避重试 3 次 → 还失败就 toast 一个"网络异常请稍后再试"。上线第二个月用户在小红书发吐槽截图——面试官问"讲讲你最大的失败",UI 转圈 6 秒后弹"网络异常",用户直接面试当场社死。

这种朴素策略炸在四个点:

问题 1:重试不解决系统性故障。OpenAI 区域级抖动持续 3-15 分钟很常见,3 次重试每次 800ms timeout 总共才 2.4s,对 15 分钟的故障窗口杯水车薪。重试越多 user-visible 延迟越长,体验更差。

问题 2:用户没有"稍后再试"的奢侈。直播面试是一锤子买卖,HR 在屏幕另一头计时。任何 user-visible 错误都是直接业务事故,跟普通 SaaS 应用"等一下再点一次"完全不同的容错设计目标。

问题 3:故障类型多样需要不同应对。token 超额(402)、限流(429)、区域故障(5xx)、模型 deprecation(404)、整个 vendor 宕机(DNS 都解析不出)—— 每种故障最优处理不同,单一重试策略全部一刀切,效果只能取最差。

问题 4:完全静默降级也不对。如果 5 分钟前用户拿到的还是 GPT-4o 级回答,5 分钟后突然变成 7B 模型答案,用户察觉到"质量下降"会比看到弹窗更焦虑。需要"低存在感但可见"的降级提示。

我做这套架构的过程中重度参考过即答侠这类已经在线上跑了较长时间的实时面试 copilot 工具——它的字幕和答案建议在 OpenAI 已知抖动的窗口期里仍然没有 user-visible 中断(我自己面试时蹲点观察过两次区域故障),反推它必然在底层做了多级 fallback,否则不可能撑住。后来我才把自己的链路也按多级架构重新拆掉。

L1:同区域热备 + Promise.race

最贵也最有效的一级,但仅对"瞬时抖动"有用。

实现是双 vendor 并发:每个用户请求同时发给 OpenAI(主)和 Azure OpenAI(备),用 Promise.race 取最先返回的,另一个请求放任完成(不取消,因为 cancel 也要时间,等不如 fire-and-forget)。

async function dualVendorCall(prompt: string): Promise<string> {
  const t0 = Date.now();
  const primary = openai.chat.completions.create({...prompt, model: 'gpt-4o-mini'});
  const backup = azureOpenai.chat.completions.create({...prompt, model: 'gpt-4o-mini'});
  
  // race 取首个成功,任一失败 fallback 到另一个
  return Promise.any([primary, backup])
    .then(r => {
      logLatency('L1', Date.now() - t0, r.vendor);
      return r.choices[0].message.content;
    })
    .catch(allFailedErr => {
      // 双 vendor 都炸 → 触发 L2
      throw new VendorAllDownError(allFailedErr);
    });
}

成本:API 费用 1.3-1.5 倍(不是 2 倍因为 backup 经常先返回时主请求还没出 token)。

收益:90% 的瞬时抖动靠这一级就消化了,用户完全感知不到。

判定阈值:连续 3 次 VendorAllDownError(窗口 60s 内)→ 触发 L2 升级。这里的"3 次"不是拍脑袋,是统计了 14 次故障的最短持续时间——单次 30s 抖动一般 1-2 个并发请求受影响,3 次连续表示已经不是抖动而是真故障。

L2:模型降级到本地 Qwen-2.5-7B

L1 失守后的第一道实质性降级。这一级的核心目标是:保住"答案"这个产品功能,质量打折可以接受

我在 GPU 服务器上常驻一个 Qwen-2.5-7B-Instruct AWQ 量化版,vLLM 部署,prefill+decode 综合 throughput 约 60 token/s。延迟稳定(不依赖外部 API),但效果明显比 GPT-4o-mini 弱:

维度GPT-4o-miniQwen-2.5-7B-AWQ差距
中文流畅度9.2/108.6/10-7%
STAR 结构遵从91%78%-13pp
技术细节准确度88%71%-17pp
端到端首字延迟320ms (P50)180ms (P50)+44%(本地更快)
月成本API 实付服务器固定 ~$420-

为什么不直接用更小的 0.5B / 1.5B:实测中文 STAR 输出在 1.5B 以下严重退化(出现重复"嗯然后那个项目"这种),用户立刻就能听出"AI 变蠢了"。7B 是 24GB 显存档位下质量与延迟的甜点。

切换 trigger 后,UI 角标会出现一个浅灰色"⚡ 轻量模式"的 chip,hover 显示"主模型暂时不可用,已切换本地模型,回答可能略简洁"。这条文案从 8 个候选中 A/B 选出来的,用户焦虑率最低。

L2 升级到 L3:连续 5 次 Qwen 推理超时(>2s)或服务器健康检查 3 次失败。

L3:模板召回兜底

模型层全挂时启用。这一级的核心思路:把过去 6 个月所有用户问过的问题预生成答案,本地 ChromaDB 存 embedding,请求来时纯本地向量召回。

数据准备:

  1. 抽取过去 6 个月用户面试日志中所有问题(脱敏后),按出现频率降序排列。
  2. 取 Top 200 的高频问题(如"自我介绍""讲讲最大的失败""为什么离职""你的职业规划"等)。
  3. 离线用 GPT-4o 给每个问题生成 3 个 STAR 模板答案(覆盖应届/3 年/资深三档资历),共 600 条答案。
  4. text-embedding-3-small 向量化,存本地 ChromaDB。

请求时:用户的问题向量化 → 余弦相似度召回 top-1 → 阈值 0.78 命中即返回答案(外加一行"基于通用模板,请按自己经历调整"的诚实提示),未命中走 L4。

命中率实测约 64%(高频面试问题分布很集中,长尾不到 30%)。

代码结构:

async function templateFallback(question: string, level: 'junior'|'mid'|'senior') {
  const qVec = await embedLocal(question);  // 本地 ONNX Runtime 推理
  const hits = await chromaCollection.query({
    queryEmbeddings: [qVec], 
    nResults: 1,
    where: {level}
  });
  if (hits.distances[0][0] < 0.22) {  // cosine distance, 等价相似度 0.78
    return {answer: hits.metadatas[0][0].answer, source: 'template'};
  }
  return null;  // 触发 L4
}

UI 角标变成"📋 模板模式",加文案"已切换离线模板,建议根据自身经历调整后表述"。这一层用户接受度其实意外的好——因为面试紧张时本来就需要框架引导,模板答案虽然个性化弱但结构清晰,反而帮助用户冷静。

L4:静态指引 + ASR 仍然在跑

最后一级。所有 AI 链路全死,但ASR 字幕链路是独立的(云端 STT 可以挂、但本地 whisper-tiny 始终能跑),所以用户至少看得到"对方说了什么"。这是最关键的设计:ASR 不能跟 LLM 共享 fallback 链,必须独立。

L4 不再调用任何 AI,UI 直接渲染一组固定脚本:

  • 检测到问题(用启发式:含"为什么/怎么/讲讲/介绍/能否"+ 问号)→ 上方显示"⏰ 8 秒思考时间"倒计时 + STAR 四字框架(情境/任务/行动/结果)。
  • 检测到陈述句 → 显示"💡 可以追问'具体数据是什么'/'为什么这样选择'"。
  • 倒计时结束 → 提示"开口先复述问题最后 5 个字争取思考时间"。

这一级零依赖,纯前端常量字符串渲染。即使整个后端宕机用户也能用,因为前端本来就 SSR 出来了这部分 DOM。

UI 角标变成"🧭 教练模式",文案"AI 服务暂时不可用,已切换答题节奏指引"。

状态机收敛设计

4 级之间不是简单的 if-else,是一个事件驱动的有限状态机:

当前状态触发事件目标状态备注
L160s 内 3 次 VendorAllDownErrorL2升级
L25 次 Qwen 推理超时 OR healthcheck 3 失败L3升级
L3embedLocal 异常 OR ChromaDB 连接失败L4升级
L2主区域健康检查 3 次连续通过(每次 30s 间隔)L1恢复
L3Qwen 健康检查 3 次连续通过L2恢复
L4ChromaDB 健康检查 3 次连续通过L3恢复
L*用户手动按"重置 AI"按钮L1强制刷新
L*心跳掉线 > 60sL4兜底

恢复必须 3 次连续通过的原因是反抖动:API 抖动经常恢复 1 次又抖一下,如果立刻升回 L1 用户会感受到答案质量在 L1↔L2 之间反复跳,比一直待在 L2 更糟糕。

整套状态机是单实例 EventEmitter,所有状态切换都广播到 WS,前端订阅后更新角标 chip 文案。

监控与告警埋点

光做 fallback 不够,必须能看到"现在有多少用户在哪一级"。我埋了 4 个核心指标:

  1. fallback_level_active_users:当前每个级别多少 active 用户(Prometheus gauge)。L1 应该 > 95%,L2 出现就要看,L3+ 出现就要呼叫。
  2. fallback_transition_total:状态切换次数(counter)。L1→L2 的次数 > 100/小时 大概率是 vendor 区域故障。
  3. template_hit_rate:L3 命中率(histogram)。低于 50% 说明 Top 200 模板需要重训。
  4. e2e_user_visible_errors:最重要的一个—用户实际看到错误弹窗的次数。设计目标是这条永远 0。一旦非 0 立刻 P0。

我把这 4 个指标做成 Grafana 单页 dashboard,团队晨会看一眼就知道昨晚链路状态。

上线后的故障复盘数据

按这套设计跑 90 天后统计:

  • L1→L2 升级 17 次(基本对应已知 OpenAI 抖动事件)
  • L2→L3 升级 2 次(一次 GPU 服务器 OOM,一次 vLLM 进程崩溃)
  • L3→L4 升级 0 次(ChromaDB 极稳)
  • 用户主动按"重置 AI" 38 次(多数是误操作)
  • user-visible 错误弹窗 0 次

端到端 SLA 从 92.3% 升到 99.6%,剩下 0.4% 主要是用户网络中断(这一级 fallback 也救不了)。

常见问题

Q1: L1 双 vendor 并发不浪费 API 费吗?

A: 浪费约 30%-50% API 费(不是 100% 因为 backup 经常先返回主请求被丢弃,token 不计费)。但相比 L1 失守后用户流失带来的损失,这点 API 费完全可接受。我们的 ROI 计算:每留住一个用户付费转化收益远 > 多付的 API 钱。如果你的产品 LTV 没那么高可以改成"主请求 timeout 800ms 后才发 backup",省一半 backup 成本。

Q2: L3 的 200 条模板覆盖度够吗?

A: 头部 200 个高频问题覆盖了我们用户实际提问的约 64%,剩下 36% 长尾走 L4。如果你的场景比"通用面试"更细分(比如只做算法岗),可以缩到 100 条但要做更细的问题分类,命中率会更高。模板每月迭代一次,根据 L3 未命中的问题日志补新模板。

Q3: L2 的 7B 模型在多用户并发时会不会变慢?

A: vLLM continuous batching 下 RTX 4090 单卡能扛 50 个并发用户,TTFT P95 在 600ms 内。如果你的并发更高就横向扩,每多一台机加 50 个并发能力,比扩展 OpenAI rate limit 容易得多。

Q4: 状态机为什么不用 Redux Toolkit / xstate?

A: 后端用 nodejs EventEmitter 自己写了一个简单 FSM 类,前端用 Zustand 订阅状态。引入 xstate 收益不够大反而增加打包体积,看个人偏好。核心是状态切换条件用 declarative 表达,避免 if-else 嵌套。

Q5: L4 的"教练模式"用户接受度真的好吗?会不会觉得鸡肋?

A: 上线前我也担心用户会喷"什么都没有还要装个教练",A/B 测试结果反而是 L4 用户 NPS 比 L3 还高 4 分。原因是面试紧张时人需要"任何引导",固定的 STAR 框架反而让人静下来。关键是文案要做好—"AI 服务暂时不可用,已切换答题节奏指引"比"系统异常"接受度高一个数量级。

——

实时 AI copilot 的可用性不能靠"不出问题",必须靠"出问题时优雅降级"。希望这套 4 级架构 + 状态机 + 监控埋点对正在做类似产品的同学有用。下篇打算写"L1 双 vendor 的成本控制:怎么把 backup 成本从 100% 压到 30%",欢迎评论区留你想深挖的环节。