给 Agent 工具调用加上生产级容错:重试、退避与熔断
前言
在上一篇文章写了 Agent Loop:工具调用失败时,把错误信息追加回对话,让 LLM 自己修正参数重试——这是语义级重试。但生产环境里还有另一类错误:网络超时、服务重启、瞬时不可用。这类错误让 LLM 介入是浪费——你不需要模型”思考怎么修正”,你只需要等半秒再试一次。
在这一期补上了这一层:RetryController,一个处理瞬时故障的工程级重试器。
零、三层架构图
先给全貌——工具调用失败后,错误怎么在两个重试层之间流转:
flowchart TD
A[工具调用] --> B{RetryController 执行}
B -->|成功| C[结果追加到消息]
B -->|瞬时错误| D{重试次数 < 3?}
D -->|是| E[指数退避 base*2^N]
E --> B
D -->|否| F[错误传给 LLM]
C --> G[LLM 基于结果回复]
F --> H{错误类型?}
H -->|参数错误| I[format_error_for_llm
LLM 修正参数]
I --> A
H -->|服务不可用| J[告知用户稍后重试]
style A fill:#F1EFE8,stroke:#888780,color:#444441
style B fill:#EEEDFE,stroke:#534AB7,color:#3C3489
style E fill:#E1F5EE,stroke:#0F6E56,color:#085041
style I fill:#FCEBEB,stroke:#A32D2D,color:#791F1F
style J fill:#FCEBEB,stroke:#A32D2D,color:#791F1F
style C fill:#EAF3DE,stroke:#3B6D11,color:#27500A
熔断器三态状态机——连续失败触发熔断,冷却后探测恢复:
stateDiagram-v2
[*] --> CLOSED: 初始
CLOSED --> OPEN: 连续失败 ≥ 阈值(5)
OPEN --> HALF_OPEN: 冷却期过(30s)
HALF_OPEN --> CLOSED: 探测成功
HALF_OPEN --> OPEN: 探测失败
CLOSED --> CLOSED: 成功重置计数
note right of CLOSED: 正常调用,失败计数归零
note right of OPEN: 直接拒绝所有调用
防止雪崩
note right of HALF_OPEN: 放一个探测请求
验证服务是否恢复
重试时序——前两次瞬时失败自动退避,第三次成功(LLM 全程无感知):
sequenceDiagram
autonumber
participant A as Agent
participant R as RetryController
participant T as Tool.fn
A->>R: try_with_retry(fn, args)
R->>T: 第 1 次尝试
T-->>R: TimeoutError(瞬时)
R->>R: 退避 0.5s
R->>T: 第 2 次尝试
T-->>R: ConnectionError(瞬时)
R->>R: 退避 1s
R->>T: 第 3 次尝试
T-->>R: 成功返回数据
R-->>A: {"success": True, result}
一、两类错误,两条处理路径
Agent 工具调用的失败分两类,处理方式完全不同:
| 错误类型 | 例子 | 处理 | 谁介入 |
|---|---|---|---|
| 永久错误 | 参数类型错、字段缺失 | 不重试 | LLM(修正参数) |
| 瞬时错误 | 网络超时、连接被拒 | 自动重试 | 系统(无感知) |
前端类比:瞬时错误 ≈ axios-retry 自动重试 5xx(用户无感知);永久错误 ≈ 表单校验 400(用户看到错误自己修)。
永久错误不重试是核心原则——TypeError 不会因为多跑一次变对。重试是给”环境波动”的第二次机会,不是给”代码 bug”的。
二、RetryController:错误分类
1def categorize_error(self, exception):2 if isinstance(exception, (asyncio.TimeoutError, ConnectionError, OSError)):3 return ErrorCategory.TRANSIENT # 网络层错误 → 可重试4 return ErrorCategory.PERMANENT # 代码/逻辑错误 → 不重试分类的依据是错误本质,不是错误名:
- TRANSIENT(瞬时):超时、连接被拒、DNS 失败——共同特征是环境问题。不是代码 bug,是外部波动,等一会儿大概率恢复。
- PERMANENT(永久):类型错误、参数缺失——共同特征是逻辑问题。代码不变,结果永远一样,重试纯浪费。
三、指数退避:为什么不是固定间隔
1delay = self.backoff_base * (2 ** attempt) # 0.5s → 1s → 2s → 4s前端类比:HTTP 429 Too Many Requests 的标准处理——Retry-After 头 + 指数退避,是 Web 生态验证过的模式。
为什么指数而不是固定间隔?
- 固定间隔太急:服务要重启 30 秒,你每 1 秒试一次——前 29 次全是浪费
- 固定间隔太慢:网络抖动 200ms 就恢复了,你干等 5 秒才重试
- 指数退避平衡:前几次快(应对抖动),后几次慢(给重启留时间)
四、熔断器:防止雪崩
1CLOSED(闭合)──连续失败≥阈值──> OPEN(断开)──冷却期过──> HALF_OPEN(半开)2 ↑ │3 └─────────── 探测成功,回到 CLOSED ────────────┘如果下游服务已经挂了,重试只是反复打一个注定失败的接口——浪费资源、拖慢响应、放大压力。熔断器在连续失败达到阈值后直接拒绝,等冷却期过了再放一次探测流量。
前端类比:CDN 节点健康检查。连续探活失败就切备用节点,定期探活,恢复后自动切回。
1class CircuitState(Enum):2 CLOSED = "closed" # 正常调用3 OPEN = "open" # 直接拒绝4 HALF_OPEN = "half_open" # 允许一次探测五、接入 Agent Loop
1exec_fn = functools.partial(self.executor.execute, action["tool"])2result = await self.retry.try_with_retry(exec_fn, arguments=action["arguments"])两层重试各司其职,互不干扰:
1工具调用失败2 ├─ 瞬时错误(超时/断连)→ RetryController 退避重试 ≤3 次 → LLM 无感知3 └─ 永久错误(参数错)→ format_error_for_llm → LLM 修正参数 → 再调结论
错误重试的层次 = 可靠性设计的分层。先分类再重试——不是所有失败都值得重试;先熔断再等待——不是所有重试都该继续。
Agent 从”能跑”到”可靠”的差距,不在功能多不多,在失败时怎么表现。给工具调用加上重试、退避、熔断——这是玩具 Agent 和生产级 Agent 的分水岭。