766f7a0290
第二轮 Codex 对抗审查报的五条全部核实成立,0003/0004 已冻结,修订新写一份。 五条都不改任何决策的结论,改的是结论到形状之间那一段的落实。 决策一:步记录加一个布尔的完成信号列。0004 决策二承诺靠它区分两条完成通路, 而决策四的字段表里没有这个字段。它自己就够区分,不必查注册表—— 事后分析手上常常只有轨迹文件。 决策二:存储接缝六个方法重切一刀,把「按预分配 ID 写一条结果」拆成 「写模型调用结果」与「写动作结果与步记录」。原子性从散文变成签名里不可表达其他形态。 决策三:观察那一列的口径改成「回填进历史的那段文本」,不加字段。 dissect 的 render_messages 把 step.observation 原样套模板发出, raw_output 也是解析器截断后的版本——两侧同构,都是一段文本加一个数字。 决策四:作废 0004 词表里「一个可能取不到的完成信号」,那是上一轮 fatal bug 的原话。 决策五:存储接缝加前缀持久性。少了它,一次明明成功的模型调用会被恢复 记成「状态未知」,而它成功的证据就在同一份日志里。 结论已回写 architecture.md 第九节与决策索引;migrations/dissect.md 需求七 补上「分开记的是文本加数字,不是两段文本」,堵掉这次审查里出现过的误读。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
166 lines
12 KiB
Markdown
166 lines
12 KiB
Markdown
# Design 0005 · 存储接缝的原子写、前缀持久性,与步记录的三处修订
|
||
|
||
**日期** 2026-08-09 · **状态** 已接受(2026-08-09 项目负责人确认)
|
||
|
||
**取代** `0003-public-api-shape.md` 决策四「存储接缝」那一段里的方法清单——六个方法怎么切
|
||
改了一刀(见决策二)。那份文档的其余部分**全部继续有效**,包括存储接缝挂在定义上、全部方法
|
||
带运行标识、端口不持有隐式当前运行、不设存在性查询,以及写入粒度三条的其余两条。
|
||
|
||
**取代** `0004-stopping-and-step-record.md` 三处:「几个词的最短解释」里动作执行接缝那一条
|
||
(见决策四)、决策二里「看完成信号字段区分两种完成」那句所依赖的前提(见决策一)、决策四
|
||
字段表里「观察」那一行的口径与字段总数(见决策三)。那份文档的其余部分**全部继续有效**,
|
||
包括十个停止原因的取值、判定顺序 A 到 H、两个预算计数的语义。
|
||
|
||
**补充** `0003-public-api-shape.md` 决策四:给存储接缝加一条它没写的要求——前缀持久性
|
||
(见决策五)。
|
||
|
||
**触及** `../explanation/architecture.md` 第九节里「存储」与「动作执行」两段。本文件的结论
|
||
要回写到那里,不回写这份文档就是死的(`../README.md` 第 3 节)。
|
||
|
||
**本文件写完时状态是「待确认」,2026-08-09 由项目负责人确认后转为「已接受」。** 它要过
|
||
`../../CLAUDE.md` §2 那道人类门,因为改的是一个公共 Protocol 的方法清单与一个持久化结构的
|
||
字段集合。所以在确认之前存储接缝与步记录这两样都不写代码,现在这条阻塞解除。
|
||
|
||
## 背景
|
||
|
||
`0003` 与 `0004` 在 2026-08-08 被确认,随即送了第二轮 Codex 对抗审查——第一轮审的是有 bug
|
||
的版本,修完的形状没人看过。第二轮报了五条,逐条核实全部成立。
|
||
|
||
这五条的共同点是:**它们都不改任何一个决策的结论,改的是结论到形状之间那一段的落实**。
|
||
比如「动作结果与步记录必须原子」这个结论没人反对,但六个方法的切法让实现者没有任何办法
|
||
兑现它。这类问题在纯文档阶段几乎抓不到,因为读者会顺着结论读过去,默认形状能承载它。
|
||
|
||
`0003` 与 `0004` 已经冻结,按 `../README.md` 的规矩改不了,所以修订新写一份。
|
||
|
||
## 决策一:步记录加一个「完成信号」字段
|
||
|
||
`0004` 决策二写着:`task_completed` 底下压着两个可信度不同的信号源,要区分是哪一种,看那
|
||
一步的步记录——环境信号那种的完成信号字段为「已完成」。但决策四的字段表里**没有这个字段**。
|
||
|
||
失败场景不需要构造:一次 dissect 的 AppWorld 运行在第 N 步停在 `task_completed`,一次 GovDoc
|
||
的运行也在第 N 步停在 `task_completed`,事后分析打开两份轨迹,按文档去找那一列,找不到。
|
||
|
||
**加一个布尔字段,新增字段从四个变五个。** 口径是这一步的动作执行接缝返回的完成信号原值;
|
||
没有动作的步(模型调用失败、解析失败)恒为「未完成」。
|
||
|
||
**它自己就够区分两条完成通路,不需要再查注册表。** 停止原因是 `task_completed` 而这一列为
|
||
「已完成」,走的是环境那条;同样的停止原因而这一列为「未完成」,那只可能是完成标记被触发。
|
||
这一点重要,因为事后分析手上往往只有轨迹文件——dissect 的公共设定规定反思模型读取证据的
|
||
方式是 agentic 文件探索,那份 jsonl 离开数据库也得能读懂。靠「工具名 + 注册表里哪些工具被
|
||
标了完成」来区分的话,注册表不在文件里,这个区分就只有当时在场的人做得出来。
|
||
|
||
**不改成拆两个停止原因取值。** `0004` 决策二论证过为什么共用一个:「这次运行为什么停」的
|
||
答案是同一个。那条论证本文不动。
|
||
|
||
## 决策二:存储接缝的六个方法重新切一刀
|
||
|
||
`0003` 决策四给的六个方法是:写运行开始、写一条意图、按预分配 ID 写一条结果、写一条步记录、
|
||
读回整份日志、写运行结束。同一节又要求「动作结果与步记录必须作为一次原子写入落地」。
|
||
|
||
**这两句话互相矛盾,而矛盾的一侧是可执行的那一侧。** 实现者照方法清单写,动作结果走「写
|
||
一条结果」、步记录走「写一条步记录」,就是两次调用。jsonl 形态下这是两次 append:第一次
|
||
写完 fsync、第二次还没写,进程被杀。恢复时按 `0002` 的四态表看到「动作意图有、结果有」,
|
||
判为「执行完了,跳过」——而那一步的步记录不存在,历史文本永远丢了。这正是上一轮抓出来的
|
||
那个 bug,`0003` 自己在同一段把它的后果写清楚了,然后把能产生它的形状留在了方法清单里。
|
||
|
||
散文写「必须原子」拦不住任何人。**要拦住就得让「只写一半」在签名上不可表达。**
|
||
|
||
**新的六个方法:写运行开始、写一条意图、写模型调用结果、写动作结果与步记录、读回整份日志、
|
||
写运行结束。** 方法数没变,「按预分配 ID 写一条结果」被拆成两个——一个收模型调用结果,
|
||
一个同时收动作结果与步记录。两个都仍然按预分配 ID 写。
|
||
|
||
**「写动作结果与步记录」的动作结果那一项可以是空,但必填、无默认值。** 模型调用失败的步、
|
||
解析失败的步照样有步记录,只是没有动作(`0004` 里「步」的定义就是这么写的)。不给默认值是
|
||
因为 `../../CLAUDE.md` §6 那条——默认参数会掩盖「这一步到底有没有动作」这个关键判断,而
|
||
调用方每次显式传一个空值,这件事就在调用点看得见。
|
||
|
||
**代价有两条。** 一是模型调用结果与动作结果不再共用一个方法,jsonl 形态下两段实现几乎一样,
|
||
实现者要写两遍。二是将来如果出现第三种有副作用的执行,得再切一个方法而不是复用现成的。
|
||
两条都接受:换来的是「写了一半」这个状态在类型上不存在,而它是上一轮那个 bug 的唯一入口。
|
||
|
||
**不设「这个 ID 的结果存在吗」这类存在性查询这条继续有效**,理由没变——它可以由「读回整份
|
||
日志」推出来,端口少一个方法就是少一份永久合同。
|
||
|
||
## 决策三:「观察」这一列存的是进历史的那段文本,不加字段
|
||
|
||
`0004` 决策四的字段表把「观察」的口径写成「环境返回的完整原文」。而 `0003` 决策七加「逐步
|
||
结果」这类记录,为的正是让恢复**只读不算**——手上要有前几步进历史的文本,否则重建不出下
|
||
一轮的消息序列。这两句话对不上:如果存的是完整原文,而进历史的是截断之后的版本,恢复就
|
||
得拿原文重跑一遍截断逻辑,跟重跑解释器是同一类问题。
|
||
|
||
**查了 dissect 的实现,那一列存的本来就是进历史的文本。** `harness/agent/memory.py` 的
|
||
`render_messages` 把 `step.observation` 原样套进包装模板发出去;`harness/agent/loop.py` 组装
|
||
`Step` 时 `observation_truncated_chars` 恒为 0(今天不截断)。模型那一侧同构:存下来的
|
||
`raw_output` 是解析器截断之后的文本,正是回填进历史的那段,而模型可见输出的全长由
|
||
`content_chars` 这个数字单独记。
|
||
|
||
于是 `../migrations/dissect.md` 需求七「模型原文与真正进入历史的文本必须分开记,环境观察
|
||
同理」的正确读法是:**分开记的是「文本」和「被丢掉了多少」,不是两段文本。** 两侧都是
|
||
一段文本加一个数字。
|
||
|
||
**所以改口径,不加字段:「观察」= 这一步回填进历史的那段观察文本;被丢掉了多少由
|
||
「观察被截断的字符数」单独记。** 字段总数不变。
|
||
|
||
**包装模板换了会不会让恢复出的历史不一样?不会,那条路已经堵上了。** 观察包装模板是请求级
|
||
参数(`0003` 决策三的参数表里有它),而运行开始记录携带合并后的参数快照,恢复时读回来与
|
||
当前装配比对,不一致直接报错。
|
||
|
||
**代价是环境返回的完整原文不进轨迹。** 哪天有消费者要分析「被丢掉的那部分里有什么」,现在
|
||
的字段给得出数量给不出内容。登记为已知缺口,不预留字段——按 `../../CLAUDE.md` §6,预留一个
|
||
没人用的字段等于替一个不存在的需求做了决定。
|
||
|
||
## 决策四:`0004` 术语表里动作执行接缝那一条作废
|
||
|
||
`0004` 第 26 行到 27 行写着动作执行接缝「返回状态、观察、以及一个可能取不到的完成信号」。
|
||
**「可能取不到」正是上一轮那个 fatal bug 的原始表述**,同一份文档的决策三已经把它否掉了,
|
||
词表没跟着改。
|
||
|
||
失败场景是实现者从头读:第 26 行学到完成信号是「可能取不到」的,于是写成一个可空类型,
|
||
GovDoc 那种没有环境完成信号的执行器返回空;停止判定如果沿用旧解释,GovDoc 的每一次运行
|
||
都会在第一步撞 `env_error` 终止。上一轮那个 bug 原样复活。
|
||
|
||
**替换成:库交给它一个动作,它返回五个字段——状态(已执行 / 未执行 / 环境故障)、观察、
|
||
观察是不是库合成的、完成信号(布尔)、被截断的字符数。**
|
||
|
||
单独列成一条决策而不是当勘误处理,有两个理由。`0004` 冻结了,本来就改不了;而且这句话不是
|
||
笔误,它是一个已经被推翻的设计的残留,照它写出来的代码会错得很具体。
|
||
|
||
## 决策五:存储接缝加一条前缀持久性要求
|
||
|
||
`0003` 决策四的写入序列表里,第 2 次写(模型调用结果)与第 4 次写(动作结果与步记录)都标
|
||
着「不是屏障」,理由是它们后面紧跟的不是副作用。这个理由只看了紧挨着的下一步,而**再下一步
|
||
就是新一轮的模型调用意图,那是屏障**。
|
||
|
||
失败场景:第 N 步的模型调用结果写下去了但底层还没落盘,动作意图(屏障)落了盘,动作执行了。
|
||
崩溃。恢复时读到「模型调用的意图有、结果无」,按四态表判为**状态未知**,模型调用的重放策略
|
||
默认是绝不重放,于是这一步被记成一次状态不明的模型调用。**但它其实成功了**——证据就在同一
|
||
份日志里,那条动作意图是从它的返回值解释出来的,没有它根本写不出来。结果是轨迹里多出一条
|
||
假的「状态未知」,而下游按停止原因和这类状态统计失败率。
|
||
|
||
**加一条:存储实现必须保证前缀持久性——当第 k 次写入被确认已经持久时,第 1 到 k-1 次写入
|
||
也已经持久。** 屏障因此不只保证自己落盘,也保证它之前的一切都落了盘。
|
||
|
||
两个已知形态天然满足它:jsonl 逐行追加同一个文件,`fsync` 一次就把之前所有内容刷下去;
|
||
关系数据库在同一个连接上顺序提交事务,先提交的先持久。
|
||
|
||
**代价是排除了一种实现形态**:把不同记录类型写进彼此之间没有顺序保证的多个后端(意图写一个
|
||
内存队列、步记录写另一个数据库)。没有消费者要这么做,dissect 是一个文件、GovDoc 是一个库,
|
||
现在把这条路封掉的成本接近零;等它真的出现再放开,那时至少知道要为它补什么。
|
||
|
||
## 这五条为什么第一轮没被发现
|
||
|
||
第一轮 Codex 审的是有 bug 的版本,它的注意力被那两条致命问题吃掉了——完成信号的空值语义、
|
||
恢复漏掉的那个窗口。修完之后形状变了,而新形状带来的问题只有对着新形状才看得见:完成信号
|
||
从三态收成布尔,才产生了「步记录里那一列到底存不存在」的问题;「允许合并」改成「必须原子」,
|
||
才让方法清单跟契约的矛盾浮出来。
|
||
|
||
**修复本身会产生新的审查面,这是修复的常态,不是这次特殊。** 记在这里是为了让下一次不要
|
||
默认「修完就完了」——`../../CLAUDE.md` §3 点名的四类高风险产物,每改一轮都得重新过一遍那道闸。
|
||
|
||
## 留给后续的
|
||
|
||
**「被丢掉的那部分观察里有什么」查不到。** 见决策三的代价。等有消费者提出来再定。
|
||
|
||
**公共类型的英文名与接缝的具体签名仍然没定。** 本文件通篇用中文概念名,与 `0003`、`0004`
|
||
一致。存储接缝这次改了方法的切法,落地时六个方法的英文名连同其余接缝一起过人类门。
|