From 4f8812fa8259bf4ecf2c47715d40df674c06d33d Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sun, 9 Aug 2026 10:48:33 -0400 Subject: [PATCH] =?UTF-8?q?docs(design):=20=E8=90=BD=E6=88=90=E8=BE=B9?= =?UTF-8?q?=E7=95=8C=E3=80=81=E7=BB=AD=E8=B7=91=E3=80=81=E5=85=AC=E5=85=B1?= =?UTF-8?q?=20API=20=E5=BD=A2=E7=8A=B6=E4=B8=8E=E5=81=9C=E6=AD=A2=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=E5=9B=9B=E4=BB=BD=E5=86=B3=E7=AD=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第 ② 阶段需求对齐与第 ③ 阶段架构的产出,代码尚未开始。 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) --- CLAUDE.md | 1 + research-wiki/README.md | 70 +- research-wiki/design/0001-scope-boundary.md | 147 +++++ .../design/0002-step-level-resume.md | 184 ++++++ research-wiki/design/0003-public-api-shape.md | 613 ++++++++++++++++++ .../design/0004-stopping-and-step-record.md | 284 ++++++++ research-wiki/explanation/architecture.md | 537 +++++++++++++++ research-wiki/explanation/scope.md | 154 +++++ research-wiki/migrations/dissect.md | 194 ++++++ research-wiki/migrations/govdoc-saas.md | 152 +++++ 10 files changed, 2332 insertions(+), 4 deletions(-) create mode 100644 research-wiki/design/0001-scope-boundary.md create mode 100644 research-wiki/design/0002-step-level-resume.md create mode 100644 research-wiki/design/0003-public-api-shape.md create mode 100644 research-wiki/design/0004-stopping-and-step-record.md create mode 100644 research-wiki/explanation/architecture.md create mode 100644 research-wiki/explanation/scope.md create mode 100644 research-wiki/migrations/dissect.md create mode 100644 research-wiki/migrations/govdoc-saas.md diff --git a/CLAUDE.md b/CLAUDE.md index 15a9e13..65fa605 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,7 @@ | 这类事实 | 权威处 | |---|---| +| **哪些事归本库管、哪些不归**,以及判据 | `research-wiki/explanation/scope.md` | | 分层、依赖方向、模块边界 | `research-wiki/explanation/architecture.md`,将由 `pyproject.toml` 的 import-linter 契约机器断言。**那些契约还没写**(要等 `src/` 落地才写得出来),在它们出现之前这份文档就是权威,没有机器兜底 | | 公共 API 的行为契约:一次 `run` 到底保证什么、边界条件怎么结算 | `tests/contract/` 的公共契约套件。它同时是任何新适配器的准入标准 | | 公共类型的字段、不变量、枚举取值 | `src/polyloop/` 的代码与其测试。**不另写一份参考文档复述它们**——那份文档不重复代码的内容太少,而它腐烂的速度和代码一样快 | diff --git a/research-wiki/README.md b/research-wiki/README.md index d4ce0e1..059b70d 100644 --- a/research-wiki/README.md +++ b/research-wiki/README.md @@ -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`,旧的原样留着。 -这是从 ADR(Architecture Decision Record,架构决策记录,一种把每次架构决策单独存成一份不可修改 -文件的做法)里保留下来的唯一一条机制。成本很低,但记录层的价值全靠它——只有旧文档还在, -你才能看出决策是怎么演变的。 +**冻结**:写完不改。决策变了就新写一份,旧的原样留着。这是从 ADR(Architecture 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 写「我们当时面对什么问题、比较了哪几个方案、为什么选了这个、放弃了什么」, diff --git a/research-wiki/design/0001-scope-boundary.md b/research-wiki/design/0001-scope-boundary.md new file mode 100644 index 0000000..614c11e --- /dev/null +++ b/research-wiki/design/0001-scope-boundary.md @@ -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 下游、业务概念不进内核), +但「这个机制该不该进库」这类判断守不住,只能靠评审。 diff --git a/research-wiki/design/0002-step-level-resume.md b/research-wiki/design/0002-step-level-resume.md new file mode 100644 index 0000000..23a90e0 --- /dev/null +++ b/research-wiki/design/0002-step-level-resume.md @@ -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` 的规矩,这是一个必须尽快关掉的缺口。 diff --git a/research-wiki/design/0003-public-api-shape.md b/research-wiki/design/0003-public-api-shape.md new file mode 100644 index 0000000..48220e4 --- /dev/null +++ b/research-wiki/design/0003-public-api-shape.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` 要相应区分「界内且已实现」 +与「界内但未实现」,否则读者会以为库有这个能力。 diff --git a/research-wiki/design/0004-stopping-and-step-record.md b/research-wiki/design/0004-stopping-and-step-record.md new file mode 100644 index 0000000..e665169 --- /dev/null +++ b/research-wiki/design/0004-stopping-and-step-record.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 一样,只是这一份特别容易被误用,因为它含一张字段表。 diff --git a/research-wiki/explanation/architecture.md b/research-wiki/explanation/architecture.md new file mode 100644 index 0000000..771cd82 --- /dev/null +++ b/research-wiki/explanation/architecture.md @@ -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/` 下对应那份。 diff --git a/research-wiki/explanation/scope.md b/research-wiki/explanation/scope.md new file mode 100644 index 0000000..aaf2209 --- /dev/null +++ b/research-wiki/explanation/scope.md @@ -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/` 落地。 diff --git a/research-wiki/migrations/dissect.md b/research-wiki/migrations/dissect.md new file mode 100644 index 0000000..881721c --- /dev/null +++ b/research-wiki/migrations/dissect.md @@ -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 现在给每次调用传五个关键字参数(账本、轮次、 +阶段、题目、尝试序号)。库这边接的是一个字符串映射,所以适配器要做一次还原(把账本那个 +字符串转回枚举、把轮次和尝试序号转回整数)。这是适配器该干的活,收益是这五维能原样进 +参数快照。 diff --git a/research-wiki/migrations/govdoc-saas.md b/research-wiki/migrations/govdoc-saas.md new file mode 100644 index 0000000..9d8e3d0 --- /dev/null +++ b/research-wiki/migrations/govdoc-saas.md @@ -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 是第三个已知消费者,定位是远期兼容。它的架构文档第十四节明确写了评估层和诊断层 +现在是空的,而且「不为它们预留任何结构」,理由是「留了位置整个系统会立刻复杂一个量级, +而现在还不知道它们真正需要什么形状——猜出来的接缝比没有接缝更难拆」。 + +**没有需求可以登记,所以不建那份文档。** 等它的方案定下来再建。这一段写在这里,是为了让 +读者知道这是一个决定而不是遗漏。