# Agent 可靠执行实战（五）：把派发预算写进账本，为什么不能失败就退款？

> 用 Python 标准库、真实子进程退出和 SQLite 并发实验，把派发预算从内存搬到持久预留账本；区分预留、实际派发与业务效果，验证重启不补满、缺回执不退款，以及保守耗尽的代价。

- 作者：芝士AI吃鱼
- 发布日期：2026-10-13
- 主题：Agent、AI工程、可靠性、SQLite
- HTML 正文：[https://ai-knowledgepoints.cn/blog/agent-reliability-05-durable-dispatch-reservations](https://ai-knowledgepoints.cn/blog/agent-reliability-05-durable-dispatch-reservations)
- Markdown 永久链接：[https://ai-knowledgepoints.cn/blog/agent-reliability-05-durable-dispatch-reservations/index.html.md](https://ai-knowledgepoints.cn/blog/agent-reliability-05-durable-dispatch-reservations/index.html.md)

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

## 正文

执行器给一项任务发了三张调用券。第一次用完，进程崩溃。第二个进程来接班，从配置里读出“最多三次”，又领到三张。面板上每个进程都很守规矩，外部工具却一直收到请求。

这是一个假设场景，不是作者的线上事故。[第四篇](https://ai-knowledgepoints.cn/blog/agent-reliability-04-deadlines-retry-budgets)把嵌套调用放进同一份时间与次数预算，但它的计数器在进程内。重启后若重新构造对象，那张“最多三次”的限额表就失去了记忆。

本篇只补这个缺口：**同一个业务意图跨越进程退出、重新启动和并发竞争后，怎样仍然守住派发上限？** 答案需要一份持久预留账本，也需要承认一个不太讨喜的代价：额度可以花掉，事情却还没发出去。

资料核对日期：2026 年 10 月 13 日。业务内容是合成工单；子进程、SQLite 事务、突然退出和恢复检查是真实执行。没有调用大模型、真实业务服务或网络 API，不把本机实验包装成分布式配额服务。

## 1. 先别给计数器起一个会撒谎的名字

把一个字段叫作 sent_count，读者很容易以为它准确记录“已经发出的次数”。可写数据库和发送请求不是同一个原子动作。

先发送再加一：接收端已经处理，发送端在加一之前退出，恢复后少算了一次。先加一再发送：数据库已经提交，进程在发送前退出，恢复后多算了一次。再加一条 sent=true 日志，也只是把这个窗口搬到下一行。

所以本例明确记三种不同数字：

- `spent`：已提交的预留数，包含首次请求；它是保守消耗，不是实际发送证明
- `receiver_calls`：测试仪器观察到的接收端入口次数，包含同键重放；它是本地函数边界计数，不是网络抓包
- `effects`：接收端最终业务表行数，受业务幂等规则影响

在本文受控发送入口、每张新预留至多调用一次接收端、没有隐藏重试的前提下，要守住的是：接收端入口次数不超过预留数，预留数不超过上限。三个数字不必相等。

例如预留两次后都在发送前退出，结果可以是 2、0、0。对同一意图派发两次，接收端去重，结果可以是 2、2、1。业务只发生一次，不代表调用只发生一次；调用没有发生，也不代表额度没有消耗。

![发送端先原子提交预留，再进入接收端，最后记录确认；预留后发送前退出会消耗额度但没有业务效果](https://ai-knowledgepoints.cn/images/articles/agent-reliability-05/reservation-boundary.svg)

预留是派发的前置条件，不是派发已经发生的证据。三个步骤没有共同提交按钮。

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

## 2. 原子操作的单位，是上限检查加整张预留

发送端使用两张表。task 保存稳定意图 ID、固定参数、工具安全类型、上限 cap、已消耗 spent 与确认标志。reservation 为每次获准尝试保存独立序号和确认状态。

一次申请必须在同一个写事务里完成：读取任务、检查能否继续、增加 spent、插入 reservation，再提交。不能先查出“还有一张”，释放事务后再各自减一；两个工作进程可能同时拿着那句“还有一张”去发送。

核心实现如下，完整代码还会先检查已确认、非幂等 unknown 和预算耗尽：

```python
with db(Path(root) / 'sender.db') as c, tx(c):
    row = c.execute('SELECT * FROM task WHERE id=?', ('op-1',)).fetchone()
    # 准入条件检查也在本事务内
    seq = row['spent'] + 1
    c.execute('UPDATE task SET spent=? WHERE id=?', (seq, 'op-1'))
    c.execute('INSERT INTO reservation(task_id,seq) VALUES(?,?)', ('op-1', seq))
    return {'seq': seq, 'payload': row['payload'], 'safety': row['safety']}, 'reserved'
```

这里的 return 不会绕过上下文管理器：`tx` 在函数真正返回前执行 COMMIT。若提交失败，调用方拿不到可用于发送的返回值。数据库约束同时要求 `0 <= spent <= cap`，预留主键为意图 ID 与序号。约束是最后一道检查，不能取代事务里的准入逻辑。

[SQLite 事务文档](https://www.sqlite.org/lang_transaction.html)说明 `BEGIN IMMEDIATE` 会立即开始写事务；遇到其他写事务时仍可能忙等待或返回 SQLITE_BUSY。本文连接设有十秒等待上限。等待结束仍不能写入就报错，不跳过预算发请求，也不因为拿不到锁就改用内存计数。十秒只是样例的数据库竞争配置，不是 Agent 的业务截止时间。

本例每次恢复仍用同一个 `op-1`。重复初始化只接受同一参数、工具安全类型与 cap，不重置 spent；换参数、改上限都作为冲突拒绝。生产不一定永远禁止预算变更，但变更必须是明确决策，有范围和记录，不能作为重启函数的隐含副作用。

## 3. 旧预留不能当作一张可无限复印的通行证

持久化以后，另一种错误很容易出现：恢复扫描看到第 1 张预留未确认，于是拿第 1 张再次发送。又退出，再用第 1 张发送。账面永远只扣一张，外部请求却可以无限多。

因此，本例的 reservation 是消耗记录，不是可反复领取的待发队列。`reserve()` 只返回刚刚成功提交的新预留；`attempt()` 每次至多进入接收端一次，恢复不会从旧行重建发送许可。需要重新派发时，先重新通过业务安全判断，再领取新的序号。

稳定意图 ID 与尝试序号必须分开。第一次和第二次业务重放仍是 `op-1`，接收端才能返回原工单；预算预留则从 1 变成 2，才能承认调用资源又消耗了一次。把意图 ID 换掉，会破坏去重；把尝试序号重复使用，会掩盖调用数。

这条上界依赖受控入口。公开一个 receive 函数供任意代码绕过预算，或把同一张预留复制给两个发送线程，都不在保证范围内。示例函数只是可信测试组件，没有认证、票据签名或不受信任客户端协议。生产需要收紧真正能调用工具的路径，并确认 SDK、代理与重定向没有藏着额外请求。

此外，预算不是租约，也不是 fencing。已经取得预留的旧进程可以暂停，稍后恢复发送；另一个进程确认任务后，本例只会阻止新的预留，不会撤销旧进程手里的许可。因此，“已经确认”不等于“所有在途请求终结”。接收端去重仍有必要；若需要严格撤销旧写入，应回到[第二篇](https://ai-knowledgepoints.cn/blog/agent-reliability-02-leases-fencing)的接收端边界，而不是删掉一行预算记录。

## 4. 真正退出四次，观察不同的账

本次环境为 Python 3.12.14、SQLite 3.53.1。驱动创建临时目录，启动全新的 Python 子进程，在明确边界调用 `os._exit`，再启动另一个进程恢复。退出码 71、72、73、74 分别标记下面四个故障点。

[Python 的 os._exit 文档](https://docs.python.org/3/library/os.html#os._exit)说明它直接终止进程，不经过普通清理处理器。因此，提交前退出的恢复不是 finally 替我们撤销，而是依赖 SQLite 对未提交事务的处理。实验没有模拟断电、磁盘损坏或文件系统违约。

以下四组上限均为 2，接收端具有事务幂等契约。表中的数字来自真实运行的 results.json；“恢复后”包括新进程的一次尝试或停止判断。

| 退出位置 | 恢复前预留 / 入口 / 效果 | 恢复后预留 / 入口 / 效果 | 为什么 |
| --- | --- | --- | --- |
| 增加计数并插入行，但尚未 COMMIT | 0 / 0 / 0 | 1 / 1 / 1 | 未提交预留回滚，新进程可重新申请 |
| 预留已 COMMIT，尚未调用接收端 | 1 / 0 / 0 | 2 / 1 / 1 | 第一次预留不退款，新派发要新预留 |
| 接收端效果已 COMMIT，尚未记录确认 | 1 / 1 / 1 | 2 / 2 / 1 | 原意图重放花新额度，接收端返回旧效果 |
| 发送端确认已 COMMIT，随后退出 | 1 / 1 / 1 | 1 / 1 / 1 | 新进程看到 confirmed，不申请也不发送 |

接收端入口日志先独立提交，然后在另一个事务里做业务检查、去重与写入。这样，参数冲突等拒绝也能计入入口次数。入口日志属于测试仪器，不是发送端已经收到的回执；代码不读取它来决定是否退款。它与业务表分开提交，也不意味着入口一被记录就必然产生业务效果。

发送端和接收端使用两个数据库文件，业务去重记录与业务效果在接收端同一事务提交，没有把两端账本放进一个总事务。这让两道提交之间的缺口实际存在。但它们仍在同一台机器，不是两个独立网络服务，也不能据此宣称覆盖网络分区或独立主机故障。

## 5. 八个进程抢三张券，检查的不只是最后有没有重单

并发场景启动八个工作进程。每个先输出 READY 并等驱动放行；驱动统一放行后，各自申请预留。拿到预留的进程输出 RESERVED，停在另一个发送门前。没有拿到的进程返回预算耗尽。驱动收齐八个准入结果后，才放行成功者进入接收端。

这样安排是为了确保竞争发生在确认之前，避免某个进程过早完成，让其他人因 confirmed 停止，掩盖预算到底能否挡住竞争。没有靠 sleep 猜测先后。十五秒超时只用于防止测试挂死，不参与任何业务耗时统计。

实际结果是：3 个预留成功、5 个预算耗尽；预留序号为 1、2、3；接收端入口 3 次，业务效果 1 条。三个成功者都取得相同的工单 ID。这同时检查了两个互不替代的事实：发送端把可见尝试限制在三次，接收端把同一业务意图的效果去重到一条。

这里多个工作进程共享的是本地 SQLite WAL。按照 [SQLite WAL 文档](https://www.sqlite.org/wal.html)，该模式要求参与进程在同一主机，不能拿这个文件直接当网络文件系统上的跨机配额服务。实验启用 WAL 与 synchronous=FULL，但没有测量吞吐、公平性或生产争用尾延迟，更没有建立共识服务。读者可以从中复用事务不变量，不能原封不动复制部署结论。

## 6. 没回执就退款，限额会怎样失效

反例把 cap 设为 1。每次接收端提交之后，子进程立刻退出，发送端没有确认。错误恢复逻辑把未确认预留删除，再把 spent 清零，然后重复这个流程三次。

最终账面是 spent=0、cap=1，接收端却被进入了 3 次。业务效果依旧只有 1 条，因为接收端正确去重。这个对照特意说明：**幂等可以保护业务结果，不能替错误预算账本控制请求压力。**

危险退款只写在实验驱动的独立反例里，安全模块没有退款 API。不是所有预留系统都永远不能释放未用额度；但释放需要足以排除旧尝试稍后发送的协议，并且原子处理释放与发送权失效。仅仅超时、租约到期、没查到效果、没看到确认，都不能自动提供这个证明。

即便确定一个进程已经结束，生产还可能存在它此前交给代理、消息队列或后台线程的工作。把“本地 PID 不在了”扩大解释成“世界上没有请求在途”，仍然是证据跳跃。本例直接函数调用，故障点由测试仪器全知控制；不能把这份测试特权冒充真实恢复器普遍拥有的判断能力。

![恢复时先读取持久状态；已确认则停止，未知非幂等操作转对账，可安全重放仍需新预留，耗尽不得重置或退款](https://ai-knowledgepoints.cn/images/articles/agent-reliability-05/recovery-decisions.svg)

剩余额度只是资源条件。业务许可与结果证据仍然先于再次执行。

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

## 7. 最难接受的通过项：额度耗尽，事情一次都没做

另一组实验上限为 2，连续两次都在预留提交后、接收端调用前退出。第三个新进程返回 budget_exhausted。最终预留 2 次、入口 0 次、效果 0 条。

这不是测试失败，而是本实现明确选择的安全与活性取舍。它能限制最多派发多少次，不能保证至少派发一次，更不能保证最终业务成功。预算越紧，保守占用越容易导致“什么也没做成就停了”；预算越宽，允许消耗的资源也越多。不能用一个未经测量的统一次数替业务做决定。

任务停止后应保留原因和证据。在这个样例里，spent 已满与 confirmed=false 可以同时出现；旧预留 ack=0 只能说明缺确认，不能单独判断业务发生与否。要继续，应先核验结果并由业务决策明确批准新的资源或动作，不能换个任务 ID 悄悄拿一整份新额度。

非幂等工具还有更严的门。本例上限为 3，第一次效果提交后回执丢失，恢复直接返回 unknown_requires_reconciliation，剩下两张券也不能用。甚至预留后尚未发送就退出，恢复器看见相同的未确认状态，也保守停止。这再次区分测试仪器知道的故障点，与业务恢复器真的持有的证据。

如何从 unknown 获得可负责的结论，仍沿用[第三篇](https://ai-knowledgepoints.cn/blog/agent-reliability-03-unknown-reconciliation)的终态查询、封口与人工对账边界。不能把“预算够用”写成“允许再执行”。

## 8. 下载后，重跑相同的失败边界

五个文件放在同一空目录，使用 Python 3.10 或更新版本，无第三方依赖：[durable_budget.py](https://ai-knowledgepoints.cn/examples/agent-reliability-05/durable_budget.py)、[test_durable_budget.py](https://ai-knowledgepoints.cn/examples/agent-reliability-05/test_durable_budget.py)、[run_experiment.py](https://ai-knowledgepoints.cn/examples/agent-reliability-05/run_experiment.py)、[results.json](https://ai-knowledgepoints.cn/examples/agent-reliability-05/results.json)、[README.md](https://ai-knowledgepoints.cn/examples/agent-reliability-05/README.md)。

```bash
python3 test_durable_budget.py
python3 run_experiment.py --check
```

本次 24 项 unittest 全部通过，8 组具名场景重新运行后与完整存档一致。测试包括零上限、非法上限、配置冲突、插入失败回滚、真实退出恢复、并发额度竞争、参数冲突计数、错误确认对象、非幂等 unknown 和危险退款反例。存档不包含 PID、临时目录或墙钟耗时，比较的是有确定含义的状态与计数。

这套测试实测于 Linux。管道握手使用 selectors，未验证 Windows 管道兼容性；所有数据库写在临时目录，不读取凭证或用户数据。计数断言只覆盖列出的交错与受控入口，不代表已穷尽所有调度、故障或真实 SDK 行为。

本篇刻意没有添加持久 deadline。第四篇的逻辑 tick 不能直接写入数据库后当作跨进程、跨机器永久有效的期限。如何保存原期限、处理时钟域与重启后的剩余窗口，仍需独立协议与测试。预算持久化完成，不会顺便解决时间恢复。

## 9. 账本必须比进程活得久，也必须记得自己不知道什么

迁移到真实系统时，至少要追问：所有发送入口是否真的先预留？同一预留能否被两个执行者复用？配置更新、任务克隆、旧备份恢复或清理历史行，会不会让 spent 倒退？确认依据是否来自可信业务回执？旧进程和代理是否仍可能发出已经获准的请求？

这些问题里，额度倒退会破坏资源上界，换意图会破坏业务去重，伪造确认会制造假成功，擅自退款会把未知藏进漂亮的数字。不能只审查 SQL 是否原子，就把整个执行器盖章为可靠。

本系列第五篇新增的是跨进程的保守派发预算。第一篇的接收端幂等、第二篇的 fencing、第三篇的 unknown、第四篇的期限与取消仍然各管一段边界。可靠恢复的目标，不是让重启之后一切看起来像新的一样，而是让新进程忠实接过旧进程留下的责任。

进一步讨论可查看[知识星球](https://ai-knowledgepoints.cn/planet)，或通过[公众号入口](https://ai-knowledgepoints.cn/about#wechat)关注更新。

本文及原创图解、样例代码由 AI 辅助制作，并经过代码执行与内容核验。合成业务场景与本地事务实验不构成生产系统可靠性承诺。
