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
+66 -4
View File
@@ -57,6 +57,32 @@
哪份该更新、哪份不该更新,结果是全都不更新。那一版的 `API.md` 落后代码 210 个提交,
`CLAUDE.md` 落后 676 个提交,而两份文档当时都写着要保持同步。
### 两层的写法不一样,写成一样就说明有一层写错了
同一件事在两层各出现一次是正常的:分层是什么、接缝有哪些,`design/` 里定过一遍,
`explanation/` 里还要描述一遍。**重复的是事实,不重复的是理由。**
| | `design/` | `explanation/` |
|---|---|---|
| 回答什么 | 当初为什么这么定 | 现在到底是什么样 |
| 时间 | 有时间性:当时的处境、比较过哪几个、否决了什么 | 无时间性,只描述现在 |
| 主语 | 我们、这次决策 | 系统里的东西:这一层、这个接缝、这条规矩 |
| 备选方案 | 必须写全,含否决理由 | 不写——备选方案是历史 |
| 理由 | 完整论证 | 就地一两句,展开指回 `design/` |
| 冲突时 | 冻结,可能已经过时 | **以它为准** |
**记录层写成法条就走音了。** ADR 实践里这个反模式有个名字叫「Blueprint or Policy in
Disguise」——本该是一份记录活动及其结果的日志,写着写着变成了菜谱或者法条那种命令式、
权威式的口吻。design doc 该像一份设计讨论的笔记:叙述、有处境、有取舍。
**常青层写成自述也走音了。** 主语一旦变成「这份文档」「这一节」「这里」,动词一旦变成
写作动作(不复述、列出来、说清楚、免得读者),语气就错了,**哪怕那句话本身有道理**。
改法是把主语换回系统。判据在语气,不在内容——那种句子往往真的带着信息,按「删掉之后信息
有没有少」来判会把它留下来,而它照样读着别扭。
**常青文档里的决策索引只索引、不复述理由。** 复述会漂移:旧文档冻结着,索引里那句转述
却跟着人的记忆变,几个月后两处就对不上,而机器查不出来。
## 2. 常青层:四类,各自要有更新触发点
前三类的划分借自 Google 的工程文档实践,以及 Diátaxis——一个把文档按「读者此刻想干什么」
@@ -201,10 +227,46 @@ design doc 冻结的时候,它的结论必须已经落到两处之一:
形状已经定了、代码还不存在」本身就是一种真实情况——写清楚形状,再写明它还没有代码、缺口在哪,
就够了。
**冻结与取代**:写完不改。决策变了就新写一份,在新文档开头标 `supersedes: 0001`旧的原样留着。
这是从 ADRArchitecture Decision Record,架构决策记录,一种把每次架构决策单独存成一份不可修改
文件的做法)里保留下来的唯一一条机制。成本很低,但记录层的价值全靠它——只有旧文档还在,
你才能看出决策是怎么演变的。
**冻结**:写完不改。决策变了就新写一份,旧的原样留着。这是从 ADRArchitecture Decision
Record,架构决策记录,一种把每次架构决策单独存成一份不可修改文件的做法)里保留下来的
唯一一条机制。成本很低,但记录层的价值全靠它——只有旧文档还在,你才能看出决策是怎么演变的。
### 新文档和旧文档之间是什么关系,在头部写清楚
一份新的 design doc 很少是凭空长出来的,它多半跟已有的某几份有关系。**只有「整份作废」
一种关系是不够用的**——大多数时候动的只是旧文档里的一小块,而那份文档的其余部分还在生效。
所以关系词有五个,每个都写成 `**关系词** 目标 + 具体到哪一节`
| 关系词 | 什么时候用 | 必须同时写清楚 |
|---|---|---|
| **取代** | 推翻旧文档的某个结论 | 取代的是哪一节;那份文档的其余部分是不是仍然有效 |
| **补充** | 沿着旧文档的某条决策继续往下定 | 补充的是哪几条决策 |
| **回答** | 填掉旧文档明写「还没定」的坑 | 回答的是哪一节留的坑 |
| **触及** | 本文的结论要回写进哪份常青文档 | 回写到哪一节;**不回写这份 design doc 就是死的** |
| **不取代任何文件** | 填的是一块从来没人填过的空白 | 为什么这块空白到现在才填 |
「取代」必须点名其余部分仍然有效,否则读者会以为整份旧文档作废了,连那些还在生效的决策
一起丢掉。
**旧文档不回标。** 被取代的那份不加任何指向新文档的痕迹——加了就是改内容,而记录层的价值
全在于它没被改过。读者靠常青文档里的**决策索引**找到当前有效的那份,而不是靠在 `design/`
目录里翻。所以决策索引是常青文档的必备一节,它只索引不复述理由。
代价说清楚:直接跳进某一份旧 design doc 的人,有可能读到一个已经被取代的结论,而那份文档
里没有任何东西提示他。**这是接受了的风险**,换来的是记录层真的不可变。降低风险的办法是让
常青文档成为入口——任何一个问题,先在常青层找到答案,再顺着决策索引跳进 design。
### 状态字段
头部写 `**日期** YYYY-MM-DD · **状态** X`。状态只有两个取值。
**待确认**——这份 design doc 定的东西按 `../CLAUDE.md` §2 要过人类门。**这种文档必须在正文
最前面写明:在它被确认之前,什么不许做。** 只写「待确认」是不够的,那让读者无从判断这份
文档算不算数;写清楚被阻塞的是哪件具体的事,这个状态才有操作意义。
**已接受**——确认之后改成 `已接受(YYYY-MM-DD 项目负责人确认)`,并在正文留一句说明它
原来是待确认、为什么要过门。**改状态字段不算破坏冻结规则**,因为决策内容一个字没动。
不需要过门的 design doc 写完直接就是「已接受」。
**和 `explanation/architecture.md` 的分工**(这两份最容易搞混):改一次架构,两份都要动,但写的
东西不同。design doc 写「我们当时面对什么问题、比较了哪几个方案、为什么选了这个、放弃了什么」,
+147
View File
@@ -0,0 +1,147 @@
# Design 0001 · PolyLoop 的边界
**日期** 2026-08-07 · **状态** 已接受
## 背景
### 需要的三个前提
**PolyLoop 是一个库,它的治理单位是一次 Agent Session。** 所谓一次 Agent Session,指围绕
一个目标的有界多轮循环:模型做一次决策,执行一个动作,把观察写回历史,再决策,直到完成
或者撞上某个停止条件。一次模型调用本身不归它管,那是 PolyGateway 的治理单位。
**「执行内核」与「阶段编排」是两种不同的东西,日常都被叫作「agent 框架」。**
执行内核持有上面那个循环,管预算、停止判定、逐步轨迹、取消。阶段编排是另一层:它把一个
大任务切成几个固定阶段,每个阶段跑一次执行内核,阶段之间交换状态,并检查每个阶段的产物
合不合格。
四个已知消费者里,两种都有真实实现。dissect 的 `harness/agent/loop.py` 是执行内核。
GovDoc-SaaS 的 `packages/docagent-core/` 两种都有——`agent/loop.py` 是执行内核,
`workflow/` 是阶段编排(它的 `phase.py` 第一行注释写着「PES 三阶段泛化为 N 阶段」)。
GovDoc-Editor 今天在生产上跑的是阶段编排,而它把执行内核外包给了 Claude Agent SDK。
**四个消费者的成熟度差得很远。** dissect 的循环是活的,有测试,跑在真实实验里。
GovDoc-Editor 是活的生产系统,但形态是阶段编排。GovDoc-SaaS 正在重构,它的执行内核代码
移植自一个已废弃的项目,且业务层一次都没调用过。CHSAnalyzer 的评估层和诊断层还是空的,
它自己的架构文档明确写了不为它们预留结构。除这四个之外,实验室还有若干将来可能接入的
项目,现在连需求形状都不知道。
### 问题
边界画错有两个方向,代价不对称。
**画大了**:为一个还不存在的需求写代码。这类代码删不掉——它会长出配套的字段、分支和测试,
而且一旦下游装上了,公共类型的字段就受「只增不删不改名」的约束,真要删得发新 major。
**画小了**:每个下游各写一遍同样的东西。这正是抽库要消灭的事,而且写三遍就有三份 bug。
真正麻烦的不是这两个方向本身,是**没有判据**。当前只有 dissect 一个硬消费者,如果不给判据,
库会不知不觉长成 dissect 的形状;而每来一个新项目,「这个该不该进库」就要重新吵一遍,
每次吵的结论还未必一致。
## 决策
### 一、只做执行内核,阶段编排留在项目侧
三条理由。
执行内核有两份独立的一手证据——dissect 和 docagent-core 各写了一遍。阶段编排只有 GovDoc
一家,dissect 连「阶段」这个概念都没有。
依赖方向是单向的。docagent-core 自己的 import-linter 契约就写着子包互不依赖,唯一豁免是
workflow 依赖 agent。所以先做执行内核不会走进死胡同:阶段编排将来无论留在项目里还是抽成
另一个库,都是在执行内核之上加东西,不需要回头改它。
反过来做会把 dissect 挡在门外。
### 二、判据:一个目标之内的事在界内,多个目标之间的事在界外
这句话本身判不了案,配三条判据,**三条全中才在界内**:
1. **时机** —— 它发生在一次运行开始之后、返回之前。
2. **信息** —— 它不需要知道这次运行之外的任何事:上一次运行的结果、下一次运行是什么、
此刻还有几次运行在跑、这个目标是谁派下来的。
3. **性质** —— 它回答「怎么跑」,不回答「跑什么」「为什么跑」「跑得好不好」。
再配一条补充规则,否则第一条会被滥用:**只认接缝,不认接缝后面是什么。** 检索、沙箱、
容器、工作区目录,库一概不知道——它只知道有一个环境接口,交给它一个动作、拿回一段观察。
没有这条,「检索发生在一次运行之内,所以检索该进库」这种推理就成立了。
补充规则有个好用的等价说法:**能表达成「环境提供的一个动作」的,就在界外。** 检索能,
沙箱执行能,写文件能,调外部 API 能。而「记这一步花了多少预算」不能——没有任何环境会
提供这么个动作,那是库自己的算法。
当前的完整裁决清单在 `../explanation/scope.md`,那份是常青文档,会随新消费者接入而更新。
这里只记几条有代表性的推理,因为它们是判据怎么用的示范。
**评分和业务正确性判定在界外**,栽在「性质」上:它们回答「跑得好不好」。这条对 dissect 是
硬要求——让循环拿得到分数等于给被试开真值后门,实验当场作废。
**阶段产物校验在界外**,同样栽在「性质」上:`required_outputs` 判的是「这个阶段的目标达成
没有」,和评分同类。但要注意「校验」这个词底下压着三件不同的事,另外两件在界内:模型输出
能不能解析成一个动作(那是解析接缝),以及动作参数合不合法(那是环境裁决,库定语义)。
**工具的注册与分发机制在界内,而且必须由库提供。** 三条判据全过,但更硬的理由是:模型看见
的工具 schema、「这个工具存不存在」的校验、以及最后真正分发的那张表,**三者必须同源**。
不同源就会漂移——模型看见一个已经删掉的工具,或者校验放行了一个分发时找不到的名字。库不
提供的话,每个项目自己写一个注册表,就等于每个项目自己漂移一次。
**具体某个工具的实现要分两类。** 通用工具(读写文件、grep、跑 shell)不含业务概念,可以
作为可选装配放进库。业务工具(查法条、读超声图像、向 benchmark 提交答案)留在项目。检索
属于后者:三个下游里只有 GovDoc 需要,而它已经有自己的检索引擎;把检索放进来还会拖进
embedding、向量库、文本切分一整套依赖,而这些东西的形态差异极大,抽出来那个八成谁都不合用。
**「接着跑」在界内,「知道要接着跑、以及从哪儿读」在界外。** 崩溃后恢复这件事被当成一个词
用,实际要拆开:拿着一段已有历史从中间继续,三条判据全过;而判断该不该续、历史存在哪、
怎么读回来,要知道这次运行之外的事。展开见 `0002-step-level-resume.md`
### 三、准入规矩:界外的东西要进来,先有两个真实消费者
「真实消费者」指有跑着的代码或已冻结的设计,不包括「将来可能需要」。在满足这个条件之前,
界外的需求只登记不实现,也**不为它预留结构**。
不预留是刻意的。预留一个接缝的成本不是零:它会出现在公共类型里,受兼容性约束,还要有人
维护它的测试;而它守护的那个形状是猜出来的,等真需求到了多半不合用,那时候拆比重写贵。
CHSAnalyzer 在自己的架构文档里对同一件事的判断是「猜出来的接缝比没有接缝更难拆」,这里
采用同一个判断。
## 否决的方案
**执行内核和阶段编排都做。** GovDoc-SaaS 已经写了一版 `PhasedWorkflow`,看起来不用白不用。
否决理由是阶段编排只有一个消费者验证过它的语义,而 dissect 用不上那半个库——一个下游只用
一半的库,另一半就没人替它挑错。另外阶段编排的形状分歧很大:GovDoc-Editor 是固定三阶段
靠文件交换状态,docagent-core 泛化成了 N 阶段带断点续跑,两者已经不是一个东西了。
**列一份「界内功能清单」,不给判据。** 这是最省事的做法,也是最先失效的。清单没法回答
它没列到的东西,而实验室将来要接入的项目现在连需求形状都不知道;每来一个新项目,清单就要
补一次,而补的人手上没有判据,只能凭当时的感觉。判据的价值正在于它对没见过的情况也能出结论。
**照 `reference/agent-core.md` 那份提案画边界。** 那是别人为本项目写的架构提案,不是本项目
的决策。它对执行内核和阶段编排的切法与这里基本一致,这一点是它的贡献。但它的证据基础是
两个消费者(其中一个的形态与今天的实际情况已经对不上),而且它把崩溃恢复整个推到了库外,
与本文的裁决不同。按 `../../CLAUDE.md` §0,那份文档不是任何事实的权威,其中任何一条在被
本项目的 design doc 明确采纳之前都不作数。
**为不确定的未来消费者预留接缝。** 见上面「准入规矩」那一节的理由。
## 代价
**当前只有一个硬消费者,库有长成 dissect 形状的风险。** 这是最实在的代价,没法消除。对策是
GovDoc 那边不做迁移但做设计级验收:把 GovDoc-Editor 今天在跑的阶段编排、以及 docagent-core
`Phase``PhasedWorkflow` 当成一份需求清单,逐条问「PolyLoop 能不能承载它」,答不上来的
就是边界缺口,登记进 `../migrations/`
**「性质」那条判据有主观空间。** 「怎么跑」和「跑得好不好」在多数情况下分得清,但边缘情况会
有争议。缓解办法是按 `../../CLAUDE.md` §3 那条:结论有分歧时,以能否指出具体失败场景为准。
**「两个真实消费者」这条规矩会拖慢真需求。** 一个只有 dissect 需要、但确实属于执行内核的
机制,按规矩要等第二个消费者。接受这个代价,因为反方向的错误更贵:一个提前进库的错抽象会
同时污染所有下游,而且受兼容性约束删不掉。
## 结论落到哪里
本文的裁决清单落在 `../explanation/scope.md`(常青层,会随新消费者更新),判据本身也在那里。
`../../CLAUDE.md` §0 的权威表指向它。代码还不存在,所以现阶段没有机器兜底——将来
`pyproject.toml` 的 import-linter 契约能守住其中一部分(不反向 import 下游、业务概念不进内核),
但「这个机制该不该进库」这类判断守不住,只能靠评审。
@@ -0,0 +1,184 @@
# Design 0002 · 步级恢复与它保证不了的东西
**日期** 2026-08-07 · **状态** 已接受
## 背景
### 需要的两个前提
**一次运行由若干步组成,每一步都可能对外部世界产生副作用。** 一步的形状是:调模型拿到
决策,把决策解析成一个动作,把动作交给环境执行,拿回观察,写进历史。其中「环境执行」
那一下是真的在动外部世界——写文件、跑命令、调外部 API。
**进程会在任意时刻死掉。** 被 kill、机器重启、OOM。四个下游都会撞上,dissect 一次实验
跑几百个 rollout 时进程被杀是常态。
### 问题
进程死掉之后重新跑,有两种做法。整次运行重跑,代价是已经花掉的模型调用白花,而且已经发生
的副作用会再发生一遍。从断点接着跑,代价是要回答一个很难的问题:**上一步到底做完没有。**
难在哪里,取决于断点落在哪个相位:
**断在模型调用之前** —— 没花钱、没副作用。重跑这一步,干净。
**断在模型返回之后、动作执行之前** —— 钱已经花了,动作还没做。重跑会重复付费。对 dissect
这还不只是钱的问题:它的生成、评估、反思三本账要求每次调用都记上,而轨迹里的每一步靠
调用标识与账目对齐。重跑一次,账上多一条而轨迹里只有一条,对不上。
**断在动作执行之后、观察写回之前** —— 副作用已经发生了:文件写了、命令跑了、外部 API 调了。
重跑这一步就是**把副作用再做一遍**。而库不知道这个动作幂不幂等。
第三种最麻烦,也是本文要处理的核心。
## 决策
### 一、不承诺原子性,承诺可检测
严格意义的原子性做不到,先把这一点写死,免得契约里出现一个交付不了的词。
原因是副作用发生在库**之外**(文件系统、容器、外部服务),记录发生在库**这边**。要让两者
同时成功或同时失败,需要一个横跨两个系统的事务,而这要求环境支持两阶段提交。环境是一个
Protocol,后面可能是 docker exec、可能是一次 HTTP 调用,没有哪个能配合。
所以契约里写的是这两条,它们都能被测试断言:
> **绝不静默丢失。** 任何可能已经执行过的动作,一定在日志里留下痕迹;恢复时一定能被识别
> 为「状态未知」,而不是被当成「没发生过」。
>
> **不替工具猜幂等性。** 遇到状态未知的动作要不要重放,由工具自己声明,库只执行声明。
「绝不静默丢失」是这里唯一真正的保证。它挡不住重复执行,但它把**静默的重复**变成了
**看得见的未知**——而后者是可以被处理的,前者不行。
### 二、先写意图,再执行,结果 ID 预先分配
执行任何有副作用的东西之前,先往日志里写一条意图记录,里面带着「这次执行的结果将来会以
哪个 ID 存下来」。执行完再按那个 ID 写结果。
预先分配 ID 是关键的一步。恢复时可以精确地问「这个 ID 的结果条目在不在」,而不是靠模糊
匹配去猜哪条结果对应哪次执行。
恢复时每一次执行有四种状态:
| 意图记录 | 结果条目 | 含义 | 做法 |
|---|---|---|---|
| 无 | 无 | 还没开始执行 | 重跑这一步 |
| 有 | 有 | 执行完了,结果也存了 | 跳过 |
| 有 | 无 | **状态未知** | 按工具声明的重放策略决定 |
| 无 | 有 | 结构上说不通 | 判为日志损坏,拒绝续跑 |
最后一行是刻意的:读到说不通的状态就失败,不修复也不带着它继续。这和公共类型「读到未知
schema major 直接失败」是同一个态度——一个被猜着修好的日志,会让后面每一个基于它的判断
都建立在猜测上,而且不会有任何地方提示这件事发生过。
### 三、模型调用与动作执行用同一套保护
上面那套不只用在动作上,模型调用也要。模型调用的「副作用」是花钱和记账,第二种断点情形
说的就是它。所以每次模型调用之前也写一条意图记录,带尝试序号和预分配的结果 ID。
### 四、重放策略由工具声明,默认「绝不重放」
工具声明自己是 `safe` 还是 `never`
- `safe` —— 这个工具幂等,状态未知时重放没关系。读文件、grep 属于这类。
- `never` —— 这个工具有不可重复的副作用,状态未知时绝不重放。写文件、跑 shell、调外部 API
属于这类。
这条回答了「库不知道动作幂不幂等」那个问题:库确实不知道,但它可以要求工具回答。
**默认值取 `never`。** 两个方向的错误代价不对称:默认 `safe` 而声明漏了,后果是重复写文件、
重复提交,静默损坏数据;默认 `never` 而声明漏了,后果是本来能自动续上的运行多停一次,
有人会看见。这与「宁可报错也不要用默认值兜底」是同一个判断。
代价是接入的项目一开始会觉得续不上,得逐个给工具标 `safe`。接受这个代价,因为标错 `safe`
的后果要很久以后才发现。
### 五、库持有存储端口,「运行结束」这个标记由库写
做恢复就意味着库要往持久存储里写东西——意图日志得比进程活得久。于是有个问题:最终结果
是谁负责落盘。
**决定是库自己写**,通过一个存储端口(Protocol),实现由项目提供。库在把结果返回给调用方
**之前**先写下「这次运行结束了」这个标记。
理由是另一条路有个具体的失败场景:如果结果由项目落盘,那么「运行正常跑完、库返回了结果、
项目在存它的时候崩了」这种情况下,重启后日志显示最后一步有结果、没有结束标记,而项目那边
什么都没有。这时候该续跑吗?续了就重复执行最后一步的副作用,不续就丢掉一次已经花完钱的
运行。歧义来自结果跨了两个存储。库自己写结束标记,这个歧义不存在。
代价是库多一个存储端口,也就多一份 Protocol、多一套契约测试,还多一个降级方向问题——存储
后端挂了要报错而不是放行,因为放行意味着这次运行没有恢复能力,而调用方不知道。
### 六、记录种类按本项目真实有的功能定,不照抄
意图日志需要几种记录,取决于有几种需要保护的事。本项目当前是三种:
- 一步开始了(带尝试序号与预分配的结果 ID)
- 一个动作要执行了(带预分配的观察 ID 与工具声明的重放策略)
- 一次运行结束了(带结束原因)
参考实现 `reference/pi` 的记录道有九种,多出来的六种各自对应它有而本项目没有的功能:历史
压缩与会话树导航是两种独立的长操作(本项目的压缩发生在一次运行内部,也没有会话树);
交互式界面的消息队列有入队与撤销两种记录(本项目一次运行只在开始时拿到一个目标,中途不
接受新消息);延迟响应的挂起写入需要一条(PolyGateway 不暴露延迟响应);用量单独一条
(本项目的记账归 PolyGateway,不复制一套)。
不照抄有四条理由,每一条都是实打实的成本。
**每条记录都是一份永久合同。** 这些记录会进 dissect 的实验数据集和 GovDoc 的数据库,此后
受「字段只增不删不改名」约束,真要删得发新 major 并写迁移。现在抄一条用不上的,等于替
将来的自己签一份不需要而且解约很贵的合同。
**抄记录不抄校验,那条记录就是装饰品。** 日志的完整性靠记录**之间**的约束保证,不是靠单条
记录自己的规则——比如「中止之后不该再有入队记录」这条,只有在同时存在队列记录和中止记录
时才有意义。抄了记录不抄约束,那个字段就是个没人守的洞;连约束一起抄,就要为一个本项目
没有的功能维护那些约束和它们的测试。
**恢复逻辑的复杂度取决于记录能组合出多少种状态,不是记录有几条。** 恢复要把每一种合法状态
都还原正确,还要把不合法的认出来并拒绝。三种记录能拼出的状态比九种少一个数量级,这直接
决定契约测试能不能写全——而**写不全的恢复逻辑等于没有恢复逻辑**,它会在某个没被覆盖的组合
上悄悄还原出一个错的状态然后接着跑,这类错误没有失败现场。
**抄来的记录会把它背后的概念一起请进来。** 一个带「会话树导航」取值的记录进来,就等于承认
本项目有会话树这个概念。一个概念进来会长出配套的取值、分支和测试,删的时候要一起动。
## 否决的方案
**不做步级恢复,崩了就整次重跑。** 这是最省事的,也是本项目最初的倾向——理由是四个消费者
里没有一个明确要求过它。否决是因为「进程莫名其妙断掉」在每个项目上都会发生,而整次重跑
既浪费已花的模型调用,也一样会重复执行副作用,并没有真正回避第三种断点情形。
**只做阶段级恢复。** GovDoc 的做法:以阶段为单位,产物齐全就跳过整个阶段。它更简单,但
`0001-scope-boundary.md` 的裁决,阶段属于多个目标之间的事,在界外;而且它保护不了一次
运行内部的几十步。
**结束标记由项目写。** 见上面第五条的失败场景。
**重放策略默认 `safe`。** 见上面第四条的不对称性论证。
**照抄参考实现的九种记录。** 见上面第六条的四条理由。
## 代价
**「状态未知」这种情形无法自动消除,总有一部分运行需要人介入。** 一个声明为 `never` 的工具
断在未知状态,库会停下来报告,而不是替谁做决定。这是设计的目的,不是缺陷,但它意味着
恢复不是全自动的。
**库现在要写持久存储,这是一个新的失败面。** 存储后端本身会挂、会写坏、会写满。降级方向
定为报错而不是放行,所以它挂了会直接影响可用性。
**round-trip 契约测试不好写但绕不过。** 跑到第 N 步存下来、读回来、接着跑完,结果必须和一
口气跑完等价(除时间戳这类显式的非确定字段)。恢复的 bug 天然没有失败现场,它表现成
「跑出来的结果有点不一样」,只有这类测试抓得住。
**「绝不静默丢失」这条保证的强度受限于存储端口的实现。** 如果某个实现的写入不是持久的
(比如缓冲了没落盘),保证就不成立。这要写进存储端口的契约并由契约测试断言,但库没法
强制一个第三方实现真的落了盘。
## 结论落到哪里
「接着跑在界内、决定与存取在界外」这条裁决落在 `../explanation/scope.md`。本文其余的机制
形状(记录种类、四种恢复状态、重放策略、存储端口)现在没有代码,也还没有对应的常青文档——
它们要等第 ③ 阶段的架构文档,那时候和分层、取消传播、契约测试放在一起写。在那份文档出现
之前,本文是这些机制的唯一记录,而按 `../README.md` 的规矩,这是一个必须尽快关掉的缺口。
@@ -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` 要相应区分「界内且已实现」
与「界内但未实现」,否则读者会以为库有这个能力。
@@ -0,0 +1,284 @@
# 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 一样,只是这一份特别容易被误用,因为它含一张字段表。
+537
View File
@@ -0,0 +1,537 @@
# 库的结构
> **更新触发点。** 常青文档必须写明「什么事情发生时它一定会被改」,本项目把这件事分三档:
> 第 1 档是机器断言(文档说的和代码不符,CI 直接失败),第 2 档是同提交同改,
> 第 3 档是定期复审。能用强的就不用弱的,完整说明见 `../README.md`。
>
> 本文件的第七、八、九节是**第 1 档**:那三节讲的分层、模块边界与抽象接缝由 `pyproject.toml`
> 的 import-linter 契约与 `tests/contract/` 断言。**其余章节是第 2 档**。
>
> **当前状态:本文件描述的是目标结构,`src/` 下一行代码都没有。** 常青层本该描述当前真实
> 情况,而这份在代码之前就存在。接受这个例外的理由与它的过期条件见
> `../../README.md` 的阶段清单第 ③ 条。`src/` 落地完成后删除本段。
>
> 由此带来一个读者必须知道的约定:**后文以现在时提到的 `polyloop/` 路径,指的是落地之后
> 该内容所在的位置**,不一定是现在就能打开的模块。这么写是为了让这份文档在代码落地那天
> 不需要逐句改时态。
>
> **本文件与 design doc 冲突时以本文件为准。** design doc 写完就冻结,它记录的是当时定了
> 什么;本文件描述的是现在是什么样。两者对同一件事都会提到,这是有意的——但理由只在
> design doc 里写全,本文件只就地给一两句,展开指回去。
---
## 一、这个库在做什么
实验室里有三个项目要让大模型自己干活:给它一个目标,它自己想下一步做什么、动手做、看结果、
再想下一步,反复若干轮直到做完。三个项目各自写了一遍这个循环,于是有了三份各自的 bug。
PolyLoop 把这个循环收成一份。它治理的单位是**一次运行**:围绕一个目标的有界多轮
「模型决策 → 执行动作 → 写回观察 → 再决策」,含预算、停止判定、逐步轨迹、取消、
崩溃恢复。
它下面压着另一个共用库 PolyGateway,那个库治理的是**一次模型调用**——多源、限流、
重试、换源、熔断、缓存、遥测。PolyLoop 用它的顶层公共 API 拿模型,不重建这一套。
它上面留给项目的是**多次运行之间的事**:一个大任务切成几个阶段、一批题目并行跑、
结果怎么评分汇总。这些不在本库内,判据见 `scope.md`
## 二、一次运行长什么样,以及几个词
后文的词都能在这张图上指出来,所以先看图。
```
项目交进来一个目标
┌──▶ ① 装配上下文 把目标、已走过的步、注入内容拼成一份消息序列
│ │
│ ▼
│ ② 调模型 经模型调用接缝出去,拿回可见回复与推理段
│ │
│ ▼
│ ③ 解释回复 经决策解释接缝,得到三者之一:
│ │ 动作 / 最终回答 / 无效决策
│ ▼
│ ④ 执行动作 经动作执行接缝,拿回一段观察
│ │ 只有解释成「动作」时才走这一步
│ ▼
│ ⑤ 记一步 把这一步发生的全部事实写进轨迹
│ │
└────────┤ 没有停止条件成立,回到 ①
▼ 某个停止条件成立
返回一个结果:停止原因 + 完整轨迹
```
**① 到 ⑤ 合起来叫一步**,从目标进来到结果出去叫**一次运行**。一次运行是零到多步——
预算为零或一进来就被取消时,一步都不走。
②③④ 各自对应一个**接缝**:库定形状,下游给实现。所以同一个循环能跑代码执行型的 agent,
也能跑工具调用型的,差别全在这三处的实现里。
**停止条件不止一种,判定的顺序是公共契约**,不是实现细节——顺序错了,「恰好在最后一步做完」
会被记成「预算耗尽」,而两者的轨迹长度一模一样。取值与顺序在
`../design/0004-stopping-and-step-record.md`
---
下面这些词在后文频繁出现,外部读者猜不对含义。前六个现在就得记住,其余读到再回来查。
**一次运行**——从一个目标开始,到库返回结果为止的整个过程。它是本库的治理单位,也是
公共 API 里 `run` 那个动词的单位。一次运行包含零到多次模型调用。
**步**——一轮决策。一步之内最多调用一次动作执行;模型调用失败或输出解析不出动作时,
那一步照样成立、照样留痕,只是没有动作。
**动作**——模型这一步决定要做的事。它的内部结构由项目决定:可以是一段代码,可以是一次
工具调用。库不规定。
**观察**——动作执行之后回给模型看的文本。动作本身报错(代码抛异常、命令返回非零)算正常
观察,要原样回喂让模型自己纠正;只有环境自己坏了才另算。
**接缝**——库定义一个接口、下游提供实现的那个边界。库只认接口的形状,不认后面是什么:
交给它一个能执行动作的对象,它不问那后面是 docker 容器还是一张工具表。设一个接缝的成本
是一份永久合同加一套契约测试,所以判据很严,见第九节。
**意图日志**——库在做任何有副作用的事之前,先往持久存储里写一条「我准备干什么」。崩溃
之后靠它判断上一步做没做完。它不是给人看的日志,是恢复用的结构化记录。
---
以下读到再回来查。
**Skill 注入**——把一段项目准备好的能力说明贴进上下文。库只负责贴和记录贴了什么,
不负责生成、评测、挑选。
**重放策略**——一个工具对「我幂等吗」这个问题的回答。取值只有两个:可重放、绝不重放。
崩溃恢复在状态未知时读它。
**参数快照**——一次运行开始时冻结下来的全部可复现参数,来自定义和请求两侧。恢复时拿它
和当前装配比对,不一致就拒绝续跑。
**耐久屏障**——一次「必须确保已经落盘才能往下走」的等待点。一步之内有两个:写动作意图
之后、写模型调用意图之后。它是恢复能力的成本所在。
**执行内核与阶段编排**——两种都被叫作「agent 框架」的东西。执行内核持有上面那个循环;
阶段编排把一个大任务切成几个固定阶段、每阶段跑一次内核、阶段之间交换状态。本库只做前者。
## 三、架构优先满足什么
四条,按优先级。冲突时高的赢。
**一、下游的实验数据不能被静默改写。** dissect 的每一次运行都是一个论文数据点,轨迹文件
是原始数据。一个本该被记录的事实丢了、或者一个没发生的事被记成发生过,事后补不回来也
查不出来。这条排第一,因为它的失败没有现场。
**二、公共承诺要能长期不变。** 三个下游各自 `pip install` 本库,改一次公共类型要三个项目
同时改代码。所以宁可现在多想一天,不要将来发一个 major。
**三、每一处行为都要能从快照复现。** 装配了什么组件、用了什么参数,必须能从已经装配好的
对象上读出来,而不是靠调用方手写一份声明——手写的记的是意图不是事实。
**四、接入成本。** 排在最后。一个难用但正确的库可以靠文档补救,一个好用但会静默出错的
库补救不了。
## 四、不可协商的约束
这几条不是权衡出来的,是身份带来的。完整清单在 `../../CLAUDE.md` §1,这里只列直接塑造
结构的那些。
**零业务假设。** 库内不出现任何下游的业务词汇。三个下游的领域互不相交,一个业务词进来就
等于替其中一个做了另外两个不需要的假设。
**不反向 import 任何下游项目。** 由 import-linter 断言。
**公共类型的字段只增不删不改名,新增字段必带默认值。**
**持久化结构带独立的 schema 版本,读到未知 major 直接失败。** 不靠默认值补齐——一次静默的
默认值填充会把「这件事没发生过」改写成「发生了但值为空」。
**`asyncio.CancelledError` 永不吞没。** 取消要能穿过模型调用与动作执行,in-flight 资源在
`finally` 释放。
**模型调用一律经 PolyGateway。** 库内不写重试、限流、熔断、缓存、遥测。
## 五、系统边界:外面有什么
```
┌───────────┐ ┌──────────────┐ ┌──────────────┐
│ dissect │ │ GovDoc-SaaS │ │ CHSAnalyzer │
│ Runner │ │ 阶段编排 │ │ (远期) │
└─────┬─────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
└────────────────┼──────────────────┘
│ 装配定义,逐次调 run / resume
┌─────────────────────┐
│ PolyLoop │
│ 一次运行的治理 │
└──────────┬──────────┘
│ 经模型调用接缝
┌─────────────────────┐
│ PolyGateway │
│ 一次模型调用的治理 │
└─────────────────────┘
```
上面那一层做的事本库一概不做:决定何时调用哪个 agent、调用多少次、怎么并行、怎么汇总、
怎么评分。哪些归本库、哪些不归,判据与完整清单在 `scope.md`
下面那一层做的事本库也不做:重试、换源、限流、熔断、响应缓存、成本遥测。
## 六、总体思路
### 一步之内谁调谁
第二节那张图讲的是「发生了什么」,这张讲的是「谁去做的」。
```
session _stopping _assembly 模型接缝 解释接缝 执行接缝
│ │ │ │ │ │
│─ 能走吗? ─▶│ │ │ │ │
│◀─ 能 ──────│ │ │ │ │
│ │ │ │ │ │
│─ 装配 ────────────────▶│ │ │ │
│◀─ 消息序列 ────────────│ │ │ │
│ │ │ │ │ │
│─ 调模型 ──────────────────────────▶│ │ │
│◀─ 回复 ───────────────────────────│ │ │
│ │ │ │ │ │
│─ 解释 ─────────────────────────────────────▶│ │
│◀─ 动作 ────────────────────────────────────│ │
│ │ │ │ │ │
│─ 执行 ───────────────────────────────────────────────▶│
│◀─ 观察 ──────────────────────────────────────────────│
│ │ │ │ │ │
│ 记一步,回到最上面 │
时间往下走。每一根横线都从 session 那一列出发,也回到那一列。
别的五列之间没有任何一根线——它们互相不认识,也不 import 对方。
```
**这张图是第七节那条「同层互不 import」的运行时样子。** `_assembly` 装配完上下文,结果先回到
`session`,再由 `session` 交给下一个。它们之间不直接说话,好处是各自能被单独测穷:测
`_stopping` 不必构造一份消息序列,测 `_assembly` 不必构造一份预算计数。
代价是 `session` 会长——所有编织都堆在那一个模块里。这是刻意的:**编织逻辑集中在一处,
比散在五个模块之间互相调用更容易看懂,也更容易在出错时定位。**
### 三条原则
**循环归库,调用方只交目标、只收结果。** 公共 API 只有两个动词:跑一次、接着跑。不提供
单步接口、不提供异步生成器、不让调用方自己写 while。理由是三件东西必须有确定的归属——
取消时释放资源的 `finally`、「这次运行结束了」这个标记的写入点、停止判定的顺序——
而它们一旦落在调用方的栈上就没有归属了。展开见 `../design/0003-public-api-shape.md` 决策一。
**可变的东西沿接缝出去,不可变的事实沿返回值回来。** 模型怎么调、输出怎么解释、动作怎么
执行、记录往哪存、事件发给谁,五处允许换实现。其余全在库内,且每一步的产物都是冻结值。
**先写意图再动手。** 任何有副作用的事之前,先往持久存储写一条意图,带着结果将来的 ID。
崩溃之后靠「有意图没结果」这个状态识别出「不知道做没做」,而不是把它当成没发生过。
展开见 `../design/0002-step-level-resume.md`
## 七、分层与依赖方向
这一节和第八、第九节是本文件的正题,也是机器断言的对象。
### 分层
**这张图讲的是谁 import 谁,不是运行顺序,也不是数据流向。**
`A ──▶ B` 读作「A 的代码里写了 `from polyloop.B import ...`」,也就是 A 依赖 B。
```
第 4 层 ┌───────────────┐ ┌───────────────┐ ┌────────────────┐
装配层 │ session │ │ stores │ │ adapters │
│ 定义、请求 │ │ jsonl / 内存 │ │ PolyGateway │
│ run、resume │ │ 存储实现 │ │ 适配器 │
└───────────────┘ └───────────────┘ └────────────────┘
这三个互不 import:session 不认识任何具体实现,具体实现也不
认识 session。把它们装到一起的是调用方,不是库自己
第 3 层 ┌───────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐
逻辑层 │ tools │ │_assembly │ │_stopping │ │_recovery │ │ serialization │
└───────┘ └──────────┘ └──────────┘ └──────────┘ └───────────────┘
这五个也互不 import。它们要配合时一律由 session 出面转手,
不直接说话——各自的测试才不必把别人一起搬进来
第 2 层 ┌─────────────────────────────────────────────────────────┐
接口层 │ polyloop.ports │
│ 五个接缝的 Protocol,以及它们的入参 / 返回结构体 │
└─────────────────────────────────────────────────────────┘
第 1 层 ┌─────────────────────────────────────────────────────────┐
数据层 │ polyloop.types │
│ 公共值类型、枚举、持久化记录 │
└─────────────────────────────────────────────────────────┘
它谁也不 import(标准库除外)。上面每一层都能直接 import 它,
不必逐层往下传——分层禁止的是往上,不是要求一层一层往下
```
第 4 层里 `stores``adapters` 只够到第 2 层(它们 import `ports` 去实现那些 Protocol
再 import `types` 用那些数据类型),不需要碰第 3 层。
**存储实现在上面而不是下面,这一点最容易画反。** 直觉上存储是底层设施,该垫在最底下;
但按 import 方向,是存储实现去 import 接口定义,所以它在接口之上。库的核心不认识任何具体
存储,只认识那个接口——换一个后端,核心一行都不用改。
判据只有一句:**`polyloop.types` 是依赖图的汇点**——所有箭头最终都指向它,没有一根从它出去。
它就是「字段只增不删不改名」保护的那份合同本身。
### 九条依赖规则
这些规则在代码里是**看不见的**——打开 `polyloop/ports/` 只会看到一堆正常的 Protocol,
看不到那里缺了什么。所以必须写下来,并且每一条都有对应的机器断言。
**一、分层,自高向低**:装配层(`session``stores``adapters`> 逻辑层(`tools`
`_assembly``_stopping``_recovery``serialization`> `ports` > `types`。低层不许
import 高层。
**二、装配层那三个互相独立。** `session` 不许 import `stores``adapters`,反过来也不许。
这条不能靠分层规则表达——同一层的模块在分层契约里默认是可以互相 import 的,要另立一条独立性
契约。它守的是「库不顺手提供任何默认实现」:`session` 一旦 import 了某个存储实现,那个实现
就成了隐式默认,而不传存储的人不会知道自己这次运行没有恢复能力。
**三、`tools`、`_assembly`、`_stopping`、`_recovery`、`serialization` 五者互不 import**
不设豁免。它们之间的编织只能发生在 `session` 里。理由是这五个模块各自要能被单独测穷,
而互相 import 一次,测哪个都要把另一个一起搬进来。这条规则在运行时长什么样,见第六节那张
时序图——所有横线都从 `session` 出发,五列之间一根线都没有。
**四、`polyloop` 的任何子模块不许 import 任何下游项目的包。** 反向依赖会让这个库跟着某一个
下游的发版节奏走。
**五、除 `polyloop.adapters` 外,一切不许 import `polygateway`。** 爆炸半径钉在一个模块里。
**六、`types` 与 `ports` 不许 import 任何第三方包。** 公共类型与接缝签名上不许出现第三方
类型,否则那个包的 major 就是本库的 major。直接后果是工具参数 schema 用普通 JSON Schema
字典而不是某个校验库的模型类。
**七、`_stopping`、`_assembly`、`_recovery` 不许 import `asyncio` 与 `pathlib`。** 这三个
模块要是无 I/O 的纯逻辑,才能被穷举测试。
**这一条是烟雾报警,不是纯度契约。** `os``subprocess``sqlite3` 都绕得过去,而 `open()`
是内置函数,import-linter 根本看不见。它拦得住最常见的那种偷懒——写着写着顺手 `await`
一下存储、顺手读个文件;拦不住存心的。真正守住纯度的是这三个模块的测试形态:它们的测试
不用任何 fixture 起外部资源、没有一个 `async def`。那一条没有机器能查,只能靠
`../../CLAUDE.md` §3 的评审看。
一个直接推论:`_recovery` 不能自己读存储。它只接收 `session` 已经读出来的记录,做纯函数
判定。这决定了它的函数签名形状。
**八、`ports` 不许 import 其余任何 `polyloop` 模块。** 第一条已覆盖,单列是为了让违规信息
直接指向「接缝定义模块被污染了」,而不是一条泛泛的分层报错。
**九、`import polyloop` 之后,`sys.modules` 里不许出现 `polygateway`。** 这条由契约测试
断言,不是 import-linter。顶层只再导出五个公开模块;`stores``adapters` 必须显式
import。理由是一个「顺手提供的默认模型客户端」会让每个进程在 import 时把网关连同它的
provider 目录一起拉起来。
## 八、代码地图:每个模块装什么
| 位置 | 公开 | 装什么 |
|---|---|---|
| `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 模型适配器 |
`polyloop/types/` 的读者是所有人:dissect 的 runner 读步记录与停止原因把轨迹头拼回去,
GovDoc 的编排层读停止原因决定这个阶段算不算可挽救,写任何适配器的人都要构造这里的结构体。
`polyloop/ports/` 的读者是写适配器的人和 `tests/contract/`。它用 `typing.Protocol` 而不是
抽象基类,理由是下游的对象往往已经是它自己的类、还要同时满足项目自己更宽的接口;只有
结构化子类型能让同一个对象同时满足库的窄视图和项目的宽视图。
`polyloop/tools/` 一个模块持有工具相关的全部六件事:注册、模型可见 schema 的生成、存在性与
参数校验、分发、重放策略声明、完成标记(哪个工具一旦成功执行就代表目标达成)。前四件必须
同源,这是 `scope.md` 定死的要求;后两件同住一处的理由相同——它们都是「关于某个工具的一条
事实」,分开存就会跟工具清单漂移,而漂移的表现是「模型明明提交了,运行却没停」。
同源的机器保证就是这六件由同一个注册表实例驱动、住在同一个模块里。注册表是不可变值对象,
取子集返回新实例——不能是进程级单例,因为同一进程里可能同时持有多份不同的窄集合。
三个内部模块用下划线开头且不进 `polyloop/__init__.py`。Python 拦不住谁去 import 它们,
下划线是唯一的机器信号。
硬性规则:根目录不得出现 `.py`;不许出现 `helpers/``common/``shared/``misc/`
`utils/` 这类名字——它们的职责是「剩下的东西」,一句话说不清职责就没有边界。
## 九、抽象接缝:哪里允许换实现
设一个接缝的判据只有一条:**举得出两个在真实消费者身上形态明显不同的实现。** 两个实现
不要求来自两个不同的项目,同一个项目的两个不同用途也算。举不出两个的不设接缝——抽象出来
也只有一个实现,白白多一份永久合同和一套契约测试。
### 五个接缝
**模型调用**(挂定义)。把已装配好的消息序列送出去,拿回可见回复与推理段。签名里不出现
重试次数、退避时长、限流配额——出现即意味着库在治理一次模型调用,而那归 PolyGateway。
两个形态:一种要在调用点按多本账各记一条并自己按价格表算成本,一种要在调用外面套退避并
累加本次运行的用量。
返回类型是库自己的,不是 re-export PolyGateway 的响应类型。理由是接缝定义模块不许有第三方
依赖(第七节规则五),而且 PolyGateway 加一个字段就等于本库的公共类型变了一次却没发过版。
**决策解释**(挂定义)。把一次模型回复解释成三分支之一:动作、最终回答、无效决策。
库不带默认实现——带了就等于替某一家定了动作语言。两个形态:一种从代码围栏里抽 Python
源码,一种从 JSON 里抽工具名与参数。
**动作执行**(挂请求)。结算一个动作,返回状态(已执行 / 未执行 / 环境故障)、观察、
观察是不是库合成的、完成信号、被截断的字符数。两个形态:一种把一段代码交给已开好的容器
会话、状态恒为已执行,一种查工具注册表分发、工具不存在或参数不合法时返回未执行。
**完成信号是布尔,查询失败由状态字段的「环境故障」表达,它自己没有第三个取值。** 没有环境
完成信号的环境返回「未完成」——这是一条已经在跑的约定,dissect 的环境协议就是这么规定的。
不给「本环境没有这个概念」单设一个取值,是因为它在所有代码路径上的行为和「未完成」完全
相同,而一个行为上无差别的枚举取值只会腐烂。
环境不另设第二个接缝:执行动作与查询完成信号住在同一个对象上。拆开会让工具分发那条路径
多出一个只能返回常量的空壳。
一次运行的完成有**两个信号源**,可信度不同,所以分开表达:环境的完成信号是环境状态里真的
留下了记录;工具注册表上的完成标记是 agent 自己宣布的(调了某个被标记的工具即视为达成)。
两者都由库消费,不各开接缝。
**存储**(挂定义)。六个方法,全部带运行标识,端口不持有「当前运行」的隐式状态——一个有
隐式当前运行的端口在并发下会把 A 的意图写进 B 的日志。两个形态:一种逐行写本地 jsonl
文件,一种写关系数据库。
写入粒度是契约的一部分:两条意图记录各自单独落地;**动作结果与步记录必须作为一次原子写入
落地**,要么都可见、要么都不可见。后者不原子的话,崩在两者之间会让那一步的历史文本永远丢失,
而恢复判定会把它读成「执行完了,跳过」。
**事件出口**(挂定义)。投递失败由库捕获、记日志、把失败计数加一,然后继续跑。失败不再
转成一条事件从同一个出口发出去——那会自我喂食,一个持续失败的出口会让失败处理路径变成
递归。两个形态:一种把进度回写业务数据库,一种把审计事件送进日志管道。
### 三个刻意不抽象的地方
**完成判定。** 信号源全在库内,或者已经在动作执行接缝的返回值上。再设一个入口等于同一件
事有两个来源,而两个来源迟早分叉。
**观察投影。** 需要搬运的两个量——模型原文与进历史文本、环境观察与是否合成——已经分别由
决策解释与动作执行的返回结构体携带。再开一条能改观察的路径必然分叉。
**上下文装配。** 真正会变的是渲染格式,而渲染格式留在项目侧:它是下游的实验因子,库在
内部按某个条件拼自己的文本,那段文本就成了库定的实验刺激,并且逃出项目的参数快照。
归库的只有段顺序与注入槽的位置,那是纯函数不是扩展点。
## 十、装配形态
本库不部署,也不跑模型推理,没有常驻进程。它被 `pip install` 进下游项目,在下游的进程里
被装配和调用。
装配分两步。**定义**在进程或 worker 启动时装配一次,持有跨运行不变的能力:模型调用接缝、
决策解释接缝、存储接缝、事件出口。它是不可变的,可以被并发复用。
**请求**每次运行构造一个,持有这次运行独有的数据:运行标识、预算、动作执行器、本次可见的
工具集、上下文各段、注入内容、模型绑定、模型调用的重放策略、取消收尾时限。它构造廉价——
无 I/O、无网络校验、无哈希计算。
切点是「跨运行变不变」。预算只在请求这一处,不设「定义给默认值、请求可覆盖」——两处取值
意味着「这次到底跑的什么设置」要对照两个地方才答得出来。
同一份定义可以被并发驱动跑很多次运行,所以定义里持有的每一个组件都必须不可变或可重入:
不许在实例上留计数器、缓存游标或当前运行的元数据。
## 十一、横切概念住在哪
| 概念 | 住在哪 | 说明 |
|---|---|---|
| 预算与停止判定 | `_stopping` | 无 I/O 纯逻辑,可穷举测试 |
| 上下文装配与规模度量 | `_assembly` | 同上 |
| 恢复状态判定与身份校验 | `_recovery` | 同上,且只接收已读出的记录 |
| 取消 | `session` | 标准 asyncio 取消,库不提供自己的取消令牌 |
| 意图日志的写入时机 | `session` | 唯一持有运行生命周期的地方 |
| 事件发射 | `session` | 接缝在 `ports`,发射时机在 `session` |
| schema 版本与迁移 | `serialization` | 读到未知 major 直接失败 |
取消这一条值得单独说:库不提供自己的取消令牌或 `cancel()` 方法。取消状态的权威在业务侧
(有的下游是数据库租约、有的是实验控制),库没有资格也没有能力持有它;自己再提供一个
就多了一处会漂移的状态。
## 十二、下游的可复现要求如何约束架构
dissect 的每一次运行都是论文数据点,这个性质对结构提了几条别的消费者不会提的要求。完整
清单在 `../migrations/dissect.md`,这里只列改变了结构的那些。
**停止原因必须是分类枚举,而且要能区分「恰好在最后一步做完」与「预算耗尽」。** 从轨迹长度
反推不出来,两种情况的长度一模一样。这条把停止判定的顺序变成了公共契约,而不是实现细节。
**上下文超限必须显式终止,不许静默截断。** 截断是一次前缀破坏操作,会让后续每一步重新
按全价计费。这条要求库在装配之后、模型调用之前能数出规模,所以消息内容必须能报告一个
规模度量。
**三类没碰环境的步也要留痕**:解析失败、模型调用失败、环境故障。前者消耗了一次模型调用,
不计则预算对等不成立;后两者的情形是钱已经花了、账已经记了,丢掉那一步会让账目与轨迹
对不上。
**消息段按变化频率从低到高装配。** 稳定前缀在前、逐次变化的在后。排错了不会报错,只会让
供应商的 prompt cache 静默失效,而多付的幅度随注入规模变化——于是缓存伪影会精确地伪装成
实验效应。
**历史只追加,不改写不重排。** 理由同上。
**一份定义可以被并发驱动跑不同的注入内容**,所以定义里不许有实例级可变状态。
## 十三、这份文档靠什么不腐烂
第七、八、九节将来由机器断言,每一节各有对应的检查:
- **第七节**——九条依赖规则,前八条各一条 import-linter 契约(第一条是分层契约,第二、三条
是独立性契约,其余是禁止型契约),第九条一个契约测试。
- **第八节**——一个断言检查 `polyloop/` 下有哪些位置,和第八节那张表逐行对得上。表里有一行
在代码里找不到、或者代码里多出一个没写进表的位置,都算失败。没有这一条,新加的模块会
悄悄绕过第七节的分层规则——一个规则里没提到的模块,等于没有任何约束。
- **第九节**——一个断言检查接缝的数量和位置没有悄悄增长;另一个断言检查 `polyloop/`
不出现重试、限流、熔断的实现。
**写到哪一步了:一条都没写。** 这些断言要等 `src/` 落地才写得出来,在那之前这三节没有机器
兜底,只能靠人在实现时逐条对照。这是「架构文档先于代码存在」这个安排最实在的代价。
其余章节是第 2 档:改相关代码时,改本文件是同一个提交的一部分。
## 十四、已知缺口与尚未决定的部分
**公共类型的英文名与接缝的具体签名还没定。**`../../CLAUDE.md` §2 它们要过人类门。
本文件通篇用中文概念名指代它们,这是刻意的留白不是遗漏。它们和实现一起落地,落地时本文件
第八、九、十一节要补上英文名。
**停止判定的顺序、停止原因的取值、两个预算计数的语义、步记录的字段清单不在本文件里。**
这四样已经定了,在 `../design/0004-stopping-and-step-record.md`;落地之后权威转移到代码——按
`../../CLAUDE.md` §0,公共类型的字段与枚举取值的权威是 `src/polyloop/`,不另写参考文档复述。
那份 design doc 记的是第一版为什么定成这样,不是查字段的地方。
**事件出口的事件类型还没定**,所以这个接缝的契约套件现在写不了。方向已经定了——观察走
事件流、干预走具名回调——但事件集与回调清单要独立成篇。
**多模态内容的规模度量没有答案。** 消息内容是块序列而不是裸字符串,第一版只定义文本块,
它的度量是准确的字符数。将来加图片块时必须同时给出它的度量定义,以及上下文上限在混合
内容下的语义。
**上下文压缩与最终输出的 schema 校验按 `scope.md` 都在界内,但都还没有第二个真实消费者**
所以本版不实现、不设接缝、不预留字段。
## 十五、决策索引
只索引,不复述理由。理由在各份 design doc 里,复述会漂移。
| 决策 | 在哪 |
|---|---|
| 哪些事归本库管、哪些不归,判据是什么,为什么只做执行内核 | `../design/0001-scope-boundary.md` |
| 崩溃恢复承诺什么、为什么不承诺原子性、重放策略为什么由工具声明 | `../design/0002-step-level-resume.md` |
| 公共 API 分几层、五个接缝为什么是这五个、依赖规则为什么这么定 | `../design/0003-public-api-shape.md` |
| 停止原因为什么是这十个、判定为什么按这个顺序、步记录为什么是这些字段 | `../design/0004-stopping-and-step-record.md` |
边界的当前裁决清单(哪些在界内、哪些在界外)在 `scope.md`,那份是常青的,会随新消费者
接入而更新。每个下游要迁什么、迁完算不算数在 `../migrations/` 下对应那份。
+154
View File
@@ -0,0 +1,154 @@
# PolyLoop 管什么、不管什么
> **更新触发点:第 2 档(同提交同改)。** 三种事情发生时,本文在同一个提交里改:新消费者
> 接入并带来了新的裁决;某项因为凑齐了两个真实消费者而从界外移进界内;某项被实现时发现
> 判据用错了。判据本身的变更走人类门,并新写一份 design doc 标 supersedes。
>
> **当前状态:本文描述的是已经定下来的边界,`src/` 还不存在。** 界内的东西大部分还没有代码。
> 这段说明在库本体落地后删掉。
一个新项目来问「我要的这个东西,PolyLoop 管不管」,答案在这里。
这个问题不好答,因为「agent 框架」这个词罩着两种不同的东西。一种是**执行内核**:持有
「模型做决策 → 执行动作 → 写回观察 → 再决策」这个循环,管预算、停止判定、逐步轨迹、取消。
另一种是**阶段编排**:把一个大任务切成几个固定阶段,每个阶段跑一次执行内核,阶段之间交换
状态,并检查每个阶段的产物合不合格。
**PolyLoop 是前者。** 阶段编排留在项目侧。这个划分和它的理由记在
`../design/0001-scope-boundary.md`
## 判据
一句话是:**管「一个目标之内」的事,不管「多个目标之间」的事。** 一次 `run` 等于一个目标。
这句话自己判不了案,配三条判据,**三条全中才在界内**:
1. **时机** —— 它发生在一次运行开始之后、返回之前。
2. **信息** —— 它不需要知道这次运行之外的任何事:上一次运行的结果、下一次运行是什么、
此刻还有几次运行在跑、这个目标是谁派下来的。
3. **性质** —— 它回答「怎么跑」,不回答「跑什么」「为什么跑」「跑得好不好」。
再配一条补充规则:**只认接缝,不认接缝后面是什么。** 检索、沙箱、容器、工作区目录,库一概
不知道——它只知道有一个环境接口,交给它一个动作、拿回一段观察。没有这条,第一条判据会被
滥用成「检索发生在一次运行之内,所以检索该进库」。
补充规则有个更好用的等价说法:**能表达成「环境提供的一个动作」的,就在界外。** 检索能,
沙箱执行能,写文件能,调外部 API 能。而「记这一步花了多少预算」不能——没有任何环境会提供
这么个动作,那是库自己的算法。
### 排除条款:不夺走下游要调的那个旋钮
三条判据都过、补充规则也不适用,仍然可能不该进库——**如果它是下游要扫的实验因子或要调的
业务刺激。**
Skill 的渲染形态是这一条唯一的现役例子。把注入内容用什么格式贴进提示词,按三条判据全过:
它发生在一次运行之内、不需要知道运行之外的事、回答的是「怎么跑」。但 dissect 要扫这个因子
来测它的效应量。库一旦把格式写死,那段文本就成了**库定的实验刺激**,而且它逃出了项目的
参数快照——扫不动了。
判据是:**这个取值变一下,下游的实验结论或业务结果会不会跟着变?** 会,就留在项目侧,
哪怕它长得再像库该管的事。
## 当前裁决
「已落地」一栏区分两件不同的事:某项归本库管(在界内),和本库现在真的做了它。凡是标
「未落地」的,第一版不实现、不设接缝、不预留字段——归属不变,只是还没到做的时候。
### 界内
| 事项 | 已落地 | 说明 |
|---|---|---|
| 主循环:决策、执行、观察、再决策 | 待落地 | 结构已定,代码未写 |
| 预算与停止判定 | 待落地 | 判定顺序与预算计数语义还没定,见第十四节缺口 |
| 停止原因的分类与取值 | 待落地 | 取值还没定 |
| 逐步轨迹 | 待落地 | 字段清单还没定 |
| 取消传播 | 待落地 | |
| 决策解释接缝 | 待落地 | 把模型回复解释成动作 / 最终回答 / 无效决策 |
| 动作执行接缝 | 待落地 | 含完成信号查询 |
| 工具的注册、模型可见 schema 生成、存在性与参数校验、分发 | 待落地 | 四者必须同源 |
| 通用工具的现成实现 | 待落地 | 可选装配;读写文件、grep、跑 shell 这类 |
| Skill 的注入与注入顺序快照 | 待落地 | 只注入和记录 |
| 崩溃后「接着跑」 | 待落地 | 见 `../design/0002-step-level-resume.md` |
| 意图日志与恢复状态判定 | 待落地 | 同上 |
| 运行结果的持久化存储端口 | 待落地 | 同上 |
| 最终输出对项目所给 schema 的校验,以及校验失败后的有界重问 | **不做** | 凑不齐两个真实消费者 |
| 上下文压缩 | **不做** | 同上。做的时候必须是显式策略,基线关闭 |
后两项标「不做」而不是「待落地」,区别在于前面那些只是排期问题,这两项是**准入问题**——
它们要等第二个真实消费者出现,见下一节。
### 界外
| 事项 | 栽在哪一条 |
|---|---|
| 评分、业务正确性判定 | 性质 |
| 阶段编排 | 信息、性质 |
| 阶段产物是否齐全、合不合格 | 性质 |
| 崩溃后「判断该不该续、历史存哪、怎么读回来」 | 时机、信息 |
| 阶段级的跳过与续跑 | 时机、信息 |
| 并行调度、批量运行、结果汇总 | 时机、信息 |
| 跨运行的长期记忆 | 信息 |
| Skill 的生成、评测、进化、审批、激活 | 时机、信息、性质 |
| Skill 的选择规则(这次注入哪几条) | 信息 |
| Skill 的渲染形态 | 排除条款 |
| 上下文的渲染格式 | 排除条款 |
| 检索、文本切分、向量库、重排 | 接缝后面 |
| 工作区目录的建立、快照与清理 | 接缝后面 |
| 沙箱与容器的生命周期 | 接缝后面 |
| 业务工具的实现 | 接缝后面 |
| 任务队列、租约、心跳、多租户规则 | 时机、信息 |
| 模型调用的重试、换源、限流、熔断、缓存、遥测 | 归 PolyGateway |
| 一次模型调用失败之后要不要再来一次 | 见下 |
**最后一条要说清楚,因为它容易被读成「库要做一层重试」。** 重试分两个尺度,两个都不归
本库:一次模型调用**之内**的重试与换源归 PolyGateway;**整次运行**失败之后要不要重跑,
归下游的 runner。中间那一层——「这一步的模型调用失败了,库自己再调一次」——举不出两个
消费者,所以不做。预算口径一并定死:PolyGateway 内部换源重试不消耗任何预算,因为库数的
是一次模型调用接缝的调用,口径必须是库自己能观测的量。
### 三个容易判错的地方
**「校验」这个词底下压着三件不同的事,两件在界内。** 模型吐的文本能不能解析成一个动作——
那是决策解释接缝,界内。动作的参数合不合法、这个工具存不存在——那是动作执行接缝裁决、
库定语义,界内。一次运行结束后文件系统上的产物齐不齐、合不合格——那是「跑得好不好」,界外。
接缝的完整清单、各自的职责、以及为什么恰好是这几个,在 `architecture.md` 第九节。本文只
判归属,不定形状。
**工具调用在界内,某个具体工具的实现不一定。** 模型说要调 `search`,库解析它、校验它、分发
它、把结果变成观察,这一整套在界内。`search` 本身怎么实现在界外,由项目注册进来。库自带
的通用工具是个例外,判据是不含业务概念且有两个以上消费者需要——读写文件过得了,检索过不了。
**恢复要拆成两件事。** 拿着一段已有历史从中间接着跑,界内。判断该不该续、历史存在哪、怎么
读回来,界外。
## 界外的东西怎么进来
**先有两个真实消费者,并且它们的语义稳定一致。**「真实消费者」指有跑着的代码或已冻结的
设计,不包括「将来可能需要」。
在满足这个条件之前,界外的需求**只登记不实现,也不为它预留结构**。不预留是刻意的:预留一个
接缝要占公共类型的位置、受兼容性约束、还要有人维护它的测试,而它守护的那个形状是猜出来的,
等真需求到了多半不合用,那时候拆比重写贵。
登记的地方是 `../migrations/` 下对应那份。
## 边界之外,库仍然要为它们留出余地
不做某件事,不等于可以挡住别人做。三条已知的、由界外功能反向施加给界内设计的约束:
**阶段编排要给每次运行配不同的预算。** 一份 agent 定义在三个阶段里可能要用三个不同的步数
上限,所以预算不能只挂在定义上。
**阶段编排要按阶段收窄工具集。** 每个阶段可见的工具不同,所以工具集要能按次运行装配,而
不是固定在定义里。这条和「四者同源」不冲突:每次运行构造一个窄的注册表就行。
**阶段级续跑要能判断上一次运行是不是真的跑完了。** 所以运行结果必须是可持久化、可读回、
可判定的结构,而且「结束了」这个事实要由库自己写下来,不能跨两个存储。这条已经落进
`../design/0002-step-level-resume.md`
## 已知没有机器兜底的部分
`pyproject.toml` 的 import-linter 契约将来能守住其中一部分——不反向 import 下游、业务概念
不进内核。但「这个机制该不该进库」这类判断守不住,只能靠 `../../CLAUDE.md` §3 的评审。
那些契约现在还没写,要等 `src/` 落地。
+194
View File
@@ -0,0 +1,194 @@
# 迁移:dissect
> **更新触发点:第 2 档(同提交同改)。** 公共类型或接缝语义变更时,在同一个提交里核对
> 本文的组件映射与需求条目;dissect 侧真正搬走某一块代码时,在同一个提交里勾掉删除清单
> 对应行。
>
> **当前状态:一行代码都还没搬。** 组件映射一栏填的是接缝名而不是类名,因为 `src/` 还不
> 存在;等第 ③ 阶段架构定了再补具体类型名。
dissect 是 PolyLoop 唯一做硬迁移的消费者,也是唯一有跑着的代码、有测试、有真实实验在依赖
的消费者。它的循环在 `reference/dissect/harness/agent/`,五个模块加起来 1158 行,配套测试
`reference/dissect/tests/agent/`1278 行。
**验收口径:把那套循环搬到 PolyLoop 上之后,dissect 现有测试全绿,且真实实验跑出来的轨迹
与迁移前逐字段可比。** 这是 PolyLoop 唯一的硬验收标准,另外两个消费者走设计级验收
(见 `govdoc-saas.md`)。
## dissect 的项目术语
这些词是 dissect 的,不是 PolyLoop 的——库不认识它们,本文和 dissect 侧的代码注释里会用到。
PolyLoop 自己的词表在 `../explanation/architecture.md` 第二节。
**rollout**——被试 agent 在一道题上跑完的一次完整过程。它正好对应 PolyLoop 的「一次运行」,
所以迁移之后 dissect 的一个 rollout 就是一次 `run`
**三本账**——dissect 把模型调用按用途分成三类分别记账:做题的(生成)、跑验证集的(评估)、
让模型反思改进的(反思)。分开记是它的实验纪律要求的——比较两个配置时,双方的**总花费必须
对等**,而混在一本账里就算不清谁在哪一类上多花了。每次调用记哪一本由调用方指定,循环本身
不选。
**best-of-N**——同一道题独立重做 N 次、取最好的那次。它是一个「同预算基线」:如果一个花哨
的方法效果好,得先证明它比「同样的钱拿去重做 N 次」更好。
**因子**——dissect 要测的设计变量,十来个,每个能独立扫几档取值。它整个项目就是在测每个因子
的效应量,所以任何被库写死、又会影响成绩的取值,都可能污染某个因子的测量。
**AppWorld**——一个交互式 benchmark(给 agent 一批模拟的 app 和一个任务,看它能不能用代码
操作那些 app 完成)。它是 dissect 的第一个任务,dissect 的提示词格式和多代码块处理策略都跟
它的官方实现对齐,为的是成绩可比。
**缓存成本校正的锚点**——供应商对「命中缓存的前缀」按折扣价计费。要事后算清一次运行真实
花了多少,得知道哪些前缀被缓存了;而一次上下文截断会把前缀打断,之后每一步重新全价,
原来那个推算的参照就没了。
## dissect 是什么,为什么它的约束特别硬
dissect 是一个论文项目,研究「agent 的文本自我进化为什么有效」。它把自我进化拆成十来个可以
独立操纵的设计因子,用受控实验测每个因子的效应量。**被试 agent 的每一次运行都是一个实验
数据点**,轨迹文件是论文的原始数据。
这带来一批别的项目没有的约束。轨迹里少记一个字段,事后补不回来——那次运行已经发生过了。
一个本该被记录的失败被静默吞掉,会以「效应」的形式进入统计。所以下面「需求条目」那一节里
的每一条,都是从这个性质推出来的,不是偏好。
## 删除清单
「继任」指这块代码的职责由 PolyLoop 承担,dissect 侧删掉。「留下」指它是 dissect 的项目
资产,PolyLoop 只提供它要实现的接缝。
| dissect 侧 | 行数 | 去向 |
|---|---|---|
| `harness/agent/loop.py``ReActAgent._loop``_take_step` | ~130 | 继任 |
| `harness/agent/loop.py``_make_step` | ~35 | 继任 |
| `harness/agent/loop.py``_observe``_is_done` | ~40 | 继任(成为动作执行接缝的语义) |
| `harness/agent/loop.py``ReActAgent.run` 装配部分 | ~75 | 留下(组装 Rollout 头、传项目 metadata |
| `harness/agent/memory.py``StopReason` | ~25 | 继任 |
| `harness/agent/memory.py``Step` | ~65 | 继任(字段是库的下界,见需求条目) |
| `harness/agent/memory.py``Rollout` | ~90 | 留下 |
| `harness/agent/memory.py``render_messages` | ~40 | 继任 |
| `harness/agent/context.py``ArtifactView` | ~20 | 继任(成为 Skill 注入的输入形态) |
| `harness/agent/context.py``build_prefix` 段顺序约束 | ~15 | 继任(约束本身,不含渲染格式) |
| `harness/agent/context.py``PromptTemplate``_split_by_role` | ~95 | 留下 |
| `harness/agent/parser.py` 全部 | 182 | 留下 |
| `harness/agent/config.py` 的预算字段 | ~10 | 继任 |
| `harness/agent/config.py` 的 YAML 加载与校验 | ~140 | 留下 |
| `harness/envs/protocol.py``Episode` | ~60 | 继任(成为动作执行接缝) |
| `harness/envs/protocol.py``ScoredEpisode` / `TaskEnv` / `TaskItem` / `ItemScore` | ~120 | 留下 |
标「继任」的十行相加,净删除约 440 行,其中循环本体和轨迹结构占大头。
### 三个「留下」值得解释
**`Rollout` 留下**,因为它的二十个头字段绝大多数是项目 metadata——运行标识、进化轮次、
题目标识、场景标识、数据划分、用途、随机种子、尝试序号这些,PolyLoop 一个都不认识。
库只返回它自己知道的事实,不反向吸收这些字段——吸收了就等于替另外两个下游做了它们不需要
的假设。
**`parser.py` 留下**,因为它是 dissect 的动作语言。它把模型输出里的 Python 代码围栏抽出来,
处理未闭合围栏、空围栏、多围栏这些情况,并且刻意把第一个围栏之后的文字截掉(模型常在
代码块后编造「执行结果」)。这套规则是 dissect 和 AppWorld 官方对齐的口径,不是通用的。
PolyLoop 提供的是「把模型响应解释成动作、最终回答或无效决策」这条接缝,`parser.py` 是它的
一个实现。
**`config.py` 的加载与校验留下**,因为 dissect 有一条硬纪律:所有实验参数从 YAML 读入、
没有任何默认值、多余的键也要报错。这套「配置双模式」是它自己的规矩。PolyLoop 只接受
已经组装好的预算值。
## 需求条目
这些是 dissect 的代码和它的实验有效性对 PolyLoop 施加的硬约束。每一条都能指出一个具体的
失败后果。
**一、停止原因必须是枚举,而且要能区分「恰好在最后一步做完」与「预算耗尽」。**
从轨迹长度反推不出来,两种情况的长度一模一样。dissect 的崩坏判据要按停机原因分层,这一档
分不开,整批数据的分层就塌了。
**二、上下文超限必须显式终止,不许静默截断。** 截断是一次前缀破坏操作,会让后续每一步
重新按全价计费,并把缓存成本校正的锚点搞丢。而且被截断的运行会表现成一批低分,看起来像
模型能力不足。
**三、三类没碰环境的步也必须留痕:解析失败、模型调用失败、环境故障。** 前者消耗了一次
模型调用,不计的话预算对等不成立。后两者的情形是钱已经花了、账已经记了,丢掉那一步会让
账目与轨迹对不上,而且会丢掉模型在出故障那一步说了什么——排查「是环境坏了还是模型写了
危险代码」最需要的就是这段原文。
**四、模型调用标识是轨迹与账目之间唯一的连接键,它可以是「没有」,但不能是空串。**
空串是个看起来合法的键,连表时静默匹配不上;显式的「没有」至少能被筛出来。它为空的合法
含义只有一个:调用在记账之前就失败了。
**五、消息段必须按变化频率从低到高装配。** 稳定前缀在前、逐题变化的在后。排错了不会报错,
只会让供应商的 prompt cache 静默失效,而多付的幅度随注入内容的规模变化——于是缓存伪影会
精确地伪装成因子效应。
**六、历史只能追加,不能改写或重排。** 理由同上:第 n 步的提示词正好是第 n-1 步加一段尾巴,
任何中途截断、摘要、重排都会让后续每一步重新全价计费。
**七、模型原文与真正进入历史的文本必须分开记,环境观察同理。**
dissect 的解析器会把第一个代码围栏之后的文字丢掉,所以「模型说了什么」和「下一轮模型看见
什么」是两个不同的量。混用会让统计口径出错。
**八、可见回复与推理段的长度按字符记,不依赖上游上报。**
实测中转网关会用本地分词器补算并整体替换用量对象,把明细一起吃掉——某次标定里 24 次调用
的推理 token 全部没上报。字符数直接数,不受上报与否影响。
**九、一个 agent 定义可以被并发驱动跑不同的题,任何实例级可变状态都不允许。**
dissect 有一档实验要在同一时刻用不同的注入内容跑同一批题,实例级状态会让并发的候选互相污染。
**十、循环拿不到分数。** 让被试拿得到真值等于开后门,实验当场作废。评分必须在会话关闭前由
外层用宽接口完成,PolyLoop 从始至终只见窄接口。
## 组件映射
接缝的完整清单与各自职责在 `../explanation/architecture.md` 第九节,本表只做映射。
| dissect 侧 | PolyLoop 侧 | 备注 |
|---|---|---|
| `LedgerClient.chat` | 模型调用接缝 | dissect 的三本账记账继续作为绑定上下文的实现装配进来 |
| `parse_code_action` | 决策解释接缝 | 三分支:动作 / 最终回答 / 无效决策 |
| `Episode.execute` | 动作执行接缝 | dissect 侧只有「返回文本」和「抛异常」两种结果,状态恒为「已执行」 |
| `Episode.is_done` | 动作执行接缝返回值上的完成信号字段 | **不是独立接缝**,见 architecture.md 第九节 |
| `ArtifactView`(通道、条目标识、内容三字段) | Skill 注入的输入形态 | |
| `build_prefix` 的段顺序约束 | **不是接缝**,是库内 `_assembly` 的纯函数 | 渲染格式留在 dissect,理由是排除条款(见 `../explanation/scope.md` |
| `AgentConfig` 的四个预算字段 | 请求上的预算 | |
| `StopReason` | 停止原因 | dissect 现有六个取值是库的下界 |
| `Step` | 逐步轨迹的一步 | dissect 现有十三个字段是库的下界 |
具体类型名与签名按 `../../CLAUDE.md` §2 要过人类门,还没定;定了之后本表补上英文名。
## 缺口登记
**轨迹文件的格式是反思模型的唯一输入界面,迁移后要由 dissect 自己重组。**
`Rollout.to_jsonl` 现在把项目 metadata 头和逐步轨迹写在同一个文件里,第一行是头、后面每行
一步。PolyLoop 只返回运行标识和步序列,所以 dissect 要自己把两者拼回那个格式,而且格式
不能变——现有的分析代码和不变量检查器都在读它。这是一笔真实的迁移成本,不是设计缺陷。
**模型是正常收尾还是被输出长度上限砍断,现在拿不到。** dissect 的 `Step` 里没有这个字段,
原因是 PolyGateway 的响应类型不透出它。这个信息有实际价值——输出被砍断时代码只写了一半,
表现成解析失败或语法错误,会与「模型不会做」混淆。迁移后仍然拿不到,要补得给 PolyGateway
提 PR。登记在这里是为了迁移时不要误以为是 PolyLoop 弄丢的。
**动作被拒绝这个概念 dissect 侧没有。** 它的环境执行只有两种结果:返回文本(代码报错也算
正常观察)或者抛异常(环境故障)。PolyLoop 的动作执行接缝要区分「已执行」和「未执行」,
是因为另一个消费者需要。dissect 侧的实现状态恒为「已执行」,这一点要在迁移时确认不会改变
它的步数统计口径。
### 由 `../design/0003-public-api-shape.md` 新产生的四项成本
**每个 episode 要写一个包装类。** dissect 现有的 `Episode` 协议与 PolyLoop 的动作执行接缝
**不构成结构化子类型**:那边是 `execute(action: str) -> str``is_done() -> bool`
而库这边要的是一个返回结构体(状态、观察、是否合成、完成信号、截断字符数)的方法。
所以不能直接把 `Episode` 传进去,每个 benchmark 的适配器要多一层薄包装。
**续跑路径要改。** dissect 的 runner 现在把「没有结果行的尝试」重新排进待办,用完全相同
的六维主键第二次调用循环。迁移后这条路要改成调「接着跑」而不是「再跑一次」——按
`../design/0003` 的前置条件,用同一个运行标识第二次调「跑一次」会直接报错。
**要选一个存储实现并给它一个目录。** 存储接缝是必填的,不传就装配不起来。dissect 如果暂时
不要恢复能力,要显式装配那个明确命名的、不提供恢复的内存实现——「我不要恢复」是一次
看得见的选择,不是一个可以忘记传的参数。
**模型绑定要从关键字参数还原。** dissect 现在给每次调用传五个关键字参数(账本、轮次、
阶段、题目、尝试序号)。库这边接的是一个字符串映射,所以适配器要做一次还原(把账本那个
字符串转回枚举、把轮次和尝试序号转回整数)。这是适配器该干的活,收益是这五维能原样进
参数快照。
+152
View File
@@ -0,0 +1,152 @@
# 设计对齐:GovDoc-SaaS
> **更新触发点:第 2 档(同提交同改)。** 公共类型或接缝语义变更时,在同一个提交里核对
> 本文的需求条目还能不能被承载;GovDoc 侧的 agent 方案定下来时,在同一个提交里更新需求
> 来源与缺口清单。
>
> **当前状态:这不是一份迁移清单,是一份需求清单。** GovDoc-SaaS 的 agent 部分还没有可迁移
> 的东西,本文的作用是防止 PolyLoop 只按 dissect 一家的形状长。
PolyLoop 只有 dissect 一个硬消费者(见 `dissect.md`)。只对着一个消费者做,做出来的库会长成
那个消费者的形状,而这件事在完成之前看不出来。GovDoc 这边不做迁移验收,改做**设计级验收**:
把它真实需要的东西列成条目,逐条问 PolyLoop 能不能承载,答不上来的登记成缺口。
## GovDoc 的项目术语
这些词是 GovDoc 的,不是 PolyLoop 的。PolyLoop 自己的词表在
`../explanation/architecture.md` 第二节。
**PES**——Plan-Execute-Summarize,「计划—执行—总结」三阶段。GovDoc 的一个任务被切成这三段,
每段跑一次 agent,段与段之间靠工作区里的文件交换状态:计划阶段产出一份计划文件,执行阶段
读它并产出一批发现,总结阶段读那批发现产出最终结果。
**工作区**——一次 agent 运行独占的沙箱目录。里面有这次运行能看的数据、能写的输出、以及
运行结束后要留下的产物。它由 GovDoc 侧建立、快照和清理,PolyLoop 不认识它。
**必须产出的文件**——每个阶段声明几个文件路径,跑完之后这些文件必须存在,否则这个阶段算
失败。这是 GovDoc 判断「阶段目标达成没有」的方式,按 `../explanation/scope.md` 它在界外。
**提交型完成**——agent 靠调用一个特定的工具来宣布自己做完了,环境状态不发生任何变化。它跟
另一种完成方式(环境自己报告「这道题结束了」)是两回事,两者都要能表达。
## 为什么这里没有迁移清单
GovDoc 有两套 agent,形态完全不同,而两套都不能直接当迁移对象。
**GovDoc-Editor 是今天跑在生产上的系统。** 它的 agent 循环归 Claude Agent SDK,业务层明令
禁止 import 那个 SDK,中间隔着一层叫 Scrivai 的编排框架。Scrivai 做的是「计划—执行—总结」
三阶段编排:每个阶段跑一次 agent,阶段之间通过工作区里的文件交换状态,每个阶段有自己的
步数上限、可用工具集和必须产出的文件清单。
`../explanation/scope.md` 的判据,**阶段编排在 PolyLoop 界外**。所以 GovDoc-Editor 不是
迁移对象,它是需求来源——它告诉我们「阶段编排要建在执行内核之上」时,对执行内核提了哪些
要求。
**GovDoc-SaaS 是 GovDoc-Editor 的重构版**,它的 `packages/docagent-core/` 正在把上面那套东西
换成自建:`workflow/phase.py` 的注释写着「三阶段泛化为 N 阶段」,`PhasedWorkflow` 自己接
`AgentLoop`Claude Agent SDK 不见了。
但那份 `AgentLoop` 不能当迁移基准。文件头第一句写着它「完整保留」某个项目的循环逻辑,而
那个项目已经废弃;更要紧的是,`grep "from docagent_core" src/` 在业务层零匹配——它有测试,
但从来没被任何业务代码调用过。**拿一份没有调用方、且血统来自废弃项目的代码当验收标准,
验的是那个废弃项目的行为。**
所以需求来源是两处:GovDoc-Editor 的生产实践,以及 `docagent-core` 作为一次已有的抽象尝试
所暴露的形状。
## 需求条目
**一、一份 agent 定义要能按次运行配不同的预算。**
生产上那份审核 agent 的三个阶段分别是 50、50、16 步。如果预算只能挂在定义上,就得为三个
阶段建三份定义,而它们除了预算之外完全一样。
**二、要能按次运行收窄工具集。**
三个阶段可见的工具不同:计划阶段有读、写、搜索、执行;执行阶段多一个技能调用;总结阶段
只有读、写、通配查找。这条和「模型可见的 schema、存在性校验、分发三者同源」不冲突——
每次运行构造一个窄的注册表就行。
**三、工具调用协议是 JSON,而且要容错。**
模型输出的是一段 JSON,含反思、计划、动作三部分,动作里有工具名和参数。实际遇到的两种
偏差都得处理:整段 JSON 被包在代码围栏里,以及参数被平铺在动作层而没有嵌套。后者的收拢
要保守——只有当除工具名之外确实存在平铺参数时才收拢,否则会把「缺参数」这个错误静默升级
成「参数为空但合法」。
**四、无效的工具调用不计有效步,但要计入总迭代上限。**
不计有效步是因为它没真的做事;计入总迭代是因为模型可能一直调用不存在的工具,没有这个上界
循环不会停。这两个计数必须分开,混成一个就防不住无限循环。
**五、提交型完成:某个特定工具被调用即视为完成。**
生产上那份 agent 靠调用一个提交工具来结束,环境状态不发生变化。这和另一种完成方式——环境
自己报告「这道题结束了」——是两回事,两者都要能表达。
**六、审计事件必须覆盖模型步与工具调用,且事件发送失败不能中断循环。**
业务侧有一条硬纪律:agent 的原始输出、修复后的输出、恢复来源全程留痕,禁止静默修复。所以
「模型原文」和「解析/修复之后的结果」要分别可见。
**七、取消要能穿透。** 长任务的所有权以数据库租约为准,取消信号从外部进来之后必须能打断
正在进行的模型调用与工具执行,并释放在途资源。
**八、工作区是一个有边界的端口。**
生产实现把它抽象成六个操作:按行读、原子写、grep、列文件、判断存在、算校验和;所有路径都
是工作区内的相对路径,越界要抛异常。按 `../explanation/scope.md`,工作区的建立、快照与清理
在界外,但**提示词的构造要能读工作区**(阶段的提示词是从上一阶段的产物生成的),这一点见
下面的缺口。
**九、阶段级续跑要能判断上一次运行是不是真的跑完了。**
阶段级续跑本身在界外,但它依赖一个界内的事实:运行结果必须是可持久化、可读回、可判定的
结构,而且「这次运行结束了」要由库自己写下来。这条已经落进 `../design/0002-step-level-resume.md`
## 已经有答案的(原缺口登记,`0003` 回答)
这三条曾经登记为「PolyLoop 答不上来」,`../design/0003-public-api-shape.md` 已经定了。
留在这里是因为它们是 GovDoc 侧真实的接入约束,迁移时要照着改代码。
**预算挂在请求上,不是定义上。** 一份定义可以被三个阶段共用,各传一份预算,不会长出三份
除预算外完全相同的定义。**不设「定义给默认值、请求可覆盖」**——两处取值意味着「这次到底
跑的什么设置」要对照两个地方才答得出来。
**提示词的内容由项目读好了传进来,库不接触工作区。** 上下文装配不是接缝,所以「让库接受
一个项目对象再转交」这条路不存在。每个阶段的提示词构造器仍然在 GovDoc 侧,它照常读工作区,
只是把读出来的结果作为上下文的一段交给库。库只管段的顺序和注入槽的位置。
**两层重试都不归本库,预算口径按库能观测的量算。** 一次模型调用之内的重试与换源归
PolyGateway,整次运行的重试归 GovDoc 自己的编排层;中间那一层(库在模型调用失败后自己再
调一次)举不出两个消费者,不做。**PolyGateway 内部换源重试不消耗任何预算**,因为库数的是
一次模型调用接缝的调用——口径必须是库自己能观测的量,否则换个后端口径就变了。
**提交型完成靠工具注册表上的完成标记。** 注册表知道哪个工具一旦被成功执行就代表目标达成,
库在动作结算之后查它。GovDoc 的动作执行接缝完成信号恒为「未完成」——它的环境状态不因为
提交而改变,所以环境这条通路对它永远不成立,收尾走的是完成标记这条。
这一条曾经差点出大事:`0003`/`0004` 的初稿把完成信号定成「布尔或空、空表示取不到」,并且
规定「取不到即环境故障」。照那个写法,GovDoc 的**每一次运行都会在第一步撞环境故障终止**。
Codex 对抗审查抓出来了,修法见 `../design/0004-stopping-and-step-record.md` 决策三 G 档。
这一条对 GovDoc 是实质变化:`docagent-core` 现有的那层步级重试(超时与网络异常,退避
20 秒和 40 秒)迁移后没有对应位置。它当初存在的理由是「治理层的某些异常类型如果没在装配点
显式注入,重试对它们就静默失效」——那是 PolyGateway 装配的问题,要在装配点解决,不是在
Agent 层补一层。
## 缺口登记
这些是 PolyLoop 现在答不上来的问题。答不上来不等于设计错了,但每一条都得有明确结论——
哪怕结论是「不支持,理由是什么」。
**审计出口与事件流的关系。** 生产实现的审计出口是一个「发一条带类型和载荷的事件」的接口,
而 PolyLoop 的方向是「观察走事件流、干预走具名回调」。事件流能不能覆盖审计的需求,取决于
事件里带不带原始响应——审计纪律要求原始输出和修复后的输出都留痕。事件集与回调清单要独立
成一份 design doc,这条在那时候定。
**动作被拒绝之后的重复行为。** 生产实现对无效工具调用不设单独的重试上限,靠总迭代上界收敛。
PolyLoop 目前的想法一致,但要确认这在长阶段(50 步)上够不够——模型反复调用同一个不存在的
工具,会把整个步数预算烧光而不产出任何东西,而这在轨迹上表现成「预算耗尽」,与真的做不完
混在一起。
## CHSAnalyzer 为什么没有对应的文档
CHSAnalyzer 是第三个已知消费者,定位是远期兼容。它的架构文档第十四节明确写了评估层和诊断层
现在是空的,而且「不为它们预留任何结构」,理由是「留了位置整个系统会立刻复杂一个量级,
而现在还不知道它们真正需要什么形状——猜出来的接缝比没有接缝更难拆」。
**没有需求可以登记,所以不建那份文档。** 等它的方案定下来再建。这一段写在这里,是为了让
读者知道这是一个决定而不是遗漏。