docs(design): 按两轮硕士生冷读改 0009 与 0010

0009 最实的一条是文档与代码对不上:文档说那条扫描测试断言 __dataclass_params__.kw_only,
而代码实际查的是构造签名。冷读的人正确地指出前者守的范围更窄——哨兵写法 KW_ONLY 达成了同样
的效果但那个标志是假(会误杀),而 kw_only=True 的类里用 field(kw_only=False) 开口子那个
标志仍然是真(会漏掉)。文档改成描述实际做法并写清楚这条理由。另修:「长六个字符」实际是
五个;补上两种写法的区别、Pydantic AI 那条「超过一个位置参数」与本文「一个都不许」的差别
来自处境不同、规则作用域只管数据类(NamedTuple 与 pydantic 绕得过,是已知边界)、
以及 §1.3「新增字段必带默认值」为什么不受影响。

0010 最实的一条是论据自相矛盾:决策二拿「跑通了的下游逐字一致」当段序依据,决策三又说那个
槽位在那边至今没实现、收到非空注入直接抛错。改成说准——它证明的是有人设计装配顺序时把槽位
放在了这里,不是这个位置在真实提示词里验证过。另补一节「读本文需要的几个名字」(注入/通道/
解释器/合成观察/四者同源/pi 全是首次出现即使用,而且「Skill 条目」「工件」「Injection」三个
说法指同一样东西却没有一处对上);承认库实际还定了消息边界的粒度,超出了「只管顺序槽位规模」
那句话,并给出这条线画在哪的判据;说明「变化频率」指的是跨运行而不是一次运行内部;补上规模
量出来给谁用、模板为什么在构造请求时就校验、以及为什么不回填解释出来的动作。
This commit is contained in:
2026-08-10 03:15:46 -04:00
parent 76495d9e39
commit c5c1e68706
2 changed files with 116 additions and 20 deletions
@@ -53,6 +53,10 @@ Pydantic AI 在 2026 年 7 月加了一条 meta-test,扫描整个包、对**
(不给已有的数据类补,因为那会打断按位置构造的调用方;白名单只减不增,清空它得发一个新 (不给已有的数据类补,因为那会打断按位置构造的调用方;白名单只减不增,清空它得发一个新
major。) major。)
他们那条检查的门槛是「**超过一个**位置参数就失败」,比本文严一档还是松一档取决于怎么看:
他们要给存量留活口,所以放行只有一个位置参数的类;本库没有存量,直接要求一个都没有。这个
差别来自处境不同,不是判据不同。
它已经付了本文想避开的那笔账:存量类型只能进白名单、只能等下一个 major 才清得掉。本库现在 它已经付了本文想避开的那笔账:存量类型只能进白名单、只能等下一个 major 才清得掉。本库现在
一个存量调用方都没有,所以不需要白名单,也不需要弃用期。 一个存量调用方都没有,所以不需要白名单,也不需要弃用期。
@@ -63,22 +67,53 @@ major。)
关键字的(`case ToolSpec(name=name)`)。本库现在没有一处按位置解构公共类型,接受这个影响; 关键字的(`case ToolSpec(name=name)`)。本库现在没有一处按位置解构公共类型,接受这个影响;
它和位置构造是同一件事的两面,留着其中一面就等于留着字段顺序这份承诺。 它和位置构造是同一件事的两面,留着其中一面就等于留着字段顺序这份承诺。
## 两种写法先说清楚
Python 有两种方式让数据类只收关键字参数,本文和引文里各出现了一种:
```python
@dataclass(kw_only=True) # 装饰器参数:整个类的字段一律只收关键字
class A:
x: int
@dataclass # 哨兵字段:它后面的字段只收关键字
class B:
x: int
_: KW_ONLY
y: int
```
**本库统一用装饰器参数那种**,因为它对整个类生效,不会出现「哨兵前面那几个还能按位置传」
这种半吊子状态。下面引用的 Pydantic AI 用的是哨兵写法,那是他们的选择,两者达成的效果在
「整个类都只收关键字」这一点上是一样的。
## 决策:`src/polyloop/` 下每一个数据类都加 `kw_only=True` ## 决策:`src/polyloop/` 下每一个数据类都加 `kw_only=True`
包括个内部模块里的。一条规则不留判断余地,也才写得成机器检查。 包括下划线开头的那几个内部模块里的。一条规则不留判断余地,也才写得成机器检查。
**规则的作用域是「数据类」,这是一条已知的边界。** 一个 `NamedTuple`、一个手写 `__init__`
的普通类、一个 pydantic 模型都能绕过它,而它们的字段顺序照样会变成承诺。本库现在只用数据类
(pydantic 会引第三方类型进公共签名,`CLAUDE.md` §1 不许),所以这条边界现在不咬人;哪天要
引入别的形态,这条规则和守它的那条测试都要跟着改。
**`../../CLAUDE.md` §1.3「新增字段必带默认值」不受影响,仍然照做。** 只收关键字确实解除了
Python 那条「有默认值的字段后面不能跟无默认值字段」的语言限制,于是加一个必填字段在语法上
成立了。但 §1.3 管的是另一件事:已经在跑的下游代码不该因为多了个字段就崩,而那要求新字段
有默认值,和字段顺序无关。
**为什么不按类型大小分档。** 一个「字段多的用关键字、小壳保持位置可用」的规则,每加一个类型 **为什么不按类型大小分档。** 一个「字段多的用关键字、小壳保持位置可用」的规则,每加一个类型
都要判一次,而判错那次不会当场报错。`../../CLAUDE.md` 开头那条「能交给机器的就别靠自觉」在这里 都要判一次,而判错那次不会当场报错。`../../CLAUDE.md` 开头那条「能交给机器的就别靠自觉」在这里
直接适用:统一规则可以写成一条扫描测试,分档规则写不成。 直接适用:统一规则可以写成一条扫描测试,分档规则写不成。
**代价照实认下:适配器代码里那些两三个字段的小壳要多打字。** `TextBlock(text="hi")` **代价照实认下:适配器代码里那些两三个字段的小壳要多打字。** `TextBlock(text="hi")`
`TextBlock("hi")` 长六个字符,而它在写适配器和测试替身时会出现很多次。这笔账认下来,换的是 `TextBlock("hi")` 多五个字符,而它在写适配器和测试替身时会出现很多次。这笔账认下来,换的是
「字段顺序永远不是承诺」这一条对所有类型同时成立。 「字段顺序永远不是承诺」这一条对所有类型同时成立。
**这不违反实验室惯例,因为那个惯例并不存在。** 七个仓库 223 个 `@dataclass``kw_only` 出现 **没有一条实验室惯例被违反,因为在这件事上没有惯例。** 扫过 `reference/` 下六个仓库加
零次,但那是默认行为,不是任何一处做过的选择——没有一份文档或注释论证过位置构造。同一批仓库 CHSAnalyzer 的活版本,223 个 `@dataclass``kw_only` 出现零次——但那是默认行为,没有一份
里另有 66 个 pydantic 模型,它们天生只收关键字,所以「关键字构造」本来就是这个实验室里更常见 文档或注释论证过位置构造。这不等于「没人做过选择」,只等于「没人写下过理由」,而没有理由
的那一种体验。 可查的做法不构成需要遵守的先例。同一批仓库里另有 66 个 pydantic 模型,它们天生只收关键字,
所以「关键字构造」本来就是这个实验室里更常见的那一种体验。
**加 `kw_only` 本身是破坏性变更,所以只能现在做。** 一旦有下游按位置构造过任何一个公共类型, **加 `kw_only` 本身是破坏性变更,所以只能现在做。** 一旦有下游按位置构造过任何一个公共类型,
再加就会让那行代码直接 `TypeError`。这也是本文不能推迟的理由:它的成本随时间从零跳到「发一个 再加就会让那行代码直接 `TypeError`。这也是本文不能推迟的理由:它的成本随时间从零跳到「发一个
@@ -86,10 +121,16 @@ major。)
## 机器保证 ## 机器保证
`tests/unit/` 下一条扫描测试遍历 `polyloop` 包内所有数据类,断言每一个 `tests/unit/` 下一条扫描测试遍历 `polyloop` 包内所有数据类,**逐个查它的构造签名,断言每一个
`__dataclass_params__.kw_only` 为真。它带 fail-closed 守卫:先断言扫到的数据类数量不为零, 参数都是只收关键字的那一种**。
否则包被改名或搬走之后这条测试会扫到空列表然后安静地绿,而绿的含义从「全都合规」变成
「什么都没检查」。 **查签名而不是查那个装饰器参数。** 要守的承诺是「按位置构造不了」,而 `kw_only=True` 只是
达成它的一种写法:哨兵写法同样达成它、但那个装饰器标志是假,查标志会误杀一个完全合规的类;
反过来,`kw_only=True` 的类里用 `field(kw_only=False)` 给某个字段单独开一个口子,那个标志
仍然是真,查标志漏得掉。签名是这两种偏差的共同下游,查它两边都不漏。
它带 fail-closed 守卫:先断言扫到的数据类数量不为零,否则包被改名或搬走之后这条测试会扫到
空列表然后安静地绿,而绿的含义从「全都合规」变成「什么都没检查」。
**这条测试不违反「测试绑行为不绑实现」**`../../CLAUDE.md` §1.8)。它断言的是一条对下游的 **这条测试不违反「测试绑行为不绑实现」**`../../CLAUDE.md` §1.8)。它断言的是一条对下游的
承诺——「你不能按位置构造,所以字段顺序不受任何保护」——而不是某个内部类有哪些方法。判据是 承诺——「你不能按位置构造,所以字段顺序不受任何保护」——而不是某个内部类有哪些方法。判据是
+65 -10
View File
@@ -11,6 +11,32 @@
**触及** `../../src/polyloop/_assembly/` **触及** `../../src/polyloop/_assembly/`
## 读本文需要的几个名字
它们都定在别处,这里各给一句,免得读到一半得去翻另外几份文档。
**注入 / `Injection`** 是一条要贴进上下文的额外内容,本库只负责贴和记录贴了什么,不负责
生成、评测、挑选。它有两个字段:`entry_id`(进轨迹的标识)与 `content`(贴进去的正文)。
`0003``0006` 里管这类东西叫「Skill 条目」,某个下游的代码里叫「工件」,**三个说法指的
是同一样东西**——本文一律用「注入」。
**通道** 是注入的分组键。`RunRequest.injections` 是一个「通道名 → 若干条注入」的映射;库不
解释通道名,只按键分组贴。现在只有一个通道在用,做成映射是因为将来多一个通道是加一个键、
不是改类型。
**决策解释接缝**(下文简称解释器)把一次模型回复解释成三分支之一:动作、最终回答、无效决策。
它同时交回一段 `history_text`——**这一步回填进对话历史的那段文本,可以与模型原文不同**。
**合成观察** 是库自己写、回填给模型看的几段固定文本,挂在 `AgentDefinition` 上。动作被拒绝、
环境故障、模型调用失败三档用它,因为那三档没有真的环境输出可回填(`0007` 决策二)。
**四者同源**`scope.md` 定死的一条要求:工具的注册、模型可见 schema 生成、存在性与参数
校验、分发,四件事必须由同一个**工具注册表**实例驱动,否则会漂移成「模型看见一个已经删掉的
工具」。
**pi**`reference/` 下六个参考仓库之一,一个 TypeScript 写的 agent 框架。按 `CLAUDE.md` §0
它是参考资料不是权威,本文引它只作旁证。
## 装配是什么 ## 装配是什么
每一步调模型之前,库要把一堆片段拼成一个消息序列交给模型调用接缝。片段有四类:这次运行从头 每一步调模型之前,库要把一堆片段拼成一个消息序列交给模型调用接缝。片段有四类:这次运行从头
@@ -18,8 +44,15 @@
以及前面每一步的「模型说了什么 + 环境返回了什么」。 以及前面每一步的「模型说了什么 + 环境返回了什么」。
**库不决定这些片段里写什么**,那是渲染格式,按 `../explanation/scope.md` 的排除条款留在项目侧 **库不决定这些片段里写什么**,那是渲染格式,按 `../explanation/scope.md` 的排除条款留在项目侧
——它是某个下游要扫的实验因子,库把它写死,那段文本就成了库定的实验刺激,而且逃出了项目自己 ——有下游要系统地改动这些文本并观测它带来的差异,库把它写死,那段文本就成了库定的变量,而且
的参数快照。**库决定的只有拼接顺序、槽位在哪、以及规模怎么量。** 逃出了项目自己的参数快照。
**库决定的是拼接顺序、槽位在哪、消息边界的粒度、以及规模怎么量。** 「消息边界」这一项要单独
点出来:决策三让每条注入各占一条消息、决策四让每个已完成的步占两条,这都是库在定形状。它和
「片段里写什么」的分界线是**这个决定会不会随项目而变**——分隔符、标题、格式模板每家都不同,
而「一条注入是不是独立成一条消息」不改变任何一家看到的文字内容,只改变它落在哪条消息里。
把边界也交给项目的话,库就没法保证空注入等同了(决策三),因为那时连「有没有多出一条消息」
都由项目说了算。
## 决策一:库不渲染工具段,`tool_section_template` 删掉 ## 决策一:库不渲染工具段,`tool_section_template` 删掉
@@ -59,12 +92,21 @@ run 级片段 → 注入槽 → 目标级片段 → 逐步的「模型输出 /
``` ```
**照变化频率从低到高排。** 模型供应商按前缀缓存计费,把逐次变化的东西排到前面会让缓存静默 **照变化频率从低到高排。** 模型供应商按前缀缓存计费,把逐次变化的东西排到前面会让缓存静默
失效,而多付的幅度随注入内容的规模变化——于是缓存伪影会精确地伪装成下游想测的效应。run 级 失效,而多付的幅度随注入内容的规模变化——于是缓存伪影会精确地伪装成下游想测的效应。
片段一次运行内不变,注入与目标级片段一次运行内也不变但随运行变,历史每一步都在长。
**注入槽排在两段之间,不是最前也不是最后。** 这和唯一一个跑通了的下游逐字一致:它的装配是 **这里说的「变化」是跨运行的变化,不是一次运行内部的。** 四类片段里只有历史在一次运行内部
`run_prefix → 工件 → item_suffix`。排到最前会把最稳定的那段挤到后面去,白丢缓存;排到最后会 增长,前三类在一次运行内都不变——所以排序的意义全在前缀缓存跨运行复用那一头:同一份 agent
让注入内容跑到题面后面,而它通常是在给「怎么做这道题」的额外指引,位置在题面之后读起来是反的。 定义跑几十次,run 级片段每次都一样,注入与目标级片段每次都不同。把不变的排在最前面,这几十
次才共享得到同一段缓存前缀。
**注入槽排在两段之间,不是最前也不是最后。** 排到最前会把最稳定的那段挤到后面去、白丢缓存;
排到最后会让注入内容跑到题面后面,而它通常是在给「怎么做这道题」的额外指引,位置在题面之后
读起来是反的。
**那个下游的装配代码里,槽位就在这个位置**`run_prefix → 工件 → item_suffix`)。这条旁证要
说准:它证明的是**有人在设计装配顺序时把槽位放在了这里**,不是「这个位置已经在真实提示词里
验证过」——那个槽位的渲染至今没有实现,它的代码在收到非空注入时直接抛错(见决策三)。所以
上面那两条理由才是这个位置的依据,这条旁证只说明我们和它想到一块去了。
**库不合成任何 system 消息。** 那个跑通了的下游全程没有 system 消息(它的模板只用 USER 与 **库不合成任何 system 消息。** 那个跑通了的下游全程没有 system 消息(它的模板只用 USER 与
ASSISTANT 两种角色,ReAct 那套惯例如此),另一个有。库自己加一条的话,前者的提示词凭空多出 ASSISTANT 两种角色,ReAct 那套惯例如此),另一个有。库自己加一条的话,前者的提示词凭空多出
@@ -98,14 +140,23 @@ ASSISTANT 两种角色,ReAct 那套惯例如此),另一个有。库自己
- `ASSISTANT`,内容是那一步的 `raw_output`。**这是解释器交回来的那段文本,不一定等于模型原文** - `ASSISTANT`,内容是那一步的 `raw_output`。**这是解释器交回来的那段文本,不一定等于模型原文**
——解释器有权改写它,比如把第一个代码围栏之后的内容整段丢掉(模型常在代码块后面编造执行 ——解释器有权改写它,比如把第一个代码围栏之后的内容整段丢掉(模型常在代码块后面编造执行
结果,留着的话下一轮它会把那段幻想当成真发生过的事)。库照 `raw_output` 回填,不自己截断。 结果,留着的话下一轮它会把那段幻想当成真发生过的事)。库照 `raw_output` 回填,不自己截断。
**回填的是这段文本,不是解释出来的那个动作。** 解释结果是库自己用的结构(工具名、参数、
或者一段代码),把它回填就等于让库替项目决定「模型在历史里看见的自己长什么样」——而那正是
渲染格式。项目要让模型看见规范化后的动作,自己在解释器里把 `history_text` 写成那样就行,
这条路已经开着。
- `USER`,内容是 `observation_template.format(observation=step.observation)` - `USER`,内容是 `observation_template.format(observation=step.observation)`
**观察为什么要套模板。** 环境返回的是裸文本,模型需要一个边界才知道哪里是它自己说的、哪里是 **观察为什么要套模板。** 环境返回的是裸文本,模型需要一个边界才知道哪里是它自己说的、哪里是
环境说的。那个边界长什么样归项目——一个下游用的是 ``Output:\n```\n{observation}\n```\n\n``。 环境说的。那个边界长什么样归项目——一个下游用的是 ``Output:\n```\n{observation}\n```\n\n``。
**模板必须含 `{observation}` 占位符,不含就在装配时直接报错。** 不校验的话,一个写错的模板会让 **模板必须含 `{observation}` 占位符,不含就报错。** 不校验的话,一个写错的模板会让每一步的
每一步的观察整个消失,而模型收到的是一段固定文本、看起来完全正常——它会以为每次执行都返回了 观察整个消失,而模型收到的是一段固定文本、看起来完全正常——它会以为每次执行都返回了同样的
同样的东西,然后开始瞎猜,而轨迹里那一列明明存着真的观察。 东西,然后开始瞎猜,而轨迹里那一列明明存着真的观察。
**校验在构造运行请求那一刻做,装配时再查一遍。** 前者是主闸:那时还没写运行开始记录、没调
模型、没花钱,报错的代价只是一次构造失败。后者是因为装配函数是纯函数、可以被单独调用,它
不该指望调用方已经查过。两处查的是同一件事,重复的代价是几微秒。
**用 `str.format` 而不是别的替换方式**,因为那个下游今天用的就是它,迁移时模板字符串一个字都 **用 `str.format` 而不是别的替换方式**,因为那个下游今天用的就是它,迁移时模板字符串一个字都
不用改。代价是模板里的其他花括号要转义,这一条写进那个字段的 docstring。 不用改。代价是模板里的其他花括号要转义,这一条写进那个字段的 docstring。
@@ -117,6 +168,10 @@ ASSISTANT 两种角色,ReAct 那套惯例如此),另一个有。库自己
提示词规模 = 所有消息、所有内容块的规模度量之和,单位是**字符**不是 token。 提示词规模 = 所有消息、所有内容块的规模度量之和,单位是**字符**不是 token。
**量出来给谁用**:停止判定的 B 档拿它和 `Budget.max_prompt_chars` 比,超了就以
`context_overflow` 终止这次运行,不静默截断(`0004` 决策三 B)。上限由调用方按次设定,单位
同样是字符。
**为什么是字符。** `0003` 决策六定了消息内容是内容块序列而不是裸字符串,理由之一是库不需要 **为什么是字符。** `0003` 决策六定了消息内容是内容块序列而不是裸字符串,理由之一是库不需要
内容是字符串、只需要每个块能报出一个规模度量。文本块的度量是准确的字符数。用 token 要引一个 内容是字符串、只需要每个块能报出一个规模度量。文本块的度量是准确的字符数。用 token 要引一个
分词器,那是第三方依赖,而且不同模型的分词器不同——一个跨模型可比的上限用 token 量反而不可比。 分词器,那是第三方依赖,而且不同模型的分词器不同——一个跨模型可比的上限用 token 量反而不可比。