第1930篇:AI服务的熔断与降级设计——LLM不可用时如何优雅地撑住系统
第1930篇:AI服务的熔断与降级设计——LLM不可用时如何优雅地撑住系统
适读人群:AI服务的后端工程师和架构师 | 阅读时长:约20分钟 | 核心价值:设计完善的熔断降级机制,让AI服务在依赖方出故障时仍然能提供有价值的响应
凌晨两点,我被电话叫醒。
线上监控报警:AI客服的错误率飙升到了40%,大量用户请求失败。
登上服务器一看:原来是通义千问API因为不明原因出现了间歇性超时,响应时间从正常的2秒拉长到了30秒,然后全部超时失败。
但比这更糟糕的是:我们的AI服务在等待LLM响应时,把线程池里的线程全占满了。后来的请求连"LLM超时失败"都返回不了,直接在线程池等待队列里超时,报了一堆 RejectedExecutionException。
等我把这场火扑灭已经是凌晨三点多。事后复盘,问题很清楚:AI服务对LLM依赖太脆弱了,没有熔断,没有降级,一个外部依赖的故障就能把整个系统拖垮。
这篇文章,就是从那次事故里逼出来的。
先理解问题:AI服务的特殊脆弱性
普通微服务的熔断降级已经有很成熟的方案(Resilience4j、Hystrix),为什么AI服务还值得单独讨论?
因为AI服务对外部依赖的性质不一样:
普通微服务调用下游,通常是"成功/失败"二元状态,失败了抛异常就行了。
AI服务调用LLM,有更多复杂的情况:
- 超时但不报错:LLM在生成中,请求没失败,但就是很慢
- 流式输出中断:流式响应开始了,输出到一半中断了
- 限流(Rate Limit):API配额用完了,后续所有请求都失败
- 部分功能降级:主模型不可用,能不能用小模型凑合?
- 缓存命中:这个问题之前回答过,能不能直接返回缓存?
这些情况需要不同的处理策略,比传统的"熔断+回退"要复杂。
分层降级策略:四道防线
我把AI服务的降级设计为四层,从最优到最差依次降级:
这四层的设计思路是:每一层都比下一层可用性更高,质量更低。在最好的情况下走第一层,在最差的情况下走最后一层,但无论如何,不能直接给用户一个冷冷的500错误。
熔断器的实现:用Resilience4j
依赖配置:
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.1.0</version>
</dependency>熔断器配置(application.yaml):
resilience4j:
circuitbreaker:
instances:
# 主LLM的熔断器
primaryLlm:
register-health-indicator: true
sliding-window-size: 20 # 用最近20次请求计算错误率
minimum-number-of-calls: 10 # 至少10次请求才开始计算
failure-rate-threshold: 50 # 错误率超过50%就熔断
slow-call-duration-threshold: 10s # 超过10秒算慢调用
slow-call-rate-threshold: 80 # 慢调用率超80%也熔断
wait-duration-in-open-state: 30s # 熔断后等30秒再尝试恢复
permitted-number-of-calls-in-half-open-state: 3 # 半开状态允许3次试探
# 备用LLM的熔断器(更宽松)
fallbackLlm:
sliding-window-size: 10
minimum-number-of-calls: 5
failure-rate-threshold: 60
wait-duration-in-open-state: 20s
# 超时配置(必须配,防止线程池被慢请求占满)
timelimiter:
instances:
primaryLlm:
timeout-duration: 30s
cancel-running-future: true # 超时后取消Future,释放线程
fallbackLlm:
timeout-duration: 15s
cancel-running-future: true核心服务实现:
@Service
@Slf4j
public class ResilientAiChatService {
private final ChatClient primaryChatClient; // 主模型(qwen-max)
private final ChatClient fallbackChatClient; // 备用模型(qwen-turbo,更快更便宜)
private final SemanticCacheService semanticCache;
private final KnowledgeBaseService knowledgeBase;
private final CircuitBreakerRegistry circuitBreakerRegistry;
private final TimeLimiterRegistry timeLimiterRegistry;
/**
* 带四级降级的AI问答
*/
public ChatResult queryWithFallback(String tenantId, String sessionId, String query) {
// === 第一级:语义缓存 ===
Optional<CachedAnswer> cached = semanticCache.findSimilar(tenantId, query, 0.92);
if (cached.isPresent()) {
log.debug("语义缓存命中, query={}", query);
return ChatResult.fromCache(cached.get().getAnswer());
}
// === 第二级:主模型 ===
try {
String answer = callWithCircuitBreaker("primaryLlm",
() -> callPrimaryLlm(tenantId, sessionId, query));
// 成功后存入语义缓存
semanticCache.put(tenantId, query, answer);
return ChatResult.fromPrimary(answer);
} catch (CallNotPermittedException e) {
log.warn("主模型熔断器开路,切换备用模型");
} catch (TimeoutException e) {
log.warn("主模型超时,切换备用模型");
} catch (Exception e) {
log.warn("主模型调用失败: {},切换备用模型", e.getMessage());
}
// === 第三级:备用模型 ===
try {
String answer = callWithCircuitBreaker("fallbackLlm",
() -> callFallbackLlm(tenantId, sessionId, query));
return ChatResult.fromFallback(answer, "备用模型");
} catch (CallNotPermittedException e) {
log.warn("备用模型熔断器开路,切换纯检索");
} catch (Exception e) {
log.warn("备用模型调用失败: {},切换纯检索", e.getMessage());
}
// === 第四级:纯检索(不经过LLM生成) ===
try {
List<Document> docs = knowledgeBase.search(tenantId, query, 3);
if (!docs.isEmpty()) {
String retrievalAnswer = buildRetrievalOnlyAnswer(query, docs);
return ChatResult.fromRetrieval(retrievalAnswer);
}
} catch (Exception e) {
log.error("知识库检索也失败了", e);
}
// === 兜底:静态回复 ===
log.error("所有降级策略都失败,返回兜底回复");
return ChatResult.fromFallbackMessage(
"抱歉,AI助手服务暂时出现问题,我们正在紧急处理。" +
"如有紧急需求,请联系人工客服(工作时间:9:00-18:00)。"
);
}
private <T> T callWithCircuitBreaker(String cbName, Callable<T> callable) throws Exception {
CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(cbName);
TimeLimiter timeLimiter = timeLimiterRegistry.timeLimiter(cbName);
Callable<T> decorated = TimeLimiter.decorateFutureSupplier(
timeLimiter,
() -> CompletableFuture.supplyAsync(() -> {
try {
return callable.call();
} catch (Exception e) {
throw new RuntimeException(e);
}
})
);
return circuitBreaker.executeCallable(decorated);
}
private String callPrimaryLlm(String tenantId, String sessionId, String query) {
// 实际调用主模型的逻辑
List<Document> context = knowledgeBase.search(tenantId, query, 5);
String prompt = buildRagPrompt(query, context);
return primaryChatClient.prompt()
.user(prompt)
.call()
.content();
}
private String callFallbackLlm(String tenantId, String sessionId, String query) {
// 备用模型:简化处理,减少上下文以降低延迟
List<Document> context = knowledgeBase.search(tenantId, query, 3); // 少取一些
String prompt = buildSimplifiedPrompt(query, context); // 更简短的prompt
return fallbackChatClient.prompt()
.user(prompt)
.call()
.content();
}
private String buildRetrievalOnlyAnswer(String query, List<Document> docs) {
// 不经过LLM,直接把检索到的文档内容整理返回
StringBuilder sb = new StringBuilder();
sb.append("以下是知识库中与您的问题最相关的内容:\n\n");
for (int i = 0; i < docs.size(); i++) {
sb.append("--- 相关内容").append(i + 1).append(" ---\n");
sb.append(docs.get(i).getText()).append("\n\n");
}
sb.append("[以上内容来自知识库,AI助手暂时无法提供个性化总结]");
return sb.toString();
}
}流式输出的降级处理
上面的方案处理的是普通请求-响应模式。如果你的AI服务用了流式输出(SSE或WebSocket),降级逻辑更复杂,因为流式输出可能在中途中断。
@Service
public class StreamingAiService {
private final ChatClient chatClient;
public Flux<String> streamWithFallback(String query, String sessionId) {
return Flux.create(emitter -> {
try {
// 尝试流式输出
chatClient.prompt()
.user(query)
.stream()
.content()
.timeout(Duration.ofSeconds(60))
.doOnNext(chunk -> emitter.next(chunk))
.doOnComplete(() -> emitter.complete())
.doOnError(e -> {
log.warn("流式输出中断: {}", e.getMessage());
// 流式中断时,发送一个降级提示,然后完成
emitter.next("\n\n[回答生成中断,已返回部分内容。如需完整回答,请重试]");
emitter.complete();
})
.subscribe();
} catch (Exception e) {
// 连流式请求都发不出去,直接走降级
emitter.next("抱歉,服务暂时无法处理您的请求,请稍后重试。");
emitter.complete();
}
});
}
}限流保护:防止配额耗尽
LLM的API通常有Rate Limit(每分钟请求数或Token数限制)。当流量突增时,如果没有应用侧的限流,LLM会返回429错误,触发熔断,整个服务就挂了。
最好在应用侧主动限流,而不是等LLM返回429再处理:
@Component
public class LlmRateLimiter {
// 每秒最多发出N个请求(根据API配额设定)
private final RateLimiter globalRateLimiter;
// 每个用户/租户的限速
private final ConcurrentHashMap<String, RateLimiter> tenantLimiters =
new ConcurrentHashMap<>();
public LlmRateLimiter(@Value("${ai.llm.global-rps:10}") double globalRps) {
this.globalRateLimiter = RateLimiter.create(globalRps);
}
/**
* 尝试获取一个令牌
* @return true=可以发请求,false=被限速,应该降级
*/
public boolean tryAcquire(String tenantId) {
// 先看全局限速
if (!globalRateLimiter.tryAcquire(100, TimeUnit.MILLISECONDS)) {
log.warn("全局LLM限速触发,当前租户: {}", tenantId);
return false;
}
// 再看租户限速
RateLimiter tenantLimiter = tenantLimiters.computeIfAbsent(
tenantId,
id -> {
TenantConfig config = tenantConfigService.getConfig(id);
return RateLimiter.create(config.getLlmRps());
}
);
if (!tenantLimiter.tryAcquire(50, TimeUnit.MILLISECONDS)) {
log.warn("租户{}的LLM限速触发", tenantId);
return false;
}
return true;
}
}降级触发时的用户体验设计
技术上的降级做好了,还有一个容易被忽略的问题:用户体验。
如果你悄悄地把主模型切换到了备用小模型,用户可能感受到质量变化但不知道为什么,会觉得产品"时好时坏",体验很差。
我推荐在降级时透明地告知用户:
@Service
public class ChatResponseEnricher {
public ChatApiResponse enrichWithDegradationInfo(ChatResult result) {
ChatApiResponse response = new ChatApiResponse();
response.setContent(result.getAnswer());
switch (result.getSource()) {
case PRIMARY_MODEL:
// 正常情况,不需要额外说明
break;
case FALLBACK_MODEL:
response.setSystemNote(
"当前使用备用服务,回答质量可能略有下降,完整服务预计很快恢复。"
);
response.setShowRefreshButton(true);
break;
case RETRIEVAL_ONLY:
response.setSystemNote(
"AI生成服务暂时不可用,以下为知识库直接检索结果,未经AI整理。"
);
response.setShowRetryLater(true);
break;
case STATIC_FALLBACK:
response.setSystemNote("服务暂时维护中");
response.setShowCustomerServiceButton(true);
break;
}
return response;
}
}这样做有几个好处:用户知道现在是降级状态,对质量差距有心理预期;显示"刷新"或"稍后重试"按钮,给了用户一个明确的操作路径;不会让用户觉得产品质量不稳定,而是"系统在透明地告诉我发生了什么"。
监控:让降级情况可见
熔断和降级发生了多少次、原因是什么,必须有监控:
@Component
public class DegradationMetricsCollector {
private final MeterRegistry meterRegistry;
public void recordDegradation(String from, String to, String reason) {
Counter.builder("ai.service.degradation")
.tag("from", from)
.tag("to", to)
.tag("reason", reason)
.register(meterRegistry)
.increment();
// 如果降级到最低级(静态兜底),发告警
if ("static_fallback".equals(to)) {
alertService.sendAlert(
AlertLevel.HIGH,
String.format("AI服务降级到兜底静态回复!原因:%s", reason)
);
}
}
}在Grafana里画出"各级别降级次数"的时序图,能直观地看到系统的健康状况,也能快速发现"从昨天开始备用模型触发次数增加了"这类异常趋势。
好的熔断降级设计,不是为了预防故障(故障总会发生),而是为了让故障的影响范围和持续时间最小化。
那次凌晨两点被叫醒的事故,如果当时有这套熔断降级,用户看到的会是"AI助手暂时使用备用服务,质量略有下降",而不是"服务不可用"。从用户角度,前者完全可以接受,后者是在破坏信任。
这个差别,就是熔断降级设计的价值所在。
