第 ② 阶段需求对齐与第 ③ 阶段架构的产出,代码尚未开始。 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>
45 KiB
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 契约或一个测试。
- 分层,自高向低:装配层(
session、stores、adapters)> 逻辑层(tools、_assembly、_stopping、_recovery、serialization)>ports>types。 - 装配层那三个互相独立:
session不许 importstores或adapters,反过来也不许。 初稿把stores/adapters排在session之上,那在分层契约里意味着「允许存储实现 import session」——不该允许。同层加独立性契约才禁得住。它守的是「库不顺手提供任何默认 实现」:session一旦 import 了某个存储实现,那个实现就成了隐式默认,而不传存储的人 不会知道自己这次运行没有恢复能力。 tools、_assembly、_stopping、_recovery、serialization五者互不 import,不设豁免。 它们之间的编织只能发生在session里。polyloop全部子模块禁止 import 任何下游项目的包。- 除适配器模块外,一切禁止 import
polygateway。 types与ports禁止 import 任何第三方包。公共类型与接缝签名上不许出现第三方类型, 否则那个包的 major 就是我们的 major。_stopping、_assembly、_recovery禁止 importasyncio与pathlib。 这一条是烟雾报警,不是纯度契约——os、subprocess、sqlite3都能绕过它,而open()是内置函数,import-linter 根本看不见。它拦得住最常见的那种偷懒(写着写着顺手await一下存储、顺手读个文件),拦不住存心的。初稿把它写成「这条要求唯一写得成机器 检查的形式」,那个说法过头了。真正守住纯度的是这三个模块的测试形态:它们的测试不许 用任何 fixture 起外部资源、不许有async def,而这一条没有机器能查,只能靠评审看。ports禁止 import 其余任何polyloop模块。第 1 条已覆盖,单列是为了让违规信息直接 指向「接缝定义模块被污染了」。- 非 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 要相应区分「界内且已实现」
与「界内但未实现」,否则读者会以为库有这个能力。