套件从 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)。
43 KiB
库的结构
更新触发点。 常青文档必须写明「什么事情发生时它一定会被改」,本项目把这件事分三档: 第 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/ 下对应那份。