feat(_stopping): 落成停止判定四档,0008 转已接受
四个纯函数各管一档,顺序本身由主循环走:预算准入(A)、提示词规模(B)、连续解析失败(D)、 完成判定(G)。加一个不可变的三计数结构。 三处刻意钉死的边界,都写了测试: - 两个预算上界同时耗尽报步数那一个。理由是兼容性不是原理(某下游按步数耗尽的占比告警), 正因为推不出来才必须被测试钉死。 - 预算「达到即拦」用 >=,提示词规模「超过才拦」用 >。两处不是同一件事:一个数已经用掉 几个额度,一个量一个东西有多大。 - 环境故障排在完成判定前面;未执行不做完成判定;完成信号恒为假不是故障(初稿在这里 写错过,会让某个下游的每一次运行都在第一步终止)。 不 import polyloop.tools,五个逻辑层模块互不 import,所以「这次执行的工具被标了完成标记吗」 由调用方查好注册表传一个布尔进来。
This commit is contained in:
@@ -1,7 +1,147 @@
|
||||
"""停止判定与预算结算。内部模块。
|
||||
"""停止判定:每一档在什么条件下给出哪个停止原因。
|
||||
|
||||
判定顺序是有序的,不是一组独立条件——「恰好在最后一步做完」和「预算耗尽」的轨迹长度
|
||||
一模一样,顺序错了两者会互换,而且不会有任何地方报错。
|
||||
**内部模块**(下划线开头,不进 `polyloop/__init__.py`)。它是
|
||||
`research-wiki/design/0004-stopping-and-step-record.md` 决策三那套顺序的唯一实现;顺序本身由
|
||||
`polyloop.session` 的主循环走,这里只提供每一档的判定。
|
||||
|
||||
**纯逻辑,约束同 `_assembly`。**
|
||||
**纯逻辑,无 I/O、无事件循环。** 这一条由 `pyproject.toml` 的一条 import 契约断言(禁止
|
||||
import `asyncio` 与 `pathlib`)。它是烟雾报警不是纯度证明——`os`、`subprocess` 都能绕过去。
|
||||
|
||||
**不 import `polyloop.tools`**,五个逻辑层模块互不 import。所以「这次执行的工具被标了完成
|
||||
标记吗」由调用方查好注册表、把答案作为一个布尔传进来。
|
||||
|
||||
判定顺序的完整论证不在这里复述,见那份 design doc。这里的 docstring 只写「读这段代码的人
|
||||
不知道就会写错什么」。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from polyloop.types import ActionOutcome, ActionStatus, Budget, StopReason
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RunCounters:
|
||||
"""一次运行走到此刻的三个计数。
|
||||
|
||||
**步数与已执行动作数是两个独立计数,不是一个标量。** 两个消费者要的不是同一个量:一个数
|
||||
的是追加进轨迹的步记录条数(解析失败、模型调用失败、环境故障的步都算),一个数的是动作
|
||||
执行接缝返回「已执行」的次数。合成一个的后果是其中一方的语义被改写,而且「模型反复调不
|
||||
存在的工具烧光预算」与「真的做了五十步没做完」在轨迹上就分不开了。
|
||||
|
||||
不可变,每次推进返回新实例。并发的多次运行各持一份,不共享。
|
||||
"""
|
||||
|
||||
#: 追加进轨迹的步记录条数。
|
||||
steps_appended: int = 0
|
||||
#: 动作执行接缝返回「已执行」的次数。
|
||||
actions_executed: int = 0
|
||||
#: 连续解释不出有效决策的次数。任何一个有效决策把它清零。
|
||||
consecutive_parse_failures: int = 0
|
||||
|
||||
def with_step_appended(self) -> "RunCounters":
|
||||
return replace(self, steps_appended=self.steps_appended + 1)
|
||||
|
||||
def with_action_executed(self) -> "RunCounters":
|
||||
"""动作执行接缝返回「已执行」时加一。
|
||||
|
||||
**只有「已执行」加**:未执行与环境故障都不算真的做了事。步数那一侧另算——那一步
|
||||
照样被记进轨迹,所以两个计数在同一步里可能一个加一个不加。
|
||||
"""
|
||||
return replace(self, actions_executed=self.actions_executed + 1)
|
||||
|
||||
def with_parse_failure(self) -> "RunCounters":
|
||||
return replace(self, consecutive_parse_failures=self.consecutive_parse_failures + 1)
|
||||
|
||||
def with_parse_success(self) -> "RunCounters":
|
||||
"""任何一个有效决策(动作或最终回答)把连续失败计数清零。
|
||||
|
||||
不清零的话,一次运行里零散的几次解析失败会累加到上限,然后一次「连续失败」被报成
|
||||
停止原因——而它根本没有连续过。
|
||||
"""
|
||||
return replace(self, consecutive_parse_failures=0)
|
||||
|
||||
|
||||
def budget_admission(counters: RunCounters, budget: Budget) -> StopReason | None:
|
||||
"""A 档:预算准入。每次迭代**开头**问一次,不是上一次迭代的结尾。
|
||||
|
||||
放在开头,一次「恰好用满预算完成」的运行走到完成判定、记成目标达成;放在结尾,它会先撞
|
||||
上预算上限记成预算耗尽。**两者的轨迹长度一模一样**,事后从数据里分不出来,而按停止原因
|
||||
分层的整批统计会因此失真——本该算作成功的那些运行被计进了「预算不够」那一档。
|
||||
|
||||
**两个上限同时命中报步数那一个。** 这条的理由是兼容性而不是原理:某个下游的告警判据按
|
||||
步数耗尽的占比统计,报另一个会让那条判据在这种情况下漏掉。两个上界同时耗尽时,两个原因
|
||||
描述的其实是同一件事——所以它必须被固定下来并被测试断言,而不是留给实现随手决定
|
||||
(`research-wiki/design/0004-stopping-and-step-record.md` 决策一)。
|
||||
|
||||
上限是**可以取到**的:已追加步数**达到**上限就不许再走一步。
|
||||
"""
|
||||
if counters.steps_appended >= budget.max_steps:
|
||||
return StopReason.STEP_BUDGET
|
||||
if counters.actions_executed >= budget.max_actions:
|
||||
return StopReason.ACTION_BUDGET
|
||||
return None
|
||||
|
||||
|
||||
def prompt_size_admission(prompt_chars: int, budget: Budget) -> StopReason | None:
|
||||
"""B 档:装配出的提示词规模。**超过**上限才拦,正好等于上限是允许的。
|
||||
|
||||
和 A 档那个「达到即拦」不是一回事:那里数的是已经用掉几个额度,这里量的是一个东西有多
|
||||
大。两处都把上限本身算作允许,只是「用掉 N 个」和「有 N 那么大」在同一条界线上落到了
|
||||
不同的比较符上。
|
||||
|
||||
**超限必须显式终止,不许静默截断。** 截断是一次前缀破坏操作,会让后续每一步重新全价
|
||||
计费,而且被截断的运行表现成一批低分,看起来像模型能力不足。
|
||||
|
||||
命中这一档时**不产生步记录、也不写任何意图记录**——模型还没被调用、没花钱、没有调用
|
||||
标识需要对账。这是唯一一种「真的一步都没走」的终止。伪造一条空步会在轨迹里多出一条永远
|
||||
连不上账目的记录。这条约束由调用方遵守,这个函数管不着。
|
||||
"""
|
||||
if prompt_chars > budget.max_prompt_chars:
|
||||
return StopReason.CONTEXT_OVERFLOW
|
||||
return None
|
||||
|
||||
|
||||
def parse_failure_admission(counters: RunCounters, budget: Budget) -> StopReason | None:
|
||||
"""D 档:连续解析失败。计数**加过之后**问,达到上限就停。
|
||||
|
||||
这一支**跳过完成判定**:这一步没碰环境,环境的完成信号不可能因为它改变。
|
||||
"""
|
||||
if counters.consecutive_parse_failures >= budget.max_consecutive_parse_failures:
|
||||
return StopReason.PARSE_FAILED_REPEATEDLY
|
||||
return None
|
||||
|
||||
|
||||
def completion_verdict(outcome: ActionOutcome, tool_completes_run: bool) -> StopReason | None:
|
||||
"""G 档:动作执行之后的完成判定。返回 `None` 表示接着跑。
|
||||
|
||||
`tool_completes_run` 是调用方查注册表得来的答案:这次执行的工具被标了完成标记吗。没有
|
||||
工具的动作(模型输出的是一整段代码)传假。这个模块不 import 工具模块,所以查不了。
|
||||
|
||||
**状态是「未执行」时不做完成判定**:动作根本没进入真实执行,环境状态没变,完成条件不可能
|
||||
因为它成立。
|
||||
|
||||
**完成信号恒为「未完成」不是故障。** 没有环境完成信号的环境就是这么返回的,走「接着跑」
|
||||
那一支,靠完成标记或预算收尾。初稿在这里写错过,错法值得记下来:那时完成信号被定成
|
||||
「布尔或空、空表示取不到」,这一档写着「取不到就是环境故障」——而有一个下游每一步都返回
|
||||
空,于是它的每一次运行都会在第一步撞环境故障终止。不是边缘情况,是全部。修法是把完成
|
||||
信号收成布尔、把「查询失败」挪到状态字段上,两件事从此不共用一个取值。
|
||||
|
||||
**环境故障排在完成判定前面。** 环境坏了就没有下一步可走,继续跑只会产出一串同样的故障,
|
||||
把预算烧光而轨迹上全是噪声。
|
||||
"""
|
||||
if outcome.status is ActionStatus.ENV_ERROR:
|
||||
return StopReason.ENV_ERROR
|
||||
if outcome.status is not ActionStatus.EXECUTED:
|
||||
return None
|
||||
if outcome.env_reported_completion or tool_completes_run:
|
||||
return StopReason.TASK_COMPLETED
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RunCounters",
|
||||
"budget_admission",
|
||||
"completion_verdict",
|
||||
"parse_failure_admission",
|
||||
"prompt_size_admission",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user