Files
PolyLoop/research-wiki/design/0002-step-level-resume.md
T
iomgaa 4f8812fa82 docs(design): 落成边界、续跑、公共 API 形状与停止语义四份决策
第 ② 阶段需求对齐与第 ③ 阶段架构的产出,代码尚未开始。

design/0001 定边界判据:三道测试(时机 / 信息 / 性质)全过才在界内,
外加「只认接缝、不认接缝后面是什么」与不夺走下游实验因子的排除条款。
design/0002 定步级续跑:不承诺原子性,承诺绝不静默丢失与不替工具猜幂等性;
先写意图再执行、结果 ID 预分配、重放策略由工具声明且默认绝不重放。
design/0003 定公共 API 形状:单一入口两个动词、五个接缝、三个伪接缝的排除理由、
分层与九条依赖规则。design/0004 定停止判定顺序、十个停止原因取值与步记录字段表。
0003 与 0004 需过 CLAUDE.md §2 人类门,已由项目负责人确认,状态转为已接受。

explanation/scope.md 与 explanation/architecture.md 是这四份决策的常青回写,
分层与模块边界的权威在 architecture.md,将来由 import-linter 契约机器断言。
migrations/ 下 dissect 是唯一的硬迁移验收,govdoc-saas 只做设计级对齐。

三道闸都过了:14 agent 对抗辩论定骨架,两轮硕士生阅读报的 30 余条已修完,
Codex 对抗审查抓出的两条致命问题(提交型完成被误判成环境故障、
崩溃恢复漏一个状态)已修,修完的形状还没送 Codex 复审。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:48:33 -04:00

11 KiB
Raw Permalink Blame History

Design 0002 · 步级恢复与它保证不了的东西

日期 2026-08-07 · 状态 已接受

背景

需要的两个前提

一次运行由若干步组成,每一步都可能对外部世界产生副作用。 一步的形状是:调模型拿到 决策,把决策解析成一个动作,把动作交给环境执行,拿回观察,写进历史。其中「环境执行」 那一下是真的在动外部世界——写文件、跑命令、调外部 API。

进程会在任意时刻死掉。 被 kill、机器重启、OOM。四个下游都会撞上,dissect 一次实验 跑几百个 rollout 时进程被杀是常态。

问题

进程死掉之后重新跑,有两种做法。整次运行重跑,代价是已经花掉的模型调用白花,而且已经发生 的副作用会再发生一遍。从断点接着跑,代价是要回答一个很难的问题:上一步到底做完没有。

难在哪里,取决于断点落在哪个相位:

断在模型调用之前 —— 没花钱、没副作用。重跑这一步,干净。

断在模型返回之后、动作执行之前 —— 钱已经花了,动作还没做。重跑会重复付费。对 dissect 这还不只是钱的问题:它的生成、评估、反思三本账要求每次调用都记上,而轨迹里的每一步靠 调用标识与账目对齐。重跑一次,账上多一条而轨迹里只有一条,对不上。

断在动作执行之后、观察写回之前 —— 副作用已经发生了:文件写了、命令跑了、外部 API 调了。 重跑这一步就是把副作用再做一遍。而库不知道这个动作幂不幂等。

第三种最麻烦,也是本文要处理的核心。

决策

一、不承诺原子性,承诺可检测

严格意义的原子性做不到,先把这一点写死,免得契约里出现一个交付不了的词。

原因是副作用发生在库之外(文件系统、容器、外部服务),记录发生在库这边。要让两者 同时成功或同时失败,需要一个横跨两个系统的事务,而这要求环境支持两阶段提交。环境是一个 Protocol,后面可能是 docker exec、可能是一次 HTTP 调用,没有哪个能配合。

所以契约里写的是这两条,它们都能被测试断言:

绝不静默丢失。 任何可能已经执行过的动作,一定在日志里留下痕迹;恢复时一定能被识别 为「状态未知」,而不是被当成「没发生过」。

不替工具猜幂等性。 遇到状态未知的动作要不要重放,由工具自己声明,库只执行声明。

