Skip to content

熔断与降级:厂商挂了不能拖垮整个系统 ​

1. 本节产出 ​

用 Resilience4j 给 LLM 调用和工具调用各加熔断器:连续失败达到阈值后快速失败、半开状态探测恢复、降级返回可用结果。并且有一个故障演练能演示完整的熔断 → 恢复过程。

2. 前置依赖 ​

3. 为什么 Agent 必须有熔断 ​

重试解决的是「偶发失败」,熔断解决的是「持续故障」。两者缺一不可:

场景只有重试有熔断
偶发 429重试成功 ✅重试成功 ✅
厂商故障 30 分钟每个任务都重试 N 次,总成本翻倍且全部失败快速失败,成本接近零

只有重试时在持续故障下的表现:100 个并发任务 × 每个重试 3 次 = 400 次调用,全部失败,钱照花,而且你的重试还在给已经过载的厂商雪上加霜。

熔断器带来的改变:失败率超过阈值后,后续请求直接失败(不发起调用),成本归零、延迟归零、不再冲击下游。

这正是 Java 工程师的既有经验可以直接迁移的地方——你在微服务里用 Resilience4j 保护下游服务,现在只不过下游换成了 LLM 厂商。

4. 核心原理 ​

4.1 熔断器状态机 ​

CLOSED(正常)
   │ 失败率 > 阈值(如 50%)
   ▼
OPEN(熔断,快速失败)
   │ 等待 waitDuration(如 30s)
   ▼
HALF_OPEN(半开,放少量请求探测)
   │ 成功 → CLOSED
   │ 失败 → 回到 OPEN

4.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: 15s

minimumNumberOfCalls 很重要:不设的话,前两次调用都失败就会立刻熔断(因为失败率 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. 生产避坑 ​

  1. 熔断必须按厂商和工具分别隔离。用一个全局熔断器会导致:某个内部工具挂了 → 全部 LLM 调用被熔断 → 整个系统不可用。粒度太粗的熔断比没有熔断更危险。
  2. minimumNumberOfCalls 必须设置。不设时前两次失败就会触发熔断(100% 失败率),而这可能只是偶发抖动。经验值 5~10。
  3. 熔断后的降级要有真实价值。返回「服务不可用」是最差的降级。优先顺序:切备用厂商 → 返回缓存 → RAG 场景返回检索片段 → 最后才是告知不可用。能给出次优结果的降级,用户留存差别很大。

8. 延伸与锚点 ​

  • 思考题:熔断防住了厂商故障,但如果 Agent 自己陷入死循环(一直在调工具、就是不结束)呢?(答案在下一课时)
  • 代码锚点:git checkout ch03-09-circuit-breaker
  • 下一课时:03-10 迭代上限与死循环检测
  • 对应课件:L03-09 熔断与降级