返回文章列表

Agent 可靠执行实战(二):租约过期了,旧进程为什么还能写?

芝士AI吃鱼2026年10月7日12 分钟阅读阅读统计加载中

两个 Agent 工作进程,领到了同一份任务。新来的已经交卷,旧的突然醒了,把文件覆盖回自己的版本。调度面板却一片祥和:任务成功,负责人是新进程。

不是面板撒谎。它只管住了自己的那本账。

这是本篇的假设场景。下面用真实子进程和 SQLite 把它跑出来:旧进程 A 领取任务后暂停,新进程 B 在租约到期时接管、提交结果,随后 A 恢复执行。没有调用大模型,也没有连接真实业务服务;合成的是文档内容,真实运行的是进程、事务和最后留下的数据。

第一篇解决“工具执行了,回执丢了”的重复调用。本篇进一步处理接管后的旧写入。资料核对日期:2026 年 10 月 7 日。这是机制实验,不是生产吞吐量、跨机房容灾或模型能力评测。

1. 租约回答谁该干活,不保证谁还能动手#

租约给任务一个有效期。A 在逻辑时刻 0 拿到任务,截止时刻为 10;到 10 还没有完成,就允许 B 接管。这里有效区间是半开区间:now < deadline 才有效,等于截止时刻已经过期。

A 暂停时,并不会顺带撤销它已经持有的文件句柄、请求参数和工具连接。等它恢复,之前准备好的写请求照样可能发出去。哪怕 A 在写之前检查过租约,检查与实际写入之间仍可以再次暂停。因此,“写前先看一眼”不能作为最终防线。

Martin Kleppmann 对分布式锁的分析讨论了客户端暂停与延迟请求造成的过期写入,并提出由被保护的存储检查 fencing token。本文只复现其中的旧写入问题,不据此评判某个锁产品在所有场景下是否可用。

如果任务只是重复生成一份无副作用的临时草稿,多算一次可能只是浪费资源。若任务要覆盖发布文件、改库存或写共享状态,过期执行就会变成正确性问题。是否需要隔离,先看结果写到哪里。

2. 给每次接管一个代次,不只记名字#

实验对每个资源分别保存 owner、epoch、deadline。第一次领取得到 epoch 1,接管时变成 2,再接管继续增加。递增发生在 BEGIN IMMEDIATE 事务内,读取当前代次与写入新租约不能被另一笔写事务插入。

SQLite 事务文档说明了 IMMEDIATE 事务提前取得写事务的行为。本例使用这一能力串行化本机数据库写入,没有把一个 Python 锁变量当作分布式租约服务。真实多节点系统还需要可靠的权威状态、故障恢复和时间语义,这些不在本例实现范围内。

只检查 owner 不够:同一个名为 A 的进程重新领取任务,也可能已经是另一代。旧请求必须带旧 epoch;新请求即使名字相同,也不能替旧请求背书。

发送端完成任务时用条件更新:资源、owner、epoch 都匹配,且租约未过期,才写入结果。完整实现见文末下载,核心条件如下:

UPDATE lease SET result = ?
WHERE resource = ? AND owner = ? AND epoch = ? AND deadline > ?;

受影响行数是 0,就意味着本次完成记录没有被接受。不要把它强行改成无条件更新,更不要因为“工作确实做完了”就忽略失去所有权。

但这条 SQL 仍然只保护 lease 表。A 若先覆盖业务文档,再回来发现完成记录写不进去,文档已经受损。于是要把第二道检查放到真正接收写入的位置。

发送端按owner和epoch拒绝过期完成记录,接收端按已见最高epoch隔离旧文档写入,两道检查不能互相替代
调度账正确,不代表业务结果正确。检查要落到各自真正提交的位置。 查看大图 ↗

3. fencing 必须和业务写入一起提交#

接收端为每个资源保存已经接受的最高 epoch。收到写入时,在同一个事务里比较代次、更新代次并写入内容:低于已见代次就拒绝;高于它可以接受。不能先查询代次、释放事务,再单独写文件,否则新的代次可能在这两步之间到达。

本例额外规定:同一 epoch 只允许一个替换值。相同代次、相同值返回 replay;相同代次、不同值返回 conflict。这是为了让重放行为可检查的收窄协议,不是通用工具接口。如果一个任期内需要多次更新,要另行定义操作 ID、顺序和冲突规则。

with tx(path) as c:
    row = c.execute(
        'SELECT * FROM sink WHERE resource=?', (resource,)
    ).fetchone()
    if row and epoch < row['epoch']:
        return 'stale'
    # 其余代次检查,以及 epoch / value 更新,也在这个事务里

这里的 token 不是密码。实验假设只有可信的租约权威能够签发正确代次,没有实现认证或防伪。如果任何调用方都能随口报出一个巨大的 epoch,它可以挡住后续合法写入。资源范围也必须一致:文档甲的第 99 代,不能拿来否定文档乙的第 1 代。

4. 让 A 真暂停,让 B 真接管#

实验驱动使用 Python multiprocessing 的 spawn 模式,A 与 B 是独立子进程。A 提交领取事务后发出 ready 信号,然后等待 resume;主进程收到信号才启动 B,等待 B 提交并退出后,才唤醒 A。没有用 sleep 猜测哪个进程跑得快。

租约的时间用显式整数 tick 控制。0、10、11 是逻辑时刻,不是实测秒数;15 秒的等待上限只是防止测试挂死。事件交错和 SQLite 写入是真实执行,网络抖动、时钟漂移、机器断电没有被注入。

