# Agent 可靠执行实战（四）：每层都尝试三次，为什么工具被调用了二十七次？

> 用 Python 标准库、确定性逻辑时钟与真实 SQLite 提交，对照嵌套重试、端到端截止时间和共享派发预算，说明取消等待为什么不能证明副作用被撤销。

- 作者：芝士AI吃鱼
- 发布日期：2026-10-12
- 主题：Agent、AI工程、可靠性、分布式系统
- HTML 正文：[https://ai-knowledgepoints.cn/blog/agent-reliability-04-deadlines-retry-budgets](https://ai-knowledgepoints.cn/blog/agent-reliability-04-deadlines-retry-budgets)
- Markdown 永久链接：[https://ai-knowledgepoints.cn/blog/agent-reliability-04-deadlines-retry-budgets/index.html.md](https://ai-knowledgepoints.cn/blog/agent-reliability-04-deadlines-retry-budgets/index.html.md)

引用本文时，请注明作者与 HTML 正文链接。Markdown 版本用于机器阅读，与 HTML 正文共享同一内容来源。

## 正文

最外层的 Agent 日志很克制：最多尝试三次。工具适配器也很克制：最多尝试三次。HTTP 客户端同样很克制：最多尝试三次。三份克制叠在一起，末端却收到了二十七次调用。

这是一个合成失败场景，不是作者的生产事故。它暴露的不是哪个库特别鲁莽，而是一个没有人负责的边界：**谁拥有整次任务的时间和重试额度？** 每层只对自己的配置负责，整体就可能既超时，又超量。

[前三篇](https://ai-knowledgepoints.cn/blog/agent-reliability-03-unknown-reconciliation)分别处理回执丢失、失效工作进程和不可幂等工具的结果不明。本篇继续往调度层走：即使某次重试在业务语义上安全，也不能无限消耗时间和下游资源；反过来，即使任务已经到期，也不能把远端可能发生过的事情一笔勾销。

资料核对日期：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 的超时、重试与退避说明](https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/)讨论了多层重试放大负载，并建议为合适的低成本操作选择单一重试位置。这里的二十七次是我们自己的三层合成实验，不是引用一个生产案例，更不能据此宣称所有 Agent 都会放大二十七倍。

最稳妥的起点通常是确定一个重试所有者，其余层把结果和错误语义向上返回。如果已有 SDK 无法关闭内部重试，上层至少要知道其最大尝试数、总耗时和哪些发送被隐藏了。所谓“共享预算”也不是在每个函数入口复制一份数字；那样只是把同一张限额表复印三份。

![三层各最多尝试三次可产生二十七次末端派发；共享预算应在实际派发处扣减，包含首次请求](https://ai-knowledgepoints.cn/images/articles/agent-reliability-04/retry-multiplication.svg)

二十七是明确失败序列下的实际派发计数，不是生产流量测量。

[查看竖版大图](https://ai-knowledgepoints.cn/images/articles/agent-reliability-04/retry-multiplication-mobile.svg)

## 2. 每次都设置 timeout，整条任务仍可能没有终点

假设入口给任务十个逻辑 tick。排队花了三个，参数准备花了两个。若工具调用仍拿到一个全新的十 tick 超时，原任务已经悄悄获得延期。第一次失败后，再创建一份十 tick 窗口，重试又把终点推远了。

端到端 deadline 的做法，是在入口确定同一个截止点 D。后续步骤计算剩余量 `remaining = D - now`；没有剩余量，就不开始新尝试。单次调用允许的等待量不能超过 remaining，也可以进一步受到单次上限约束。重试次数、退避、排队和本地准备都不会让 D 自动变大。

[gRPC 的 deadline 文档](https://grpc.io/docs/guides/deadlines/)区分了绝对截止点与相对超时，并说明传播时要扣除已消耗时间。它还提醒，不同语言的默认传播行为不同。因此，给入口配置 deadline 后，仍应验证具体语言、客户端和中间层是否真的传下去了，不能靠命名猜测。

本文把有效窗口定义为半开区间：`now < D` 才允许发起，等于 D 已经过期。这个边界必须在代码和测试里一致。至于某个真实协议如何处理恰好在截止点完成的响应，应由其契约决定，不能把本例的逻辑 tick 规则冒充所有客户端的实现。

还有一笔容易漏掉的时间：清理与结果持久化。业务处理用完全部窗口，未必还能把结果可靠交还上游。生产设计可给清理和应答保留空间，但保留多少取决于实际系统；本例不凭空推荐一个百分比。截止时间也应来自业务容忍度和测量，而非“十秒看起来挺合理”。

## 3. 时间额度和派发额度，守的是两道门

只有 deadline 不够。若下游快速失败，一小段时间内仍可能涌入大量重试。只有次数上限也不够。一次卡住的调用就可能耗尽用户全部等待窗口。

本例因此分别检查两个条件：任务是否还有时间，以及是否还有末端派发额度。总派发上限包含第一次请求；例如上限为四，最多允许一次首发加三次重试。发送前的本地参数校验、准入拒绝和本地计算不是末端派发；真正进入接收端边界之前，额度必须已经扣除。已经发出的请求即使被远端拒绝或结果不明，也消耗一次额度，不能因为没成功就把调用次数抹去。

额度耗尽是一种不可继续重试的控制结果。若内层抛出“预算耗尽”，外层却把它当成普通网络错误再创建新预算，限制立刻失效。因此，这类停止原因要一路保留，不被宽泛的 `except Exception: retry()` 吞掉。未知业务结果、取消和不可重试错误也需要同样明确的出口。

本例的共享上下文只在一个 Python 进程内顺序使用。它展示同一份可变额度沿调用栈传递，不能被当成跨机器或并发安全的配额服务。多个线程、进程或执行节点竞争额度时，需要原子扣减、明确的分配协议或统一调度者。网络超时后是否归还额度也不能随意决定：发送端不知道请求有没有出去时，保守计数比“失败就退款”更可靠。

此外，一项任务的总预算与整个服务的重试预算是两回事。即便每个任务只增加三次请求，一万个任务一起重试也可能压垮依赖。生产系统还需要服务级限流、并发上限和过载保护。本例没有实现这些能力，不用单任务测试替代容量规划。

![入口固定截止点，排队准备、尝试与退避共同消耗窗口，下层只获得剩余时间](https://ai-knowledgepoints.cn/images/articles/agent-reliability-04/deadline-propagation.svg)

deadline 约束等待和新派发，预算约束末端调用数；任何一项耗尽都要停。

[查看竖版大图](https://ai-knowledgepoints.cn/images/articles/agent-reliability-04/deadline-propagation-mobile.svg)

## 4. 能不能重试，必须先于还剩几次

预算只是上限，不是业务许可。账户里还有额度，不代表可以再发一封邮件、再创建一张不可去重的工单，或者替用户重复一次已经取消的动作。

[RFC 9110 第 9.2.2 节](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2)约束了非幂等请求的自动重试：需要知道实际语义可幂等，或知道原请求未被应用，而不是看到通信错误就重发。HTTP 方法是线索，不足以取代工具自己的业务协议。

本例采用保守策略：只对声明为只读或具有明确幂等契约的操作进行自动重试，还要满足错误本身可重试。幂等操作沿用同一个稳定键和相同参数，不通过换键绕过旧结果。不可幂等写入一旦可能已被接收却没有确认，就保留 unknown，停止自动再执行。参数冲突等确定性错误同样不应被退避掩盖。

下载代码中的重试准入只保留两个条件；完整调用链还会检查时间、取消和派发额度：

```python
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 的取消文档](https://grpc.io/docs/guides/cancellation/)说明，取消通常不能直接打断应用处理器，处理器需要主动配合。由此也不能推导一个已经完成的数据库提交会被取消信号倒转。取消传播、业务回滚和补偿操作必须分别设计。

本文的取消实验启动真实子进程，向临时 SQLite 数据库提交一条合成效果。子进程提交后输出专供测试驱动使用的 `COMMITTED_BARRIER`，然后卡在标准输入的放行门前，尚未返回业务回执。父进程收到屏障信号后终止子进程，再用新连接读取最终行数。效果仍然存在。屏障是测试仪器的特权观测，不是被模拟的业务调用方已经收到成功回执；五秒等待上限只防止测试挂死，没有参与延迟计分。

这比只改一个取消标志更直接：即使本地工作进程真的停止，已经提交的效果也不会自动消失。但它仍然没有运行真实 RPC 取消或跨机器延迟信号实验，也不模拟机器断电、磁盘损坏或数据库丢写。调度模型与提交边界实验各证明一部分，不能拼成未经测试的端到端分布式保证。

因此，状态记录最好分开保存：执行为什么停止，以及业务结果能否确认。前者可能是 deadline、budget 或 cancelled；后者可能是 confirmed、not_started 或 unknown。只留一个红色 failed，会诱导后来的人认为业务一定未发生，进而点击重试制造第二次效果。

结果不明时，后续只读核验可以成为另一项有负责人、有期限的工作，但不能借“恢复”之名绕过用户的取消继续执行原动作。补偿又是新的业务操作，需要明确对象和授权。更不能把删除本地日志当成撤销远端事实。[第三篇](https://ai-knowledgepoints.cn/blog/agent-reliability-03-unknown-reconciliation)给出了查询强度、终态与人工对账的完整边界。

![远端已提交后调用方取消等待，停止原因与结果状态分别保留，已提交效果不能被本地取消抹去](https://ai-knowledgepoints.cn/images/articles/agent-reliability-04/cancellation-boundary.svg)

cancelled 描述控制流程；unknown 描述证据不足。它们并不矛盾。

[查看竖版大图](https://ai-knowledgepoints.cn/images/articles/agent-reliability-04/cancellation-boundary-mobile.svg)

## 7. 下载实验，检查派发记录和最终数据库

实验文件见：[retry_budget.py](https://ai-knowledgepoints.cn/examples/agent-reliability-04/retry_budget.py)、[test_retry_budget.py](https://ai-knowledgepoints.cn/examples/agent-reliability-04/test_retry_budget.py)、[run_experiment.py](https://ai-knowledgepoints.cn/examples/agent-reliability-04/run_experiment.py)、[results.json](https://ai-knowledgepoints.cn/examples/agent-reliability-04/results.json)、[README.md](https://ai-knowledgepoints.cn/examples/agent-reliability-04/README.md)。将文件放在同一空目录，使用 Python 3.10 或更新版本运行：

```bash
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 文档](https://docs.python.org/3/library/time.html#time.monotonic)说明 monotonic 时钟不会倒退，但参考点未定义，因此不能把一个进程随手产生的绝对读数当作跨机器通用时间。跨服务应通过经过设计的期限传播协议传递剩余量，并考虑传输与本地等待，不能直接复制本例整数。

其次是隐藏的派发。重定向、连接层透明重试、SDK 自动重试和代理重放，都可能让一次方法调用对应多次请求。如果计数位置在它们外面，文章里的派发上限就只约束了包装函数。上线前要用可观测日志或故障注入确认预算究竟覆盖到哪一层。

第三是任务重启。本例的控制预算是进程内对象，不提供崩溃恢复。若工作进程每次重启都得到一张新额度，真实任务仍可能无限重试。需要在持久任务状态中保存已消耗额度与原期限，定义恢复时的时间转换，并保留同一业务意图。至于多节点原子计数、恢复时钟和排队存储故障，必须另做实验。

第四是不可抢占的工作。一个阻塞客户端即使收到较短超时参数，也可能在 DNS、TLS、扩展模块或清理阶段继续运行。发起前检查 deadline 只能约束是否再开始，不能凭空强制中止已执行的同步代码。应核对客户端真正覆盖的阶段，区分调用方等待结束和后台活动结束，不能把本例的合作式逻辑时钟当成硬实时保证。

## 9. 最后留下的应该是一份能解释的停止记录

一次执行没有得到结果时，日志至少应能回答：原始业务意图是什么，谁负责重试，额度起点与当前消耗是多少，哪些层发生了实际派发，入口截止点如何传播，为什么停止，以及远端结果还缺什么证据。记录操作标识和必要摘要即可，不能为可观测性随手复制密钥、用户正文或敏感业务数据。

重试次数少，不一定可靠；重试次数多，也不一定积极。真正有用的约束是：在业务语义允许的范围内，用一份没有被偷偷复制的额度，追求一个没有被悄悄延后的终点。到点停下来后，系统仍要诚实保留它知道和不知道的部分。

本系列第四篇增加的是资源与期限边界，没有替代前三篇的事务幂等、接收端 fencing 和 unknown 对账。可继续阅读[第一篇](https://ai-knowledgepoints.cn/blog/agent-reliability-01-outbox-idempotency)、[第二篇](https://ai-knowledgepoints.cn/blog/agent-reliability-02-leases-fencing)与[第三篇](https://ai-knowledgepoints.cn/blog/agent-reliability-03-unknown-reconciliation)。进一步讨论可查看[知识星球](https://ai-knowledgepoints.cn/planet)，或通过[公众号入口](https://ai-knowledgepoints.cn/about#wechat)关注更新。

本文及原创图解、样例代码由 AI 辅助制作，并经过代码执行与内容核验。合成数据与逻辑时间仅用于机制实验，不构成生产系统可靠性承诺。
