重试是本能。但「重试一次」和「多扣一次款」之间的距离,靠什么堵住?
这篇记的是:哪些操作根本不需要幂等保护、幂等键该拿什么做身份(以及一个看起来天经地义、实际永远失效的反例)、三层重试会怎样放大,还有「不确定状态」下为什么必须先查权威状态。
1. 前提:先分清什么是”副作用”#
【推导】不是所有的工具调用都需要幂等保护——先分类:
① 只读工具(查询、检索、读文件)
→ 重试天然安全,不需要幂等键
② 幂等的写("把状态设为 X")
→ 重试安全(重复执行结果一样),但要注意"覆盖"语义
③ 非幂等的写("余额减 1"、"追加一条记录"、"发一条消息")
→ 【必须】有幂等保护,否则重试就是重复执行text【推论】“重试安不安全”不是由”这个工具重不重要”决定的,是由”重复执行会不会产生第二次效果”决定的。 所以分类要在设计工具时就做,而不是出事之后再补。
【事实】Agent 场景的特殊性:模型会自己重试。它看到工具报错,可能换一种参数再调一次——这相当于一个”没有退避、没有上限、没有幂等键”的自动重试(无进展检测也治这个)。
自检:为什么”我们的工具调用基本不会失败”不能作为不做幂等的理由?
2. 幂等键:必须用业务身份【推导】#
【事实】RuleArena 的真实设计:
UNIQUE(run_id, action_type, idempotency_key)sql【推导】为什么不能用 ToolCallID:
第一次调用:call_abc → 插入成功
超时(其实已执行)→ 重试
第二次调用:call_def → 【键变了】→ 唯一约束认为这是新记录 → 插入成功
→ 同一个副作用被执行了两次text【结论】幂等键必须是”业务身份”(这笔操作在业务上是什么),不是”调用身份”(这次调用长什么样)。
【取舍】幂等键的选法:
✓ run_id + action_type + 业务键(订单号、工单号、窗口标识)
✓ 天然的业务唯一标识(同一窗口的采集记录)
✗ ToolCallID(重试会换)
✗ 时间戳(重试会换)
✗ 随机 UUID(重试会换)text【推论】判据是一句话:“同一个业务意图重试时,这个键会不会变?” 会变就不能用。
自检:为什么”我们每次调用都生成了一个唯一 ID 做幂等”反而更危险?
3. 重试放大【推导】#
客户端重试 3 次 × 网关重试 3 次 × 服务内部重试 3 次
= 最坏 3 × 3 × 3 = 27 倍请求打到下游text【结论】每层都”好心”地重试,合起来就是一次流量放大 27 倍的风暴。
【事实】raw 里的真实事故形态:“队列积压 8 万,直接把副本从 20 扩到 100,情况反而更糟”——因为多层重试把一次故障放大成了任务风暴(LLM 429、连接池打满、超时任务重新入队)。
【取舍】所以重试必须分层设计,要一起定三件事:
① 哪一层负责重试(只让一层重试,其余快速失败)
② 重试几次 + 退避 + 抖动(的重试三件套)
③ 哪些错误可重试(见下)text【事实】重试分类(哪些能重试):
| 类别 | 例子 | 处理 |
|---|---|---|
| 可重试 | 网络抖动、限流、下游 5xx、死锁回滚(40001) | 退避后重试,带上限 |
| 不可重试 | 参数校验失败、权限拒绝、约束冲突(23505) | 立即失败,走业务分支 |
| 不确定 | 超时、连接中断、进程被杀 | 先查权威状态(见第 4 节) |
【推论】把”不确定”混进”可重试”是最危险的——因为超时的真实含义是”我不知道做没做成”,而不是”没做成”。
4. 不确定状态:先查,不要赌【推导】#
【推导】工具调用超时,系统处于”未知”状态。两个选择:
选择 A:直接重试
如果第一次其实成功了 → 重复执行 → 重复扣款
选择 B:先查权威状态
用业务键查一次:"这笔操作做了吗?"
→ 做了 → 补记结果,跳过
→ 没做 → 重试text【推导】算一下这笔账:
设 P(第一次其实已执行) = 50%
重复执行的事故代价 = 100 单位(对账、退款、客诉)
查询一次的成本 = 1 单位
选择 A 的期望损失 = 0.5 × 100 = 50 单位
选择 B 的成本 = 1 单位
差 50 倍text【结论】“先查权威状态”的成本几乎可以忽略,而它能消掉一整类事故。
【事实】一种常见的表述(恢复协议):
读取 Checkpoint → 校验版本 → 【查询业务权威状态】
→ 副作用已发生? → 是:补记结果 / 跳过重复
→ 否:按策略重试或人工
→ 重建最小 Context → 从安全节点恢复text【推论】注意”查询业务权威状态”这一步是独立的一步——它不是”顺手查一下”,是恢复协议的必要环节。因为 checkpoint 记录的是”意图”,不是”事实”(第 5 节会展开)。
自检:为什么”超时后重试”和”失败后重试”必须区别对待?
5. Checkpoint 与副作用:记录的是”意图”还是”事实”【推导】#
【事实】checkpoint 的时机(这几条值得记住):
① 进入 / 离开每个有业务意义的节点
② 【外部副作用之前】记录 intent
③ 【副作用完成后】记录结果与业务幂等键
④ 人工中断前
⑤ 大型子任务完成后
⑥ Context 压缩或分支合并后text【推导】为什么要”前后各记一次”:
② 之前记 intent:崩溃在调用中间时,系统知道"这一步本来要做 X"
③ 之后记结果:崩溃在调用之后时,系统知道"X 已经做了"
只记 ②(无 ③):恢复时不知道做没做 → 只能先查权威状态(第 4 节)
只记 ③(无 ②):崩溃在中间时,连"本来要做什么"都不知道 → 无从恢复text【推论】checkpoint 记录的是”意图 + 事实”两份,而它们之间正好夹着那个”不确定窗口”。 这就是为什么原始素材说”Checkpoint 不是简单序列化内存”——它必须与工具版本、业务状态和幂等记录协调。
自检:为什么”把内存序列化下来”不等于做了一个可用的 checkpoint?
6. 四种操作要分清:Resume / Replay / Retry / Fork【事实】#
【事实】一张常用的对照(这张表是这一节的重点):
| 操作 | 目标 | 对副作用的处理 |
|---|---|---|
| Resume | 完成原任务 | 查询状态,必要时继续执行 |
| Replay | 复现 / 评估轨迹 | 默认使用记录结果或沙盒,不重做真实副作用 |
| Retry | 重做一个失败步骤 | 仅对可重试错误且有幂等保障 |
| Fork | 从历史节点探索新方案 | 新 branch,继承只读事实,不继承未批准写权限 |
【推导】这四个最容易混的是 Replay 和 Resume:
Resume:我要把这件事做完 → 该做的副作用要真做
Replay:我要看看当时到底发生了什么 → 【不能真做副作用】text【推论】Replay 如果重做真实副作用,那它就不是 Replay,是”重放事故”——这是评测和调试场景里最容易踩的坑(比如你想复现一次失败,结果又扣了一次款)。
【推论】Fork 那条”不继承未批准写权限”设计得很细:你可以继承”当时读到的事实”,但不能继承”当时被批准的写权限”——因为权限的批准是针对具体动作的(的”批准必须绑定具体动作摘要和版本”)。
自检:为什么 Replay 默认不能重做真实副作用?
7. 边界与常见误解#
| 常见说法 | 修正 |
|---|---|
”用 ToolCallID 做幂等键最自然” | 它永远失效。重试会产生新的调用 ID,唯一约束认为这是新记录 → 同一个副作用被执行两次。必须用业务身份(run_id + action_type + 业务键) |
| “超时了就重试” | 超时的含义是”不知道做没做成”,不是”没做成”。直接重试可能重复执行;正确动作是先按业务键查权威状态(期望损失 50 vs 成本 1,差 50 倍) |
| “每一层都加个重试更保险” | 三层各重试 3 次 = 最坏 27 倍放大。raw 里的真实事故:队列积压时扩副本反而更糟,因为多层重试把一次故障放大成了任务风暴。只让一层重试,其余快速失败 |
| ”约束冲突和网络错误一样,重试就行” | 完全不同。约束冲突(23505)是确定性失败,重试一万次也不会成功——要走业务分支。只有可重试错误才重试(网络抖动、限流、死锁回滚) |
| “幂等靠应用层判断就够了” | 应用层幂等是优化,数据库约束才是正确性。锁会失效(超时、网络分区),唯一约束不会。而且应用层的”先查再写”在并发下有窗口 |
| ”Replay 就是把当时的操作再做一遍” | 那就成了”重放事故”。Replay 的目标是复现 / 评估轨迹,默认使用记录结果或沙盒,不重做真实副作用。Resume 才是”把事做完" |
| "Checkpoint 就是把内存序列化下来” | 不是。它必须与工具版本、业务状态和幂等记录协调——而且要在副作用之前记 intent、之后记结果,两份合起来才能恢复 |
【取舍】读成一句话:幂等不是”让操作不重复执行”,是”让重复执行不产生第二次效果”——前者做不到(网络就是会重发),后者可以。