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:
2026-08-09 10:48:33 -04:00
parent a2e94318b9
commit 4f8812fa82
10 changed files with 2332 additions and 4 deletions
@@ -0,0 +1,613 @@
# Design 0003 · 公共 API 的形状、模块边界与接缝清单
**日期** 2026-08-07 · **状态** 已接受(2026-08-08 项目负责人确认)
**取代** `0002-step-level-resume.md` 决策六里「本项目当前是三种」那一段,改成五种(见决策七)。
那份文件的其余部分——不承诺原子性、先写意图再执行、结果 ID 预分配、重放策略由工具声明且
默认绝不重放、库自己持有存储端口并在返回结果前写下结束标记——**全部继续有效**。
**触及** `../explanation/architecture.md` 第六到第十一节,以及 `../explanation/scope.md`
裁决清单。本文件的结论要回写到那两处,不回写这份文档就是死的(`../README.md` 第 3 节)。
**本文件写完时状态是「待确认」,2026-08-08 由项目负责人确认后转为「已接受」。** 它要过
`../../CLAUDE.md` §2 那道人类门,因为定的是公共类型的字段、Protocol 签名与模块边界;照一份
没过门的形状写下去,返工的是全部实现而不是一份文档。所以在确认之前 `src/polyloop/` 下不写
任何代码,现在这条阻塞解除。
> **本文件超过了 `../../CLAUDE.md` §6 给 `design/` 定的 600 行上限。** 按那一条停下来检查过:
> 它讲的是一件事——公共 API 的形状,以及每一处为什么这么定。九个决策全部落在「下游看得见
> 什么、按什么写代码」这个面上:分层与依赖决定了下游能 import 到什么,五个接缝决定了下游
> 要实现什么,消息形态与记录集合决定了下游要构造和存什么。
>
> 唯一够格独立成篇的候选是决策七那套记录集合(它修订 0002、更新触发点也和其余各条不同)。
> **还是留在这里**,因为它改的是存储接缝的参数类型,而存储接缝是决策四的一部分——拆出去
> 之后,读决策四的人要跳出去才知道那个接缝吃什么。
>
> 再涨就要重新检查一遍,别拿这一段当豁免。
## 几个词的最短解释
完整定义在 `../explanation/architecture.md` 第二节,这里只给读本文件够用的那一层。
- **接缝**——库定义一个接口、下游提供实现的那个边界。库只认接口的形状,不认后面是什么。
- **一次运行**——从一个目标开始、到库返回结果为止的整个过程,是本库的治理单位。
- **步**——一轮决策。一步之内最多执行一次动作;模型调用失败或解析不出动作时那一步照样成立。
- **意图日志**——库在做任何有副作用的事之前先写下的「我准备干什么」,崩溃后靠它判断做没做完。
- **参数快照**——一次运行开始时冻结的全部可复现参数,恢复时拿它和当前装配比对。
- **耐久屏障**——一次「必须确保已经落盘才能往下走」的等待点,是恢复能力的成本所在。
## 背景
### 需要的三个前提
**PolyLoop 只做执行内核。** 它持有「模型做决策 → 执行动作 → 写回观察 → 再决策」这个循环,
管预算、停止判定、逐步轨迹、取消、崩溃恢复。把多次运行组织成流程的那一层(阶段编排、
并行调度、评分汇总)留在项目侧。边界判据在 `../explanation/scope.md`
**三个消费者的成熟度差得很远。** dissect 的循环是活的、有测试、跑在真实实验里,它是唯一做
硬迁移验收的。GovDoc-SaaS 的 agent 部分还没落地,只做设计级对齐。CHSAnalyzer 的相关层
还是空的。清单在 `../migrations/` 下。
消费者按项目数是三个。`0001``0002` 里说的「四个」是另一种数法——它们把 GovDoc-Editor
(今天在生产上跑、agent 循环归 Claude Agent SDK 的那个系统)和 GovDoc-SaaS(正在重构、
要接 PolyLoop 的那个)分开数,因为在讨论需求来源时两者提供的证据不同。本文按项目数,
`../../README.md` 的消费者表也按项目数,那张表是这个数字的权威。
**公共 API 一旦发布就很难改。** 公共类型的字段只增不删不改名,改一次要发新 major 并让三个
下游同时改代码。所以本文定的每一处形状,判据都要包含「它一年之内会不会被迫破坏性变更」。
### 问题
前两份 design doc 定了边界和恢复语义,但没有回答三个问题,而它们全是公共 API:
- 公共 API 分几层?低阶循环要不要作为公共函数导出,还是只暴露一个入口把循环藏起来?
- 模块怎么划分、依赖方向怎么走?这要能写成 import-linter 契约。
- 有哪些抽象接缝、各自负责什么?哪些看起来是接缝其实不是?
### 这次决策的证据来源
本文的结论来自一轮多智能体对抗辩论:三份取证报告(从 dissect 与 GovDoc 的真实代码、以及
已冻结文档中提取出 107 条约束,其中 103 条判为硬约束)、四份业界调研(pi、Anthropic 与
OpenAI 官方 SDK、LangGraph 与 AutoGen、smolagents 与 Pydantic AI)、三份从不同优先级
独立产出的方案(实验可控性优先、接入成本优先、长期可演进优先),以及三份对它们的独立证伪
(共 17 条致命指控)。裁决稿存在 `../scratch/` 下,会被清理,所以本文不依赖它存在。
**这个来源本身要打个折扣。** 那些结论里有一部分核对过参考代码并给出了文件与行号,另一部分
没有。本文只采纳能指出具体失败场景、或能核对到代码的那些;采纳时不复述它的措辞,重新论证
一遍。有两处本文的结论与那轮辩论的推荐**不同**,都在下文标明了。
## 决策一:单一驱动入口,不导出低阶循环,也不导出单步
公共 API 只提供「跑一次」与「接着跑」两个协程。不提供 `step()`、不提供异步生成器、不提供
让调用方自己写 while 循环的低阶函数。
三条理由,每条都指向一个没有失败现场的错误。
**取消没有地方放 `finally`。** `CancelledError` 必须能穿过模型调用与环境执行,in-flight 的
容器租约、连接、临时目录要在 `finally` 里释放。如果两步之间的 await 点落在调用方的栈上,
库根本没有位置写那个 `finally`——释放责任变成调用方的自觉,而漏掉一处只表现为资源占用
慢慢往上涨。
**「这次运行结束了」这个标记没有确定的写入点。** 0002 决定这个标记由库在返回结果之前写下,
因为跨两个存储会产生歧义。调用方一旦握着终止权,库就只能靠调用方记得调一个收尾方法,
而忘记调不报错。
**停止判定顺序会漏到库外。** 它是公共契约的一部分,而且是 `../../CLAUDE.md` §3 点名必须
对抗审查的四类高危产物之一。交出循环,这个顺序就变成调用方 while 条件里的几个比较。
**异步生成器要单独论证,因为上面三条对它只成立一条。** 生成器的循环体仍然在库里,`finally`
也仍然在库的栈上,结束标记也仍然由库写——所以前两条不适用。真正拦住它的是第三条的一个变形:
生成器一旦被公开,「什么时候停」就取决于调用方还迭不迭代,于是 `break` 出去和跑到停止条件
成了两条语义不同但外部看不出区别的路径,而库要在第一条路径上做的收尾(写结束记录、发终止
事件)没有触发点。生成器的清理靠 `aclose()`,而 `aclose()` 什么时候被调、会不会被调,
取决于调用方那个 `async for` 是正常退出还是被垃圾回收——这不是库能断言的事。
除此之外,本项目举不出两个需要单步的消费者:dissect 的 runner 从头到尾只要一个跑完的结果,
它并不在两步之间插代码;GovDoc 要在**工具执行的前后**做事(挡下一次调用、改写工具结果),
那是接缝实现里就能做的,不需要把整个循环交出去。
业界证据同向。Claude Agent SDK 在选型表里把想自己写工具循环的人明确指向更低层的 SDK;
OpenAI Agents SDK 内部已按单步分解但不放进公共 API;LangGraph 的单步驱动住在私有模块里。
反向的两个例子代价可见:smolagents 真的开了 `step()`,它的官方样例里步数上限变成了用户
循环里的一个比较,预算与停止语义整个漏到库外;Pydantic AI 的单步接口把私有模块的节点类型
写进了公开签名。
## 决策二:分层与模块
十个模块,五个公开、三个内部、两个可选装配(可选的那两个也是公开的,但必须显式 import,
不进顶层再导出,理由见决策八第 9 条)。
| 模块 | 公开 | 装什么 |
|---|---|---|
| `polyloop.types` | 是 | 公共值类型、枚举、持久化记录 |
| `polyloop.ports` | 是 | 全部 Protocol 与它们的入参/返回结构体 |
| `polyloop.tools` | 是 | 工具规格与注册表,以及由注册表派生的动作执行器 |
| `polyloop.serialization` | 是 | 记录的编解码与 schema major 校验 |
| `polyloop.session` | 是 | 定义、请求、`run``resume` |
| `polyloop._assembly` | 否 | 段序、注入槽、规模度量 |
| `polyloop._stopping` | 否 | 停止判定与预算结算 |
| `polyloop._recovery` | 否 | 四态判定与运行身份校验 |
| `polyloop.stores` | 是,须显式 import | 库自带的存储实现 |
| `polyloop.adapters` | 是,须显式 import | PolyGateway 模型适配器 |
**`types` 是依赖图的汇点**:谁都能 import 它,它谁也不 import。它就是「字段只增不删不改名」
保护的那份合同本身。dissect 的 runner 要读它来把轨迹头拼回去,GovDoc 的编排层要读停止原因
来决定这个阶段算不算可挽救,写任何适配器的人都要构造它里面的结构体。
**`ports``typing.Protocol` 而不是抽象基类。** 理由是 dissect 的 `Episode` 已经是它自己的
类,还要同时满足一个更宽的、带评分能力的视图;只有结构化子类型能让同一个对象同时满足库的
窄视图和项目的宽视图。要求它继承库的基类就是反向耦合。`ports` 必须零第三方依赖、不 import
任何实现模块,否则「实现模块互不依赖」那条契约根本写不出来。
**`tools` 一个模块持有全部工具相关的事**,六件:注册、模型可见 schema 的生成、存在性与参数
校验、分发、重放策略的声明、完成标记(哪个工具一旦成功执行就代表目标达成)。
前四件必须同源,那是 `../explanation/scope.md` 已经定死的要求;后两件是本文新加进同一个
模块的,理由相同——它们都是「关于某个工具的一条事实」,分开存就会跟工具清单漂移。同源的
机器保证就是这六件住在同一个模块里、由同一个注册表实例驱动。
注册表是不可变值对象,取子集返回新实例——不能是进程级单例,因为 GovDoc 三个阶段要在同一
进程里同时持有三份不同的窄集合。
**`serialization` 公开,因为下游要在库之外读那批记录。** GovDoc 的编排层要跨进程判断「这个
阶段上次跑完没有」,它读的是存储里的记录而不是调库;dissect 的分析代码要脱离运行时读几个月
前的轨迹。两者都需要一个稳定的解码入口和 schema major 校验,而不是各自照着字段猜。它不公开
的话,这两处都会长出一份手写的解析器,然后各自漂移。
**三个内部模块用下划线开头且不进 `__init__.py`。** Python 拦不住谁去 import 它们,下划线是
唯一的机器信号。仅靠 docstring 写「实验性」在下游看不到实现的库里必然失效——OpenAI Agents
SDK 有一个类同时出现在 `__all__` 里和一句「不属于公共 API」的 docstring 里。
**三个内部模块为什么是三个而不是一个。** 它们的共同点是「无 I/O 的纯逻辑」,但各自要被
穷举测试的输入空间完全不同:`_stopping` 的输入是各项计数与上一步的结果,`_assembly` 的输入
是各段文本与注入内容,`_recovery` 的输入是一份读回来的记录序列。合成一个模块,测哪一个都要
把另外两个的输入一起构造出来。
`_stopping` 还有一条独有的理由:`../../CLAUDE.md` §3 要求停止判定与预算结算必须过 Codex
对抗审查,而审查对象散在三处这道闸就等于空转。
## 决策三:定义与请求怎么切
两个 frozen 类型。切点在**跨运行不变的能力**与**每次运行都变的数据**之间。
**定义**持有五样东西,可并发复用:
| 字段 | 是什么 |
|---|---|
| 模型调用接缝 | 见决策四 |
| 决策解释接缝 | 同上 |
| 存储接缝 | 同上 |
| 事件出口 | 同上 |
| 参数视图 | 只读,把上面四个接缝各自上报的参数聚合成一份,供参数快照取用 |
库在动作被拒绝或环境故障时要合成一段观察喂回给模型,那几段文本也挂在定义上——它们跨运行
不变,而且属于「模型看得见的东西」,必须能进快照。
**请求**每次运行构造一个,构造廉价(无 I/O、无网络校验、无哈希计算):
| 字段 | 是什么 |
|---|---|
| 运行标识 | 不透明字符串,库不解析。项目自己的六维主键之类拼成它 |
| 预算 | 各项无默认值,全必填 |
| 动作执行器 | 见决策四。环境句柄,或由工具注册表派生的分发器 |
| 工具集 | 本次可见的那个(子)注册表 |
| 上下文各段 | 已渲染好的消息序列,分运行级与目标级两段 |
| 注入内容 | 本次要贴进上下文的 Skill 条目 |
| 模型绑定 | 字符串映射,库不解释,原样透传给每次模型调用 |
| 模型调用的重放策略 | 必填无默认,见决策七 |
| 观察包装模板 | 环境观察回填历史时套的格式 |
| 工具段渲染样式 | 工具清单贴进提示词的样式 |
| 取消收尾时限 | 取消时留给「写结束记录」的秒数 |
**为什么保留定义这个对象、而不是只留一个请求结构体。** 可复现性参数必须能从**已装配好的库
对象**上读出来。
dissect 的 runner 在跑第一道题之前要生成一份运行快照,里面必须有**模型标识串**(具体是哪个
模型的哪个版本)和**网关作用域**(PolyGateway 里一组共用账号与并发配额的逻辑角色名,
决定了这次实验的调用打在哪批账号上)。这两样取不到,它直接报错拒绝开跑。
为什么不能让调用方手写传进来:手写的快照记的是**一份声明**而不是**事实**——写的人以为用的
是某个模型,实际装配的可能是另一个,而不变量检查只验数据自洽、不验数据与现实一致,这类错
没有任何机器兜得住。所以快照必须从真实对象上读。
模型客户端一旦挂到每次运行的请求上,定义级快照里就结构性地不可能有模型串——那时还没有
任何一个请求被构造出来。
另一条:三个下游共用一个底层 PolyGateway 客户端、限流桶不被拆开,只有在客户端住在一个可复用
对象上时才是结构性保证;挂在请求上就退化成靠调用方自觉。
**为什么预算只在请求这一处,不设「定义给默认值、请求可覆盖」。** 两处取值意味着「这次到底
跑的什么设置」要对照两个地方才答得出来,覆盖发生时快照记哪一个还要另外规定。dissect 每次
重传同一个 frozen 预算对象,不会漂移;GovDoc 三个阶段共用一份定义、各传一份预算,也不会
长出三份除预算外完全相同的定义。「定义给默认值」唯一的支持理由是方便,举不出失败场景。
**为什么模型绑定用字符串映射而不是一个不透明对象。** dissect 要给每次模型调用绑五维项目
信息(账本、轮次、阶段、题目、尝试序号)。用不透明对象直通最省事,代价是公共签名上一个
永久的洞,洞里的东西永远进不了参数快照,契约测试对它也不可见。核对过那五维全部能表达成
字符串,所以不需要开这个洞。代价是 dissect 的适配器要做一次还原,那是适配器该干的活。
## 决策四:五个接缝
判据只有一条:**举得出两个在本项目真实消费者身上形态明显不同的实现。** 举不出两个的不设
接缝——抽象出来也只有一个实现,白白多一份永久合同和一套契约测试。
**「两个实现」不要求来自两个不同的项目。** 同一个消费者的两个不同用途,只要形态真的不同,
就算数。判据要挡的是「只有一种做法却硬抽象出一层」,不是「用的人不够多」。这半句必须写
出来,否则会出现下面这种错误推理:某个接缝只有一个项目在用,于是判它证据不足,于是再去
找第二条判据来给它开后门——而任何一条能给它开后门的判据,多半也会把已经判为伪接缝的那几个
一起放进来。
下面五个接缝,每个都按这条判据给出两个形态。
**「序号」这个词在两处指两个不同的量,不要混。** 库自己用的是**本次运行内的第几次模型调用**
(从 0 递增,一次运行一条计数),它进意图记录、也进模型调用接缝的入参。dissect 的项目绑定
里那个「尝试序号」是**同一道题的第几次独立重做**(它的 best-of-N 重采样靠它区分),那是
项目 metadata,库不理解、原样透传。0002 决策三里说的「尝试序号」指的是前者,措辞上跟 dissect
的撞了名,实现时以本文为准。
**模型调用接缝**(挂定义)。入参是一次调用(已装配好的消息序列、本次运行内的调用序号、
运行标识、库预分配的结果 ID、原样透传的模型绑定),返回三个字段:调用标识(可为 None,
绝不为空串)、可见回复、推理段。失败以异常表达,库接住翻译成模型故障并记一条调用标识为
None 的步。
签名里不出现重试次数、退避时长、限流配额——出现即意味着库在治理一次模型调用,而那归
PolyGateway。
两个实现形态不同:dissect 要按三本账各记一条并自己按价格表算成本(PolyGateway 的成本字段
恒为 None);GovDoc 要在调用外面套退避并累加本次运行的 token。
**返回类型是库自己的,不是 re-export PolyGateway 的响应类型。** 三条理由:re-export 会让
接缝定义模块运行期依赖 PolyGateway,零依赖契约要开豁免,任何只想写测试替身的下游也得装上
它;PolyGateway 加一个字段就等于 PolyLoop 的公共类型变了一次而没发过版;而且它并不合身,
dissect 已经登记过那个类型不透出「模型是正常收尾还是被长度上限砍断」,成本字段也恒为 None。
**决策解释接缝**(挂定义)。把一次模型回复解释成三分支之一:动作、最终回答、无效决策。
库不带任何默认实现——带了就等于替某一家定了动作语言。
动作分支要带一个「这一步的动作在轨迹里长什么样」的字段,由实现方决定内容,库原样填进步
记录。dissect 传那段 Python 代码,GovDoc 传序列化后的参数。没有这个字段,dissect 轨迹里
那一列会被库改写,而那个文件是它的反思模型的唯一输入界面。
无效分支的说明文本**就是**回喂给模型的观察,不是从一个固定串里取。dissect 的解析器对五种
解析失败各有一条对症说明(没有代码块、空的未闭合块、闭合围栏后跟了别的内容、多块策略下
第一块为空、拼接策略下全空),压成一句会改掉实验刺激。
**动作执行接缝**(挂请求)。返回五个字段:状态(已执行 / 未执行 / 环境故障)、观察、观察
是不是库合成的、完成信号、被截断的字符数。
**完成信号是布尔,没有第三个取值。查询这件事本身失败了,由状态字段的「环境故障」表达。**
这一条初稿写错过,错法值得记下来:初稿把它定成「可为空表示取不到」,于是「这个环境根本
没有完成信号这个概念」和「有这个能力但这次问不出来」压进了同一个空值,而停止判定把空值
判为环境故障——结果是 GovDoc 那种本来就没有环境完成信号的消费者,**每一步都会被记成环境
故障,第一步就终止**。不是某个边缘情况,是全部运行。
修法有两种,选了简单的那种。一种是把完成信号扩成三态(已完成 / 未完成 / 本环境没有这个
概念),另一种是把它收成布尔、把「查询失败」挪到状态字段上。选后者,因为**第三个取值在
所有代码路径上的行为和「未完成」完全相同**——停止判定对两者的处理一模一样,步记录里没有
任何消费者要区分它们。一个行为上无差别的枚举取值正是我们自己禁止的那类抽象。
这也正好和 dissect 现有的环境协议逐字对齐:它的 `is_done` 返回布尔,docstring 明写「另外
两个 benchmark 没有这种信号,**实现应当恒返回 False**」,异常才表示查询失败。没有完成信号
的环境返回「未完成」是一条已经在跑的约定,不需要为它新造一个取值。
两个实现形态不同:dissect 把一段 Python 源码交给已经开好的容器会话,状态恒为「已执行」
(代码抛异常也是正常观察),完成信号来自问环境(AppWorld 的完成是 agent 在环境里调了一个
API、环境状态里真的留下了记录);GovDoc 拿工具名与参数去查注册表再分发,工具不存在或参数
不合 schema 时返回「未执行」,完成信号恒为「未完成」——它的完成靠提交型工具,见下一段。
两者的差别是结构差别,不是配置差别。
**判别位放在返回结构体上而不是用异常类型编码。** 失败场景具体:项目自定义工具里抛一个普通
`ValueError`(参数解析时极常见)如果被判成「工具无效、不计有效步」,模型就能无限重试同一个
坏工具直到上界耗尽。
**环境不另设接缝。** dissect 的 `Episode` 本来就同时有执行和完成查询,拆成两个 Protocol 会
让工具分发那条路径多出一个只能返回常量的空壳。但也不把环境整个折进某个工具的 handler——
那会让「查询完成信号失败要报成环境故障」这个判定移进项目的 handler 里,库断言不了,而写错
的表现是运行安静地跑到预算耗尽。
库提供由工具注册表派生的默认执行器:工具不存在与参数不合 schema 由它判定、不经过任何
handler,直接合成「未执行」。
**提交型完成靠注册表上的完成标记,不靠环境。** 注册表知道哪个(或哪几个)工具一旦被成功
执行,就代表这次运行的目标达成了。GovDoc 的完成正是这种——它的 agent 调一个提交工具来宣布
做完,环境状态一点没变。
这一项**必须和注册表的其余职责同源**,理由和「四者同源」是同一条:完成标记如果单独存在
一份清单,那份清单里的工具名和注册表里的会漂移,而漂移的表现是「模型调了提交工具,运行
却没停」——它看起来像模型不听话,不像配置错了。
dissect 不注册任何工具,所以这条路径在它那边永远不触发;它的完成全部来自环境信号。
**存储接缝**(挂定义)。六个方法:写运行开始、写一条意图、按预分配 ID 写一条结果、写一条
步记录、读回整份日志、写运行结束。全部带运行标识,端口不持有「当前运行」的隐式状态——
一个有隐式当前运行的端口在并发下会把 A 的意图写进 B 的日志。
不设「这个 ID 的结果存在吗」这类存在性查询:它可以由「读回整份日志」推出来,端口少一个
方法就是少一份永久合同。
两个实现形态不同:dissect 逐行追加写本地 jsonl 文件(它今天的轨迹就是这个格式,进程被杀
也留得下可读的前半段);GovDoc 写关系数据库,因为它的编排层要跨进程查「这个阶段上次跑完
没有」。前者是追加流,后者是带事务的表,形态不同。
写入粒度是契约的一部分,三条:
**一、两条意图记录必须各自单独落地。** 意图必须在副作用之前就持久,这是 0002 的全部意义。
**二、动作结果与步记录必须作为一次原子写入落地。** 存储实现要么两者都可见、要么都不可见,
不许出现只写了一半的中间态。
这一条初稿写的是「允许合并成一次写」,那是错的。「允许」意味着实现可以分开写,于是存在
「动作结果写成功、步记录还没写就崩了」这个窗口;而恢复的四态判定按「意图 / 结果」判,
这种状态会被读成「执行完了,跳过」,那一步的历史文本就永远丢了——恢复出来的消息序列比
不中断跑完时少一轮,而模型看到的东西不一样,后面每一步都跟着偏。这正是 0003 决策七加
「逐步结果」这类记录要防的事,一个「允许」把它放了回来。
**三、模型调用结果单独落地,合并不了。** 它和步记录之间隔着一次动作执行,物理上没法凑成
一次写。
于是一步的写入序列是四次,其中两个是耐久屏障:
| 顺序 | 写什么 | 是不是屏障 |
|---|---|---|
| 1 | 模型调用意图 | **是**——必须落盘才能发出调用 |
| 2 | 模型调用结果 | 否 |
| 3 | 动作意图 | **是**——必须落盘才能执行动作 |
| 4 | 动作结果与步记录(**必须原子**) | 否 |
「耐久屏障」指一次必须确认已经落盘才能往下走的等待点。第 2、4 次写不是屏障,因为它们后面
紧跟的不是副作用;崩溃落在它们身上,恢复时看到的是「有意图没结果」,正好是意图日志要
识别的那个状态。
**事件出口**(挂定义)。投递失败由库捕获、记日志、把失败计数加一,然后继续跑。**失败不再
转成一条事件从同一个出口发出去**——那会自我喂食,一个持续失败的出口会让失败处理路径变成
递归。
那个计数放在**运行结果**上,是一个带默认值 0 的公共字段。放在返回值上而不是只记日志,是
因为日志没人看;而且这不是空的 `except`,不触发 ruff 的相应规则。
两个实现形态不同:GovDoc 有两个——一个把进度逐步回写业务数据库供前端轮询,一个把审计事件
送进日志管道;前者要求低延迟、可以丢,后者要求不丢、可以慢。dissect 装一个空实现。
按判据这一条过线靠的是 GovDoc 内部那两个,不是「两个项目各一个」。
## 决策五:三个候选接缝判为伪接缝
**完成判定不设接缝。** 它有两个信号源,两个都已经在库拿得到的东西上:动作执行接缝返回值里
的完成信号,以及工具注册表上的完成标记。再设一个接缝等于同一件事有第三个来源,而多个来源
迟早分叉——分叉的表现是「模型明明提交了,运行却没停」,看起来像模型不听话而不像配置错了。
这两个信号源**不能合并成一个**:前者是环境状态里真的留下了记录,后者是 agent 自己宣布的。
dissect 的环境协议专门写过这个区别——「agent 说自己做完了不算环境侧信号,把这类自报当成
环境侧信号,等于让 agent 单方面宣布自己成功」。两者可信度不同,所以分开表达;但都由库消费,
不各开一个接缝。
**观察投影不设接缝。** 要搬运的两个量(模型原文与进历史文本、环境观察与是否合成)已经分别
由决策解释与动作执行的返回结构体携带。再开一条能改观察的路径,两条路径必然分叉——
`docagent-core` 的 hook 文档承诺「替换」而代码做的是「追加」,pi 的三份投影已经漂移到
同一条记录在正常回合可见、在压缩里不可见。
**上下文装配不设接缝。** 真正会变的是渲染格式,而渲染格式按 `../explanation/scope.md`
排除条款留在项目侧——它是 dissect 要扫的实验因子,库把它写死,那段文本就成了库定的实验
刺激并且逃出项目的参数快照。归库的只有段顺序与注入槽的位置,那是纯函数不是扩展点。
于是 GovDoc 那边「提示词要从工作区读上一阶段的产物」这件事,解法是它自己读好了、把结果
作为上下文的一段交进来,而不是让库接受一个它不认识的项目对象再转交。
## 决策六:消息形态是内容块序列,不是裸字符串
**这一条与那轮辩论的推荐不同,理由重新论证。**
一条消息 = 一个角色 + 一个内容块序列。第一版只定义文本块,但类型从第一天起就是序列。
那轮辩论推荐把内容钉死成字符串,唯一的理由是上下文规模上限要在装配之后、模型调用之前判定,
而判定要求库能数出字符数。这个顾虑成立,但结论跳过了一步:库不需要内容是字符串,只需要
每个块**能报出一个规模度量**。
不钉死的理由有三条。
**PolyGateway 本来就接受多模态内容数组。** 钉死成字符串会让 PolyLoop 比它下面那一层还窄,
而 PolyLoop 的定位是在它之上加一层循环治理,不是缩窄它。
**CHSAnalyzer 是影像项目。** 它将来接入几乎必然要传图。
这里要跟 `0001` 决策三那条准入规矩对齐一下,否则下一个人不知道能不能援引本条。那条规矩是
「界外的东西要进来,先有两个真实消费者;在那之前只登记不实现,**也不为它预留结构**」,
理由是猜出来的接缝比没有接缝更难拆。
本条**不是**对那条规矩的例外,因为它没有预留任何结构:第一版只定义文本块,没有图片块、
没有为图片留字段、没有为它开接缝。它改的只是**容器的形状**——序列而不是裸值。判据可以
写成一句:**如果将来加这个东西是兼容变更,就不必现在做;如果是破坏性变更,那就要在第一版
把容器留对。** 前者不预留,后者不是预留是选对类型。
Skill 注入的通道字段是同一个判据下的另一个例子:它现在只有一个通道在用,但它是个映射不是
单值,因为将来多一个通道是加一个键,不是改类型。
**放宽是破坏性变更,钉死不是。** 把内容从字符串改成联合类型不是加字段,是改类型;所有把它
当字符串用的下游代码会静默出错或直接崩。反过来,一开始就是序列而第一版只有文本块,将来
加一种块类型是加联合分支,对逐块处理的代码完全兼容。
角色取值同理:第一版只有系统、用户、助手三种,但它是枚举,加取值是兼容变更。模型 API 原生的
工具调用与工具结果消息将来要靠这两处扩展承载——GovDoc-Editor 今天在生产上跑的正是原生
工具调用路线。
**代价照实认下:多模态内容的规模度量现在没有答案。** 一张图在上下文里占多少「字符」不是一个
有意义的问题,它占的是 token 而且换算依供应商而变。第一版不回答这个问题,因为没有消费者
可以校准;第一版只保证文本块的度量是准确的字符数。将来加图片块时,必须同时给出它的度量
定义并写清楚这个定义是怎么来的,以及上下文上限在混合内容下的语义。这一条登记为已知缺口。
## 决策七:记录集合从三类扩到五类(修订 0002)
0002 定了三类记录:一步开始了、一个动作要执行了、一次运行结束了。加两类:**运行开始**与
**逐步结果**
**为什么要加逐步结果。** 只存模型原始回复与环境原始观察的话,恢复时手上没有前几步「进历史
的文本」,重建不出下一轮的消息序列,只能拿存下来的模型回复**再跑一遍解释器**。这凭空产生
一条从没声明过的契约:解释器必须永远确定、永远不能升级。而 GovDoc 的解释器正在持续加容错
规则。
失败场景可直接写成测试:跑到第 N 步存盘,改一条解释器的容错规则,恢复,断言轨迹与一口气
跑完等价——当前设计下这条断言必挂,而库不会报错。加上这类记录之后,恢复只读不算。
**「只读不算」只对已经完成的步成立,被打断的那一步是例外。** 崩溃可能落在模型调用结果已经
落地、动作意图还没写的位置。那时这一步既没有步记录、动作也还没执行,恢复必须拿存下来的
模型回复重新跑一次解释器才能得到动作。
这个例外是安全的,因为**副作用还没发生**:重新解释出一个不同的动作,跟第一次就解释成那个
动作没有区别。它跟前面那个失败场景的分界很清楚——已完成的步永远不重新解释,被打断的那一步
必须重新解释。这条要写进恢复的契约测试。
**为什么要加运行开始。** 它携带合并后的参数快照(定义级 + 请求级)。恢复时读回来与当前装配
比对,不一致直接报错。
这道守卫 dissect 今天已经有(`harness/record/store.py``verify_same_run``runner.py`
任何写入之前调它)。库接管恢复责任之后不能把它弄丢。没有它,用同一个运行标识但换一份定义
恢复,前几步与后几步会来自两个不同的模型而全程零报错——这正是 `../../CLAUDE.md` §1.4 点名
的、要到统计阶段才分不清哪些行是真的那类损坏。
**这不违反 0002 决策六的原则。** 那条原则是「记录种类按本项目真实有的功能定,不照抄参考
实现的九种」。加这两类正是在应用它:每一类都由一个具体的失败场景逼出来,不是抄来的。
**模型调用的重放策略从「工具声明」扩到「请求上的必填字段」。** 0002 决策四只让工具声明重放
策略,而模型调用没有工具。模型调用到结果落盘之间是一步之内最长的等待窗口,崩溃概率正比于
窗口长度;这一档没定义,恢复能力在实践中大半不生效。做成必填、无默认,强迫这次选择被看见。
**它挂在请求上而不是定义上,因为它不是模型的属性,是这次运行愿意付什么代价的选择。**
同一个模型客户端,dissect 跑实验时选「绝不重放」(重放会让账目多出一条而轨迹里只有一条,
两张表的连接键对不上);同一份定义如果被拿去跑一次不进实验数据集的冒烟测试,选「可重放」
反而更合适。取值随用途变而不随模型变,所以按决策三那条切线它归请求。
## 决策八:依赖规则
九条,全部能写成 import-linter 契约或一个测试。
1. 分层,自高向低:装配层(`session``stores``adapters`> 逻辑层(`tools``_assembly`
`_stopping``_recovery``serialization`> `ports` > `types`
2. **装配层那三个互相独立**`session` 不许 import `stores``adapters`,反过来也不许。
初稿把 `stores`/`adapters` 排在 `session` **之上**,那在分层契约里意味着「允许存储实现
import session」——不该允许。同层加独立性契约才禁得住。它守的是「库不顺手提供任何默认
实现」:`session` 一旦 import 了某个存储实现,那个实现就成了隐式默认,而不传存储的人
不会知道自己这次运行没有恢复能力。
3. `tools``_assembly``_stopping``_recovery``serialization` 五者互不 import,不设豁免。
它们之间的编织只能发生在 `session` 里。
4. `polyloop` 全部子模块禁止 import 任何下游项目的包。
5. 除适配器模块外,一切禁止 import `polygateway`
6. `types``ports` 禁止 import 任何第三方包。公共类型与接缝签名上不许出现第三方类型,
否则那个包的 major 就是我们的 major。
7. `_stopping``_assembly``_recovery` 禁止 import `asyncio``pathlib`
**这一条是烟雾报警,不是纯度契约**——`os``subprocess``sqlite3` 都能绕过它,而
`open()` 是内置函数,import-linter 根本看不见。它拦得住最常见的那种偷懒(写着写着顺手
`await` 一下存储、顺手读个文件),拦不住存心的。初稿把它写成「这条要求唯一写得成机器
检查的形式」,那个说法过头了。真正守住纯度的是这三个模块的**测试形态**:它们的测试不许
用任何 fixture 起外部资源、不许有 `async def`,而这一条没有机器能查,只能靠评审看。
8. `ports` 禁止 import 其余任何 `polyloop` 模块。第 1 条已覆盖,单列是为了让违规信息直接
指向「接缝定义模块被污染了」。
9. 非 import-linter:契约测试断言 `import polyloop` 之后 `sys.modules` 里没有 `polygateway`
顶层只再导出五个公开模块,存储实现与适配器必须显式 import。理由是一个「顺手提供的默认
模型客户端」会让每个进程在 import 时把网关连同它的 provider 目录一起拉起来。
第 6 条的直接后果:工具参数 schema 是**普通 JSON Schema 字典**,不是 pydantic 模型。校验
实现是库的内部依赖、随时可换。代价是工具作者要手写 JSON Schema,比写一个 pydantic 模型
啰嗦,登记为将来可加的一个电池层辅助函数,本版只登记不实现。
## 决策九:步记录以 dissect 现有字段为下界
字段名与口径原样继任 dissect 现有的十三个字段,新增字段一律带默认值。
**完整的字段清单、三个不可改写的口径、以及每个口径为什么这么定,在
`0004-stopping-and-step-record.md` 决策四。** 那里是这批参数唯一写全的地方——同一张字段表
在两份 design doc 里各写一遍,迟早有一处被改、另一处没改,而两份都是冻结文档,改的时候
不会有任何提示。
## 否决的方案
**导出低阶循环,或导出单步。** 见决策一的三条理由。
**在 `run` 之外再公开一个可手动驱动的会话对象。** 技术上成立,退出路径仍归库。否掉它的理由
是准入判据:两个消费者一个都举不出来,而每多导出一层就多一份永久合同和一套契约套件。将来
真要开,库内按「先写意图再执行」收敛出来的那份副作用清单就是它的天然粒度,不需要重构。
**一个自己持有环境生命周期与可变历史的有状态门面。** 门面持有环境生命周期,dissect 就没有
地方在会话关闭之前插入评分,而评分必须在关闭前完成否则环境状态就没了。门面持有可变历史
与步计数,dissect 那档「同一时刻用不同注入跑同一批题」的实验会让并发候选互相污染,而污染
的表现是成绩变化不是报错。
**取消定义对象,只留一个请求结构体。** 它在「每次功能增长都是加一个带默认值的字段」这一点
上确实最省,但它让参数聚合视图不存在,而 dissect 要在第一个运行之前拿到模型串。同样的问题
也否掉了「把模型调用接缝移到请求上」。
**给模型调用接缝一个不透明对象来直通项目绑定。** 见决策三。
**re-export PolyGateway 的响应类型。** 见决策四。
**用 pydantic 模型做工具参数 schema。** 见决策八第 6 条。pi 出过一次一模一样的破坏性变更
(它的 schema 库从 0.34 升到 1.x),成因正是 schema 库的类型出现在公共 API 表面。
**把完成判定、观察投影、上下文装配各设成一个接缝。** 见决策五。
**库内做步级重试。** 一次调用之内的重试与换源归 PolyGateway,整次运行的重试归下游的 runner,
中间那一层举不出两个消费者。`docagent-core` 的反例很具体:它那层重试的默认可重试异常集合
漏掉了网关库自己的异常,于是在最需要它的场景下一声不吭地什么都不做,而这一点被它自己的
代码注释记录了下来。预算口径一并定死:PolyGateway 内部换源重试不消耗任何预算,因为库数的
是一次模型调用接缝的调用——口径必须是库自己能观测的量,否则换个后端口径就变了。
**给事件流加投递保证,让它承担 GovDoc 的审计需求。** 一旦有投递保证,事件流就变成持久结构,
此后每加一个事件类型都要走人类门改 schema 版本。审计改由轨迹与意图日志承担:模型原文与
进历史文本在步记录里各占字段,本来就不可丢。
**存储接缝做成可选参数,不传就不具备恢复能力。** 这是用默认参数掩盖关键逻辑:不传的人不会
知道自己这次运行没有恢复能力。改成必填,加一个显式命名的、明确不提供恢复的内存实现,
「我不要恢复」就成了一次看得见的选择。
**把消息内容钉死成字符串。** 见决策六。
## 代价
**每一步四次存储写、两个耐久屏障。** 对 GovDoc 的 Postgres 后端是每步两次不可合并的往返,
五十步的长阶段上可感知。两条意图记录不能合并——合并了 0002 就没有意义了。
**请求上的必填字段很多**——决策三那张表十一行,一行都不给默认值。最小可跑的请求相当臃肿,
新接入者第一印象会是这个库很难用。这是 dissect 的实验纪律(不得存在影响行为又读不出来的
默认值)外溢到另外两个消费者身上的成本,缓解只能靠文档给一个用 `dataclasses.replace`
派生模板的范式。
**在两步之间插入调用方代码这个能力真的没有了。** 调用方只能在接缝实现里插入,而接缝实现拿
不到这一步的完整记录——那是库在接缝返回之后才组装的。如果 dissect 将来要做「按上一步的
记录动态换注入」这类实验,这会立刻变成阻塞。
**多模态内容的规模度量没有答案。** 见决策六末尾。第一版只保证文本块的度量准确。
**模型调用接缝的返回类型是库自定义,所以 PolyGateway 每透出一个新事实,PolyLoop 都要跟着
发一版。** dissect 已登记的「模型是正常收尾还是被砍断」那个缺口,从「等 PolyGateway 一个
PR」变成「等两个 PR」。
**事件出口的事件类型属于后续 design doc,所以本文冻结了一个参数类型还不存在的公共签名。**
这个接缝的契约套件在那份文档之前写不了。如果那时发现事件需要携带同步返回值,签名就得改,
而那是破坏性变更。
**「模型调用一律走 PolyGateway」在这一层退化成一条约定。** import-linter 保证得了库内不写
重试限流、保证得了 PolyGateway 只出现在一个模块里,保证不了某个下游的模型接缝实现绕开它
直连。这里不假装机器守得住。
**第一版的测试体量很可能超过实现本身**:五套接缝契约套件,加上恢复的往返等价、并发隔离、
停止顺序、身份校验、取消收尾这几类,再加一条**空注入等同**——注入内容为空、工具集为空时,
装配出来的消息序列必须与「根本没有注入槽和工具段」的版本逐字节相同。这一条是 dissect 要的:
它第一阶段不注入任何东西,如果库悄悄多写一个空标题或一个换行,那一阶段的基线就和后续阶段
不可比了。`../../README.md` 的阶段清单现在没有
为它留位置,这笔账要在阶段规划里认下。
**`../migrations/dissect.md` 要补登记四项成本**:每个环境会话要写一个包装类(dissect 现有的
协议与本文的动作执行接缝不构成结构化子类型,核对过 `harness/envs/protocol.py`);续跑路径要
从「再跑一次」改成「接着跑」;要选一个存储实现并给它一个目录;模型绑定要从关键字参数还原
成字符串映射。
## 留给后续 design doc 的
**停止判定顺序、停止原因的取值、两个预算计数的语义。** 它们是本轮辩论的顺带产出,不在本文
原定范围(分层、依赖方向、接缝清单)内,而且属于 `../../CLAUDE.md` §3 点名必须对抗审查的
高危产物,值得独立成篇以便独立审查和独立引用。本文只定这些字段存在于哪个结构体上。
**事件集与具名回调的清单和签名。** 方向已定(观察走事件流、干预走具名回调),细节未定。
**上下文压缩与最终输出的 schema 校验。** 两者按 `../explanation/scope.md` 都在界内,但都凑不齐
两个真实消费者,本版不实现、不设接缝、不预留字段。`scope.md` 要相应区分「界内且已实现」
与「界内但未实现」,否则读者会以为库有这个能力。