先看一个真实痛点。ItineraryPlanAgent 在规划行程时会调天气工具,如果工具存在网络抖动、服务不可用、连续返回错误,会发生什么? ReAct 循环是「推理 → 调工具 → 看结果 → 再推理」的闭环。工具报错后,模型看到错误信息,往往会再试一次——毕竟从模型视角看「可能只是偶然失败」。于是同一个坏掉的工具被反复调用:一次失败浪费一轮推理(一次 LLM 调用 + 一次外部请求 + 几秒延迟),连着失败十次就是十轮空转,既烧 token 又拖慢响应,最后大概率还是失败。模型没有「这个工具现在坏了,别再叫它」的记忆。 这正是熔断要解决的问题,但 Agent 场景的熔断和传统微服务熔断有个关键区别: - 微服务熔断:熔断器拦在调用方和被调方之间,OPEN 时拦截请求、快速失败返回。调用方代码照样会去 call,只是被熔断器挡回来。 - Agent 工具熔断:最优雅的做法不是「拦截调用」,而是直接让模型看不到这个工具。工具从 Toolkit 里卸载后,模型在下一轮推理时压根不知道有这个工具,自然不会去调用它——从源头上消灭了「重复失败」。等冷却期过了再把工具加回来。 gogo-agent 的 ToolCircuitBreakerHook 走的就是第二条路:用 ToolGroup.removeTool / addTool 动态增删工具,让熔断对模型「透明」。这是本设计最核心的立意。 我们项目中的工具熔断采用的是 熔断器模式+指数退避+工具卸载 实现的。
熔断器模式
这个名字来自家用电路里的「保险丝 / 空气开关」:电流过载时,开关自动跳闸切断电路,保护后面的电器不被烧坏;等你排查完故障,再手动或自动合闸恢复供电。软件里的熔断器是同一个思路的搬运——它是一个包在「不稳定调用」外面的状态机,专门用来在下游持续故障时快速止损,而不是让调用方一次次撞墙。
它有三个状态,构成一个闭环:
- CLOSED(关闭 / 正常):电路接通,请求正常放行。熔断器在背后默默数「连续失败了几次」。这里的「关闭」指的是开关闭合、电路通,容易和直觉搞反,记住「CLOSED = 正常工作」即可。
- OPEN(打开 / 熔断):连续失败达到阈值,开关跳闸、电路断开。此后的一段「冷却期」内,所有请求不再真正打到下游——要么快速失败,要么(像本工程这样)干脆让调用方看不到这个能力。目的是给故障方喘息时间,也不再浪费自己的资源。
- HALF_OPEN(半开 / 试探):冷却期一过,熔断器放行少量试探请求去探探下游是否恢复。探测成功 → 回到 CLOSED 正常服务;探测又失败 → 退回 OPEN 继续冷却。
熔断器解决的核心矛盾是:故障往往不是一瞬间的,而是持续一段时间的(下游宕机、网关抖动、限流)。在这段时间里,不停重试既救不活下游,又拖垮自己。熔断器用「快速失败 + 定时试探」把「盲目重试」变成「有节制的探测」。
指数退避
熔断跳闸后要冷却多久?固定时长(比如永远等 30 秒)有个毛病:如果下游是「偶尔抖一下」,30 秒太久;如果下游是「长时间挂了」,30 秒又太短,会导致频繁试探、频繁再次熔断,形成无谓的抖动。 指数退避的思路是让冷却时间随失败次数指数增长:第一次熔断等 T,第二次等 2T,第三次 4T……即 冷却时长 = 初始时长 × 倍数^(降级代数-1)。含义很直白——一个工具越是反复出问题,就说明它的故障越顽固,越应该把它隔离得久一点,别再频繁去打扰它,也别再频繁浪费自己的探测成本。 但指数增长不能无限涨(否则一个工具可能被隔离几天),所以必须封顶:min(初始 × 倍数^(代数-1), 上限)。gogo-agent 的参数是初始 60 秒、倍数 2.0、上限 600 秒,于是冷却序列是 60s → 120s → 240s → 480s → 600s(封顶)。指数退避在重试、限流、熔断等所有「和不稳定资源打交道」的场景里都是标配。
配置、状态存储、Hook
熔断能力由三个类协作完成,职责清晰分离。
配置:CircuitBreakerProperties
所有可调参数集中在 app.circuit-breaker 配置节点,绑定到一个 @ConfigurationProperties Bean:
@Component
@ConfigurationProperties(prefix = "app.circuit-breaker")
public class CircuitBreakerProperties {
private boolean enabled = false; // 总开关,默认关
private String monitoredToolNames = ""; // 白名单:只有这些 tool 会被熔断
private String excludeToolNames = ""; // 黑名单:优先级高于白名单
private int failureThreshold = 3; // 连续失败几次触发熔断
private long initialCooldownSeconds = 30; // 首次冷却时长
private double backoffMultiplier = 2.0; // 指数退避倍数
private long maxCooldownSeconds = 600; // 冷却上限,防止退避无限增长
private String redisKeyPrefix = "tool:cb:"; // Redis 键前缀,区分环境
private long stateTtlSeconds = 86400; // 脏数据自动清理 TTL
// ...
}
对应的 application.yml:
app:
circuit-breaker:
enabled: ${TOOL_CB_ENABLED:true}
# 只有白名单里的 tool 才会被熔断,避免误伤 DB/缓存这类基础设施工具
monitored-tool-names: ${TOOL_CB_MONITORED_NAMES:query_weather,query_destination_news}
failure-threshold: 3
initial-cooldown-seconds: 60
backoff-multiplier: 2.0
max-cooldown-seconds: 600
redis-key-prefix: "tool:cb:"
state-ttl-seconds: 86400
白名单机制:熔断绝不是「对所有工具一视同仁」。数据库读写、缓存这类基础设施工具偶尔失败是正常的,一旦被熔断卸载反而会让 Agent 彻底瘫痪。所以只有明确列入 monitored-tool-names 的外部慢依赖 / 不稳定接口(这里是天气查询、目的地新闻查询)才纳入监控,其余工具永远放行。黑名单 excludeToolNames 优先级更高,用于「全局白名单 + 临时屏蔽个别 tool」的场景。(并且,我们尽量应该选择熔断后对主要流程影响不大的工具)
状态存储:CircuitBreakStore(Redis + Lua 原子脚本)
熔断状态存在 Redis 而不是进程内存,原因很实在:集群多实例共享同一份熔断视图(A 节点发现工具坏了,B 节点也应立即知道),且实例重启不丢状态。 每个 tool 只用两把 key,设计极简:
{prefix}fail:{toolName} → 连续失败计数(String,INCR 原子自增)
{prefix}gen:{toolName} → Hash { n: 降级代数, at: 开启时间戳(ms) }
at 字段存在即代表 OPEN 状态,不额外存一个 "open" 布尔标记。isOpen() 就是判断 Hash 里有没有 at 字段:
public boolean isOpen(String toolName) {
return Boolean.TRUE.equals(redisTemplate.opsForHash().hasKey(genKey(toolName), FIELD_OPENED_AT));
}
而冷却时长完全派生计算、不持久化——由「降级代数 n」按指数退避算出来:
private long computeCooldown(long generation) {
double raw = properties.getInitialCooldownSeconds()
* Math.pow(properties.getBackoffMultiplier(), Math.max(0, generation - 1));
long cooldown = (long) Math.ceil(raw);
return Math.min(cooldown, properties.getMaxCooldownSeconds()); // 封顶
}
第一次熔断冷却 60s,第二次 120s,第三次 240s……直到 600s 封顶。工具越是反复出问题,隔离得越久,这就是指数退避的意义。 并发安全靠 Lua 脚本保证「多步操作原子完成」。比如进入 OPEN 状态要同时做三件事(代数 +1、记开启时间、续 TTL),用一个脚本一次搞定,杜绝「代数加了但时间没记」的中间态:
private static final RedisScript<Long> OPEN_NEXT_GENERATION_SCRIPT = new DefaultRedisScript<>(
"local n = redis.call('HINCRBY', KEYS[1], ARGV[1], 1) "
+ "redis.call('HSET', KEYS[1], ARGV[2], ARGV[3]) "
+ "redis.call('EXPIRE', KEYS[1], ARGV[4]) "
+ "return n",
Long.class);
失败计数的 INCR + EXPIRE 同理打包成一个脚本,避免两次往返之间留下无 TTL 的脏 key。 判断冷却期是否过去,就是拿开启时间戳 + 派生冷却时长和当前时间比:
public boolean isCooldownExpired(String toolName) {
Object atObj = redisTemplate.opsForHash().get(genKey(toolName), FIELD_OPENED_AT);
if (atObj == null) return true; // 不在 OPEN 状态 = 视为已过冷却
long openedAt = Long.parseLong(atObj.toString());
long cooldown = getCooldownSeconds(toolName);
return System.currentTimeMillis() >= openedAt + cooldown * 1000L;
}
Hook:ToolCircuitBreakerHook(状态机的执行者)
这是把配置和存储串起来的核心。它实现 AgentScope 的 Hook,监听两个事件,这两个事件的分工是整个设计最需要理解的地方。
谁统计、谁执行
gogo-agent 的巧妙之处在于把「状态变更」拆给两个不同的 Hook 事件处理,各司其职: - PostActingEvent(工具执行后)= 统计 + 立即卸载。它是「事后诸葛」:看这次工具调用成功还是失败,更新 Redis 计数,达到阈值就当场把工具从 group 移除。 - PreReasoningEvent(模型推理前)= 状态机唯一权威。它是「事前守门」:每轮推理开始前,根据 Redis 的 OPEN 状态 + 冷却期,决定每个受监控工具此刻「该在 group 里还是该被卸载」,做最终对齐。 为什么要拆成两个? 因为 PostActing 只能对「刚调用过的那个工具」做反应,而恢复(把冷却期已过的工具加回来)这件事发生在「工具已经被卸载、模型好久没调它」的时候——这时只有 PreReasoning 每轮都扫一遍才能兜住。让 PreReasoning 做唯一权威,避免了两个事件抢着 addTool/removeTool 导致的状态抖动。
PostActing:统计与即时熔断
先做层层过滤(工具为空、被挂起、toolkit 为空、不在白名单都直接放行),然后核心判断:
boolean isError = isErrorResult(toolResult);
boolean open = failureStore.isOpen(toolName);
boolean cooldownExpired = failureStore.isCooldownExpired(toolName);
// OPEN 且冷却期未到:模型本不该看到 tool,但极端时序下 LLM 可能在熔断前已决策调用 —— 兜底放行
if (open && !cooldownExpired) {
return Mono.just(event);
}
boolean halfOpen = open; // 走到这里说明 cooldownExpired==true,OPEN 即处于半开探测期
if (isError) {
if (halfOpen) {
// 半开期探测又失败 → 立即重新熔断,冷却时长按新代数指数增长
long cooldown = failureStore.openWithNextGeneration(toolName);
removeToolFromGroup(toolkit, toolName);
} else {
long count = failureStore.incrementFailureCount(toolName);
if (count >= properties.getFailureThreshold()) {
long cooldown = failureStore.openWithNextGeneration(toolName);
removeToolFromGroup(toolkit, toolName); // 达阈值,当场卸载
}
// 未达阈值:只累加计数,工具仍可用
}
} else { // 成功
if (halfOpen) {
failureStore.clearOpen(toolName); // 半开探测成功 → 完全恢复
failureStore.resetFailureCount(toolName);
} else if (failureStore.getFailureCount(toolName) > 0) {
failureStore.resetFailureCount(toolName); // 健康期偶发失败被一次成功清零
}
}
两个关键细节: 1. 连续失败才熔断:只要中间成功一次,resetFailureCount 就把计数清零。熔断针对的是「持续故障」,不是「偶发抖动」。 2. 成功路径不碰 toolkit:即使半开探测成功,PostActing 也只改 Redis 状态、不在这里 addTool。恢复动作统一交给下一次 PreReasoning,避免在 PostActing 里反复增删工具。
PreReasoning:唯一权威的状态对齐
每轮推理前,遍历所有受监控工具,用一条简单规则决定去留:
// 唯一规则:OPEN 且未过冷却 → 卸载;其它情况(CLOSED / 半开期)→ 加回
for (String toolName : monitoredToolNames) {
boolean open = failureStore.isOpen(toolName);
boolean cooldownExpired = failureStore.isCooldownExpired(toolName);
boolean shouldUninstall = open && !cooldownExpired;
applyToolAvailability(toolkit, toolName, shouldUninstall);
}
applyToolAvailability 是幂等的——只在「状态不一致」时才动手,该卸载但还在就 removeTool,该恢复但不在就 addTool,已经对齐就什么都不做:
private void applyToolAvailability(Toolkit toolkit, String toolName, boolean shouldUninstall) {
ToolGroup group = toolkit.getToolGroup(TOOL_CIRCUIT_BREAKER_GROUP);
if (group == null) return;
boolean contains = group.containsTool(toolName);
if (shouldUninstall && contains) {
group.removeTool(toolName); // 卸载:模型下一轮看不到
} else if (!shouldUninstall && !contains) {
group.addTool(toolName); // 恢复:模型下一轮又能用了
}
// 状态一致则无动作
}
注意 PreReasoningEvent 没有 getToolkit(),要从 agent 上取((ReActAgent).getToolkit())——这是和 PostActingEvent 的一个 API 差异。 冷却期一过会发生什么? isCooldownExpired 返回 true → shouldUninstall 变 false → PreReasoning 把工具 addTool 加回来 → 模型这一轮又能看到它了(这就是「半开期」)→ 模型如果去调用它,PostActing 就来判定:成功则 clearOpen 完全恢复,失败则 openWithNextGeneration 用更长的冷却重新熔断。整个 CLOSED→OPEN→HALF_OPEN→CLOSED 的循环就此闭合。
错误识别:只认框架统一前缀
怎么判断一次工具调用算「失败」?答案很克制——只认框架给异常工具结果统一加的前缀,不对具体业务字段做特殊判断:
private static final String ERROR_PREFIX = "Tool execution failed";
private boolean isErrorResult(ToolResultBlock toolResult) {
for (ContentBlock block : toolResult.getOutput()) {
if (block instanceof TextBlock text
&& text.getText() != null
&& text.getText().contains(ERROR_PREFIX)) {
return true;
}
}
return false;
}
这样做的好处是通用:任何工具抛异常都会被框架包上这个前缀,熔断器不需要理解每个工具的业务语义。代价是「业务层面失败但没抛异常」(比如返回了 {"code": 500} 却是正常 200 响应)不会被识别——这是通用性与精确性的取舍。
如何接线到 Agent
熔断能力落到某个 Agent 上需要三步,以 InfoAgent 为例。 第一步,把 store 和 hook 注册为 Bean(TravelAgentConfig):
@Bean("toolFailureStore")
public CircuitBreakStore toolFailureStore(StringRedisTemplate redisTemplate,
CircuitBreakerProperties properties) {
return new CircuitBreakStore(redisTemplate, properties);
}
@Bean
public ToolCircuitBreakerHook toolCircuitBreakerHook(
CircuitBreakerProperties properties,
@Qualifier("toolFailureStore") CircuitBreakStore toolFailureStore) {
return new ToolCircuitBreakerHook(properties, toolFailureStore);
}
第二步,在 Agent 的 build() 里创建熔断工具组,并把需要监控的工具注册进这个组:
// 创建熔断专用工具组
toolkit.createToolGroup(TOOL_CIRCUIT_BREAKER_GROUP, TOOL_CIRCUIT_BREAKER_GROUP, true);
// 把不稳定的外部工具放进这个组 —— 只有组内工具会被熔断增删
toolkit.registration().tool(destinationLiveTools).group(TOOL_CIRCUIT_BREAKER_GROUP).apply();
第三步,把 hook 挂到 Agent 的 hooks 列表:
.hooks(List.of(..., toolCircuitBreakerHook, ...))
至此闭环形成:白名单工具进熔断组 → hook 监听两个事件 → 失败达阈值卸载 → 冷却期过恢复。