「绝不静默丢失」是这里唯一真正的保证。它挡不住重复执行,但它把静默的重复变成了 看得见的未知——而后者是可以被处理的,前者不行。

二、先写意图,再执行,结果 ID 预先分配

执行任何有副作用的东西之前,先往日志里写一条意图记录,里面带着「这次执行的结果将来会以 哪个 ID 存下来」。执行完再按那个 ID 写结果。

预先分配 ID 是关键的一步。恢复时可以精确地问「这个 ID 的结果条目在不在」,而不是靠模糊 匹配去猜哪条结果对应哪次执行。

恢复时每一次执行有四种状态:

意图记录 结果条目 含义 做法
还没开始执行 重跑这一步
执行完了,结果也存了 跳过
状态未知 按工具声明的重放策略决定
结构上说不通 判为日志损坏,拒绝续跑

最后一行是刻意的:读到说不通的状态就失败,不修复也不带着它继续。这和公共类型「读到未知 schema major 直接失败」是同一个态度——一个被猜着修好的日志,会让后面每一个基于它的判断 都建立在猜测上,而且不会有任何地方提示这件事发生过。

三、模型调用与动作执行用同一套保护

上面那套不只用在动作上,模型调用也要。模型调用的「副作用」是花钱和记账,第二种断点情形 说的就是它。所以每次模型调用之前也写一条意图记录,带尝试序号和预分配的结果 ID。

四、重放策略由工具声明,默认「绝不重放」

工具声明自己是 safe 还是 never

  • safe —— 这个工具幂等,状态未知时重放没关系。读文件、grep 属于这类。
  • never —— 这个工具有不可重复的副作用,状态未知时绝不重放。写文件、跑 shell、调外部 API 属于这类。

这条回答了「库不知道动作幂不幂等」那个问题:库确实不知道,但它可以要求工具回答。

默认值取 never 两个方向的错误代价不对称:默认 safe 而声明漏了,后果是重复写文件、 重复提交,静默损坏数据;默认 never 而声明漏了,后果是本来能自动续上的运行多停一次, 有人会看见。这与「宁可报错也不要用默认值兜底」是同一个判断。

代价是接入的项目一开始会觉得续不上,得逐个给工具标 safe。接受这个代价,因为标错 safe 的后果要很久以后才发现。

五、库持有存储端口,「运行结束」这个标记由库写

做恢复就意味着库要往持久存储里写东西——意图日志得比进程活得久。于是有个问题:最终结果 是谁负责落盘。

决定是库自己写,通过一个存储端口(Protocol),实现由项目提供。库在把结果返回给调用方 之前先写下「这次运行结束了」这个标记。

理由是另一条路有个具体的失败场景:如果结果由项目落盘,那么「运行正常跑完、库返回了结果、 项目在存它的时候崩了」这种情况下,重启后日志显示最后一步有结果、没有结束标记,而项目那边 什么都没有。这时候该续跑吗?续了就重复执行最后一步的副作用,不续就丢掉一次已经花完钱的 运行。歧义来自结果跨了两个存储。库自己写结束标记,这个歧义不存在。

代价是库多一个存储端口,也就多一份 Protocol、多一套契约测试,还多一个降级方向问题——存储 后端挂了要报错而不是放行,因为放行意味着这次运行没有恢复能力,而调用方不知道。

六、记录种类按本项目真实有的功能定,不照抄

意图日志需要几种记录,取决于有几种需要保护的事。本项目当前是三种:

  • 一步开始了(带尝试序号与预分配的结果 ID)
  • 一个动作要执行了(带预分配的观察 ID 与工具声明的重放策略)
  • 一次运行结束了(带结束原因)

参考实现 reference/pi 的记录道有九种,多出来的六种各自对应它有而本项目没有的功能:历史 压缩与会话树导航是两种独立的长操作(本项目的压缩发生在一次运行内部,也没有会话树); 交互式界面的消息队列有入队与撤销两种记录(本项目一次运行只在开始时拿到一个目标,中途不 接受新消息);延迟响应的挂起写入需要一条(PolyGateway 不暴露延迟响应);用量单独一条 (本项目的记账归 PolyGateway,不复制一套)。

