# 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 一样,只是这一份特别容易被误用,因为它含一张字段表。