Files
PolyLoop/research-wiki/design/0002-step-level-resume.md
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

185 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 的规矩,这是一个必须尽快关掉的缺口。