docs(design): 落定第一个下游提的五个缺口的三份方案
0014 契约套件怎么发给下游、0015 参数快照的内容契约、0016 动作执行接缝抛异常时的契约。 三份都过了 CLAUDE.md §3 的硕士生冷读,冷读抓到的八处「在讲文档自己」的句子、五处缺前置 知识、三处只写结论没写理由、三处参数两地取值不同,全部采纳。 0016 否掉了提 issue 那一方倾向的方案(库捕获执行器异常转 ENV_ERROR)。三层理由:日志 「不自洽」这个前提本身不成立——异常抛出时副作用状态未知,日志停在「动作意图有、结果无」 正是照实记录;库替它写一条步记录反而是在编造,而那条记录会让恢复把未知状态抹掉;最后 它会把执行器里一个 AttributeError 变成一批环境故障,run() 照常返回正常结果,调用方拿不到 任何异常。
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
# Design 0016 · 动作执行接缝抛异常时的契约
|
||||
|
||||
**日期** 2026-08-26 · **状态** 已接受(2026-08-26 项目负责人确认)
|
||||
|
||||
**回答** 实验室 Gitea 上 PolyLoop 仓库的 issue #2,标题是「ActionExecutor 的契约没说抛异常时
|
||||
会怎样,而库这一侧不捕获」,提出者是下游项目 dissect2。
|
||||
|
||||
**补充** `0007-seam-behaviour.md` 决策一。那一条定的是三个动作状态各自在什么条件下被赋上,
|
||||
说的全是协议之内的事;本文往下定协议之外那条路——实现方不返回结果、直接抛出时会怎样。
|
||||
`0007` 的其余部分不受影响。
|
||||
|
||||
**触及** `../../src/polyloop/ports/__init__.py` 里 `ActionExecutor` 的 docstring、
|
||||
`../../tests/contract/` 里动作执行接缝那一份,以及 `../../tests/unit/test_session.py`。要回写的
|
||||
是决策一那三条正面表述:docstring 补上「环境故障走返回值、抛出的异常库不接管」,契约那份补
|
||||
一条不带断言的说明,指向库这一侧真正断言它的地方,而那条断言本身加在
|
||||
`tests/unit/test_session.py` 里(见文末)。**不回写这三处,本文就是死的**——写适配器的人读的是
|
||||
`ActionExecutor` 的 docstring,而它现在通篇不提异常。
|
||||
|
||||
## 背景
|
||||
|
||||
issue #2 指出的事实逐条核过都成立。
|
||||
|
||||
`ActionExecutor` 的 docstring 只写了一件事:动作本身报错算「已执行」,不算环境故障。那句话
|
||||
管的是**返回值**里状态那一列该填什么。实现方如果干脆不返回、直接抛出,那句话一个字都没覆盖。
|
||||
`session` 里调用执行器的那一行外面也确实没有 `try`:`_Driver._execute` 写完动作意图就 `await`
|
||||
执行器,拿到 `ActionOutcome` 往下走,异常原样穿出去。
|
||||
|
||||
对照之下,`DecisionParser` 的 docstring 明写了「不许抛异常」,并且写清了真抛了库也不接管、
|
||||
以及为什么不接管。同一个模块里的两个接缝,一个把这件事写死了,另一个从没提过。所以这不是
|
||||
「写得不够细」,是一处真实的契约空白。
|
||||
|
||||
issue 给了两条路,提出者倾向第二条:
|
||||
|
||||
- 在契约里明写「不许抛异常」,环境故障一律走返回值;
|
||||
- 库捕获执行器抛出的异常,把它转成 `ActionStatus.ENV_ERROR`,这样那一步的步记录和这次运行
|
||||
的结束记录一定会落地。
|
||||
|
||||
倾向第二条的理由是:它对「日志一定自洽」这件事的保证更硬。
|
||||
|
||||
契约按第一条定,并写得比 issue 那句话更精确。第二条被否,理由在决策二。
|
||||
|
||||
## 决策一:环境故障走返回值,异常穿出不被库接管
|
||||
|
||||
契约的正面表述有三条。
|
||||
|
||||
环境自己坏了——连不上、协议不对、开好的会话没了——执行器返回 `ActionStatus.ENV_ERROR`,
|
||||
不要以异常表达。
|
||||
|
||||
实现方真的抛出了异常,库不捕获,异常原样穿出 `run()` 与 `resume()`。调用方拿到的是那个异常
|
||||
本身,不是一个正常返回的运行结果。
|
||||
|
||||
`asyncio.CancelledError` 必须原样穿过,不许捕获吞没。这一条来自 `../../CLAUDE.md` §1.6,对
|
||||
每一个执行器实现都成立,契约套件里已经有它的断言。这里重复一遍,是因为上一条读快了容易读成
|
||||
「异常一概不用管」,而取消恰恰是那个必须管的异常——管的方式是让它穿过去,并且在 `finally`
|
||||
里把 in-flight 资源放掉。
|
||||
|
||||
这条契约划出的责任分界是:**把可预期的环境异常翻译成 `ENV_ERROR` 是适配器的正常工作,不是
|
||||
catch-all**。一个 HTTP 客户端的连接超时、一个容器会话的「会话已关闭」,适配器知道这些异常长
|
||||
什么样,也知道它们意味着环境不能接着服务了,捕获它们并返回 `ENV_ERROR` 是在履行契约。剩下
|
||||
的那些——实现方自己都没预料到的异常——是 bug,让它穿出去。
|
||||
|
||||
**这条契约拦不住存心的实现方,代价认下来。** 一个执行器完全可以在自己的 `execute` 外面套一层
|
||||
`except Exception: return ENV_ERROR`,产生的效果和被否掉的方案二一模一样,只是发生在下游而不是
|
||||
库里。库这一侧分不出这两者——它收到的都是一个填好了状态的 `ActionOutcome`,看不出那个状态是
|
||||
判出来的还是兜出来的,也没有任何机器手段能拦。
|
||||
|
||||
**这样仍然比方案二好,差的不是能不能拦住,是谁知道自己做了这个选择。** 下游那么写,是它在
|
||||
自己的仓库里、对自己的数据做的一次决定;它知道自己这么做了,出问题时它查得到那一行。库那么
|
||||
写,是替所有下游做了同一个决定,而且没有任何一个下游有机会知道——它们只会看到一批
|
||||
`ENV_ERROR`,然后去查环境,查一个根本没坏的环境。所以契约保证的不是「不可能被绕过」,是
|
||||
**默认行为是对的**:一个照着契约写、没有多套一层的实现,它的 bug 不会被伪装成环境故障。
|
||||
|
||||
## 决策二:为什么不采纳「库捕获并转成 `ENV_ERROR`」
|
||||
|
||||
### 第一层:「日志不自洽」这个前提本身不成立
|
||||
|
||||
执行器抛异常时,日志里的状态是明确的:这一步的模型调用意图有、模型调用结果有、动作意图有、
|
||||
步记录没有。
|
||||
|
||||
`_recovery` 判一次执行处在哪一态时只看两个维度。「一次执行」指一次模型调用,或者一次动作
|
||||
执行;两个维度是「它的意图写进日志了没有」与「它的结果写进日志了没有」。四种组合各有一个
|
||||
含义(`0002-step-level-resume.md` 决策二):
|
||||
|
||||
| 意图 | 结果 | 含义 | 恢复做什么 |
|
||||
|---|---|---|---|
|
||||
| 无 | 无 | 还没开始 | 重跑这一步 |
|
||||
| 有 | 有 | 执行完了 | 跳过 |
|
||||
| 有 | 无 | 状态未知 | 按那条意图上记着的重放策略决定 |
|
||||
| 无 | 有 | 结构上说不通 | 判为日志损坏,拒绝续跑 |
|
||||
|
||||
执行器抛异常落在第三行。`plan_resume` 按那条动作意图上记着的重放策略分岔:声明为「可安全
|
||||
重放」的给出 `ResumeAction.REPLAY_LAST_ACTION`,续跑时重新解释那条已经存下来的模型回复,再
|
||||
执行一次动作;否则给出 `ResumeAction.STOP_UNKNOWN`,`resume()` 拿着它走收尾,以
|
||||
`StopReason.RESUME_STATE_UNKNOWN` 结束这次运行。
|
||||
|
||||
那条重放策略的值来自**工具规格**——给库注册一个工具时连同名字、描述、参数 schema 一起声明的
|
||||
那份说明,其中一项就是「这个工具重复执行一次是否无害」。有一类动作问不出规格:模型输出的是
|
||||
一整段代码而不是一次工具调用,没有工具名可查。这一类一律取「绝不重放」。
|
||||
|
||||
这不是误判。执行器抛异常之后,副作用到底发生没发生本来就是未知的——异常可能来自环境返回的
|
||||
错误,也可能来自适配器在拿到结果之后的一行代码,从库这一侧看不出区别。这和进程崩在动作执行
|
||||
中途是同一种状态,也正是四态表那一档要描述的东西。日志现在记的就是事实。
|
||||
|
||||
### 第二层:库替它写一条步记录反而是在编造
|
||||
|
||||
`StepCompleted` 有一条构造期不变量:`result_id` 为空当且仅当 `action_outcome` 也为空。要给
|
||||
出异常的这一步写一条步记录,就必须同时编一个 `ActionOutcome` 出来——状态、观察、完成信号、
|
||||
截断字符数,四个字段全都是库现造的,没有一个来自执行器。
|
||||
|
||||
而一条带着动作结果的完整步记录,恢复读到的是「上一步走完了」,于是接着往下跑,那个未知状态
|
||||
就被抹掉了。这比不写更糟:不写只是少一条记录,日志停在「意图有、结果无」,恢复照实判成未知;
|
||||
写了是把「不知道」改写成「知道,而且是这个值」,而后面每一步都建立在这个值上。
|
||||
|
||||
### 第三层:它把实现方的 bug 伪装成环境故障,然后送进下游的统计
|
||||
|
||||
执行器里一个 `AttributeError` 会被转成 `ActionStatus.ENV_ERROR`,这次运行以
|
||||
`StopReason.ENV_ERROR` 正常收尾,`run()` 返回一个正常的运行结果,调用方拿不到任何异常。一个
|
||||
包装类的 bug 就这样变成了「这批实验里若干次运行环境故障」。
|
||||
|
||||
这正是 `DecisionParser` 那段「不许抛」论证反对的事:库接住一个不属于协议的异常,就得给它编
|
||||
一个停止原因,而任何一个编出来的原因都会把实现的 bug 伪装成「这次运行以某某原因结束」。提
|
||||
issue 的下游自己也说了不想要「环境真的故障」和「我们的包装类有 bug」在数据里分不开,而方案二
|
||||
正好造成这个后果。
|
||||
|
||||
## 决策三:模型调用接缝捕获、动作执行接缝不捕获,这个不对称从哪来
|
||||
|
||||
`_call_model` 里捕获了 `Exception`,写一条失败的模型调用结果记录,然后以
|
||||
`StopReason.LLM_ERROR` 收尾;`_execute` 里什么都不捕获。同一份代码里两个接缝待遇相反,这不是
|
||||
疏忽,有两条理由。
|
||||
|
||||
**两个接缝的契约对「失败怎么表达」的规定正好相反。** `ModelClient` 的契约规定失败**必须**以
|
||||
异常表达,不许返回一个内容为空的正常回复——后者会让库没有任何办法把基础设施故障和「模型真的
|
||||
回了空字符串」分开,而这两者在分析里属于完全不同的类别。所以库捕获 `ModelClient` 抛出的东西,
|
||||
处理的是**协议内的正常路径**:那个异常就是契约规定的失败表达方式。`ActionExecutor` 的契约
|
||||
规定环境故障**必须**以 `ENV_ERROR` 返回值表达,于是抛异常落在协议之外,库不接管。
|
||||
|
||||
**库能不能确定地知道这一步该结算成什么。** 模型调用抛出异常意味着没拿到回复,这一点是确定的:
|
||||
本库要为这一步结算的问题是「有没有一条模型回复可用」,而这个问题有确定答案。所以库写下一条
|
||||
失败的模型调用结果,记的是事实,恢复读到它也不会再去猜。钱花没花是 PolyGateway 那一层的账,
|
||||
不是本库要结算的东西,而且「花了钱却没有留痕」这件事不会发生——那条 `ModelCallResult` 一定会
|
||||
落地,`failure` 字段说明这次失败的形态,对账要用的东西都在那儿。
|
||||
|
||||
动作执行抛出异常意味着环境状态未知。副作用发生了没有、发生了多少,库无从知道,日志里也没有
|
||||
任何一处记着,所以库没有资格替它结算成任何一个具体的值。不写恰好是照实:日志停在「动作意图
|
||||
有、步记录无」,恢复照四态表把它读成未知。
|
||||
|
||||
## 决策四:提 issue 的下游实际要做的事
|
||||
|
||||
他们的硬需求有两条:环境故障那一步也必须记,因为那一步的模型调用已经成功、钱已经花了、账已经
|
||||
在网关那边记了;以及丢掉那一步会连带丢掉模型在出故障那一步说了什么。
|
||||
|
||||
在返回 `ENV_ERROR` 那条路上,这个需求已经满足了。`_loop` 拿到执行结果之后**无条件**写一条
|
||||
步记录,然后才做完成判定并以 `StopReason.ENV_ERROR` 收尾——写步在前、判停在后,环境故障那一步
|
||||
和别的步一样有完整记录。
|
||||
|
||||
在抛异常那条路上,这一步的步记录确实没有,但账目没有丢:`_call_model` 是在拿到回复之后立刻
|
||||
写模型调用结果记录的,写完才返回,而执行器要等解释完决策之后才被调用。所以异常抛出时,那条
|
||||
模型调用结果早已经在日志里,「模型说了什么」从 `RunLog.model_results` 里读得到。丢的只是那一
|
||||
步的步记录,而步记录本来就是「这一步走完了」的标记。
|
||||
|
||||
他们担心的另一件事——「要在自己的包装类里写 catch-all,而我们的协作约定禁止这种形状」——也
|
||||
不成立。捕获具体的环境异常(连接失败、超时、会话已关闭)并返回 `ENV_ERROR`,捕获的是有名有姓
|
||||
的几个异常类型,这不是 catch-all,`../../CLAUDE.md` §1.7 禁的是吞掉错误,而这里错误没有被吞:
|
||||
它变成了一个明确的状态值,还带着给模型看的观察文本。
|
||||
|
||||
## 留给后续的
|
||||
|
||||
**`ModelClient` 抛出的实现方 bug 会被记成 `StopReason.LLM_ERROR`。** 适配器里的一个
|
||||
`AttributeError` 和模型网关真的连不上,在日志里长得一模一样。这是既定设计的已知代价,暂不
|
||||
动:那条路的正确性建立在「库能确定地知道这一步该结算成什么」上,不是建立在「能分辨 bug 和真
|
||||
故障」上。要改的话得先想清楚库凭什么区分这两者,而不是在 `_call_model` 里多加几个 `except`
|
||||
分支。
|
||||
|
||||
**契约套件验不了「实现方在环境故障时返回 `ENV_ERROR` 而不是抛异常」。** 这套件面对的是一个
|
||||
任意实现,没有办法逼它进入环境故障——真去把它的网络掐掉既不可移植,也会把那些根本没有网络的
|
||||
合法实现判成不合格。这和三个状态的触发条件验不到那一层是同一个原因,那条已经作为一段不带
|
||||
断言的说明留在动作执行接缝的契约文件里,本文这一条按同样的口径写。
|
||||
|
||||
能验的是库这一侧,也必须验:执行器抛出一个普通异常时,那个异常穿出 `run()`,且日志停在
|
||||
「动作意图有、步记录无」。这条断言落在 `../../tests/unit/test_session.py`——那里现在还没有它,
|
||||
是本文落地时要加的。
|
||||
Reference in New Issue
Block a user