← 返回笔记

🔬 研究 🚧 进行中 · ai agent 架构 可靠性

幂等键必须是业务身份,不是调用身份

用 ToolCallID 当幂等键看起来天经地义,实际永远失效。记一次重试放大的三层算式,以及不确定状态下为什么该先查而不是赌。

阅读 | 评论

重试是本能。但「重试一次」和「多扣一次款」之间的距离,靠什么堵住?

这篇记的是:哪些操作根本不需要幂等保护、幂等键该拿什么做身份(以及一个看起来天经地义、实际永远失效的反例)、三层重试会怎样放大,还有「不确定状态」下为什么必须先查权威状态。

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、之后记结果,两份合起来才能恢复

【取舍】读成一句话:幂等不是”让操作不重复执行”,是”让重复执行不产生第二次效果”——前者做不到(网络就是会重发),后者可以。


🗂️ 这是知识库中的一篇🔬 研究。

内容可能不完整或仍在整理中。

← 返回笔记