Agent 认真查了名额表:A 组还有十个,B 组一个没有。它拟好计划,从 A 调三个给 B。排队、等待、进程恢复,一切都很稳。接收端还专门查了操作 ID,确认这件事从来没做过。
然后,它把三个名额调了过去。
唯一的小问题是:等待期间,另一笔业务已经给 B 增加了两个名额。这个动作没有重复,数据库也没有坏,可它依据的那张表已经旧了。
这是本篇的假设场景,不是作者的线上经历。它把可靠执行往前推了一步:幂等能回答“是不是又做了一遍”,却不会回答“现在还该不该按这个计划做”。
第一篇处理回执丢失,第二篇约束旧工作进程,第六篇讨论历史记录如何保留。这一篇把目光从请求身份移到业务依据:计划看见了哪个版本,执行时是否仍然是那个版本。
资料核对日期: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 规定提供了一个熟悉的参照:条件请求可以把执行与当前表示的验证器绑定,帮助避免更新覆盖。本文没有实现 HTTP 服务或 ETag,也没有声称两个资源的版本向量等同于一个标准 If-Match 请求;这里只借它说明,前置条件应该属于接收协议,不能只存在于 Agent 的思考过程里。
2. 去重入口里,先查历史,再问现在#
给执行器加版本检查,看起来只要多写一个 if。最容易踩的坑,却是把它加在了最前面。
第一次执行成功后,A 从版本 1 变成 2,B 也从 1 变成 2。假如回执在返回途中丢了,调用方带着原来的命令重试。如果先查版本,接收端会说 stale。它把自己刚刚成功做过的事情,当成了一个尚未执行、依据过期的新计划。
因此执行顺序必须是:先用稳定操作 ID 查回执。找到同一命令,就返回已经保存的结果;找到同一 ID、不同命令,返回 conflict;只有没有回执的新操作,才检查业务状态。
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 事务。在这里,其他写入只能排在这笔事务之前或之后。
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 事务文档说明,同一数据库同时只能有一个写事务,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 一路试到成功。新版本可能改变原来计算的数量、目标和业务理由,应该回到规划或审批流程重新判断。
如果真实场景要求人工批准,这里保存的版本只能说明业务依据是否变化,不能证明批准人是谁、权限是否有效,或批准的到底是哪份计划。授权信息与计划内容如何绑定,是另一个需要实现的安全边界。本篇没有把“通过版本检查”包装成“已经获批”。
回执也会增长。它们的保留窗口继续受到第六篇的约束。本例没有实现回执清理;不能一边依赖历史结果稳定,一边把它们当临时缓存随意删除。
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、test_preconditions.py、run_experiment.py、results.json、README.md。只需要 Python 3.10 或更新版本,不安装第三方依赖:
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 的并发控制条件不能直接推广成某个远端服务的保证。尤其是“本地查版本,再发邮件”,邮件并不会跟着本地事务回滚。
如果任务只是无条件追加一条独立记录,稳定幂等键可能已覆盖主要风险。如果动作取决于当前余额,可以在写事务中验证余额不变量。如果整个计划确实依赖一组旧状态,就需要把这组依赖变成可核验前置条件,并接受冲突后重新决策的成本。
回到开头那张名额表。执行器没有义务替旧计划找借口,它需要能说清楚三件事:这是不是已经做过的决定;如果还没做,依据是否仍成立;如果依据变了,谁来作出下一项决定。
把“重试成功”当作终点,很容易把一份过期计划执行得格外可靠。
系列讨论可查看知识星球,或通过公众号入口关注更新。RAG 第六篇仍等待真实生成输出与细粒度审计证据,不用本例代替模型评测。
本文、原创图解与样例代码由 AI 辅助制作,并经过执行与内容核验。合成业务和本地事务实验不构成生产可靠性或授权保证。