不照抄有四条理由,每一条都是实打实的成本。

每条记录都是一份永久合同。 这些记录会进 dissect 的实验数据集和 GovDoc 的数据库,此后 受「字段只增不删不改名」约束,真要删得发新 major 并写迁移。现在抄一条用不上的,等于替 将来的自己签一份不需要而且解约很贵的合同。

抄记录不抄校验,那条记录就是装饰品。 日志的完整性靠记录之间的约束保证,不是靠单条 记录自己的规则——比如「中止之后不该再有入队记录」这条,只有在同时存在队列记录和中止记录 时才有意义。抄了记录不抄约束,那个字段就是个没人守的洞;连约束一起抄,就要为一个本项目 没有的功能维护那些约束和它们的测试。

恢复逻辑的复杂度取决于记录能组合出多少种状态,不是记录有几条。 恢复要把每一种合法状态 都还原正确,还要把不合法的认出来并拒绝。三种记录能拼出的状态比九种少一个数量级,这直接 决定契约测试能不能写全——而写不全的恢复逻辑等于没有恢复逻辑,它会在某个没被覆盖的组合 上悄悄还原出一个错的状态然后接着跑,这类错误没有失败现场。

抄来的记录会把它背后的概念一起请进来。 一个带「会话树导航」取值的记录进来,就等于承认 本项目有会话树这个概念。一个概念进来会长出配套的取值、分支和测试,删的时候要一起动。

否决的方案

不做步级恢复,崩了就整次重跑。 这是最省事的,也是本项目最初的倾向——理由是四个消费者 里没有一个明确要求过它。否决是因为「进程莫名其妙断掉」在每个项目上都会发生,而整次重跑 既浪费已花的模型调用,也一样会重复执行副作用,并没有真正回避第三种断点情形。

只做阶段级恢复。 GovDoc 的做法:以阶段为单位,产物齐全就跳过整个阶段。它更简单,但 按 0001-scope-boundary.md 的裁决,阶段属于多个目标之间的事,在界外;而且它保护不了一次 运行内部的几十步。

结束标记由项目写。 见上面第五条的失败场景。

重放策略默认 safe 见上面第四条的不对称性论证。

照抄参考实现的九种记录。 见上面第六条的四条理由。

代价

「状态未知」这种情形无法自动消除,总有一部分运行需要人介入。 一个声明为 never 的工具 断在未知状态,库会停下来报告,而不是替谁做决定。这是设计的目的,不是缺陷,但它意味着 恢复不是全自动的。

库现在要写持久存储,这是一个新的失败面。 存储后端本身会挂、会写坏、会写满。降级方向 定为报错而不是放行,所以它挂了会直接影响可用性。

round-trip 契约测试不好写但绕不过。 跑到第 N 步存下来、读回来、接着跑完,结果必须和一 口气跑完等价(除时间戳这类显式的非确定字段)。恢复的 bug 天然没有失败现场,它表现成 「跑出来的结果有点不一样」,只有这类测试抓得住。

「绝不静默丢失」这条保证的强度受限于存储端口的实现。 如果某个实现的写入不是持久的 (比如缓冲了没落盘),保证就不成立。这要写进存储端口的契约并由契约测试断言,但库没法 强制一个第三方实现真的落了盘。

结论落到哪里

「接着跑在界内、决定与存取在界外」这条裁决落在 ../explanation/scope.md。本文其余的机制 形状(记录种类、四种恢复状态、重放策略、存储端口)现在没有代码,也还没有对应的常青文档—— 它们要等第 ③ 阶段的架构文档,那时候和分层、取消传播、契约测试放在一起写。在那份文档出现 之前,本文是这些机制的唯一记录,而按 ../README.md 的规矩,这是一个必须尽快关掉的缺口。