返回文章列表

工作流版本绑定与升级:新代码上线,旧任务不能偷偷换剧本

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

一张工单已经办到第二步,系统刚好发布了新版本。新版本没有改数据库,也没有丢任务,只是调整了两个步骤的顺序。进程重启,读取“下一步是第 2 步”,然后勤勤恳恳地把第一步又做了一遍。

这是本文构造的故障场景,不是真实客户事故。它比“任务丢了”更隐蔽:记录还在,进度还在,服务也很健康;变掉的是进度所指向的那套规则。保存了游标,不等于保存了游标的含义。

第十四篇讨论如何停止新准入并等待存量完成。如果每次升级都能等所有任务结束,问题会简单一些。但有些 Agent 流程要等人确认,另一些要跨天收集结果,不能总让新版本陪着最慢的任务一起等。本篇作为 Agent 可靠执行实战第十五篇,完整处理另一条路径:新旧定义并存,新任务采用新默认,旧任务继续履行原来绑定的执行契约;确需迁移时,明确允许的状态、检查与审计。

随附项目仅用 Python 标准库和 SQLite,真正运行操作系统子进程、数据库提交与并发竞争。业务是合成积分账本,不涉及真实资金、模型、第三方工具或云工作流服务。它不是前十四篇功能的全集,也不是 Temporal 的简化兼容实现。

为什么“恢复到第几步”还不够#

示例输入是 units=10。V1 的步骤是先记入基础积分 10,再记入奖励 1,总计 11。V2 改为先记奖励 3,再记基础积分 10,总计 13。两份定义各自执行,都没有重复步骤。

现在让一个 V1 任务完成第一步,然后在恢复时读取“当前默认 V2”,从保存的游标 1 继续。V2 的索引 1 是基础积分,结果变成 10 + 10 = 20。20 既不是旧契约,也不是新契约。

这不是幂等键单独能解决的问题。即使每个 (任务 ID, 步骤索引) 都只写一次,索引 1 的业务含义仍然可能在发布后变化。若简单把版本拼进幂等键,又可能把同一业务意图伪装成一项“全新操作”。第一篇的重复投递控制回答是否重复执行一个已识别的操作,本篇先回答:我们识别的究竟是哪一个操作。

run_experiment.py 中的反例从两份真实清单取出对应步骤计算,而不是把 20 写成一个结果常量。这一组是显式构造的错误算法演示,并没有声称我们运行了某个生产引擎并使它出错。后面的恢复、退出和竞争组才实际操作持久数据库。

四个标识,不要揉成一个 version#

定义摘要标识“执行什么”。示例把规范化 JSON 做 SHA-256,清单包括解释器契约 local-ledger/1、步骤名称、顺序和参数。登记后不通过应用接口修改旧定义;改变步骤就得到新摘要。字典键的排列不影响摘要,步骤列表的顺序则必须影响。

路由版本标识“新任务默认去哪”。route 保存当前默认定义与单调递增 revision。切换默认需要提供预期 revision,过期控制指令被拒绝,避免 V1 → V2 → V1 后,一条上一轮的旧请求再次切到 V2。这不改变已有任务。

绑定版本标识“这个任务的定义是否被明确改过”。job 同时保留 accepted_digest 与 current_digest:前者是最初受理时的定义,后者是当前执行绑定。显式迁移增加 binding_revision,留下迁移审计;受理历史不随之改写。

执行代次标识“哪次领取还有资格提交”。每次领取增加 generation,回调携带任务 ID、绑定版本、执行代次、游标与定义摘要。全部匹配,才可以写下一步。本例不把定义摘要当作权限令牌,也不把路由版本拿去替代执行代次。

旧任务固定 V1 定义,新任务采用 V2 默认;worker 按能力领取,每步校验绑定和代次,仅尚无已提交步骤的就绪任务可显式迁移
原创机制示意。摘要标识清单,不证明运行的代码兼容,也不授予迁移权限。 查看大图 ↗

