让 AI 助手失败得体面:降级设计的四层防线
做这个博客助手时我有一条基本假设:它一定会失败。DeepSeek 的 Key 会欠费,模型会超时,小服务器会 OOM,网络会断流。这些不是小概率事件,是运营期内必然全都要经历一遍的事。
所以问题从来不是「怎么不失败」,而是「失败的那一刻,用户屏幕上出现什么」。崩溃有很多种,让用户对着转圈等 30 秒然后白屏是一种,500 毫秒内给出一句体面的解释加一条出路是另一种。这中间隔着四层防线,全部在前端 useChatStream.ts 和 ChatWidget.tsx 里。
第一层:15 秒首字超时,主动拉闸
发出请求起计时,15 秒没收到第一个 token,AbortController 主动断流,判定失败:
const firstTimer = window.setTimeout(() => {
if (!gotFirst) {
ctrl.abort();
cb.onFail();
}
}, FIRST_TOKEN_TIMEOUT); // 15000
为什么主动断流而不是干等?因为「慢」和「死」在 TCP 层是分不清的——连接可以挂着一小时不出一个字。没有这层,后端卡死的场景下用户看到的是一个永远转圈的窗口,这是最差的失败形态:不确定状态。超时机制的本质是把不确定的失败转换成确定的失败,确定的失败才能被后面的防线处理。
15 秒这个值后来撞过一次腰(DeepSeek 默认开思考模式,首字延迟正好压在阈值上,见这篇),但那是阈值标定问题,机制本身没错。
第二层:断流时,已流出的内容要留下
catch 分支里有个关键区分:
} catch {
if (!gotFirst) cb.onFail();
else cb.onDone(acc, [], true); // 已流出部分内容后断流:保留并标记降级
}
失败分两种:一个字没流出就死,和流到一半死。前者走 onFail,没毛病。后者如果也走 onFail,界面上逐字显示了半分钟的回答会瞬间消失、换成一句「暂时休息中」——用户读到一半的内容被抽走,这比「没回答」更冒犯。
所以流出一半断掉时,把已得内容当成最终回答保留,末尾标一个降级标记。用户读到的是不完整的真话。这层防线的原则是:已交付的价值不在回收之列。
第三层:连续失败 3 次,冷却 10 分钟
后端真挂了的时候,每次提问都老实发请求的结果是:用户每次等满 15 秒超时才能看到失败文案——这个站坏得很慢。更糟的是后端可能正在 OOM 边缘挣扎,重试洪流是在补刀。
解法是前端记失败计数,连续 3 次进入 10 分钟冷却,冷却期内根本不发请求:
if (now < degradedUntil.current) {
window.setTimeout(() => {
push({ role: "ai", text: DEGRADED_TEXT, degraded: true });
setStatus("idle");
}, 350);
return; // 不调后端
}
冷却期的请求 350 毫秒就返回降级文案——已知必败的仗不打。这层防线同时保护两端:用户不用等,服务器不用扛。一次成功回答后计数清零,自动恢复,不需要人工干预。
第四层:兜底文案里留一条出路
最低限度的一层:所有防线都触发后,用户看到的那句话。
AI 助手暂时休息中,请稍后再试。你也可以通过页脚的 GitHub 联系博主。
这句话是改过的。最初版本只有前半句,后来加上了「GitHub 联系博主」——降级不是死路,要给一条替代路径。助手答不了,人还在。这句话的成本是零,但它把「这个功能坏了」转换成了「这个通道暂时关闭,另一个通道开着」。
文案措辞也区分了场景。「暂时休息中」用于失败降级——暗示系统问题、稍后会好;而检索不到内容时是另一套话术「博客中暂无相关内容,你可以换个问题试试」——暗示问题出在选择上、换个问法就能成。两种失败给用户的心理暗示完全不同:前者让用户等,后者让用户动。混用的话,「博客没写」会被理解成「服务坏了」,「服务坏了」会被理解成「博主没写」,两头都伤。
防线之外:别让兜底掩盖真因
这套防线有一个已知的反面教材,我自己踩过。系列第一篇写 SSE 换行符 bug 时复盘过:15 秒超时兜底把所有失败折叠成同一句文案,「首字超时」「HTTP 报错」「解析失败」在界面上长得一模一样,害我多查了两天——真因是解析器不认 CRLF 分帧,但表现出来的只是「超时降级」。
那次之后定了一条规则,现在这四层防线都遵守:用户看到的文案可以一样,调试信息必须分开。每种失败在 Console 里留不同的日志(HTTP 状态码、超时、断流时已流出的长度),降级文案面向用户保持简洁,诊断信息面向开发者保持精确。兜底是 UX 层的折叠,不该是信息层的湮灭。
复盘
四层防线没有一层复杂:一个定时器、一个分支、一个计数器、一句话。但它们共同回答了一个多数 AI 教程不回答的问题——你的助手躺在地上的时候是什么姿势。做 AI 功能时大家的注意力都在「答得好」上,但用户的信任往往是在失败时刻建立或崩塌的:答得惊艳十次,抵不过一次白屏没解释。
反过来讲,体面的降级也在为功能本身争取空间。正因为失败有底线、恢复是自动的,我才敢把「连发 11 次触发限流」「故意改错 Key 看降级」这种破坏性测试写进上线检查清单,每次部署都真做一遍。测试敢做,是因为失败不可怕——这大概是降级设计给的最好的回报。