4f8812fa82
第 ② 阶段需求对齐与第 ③ 阶段架构的产出,代码尚未开始。 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>
185 lines
11 KiB
Markdown
185 lines
11 KiB
Markdown
# 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` 的规矩,这是一个必须尽快关掉的缺口。
|