状态机与持久化执行(State Machine & Durable Execution)
所属:01-fundamentals / 07-distributed-systems
面试权重:★★(「任务重启后从哪接着跑」的理论底座;很少被单独出题,但它能把../06-agent-infra/10-agent-runtime.md§6 的恢复、A2A 的任务状态、LangGraph 的图串成一条线)
来源:通用分布式知识,本仓库两本参考书覆盖很少 —— 按 System Design 101(ByteByteGo)、MIT 6.5840(分布式系统)、Temporal / AWS Step Functions / DBOS 的公开文档整理,未做一手考证。能对回书的有三处:① 「当前状态必须包含决策所需的全部历史信息」(马尔可夫性质)出自《深入理解 AI Agent》第 8 章;② A2A 的任务状态机出自同书第 10 章;③ 「一份快照何时才真正可用」(数据文件 + 提交记录共同构成可用快照)出自《深入理解 AI Infra》第 10.4.3 节,其中的毫秒级观测带实验条件、非本仓库测量
相关知识点:transaction-wal.md(WAL 的存储引擎视角 —— 本篇讲它与持久化执行是同一思想的两种尺度)、timeout-retry-idempotency.md(重试与幂等键的落地)、queue-pubsub.md(幂等状态迁移如何吸收重复投递)、../06-agent-infra/10-agent-runtime.md§6(保存频率与连续保存位置)、../04-agent-architecture/agent-react.md(Agent 循环本身就是一个状态机)、../04-agent-architecture/13-multi-agent-coordination.md§5(A2A 任务状态机)、../04-agent-architecture/multi-agent.md(LangGraph 的图 + 共享状态)
一句话#
状态机的价值有两层:建模层 —— 把「系统现在处于什么状态、允许发生什么」写成可枚举、可校验的显式结构,而不是一堆含义互相纠缠的布尔标志;执行层 —— 持久化执行(durable execution)不去保存进程的内存快照,而是保存「事件历史」,崩溃后从头重放代码、跳过已经完成过的步骤。后者的全部约束可以压成一句:代码必须是确定性的,所有不确定的东西都得先变成历史里的事件。
为什么重要(面试角度)#
- 它把「任务重启后接着跑」从直觉变成机制。 大多数人的答案是「加个 checkpoint」;能说清 checkpoint 是快照、事件历史是日志、两者是不同的恢复材料,以及 重放为什么要求确定性的人,明显在另一层。
- 它把 Agent 平台的一堆零散结论串起来。
../06-agent-infra/10-agent-runtime.md§6 的「保存频率取舍」「连续保存位置」是持久化执行在Agent 尺度上的同一道题;../../02-interview/design/08-agent-platform.md§4.6 的失败恢复也落在同一个判据上。能指出这三处是同一个机制,就是结构化的证据。 - A2A 的任务状态机是现成的考点。 已提交 / 进行中 / 需要输入 / 已完成 / 失败 —— 面试官问「跨组织托付任务时怎么对齐进度」,答「定义一组统一的状态词汇」比答「轮询一个 status 接口」高一层。
- 它是「为什么工作流代码要写得不自然」的答案。 「Temporal 里为什么不能直接
now()」这类问题,考的是你理不理解重放,而不是会不会用某个框架。 - 它专治一个高频错误答案:「状态就是那几个布尔字段」。 能说清 N 个布尔标志有 种组合、其中大部分非法且不可枚举,以及 显式状态让非法迁移可以被拒绝,就够分层了。
⚠️ 边界声明:你没有在生产里跑过 Temporal / Step Functions 集群,也没做过状态机复制的工程实现。本篇按「原理 + 这套账我会这么算」讲,不要说成「我们线上就是这么做的」。
核心内容#
1. 有限状态机:状态、事件、迁移、不变式#
一个有限状态机(FSM)由四样东西构成:
| 要素 | 含义 | 写不对时的症状 |
|---|---|---|
| 状态集合 | 有限、可枚举、互斥 | 出现「既是 A 又是 B」或「什么都不是」 |
| 事件 | 从外部到达、触发迁移的输入 | 把内部计算也当事件,事件集合爆炸 |
| 迁移函数 | 状态 × 事件 → 新状态(+ 动作),可带守卫条件 | 同一状态同一事件有两种走法,行为不确定 |
| 不变式 | 在每个状态下必须成立的性质 | 状态合法但数据自相矛盾 |
「状态」的定义有个容易忽略的硬要求,书里第 8 章讲 MDP 时说得很干净:「当前状态必须包含决策所需的全部历史信息」(马尔可夫性质)。国际象棋的状态不只是棋子位置,还要包含轮到谁走、王车易位权、吃过路兵权、五十步规则 —— 漏掉任何一项,状态就不足以决定下一步。反过来,如果观测里没有这些信息,就该把它们并进状态,而不是每次回头翻完整历史。
这条性质直接给出了「状态该放什么」的判据:状态是「决定下一步所需的全部信息的压缩」。太少 → 行为依赖被丢掉的历史,恢复时走岔;太多 → 状态臃肿,且往往意味着你存了可以派生出来的量(两个真相来源,迟早分叉)。
2. 为什么「显式状态 + 事件」优于「一堆布尔标志」#
假设一个任务的进度用三个布尔量表示:已扣款、已发货、已通知。 种组合里只有 4 种是合法路径,其余 4 种是「不可能状态」,但它们真的会出现 —— 因为没有任何机制阻止代码把 已通知 置为 true 而 已扣款 还是 false。布尔标志的问题是:合法组合的集合是隐式的,只存在于写代码的人的脑子里。
| 布尔标志堆 | 显式状态 + 事件 | |
|---|---|---|
| 合法性 | 隐式, 组合里大部分非法 | 状态有限可枚举,非法组合同构上不存在 |
| 接收事件 | 谁都能改任意字段 | 「这个状态下允许哪些事件」是一张可查的表 |
| 恢复 | 要重建 N 个字段并验证一致性 | 持久化一个状态值(外加必要的载荷) |
| 校验 | 只能事后断言 | 迁移函数可以拒绝非法事件 |
| 可观测 | 要写 N 个指标 | 按状态打点:多少任务卡在「需要输入」 |
| 测试 | 组合爆炸 | 每个状态一组用例,逐条边验证 |
所以它换来的三样东西是:可枚举(能穷举、能测试)、可校验(非法迁移当场拒绝而不是静默腐蚀)、可恢复(一个值就是全部进度)。 此外还有一个常被低估的收益:错误处理从「猜」变成「查表」 —— 收到一个事件时,先问「当前状态接受它吗」,而不是先执行再补救。
但别把树倒过来栽。 显式状态不是「把所有信息都塞进一个 state 字段」:状态要小到能枚举,载荷(payload)可以另放。状态是「当前允许什么」的答案,不是数据库。
3. 状态机复制:确定性重放是复制的底层机制#
把状态机用在分布式上,就得到状态机复制(state machine replication),它的前提只有两条:
这条式子把「复制」问题整个归约成了「对日志顺序达成一致」 —— 于是共识算法(Raft:选主、leader 追加日志、多数派复制后提交)负责的只是顺序,状态机负责的是语义。这里一句话带过即可,不要展开共识细节(那是另一道题)。
真正要讲的推论是确定性。 上面那条式子成立,要求「应用命令」这一步是确定性的:同一命令作用在同一状态上,任何副本都得算出同一结果。所以不确定性必须在写入日志之前就被消解掉:
- 时间戳:不由各副本自己取当前时间,而由 leader 在写日志时定下来,作为日志内容的一部分。
- 随机数:同上 —— 随机值本身要成为日志里的一条记录。
- 并发顺序:不能靠「谁先跑完谁先算」,必须由日志顺序唯一决定。
看到这里会发现:这一段和下面要讲的持久化执行,本质是同一条约束的两种应用场景 —— 区别只在于,状态机复制要的是多个副本算出同一个状态,持久化执行要的是同一个进程在重启后算出同一个状态。「确定性重放」是这两件事共用的机制。
4. 持久化执行:把「代码执行」记成事件历史#
先看它要解决的问题:一个跑了几小时的工作流进程崩了,怎么从中间接着跑?直觉答案是「保存内存快照」—— 这条路很难走通:现代程序的状态是调用栈、闭包、线程、打开的文件句柄、连接池,要一致地捕获并恢复这一整套,成本高、依赖语言的运行时支持,而且对跨语言、跨版本几乎没有可移植性。
持久化执行换了个做法:不保存程序状态,保存程序产生的决策。 工作流引擎把执行记成一份事件历史(event history):
- 工作流代码调用的每一个命令 / 活动(activity),其结果(或失败)都作为一条事件追加进历史;
- 进程重启后,把工作流函数从头重跑一遍:每个命令先查历史 —— 历史里有记录,就直接返回记录的结果、不真的执行;历史里没有,才真正执行并追加新事件;
- 于是重建出来的状态与崩溃前一致,执行从第一条没有记录的命令继续。
关键的一句是:代码会重跑,副作用不会。 因为副作用(发邮件、调 API、写库)都被包在活动里,而活动的第一次真实执行才产生事件;之后每次重放都只是「读历史」。
由此推出一条对写代码的硬约束:工作流代码必须确定性地重放 —— 每一次重跑,都要在相同的输入下走相同的分支。这就是下一节的内容。
还有两笔成本要提前知道(面试里说出来就是深度):① 历史会膨胀 —— 长寿命工作流的历史随步数增长,重放成本随之上升,所以引擎通常提供「把当前状态作为输入、开一个新的执行」(continue-as-new)来截断历史;② 代码版本会成为运维问题 —— 改了工作流代码,旧历史按新代码重放可能走岔,所以需要版本化 API,或者等存量工作流跑完再发布。
5. 确定性要求:工作流代码里不能有什么#
判据只有一条:这段代码重放时,会不会得到和上次不同的结果? 会,就不能留在工作流代码里。
| 不能直接做(不确定) | 必须改成 | 为什么 |
|---|---|---|
now() / time.time() |
框架提供的时钟(重放时返回历史里记下的时间戳) | 第二次跑的时间不同,超时判断、日期分支都会走岔 |
random() / UUID |
框架提供的随机源,值进历史 | 否则重放会选到另一条分支、另一条分支上的命令对不上历史 |
| 直接发 HTTP / 读文件 / 查库 | 放进活动(activity) | 副作用不能重跑;结果需要被记录、可被重放读取 |
| 遍历无序容器的顺序 | 先排序再遍历 | 迭代顺序不稳定 → 命令序列对不上历史 |
| 起线程 / 竞争式协程 | 框架的确定性调度(选哪一路由历史顺序决定) | 谁先跑完是运行时的随机性 |
| 读全局可变状态 / 环境变量 | 走框架(或视为版本的一部分) | 两次运行的进程环境可能不同 |
「命令对不上历史」是这里最典型的失败模式:重放时如果代码多调了一个命令(或换了顺序),引擎会发现「历史里对应位置的记录不是这个命令」,于是报错拒绝对齐 —— 这个报错是保护,不是 bug。它说明这次部署的代码与那条历史不兼容。
活动也要能安全重试。 活动会真的执行、可能超时、可能被重试 —— 所以活动本身必须幂等,或带上操作标识(与 timeout-retry-idempotency.md、../06-agent-infra/10-agent-runtime.md §6 的「先查询再重试」是同一套要求)。持久化执行把「跳过已完成的步骤」做成了自动的,但它管不到外部世界的副作用是否只发生了一次。
6. 与 WAL、与重试的关系#
WAL 和持久化执行是同一个思想的两种尺度。
| WAL(存储引擎尺度) | 持久化执行(程序尺度) | |
|---|---|---|
| 先写什么 | 先写意图(日志记录),再改数据页 | 先记决策(事件),再看效果 |
| 恢复怎么走 | 重放日志做 redo / undo | 重放代码,跳过已有记录的步骤 |
| 重放的粒度 | 页 / 字节级的操作,由存储引擎负责 | 程序本身,由框架负责「跳过」 |
| 谁承担确定性 | 日志里记的就是确定的字节 | 开发者(代码必须确定性) |
所以「为什么工作流引擎的代码要写得不自然」和「为什么数据库要写 WAL」是同一个问题的两个尺度 —— 只不过 WAL 把不确定性挡在日志之外的任务交给了存储引擎,而持久化执行把这件任务交给了写工作流的人。也正因为如此,工作流引擎自己的存储通常就是一个 WAL 型数据库 —— 一层套一层(见 transaction-wal.md)。
重试是「重放 + 跳过已完成」的特例,而且是退化得最厉害的那一种:
| 恢复手段 | 重做多少 | 代价 |
|---|---|---|
| 从头重试整个任务 | 全部 | 简单;浪费已完成的全部工作 |
| 加 checkpoint 的重试 | 上一个 checkpoint 之后的部分 | 要权衡保存频率(见 ../06-agent-infra/10-agent-runtime.md §6:每请求提交 vs 攒批提交) |
| 持久化执行 | 最后一条没有记录的步骤之后 | 框架自动做,代价是确定性约束 + 历史膨胀 + 版本管理 |
checkpoint 与事件历史是两种不同的恢复材料,这一点值得单独记:快照省重放时间,但要求一个一致的捕获点;日志精确、粒度细,但重放成本随长度增长。 书里第 10.4.3 节讲训练 checkpoint 时有一句同构的结论:「数据文件与提交记录共同构成可用快照」 —— 一组 CPU 实验中,保存接口约 7 ms 就返回,但恢复元数据约 48 ms 才提交;在提交前终止进程,恢复程序只能选上一份完整的 checkpoint。「写完了」和「这份快照可用」是两件事 —— 这正是 ../06-agent-infra/10-agent-runtime.md §6 讲的持久性语义。
7. 与「自己写状态表」的对比#
自己写状态表是:加一个 status 字段,每个步骤写成「读状态 → 判断能不能做 → 做事 → 更新状态」,失败时把状态留在原处等着人来重试。
| 自己写状态表 | 持久化执行框架 | |
|---|---|---|
| 状态字段 | 手写、手更新 | 不用手写 —— 状态由事件历史重放出来 |
| 控制流 | 必须画成迁移表(循环、异常、子流程都要显式编码) | 普通控制流(for、try/catch、子工作流) |
| 重试 | 手写重试逻辑与退避 | 活动级自动重试 / 超时策略 |
| 定时器 | 要自己起调度器扫表 | 跨重启的定时器是内建能力 |
| 外部输入 | 自己设计「等待外部事件」的机制 | signal / query 把外部事件建模成状态迁移 |
| 审计 | 状态字段只留最后一个值 | 历史就是完整的审计日志 |
| 代价 | 每个流程都重写一遍恢复逻辑;状态机只能靠自己保证正确 | 确定性约束 + 版本管理 + 新的依赖与故障域 + 历史膨胀 + 编程模型锁定 |
框架给你的核心东西是「不用手写状态字段」 —— 听起来只是省事,实际是消灭了一整类 bug:状态字段与实际进度不同步、status 忘了更新、分支漏了一条。但代价换得很实在:你的代码不再是一段普通的代码,它必须是一段可以被重放的代码。
什么时候自己写状态表反而是对的? ① 状态少、流程线性;② 状态迁移与业务数据本来就在同一个库里、能同事务提交 —— 这时多加一个工作流引擎等于引入第二个真相来源,还要处理「引擎说成功了、业务表没写」这类新问题;③ 团队没有运维这套引擎的精力。判据是:你需要的「恢复」是否已经由现有的事务边界免费提供了。
8. Temporal / Step Functions / DBOS 的取舍#
三者代表了两个轴上的不同选择:编排表达成代码还是数据,以及状态存在哪里。
- Temporal:编排即代码 —— 工作流是普通函数加 SDK 注解,引擎负责事件历史、确定性重放、活动重试与定时器。代价:工作流代码必须遵守确定性约束并做版本管理,而且要运维一套集群(或用托管服务)。定位一句话:通用持久化执行,控制流表达力最强,约束也最硬。
- AWS Step Functions:编排即数据 —— 工作流是一份声明式的 JSON 状态机(Amazon States Language),由服务解释执行。收益:定义是数据而不是被重放的代码,没有代码级的确定性约束;代价:复杂控制流用 JSON 表达很啰嗦,且状态机与业务代码是两个分开的产物,容易漂移。定位一句话:托管、免运维、可视化,代价是表达力和代码-定义同步。
- DBOS:把持久化执行放进数据库 —— 按它的公开定位,工作流状态与业务数据放在同一个库里,让状态迁移和业务写共享同一个事务,从而不必在「引擎存储」和「业务库」之间做双写。定位一句话:用「同库同事务」换掉引擎与业务库之间的一致性缝隙,代价是绑定数据库。
这里不给任何性能数字 —— 这三者的差别主要体现在一致性缝隙在哪、谁来承担确定性、运维成本多高,不在吞吐量上,而且具体数字高度依赖部署形态。
9. 状态建模的坑#
- 状态字段膨胀。 表现:
status旁边又加了stage、phase、is_retrying三个字段,它们的组合才是真实进度。原因:把「状态」和「数据」混在一起,或存了可以派生的量。判据:一个量若能由其他字段算出来,就不要存第二份 —— 两个真相来源迟早分叉。 - 终态不可重入,但事件还会来。 任务已
completed,这时到达一条迟到的「取消」或重复的「完成」事件,怎么办?三个选项,必须显式选一个:① 忽略并记录(把迟到事件写进审计/告警,用于发现上游时序问题);② 显式定义补偿迁移(从终态回到一个补偿态,而不是偷偷重开);③ 幂等地接受(若该事件就是触发这个终态的同一个事件,直接返回既有结果)。唯一错的做法是让终态静默接受迁移 —— 那等于状态机在最后一个状态上失去约束。 - 状态与副作用不同步。 两个方向都会发生:状态已更新、副作用没发生(先写
已发邮件再发,发送失败),或副作用发生了、状态没更新(邮件发了,更新状态的请求超时)。除非副作用和状态在同一个事务域里,否则不存在原子提交。 剩下的出路只有两条:把副作用做成幂等、可重试的(先记意图,重放时先查询实际结果再决定是否重做,即../06-agent-infra/10-agent-runtime.md§6 的「先查询再重试」),或做意图 + 确认两段(outbox / 事务消息,见transaction-wal.md)。 - 隐式状态(从数据推断)vs 显式状态。 用「文件是否存在」表示完成、用「字段是否为 NULL」表示未开始,都是隐式状态。代价:含义依赖多个字段的组合、改一个字段可能被解读成一次状态跳变、状态查询散落各处、无法记录「什么时候变的」和「为什么失败」。但反过来也不成立:如果某个字段本身就是业务事实(订单已支付 = 支付表里有记录),再加一个
status列就是第二真相来源。判据:状态是「当前允许哪些事件」的权威答案吗?是就显式存;只是某个业务事实的投影,就从事实派生。 - 幂等状态迁移。 重复投递是常态(见
queue-pubsub.md),所以同一个事件到达两次时,迁移必须是「已在目标状态 → 无操作并返回成功」,而不是报错或再迁一次。这是状态机能吸收至少一次投递的地方 —— 也是它和「重试」配合得最自然的一处。 - 别把「无限状态」也叫做有限状态机。 一旦状态里含计数器、余额、任意长的历史,它就不再是有限状态机了,而是状态转移系统。这个区分不是学术洁癖:有限性正是「可枚举、可穷举测试」这些收益的来源。
10. 接回 Agent#
- Agent 循环本身就是一个状态机(见
../04-agent-architecture/agent-react.md):状态 = 上下文 + 已完成步骤 + 当前目标;事件 = 模型输出、工具结果、用户输入;迁移 = 决策 → 行动 → 观察 → 回到决策。把它显式化的收益立刻可数:能加步数上限、能定义无进展检测(连续 N 步观测无变化即中止)、能让人在环中变成「进入『需要输入』状态」而不是「卡住」、也能让重启后接着跑有明确的对象。上下文就是这里的状态载荷,所以上下文该留什么、该丢什么,与第 1 节的「状态必须包含决策所需的全部历史信息」是同一个问题。 - A2A 的任务状态机(见
../04-agent-architecture/13-multi-agent-coordination.md§5,出处为《深入理解 AI Agent》第 10 章):把协作单元建模为任务,带一组标准化状态 —— 已提交 / 进行中 / 需要输入 / 已完成 / 失败,原生支持长任务与流式进度更新。这组词汇的价值在于跨组织对齐:两家互不可见的实现,只要都按这组状态汇报,就能对齐「这个任务到哪了」,而不必交换内部实现。「需要输入」是最有意思的一个 —— 它是一个等待外部事件的非终态:任务既不前进也不失败,需要另一个角色(人或其他 Agent)推它一下。这正是持久化执行里 signal / 定时器要建模的东西,也是「任务可以等很久」这件事在状态机上的形式化。 - LangGraph 的图 = 显式状态机(见
../04-agent-architecture/multi-agent.md):节点是步骤/状态,边是迁移,条件边是守卫,共享状态对象就是这台机器的状态,节点之间的 handoff 是一次迁移。它带来一个直接后果:因为状态是显式对象,就能在每一步落盘(checkpointer)并在之后恢复 —— 这恰好就是持久化执行对状态表达能力的要求。但要说清类比的边界:它不是第 1 节意义上的「有限」状态机(状态里含任意长的消息列表,状态空间无界),它是显式状态转移系统。收益来自「控制流显式化」,不来自「状态有限」 —— 把这一点讲清楚,比说「LangGraph 就是状态机」更准确。
常见面试问法#
Q:为什么要用状态机建模,而不是加几个布尔标志?
A:因为布尔标志把「合法组合的集合」留在了写代码的人的脑子里。 个标志有 种组合,其中大部分非法,但没有任何机制阻止代码进入这些组合 —— 它们真的会出现(比如 已通知 为真而 已扣款 为假)。显式状态换来三样东西:可枚举(状态有限、能穷举、能每个状态一组测试)、可校验(「这个状态下允许哪些事件」是一张可查的表,非法事件当场拒绝而不是静默腐蚀)、可恢复(持久化一个状态值就是全部进度,不用重建 N 个字段再验一致性)。另外还有两个实际收益:按状态打点做观测(多少任务卡在「需要输入」),以及错误处理从猜变成查表。但要补一句边界:状态要小到能枚举,载荷另放 —— 状态是「当前允许什么」的答案,不是数据库。
Q:什么是持久化执行?为什么不直接保存进程快照? A:持久化执行不保存程序状态,保存程序产生的决策。 保存内存快照这条路很难走通:现代程序的状态是调用栈、闭包、线程、打开的文件句柄和连接池,要一致地捕获并恢复这一整套,成本高、依赖运行时支持、跨语言跨版本基本不可移植。它换的做法是记事件历史:工作流调用的每个命令/活动及其结果都追加成一条事件;重启后把工作流函数从头重跑,每个命令先查历史 —— 有记录就直接返回记录的结果、不真的执行,没记录才执行并追加新事件。一句话:代码会重跑,副作用不会。 因为副作用被包在活动里,只有第一次真实执行才产生事件。代价要说清:历史会膨胀(需要截断机制),代码版本会成为运维问题(改代码后旧历史重放会走岔),并且工作流代码必须遵守确定性约束。
Q:为什么工作流代码里不能直接调 now() 或发网络请求?
A:判据只有一条:这段代码重放时会不会得到和上次不同的结果。 now() 两次跑拿到不同时间 → 超时判断和日期分支走岔,重放会上不同的分支;随机数/UUID 同理;直接发网络请求/读文件的问题更严重 —— 副作用不能重跑,而且它的结果需要被记录才能重放。对应的改法:时钟和随机源都用框架提供的(重放时返回历史里记下的值);所有 IO 都放进活动(activity),活动的结果被记进历史。还有两类容易被忽略:遍历无序容器的迭代顺序(要先排序)和线程/竞争式协程的调度顺序(要用框架的确定性调度,选哪一路由历史顺序决定)。最典型的失败模式是「命令对不上历史」 —— 重放时代码多调了一个命令或换了顺序,引擎发现历史里对应位置不是这个命令就报错拒绝。这个报错是保护而不是 bug,它说明这次部署的代码与那条历史不兼容。
Q:持久化执行和重试是什么关系?
A:重试是「重放 + 跳过已完成」的特例,而且是退化得最厉害的那一种。 按重做量排:从头重试整个任务(重做全部,最简单也最浪费)→ 加 checkpoint 的重试(只重做上一个 checkpoint 之后的部分,代价是要权衡保存频率)→ 持久化执行(只重做最后一条没有记录的步骤之后的部分,框架自动做)。所以「加 checkpoint」本质上就是手工版的持久化执行,而持久化执行是把「每一步都自动 checkpoint + 恢复时自动跳过」做成框架能力。要顺带说清 checkpoint 与事件历史的差别:快照省重放时间但要求一致的捕获点,日志精确但重放成本随长度增长;而且**「写完了」和「这份快照可用」是两件事** —— 数据文件与提交记录共同构成可用快照(《深入理解 AI Infra》10.4.3 节有这方面的实验观测),这就是 ../06-agent-infra/10-agent-runtime.md §6 的持久性语义。
Q:既然有工作流引擎,为什么还会有人自己写状态表?
A:因为「恢复」有时已经由现有的事务边界免费提供了。 自己写状态表的代价是每个流程都要重写一遍恢复逻辑,而且状态字段与实际进度可能不同步;框架换来的是不用手写状态字段、普通控制流(循环、try/catch、子工作流不用画成迁移表)、活动级自动重试、跨重启定时器、signal/query、以及历史即审计日志。但代价很实在:代码不再是普通代码(必须可重放)、要做版本管理、多一个依赖和一个故障域、历史会膨胀、编程模型被锁定。所以自己写状态表在三种情况下反而是对的:状态少且流程线性;状态迁移与业务数据本来就在同一个库里、能同事务提交(多加一个引擎等于引入第二个真相来源);团队没有运维它的精力。判据一句话:你需要的「恢复」是否已经被现有的事务边界覆盖了。
Q:A2A 的任务状态机为什么值得专门定义?
A:因为跨组织协作最大的问题是「状态对齐」,而不是「怎么调接口」。 两个互不可见的实现要判断一个任务到哪了,如果各自发明词汇(running / in_progress / executing),就没法自动对齐。A2A 把这组状态标准化成 已提交 / 进行中 / 需要输入 / 已完成 / 失败,并原生支持长任务与流式进度更新。其中「需要输入」是最有信息量的一个:它是等待外部事件的非终态 —— 任务既不前进也不失败,需要另一个角色推它一下,这正是持久化执行里 signal / 定时器要建模的东西。要坦白的边界:这组状态解决的是对齐,不解决语义 —— 「进行中」到底意味着「已开始处理」还是「已发出请求在等回包」,双方仍要约定;统一词汇只是让分歧可见,不能消灭分歧。
易错点#
| 易错说法 | 问题 | 应该说 |
|---|---|---|
| 「用几个布尔标志表示进度就行」 | 合法组合集合是隐式的 | 组合里大部分非法却真的会出现;显式状态可枚举、可校验、可恢复 |
「状态就是数据库里那个 status 字段」 |
混淆状态与载荷 | 状态要小到能枚举,载荷另放;状态是「当前允许什么」的答案 |
| 「状态字段越多越清楚」 | 引入第二真相来源 | 能派生的量不要存第二份;否则迟早分叉 |
| 「保存进程内存快照就能恢复」 | 低估了一致捕获的难度 | 栈 / 闭包 / 句柄 / 连接池难以一致捕获且不可移植;持久化执行改记事件历史 |
| 「重放就是重新执行一遍」 | 漏了「跳过已完成」 | 重放时先查历史:有记录就返回记录结果、不真的执行;代码会重跑,副作用不会 |
「工作流代码里调和 now() 无所谓」 |
破坏确定性 | 两次跑拿到不同时间 → 分支走岔;时钟、随机、IO 全都要走框架命令 |
| 「历史对不上就重跑一遍」 | 掩盖真实不兼容 | 「命令对不上历史」意味着代码与旧历史不兼容,这个报错是保护不是 bug |
| 「持久化执行保证外部副作用恰好一次」 | 越过了信任边界 | 它只自动跳过已记录的步骤;活动必须幂等或带操作标识,外部世界仍要自己保证 |
| 「加 checkpoint 和持久化执行是两回事」 | 漏了它们是同一谱系 | 重试 = 重放 + 跳过已完成;加 checkpoint 就是手工版的持久化执行 |
| 「checkpoint 写完就能用」 | 混淆写入与可用 | 数据文件 + 提交记录共同构成可用快照(《AI Infra》10.4.3 节的实验:保存接口约 7 ms 返回、恢复元数据约 48 ms 才提交) |
| 「终态之后再也不会收到事件」 | 忽略了迟到与重复 | 必须显式选一种:忽略并记录 / 定义补偿迁移 / 幂等接受;唯一错的是让终态静默接受迁移 |
| 「状态和副作用总能一致」 | 除非同事务,否则不可能 | 两个方向都会发生;出路是幂等副作用 + 先查询再重试,或outbox / 事务消息 |
| 「从数据推断状态更省事」 | 只看到省字段 | 隐式状态无法记录何时变、为何失败,且改一个字段可能被解读成状态跳变;判据是「状态是不是『允许哪些事件』的权威答案」 |
| 「LangGraph 就是有限状态机」 | 类比的边界没说清 | 它的状态含任意长消息列表、状态空间无界;收益来自控制流显式化,不来自「状态有限」 |
| 讲成「我们线上就是这套」 | 触碰仓库硬规矩 | 你没在生产跑过工作流引擎;用「这套账我会这么算」的口径 |
自测#
1. 一个任务用 已扣款 / 已发货 / 已通知 三个布尔量表示进度,现在线上出现了「已通知为真、已扣款为假」的任务。请诊断并给出改法。
答案
**诊断:这不是数据被写坏了,而是合法组合同构上没有被表达出来。** 三个布尔量有 $2^3=8$ 种组合,而业务上只有 4 种是合法路径;**其余 4 种是「不可能状态」,但没有任何机制阻止代码进入它们** —— 任何一处代码都能单独把 `已通知` 置为 `true`。这类 bug 的根源是「**合法组合的集合只存在于写代码的人的脑子里**」,所以既无法穷举测试,也无法在写入时校验。改法:换成显式状态 + 事件。 状态取 待扣款 / 已扣款 / 已发货 / 已完成(互斥、可枚举);事件是 扣款成功 / 发货成功 / 通知成功;迁移函数是状态 × 事件 → 新状态,非法组合(如「待扣款」下收到「发货成功」)直接拒绝并记录,而不是静默改字段。三样收益:可枚举(能每个状态写一组测试)、可校验(非法迁移当场拒绝)、可恢复(一个状态值就是全部进度)。附带收益是按状态打点做观测(多少任务卡在哪一步)。
要注意两个边界:① 状态不要吞并载荷 —— 「已发货」这个状态之外,运单号这类数据应当另放,状态是「当前允许什么」的答案,不是数据库;② 不要留下第二真相来源 —— 如果「已扣款」本身在支付表里有权威记录,就要说清谁是主,否则两个真相来源迟早分叉。
2. 有人说“Temporal 里 now() 用一下没关系,反正引擎会重放”。错在哪?
答案
**错在「重放」这个词被理解成了「重新执行一遍」—— 重放能成立的前提恰恰是代码确定性。** 判据只有一条:**这段代码重放时会不会得到和上次不同的结果。** 直接调 `now()`:第一次执行和重放时拿到的挂钟时间不同,于是**超时判断、日期分支、按时间排序**都可能走到另一条分支上;而一旦分支不同,**后续命令的序列就和历史对不上了**。同级的问题还有三类:① 随机数 / UUID —— 重放会选到另一个值甚至另一条分支;② 直接发网络请求 / 读文件 / 查库 —— 副作用不能重跑,而且结果需要被记录才能重放;③ 遍历无序容器的迭代顺序、线程/竞争式协程的调度顺序 —— 运行时的非确定性会直接反映成命令序列变化。
改法:时钟和随机源都用框架提供的(重放时返回历史里记下的值);所有 IO 放进活动(activity),活动结果被记进历史。最典型的失败模式是「命令对不上历史」 —— 重放时代码多调了一个命令或换了顺序,引擎发现历史里对应位置不是这个命令,就报错拒绝。这个报错是保护而不是 bug:它说明这次部署的代码与那条历史不兼容,应当走版本化 API,或等存量工作流跑完再发布。
再补一条活动侧的约束:活动会真的执行、会超时、会被重试,所以活动本身必须幂等或带操作标识 —— 持久化执行把「跳过已完成的步骤」自动化了,但它管不到外部世界的副作用是否只发生了一次(见 ../06-agent-infra/10-agent-runtime.md §6 与 timeout-retry-idempotency.md)。
3. 一个任务已经到 completed 状态,这时进来一条重复的「完成」事件和一条迟到的「取消」事件。分别怎么处理?为什么不能静默忽略?
答案
**先把两类事件分开,因为它们该走的路径不同。**重复的「完成」事件:这是至少一次投递的正常产物(见 queue-pubsub.md)。正确做法是「幂等状态迁移」 —— 已处于目标状态时,无操作并返回成功,绝不能报错,也绝不能「再迁一次」。这正是状态机能吸收重复投递的地方。
迟到的「取消」事件:这是语义冲突,必须显式选一种策略。三个可选项:① 忽略并记录 —— 把它写进审计与告警,用来发现上游的时序问题(为什么取消在完成之后才到);② 显式定义补偿迁移 —— 从终态回到一个补偿态,走退款/回滚流程;③ 幂等地接受 —— 若这条取消本身就是触发当前终态的那个事件的回声,直接返回既有结果。选哪一个取决于业务,但必须写进状态机,而不是留给某个分支的 if。
为什么不能静默忽略:静默忽略等于状态机在最后一个状态上放弃约束,而这恰恰是「终态不可重入」这条性质存在的意义。具体有两个后果:① 问题不会被发现 —— 上游的时序错乱、重复投递、跨系统竞态都会继续存在,而系统看起来一切正常;② 一旦之后真的需要补偿,缺口已经无法重建(你不知道哪些取消被丢了)。正确姿势是区分「可安全吸收的重复」和「需要人工决策的冲突」 —— 前者幂等吸收,后者留痕并升级。
4. 状态显示「通知邮件已发送」,但用户说没收到。可能的原因有哪两个方向?分别怎么设计?
答案
**两个方向,方向不同、解法也不同。**方向一:状态先更新,副作用后发生(或失败)。 代码先写 已发送,再调邮件服务,而邮件服务超时了。解法:反过来做 —— 先记「准备发送」的意图,再执行,成功后才推进到「已发送」;重放/重试时先查询实际结果,而不是直接再发(这正是 ../06-agent-infra/10-agent-runtime.md §6 的「先分清『没做完』还是『做完了但确认丢了』」)。更彻底的做法是 outbox / 事务消息:把「要发的通知」与业务更新写进同一个事务,由后台投递、成功后标记(见 transaction-wal.md)。
方向二:副作用发生了,状态没更新。 邮件确实发出去了,但更新状态的请求超时,恢复时按状态判断「没发过」,于是又发了一遍(或者反过来,状态丢了以后被当成没做而重做)。解法:把副作用做成幂等的 —— 用操作标识 / 幂等键让重复调用不产生第二次真实发送;状态更新本身也要可重试。
要说的硬边界:除非副作用和状态在同一个事务域里,否则不存在原子提交。 跨系统(邮件服务、外部 API)天然不在同一个事务域里,所以剩下的出路只有两条:把副作用做成幂等可重试,或者意图 + 确认两段。而且这条边界不能靠框架绕过 —— 持久化执行能自动跳过已记录的步骤,但它不能保证外部世界的副作用只发生一次。
最后一句是这道题的落点:这类问题的正解不是「让状态更准」,而是承认「状态」和「副作用」之间永远有一个窗口,然后让穿过这个窗口的操作是幂等的。
5. 把你负责的 Agent 循环改写成显式状态机,收益是什么?LangGraph 的图算不算状态机?
答案
**先写状态机三件套**:**状态** = 上下文(消息列表、已完成步骤、当前目标);**事件** = 模型输出、工具结果、用户输入;**迁移** = 决策 → 行动 → 观察 → 回到决策(条件边就是守卫,例如「步数超限」或「无进展 N 步」直接转到终止)。**注意状态载荷就是上下文** —— 所以「上下文该留什么、该丢什么」和「状态必须包含决策所需的全部历史信息」(《深入理解 AI Agent》第 8 章的马尔可夫性质)是同一个问题:丢多了,恢复后行为会走岔;留多了,状态臃肿且成本上升。显式化带来的收益是立刻可数的:① 步数上限有了明确的落点(迁移守卫,而不是散落的计数器);② 无进展检测变成一条边(连续 N 步观测无实质变化 → 中止);③ 人在环中变成「进入『需要输入』状态」,而不是「循环卡住」(这个状态在 A2A 里是被标准化过的一个非终态,见 ../04-agent-architecture/13-multi-agent-coordination.md §5);④ 重启后接着跑有了明确对象 —— 状态是显式的,就能每步落盘并按事件历史重放,这正是持久化执行对状态表达能力的要求。
LangGraph 的图算不算?算,但要说清类比的边界。 算的部分:节点是步骤/状态,边是迁移,条件边是守卫,共享状态对象就是这台机器的状态,节点之间的 handoff 就是一次迁移;而且它带来一个直接后果 —— 因为状态是显式对象,就能每步 checkpointer 落盘并恢复。不算的部分:它不是第 1 节意义上的有限状态机 —— 状态里含任意长的消息列表,状态空间无界,它是显式状态转移系统。所以收益来自「控制流显式化」,不来自「状态有限」 —— 这一点讲清楚,比说一句「LangGraph 就是状态机」准确得多。