Files
PolyLoop/research-wiki/explanation/architecture.md
T
iomgaa f8e02290f4 docs(design): 落成 0006 与 0007,公共 API 的名字、签名与接缝行为
0006 定「叫什么、什么形状」:五个接缝的 Protocol 名与签名、公共类型的英文名与
字段清单、类型分到 types / ports / tools 三个模块的判据。
0007 定「同一个签名下什么算对」:三个动作状态的触发条件、动作被拒绝时观察由库
合成而不取执行器那段、解释器不许抛异常、read_log 读不存在的运行返回空日志。
两份拆开是因为后者的权威处按 §0 是 tests/contract/,design doc 只记当初为什么这么定。

这两份改动了 0003 四处,全部在文首登记:记录集合是六种东西不是五类;
参数视图是方法不是字段;预算是四项不是两个计数;ports 装「Protocol 与它们的
入参/返回结构体」那半句写不出来——照它写 types 会反向依赖 ports。
四处全是「把字段类型逐个写出来」这个动作本身逼出来的,纯读文档看不见。

四轮评审:两轮硕士生冷读报了约 45 条,两轮 Codex 对抗审查报了 13 条,
逐条核实后基本全部成立并修完。最后一轮是唯一一次契约测试与文档互相抓到对方的错——
文档改了方法名测试没跟,测试把 dissect 的动作语言写死成输入会误杀 GovDoc 的实现。

结论回写 architecture.md:第七节补类型归属判据,第八节改 ports 那一行,
第九节补五个 Protocol 的英文名,第十四节把「英文名还没定」那条缺口换成指向;
决策索引加两行。字段表刻意不回写——按 §0 那是代码的权威。
CLAUDE.md 与 README.md 开头的「一次 Agent Session」是术语漂移,改成「一次运行」。

CLAUDE.md §7 加两条工作方式:能压成一段结论的活尽量交给 subagent、
委托出去的活交证据不交判断;以及持续往下做,只在人类门和真判断不了的岔路停。
§8 那句「讲完停下来等回应」与后者打架,收窄到只管说话方式。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 23:56:38 -04:00

