最外层的 Agent 日志很克制:最多尝试三次。工具适配器也很克制:最多尝试三次。HTTP 客户端同样很克制:最多尝试三次。三份克制叠在一起,末端却收到了二十七次调用。
这是一个合成失败场景,不是作者的生产事故。它暴露的不是哪个库特别鲁莽,而是一个没有人负责的边界:谁拥有整次任务的时间和重试额度? 每层只对自己的配置负责,整体就可能既超时,又超量。
前三篇分别处理回执丢失、失效工作进程和不可幂等工具的结果不明。本篇继续往调度层走:即使某次重试在业务语义上安全,也不能无限消耗时间和下游资源;反过来,即使任务已经到期,也不能把远端可能发生过的事情一笔勾销。
资料核对日期:2026 年 10 月 12 日。实验使用 Python 标准库,不调用大模型,不接入外部 API 或真实业务服务。时间、延迟与故障序列是人为指定的逻辑 tick;函数派发、策略判断、SQLite 提交及重新读取是真实运行。本文不是生产延迟、费用、可用性或容量测试。
1. 先统一“重试三次”究竟包含几次请求#
配置名经常比算法先出问题。max_attempts=3 通常表示包括第一次在内共三次尝试;max_retries=3 则可能表示第一次加三次重试,共四次。某些 SDK 还有透明重试、连接重建或代理层重放。只看业务代码里的 for 循环,无法知道实际发了多少请求。
本文统一使用“最多尝试三次”,明确包含第一次。对三层串行嵌套、每次末端调用都失败、各层均把错误当作可重试的合成链路,末端调用次数是 3 × 3 × 3 = 27。如果每层允许第一次之外再重试三次,同样结构会变成 4 × 4 × 4 = 64。这个乘法有前提:必须耗尽各层额度;提前成功、不可重试错误或总期限到达都会截断它。
AWS 的超时、重试与退避说明讨论了多层重试放大负载,并建议为合适的低成本操作选择单一重试位置。这里的二十七次是我们自己的三层合成实验,不是引用一个生产案例,更不能据此宣称所有 Agent 都会放大二十七倍。
最稳妥的起点通常是确定一个重试所有者,其余层把结果和错误语义向上返回。如果已有 SDK 无法关闭内部重试,上层至少要知道其最大尝试数、总耗时和哪些发送被隐藏了。所谓“共享预算”也不是在每个函数入口复制一份数字;那样只是把同一张限额表复印三份。
2. 每次都设置 timeout,整条任务仍可能没有终点#
假设入口给任务十个逻辑 tick。排队花了三个,参数准备花了两个。若工具调用仍拿到一个全新的十 tick 超时,原任务已经悄悄获得延期。第一次失败后,再创建一份十 tick 窗口,重试又把终点推远了。
端到端 deadline 的做法,是在入口确定同一个截止点 D。后续步骤计算剩余量 remaining = D - now;没有剩余量,就不开始新尝试。单次调用允许的等待量不能超过 remaining,也可以进一步受到单次上限约束。重试次数、退避、排队和本地准备都不会让 D 自动变大。
gRPC 的 deadline 文档区分了绝对截止点与相对超时,并说明传播时要扣除已消耗时间。它还提醒,不同语言的默认传播行为不同。因此,给入口配置 deadline 后,仍应验证具体语言、客户端和中间层是否真的传下去了,不能靠命名猜测。
本文把有效窗口定义为半开区间:now < D 才允许发起,等于 D 已经过期。这个边界必须在代码和测试里一致。至于某个真实协议如何处理恰好在截止点完成的响应,应由其契约决定,不能把本例的逻辑 tick 规则冒充所有客户端的实现。
还有一笔容易漏掉的时间:清理与结果持久化。业务处理用完全部窗口,未必还能把结果可靠交还上游。生产设计可给清理和应答保留空间,但保留多少取决于实际系统;本例不凭空推荐一个百分比。截止时间也应来自业务容忍度和测量,而非“十秒看起来挺合理”。
3. 时间额度和派发额度,守的是两道门#
只有 deadline 不够。若下游快速失败,一小段时间内仍可能涌入大量重试。只有次数上限也不够。一次卡住的调用就可能耗尽用户全部等待窗口。
本例因此分别检查两个条件:任务是否还有时间,以及是否还有末端派发额度。总派发上限包含第一次请求;例如上限为四,最多允许一次首发加三次重试。发送前的本地参数校验、准入拒绝和本地计算不是末端派发;真正进入接收端边界之前,额度必须已经扣除。已经发出的请求即使被远端拒绝或结果不明,也消耗一次额度,不能因为没成功就把调用次数抹去。
额度耗尽是一种不可继续重试的控制结果。若内层抛出“预算耗尽”,外层却把它当成普通网络错误再创建新预算,限制立刻失效。因此,这类停止原因要一路保留,不被宽泛的 except Exception: retry() 吞掉。未知业务结果、取消和不可重试错误也需要同样明确的出口。
本例的共享上下文只在一个 Python 进程内顺序使用。它展示同一份可变额度沿调用栈传递,不能被当成跨机器或并发安全的配额服务。多个线程、进程或执行节点竞争额度时,需要原子扣减、明确的分配协议或统一调度者。网络超时后是否归还额度也不能随意决定:发送端不知道请求有没有出去时,保守计数比“失败就退款”更可靠。
此外,一项任务的总预算与整个服务的重试预算是两回事。即便每个任务只增加三次请求,一万个任务一起重试也可能压垮依赖。生产系统还需要服务级限流、并发上限和过载保护。本例没有实现这些能力,不用单任务测试替代容量规划。
4. 能不能重试,必须先于还剩几次#
预算只是上限,不是业务许可。账户里还有额度,不代表可以再发一封邮件、再创建一张不可去重的工单,或者替用户重复一次已经取消的动作。
RFC 9110 第 9.2.2 节约束了非幂等请求的自动重试:需要知道实际语义可幂等,或知道原请求未被应用,而不是看到通信错误就重发。HTTP 方法是线索,不足以取代工具自己的业务协议。
本例采用保守策略:只对声明为只读或具有明确幂等契约的操作进行自动重试,还要满足错误本身可重试。幂等操作沿用同一个稳定键和相同参数,不通过换键绕过旧结果。不可幂等写入一旦可能已被接收却没有确认,就保留 unknown,停止自动再执行。参数冲突等确定性错误同样不应被退避掩盖。
下载代码中的重试准入只保留两个条件;完整调用链还会检查时间、取消和派发额度:
def may_retry(request: Request, outcome: Outcome) -> bool:
return outcome.status in RETRYABLE and request.safety in {
Safety.READ_ONLY, Safety.IDEMPOTENT
}
其中 RETRYABLE 仅含本例的 transient 与 unknown,不是“所有异常”。stopped 和 permanent 不会被外层重新解释成继续尝试的理由。工具是否值得重试与业务是否允许重放,缺一不可。
“声明为幂等”不是由模型临时判断出来的可靠性保证。声明应来自工具注册、接收端契约与可验证实现,还要覆盖租户作用域、参数一致性、记录保留期和旁路写入。如果去重记录只保留一天,第二天换个执行节点继续重放,原先的假设可能已失效。本文的本地示例不验证生产服务的这些条件。
有一个重要反例:某些非幂等操作若能可靠证明尚未发送,或接收端明确给出无副作用的拒绝,确实可能安全重试。本例选择更窄的默认策略,不自动推断这类能力。扩展时应该引入有来源的错误分类和协议证据,不能把“连接异常”统一理解成“对方一定没收到”。
5. 退避不是另一本免费时钟#
重试之间的等待也要消耗同一 deadline。本例先把计划等待截断到剩余窗口,等待结束后再检查,若已到期就不派发下一次请求。因此可以出现“最后只等了一部分退避,随后停止”的记录。生产中也可以提前拒绝一个明显无法在窗口内完成的退避计划,减少无效等待;即便准许等待,醒来后仍要重新检查,因为真实调度可能比计划更晚。
实际系统常用带上限的指数退避与抖动,避免许多调用方同时醒来。不过随机化只能改变请求到达的分布,不能扩大获准的次数,也不能把不可幂等操作变得安全。服务端要求的 Retry-After 等等待提示还需要按具体协议处理;剩余窗口不够时,合理结果可能是停止或交回上层,而不是忽略服务端提示抢跑。
为了让读者能逐行比较结果,本例使用确定性的延迟和退避序列,没有模拟随机客户端群,也没有测量抖动能减少多少拥塞。把随机种子固定只能让一组模拟可重现,不能把模拟升级成真实负载证据。本文不提出“退避后成功率提升多少”的结论。
还要区分一次业务操作重放和整段工作流重放。如果前半段已经发邮件,后半段只读查询失败,重新执行整个工作流可能再次发邮件。对末端工具应用预算之前,应先把工作流中的业务意图、稳定操作 ID 和恢复点定义清楚。这正是第一篇 outbox 与接收端幂等仍然不可省略的原因。
6. cancelled 和 unknown 可以同时成立#
用户不愿意继续等了,执行器可以停止发起新工作,并尝试传播取消。但调用方已停止等待,与接收端已撤销动作,不是同一个事实。
gRPC 的取消文档说明,取消通常不能直接打断应用处理器,处理器需要主动配合。由此也不能推导一个已经完成的数据库提交会被取消信号倒转。取消传播、业务回滚和补偿操作必须分别设计。
本文的取消实验启动真实子进程,向临时 SQLite 数据库提交一条合成效果。子进程提交后输出专供测试驱动使用的 COMMITTED_BARRIER,然后卡在标准输入的放行门前,尚未返回业务回执。父进程收到屏障信号后终止子进程,再用新连接读取最终行数。效果仍然存在。屏障是测试仪器的特权观测,不是被模拟的业务调用方已经收到成功回执;五秒等待上限只防止测试挂死,没有参与延迟计分。
这比只改一个取消标志更直接:即使本地工作进程真的停止,已经提交的效果也不会自动消失。但它仍然没有运行真实 RPC 取消或跨机器延迟信号实验,也不模拟机器断电、磁盘损坏或数据库丢写。调度模型与提交边界实验各证明一部分,不能拼成未经测试的端到端分布式保证。
因此,状态记录最好分开保存:执行为什么停止,以及业务结果能否确认。前者可能是 deadline、budget 或 cancelled;后者可能是 confirmed、not_started 或 unknown。只留一个红色 failed,会诱导后来的人认为业务一定未发生,进而点击重试制造第二次效果。
结果不明时,后续只读核验可以成为另一项有负责人、有期限的工作,但不能借“恢复”之名绕过用户的取消继续执行原动作。补偿又是新的业务操作,需要明确对象和授权。更不能把删除本地日志当成撤销远端事实。第三篇给出了查询强度、终态与人工对账的完整边界。
7. 下载实验,检查派发记录和最终数据库#
实验文件见:retry_budget.py、test_retry_budget.py、run_experiment.py、results.json、README.md。将文件放在同一空目录,使用 Python 3.10 或更新版本运行:
python3 test_retry_budget.py
python3 run_experiment.py --check
第一条执行 50 项边界测试,本次全部通过;第二条重新运行场景,将完整语义结果与存档比较,本次一致。逻辑时刻是模型输入和运算结果,不是用墙钟测得的秒数。SQLite 数据只写入临时目录;没有读取凭证或用户业务数据。
本次执行环境为 Python 3.12.14。三层失败链路中每层允许两次重试,也就是包括首发共三次;前四个对照的末端每次人为消耗六个 tick。退避场景单独使用两层、每层最多两次尝试、单次处理三个 tick、层内退避七个 tick;取消场景在首次六 tick 等待中于第四 tick 触发。以下是实际运行计数,tick 数不是实测毫秒:
| 场景 | 末端派发数 | 最终逻辑 tick | 观察到的约束 |
|---|---|---|---|
| 每层重置自己的范围 | 27 | 162 | 各层额度不能约束整体 |
| 仅共享总派发上限 4 | 4 | 24 | 第一次请求也计入上限 |
| 仅共享截止点 20 | 4 | 20 | 第四次等待在截止点被截断 |
| 同时共享上限 4 与截止点 20 | 4 | 20 | 时间与派发量共同约束 |
| 退避也计入截止点 14 | 2 | 14 | 其中退避消耗 8 tick |
| 在逻辑 tick 4 取消 | 1 | 4 | 不发起下一次尝试 |
时间对照中的接收端是合作式合成函数,会按照共享窗口推进逻辑时钟。因此“停在二十”不证明一个真实阻塞库可以被精确中断。预算对照也只统计本例可见的末端函数调用,不能替 SDK 的隐藏网络请求背书。
SQLite 对照则检查真实持久化效果:提交后终止子进程仍有一条效果;无保护地再次调用非幂等接收端会留下两条;带同一个稳定键重放保持一条;换键会新增为两条;相同键但不同参数被拒绝。危险重放是实验故意调用接收端的对照组,安全策略不会对 non-idempotent unknown 自动执行它。
unknown 标签在这个样例中表示调用方未得到业务回执,并非实现了持久化发送端状态机。生产恢复仍需沿用前几篇的落库和对账设计。完整场景结果与派发时刻以可下载 JSON 为准,不把控制返回值当成业务事实的替代品。
测试尤其需要覆盖等于截止点、退避吃完剩余窗口、首发计入预算、预算耗尽向上穿透、取消后不发下一次、参数冲突不重试、非幂等 unknown 不重试,以及同键幂等重放只有一条效果。这些断言约束的是这个机制实现;它们不证明任意 SDK 都服从同样规则。
8. 从样例迁移到真实系统,还有四个缺口#
首先是时间域。单机计算耗时应使用合适的单调时钟,不应把容易校时的墙钟差值当作稳定持续时间。Python 的 time 文档说明 monotonic 时钟不会倒退,但参考点未定义,因此不能把一个进程随手产生的绝对读数当作跨机器通用时间。跨服务应通过经过设计的期限传播协议传递剩余量,并考虑传输与本地等待,不能直接复制本例整数。
其次是隐藏的派发。重定向、连接层透明重试、SDK 自动重试和代理重放,都可能让一次方法调用对应多次请求。如果计数位置在它们外面,文章里的派发上限就只约束了包装函数。上线前要用可观测日志或故障注入确认预算究竟覆盖到哪一层。
第三是任务重启。本例的控制预算是进程内对象,不提供崩溃恢复。若工作进程每次重启都得到一张新额度,真实任务仍可能无限重试。需要在持久任务状态中保存已消耗额度与原期限,定义恢复时的时间转换,并保留同一业务意图。至于多节点原子计数、恢复时钟和排队存储故障,必须另做实验。
第四是不可抢占的工作。一个阻塞客户端即使收到较短超时参数,也可能在 DNS、TLS、扩展模块或清理阶段继续运行。发起前检查 deadline 只能约束是否再开始,不能凭空强制中止已执行的同步代码。应核对客户端真正覆盖的阶段,区分调用方等待结束和后台活动结束,不能把本例的合作式逻辑时钟当成硬实时保证。
9. 最后留下的应该是一份能解释的停止记录#
一次执行没有得到结果时,日志至少应能回答:原始业务意图是什么,谁负责重试,额度起点与当前消耗是多少,哪些层发生了实际派发,入口截止点如何传播,为什么停止,以及远端结果还缺什么证据。记录操作标识和必要摘要即可,不能为可观测性随手复制密钥、用户正文或敏感业务数据。
重试次数少,不一定可靠;重试次数多,也不一定积极。真正有用的约束是:在业务语义允许的范围内,用一份没有被偷偷复制的额度,追求一个没有被悄悄延后的终点。到点停下来后,系统仍要诚实保留它知道和不知道的部分。
本系列第四篇增加的是资源与期限边界,没有替代前三篇的事务幂等、接收端 fencing 和 unknown 对账。可继续阅读第一篇、第二篇与第三篇。进一步讨论可查看知识星球,或通过公众号入口关注更新。
本文及原创图解、样例代码由 AI 辅助制作,并经过代码执行与内容核验。合成数据与逻辑时间仅用于机制实验,不构成生产系统可靠性承诺。

