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

> 用不可变定义摘要、受理时绑定、worker 能力匹配和显式迁移，解决长任务跨版本恢复的语义漂移；附 Python、SQLite、真实进程退出与并发实验。

- 作者：芝士AI吃鱼
- 发布日期：2026-10-23
- 主题：Agent、AI工程、工作流、版本管理、SQLite
- HTML 正文：[https://ai-knowledgepoints.cn/blog/agent-reliability-15-version-pinned-workflows](https://ai-knowledgepoints.cn/blog/agent-reliability-15-version-pinned-workflows)
- Markdown 永久链接：[https://ai-knowledgepoints.cn/blog/agent-reliability-15-version-pinned-workflows/index.html.md](https://ai-knowledgepoints.cn/blog/agent-reliability-15-version-pinned-workflows/index.html.md)

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

## 正文

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

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

[第十四篇](https://ai-knowledgepoints.cn/blog/agent-reliability-14-durable-drain-barrier)讨论如何停止新准入并等待存量完成。如果每次升级都能等所有任务结束，问题会简单一些。但有些 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 的业务含义仍然可能在发布后变化。若简单把版本拼进幂等键，又可能把同一业务意图伪装成一项“全新操作”。[第一篇的重复投递控制](https://ai-knowledgepoints.cn/blog/agent-reliability-01-outbox-idempotency)回答是否重复执行一个已识别的操作，本篇先回答：我们识别的究竟是哪一个操作。

`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 按能力领取，每步校验绑定和代次，仅尚无已提交步骤的就绪任务可显式迁移](https://ai-knowledgepoints.cn/images/articles/agent-reliability-15/version-binding.svg)

原创机制示意。摘要标识清单，不证明运行的代码兼容，也不授予迁移权限。

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

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

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

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

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

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

[SQLite 官方事务文档](https://www.sqlite.org/lang_transaction.html)说明，同一数据库同时只有一个写事务，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 是可信教学输入，不是身份验证或真实授权证据；[第八篇的意图绑定批准](https://ai-knowledgepoints.cn/blog/agent-reliability-08-intent-bound-approvals)讨论的是另一个需要组合进去的约束。

部分执行任务怎样升级？本文的回答是拒绝，而不是凭空发明转换器。真正的状态迁移需要定义旧状态到新状态的映射，审查已有副作用、补偿方式和不可逆边界，再为每种历史状态写测试。只有充分的转换规则与证据，才能扩大允许迁移的状态集合；仅仅把 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 包；运行只使用临时目录中的数据库。

- [versioned.py：定义登记、路由、绑定、执行与迁移](https://ai-knowledgepoints.cn/examples/agent-reliability-15/versioned.py)
- [test_versioned.py：21 项协议与输入边界测试](https://ai-knowledgepoints.cn/examples/agent-reliability-15/test_versioned.py)
- [run_experiment.py：10 组场景与真实进程退出](https://ai-knowledgepoints.cn/examples/agent-reliability-15/run_experiment.py)
- [audit_invariants.py：独立混合操作与竞争审计](https://ai-knowledgepoints.cn/examples/agent-reliability-15/audit_invariants.py)
- [results.json：实际执行后保存的结果](https://ai-knowledgepoints.cn/examples/agent-reliability-15/results.json)
- [README.md：复现入口与限制](https://ai-knowledgepoints.cn/examples/agent-reliability-15/README.md)

```bash
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` 的实验命令会重写冻结结果，只在有意重新生成基线时使用，不能用覆盖基线来掩盖失败。

```python
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 文档](https://docs.temporal.io/worker-versioning)区分 Pinned 与 Auto-Upgrade：前者围绕固定 Worker Deployment Version 执行，后者随当前版本迁移，并需要手动保持重放安全。这说明“固定”与“自动升级”是需要明确选择的语义，不是部署工具默认替你解决的细节。本文没有运行 Temporal，没有实现其事件历史重放、活动路由或各类新 run 的版本继承规则。

[AWS Step Functions 的版本文档](https://docs.aws.amazon.com/step-functions/latest/dg/concepts-state-machine-version.html)把版本定义为不可变的状态机快照，并支持用版本标识启动执行。本文借用的是“默认指针可以变，执行所引用的定义不能隐身变化”这个设计思路，不模拟其 API、权限、配置快照或云端保证。

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

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

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

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

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