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 -> (参数摘要, 首次结果)},三条纪律缺一不可:

  1. 相同 key 重复到达 → 返回第一次的结果,不重新执行;
  2. 相同 key 配不同参数 → 冲突报错,不能悄悄复用旧结果;
  3. 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 用户或系统 主动停止,可能由外部事件触发

两个容易踩的细节:

失败注入:四处改动看防线在哪

改 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;路径②允许两张,但你必须能解释「这两次为什么不算重试」。

本章检查点

现在能解释什么

你能把一次失败先归到六个类别之一,再据此决定「拒绝 / 纠正 / 退避重试 / 查状态 / 转人工」;你也知道重试是否安全由 effect 决定,而让写操作可安全重试的手段是调用方生成的幂等键。下一章往更难的方向走:同一轮里多个工具请求,结果怎么保证不错位。

上一章:工具结果为什么要回到下一轮? · 下一章:多个工具和流式调用怎样不乱?

进入 keel 阅读