# Checkpoint 与回滚

> 每一次图状态转移都持久化。当一个 worker 崩溃时，它的租约（lease）过期，另一个 worker 在最新的 checkpoint 处接手。Cloudflare Durable Objects 跨几小时或几周持有状态。先提议后提交（第 15 课）为每个动作定义一份回滚计划。动作后核验闭合这个循环。EU AI Act 第 14 条让高风险系统的有效人类监督成为强制——实践中这意味着 checkpoint 必须可查询、回滚必须演练过，而审计轨迹必须在一次部署中存活。尖锐的失败模式：没有幂等键和前置条件检查，一次瞬时失败后的重试可能把一个已批准的动作执行两次。动作后核验正是抓住它的东西。

**类型：** Learn
**语言：** Python（标准库，checkpoint 与回滚状态机）
**前置要求：** 阶段 15 · 12（持久化执行），阶段 15 · 15（先提议后提交）
**预计时间：** ~60 分钟

## 问题背景

持久化执行（第 12 课）让一个崩溃的 agent 可恢复。先提议后提交（第 15 课）让一个已批准的动作可审计。本课把它们接起来：当一个已批准的动作部分执行、崩溃、然后恢复时会发生什么？回滚在什么时候跑、针对什么状态跑？

真实系统接法各异：

- **LangGraph** 把每一次图状态转移 checkpoint 到 PostgreSQL。worker 崩溃时，租约释放，另一个 worker 在最新的 checkpoint 处恢复。工作流在 `interrupt()` 处暂停，这本身就会持久化。
- **Cloudflare Durable Objects** 跨几小时或几周持有按键的状态。把计算跟已批准动作的存储放在一起。
- **Microsoft Agent Framework** 在工作流 API 里暴露 `Checkpoint` 原语；重放加幂等性覆盖重试。

每种情况下，真正管用的组合是：幂等键（防止重复执行）+ 前置条件检查（状态仍是我们当初批准时所针对的）+ 动作后核验（副作用确实发生了）+ 核验失败时回滚。

## 核心概念

### 每一次转移都持久化

图状态转移是任何把工作流从一个命名状态移到另一个的步骤。朴素的实现只在特定的提交点持久化；生产实现持久化每一次转移。代价（几次额外写入）相对于可靠度收益（重放能落在任何地方，租约恢复很精确）来说很小。

### 租约恢复

当一个 worker 崩溃时，工作流不会丢；租约（一个短命的、表明这个 worker 正在执行这次运行的声明）只是过期了。另一个 worker 拿起最新的 checkpoint 并恢复。正是这套租约机制，让生产系统能在滚动部署中存活而不丢失飞行中的工作。

### 幂等性加前置条件

光有幂等性不够。考虑：一个工作流被批准"当余额 > $1000 时从 A 转 $100 给 B"。工作流被提交，在执行中途崩溃，然后恢复。如果只检查幂等键，执行恢复，转账跑一次（正确）。但考虑在崩溃和恢复之间，A 的余额通过另一个工作流降到了 $500。幂等检查仍然通过；前置条件不通过。没有前置条件检查，我们就交付了一次透支。

每个有后果的动作两者都需要：

- **幂等键**：防止重复执行。
- **前置条件检查**：确认状态仍跟当初批准的一致。

### 动作后核验

"工具返回了 200"不是核验。真正的核验重新读取目标状态，确认副作用确实发生了。模式：

- 数据库更新：`UPDATE ... RETURNING *`，然后断言返回的行跟意图状态相符。
- 邮件发送：提交后在已发送文件夹里检查消息 ID。
- 文件写入：把文件读回来并求哈希。
- API 调用：对目标资源做一次后续 `GET`。

如果核验失败，工作流就处在一个已知的坏状态。回滚启动。

### 回滚计划

先提议后提交（第 15 课）里每个有后果的动作都带一份回滚计划。类型：

- **带内（in-band）回滚**：直接反转副作用（`INSERT` 后 `DELETE`，发送后发送一封更正邮件）。
- **补偿事务**：一个抵消原动作的新动作（标准 SAGA 模式）。
- **带外（out-of-band）回滚**：告警一个人、暂停工作流，把坏状态留给调查。

空操作回滚（"我们没法撤销这个"）必须在提议里点名。没有回滚的动作在提交时需要更强的 HITL（第 15 课的挑战-应答）。

