第二轮 Codex 对抗审查报的五条全部核实成立,0003/0004 已冻结,修订新写一份。 五条都不改任何决策的结论,改的是结论到形状之间那一段的落实。 决策一:步记录加一个布尔的完成信号列。0004 决策二承诺靠它区分两条完成通路, 而决策四的字段表里没有这个字段。它自己就够区分,不必查注册表—— 事后分析手上常常只有轨迹文件。 决策二:存储接缝六个方法重切一刀,把「按预分配 ID 写一条结果」拆成 「写模型调用结果」与「写动作结果与步记录」。原子性从散文变成签名里不可表达其他形态。 决策三:观察那一列的口径改成「回填进历史的那段文本」,不加字段。 dissect 的 render_messages 把 step.observation 原样套模板发出, raw_output 也是解析器截断后的版本——两侧同构,都是一段文本加一个数字。 决策四:作废 0004 词表里「一个可能取不到的完成信号」,那是上一轮 fatal bug 的原话。 决策五:存储接缝加前缀持久性。少了它,一次明明成功的模型调用会被恢复 记成「状态未知」,而它成功的证据就在同一份日志里。 结论已回写 architecture.md 第九节与决策索引;migrations/dissect.md 需求七 补上「分开记的是文本加数字,不是两段文本」,堵掉这次审查里出现过的误读。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
37 KiB
库的结构
更新触发点。 常青文档必须写明「什么事情发生时它一定会被改」,本项目把这件事分三档: 第 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 文件, 一种写关系数据库。
写入粒度是契约的一部分:两条意图记录各自单独落地;动作结果与步记录一次原子落地,要么 都可见、要么都不可见。不原子的话,崩在两者之间会让那一步的历史文本永远丢失,而恢复判定会 把它读成「执行完了,跳过」。这条不靠散文约束,靠方法的切法——两者由同一个方法收下, 「只写了一半」在签名上无从表达。那个方法的动作结果一项可以为空(模型调用失败的步、解析 失败的步照样有步记录),但必填、无默认值。
存储实现必须保证前缀持久性:第 k 次写入被确认已经持久时,第 1 到 k-1 次写入也已经持久。
少了它,模型调用结果还在缓冲区而后面那条动作意图(屏障)已经落盘,恢复会把一次明明成功了
的模型调用记成「状态未知」——而它成功的证据就在同一份日志里,那条动作意图正是从它的返回值
解释出来的。两个已知形态天然满足:同一个文件的追加写,fsync 一次刷掉之前全部;同一个连接
上顺序提交的事务,先提交的先持久。代价是排除了「不同记录类型写进彼此无序的多个后端」这种
形态,现在没有消费者要它。
事件出口(挂定义)。投递失败由库捕获、记日志、把失败计数加一,然后继续跑。失败不再 转成一条事件从同一个出口发出去——那会自我喂食,一个持续失败的出口会让失败处理路径变成 递归。两个形态:一种把进度回写业务数据库,一种把审计事件送进日志管道。
三个刻意不抽象的地方
完成判定。 信号源全在库内,或者已经在动作执行接缝的返回值上。再设一个入口等于同一件 事有两个来源,而两个来源迟早分叉。
观察投影。 需要搬运的两个量——模型原文与进历史文本、环境观察与是否合成——已经分别由 决策解释与动作执行的返回结构体携带。再开一条能改观察的路径必然分叉。
上下文装配。 真正会变的是渲染格式,而渲染格式留在项目侧:它是下游的实验因子,库在 内部按某个条件拼自己的文本,那段文本就成了库定的实验刺激,并且逃出项目的参数快照。 归库的只有段顺序与注入槽的位置,那是纯函数不是扩展点。
十、装配形态
本库不部署,也不跑模型推理,没有常驻进程。它被 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,字段表经
../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 |
边界的当前裁决清单(哪些在界内、哪些在界外)在 scope.md,那份是常青的,会随新消费者
接入而更新。每个下游要迁什么、迁完算不算数在 ../migrations/ 下对应那份。