AI 应用的错误边界——优雅降级不让用户感知
AI 应用的错误边界——优雅降级不让用户感知
适读人群:AI 应用工程师 / 关注系统稳定性 | 阅读时长:约16分钟 | 核心价值:AI 调用失败时的全栈降级方案,让故障无感知
去年 OpenAI 有一次大规模故障,持续了将近两个小时。
那两个小时里,我们团队有两个产品都用了 OpenAI API。
第一个产品:AI 功能直接报错,整个页面显示"服务异常,请刷新重试"。用户刷了几次还是报错,然后投诉,客服被打爆,运营同学来催我们修。
第二个产品:AI 功能在降级模式下运行,用户几乎感知不到,两小时故障期间只有 3 个用户反馈"感觉 AI 变慢了一点"。
两个产品,同一次故障,体验差异天壤之别。
区别在哪里?第二个产品提前做了降级设计,第一个没有。
降级不是"显示一条错误消息"
很多工程师对"降级"的理解是:AI 挂了就显示"AI 暂时不可用,请稍后再试"。
这不是降级,这是报错。
真正的降级是:AI 挂了,用户的核心流程依然可以走通,只是体验稍微差一点,或者某些 AI 功能变成了非 AI 的替代方案。
降级策略的三个层次
根据 AI 功能在产品里的地位,降级策略分三层:
第一层:AI 是锦上添花,降级=静默关闭
AI 只是增强体验,不影响核心功能。比如:商品搜索结果旁边的 AI 推荐理由,或者文章列表里的 AI 摘要。
这类功能 AI 挂了直接不显示就好,不需要提示,用户看不到就不会有期望。
第二层:AI 是重要功能,降级=降低质量
AI 参与核心流程,但可以有替代方案。比如:AI 搜索可以降级为关键词搜索;AI 摘要可以降级为截取文章前几句;AI 推荐可以降级为规则推荐。
第三层:AI 是核心功能,降级=有限可用
AI 就是产品的核心价值,不能完全静默关闭。这时候降级策略是:对部分用户降级,保证高价值用户的体验;或者降低功能完整度(只支持简单问题,复杂问题排队)。
后端:熔断器设计
@Configuration
public class CircuitBreakerConfig {
@Bean
public CircuitBreakerRegistry circuitBreakerRegistry() {
// 为不同的 AI 功能设置不同的熔断配置
CircuitBreakerConfig defaultConfig = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 失败率超过 50% 时熔断
.slowCallRateThreshold(30) // 30% 的调用超过慢调用阈值时熔断
.slowCallDurationThreshold(Duration.ofSeconds(10)) // 10 秒算慢调用
.waitDurationInOpenState(Duration.ofSeconds(30)) // 熔断后等 30 秒再半开
.slidingWindowSize(20) // 用最近 20 次调用来计算
.minimumNumberOfCalls(5) // 至少 5 次调用才开始统计
.build();
return CircuitBreakerRegistry.of(defaultConfig);
}
}
@Service
@Slf4j
public class ResilientAiService {
@Autowired
private ChatClient chatClient;
@Autowired
private FallbackContentService fallbackService;
private final CircuitBreaker circuitBreaker;
public ResilientAiService(CircuitBreakerRegistry registry) {
this.circuitBreaker = registry.circuitBreaker("ai-chat",
CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofSeconds(30))
.build());
// 监听熔断器状态变化,方便告警
this.circuitBreaker.getEventPublisher()
.onStateTransition(event -> {
log.warn("Circuit breaker state change: {} -> {}",
event.getStateTransition().getFromState(),
event.getStateTransition().getToState());
if (event.getStateTransition().getToState() == CircuitBreaker.State.OPEN) {
alertService.sendAlert("AI 服务熔断,已切换到降级模式");
}
});
}
/**
* 带熔断和降级的 AI 聊天
*/
public AiResponse chatWithFallback(ChatRequest request) {
return Try.ofSupplier(
CircuitBreaker.decorateSupplier(circuitBreaker, () -> callAiService(request))
).recover(throwable -> {
log.warn("AI call failed or circuit open, using fallback. error={}",
throwable.getMessage());
return getFallbackResponse(request, throwable);
}).get();
}
private AiResponse callAiService(ChatRequest request) {
String content = chatClient.prompt()
.user(request.getMessage())
.call()
.content();
return AiResponse.builder()
.content(content)
.source(ResponseSource.AI)
.build();
}
/**
* 降级响应——这是设计的重点
* 不是返回"服务不可用",而是返回有用的内容
*/
private AiResponse getFallbackResponse(ChatRequest request, Throwable error) {
// 根据请求类型,选择不同的降级策略
return switch (request.getType()) {
case KNOWLEDGE_QA -> {
// 知识问答降级:用关键词搜索返回相关文档
List<Document> docs = keywordSearchService.search(request.getMessage());
if (!docs.isEmpty()) {
yield AiResponse.builder()
.content(buildKeywordSearchResult(docs))
.source(ResponseSource.KEYWORD_SEARCH)
.degraded(true)
.degradeReason("AI 服务繁忙,为您展示最相关的文档片段")
.build();
}
// 关键词搜索也没结果,给通用回复
yield AiResponse.builder()
.content("当前 AI 服务繁忙,无法为您提供实时答案。" +
"您可以通过搜索框查找相关内容,或稍后再试。")
.source(ResponseSource.STATIC_FALLBACK)
.degraded(true)
.build();
}
case CONTENT_SUMMARY -> {
// 摘要降级:返回原文前 200 字
String summary = request.getContent().substring(
0, Math.min(200, request.getContent().length()));
yield AiResponse.builder()
.content(summary + "...")
.source(ResponseSource.TRUNCATED_CONTENT)
.degraded(true)
.degradeReason("AI 摘要服务暂时不可用,为您展示原文开头")
.build();
}
case RECOMMENDATION -> {
// 推荐降级:用规则推荐替代 AI 推荐
List<Item> items = ruleBasedRecommender.recommend(request.getUserId());
yield AiResponse.builder()
.content(serializeItems(items))
.source(ResponseSource.RULE_BASED)
.degraded(true)
.build();
}
default -> AiResponse.builder()
.content(null) // 无法降级时返回 null,前端不渲染 AI 区域
.source(ResponseSource.NONE)
.degraded(true)
.build();
};
}
private String buildKeywordSearchResult(List<Document> docs) {
StringBuilder sb = new StringBuilder("根据关键词搜索,找到以下相关内容:\n\n");
for (int i = 0; i < Math.min(3, docs.size()); i++) {
Document doc = docs.get(i);
sb.append(String.format("**%s**\n%s\n\n",
doc.getMetadata().get("title"),
doc.getContent().substring(0, Math.min(150, doc.getContent().length()))
));
}
return sb.toString();
}
}前端错误边界
前端也需要有错误边界,防止 AI 组件的错误蔓延到整个页面。
React 的 Error Boundary:
class AiErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// 上报错误
errorReporter.report(error, errorInfo);
}
render() {
if (this.state.hasError) {
// 降级 UI,不是报错页面
return this.props.fallback || (
<div className="ai-unavailable-hint">
{/* 不显眼,不影响主流程 */}
<span className="subtle-hint">AI 功能暂时不可用</span>
</div>
);
}
return this.props.children;
}
}
// 使用
function ArticlePage({ article }) {
return (
<div>
<ArticleContent article={article} />
{/* AI 摘要用错误边界包裹 */}
<AiErrorBoundary fallback={<ArticleExcerpt article={article} />}>
<AiSummaryComponent articleId={article.id} />
</AiErrorBoundary>
{/* AI 相关推荐用错误边界包裹,降级到规则推荐 */}
<AiErrorBoundary fallback={<RuleBasedRecommendations />}>
<AiRecommendations userId={currentUser.id} />
</AiErrorBoundary>
</div>
);
}Vue 3 的降级处理:
<template>
<div class="article-page">
<ArticleContent :article="article" />
<!-- AI 摘要区域 -->
<div class="ai-summary">
<template v-if="aiSummary.status === 'success'">
<p>{{ aiSummary.content }}</p>
</template>
<template v-else-if="aiSummary.status === 'degraded'">
<!-- 降级:展示原文前几句,不提醒用户 -->
<p>{{ article.excerpt }}</p>
</template>
<template v-else-if="aiSummary.status === 'loading'">
<SummarySkeleton />
</template>
<!-- status === 'silent_fail' 时什么都不显示 -->
</div>
</div>
</template>
<script setup>
const aiSummary = reactive({ status: 'loading', content: null });
async function loadAiSummary() {
try {
const response = await api.getAiSummary(article.id);
if (response.degraded) {
// 后端告知已降级
aiSummary.status = 'degraded';
aiSummary.content = response.content;
} else {
aiSummary.status = 'success';
aiSummary.content = response.content;
}
} catch (error) {
if (error.response?.status === 503) {
// 服务不可用:静默降级
aiSummary.status = 'degraded';
} else {
// 其他错误:静默失败(不显示 AI 区域)
aiSummary.status = 'silent_fail';
}
}
}
</script>那次故障的实际数据
让我们回到开头的那次 OpenAI 故障对比。
没有降级的产品(两小时故障期间):
- 客服投诉:47 条
- 用户主动咨询(在线客服):23 次
- 社交媒体负面反馈:2 条
- 活跃用户留存率:故障期间下降 31%
有降级的产品(同一时间):
- 客服投诉:3 条(用户说"AI 变慢了")
- 用户主动咨询:0 次
- 活跃用户留存率:几乎无变化
第二个产品的降级策略:
- 知识问答:降级到关键词搜索
- AI 摘要:降级到截取前 200 字
- AI 推荐:降级到基于标签的规则推荐
- AI 写作建议:直接隐藏,不提示
成本:额外大概两周的开发时间,加上定期演练(每个月模拟一次 AI 服务不可用,确认降级能正常触发)。
这两周的投入,在那两小时故障里的价值远超任何预期。
降级的开发清单
每个 AI 功能上线前,我现在都会对着这个清单过一遍:
AI 功能降级清单:
[ ] 定义了这个功能在 AI 不可用时的降级行为(不是报错,是替代方案)
[ ] 后端实现了熔断器
[ ] 降级响应的 API 格式和正常响应一致(减少前端改动)
[ ] 前端有错误边界
[ ] 前端根据 degraded 字段决定是否显示提示
[ ] 有监控:降级触发率
[ ] 做过演练:手动关掉 AI 服务,验证降级是否生效
[ ] 告警:熔断开启时通知工程师降级不是锦上添花,是 AI 应用的基础设施。AI 服务就是会挂,这是事实。提前做好准备,是工程师的基本职业素养。