摘要还有一个必须正视的边界:如果 Python 解释器实现变了,却仍声称遵守同一 ABI 和清单,哈希不会自动发现代码行为改变。测试中的“相同定义”指规范化清单字节相同,不是经过证明的程序语义等价。生产方案还要固定运行制品、依赖、提示词、工具协议及必要的模型版本,并决定哪些外部行为根本无法锁定。

受理时绑定,而不是领取时抽签#

受理过程在一个 BEGIN IMMEDIATE 写事务里完成三件事:先检查任务身份是否存在,再读取当前默认定义,最后插入带固定摘要的新任务。

如果同一身份、同一输入已经存在,返回 replay,不重新读取默认,也不重新绑定。若同一身份却带来不同 units,返回 conflict。replay 只证明原受理记录仍在,不表示任务完成,更不表示最近一次迁移没有发生;需要读取 snapshot 看当前绑定、状态和迁移记录。

默认切换也使用同一数据库的写事务。受理与切换并发时,一个请求可能被绑定到 V1,也可能绑定到 V2,取决于真实提交顺序;但不会把“读到 V1”与“写入 V2”拼成没有依据的中间状态。实验不冻结谁先赢,也不把一次运行中分配给 V1 的任务数当成性能指标。

SQLite 官方事务文档说明,同一数据库同时只有一个写事务,IMMEDIATE 会立即尝试开始写事务,并可能遇到 SQLITE_BUSY。本例设置等待时间是为了运行小规模本地竞争,不是吞吐或延迟保证;最终锁错误会暴露,而非被包装成“迁移成功”。

为什么不等领取时再读默认?因为用户收到 accepted 到 worker 真正领取之间,可能隔着一次发布。若受理承诺的是固定执行规则,那么这个等待不能成为悄悄换规则的机会。若产品的契约本来就是“执行时采用最新策略”,则需要另一个显式设计,本篇不声称所有系统都必须固定。

执行者必须能解释绑定,而不只是愿意接活#

claim(path, manifests) 接收 worker 本地支持的定义清单,只领取摘要属于这个集合的 ready 任务。如果队头是 V1,worker 只支持 V2,它会跳过 V1,寻找自己能执行的 V2 任务;没有匹配项则返回 None,不擅自降级成“用最新代码试试”。

每步提交再次核对 token 与本地能力,随后检查数据库清单和本地清单字节是否一致。登记表被直接改坏时,执行会在写效果之前失败。这个检查不等于对不可信 worker 的远程认证:worker 能否诚实声明能力、能否绕过代码直接写库,都是本例之外的问题。

效果记录与游标推进在同一个 SQLite 事务提交。完成第一步后,任务重新进入 ready;最后一步结束才是 done。每条效果保存自己的定义摘要、步骤索引、名称和值。这样不仅能检查“有几条效果”,还能检查这些效果究竟属于哪份契约。

只升级默认并启动 V2 worker,不会让旧任务丢失,但可能让它们一直等待。unfinished_by_definition 按当前绑定统计所有非 done 任务,包含 ready 和 inflight。示例中,V2 worker 完成新任务后,仍明确报告一个 V1 未完成;补回支持 V1 的 worker 后,旧任务才完成为 11。

这把部署问题从“旧任务好像能跑”改成“旧任务有谁负责跑”。但它不保证最终完成,也不自动保留旧容器、依赖或工具端点。旧执行能力不能因为默认已经切走就立即删除。反过来,未完成数为零也不是删除所有旧制品的充分条件:真实系统可能还有查询、历史重放、审计或保留期要求。

显式迁移:允许改变语义,不伪装成兼容#

本文不是把所有旧任务永远钉死。migrate 提供一个刻意狭窄的入口:必须指定任务、预期绑定版本、目标定义与原因;目标定义已登记;任务必须是 ready,且 cursor 恰好为 0。

条件通过后,迁移审计与新绑定在同一个写事务里提交,绑定版本增加,执行代次也失效。原来的 units 和 accepted_digest 保持不变。重发最初的受理请求仍返回 replay,不会撤销已经完成的迁移;使用旧 binding_revision 再改一次则返回 stale。