为了让读者零依赖运行,租约表与接收表放在同一个本机数据库文件,但每个 API 操作各自开启事务。接收端写入没有查询租约表,两端没有共享一次端到端提交。这足以展示两道检查的逻辑区别,却不能冒充两个独立服务器或独立存储故障实验。

运行得到的两组结果如下;完整事件与最终两张表保存在 results.json:

接收端策略B 写入后 A 恢复调度表最终结果业务表最终结果
无 fencingA 的旧写入被接受;完成记录被拒绝newold
原子 fencingA 写入返回 stale;完成记录被拒绝newnew

这就是开头那种“面板成功,文件却旧了”的可复现版本。发送端 CAS 两组都有,只有第二组把隔离推进到了业务接收端。

另一个回滚测试在写入新 epoch 与内容后、事务提交前抛出异常。结果两者一起回到旧状态,不会留下“已经看过新代次,但新内容没提交”的半张收据。它测试的是代码异常引发的事务回滚,不是硬件断电保障。

5. 最容易写错的一句话:到期之后,旧写入全部失效#

这句话比前面的漏洞更隐蔽,因为它听起来很像 fencing 的承诺。

第三个场景专门反驳它:A 得到 epoch 1,B 已经在租约表中接管到 epoch 2,但 B 还没有向接收端发送任何内容。此时 A 带着 1 写入,接收端还没见过 2,便接受了它。随后 B 写入 2,再来的 A 才被拒绝。

到达接收端的顺序返回值接收端看到的变化
A 的 epoch 1,B 仅完成领取written首次接受旧内容
B 的 epoch 2written替换为新内容
A 的 epoch 1 再来一次stale保持新内容

本例证明的是:接收端已提交较新代次后,较旧代次不能覆盖它。它没有证明:租约一到期,整个世界立即拒绝旧持有者。

若业务要求接管完成之前就撤销旧写权限,需要将接收端登记新 fence 纳入接管协议,定义何时才向调度层宣布接管成功,以及登记失败时怎么办。仅仅在客户端多放一次检查,还没有封住那个间隙。若接收端能够和权威状态一起原子验证,则是另一种架构,不能把它偷算作本实验已实现。

B领取epoch2但接收端尚未见到时仍可接受A的epoch1;接收端提交epoch2后才拒绝旧代次
租约到期是权威账上的事件;接收端观察到新 fence,是另一件事。 查看大图 ↗

6. 续租、重领,以及幂等不能替代的东西#

边界测试同时检查:截止时刻仍可接管,截止时刻不能续租;截止前续租成功,会推迟 B 的接管;已经被接管的 A 无法续租,也无法完成任务。同名 A 重新领取时,旧 epoch 的请求仍然失效。这些测试固定操作顺序验证状态转换,不能宣称覆盖了所有可能的分布式调度。

fencing 和第一篇的业务幂等不是同一个标识。epoch 回答“这是谁的第几代执行权”;稳定操作 ID 回答“这是否是同一次业务意图”。一次业务请求跨越两代工作进程重试,操作 ID 可以保持不变,epoch 却应该改变。

更重要的是,接收端必须有能力检查和拒绝。邮件已经发出去,新的 fence 不会让收件人失忆。调用一个不接受代次、不可撤销的外部工具,不能仅凭本地这几行 SQL 宣称 exactly-once。结果不明时,仍要保留 unknown、查询与人工对账的边界。

代次本身也有生命周期:恢复旧数据库快照、清理接收端的最高代次记录、重置计数器,都可能使旧请求重新变得“新鲜”。本篇没有实现这些运维协议,也没有借 SQLite 的事务保证推导整个分布式系统永不失败。

7. 把最后两张表跑出来,再决定是否信结论#

下载五个文件放在同一个空目录:leases.py、run_experiment.py、test_leases.py、results.json、README.md。使用 Python 3.10 或更新版本,无需第三方包:

python3 test_leases.py
python3 run_experiment.py --check results.json --output reproduced.json

第一条运行 17 个测试;第二条重新启动子进程、创建临时数据库、断言交错后的状态,再与存档结果整体比较。没有把已有结果文件当作计算过程。实验数据是临时合成值,不读取任何真实任务、用户会话或凭证。

这套最小实现适合审查不变量,不适合原样接入生产:它没有真实分布式时钟、共识、权限认证、网络分区测试、吞吐测试或独立服务故障恢复。下一篇计划继续拆解不可幂等工具的 unknown 状态与对账,仍以可运行证据决定是否发布。

真正需要守住的界线很朴素:旧进程可以醒来,可以继续算,甚至可以自信地说“完成了”。但在新的执行权已经被接收端确认之后,它不能再替新进程改答案。

相关阅读:工具回执丢失与事务幂等、断点恢复的责任边界。进一步讨论可查看知识星球,或通过公众号入口关注更新。

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

AI 实践

想把本文的问题继续做深一点?

知识星球里会继续整理相关案例、工程约束和问题讨论;具体内容以当前社区页面为准。

AI 实践知识星球加入海报,包含可扫描二维码
芝士AI吃鱼公众号二维码

关注「芝士AI吃鱼」公众号持续更新

在这里,我用「人话」和「漫画」拆解 AI 技术。公众号更新会同步整理到本站,方便继续阅读、检索和订阅。

点击二维码可放大,使用微信扫码关注。

芝士AI吃鱼

持续记录 AI 原理和工程实践

更多文章