568 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 库的结构
> **更新触发点。** 常青文档必须写明「什么事情发生时它一定会被改」,本项目把这件事分三档:
> 第 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`
## 七、分层与依赖方向
这一节和第八、第九节是本文件的正题,也是机器断言的对象。
### 分层
**类型分到 `types` 还是 `ports`,判据是「它是不是一个值」**:值类型与持久化记录住 `types`
只为一次调用打包入参或返回的壳住 `ports`。判据不能写成「会不会被写进日志」——消息不出现在
任何一条日志记录里,照那条会判进 `ports`,而上下文住 `types` 且字段就是消息序列,于是
`types` 反向依赖 `ports`。理由见 `../design/0006-public-names-and-signatures.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/` 这类名字——它们的职责是「剩下的东西」,一句话说不清职责就没有边界。
## 九、抽象接缝:哪里允许换实现
设一个接缝的判据只有一条:**举得出两个在真实消费者身上形态明显不同的实现。** 两个实现
不要求来自两个不同的项目,同一个项目的两个不同用途也算。举不出两个的不设接缝——抽象出来
也只有一个实现,白白多一份永久合同和一套契约测试。
### 五个接缝
**模型调用** `ModelClient`(挂定义)。把已装配好的消息序列送出去,拿回可见回复与推理段。签名里不出现
重试次数、退避时长、限流配额——出现即意味着库在治理一次模型调用,而那归 PolyGateway。
两个形态:一种要在调用点按多本账各记一条并自己按价格表算成本,一种要在调用外面套退避并
累加本次运行的用量。
返回类型是库自己的,不是 re-export PolyGateway 的响应类型。理由是接缝定义模块不许有第三方
依赖(第七节规则五),而且 PolyGateway 加一个字段就等于本库的公共类型变了一次却没发过版。
**决策解释** `DecisionParser`(挂定义)。把一次模型回复解释成三分支之一:动作、最终回答、无效决策。
库不带默认实现——带了就等于替某一家定了动作语言。两个形态:一种从代码围栏里抽 Python
源码,一种从 JSON 里抽工具名与参数。
**动作执行** `ActionExecutor`(挂请求)。结算一个动作,返回状态(已执行 / 未执行 / 环境故障)、观察、
观察是不是库合成的、完成信号、被截断的字符数。两个形态:一种把一段代码交给已开好的容器
会话、状态恒为已执行,一种查工具注册表分发、工具不存在或参数不合法时返回未执行。
**完成信号是布尔,查询失败由状态字段的「环境故障」表达,它自己没有第三个取值。** 没有环境
完成信号的环境返回「未完成」——这是一条已经在跑的约定,dissect 的环境协议就是这么规定的。
不给「本环境没有这个概念」单设一个取值,是因为它在所有代码路径上的行为和「未完成」完全
相同,而一个行为上无差别的枚举取值只会腐烂。
环境不另设第二个接缝:执行动作与查询完成信号住在同一个对象上。拆开会让工具分发那条路径
多出一个只能返回常量的空壳。
一次运行的完成有**两个信号源**,可信度不同,所以分开表达:环境的完成信号是环境状态里真的
留下了记录;工具注册表上的完成标记是 agent 自己宣布的(调了某个被标记的工具即视为达成)。
两者都由库消费,不各开接缝。
**两者共用一个停止原因,靠步记录上的完成信号那一列区分。** 停在「目标达成」而那一列为
「已完成」的走的是环境那条,为「未完成」的只可能是完成标记被触发。这个区分不能靠「工具名
落在注册表的完成标记里」来做——注册表不在轨迹文件里,而事后分析手上常常只有那个文件。
**观察那一项是回填进历史的那段文本,不是环境返回的原文。** 执行器丢掉了多少由「被截断的
字符数」单独记。存原文的话,恢复时得拿原文重跑一遍截断逻辑才能得到历史,而截断逻辑会随
版本变——那正是记录逐步结果要消掉的那类重算。
**存储** `RunStore`(挂定义)。六个方法:写运行开始、写一条意图、写模型调用结果、写动作结果与步记录、
读回整份日志、写运行结束。全部带运行标识,端口不持有「当前运行」的隐式状态——一个有隐式
当前运行的端口在并发下会把 A 的意图写进 B 的日志。两个形态:一种逐行写本地 jsonl 文件,
一种写关系数据库。
写入粒度是契约的一部分:两条意图记录各自单独落地;**动作结果与步记录一次原子落地**,要么
都可见、要么都不可见。不原子的话,崩在两者之间会让那一步的历史文本永远丢失,而恢复判定会
把它读成「执行完了,跳过」。**这条不靠散文约束,靠方法的切法**——两者由同一个方法收下,
「只写了一半」在签名上无从表达。那个方法的动作结果一项可以为空(模型调用失败的步、解析
失败的步照样有步记录),但必填、无默认值。
**存储实现必须保证前缀持久性:第 k 次写入被确认已经持久时,第 1 到 k-1 次写入也已经持久。**
少了它,模型调用结果还在缓冲区而后面那条动作意图(屏障)已经落盘,恢复会把一次明明成功了
的模型调用记成「状态未知」——而它成功的证据就在同一份日志里,那条动作意图正是从它的返回值
解释出来的。两个已知形态天然满足:同一个文件的追加写,`fsync` 一次刷掉之前全部;同一个连接
上顺序提交的事务,先提交的先持久。代价是排除了「不同记录类型写进彼此无序的多个后端」这种
形态,现在没有消费者要它。
**事件出口** `EventSink`(挂定义)。投递失败由库捕获、记日志、把失败计数加一,然后继续跑。失败不再
转成一条事件从同一个出口发出去——那会自我喂食,一个持续失败的出口会让失败处理路径变成
递归。两个形态:一种把进度回写业务数据库,一种把审计事件送进日志管道。
### 三个刻意不抽象的地方
**完成判定。** 信号源全在库内,或者已经在动作执行接缝的返回值上。再设一个入口等于同一件
事有两个来源,而两个来源迟早分叉。
**观察投影。** 需要搬运的两个量——模型原文与进历史文本、环境观察与是否合成——已经分别由
决策解释与动作执行的返回结构体携带。再开一条能改观察的路径必然分叉。
**上下文装配。** 真正会变的是渲染格式,而渲染格式留在项目侧:它是下游的实验因子,库在
内部按某个条件拼自己的文本,那段文本就成了库定的实验刺激,并且逃出项目的参数快照。
归库的只有段顺序与注入槽的位置,那是纯函数不是扩展点。
## 十、装配形态
本库不部署,也不跑模型推理,没有常驻进程。它被 `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 档:改相关代码时,改本文件是同一个提交的一部分。
## 十四、已知缺口与尚未决定的部分
**公共类型的字段与枚举取值不在本文件里。** 英文名与签名定在
`../design/0006-public-names-and-signatures.md`,行为契约定在 `../design/0007-seam-behaviour.md`
落地之后权威转移到 `src/polyloop/` 的代码与 `tests/contract/`。本文件只给五个接缝的
Protocol 名与模块归属,不复述字段表——按 `../../CLAUDE.md` §0,那种复述腐烂的速度和代码一样快。
**停止判定的顺序、停止原因的取值、两个预算计数的语义、步记录的字段清单不在本文件里。**
这四样已经定了,在 `../design/0004-stopping-and-step-record.md`,字段表经
`../design/0005-storage-atomicity-and-record-fields.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` |
| 存储的方法为什么这么切、为什么要前缀持久性、步记录那三处为什么改 | `../design/0005-storage-atomicity-and-record-fields.md` |
| 公共类型与接缝叫什么、字段是什么形状、类型分到哪个模块 | `../design/0006-public-names-and-signatures.md` |
| 三个动作状态什么时候赋上、动作被拒绝时观察从哪来、解释器能不能抛异常 | `../design/0007-seam-behaviour.md` |
边界的当前裁决清单(哪些在界内、哪些在界外)在 `scope.md`,那份是常青的,会随新消费者
接入而更新。每个下游要迁什么、迁完算不算数在 `../migrations/` 下对应那份。