这里的 cursor=0 必须准确读成“没有已提交的步骤”,而不是“从未启动过进程”。任务可能曾被领取,随后进程退出;可信恢复操作把它重新放回 ready,只要没有提交步骤,本例允许再迁移。成立的前提是全部业务效果都在这个数据库事务内。若旧 worker 曾向外部发送邮件,只是还没写进度,cursor=0 就不能证明什么也没发生。

只要 inflight,迁移就拒绝;只要已提交任何一步,即使任务重新回到 ready,也拒绝。领取与迁移共享写事务,因此竞争只有两类合法结局:迁移先发生,V1-only worker 不再能领到它;或领取先发生,迁移看到 inflight 而拒绝。不会出现“V1 worker 已持有旧 token,任务却无声变成 V2”的可提交窗口。

这不是语义兼容性证明。示例允许把完整的 V1 变成完整的 V2,本来就会把结果从 11 改成 13。是否可以这样改变承诺,必须由产品契约、用户批准或相应管理流程决定。函数里的 reason 是可信教学输入,不是身份验证或真实授权证据;第八篇的意图绑定批准讨论的是另一个需要组合进去的约束。

部分执行任务怎样升级?本文的回答是拒绝,而不是凭空发明转换器。真正的状态迁移需要定义旧状态到新状态的映射,审查已有副作用、补偿方式和不可逆边界,再为每种历史状态写测试。只有充分的转换规则与证据,才能扩大允许迁移的状态集合;仅仅把 started 改成 migrated 不算实现。

让发布跨过真实的进程退出#

随附测试实际通过 21 项 unittest;完整实验有 10 组。进程在指定位置调用 os._exit(73),父进程确认退出码后,用新连接读库。总共覆盖 12 个故障边界:受理前后、领取前后、步骤效果插入后及提交前后、默认切换前后、迁移审计插入后及提交前后。它们是进程退出测试,不是断电或损坏磁盘测试。

在步骤效果插入后退出,效果与游标一起回滚,任务仍是 inflight。调用显式 recover 后,旧回调返回 stale,新领取可以重新执行。若步骤提交后退出,效果与游标已经一起存在,恢复从下一步开始,默认此时切为 V2 也不会改变 V1 任务。三个步骤崩溃场景最终都只有两条本地效果,积分总计 11。

领取提交后崩溃是一个故意保留的停滞点:任务会留在 inflight,不会自动变成 ready。恢复入口需要观察到的 generation,更新时使旧代次失效。这里之所以允许人工恢复,是因为任一原 worker 与恢复者最后都要经过同一写事务和 token 检查,不会在数据库之外先做业务。本例没有租约、超时回收或远端 unknown 对账;不能直接拿它重试真实转账、邮件或订单接口。

迁移审计写完但尚未提交时退出,审计与绑定都回滚;提交后退出,两者同时保留。客户端若没拿到响应,再用旧绑定版本重试会得到 stale,需要读当前任务与审计确认发生了什么,而不是把 stale 直接当作失败或成功。本例没有为迁移命令保存永久请求 ID 回执。

并发实验先让全部子进程输出 READY,再统一发 GO。八个路由切换者竞争同一 revision,恰好一位成功、七位 stale;八个 worker 竞争同一 ready 任务,只会产生一个有效领取。受理与默认切换的竞争检查每个任务始终使用一个完整定义;领取与迁移的竞争检查审计、绑定、状态和效果一致。

results.json 冻结的是可重复结果与不变量。并发赢家是谁、哪个请求先拿锁没有被写成固定答案。--check 会重新创建数据库、运行子进程和竞争,再比较结果,不是把一份保存的 JSON 读出来就宣布测试通过。

独立的 audit_invariants.py 又实际运行 50 个种子、每个 500 次混合操作,以及 40 轮、每轮 12 个 READY 门控进程的领取/迁移/默认切换争用。它逐步检查原受理摘要不变、效果摘要与当前绑定一致、游标对应连续效果前缀、迁移审计链可接回原绑定;另外直接破坏注册表清单,确认在本地效果写入前拒绝。种子固定的是操作选择,不是操作系统调度;这些是协议性质检查,不是性能、恶意管理员防护或生产可用性证明。