### EU AI Act 第 14 条的运营解读

第 14 条要求高风险系统有"有效的人类监督"。用运营的话说，实现者把它读成：

- checkpoint 可被审计员查询。
- 回滚演练过（至少端到端测过一次）。
- 审计轨迹在一次部署中存活（checkpoint 后端不是易逝的）。
- 失败的核验被告警，而不是被悄悄记进日志。

一个在提交中途崩溃、恢复、然后在没有核验 + 回滚路径的情况下完成副作用的工作流，扛不过第 14 条的测试。

### 尖锐的失败模式：重复执行

这个领域里最常见的生产事故：

1. 动作被批准，幂等键 k。
2. 提交开始、执行、返回 200。
3. 工作流在持久化"已提交"状态之前崩溃。
4. 工作流恢复；看到"已批准但未提交"；重新执行。
5. 副作用触发两次。

缓解：在执行前持久化一个"飞行中"意图，带一个幂等键执行，然后只在动作后核验成功后才标记"已提交"。如果动作触发了而状态写入失败了，你就知道要去核验并（必要时）重新触发。如果状态写入成功了而动作失败了，你就通过恢复路径核验并恰好触发一次。

## 实际使用

`code/main.py` 实现一个带幂等性、前置条件、核验和回滚的 checkpoint 化工作流。驱动程序模拟四个场景：干净运行、崩溃后重试（幂等性抓住）、前置条件失败（工作流不触发就中止）、核验失败（回滚触发）。

## 拿去用

`outputs/skill-rollback-rehearsal.md` 为一个提议的工作流设计一个回滚演练测试，并审计 checkpoint 后端的审计轨迹持久性。

## 练习

1. 运行 `code/main.py`。验证这四个场景。对于"提交期间崩溃"的情况，确认动作跨重试恰好触发一次。

2. 把"先标记完成，再去做"的模式改成状态写入在动作之后触发。重跑崩溃场景。测量触发了多少个重复动作。

3. 为一个特定的生产动作（比如"发到一个 Slack 频道"）设计一份回滚计划。归类为带内、补偿还是带外。论证这个选择。

4. 拿一个你熟悉的工作流。指出每一次状态转移。给每一次标上持久性要求（持久化 / 不持久化）。数一数你当前没在持久化的那些。

5. 演练回滚测试：设计一个端到端测试，跑一个真实工作流、让它崩溃，并确认回滚路径触发。这个测试断言什么？

## 关键术语

| 术语 | 大家嘴上怎么说 | 实际指什么 |
|---|---|---|
| Checkpoint | "存档点" | 每一次图状态转移都持久化到一个持久存储 |
| Lease（租约） | "worker 声明" | 一个 worker 正在执行某次运行的短命声明；崩溃时过期 |
| Precondition（前置条件） | "状态门" | 断言状态仍跟已批准动作一致 |
| Post-action verify（动作后核验） | "重读检查" | 确认副作用确实在目标系统里发生了 |
| In-band rollback（带内回滚） | "直接撤销" | 用逆操作反转副作用 |
| Compensating transaction（补偿事务） | "SAGA 撤销" | 一个抵消原动作的新动作 |
| Mark-as-done-first（先标记完成） | "状态写入次序" | 在从提交返回之前持久化已提交状态 |
| Article 14（第 14 条） | "EU AI Act 人类监督" | 运营上：可查询的 checkpoint、演练过的回滚、可审计的轨迹 |

## 延伸阅读

- [Microsoft Agent Framework — Checkpointing and HITL](https://learn.microsoft.com/en-us/agent-framework/workflows/human-in-the-loop) —— checkpoint 原语与租约恢复。
- [Cloudflare Agents — Human in the loop](https://developers.cloudflare.com/agents/concepts/human-in-the-loop/) —— 把 Durable Objects 作为状态基底。
- [EU AI Act — Article 14: Human oversight](https://artificialintelligenceact.eu/article/14/) —— 监管基线。
- [Anthropic — Measuring agent autonomy in practice](https://www.anthropic.com/research/measuring-agent-autonomy) —— 长程工作流的可靠度框架。
- [Anthropic — Claude Code Agent SDK: agent loop](https://code.claude.com/docs/en/agent-sdk/agent-loop) —— Claude Code Routines 的工作流形态。
