4f8812fa82
第 ② 阶段需求对齐与第 ③ 阶段架构的产出,代码尚未开始。 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) <noreply@anthropic.com>
538 lines
35 KiB
Markdown
538 lines
35 KiB
Markdown
# 库的结构
|
||
|
||
> **更新触发点。** 常青文档必须写明「什么事情发生时它一定会被改」,本项目把这件事分三档:
|
||
> 第 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/` 下对应那份。
|