KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
07. 工具失败时为什么不能急着重试? — keel 龙骨
上一章展示了「把错误放回上下文,模型自己会改」。这句话有个前提容易被忘:改对的是参数,重试不一定是安全的。这一章处理重试前的三个问题。
上一章展示了「把错误放回上下文,模型自己会改」。这句话有个前提容易被忘:改对的是参数,重试不一定是安全的。这一章处理重试前的三个问题。
现场:一张超时,两张工单
模型请求 create_ticket,HTTP 客户端 30 秒没等到响应,asyncio.TimeoutError 抛出来。Runner 的兜底逻辑很自然地重试了一次。结果:
23:01:02 create_ticket -> timeout
23:01:33 create_ticket -> TCK-002 (重试成功)
23:04:10 值班同学在系统里发现有两张工单:TCK-001 和 TCK-002
TCK-001 是第一次调用创建的——它其实成功了,只是响应丢在路上。超时是客户端视角的事实,不是服务端的事实。
这类失败有个专门的名字:outcome_unknown。它是分布式调用里最危险的一类,因为对策与所有其他失败相反:不能重试,要先查状态。
先按"下一步做什么"给失败分类
不要按错误现象分类(超时/异常/非空返回),要按恢复动作分类:
| 类别 | 典型 code | 下一步该做什么 | 能不能自动重试 |
|---|---|---|---|
| 请求本身不合法 | unknown_tool / invalid_arguments |
拒绝,或让模型纠正参数 | 否 |
| 业务规则不允许 | incident_not_found / idempotency_conflict |
把结构化事实交给模型或用户 | 否 |
| 权限不足 | policy_denied / approval_required |
终止并请求授权 | 否 |
| 依赖暂时不可用 | tool_unavailable |
退避重试 | 是,带上限 |
| 单次调用超时 | tool_timeout |
视 effect 决定 | 通常否 |
| 结果未知 | outcome_unknown |
先查状态或人工确认 | 绝不自动 |
这张表也是为 ToolError.retryable 划的界线:它表达的是「工具认为这次失败可以再试一次」,而是否真的重试由 Harness 结合 effect、预算和幂等支持决定。两边不一致时以保守的一方为准。
effect 决定重试的代价
read 查事件、读配置 -> 重试代价 ~0,可以放宽
write 创建工单、改状态 -> 需要幂等键或先查是否生效
external 发邮件、付款、控设备 -> 默认不重试,先查状态/走审批
同一个网络抖动,落在 get_service_status 上只是多花 200ms,落在 notify_oncall 上就是值班同学被叫三次。所以重试策略必须由 effect 参与,不能按 HTTP 状态码统一处理。
幂等键:把"同一次业务意图"变成可识别的一件事
class CreateTicketArgs(BaseModel):
title: str
details: str
idempotency_key: str = Field(min_length=8, max_length=100)
幂等键应由调用方生成并随参数一起发出:tenant + tool + stable_business_request_id。服务端要持久化 {key -> (参数摘要, 首次结果)},三条纪律缺一不可:
- 相同 key 重复到达 → 返回第一次的结果,不重新执行;
- 相同 key 配不同参数 → 冲突报错,不能悄悄复用旧结果;
- key 有过期时间,但过期前必须保持稳定。
跑一遍看这三条:
python courses/foundation/tool-calling/course/project/examples/04_idempotency.py
第一次: {"call_id":"call-1","tool_name":"create_ticket","tool_version":"1.0","ok":true,
"data":{"ticket_id":"TCK-001",...},"error":null}
同一请求重复: {"call_id":"call-2",...,"data":{"ticket_id":"TCK-001",...},"error":null}
相同 key、不同参数: {"call_id":"call-3",...,"ok":false,
"error":{"code":"idempotency_conflict","message":"相同幂等键不能用于不同的工单内容","retryable":false}}
三条输出对应三条纪律,注意第二条的 ticket_id 仍是 TCK-001——第二次调用没有创建第二张工单。这就是开头那场事故的正确解法:不是去掉重试,而是让重试可重入。
顺带看一眼幂等键和 call_id 的分工:call_id 标识这一轮的这次调用(不同轮重试时它会变),幂等键标识业务意图(重试时必须不变)。两者都参与去重,作用位置不同。
Timeout、Deadline、Cancellation 是三件事
| 概念 | 谁设置 | 管住什么 |
|---|---|---|
| Timeout | 单个工具调用 | 一次调用最多等多久 |
| Deadline | 整次运行 | 这次运行最晚几点必须结束 |
| Cancellation | 用户或系统 | 主动停止,可能由外部事件触发 |
两个容易踩的细节:
- 取消后迟到的结果不能把运行状态改回成功。第 3 秒取消、第 5 秒工具返回 200,这次运行仍然是已取消——否则用户对取消没有任何控制力。
- 队列投递是 at-least-once。同一条消息可能到达两次,这不是异常而是常态,所以执行路径上必须有去重,且去重要跑在业务逻辑之前。
失败注入:四处改动看防线在哪
改 tools.py / executor.py 后重跑 04_idempotency.py:
| 改法 | 现象 | 说明 |
|---|---|---|
| 幂等键改成由服务端生成 | 重试时 key 不同,两张工单 | 幂等必须由调用方发起,否则失去意义 |
idempotency_conflict 改成返回旧结果 |
第二次的内容被悄悄丢弃 | 静默吞掉差异,是资金/资产损失类事故的典型成因 |
| 幂等状态存内存字典 | 进程重启后保护失效 | 教学实现的这一行注释里已经写明了原因 |
把 ToolExecutionError 当普通异常抛出 |
失去 code,模型读不到判断依据 |
第 06 章的自修复能力随之失效 |
生产替换点
| 教学实现 | 生产替换 |
|---|---|
内存 _TICKETS_BY_KEY |
持久化存储 + 幂等键上的 UNIQUE 约束,靠数据库兜住并发 |
raise ToolExecutionError |
领域错误码表,跨服务共享并版本化 |
| 客户端 timeout | 服务端可查询的状态机 / 对账任务,用于确认 outcome_unknown |
asyncio.TimeoutError 直接抛 |
区分连接超时、响应超时、业务超时,各自不同对策 |
| 统一 try/except | 按上表分类后再决定:忽略、纠正、退避重试、转人工 |
练习与验收
练习:给 create_ticket 加一个 2 秒的执行延迟,然后用两条不同的路径各调两次:① 相同幂等键;② 不同幂等键。记录工单总数与最后一次返回值。
验收标准:路径①必须始终只有一张工单且返回同一个 ticket_id;路径②允许两张,但你必须能解释「这两次为什么不算重试」。
本章检查点
- 为什么
outcome_unknown的对策是所有失败里最保守的? ToolError.retryable = true时,Harness 就一定重试吗?谁说了算?- 为什么不能让服务端生成幂等键?
- 取消之后工具才返回成功,这次运行应该记成什么状态?记成成功会发生什么?
现在能解释什么
你能把一次失败先归到六个类别之一,再据此决定「拒绝 / 纠正 / 退避重试 / 查状态 / 转人工」;你也知道重试是否安全由 effect 决定,而让写操作可安全重试的手段是调用方生成的幂等键。下一章往更难的方向走:同一轮里多个工具请求,结果怎么保证不错位。