Appearance
熔断与降级:厂商挂了不能拖垮整个系统
1. 本节产出
用 Resilience4j 给 LLM 调用和工具调用各加熔断器:连续失败达到阈值后快速失败、半开状态探测恢复、降级返回可用结果。并且有一个故障演练能演示完整的熔断 → 恢复过程。
2. 前置依赖
- 03-08 重试与 Token 放大
- 了解 Resilience4j 的基本概念(CircuitBreaker / RateLimiter)
3. 为什么 Agent 必须有熔断
重试解决的是「偶发失败」,熔断解决的是「持续故障」。两者缺一不可:
| 场景 | 只有重试 | 有熔断 |
|---|---|---|
| 偶发 429 | 重试成功 ✅ | 重试成功 ✅ |
| 厂商故障 30 分钟 | 每个任务都重试 N 次,总成本翻倍且全部失败 | 快速失败,成本接近零 |
只有重试时在持续故障下的表现:100 个并发任务 × 每个重试 3 次 = 400 次调用,全部失败,钱照花,而且你的重试还在给已经过载的厂商雪上加霜。
熔断器带来的改变:失败率超过阈值后,后续请求直接失败(不发起调用),成本归零、延迟归零、不再冲击下游。
这正是 Java 工程师的既有经验可以直接迁移的地方——你在微服务里用 Resilience4j 保护下游服务,现在只不过下游换成了 LLM 厂商。
4. 核心原理
4.1 熔断器状态机
CLOSED(正常)
│ 失败率 > 阈值(如 50%)
▼
OPEN(熔断,快速失败)
│ 等待 waitDuration(如 30s)
▼
HALF_OPEN(半开,放少量请求探测)
│ 成功 → CLOSED
│ 失败 → 回到 OPEN4.2 熔断粒度:按什么维度隔离
| 维度 | 好处 | 代价 |
|---|---|---|
| 全局一个熔断器 | 简单 | 一个工具挂了导致全部熔断 |
| 按厂商/模型 | 主模型挂了能切备用 | 需要多个熔断器实例 |
| 按工具名 | 精确隔离 | 实例较多 |
推荐:按「厂商 + 工具名」两个维度各建一个熔断器。这样:
- DeepSeek 挂了 → 只熔断 DeepSeek,可自动切到通义;
- 某个内部工具挂了 → 只熔断该工具,其他工具正常。
4.3 降级策略设计
LLM 熔断后:
├─ 有备用厂商 → 切备用(最优)
├─ 无备用 + 有缓存 → 返回缓存答案
├─ 无备用 + RAG 场景 → 只返回检索到的文档片段(不生成)
└─ 都没有 → 明确告知「服务暂时不可用」
工具熔断后:
└─ 把「工具不可用」作为 Observation → 模型换其他方式「只返回检索片段」这一档很实用:用户仍然能得到信息,而成本几乎为零。
4.4 与重试的顺序
调用 → 熔断检查 → 通过 → 执行 → 失败 → 重试判断 → ...
│
└─ 熔断开启 → 直接降级(不执行、不重试)熔断器必须在重试外层。否则重试会先耗尽预算,熔断器统计到的失败率反而不准。
5. 代码走查
5.1 依赖与配置
xml
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
</dependency>yaml
resilience4j:
circuitbreaker:
instances:
llmDeepSeek:
slidingWindowSize: 20 # 统计最近 20 次调用
failureRateThreshold: 50 # 失败率 50% 触发
waitDurationInOpenState: 30s # 熔断 30s 后进入半开
permittedNumberOfCallsInHalfOpenState: 3
minimumNumberOfCalls: 5 # 至少 5 次才开始统计
toolQueryOrder:
slidingWindowSize: 10
failureRateThreshold: 60
waitDurationInOpenState: 15sminimumNumberOfCalls 很重要:不设的话,前两次调用都失败就会立刻熔断(因为失败率 100%),属于误判。
5.2 给 LLM 调用加熔断
java
// ch03-agent/src/main/java/com/aitech/agent/guard/ResilientLlmCaller.java
@Service
public class ResilientLlmCaller {
private final CircuitBreakerRegistry registry;
public String call(String vendor, Supplier<String> llmCall) {
CircuitBreaker cb = registry.circuitBreaker("llm" + vendor);
return Decorators.ofSupplier(llmCall)
.withCircuitBreaker(cb)
.decorate()
.get()
;
}
/** 带降级的版本 */
public String callWithFallback(String vendor, String prompt,
Supplier<String> primary) {
try {
return call(vendor, primary);
} catch (CallNotPermittedException e) {
// 熔断开启:切备用厂商
log.warn("厂商 {} 已熔断,尝试备用", vendor);
return callFallbackVendor(prompt);
} catch (Exception e) {
log.warn("厂商 {} 调用失败", vendor, e);
return callFallbackVendor(prompt);
}
}
}5.3 备用厂商切换(利用 01-03 的适配层)
java
private String callFallbackVendor(String prompt) {
for (String backup : props.fallbackOrder()) {
try {
return call(backup, () -> models.get(backup).call(prompt));
} catch (Exception e) {
log.warn("备用厂商 {} 也失败", backup);
}
}
return FALLBACK_TEXT; // 「AI 服务暂时不可用,请稍后再试」
}01-03 做的多模型适配层在这里体现出价值:熔断时能自动切换厂商,业务代码零改动。
5.4 工具熔断
java
// ch03-agent/src/main/java/com/aitech/agent/tool/ResilientToolExecutor.java
public ToolResult execute(ToolMeta meta, String toolName, Map<String, Object> args) {
CircuitBreaker cb = registry.circuitBreaker("tool" + capitalize(toolName));
try {
return Decorators.ofSupplier(() -> doExecute(toolName, args))
.withCircuitBreaker(cb)
.decorate()
.get();
} catch (CallNotPermittedException e) {
// 工具熔断:转成可行动的 Observation,不是任务失败
return ToolResult.fail(
"UNAVAILABLE:工具 %s 当前不可用(已熔断)。请改用其他工具或基于已有信息继续。"
.formatted(toolName));
}
}5.5 监控
java
// 熔断状态要能被监控到
meters.gauge("agent.circuit.state",
Tags.of("name", cb.getName()),
cb, c -> c.getState().ordinal());6. 跑起来
bash
git checkout ch03-09-circuit-breaker
mvn spring-boot:run故障演练(本节核心演示):
bash
# 阶段 1:正常调用
for i in 1 2 3; do curl -s -X POST http://localhost:8080/api/agent/run -d '{"goal":"你好"}'; done
# 期望:正常返回,熔断状态 CLOSED
# 阶段 2:注入故障(配一个错误的 Key)
curl -X POST http://localhost:8080/api/admin/fault/inject -d '{"type":"auth-fail"}'
# 连续发 10 次请求
for i in $(seq 1 10); do curl -s -X POST http://localhost:8080/api/agent/run -d '{"goal":"你好"}'; done
# 期望:前 5 次失败后熔断开启,后续请求**立刻返回**(不再等待超时)
# 阶段 3:观察熔断状态
curl http://localhost:8080/actuator/circuitbreakers
# 期望:{"llmDeepSeek":{"state":"OPEN","failureRate":"100%"}}
# 阶段 4:恢复
curl -X POST http://localhost:8080/api/admin/fault/clear
sleep 35 # 等待 waitDuration
curl -s -X POST http://localhost:8080/api/agent/run -d '{"goal":"你好"}'
# 期望:半开放行 3 次探测,成功后回到 CLOSED| 检查项 | 通过标准 |
|---|---|
| 熔断触发 | 失败率超阈值后进入 OPEN |
| 快速失败 | 熔断后响应 < 100ms(不再等超时) |
| 降级可用 | 有备用厂商时自动切换 |
| 半开恢复 | 故障恢复后自动回到 CLOSED |
| 工具熔断 | 单个工具熔断不影响其他工具 |
| 状态可观测 | actuator 能看到状态与失败率 |
「熔断后响应 < 100ms」是最直观的证据:对比熔断前的 30s 超时等待。
7. 生产避坑
- 熔断必须按厂商和工具分别隔离。用一个全局熔断器会导致:某个内部工具挂了 → 全部 LLM 调用被熔断 → 整个系统不可用。粒度太粗的熔断比没有熔断更危险。
minimumNumberOfCalls必须设置。不设时前两次失败就会触发熔断(100% 失败率),而这可能只是偶发抖动。经验值 5~10。- 熔断后的降级要有真实价值。返回「服务不可用」是最差的降级。优先顺序:切备用厂商 → 返回缓存 → RAG 场景返回检索片段 → 最后才是告知不可用。能给出次优结果的降级,用户留存差别很大。
8. 延伸与锚点
- 思考题:熔断防住了厂商故障,但如果 Agent 自己陷入死循环(一直在调工具、就是不结束)呢?(答案在下一课时)
- 代码锚点:
git checkout ch03-09-circuit-breaker - 下一课时:03-10 迭代上限与死循环检测
- 对应课件:L03-09 熔断与降级