下载项目,从一个完整生命周期开始#

将以下六个文件保存到同一空目录,使用 Python 3.10 或更新版本。无需密钥、模型、网络或第三方 Python 包;运行只使用临时目录中的数据库。

python3 test_versioned.py
python3 run_experiment.py --check
python3 audit_invariants.py

预期看到 Ran 21 tests、OK,以及 PASS: 10 scenario groups match results.json。独立审计会再输出三行 PASS,对应混合操作、进程竞争和清单破坏检查。不带 --check 的实验命令会重写冻结结果,只在有意重新生成基线时使用,不能用覆盖基线来掩盖失败。

from pathlib import Path
from tempfile import TemporaryDirectory
import versioned as v

with TemporaryDirectory() as folder:
    p = Path(folder) / 'demo.db'
    v.init(p)
    v.register(p, v.V1)
    v.register(p, v.V2)
    assert v.switch(p, 0, v.D1) == 'switched'
    assert v.admit(p, 'old', 10) == 'accepted'
    assert v.switch(p, 1, v.D2) == 'switched'
    assert v.admit(p, 'new', 10) == 'accepted'
    v.drain(p, [v.V2])
    assert v.snapshot(p)['unfinished_by_definition'] == {v.D1: 1}
    final = v.drain(p, [v.V1])
    assert final['unfinished_by_definition'] == {}

读代码时先看 admit 为什么不重绑原 ID,再看 step 为什么不用 route,最后看 migrate 如何把条件检查、审计和绑定更新放在一起。这三条路径比“给表加一个版本字段”重要得多。

与成熟工作流系统的对应关系,以及不该对应的部分#

Temporal 的 Worker Versioning 文档区分 Pinned 与 Auto-Upgrade:前者围绕固定 Worker Deployment Version 执行,后者随当前版本迁移,并需要手动保持重放安全。这说明“固定”与“自动升级”是需要明确选择的语义,不是部署工具默认替你解决的细节。本文没有运行 Temporal,没有实现其事件历史重放、活动路由或各类新 run 的版本继承规则。

AWS Step Functions 的版本文档把版本定义为不可变的状态机快照,并支持用版本标识启动执行。本文借用的是“默认指针可以变,执行所引用的定义不能隐身变化”这个设计思路,不模拟其 API、权限、配置快照或云端保证。

还有一个重要反例:修复实现 bug、更新安全依赖,不一定要求改变业务清单。我们不应为了固定语义永远保留有漏洞的旧运行制品。现实里可以用新制品继续支持旧定义,但“仍然支持”要由回归、重放和接口证据支撑,而不是只在 capabilities 里把旧摘要填进去。反过来,提示词、工具模式甚至模型服务端行为改变,即使代码版本没动,也可能改变执行语义。

这个项目保留全部定义、任务和审计,没有实现历史清理、跨节点复制、真实授权、制品供应链验证、数据库 schema migration 或负载公平性。摘要只覆盖这里列明的 JSON 解释规则,不能给整个 Agent 行为盖上确定性的章。

升级可以换新剧本,但不该让已经演到一半的任务被迫猜台词。新任务走新默认,旧任务保留原绑定;需要改约时,有明确的状态边界与审计。可靠执行不只是让任务重启后还会动,还要让它知道自己继续履行的是哪一份承诺。

系列讨论可查看知识星球,或通过公众号入口关注更新。

本文由 AI 辅助制作。代码与冻结实验经过实际执行;积分、定义与故障场景为合成样例,未调用模型或真实业务服务。配图按已明确的白底技术信息图文字要求原创制作,不声称读取了未提供的图像参考。官方资料核对日期:2026 年 10 月 23 日。

AI 实践

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

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

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

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

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

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

芝士AI吃鱼

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

更多文章