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>
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
# 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 一样,只是这一份特别容易被误用,因为它含一张字段表。
|
||||
Reference in New Issue
Block a user