c4e5732587
套件从 tests/contract/ 搬进 polyloop.testing 之后,全仓库 28 处引用要重新指过。修了 12 处, 其余在 design/(只增不改)与 scratch/(由人清理)里。 **CLAUDE.md 改了四处事实**:§0 权威表里行为契约的权威、§0 那句依赖规则的条数、§5 目录树与 模块数、§1.8 那句「谁断言公共 Protocol 的签名」。§1 的其余硬约束与 §2 的人类门一条没动。 **architecture.md**:分层图第 4 层加一格,装配层从三个变四个;代码地图加一行;第十节按代码 逐项重写——那笔「工具段渲染样式」的欠账**没有被数字对上盖掉**,加了 fingerprints 之后请求 的字段数恰好还是十一,而组成已经换过,所以那一节正面写着它仍然欠着;新增第十条依赖规则 (pytest 只在 testing 那个 extra 里,别处 import 它会让下游的生产环境一 import 本库就 ModuleNotFoundError),带静态与运行时两半;删掉「src/ 下一行代码都没有」那段过期状态说明; 决策索引补齐 0008 到 0016,其中四行原描述说的不是那份文档真正定的东西。 **migrations/dissect.md** 那笔「内存实现不存在」的欠账还掉了。 **压测那边**三条测试守的是一条已经撤销的公共契约,改名并写清它们现在守的是场景自己的选择。 AppWorld 那处刻意的偏离(不补三个反引号)留着不恢复——那条路径要模型输出被 stop 序列截断才 触发,而压测不配 stop 序列,恢复的收益不抵重跑一次压测的成本。但注释的理由改对了:它现在是 一笔有出处的欠账,不是一个决定。 CHANGELOG 攒在「未发布」段,版本号不提前写(§1.10)。
603 lines
43 KiB
Markdown
603 lines
43 KiB
Markdown
# 库的结构
|
||
|
||
> **更新触发点。** 常青文档必须写明「什么事情发生时它一定会被改」,本项目把这件事分三档:
|
||
> 第 1 档是机器断言(文档说的和代码不符,CI 直接失败),第 2 档是同提交同改,
|
||
> 第 3 档是定期复审。能用强的就不用弱的,完整说明见 `../README.md`。
|
||
>
|
||
> 本文件的第七、八、九节是**第 1 档**:那三节讲的分层、模块边界与抽象接缝由 `pyproject.toml`
|
||
> 的 import-linter 契约与 `polyloop.testing` 那套契约套件断言。**其余章节是第 2 档**。
|
||
>
|
||
> **本文件与 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 │ │ testing │
|
||
│ 定义、请求 │ │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` 与 `testing` 只够到第 2 层(前两个 import `ports` 去实现那些
|
||
Protocol,再 import `types` 用那些数据类型;契约套件 import 同样这两处,用来给下游的实现出
|
||
题),不需要碰第 3 层。
|
||
|
||
**存储实现在上面而不是下面,这一点最容易画反。** 直觉上存储是底层设施,该垫在最底下;
|
||
但按 import 方向,是存储实现去 import 接口定义,所以它在接口之上。库的核心不认识任何具体
|
||
存储,只认识那个接口——换一个后端,核心一行都不用改。
|
||
|
||
判据只有一句:**`polyloop.types` 是依赖图的汇点**——所有箭头最终都指向它,没有一根从它出去。
|
||
它就是「字段只增不删不改名」保护的那份合同本身。
|
||
|
||
### 十条依赖规则
|
||
|
||
这些规则在代码里是**看不见的**——打开 `polyloop/ports/` 只会看到一堆正常的 Protocol,
|
||
看不到那里缺了什么。所以必须写下来,并且每一条都有对应的机器断言。
|
||
|
||
**一、分层,自高向低**:装配层(`session`、`stores`、`adapters`、`testing`)> 逻辑层(`tools`、
|
||
`_assembly`、`_stopping`、`_recovery`、`serialization`)> `ports` > `types`。低层不许
|
||
import 高层。
|
||
|
||
**二、装配层那四个互相独立。** `session`、`stores`、`adapters`、`testing` 两两之间都不许
|
||
import。这条不能靠分层规则表达——同一层的模块在分层契约里默认是可以互相 import 的,要另立
|
||
一条独立性契约。它守的是「库不顺手提供任何默认实现」:`session` 一旦 import 了某个存储实现,
|
||
那个实现就成了隐式默认,而不传存储的人不会知道自己这次运行没有恢复能力。
|
||
|
||
契约套件落在这一层、并且同样被这条独立性契约管住,有它自己的一条理由:本库声明了一个 pytest
|
||
插件入口,所以每一个装了本库的下游项目在 pytest 启动时都会加载 `polyloop.testing`。它一旦
|
||
import `adapters`,那次加载就会把网关连同它的 provider 目录一起拉起来——那正是规则九要防的
|
||
事,只不过触发路径不是 `import polyloop`,而是 pytest 自动加载插件
|
||
(`../design/0014-contract-suite-distribution.md` 决策三)。
|
||
|
||
**三、`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.testing` 外一切不许 import `pytest`,且 `import polyloop` 之后
|
||
`sys.modules` 里也不许出现 `pytest`。** 这条有两半,形状和规则五加规则九那一对相同:静态
|
||
那半是一条 import-linter 契约,运行时那半是一条测试。守的事很具体——pytest 只住在 `testing`
|
||
这个 extra 里,核心的运行时依赖是空的,所以别处 import 它,下游的生产环境一 `import polyloop`
|
||
就 `ModuleNotFoundError`,而生产环境通常根本没装 pytest。契约套件自己顶层就 import pytest,
|
||
所以它和 `stores`、`adapters` 同一档,不进顶层的再导出,必须显式 import。
|
||
|
||
## 八、代码地图:每个模块装什么
|
||
|
||
| 位置 | 公开 | 装什么 |
|
||
|---|---|---|
|
||
| `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/testing/` | 是,须显式 import | 五个接缝的契约套件:每个接缝一个基类,下游继承它验自己的实现 |
|
||
|
||
`polyloop/types/` 的读者是所有人:dissect 的 runner 读步记录与停止原因把轨迹头拼回去,
|
||
GovDoc 的编排层读停止原因决定这个阶段算不算可挽救,写任何适配器的人都要构造这里的结构体。
|
||
|
||
`polyloop/testing/` 住在包里而不是 `tests/` 下,因为 `tests/` 不进发行包:`pip install polyloop`
|
||
之后 site-packages 里没有它,而这套用例正是下游实现接缝时的准入标准,拿不到的准入标准不成其为
|
||
标准。下游的接法是继承基类、在自己的子类里覆盖那几个必需 fixture;它不进 `polyloop/__init__.py`,
|
||
因为它顶层就 import pytest,而 pytest 只在 `testing` 这个 extra 里,核心的运行时依赖是空的
|
||
(`../design/0014-contract-suite-distribution.md` 决策一)。
|
||
|
||
`polyloop/ports/` 的读者是写适配器的人和 `polyloop/testing/`。它用 `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、无网络校验、无哈希计算。
|
||
|
||
模型绑定和指纹形状相同:都是下游自己定键名的字符串映射,都整个进参数快照,库都不解释里面
|
||
装的是什么。含义不同。绑定记的是这次运行属于哪一格(哪个账本、第几轮、哪道题),库还把它
|
||
原样透传给每次模型调用;指纹记的是这次用的材料是哪一版(提示词模板的哈希、技能库的版本
|
||
这类),只进快照,不透传。两者混在一个字段里事后分不开:一组键值里既有「第 3 轮」又有一个
|
||
sha,要靠键名的命名约定去猜哪个是哪个,而命名约定不在任何一处被断言
|
||
(`../design/0015-parameter-snapshot-contract.md` 决策一)。
|
||
|
||
**「工具段渲染样式」不在这十一样里,代码里也没有任何对应物。** `0003` 决策三的请求字段表
|
||
列了它,而它从第一版落地起就没有被实现,这笔欠账今天仍然欠着——它不是被哪次改动还掉的。
|
||
请求的字段数确实还是十一,但其中那一格现在是指纹:数字对得上,组成已经换过
|
||
(`0015` 的「留给后续的」记着这件事)。
|
||
|
||
切点是「跨运行变不变」。预算只在请求这一处,不设「定义给默认值、请求可覆盖」——两处取值
|
||
意味着「这次到底跑的什么设置」要对照两个地方才答得出来。
|
||
|
||
同一份定义可以被并发驱动跑很多次运行,所以定义里持有的每一个组件都必须不可变或可重入:
|
||
不许在实例上留计数器、缓存游标或当前运行的元数据。
|
||
|
||
## 十一、横切概念住在哪
|
||
|
||
| 概念 | 住在哪 | 说明 |
|
||
|---|---|---|
|
||
| 预算与停止判定 | `_stopping` | 无 I/O 纯逻辑,可穷举测试 |
|
||
| 上下文装配与规模度量 | `_assembly` | 同上 |
|
||
| 恢复状态判定与身份校验 | `_recovery` | 同上,且只接收已读出的记录 |
|
||
| 取消 | `session` | 标准 asyncio 取消,库不提供自己的取消令牌 |
|
||
| 意图日志的写入时机 | `session` | 唯一持有运行生命周期的地方 |
|
||
| 事件发射 | `session` | 接缝在 `ports`,发射时机在 `session` |
|
||
| schema 版本与迁移 | `serialization` | 读到未知 major 直接失败 |
|
||
|
||
取消这一条值得单独说:库不提供自己的取消令牌或 `cancel()` 方法。取消状态的权威在业务侧
|
||
(有的下游是数据库租约、有的是实验控制),库没有资格也没有能力持有它;自己再提供一个
|
||
就多了一处会漂移的状态。
|
||
|
||
## 十二、下游的可复现要求如何约束架构
|
||
|
||
dissect 的每一次运行都是论文数据点,这个性质对结构提了几条别的消费者不会提的要求。完整
|
||
清单在 `../migrations/dissect.md`,这里只列改变了结构的那些。
|
||
|
||
**停止原因必须是分类枚举,而且要能区分「恰好在最后一步做完」与「预算耗尽」。** 从轨迹长度
|
||
反推不出来,两种情况的长度一模一样。这条把停止判定的顺序变成了公共契约,而不是实现细节。
|
||
|
||
**上下文超限必须显式终止,不许静默截断。** 截断是一次前缀破坏操作,会让后续每一步重新
|
||
按全价计费。这条要求库在装配之后、模型调用之前能数出规模,所以消息内容必须能报告一个
|
||
规模度量。
|
||
|
||
**三类没碰环境的步也要留痕**:解析失败、模型调用失败、环境故障。前者消耗了一次模型调用,
|
||
不计则预算对等不成立;后两者的情形是钱已经花了、账已经记了,丢掉那一步会让账目与轨迹
|
||
对不上。
|
||
|
||
**消息段按变化频率从低到高装配。** 稳定前缀在前、逐次变化的在后。排错了不会报错,只会让
|
||
供应商的 prompt cache 静默失效,而多付的幅度随注入规模变化——于是缓存伪影会精确地伪装成
|
||
实验效应。
|
||
|
||
**历史只追加,不改写不重排。** 理由同上。
|
||
|
||
**一份定义可以被并发驱动跑不同的注入内容**,所以定义里不许有实例级可变状态。
|
||
|
||
## 十三、这份文档靠什么不腐烂
|
||
|
||
第七、八、九节各有对应的机器检查:
|
||
|
||
- **第七节**——十条依赖规则。规则一、二、三由同一条分层契约表达,规则八在那条里已经被覆盖,
|
||
另单列一条只为让违规信息直接指向 `ports`,规则四、五、七各一条禁止型契约,规则十的静态那半
|
||
也是一条禁止型契约。剩下的落在 `tests/unit/` 的测试上:规则六(不许 import 任何第三方)
|
||
写不成契约,因为「任何第三方」不是一份可枚举的清单;规则九和规则十的运行时那半写不成契约,
|
||
因为 `sys.modules` 里有谁是运行时事实,不是静态图上的边。
|
||
- **第八节**——一个断言检查 `polyloop/` 下有哪些位置,和第八节那张表逐行对得上。表里有一行
|
||
在代码里找不到、或者代码里多出一个没写进表的位置,都算失败。没有这一条,新加的模块会
|
||
悄悄绕过第七节的分层规则——一个规则里没提到的模块,等于没有任何约束。
|
||
- **第九节**——一个断言检查接缝的数量和位置没有悄悄增长;另一个断言检查 `polyloop/` 下
|
||
不出现重试、限流、熔断的实现。
|
||
|
||
**写到哪一步了:第七节那十条已经全部有断言,第八、九节还一条都没有。** 那两节现在没有机器
|
||
兜底,只能靠人在改代码时逐条对照——表里多一行少一行、接缝悄悄变成六个,CI 都不会响。
|
||
|
||
其余章节是第 2 档:改相关代码时,改本文件是同一个提交的一部分。
|
||
|
||
## 十四、已知缺口与尚未决定的部分
|
||
|
||
**公共类型的字段与枚举取值不在本文件里。** 英文名与签名定在
|
||
`../design/0006-public-names-and-signatures.md`,行为契约定在 `../design/0007-seam-behaviour.md`;
|
||
落地之后权威转移到 `src/polyloop/` 的代码与 `polyloop/testing/` 那套契约套件。本文件只给五个
|
||
接缝的 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` |
|
||
| 工具的实现挂在哪个字段上、它缺了什么时候报错、注册表派生的执行器怎么填动作结果 | `../design/0008-tool-handlers.md` |
|
||
| 字段顺序算不算一份对外承诺、公共数据类为什么一律只收关键字参数 | `../design/0009-keyword-only-public-types.md` |
|
||
| 上下文按什么顺序拼、注入槽为什么在那个位置、规模怎么量 | `../design/0010-context-assembly.md` |
|
||
| 逐行追加那个存储的文件布局、坏行怎么算、`fsync` 落在哪几处 | `../design/0011-jsonl-run-store.md` |
|
||
| 适配器为什么不自己装配网关客户端、消息怎么拼成一次网关调用、网关的异常为什么原样穿出 | `../design/0012-gateway-model-client.md` |
|
||
| 事件集为什么只有一个取值、事件带的是什么、具名回调清单为什么现在是空的 | `../design/0013-event-set-and-callbacks.md` |
|
||
| 契约套件怎么发给下游、下游怎么接上它、内存存储实现为什么叫易失 | `../design/0014-contract-suite-distribution.md` |
|
||
| 配方版本记进请求的哪个字段、注入的通道维度为什么保留、快照取值为什么必须是字符串 | `../design/0015-parameter-snapshot-contract.md` |
|
||
| 环境故障为什么走返回值、动作执行接缝抛异常时库为什么不接管、模型调用那侧为什么反而捕获 | `../design/0016-action-executor-failure.md` |
|
||
|
||
边界的当前裁决清单(哪些在界内、哪些在界外)在 `scope.md`,那份是常青的,会随新消费者
|
||
接入而更新。每个下游要迁什么、迁完算不算数在 `../migrations/` 下对应那份。
|