Files
PolyLoop/research-wiki/design/0004-stopping-and-step-record.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

285 lines
19 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 0004 · 一次运行怎么停下来,以及每一步记下什么
**日期** 2026-08-08 · **状态** 已接受(2026-08-08 项目负责人确认)
**补充** `0003-public-api-shape.md`。那份文档在「留给后续 design doc 的」一节写着「停止判定
的顺序、停止原因的取值、两个预算计数的语义还没定……在那份文档出现之前,`_stopping` 这个
模块只有一个位置,没有内容」——本文件就是那一份。它同时把 `0003` 决策九只给了下界的步记录
字段清单落实成一张表。
**触及** `../explanation/architecture.md` 第十四节。那一节现在写着「步记录的字段清单与停止
原因的取值,目前在文档体系里没有权威处」并标为「已知最严重的缺口」,本文件落地后要把那两条
划掉,改成指向本文。不回写这份文档就是死的(`../README.md` 第 3 节)。
**本文件写完时状态是「待确认」,2026-08-08 由项目负责人确认后转为「已接受」。** 它要过
`../../CLAUDE.md` §2 那道人类门,因为定的是停止原因的枚举取值与一个持久化结构的字段清单;
这两样一旦发布就受 §1.3 与 §1.4 约束——枚举取值改一个,下游按字符串匹配的分析代码会静默
查到零行。所以在确认之前 `polyloop/_stopping/` 与步记录这个公共类型都不写代码,现在这条
阻塞解除。
## 几个词的最短解释
完整定义在 `../explanation/architecture.md` 第二节,这里只给读本文件够用的那一层。
- **一次运行**——从一个目标开始、到库返回结果为止的整个过程,是本库的治理单位。
- **步**——一轮决策。模型调用失败或解析不出动作时,那一步照样成立、照样留痕,只是没有动作。
- **动作执行接缝**——库交给它一个动作,它返回状态(已执行 / 未执行 / 环境故障)、观察、
以及一个可能取不到的完成信号。
- **决策解释接缝**——把一次模型回复解释成三分支之一:动作、最终回答、无效决策。
- **意图日志**——库在做任何有副作用的事之前先写下的「我准备干什么」。
## 背景
### 问题
一次运行会以十来种不同的方式结束,而**结束方式本身是下游要分析的数据**,不是一个副产品。
dissect 的实验有一整套「崩坏判据」按停止原因分层告警:它的 `harness/record/checks_coverage.py`
里有两条 SQL 直接按字符串匹配——`WHERE stop_reason = 'step_budget'` 按占比告警,
`WHERE stop_reason IN ('llm_error', 'env_error')` 统计故障率。这两条今天在跑。
于是三件事被绑在一起,只能一起定:
**停止原因有哪些取值。** 少一档,两种本质不同的结束会被记成同一件事。多一档或改个名,
下游那两条 SQL 会静默查到零行——不报错,只是那条告警从此不再响。
**停止判定按什么顺序做。** 顺序决定了同时成立的两个条件里哪个被写下来。最典型的是
「恰好在最后一个允许的步骤上做完了」:判定顺序错了,它会被记成「预算耗尽」,而两者的轨迹
长度一模一样,事后分不出来。
**每一步记下什么。** 停止原因回答「为什么停」,步记录回答「停之前发生了什么」。两者靠同一批
分析代码消费,字段口径对不上,分析就得在两处各写一遍换算。
### 这三件事为什么必须现在定
`0003` 已经把它们排除在自己的范围之外,理由是它们属于 `../../CLAUDE.md` §3 点名必须对抗审查
的高危产物、值得独立成篇。但排除之后留下一个真空:步记录的字段清单与停止原因的取值在整个
文档体系里没有权威处——它们要继任 dissect 现有的形状,而那个形状只存在于 `reference/` 下的
代码里,按 §0 那里不是任何东西的权威。
**这个真空是临时的。** 按 §0,公共类型的字段与枚举取值的权威**是代码**,不另写参考文档
复述。`src/` 落地那天真空自动消失。本文件记的是「第一版定了什么」,代码接管之后本文冻结
在那儿,不再是查字段的地方。
## 决策一:预算是两个独立计数,不是一个标量
**步数**数的是追加进轨迹的步记录条数——包括解析失败的步、模型调用失败的步、环境故障的步。
**已执行动作数**数的是动作执行接缝返回「已执行」的次数。
两个计数各有各的上限,耗尽时撞出两个不同的停止原因。
**为什么不能合成一个。** 两个消费者要的根本不是同一个量。dissect 的 `max_steps` 数的就是
步数——它的循环写成 `for step_idx in range(max_steps)`,解析失败那一步走 `continue` 但照样
占掉一个序号,注释写明理由是「它确实消耗了一次模型调用,不计的话预算对等就不成立了」。
而 GovDoc 数的是有效步:无效的工具调用不计,另设一个总迭代上界防止模型反复调用不存在的
工具把循环卡死。
合成一个的后果是其中一方的语义被改写。分成两个之后,「模型反复调不存在的工具烧光预算」
与「真的做了五十步没做完」在轨迹上分得开,而 dissect 那一侧的语义一个字节都没变。
**两个同时耗尽时报步数那一个。** 这条的理由不是原理,是兼容性:dissect 的预注册判据按
`'step_budget'` 的占比告警,报另一个会让那条判据在这种情况下漏掉。这是本文置信度最低的
一条——两个上界同时耗尽时,两个原因描述的其实是同一件事。唯一的硬要求是它被固定并被契约
测试断言,而不是留给实现随手决定。
## 决策二:停止原因十个取值,dissect 现有的六个逐字保留
| 取值 | 什么时候 | 来源 |
|---|---|---|
| `task_completed` | 动作执行之后,环境报告目标达成,或这次执行的是被标为完成标记的工具 | dissect 现有 |
| `step_budget` | 步数预算耗尽 | dissect 现有 |
| `parse_failed_repeatedly` | 连续多步解释不出有效决策 | dissect 现有 |
| `context_overflow` | 装配出的提示词超过规模上限 | dissect 现有 |
| `env_error` | 动作执行接缝报告环境故障(含查询完成信号本身失败) | dissect 现有 |
| `llm_error` | 模型调用不可恢复地失败 | dissect 现有 |
| `agent_finished` | 模型给出最终回答 | 新增 |
| `action_budget` | 已执行动作数耗尽 | 新增 |
| `cancelled` | 外部取消 | 新增 |
| `resume_state_unknown` | 恢复时撞上「有意图没结果」,且声明为绝不重放 | 新增 |
**六个现有取值逐字保留,一个字母都不改。** 它们已经写进 dissect 的 rollouts 表,并被两条
预注册判据按字符串匹配。改名是零收益的破坏——新库跑出来的数据和历史数据从此对不上,
而且不会有任何地方报错。
**为什么加 `agent_finished`。** dissect 的动作语言里没有「最终回答」这个概念,模型要么给出
可执行的代码、要么就是解析失败。但 `0003` 决策四让决策解释接缝有三个分支,其中一支是最终
回答——那时候环境根本没被碰过,跟动作执行之后的完成是两种不同的结束。
dissect 的解释器可以继续把所有非代码输出判为无效决策,它永远不会撞上这个取值。
**`task_completed` 底下压着两个可信度不同的信号源,这一点是已知的取舍。** 一个是环境状态里
真的留下了记录(dissect 的 AppWorld 是这种),一个是 agent 调了一个被标为完成标记的工具、
环境状态一点没变(GovDoc 是这种)。dissect 的环境协议专门写过这个区别:「agent 说自己做完
了不算环境侧信号,把这类自报当成环境侧信号,等于让 agent 单方面宣布自己成功」。
**它们仍然共用一个停止原因取值**,因为「这次运行为什么停」的答案是同一个:目标达成了。
要区分是哪一种,看那一步的步记录——环境信号那种的完成信号字段为「已完成」,提交型那种的
工具名字段是那个被标记的工具。这个区分现在只在步记录里,不在停止原因里;如果哪天有下游
需要按停止原因直接分层统计这两者,那时再拆成两个取值,而那是兼容变更。
**为什么加 `action_budget`。** 见决策一。
**为什么加 `resume_state_unknown` 而不是抛异常。** 恢复时撞上未知状态是一个可预期的正常终态,
不是库自身的缺陷。抛异常会丢掉「跑到第几步、已经花了多少、前面那些步的轨迹」——而那些信息
正是项目决定「重跑还是人工介入」时要看的。所以它返回一个正常结果,停止原因是这一个。
**`cancelled` 有一个不对称,必须写清楚。** 取消时 `CancelledError` 原样重抛,`run` **不返回
结果**——为的是不破坏调用方的结构化并发语义。所以 `cancelled` 只出现在两个地方:写进意图
日志的那条结束记录里,以及恢复一个已取消运行时重建出来的结果里。
这个不对称在类型上看不出来(两处用同一个枚举),读代码的人容易写出「如果结果的停止原因是
取消」这种永假分支。只能靠 docstring 写明加一条契约测试守住。
**枚举会长,下游要防御性处理。** 按只增不删的规矩,将来加取值是兼容变更;但对用穷尽匹配
处理它的下游代码是软破坏——不会报错,只会走进一个没写的分支。已经能预见的增长点有三个:
最终输出校验失败、发生过上下文压缩、被干预回调要求停止。这一条要写进这个枚举的 docstring。
## 决策三:停止判定的顺序
每次迭代按这个顺序走,`polyloop/_stopping/` 是它的唯一实现。
**A 预算准入。** 已追加步数达到上限 → `step_budget`;已执行动作数达到上限 → `action_budget`
两者同时命中报前者。
**B 装配上下文并度量规模。** 超过上限 → `context_overflow`。**这一档不产生任何步记录,
也不写任何意图记录**——命中时模型还没被调用、没花钱、没有调用标识需要对账,这是唯一一种
「真的一步都没走」的终止。
**C 写模型调用意图,调模型。** 抛出异常 → 记一条调用标识为空的步 → `llm_error`
**D 解释决策。** 判为无效决策 → 记步、连续失败计数加一;达到上限 → `parse_failed_repeatedly`
未达上限 → 回 A。**这一支跳过完成判定**——这一步没碰环境,环境的完成信号不可能因为它改变。
**E 判为最终回答** → 记步 → `agent_finished`
**F 写动作意图,执行动作,无条件记步。** 任何一个有效决策把连续失败计数清零。
**G 完成判定。** 按这个顺序:
- 状态是环境故障 → `env_error`
- 状态是已执行,且**完成信号为「已完成」**,或者**这次执行的工具在注册表里被标为完成标记**
`task_completed`
- 其余 → 回 A。
**状态是「未执行」时不做完成判定**:动作根本没进入真实执行,环境状态没变,完成条件不可能
因为它成立。
**完成信号恒为「未完成」不是故障。** 没有环境完成信号的环境(GovDoc 全部、dissect 的两个
非 AppWorld benchmark)就是这么返回的,走「其余」这一支继续跑,靠完成标记或预算收尾。
这一档初稿写错过,错法值得记下来:初稿把完成信号定成「布尔或空、空表示取不到」,然后这里
写着「完成信号取不到 → `env_error`」——而 GovDoc 每一步都返回空,于是**它的每一次运行都会
在第一步撞环境故障终止**。不是边缘情况,是全部。修法是把完成信号收成布尔、把「查询失败」
挪到状态字段的「环境故障」上(`0003` 决策四),两件事从此不共用一个取值。
**H 回 A。**
### 为什么预算结算在下一次迭代的开头,而不是本次的结尾
这是整套顺序里唯一一处有明确失败场景的地方。
放在开头(A),一次「恰好用满预算完成」的运行走的是 G,记成 `task_completed`;放在结尾,
它会先撞上预算上限,记成 `step_budget`。**两者的轨迹长度一模一样**,事后从数据里分不出来,
而 dissect 按停止原因分层的整批数据会因此失真——本该算作成功的那些运行被计进了「预算不够」
那一档。
放在开头还有一个附带的好处:它和 dissect 现有的 `for step_idx in range(max_steps)` 逐次
对齐,迁移时不需要重新推算步数语义。
### 为什么 B 单独一档、且不产生步记录
`0003` 决策六要求消息内容能报出一个规模度量,就是为了这一档。规模超限必须显式终止,不许
静默截断——截断是一次前缀破坏操作,会让后续每一步重新全价计费,而且被截断的运行表现成
一批低分,看起来像模型能力不足。
不产生步记录,是因为这一档发生时模型还没被调用。伪造一条空步会在轨迹里多出一条没有对应
账目的记录,而步记录与账目的连接键正是模型调用标识——多出来那条永远连不上。
## 决策四:步记录的字段
十三个字段继任 dissect 现有的形状,名字与口径原样不动;四个新增字段全部带默认值。
| 字段 | 口径 | 来源 |
|---|---|---|
| 步序号 | 从 0 递增 | 继任 |
| 模型原文 | 解析之前的模型输出,解析器可能已经截断过 | 继任 |
| 可见回复字符数 | 库自己数,不从任何用量对象取 | 继任 |
| 推理段字符数 | 同上 | 继任 |
| 动作 | 这一步的动作在轨迹里长什么样,由决策解释接缝决定内容 | 继任 |
| 解析成功与否 | 布尔 | 继任 |
| 解析错误说明 | 无效决策时回喂给模型的那段文本 | 继任 |
| 观察 | 环境返回的完整原文 | 继任 |
| 观察是不是库合成的 | 区分环境返回的与库自己造的 | 继任 |
| 观察被截断的字符数 | 单独一列,可为 0 | 继任 |
| 提示词字符数 | 这一步实际发出去的规模 | 继任 |
| 模型调用标识 | 与账目之间的连接键,可为空,**绝不为空串** | 继任 |
| 整步墙钟毫秒 | 模型 + 解释 + 执行,刻意不与模型调用延迟同名 | 继任 |
| 工具名 | 工具型动作才有 | 新增 |
| 序列化后的工具参数 | 同上 | 新增 |
| 动作结算状态 | 已执行 / 未执行 / 环境故障 | 新增 |
| schema 版本 | 持久化结构的版本,读到未知 major 直接失败 | 新增 |
**模型原文那个字段保留原名与原义,不改名。** 它存的是解析之前的输出,而不是「进历史的
文本」——改名会让「轨迹逐字段可比」这条硬验收要靠 dissect 侧做一次映射,而那个映射本身就是
一处会漂移的地方。
**三个口径不可改写,理由各不相同**`0003` 决策九已经展开,这里只列结论):整步墙钟按整步
计且不与模型调用延迟同名;提示词规模按字符计不按 token 计;被截断字符数单独一列。
**模型调用标识可以是「没有」,但绝不能是空串。** 空串是个看起来合法的键,连表时静默匹配
不上;「没有」至少能被显式筛出来。它为空的合法含义只有一个:调用在记账之前就失败了。
**这张表是下界不是上界。** 将来加字段是兼容变更(带默认值),删字段或改名要发新 major。
## 否决的方案
**把两个预算合成一个计数。** 见决策一:它会改写其中一个消费者的语义,而改写之后
「模型反复调不存在的工具烧光预算」与「真的做不完」在轨迹上不可分。
**给 dissect 现有的六个取值改成更整齐的名字。** 比如把 `llm_error` 改成 `model_error`
其他术语对齐。否决理由是那两条 SQL:改名之后它们永远查到零行,一条预注册的判据静默熄火,
不会有任何地方报错。命名整齐这个收益换不来这个风险。
**把预算结算放在每次迭代的结尾。** 见决策三:「恰好用满预算完成」会被记成预算耗尽。
**上下文超限时截断而不是终止。** dissect 明令禁止,理由在 `../migrations/dissect.md` 需求
条目二。
**把完成信号定成「布尔或空,空表示取不到」。** 这是初稿的写法,被 Codex 对抗审查推翻,
展开见 G 档那一段。
**把完成信号扩成三态(已完成 / 未完成 / 本环境没有这个概念)。** 这是修上面那个错的另一条
路,也被否掉了:第三个取值在所有代码路径上的行为和「未完成」完全相同——G 档对两者的处理
一样,步记录里没有任何消费者要区分它们。一个行为上无差别的枚举取值只会腐烂,而且它还得
占一个永久的公共取值位。改成布尔加「查询失败走状态字段」更简单,也和 dissect 现有协议
逐字对齐。
**恢复撞上未知状态时抛异常。** 见决策二:会丢掉项目做决定要看的信息。
**把最终回答合进 `task_completed`。** 「谁说的做完了」这个问题从此答不了——是环境看见了
完成信号,还是模型自己宣布的。这两者在可信度上差得很远。
**为「上下文压缩发生过」现在就预留一个停止原因。** 压缩本版不实现(`../explanation/scope.md`
标为「不做」),预留一个用不上的枚举取值就是为假想需求预留结构,`0001` 决策三明令禁止。
它列在决策二末尾那三个「可预见的增长点」里,等真做的时候再加,那时是兼容变更。
## 代价
**十个取值里有四个第一版跑不出来。** dissect 用不到最终回答,GovDoc 用不到环境完成信号,
恢复未知状态要等真的崩过才见得到。一个从没被跑到过的枚举分支等于没被测过——契约测试要专门
构造这些场景,而构造「恰好崩在动作执行之后」这种场景本身就不容易。
**「两个上界同时耗尽报哪个」这条是偏好不是论证。** 它被固定下来只是为了不留给实现随手决定,
但如果哪天 dissect 的判据改了,这条也该跟着改,而没有任何机器会提示这件事。
**停止判定顺序一旦发布就是公共契约,改它是破坏性变更。** 下游会按「什么情况下得到什么停止
原因」写分析代码。而这套顺序里只有一处(预算结算的位置)有明确的失败场景论证,其余各档的
相对位置更多是「这么排读起来通顺」——它们没有被同等强度地论证过。
**步记录这张表继任的是另一个项目的字段命名习惯。** 它对 dissect 是零成本,对另外两个消费者
是要去理解一批不是为它们起的名字。选它的唯一理由是那批数据已经存在且不能重来。
**这份文档在 `src/` 落地后就不再是查字段的地方。** 按 §0,那时权威转移到代码。本文件会
留下来记录「第一版为什么定成这样」,但任何人拿它当字段手册用都会读到过期的内容——这一点
和所有 design doc 一样,只是这一份特别容易被误用,因为它含一张字段表。