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

19 KiB
Raw Blame History

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