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:
@@ -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)。它断言的是一条对下游的
|
||||||
承诺——「你不能按位置构造,所以字段顺序不受任何保护」——而不是某个内部类有哪些方法。判据是
|
承诺——「你不能按位置构造,所以字段顺序不受任何保护」——而不是某个内部类有哪些方法。判据是
|
||||||
|
|||||||
@@ -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 量反而不可比。
|
||||||
|
|||||||
Reference in New Issue
Block a user