# Agent 可靠执行实战（七）：没有重复执行，为什么还是办错了？

> 用 Python 标准库、SQLite 和真实子进程，复现旧计划在状态变化后照常执行的问题；验证双资源版本前置条件、回执优先、ABA、原子写入与崩溃恢复，区分幂等、业务新鲜度和授权。

- 作者：芝士AI吃鱼
- 发布日期：2026-10-15
- 主题：Agent、幂等、乐观并发控制、SQLite
- HTML 正文：[https://ai-knowledgepoints.cn/blog/agent-reliability-07-state-preconditions](https://ai-knowledgepoints.cn/blog/agent-reliability-07-state-preconditions)
- Markdown 永久链接：[https://ai-knowledgepoints.cn/blog/agent-reliability-07-state-preconditions/index.html.md](https://ai-knowledgepoints.cn/blog/agent-reliability-07-state-preconditions/index.html.md)

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

## 正文

Agent 认真查了名额表：A 组还有十个，B 组一个没有。它拟好计划，从 A 调三个给 B。排队、等待、进程恢复，一切都很稳。接收端还专门查了操作 ID，确认这件事从来没做过。

然后，它把三个名额调了过去。

唯一的小问题是：等待期间，另一笔业务已经给 B 增加了两个名额。这个动作没有重复，数据库也没有坏，可它依据的那张表已经旧了。

这是本篇的假设场景，不是作者的线上经历。它把可靠执行往前推了一步：**幂等能回答“是不是又做了一遍”，却不会回答“现在还该不该按这个计划做”。**

[第一篇](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-06-idempotency-retention)讨论历史记录如何保留。这一篇把目光从请求身份移到业务依据：计划看见了哪个版本，执行时是否仍然是那个版本。

资料核对日期：2026 年 10 月 15 日。业务是合成名额调拨；SQLite 读写、独立子进程暂停与退出、并发连接和故障恢复都实际运行。没有调用模型、生产数据库或真实业务 API，也不报告模型规划准确率、生产吞吐和网络延迟。

## 1. “第一次执行”并不等于“计划仍然成立”

先把业务规则说死，免得拿技术替业务做决定。本实验采用严格契约：计划必须携带源资源与目标资源的版本；接收时，只要任意一个版本变化，这份计划就不执行，调用方需要重新判断。

这是一种保守选择。B 增加两个名额之后，再给它三个，未必在所有业务里都错。若命令的真正含义是“无论当前分布如何，只要 A 够，就固定调三个”，那么目标版本变化可能不值得拒绝。这里刻意采用“仅在计划依赖状态未变化时执行”的语义，研究这种承诺怎样被兑现，而不是宣布所有写操作都要冻结整个世界。

计划由四个字段组成：src、dst、amount、expected。初始 A 的名额为 10、版本为 1；B 的名额为 0、版本为 1。计划要求把 3 个名额从 A 调给 B，并明确 expected 中 A、B 都是版本 1。

操作 ID 识别的是一次决定，版本识别的是这次决定所依据的状态。它们不是同一种编号。这里的 revision 也不是工作进程租约的 fencing token，更不是第六篇的批次 epoch。工作进程完全合法，仍然可能携带过期的业务计划。

[HTTP Semantics 的 If-Match 规定](https://www.rfc-editor.org/rfc/rfc9110.html#name-if-match)提供了一个熟悉的参照：条件请求可以把执行与当前表示的验证器绑定，帮助避免更新覆盖。本文没有实现 HTTP 服务或 ETag，也没有声称两个资源的版本向量等同于一个标准 If-Match 请求；这里只借它说明，前置条件应该属于接收协议，不能只存在于 Agent 的思考过程里。

![合成名额调拨中，B 的版本在计划与执行之间变化；事务外检查仍可能执行旧计划，事务内双资源版本前置条件会拒绝旧计划](https://ai-knowledgepoints.cn/images/articles/agent-reliability-07/stale-plan.svg)

图中的数值来自同一组可重跑的合成实验。拒绝旧计划是本文选择的业务契约，不是所有调拨业务的通用规则。

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

## 2. 去重入口里，先查历史，再问现在

给执行器加版本检查，看起来只要多写一个 if。最容易踩的坑，却是把它加在了最前面。

第一次执行成功后，A 从版本 1 变成 2，B 也从 1 变成 2。假如回执在返回途中丢了，调用方带着原来的命令重试。如果先查版本，接收端会说 stale。它把自己刚刚成功做过的事情，当成了一个尚未执行、依据过期的新计划。

因此执行顺序必须是：先用稳定操作 ID 查回执。找到同一命令，就返回已经保存的结果；找到同一 ID、不同命令，返回 conflict；只有没有回执的新操作，才检查业务状态。

```python
old = c.execute(
    'SELECT digest,result FROM receipt WHERE op=?', (op,)
).fetchone()
if old:
    if old[0] != digest:
        return {'status': 'conflict'}
    return {'status': 'replayed', 'result': json.loads(old[1])}
```

这里的 replayed 表示“返回历史结果”，并不承诺资源现在仍然保持当时的样子。业务状态可能早已被后续操作继续改变。若界面需要实时状态，应另行查询，并把历史执行结果与当前快照分别展示。

命令摘要包含源、目标、数量和完整 expected。把旧计划的版本偷偷刷新，再沿用原来的操作 ID，会得到 conflict；这很重要，否则一个“重试”就可能悄悄变成另一个决定。

代码只接受固定结构、两个不同的非空资源名、有限范围的正整数数量与版本，拒绝布尔值冒充整数。序列化时排序键，再计算 SHA-256；这是受限命令格式下的确定性比较，不是跨语言通用 JSON 规范化标准，也不是签名。讨论按摘要没有碰撞展开。调用者可信，不包含身份、租户隔离和权限校验。

## 3. 检查通过后再放开锁，等于给旧计划留后门

第一组错误对照专门做了一个看似谨慎的执行器。它先查版本，确认 A、B 都匹配；然后退出读取阶段，暂停；恢复时再开启写事务，完成调拨。

父进程等它输出 READY 后，先给 B 增加两个名额，再发送 GO。子进程继续执行，最后 A 为 7、B 为 5，业务效果记录为一条。它没有重复执行，开始时也确实检查过。问题是检查与写入之间没有共同的原子边界。

安全入口把回执读取、两项版本检查、余额约束、两行资源更新、效果与回执写入全部放进同一个 BEGIN IMMEDIATE 事务。在这里，其他写入只能排在这笔事务之前或之后。

```python
with connect(path) as c, transaction(c):
    # 先处理已存在的回执
    rows = read_source_and_destination(c, command)
    if any(rows[name][1] != revision
           for name, revision in command['expected'].items()):
        result = {'status': 'stale', 'observed': observed_revisions}
    else:
        # 检查业务约束，再在同一事务内完成两行更新与回执
        ...
```

上面是说明顺序的伪代码；可下载的 preconditions.py 包含实际 SQL 和完整分支，没有未实现的业务函数。读版本时必须覆盖 A 和 B，不能只给扣减的一方加检查，却把目标一方当作与计划无关。

[SQLite 事务文档](https://www.sqlite.org/lang_transaction.html)说明，同一数据库同时只能有一个写事务，BEGIN IMMEDIATE 会立即尝试开启写事务。本例借助这一点，在同一个 SQLite 文件内排序检查与修改。它不是利用两个彼此独立的 UPDATE 自动获得跨数据库原子性，也不是给远端 HTTP 请求套上了事务。

安全实验先让子进程停在入口外，父进程改变 B 后才放行。它看到实际版本 2，与预期 1 不同，保存 stale 结果，没有调拨。最后 A 为 10、B 为 2，业务效果数为零。这与错误对照使用相同的初始值与并发变更，区别就在是否在真正写入的事务内重查。

## 4. 版本不能只是当前值换了个名字

再做一个容易被漏掉的反例。Agent 看到 A 有十个名额。另一个操作拿走一个，后来又放回一个。Agent 重新查看，A 还是十个。

若用“数量相同”判断没有变化，过程里的两次业务变更就消失了。我们的 change 入口每次写入都增加 revision，所以 A 的数量回到 10 时，版本已经从 1 走到 3。旧计划返回 stale。

这就是这里所讨论的 ABA：状态值看起来回到了原点，历史却没有回来。严格版本检查能感知经过了变化，但代价是可能拒绝一个业务上仍然可接受的计划。若只关心余额充足这一条不变量，可以选择在事务内重新检查余额，而不是坚持整个版本不变。两者是不同的业务契约，不能靠换一个更响亮的技术名称消除取舍。

版本也不是自动可靠的。所有参与写入的入口都必须递增它，不能有后台 SQL 只改名额、不改 revision。删除资源后用相同名字和版本 1 重建，会让旧计划重新匹配；恢复旧数据库备份也可能把版本倒退。本样例没有资源删除重建协议，没有备份恢复协调，普通入口也不提供这些操作。真实系统需要稳定对象身份与不可回退的代次，或其他明确的重建与恢复方案。

整数也需要边界。代码在版本达到 SQLite 有符号 64 位整数上限时停止执行，不让它溢出后改成另一种数值语义；目标名额溢出同样拒绝。测试故意把数据推进边界，验证拒绝分支。它们检查的是机制，不代表现实系统真的运行了那么多次写入。

## 5. 拒绝也要有记忆，重规划不能伪装成重试

本例把 stale、insufficient、missing_resource 等业务拒绝保存为终态回执。对于同一 ID、同一命令，后续请求返回原来的拒绝。状态之后即便又变化，也不会让这一操作突然重新获得执行资格。

这不是唯一可能的接口设计。也可以把某些失败定义为可重试的非终态，但那就必须明确哪些状态会被缓存、哪些不会，以及调用方如何区分。本文选择保存业务拒绝，是为了让“一次决定得到一个稳定结果”可检查。格式错误会在事务前抛出异常，未保存；数据库锁等待超时等异常也不能凭空算作一个已记录的业务结果。

在实验里，旧计划收到 stale 后，重新读取得到新计划。沿用 job-1 提交新版本，返回 conflict；以 job-2 作为另一项明确决定提交，才成功调拨。这不是建议自动生成新 ID 一路试到成功。新版本可能改变原来计算的数量、目标和业务理由，应该回到规划或审批流程重新判断。

如果真实场景要求人工批准，这里保存的版本只能说明业务依据是否变化，不能证明批准人是谁、权限是否有效，或批准的到底是哪份计划。授权信息与计划内容如何绑定，是另一个需要实现的安全边界。本篇没有把“通过版本检查”包装成“已经获批”。

回执也会增长。它们的保留窗口继续受到[第六篇](https://ai-knowledgepoints.cn/blog/agent-reliability-06-idempotency-retention)的约束。本例没有实现回执清理；不能一边依赖历史结果稳定，一边把它们当临时缓存随意删除。

![执行器先查历史回执，再检查双资源版本，然后把资源更新、效果和回执放在同一个事务提交](https://ai-knowledgepoints.cn/images/articles/agent-reliability-07/atomic-preconditions.svg)

顺序同样是协议的一部分：已完成操作读历史，新操作才检查当前状态；所有业务写入与回执共同提交。

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

## 6. 扣完 A 就崩溃，B 与回执会怎样

实验用独立 Python 子进程执行命令，并在三个位置调用 os._exit。退出码分别为 71、72、73，恢复检查重新打开数据库读取，不依赖进程最后留下的成功日志。

| 故障位置 | 恢复后的 A / B | 效果 / 回执行数 | 原命令重试 |
| --- | --- | --- | --- |
| 已扣 A，尚未加 B | 10 / 0 | 0 / 0 | 执行一次，得到 7 / 3 |
| 已写完效果与回执，尚未提交 | 10 / 0 | 0 / 0 | 执行一次，得到 7 / 3 |
| 已提交，尚未回复 | 7 / 3 | 1 / 1 | 返回历史结果，不再调拨 |

前两行回滚整个未提交事务，第三行保留已提交事务。另一个场景把 stale 拒绝提交后立即退出，重试照样读到历史拒绝，效果数仍为零。

测试还用 SQLite 触发器分别阻止目标资源更新和回执插入。两种情况下，源资源的扣减、目标增加、效果与回执都不会留下半成品。触发器只用于注入错误，没有被装进真实业务服务。

并发部分使用不同连接：八个线程对同一份快照提交十六个不同操作 ID，实测一项 applied、十五项 stale，业务效果只有一条；十六次使用同一 ID 时，一次 completed、十五次 replayed。前者检查计划相互竞争，后者检查同一决定重复到达。它们不能互相替代，也不是吞吐或生产负载基准。

管道实验有 READY/GO 握手，没有用 sleep 猜测子进程是否到达关键位置。十五秒是防止测试挂死的看门狗，不是测得的业务响应时间。进程退出验证也不等于断电、磁盘损坏、多节点分区或跨服务事务验证。

## 7. 依赖没写进契约，再正确的事务也不知道要保护它

为了防止这套代码看起来无所不能，实验还放了一条明确会通过的反例。计划只携带 A、B 的版本；另一个写入把 C 的名额从 5 改为 4。A、B 没变，计划照常 applied。

对本例定义的双资源调拨，这没有违约。可如果模型实际上还参考了 C 来决定“该不该调拨”，C 就是遗漏的业务依赖。代码不会读模型的心，也不会因为用了事务就自动把这条依据补进来。

类似地，“所有资源的总名额不得超过某个上限”是一个聚合约束，不一定能靠只检查两行版本覆盖；查询依赖新增行是否存在时，现有行的版本集合也可能不够。需要把对应的业务不变量、集合版本或合适的隔离机制纳入同一权威写入路径。本文没有实现通用读集追踪、范围查询保护或跨服务快照。

这也解释了为什么版本检查不应该从提示词里随便摘出来。规划阶段需要明确依赖，执行协议需要知道哪些依赖可验证，接收端需要在实际修改时做验证。少一层，就可能得到一份格式完整、语义却不完整的 expected。

## 8. 下载后重跑，再决定自己的命令需要哪种承诺

把五个文件下载到同一空目录：[preconditions.py](https://ai-knowledgepoints.cn/examples/agent-reliability-07/preconditions.py)、[test_preconditions.py](https://ai-knowledgepoints.cn/examples/agent-reliability-07/test_preconditions.py)、[run_experiment.py](https://ai-knowledgepoints.cn/examples/agent-reliability-07/run_experiment.py)、[results.json](https://ai-knowledgepoints.cn/examples/agent-reliability-07/results.json)、[README.md](https://ai-knowledgepoints.cn/examples/agent-reliability-07/README.md)。只需要 Python 3.10 或更新版本，不安装第三方依赖：

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

本次实跑环境为 Linux、Python 3.12.14、SQLite 3.53.1。31 项 unittest 通过；11 组具名场景重新创建数据库并执行，完整输出与 results.json 一致。结果保留状态、退出码、资源数量、版本、效果与回执行数，不混入随机目录、PID 或耗时。各组实验用临时数据库，错误对照明确标为 unsafe，不会被安全入口调用。

这里采用同机 SQLite WAL 与 synchronous=FULL；没有做 HTTP/ETag 集成、模型规划、认证授权、跨库原子提交、备份恢复或 Windows 管道验证。SQLite 的并发控制条件不能直接推广成某个远端服务的保证。尤其是“本地查版本，再发邮件”，邮件并不会跟着本地事务回滚。

如果任务只是无条件追加一条独立记录，稳定幂等键可能已覆盖主要风险。如果动作取决于当前余额，可以在写事务中验证余额不变量。如果整个计划确实依赖一组旧状态，就需要把这组依赖变成可核验前置条件，并接受冲突后重新决策的成本。

回到开头那张名额表。执行器没有义务替旧计划找借口，它需要能说清楚三件事：这是不是已经做过的决定；如果还没做，依据是否仍成立；如果依据变了，谁来作出下一项决定。

把“重试成功”当作终点，很容易把一份过期计划执行得格外可靠。

系列讨论可查看[知识星球](https://ai-knowledgepoints.cn/planet)，或通过[公众号入口](https://ai-knowledgepoints.cn/about#wechat)关注更新。RAG 第六篇仍等待真实生成输出与细粒度审计证据，不用本例代替模型评测。

本文、原创图解与样例代码由 AI 辅助制作，并经过执行与内容核验。合成业务和本地事务实验不构成生产可靠性或授权保证。
