From c5c1e68706a69526de8089d324b3b7076135c1ba Mon Sep 17 00:00:00 2001 From: iomgaa Date: Mon, 10 Aug 2026 03:15:46 -0400 Subject: [PATCH] =?UTF-8?q?docs(design):=20=E6=8C=89=E4=B8=A4=E8=BD=AE?= =?UTF-8?q?=E7=A1=95=E5=A3=AB=E7=94=9F=E5=86=B7=E8=AF=BB=E6=94=B9=200009?= =?UTF-8?q?=20=E4=B8=8E=200010?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0009 最实的一条是文档与代码对不上:文档说那条扫描测试断言 __dataclass_params__.kw_only, 而代码实际查的是构造签名。冷读的人正确地指出前者守的范围更窄——哨兵写法 KW_ONLY 达成了同样 的效果但那个标志是假(会误杀),而 kw_only=True 的类里用 field(kw_only=False) 开口子那个 标志仍然是真(会漏掉)。文档改成描述实际做法并写清楚这条理由。另修:「长六个字符」实际是 五个;补上两种写法的区别、Pydantic AI 那条「超过一个位置参数」与本文「一个都不许」的差别 来自处境不同、规则作用域只管数据类(NamedTuple 与 pydantic 绕得过,是已知边界)、 以及 §1.3「新增字段必带默认值」为什么不受影响。 0010 最实的一条是论据自相矛盾:决策二拿「跑通了的下游逐字一致」当段序依据,决策三又说那个 槽位在那边至今没实现、收到非空注入直接抛错。改成说准——它证明的是有人设计装配顺序时把槽位 放在了这里,不是这个位置在真实提示词里验证过。另补一节「读本文需要的几个名字」(注入/通道/ 解释器/合成观察/四者同源/pi 全是首次出现即使用,而且「Skill 条目」「工件」「Injection」三个 说法指同一样东西却没有一处对上);承认库实际还定了消息边界的粒度,超出了「只管顺序槽位规模」 那句话,并给出这条线画在哪的判据;说明「变化频率」指的是跨运行而不是一次运行内部;补上规模 量出来给谁用、模板为什么在构造请求时就校验、以及为什么不回填解释出来的动作。 --- .../design/0009-keyword-only-public-types.md | 61 ++++++++++++--- research-wiki/design/0010-context-assembly.md | 75 ++++++++++++++++--- 2 files changed, 116 insertions(+), 20 deletions(-) diff --git a/research-wiki/design/0009-keyword-only-public-types.md b/research-wiki/design/0009-keyword-only-public-types.md index ff3e874..08a50c9 100644 --- a/research-wiki/design/0009-keyword-only-public-types.md +++ b/research-wiki/design/0009-keyword-only-public-types.md @@ -53,6 +53,10 @@ Pydantic AI 在 2026 年 7 月加了一条 meta-test,扫描整个包、对** (不给已有的数据类补,因为那会打断按位置构造的调用方;白名单只减不增,清空它得发一个新 major。) +他们那条检查的门槛是「**超过一个**位置参数就失败」,比本文严一档还是松一档取决于怎么看: +他们要给存量留活口,所以放行只有一个位置参数的类;本库没有存量,直接要求一个都没有。这个 +差别来自处境不同,不是判据不同。 + 它已经付了本文想避开的那笔账:存量类型只能进白名单、只能等下一个 major 才清得掉。本库现在 一个存量调用方都没有,所以不需要白名单,也不需要弃用期。 @@ -63,22 +67,53 @@ major。) 关键字的(`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` -包括三个内部模块里的。一条规则不留判断余地,也才写得成机器检查。 +包括下划线开头的那几个内部模块里的。一条规则不留判断余地,也才写得成机器检查。 + +**规则的作用域是「数据类」,这是一条已知的边界。** 一个 `NamedTuple`、一个手写 `__init__` +的普通类、一个 pydantic 模型都能绕过它,而它们的字段顺序照样会变成承诺。本库现在只用数据类 +(pydantic 会引第三方类型进公共签名,`CLAUDE.md` §1 不许),所以这条边界现在不咬人;哪天要 +引入别的形态,这条规则和守它的那条测试都要跟着改。 + +**`../../CLAUDE.md` §1.3「新增字段必带默认值」不受影响,仍然照做。** 只收关键字确实解除了 +Python 那条「有默认值的字段后面不能跟无默认值字段」的语言限制,于是加一个必填字段在语法上 +成立了。但 §1.3 管的是另一件事:已经在跑的下游代码不该因为多了个字段就崩,而那要求新字段 +有默认值,和字段顺序无关。 **为什么不按类型大小分档。** 一个「字段多的用关键字、小壳保持位置可用」的规则,每加一个类型 都要判一次,而判错那次不会当场报错。`../../CLAUDE.md` 开头那条「能交给机器的就别靠自觉」在这里 直接适用:统一规则可以写成一条扫描测试,分档规则写不成。 **代价照实认下:适配器代码里那些两三个字段的小壳要多打字。** `TextBlock(text="hi")` 比 -`TextBlock("hi")` 长六个字符,而它在写适配器和测试替身时会出现很多次。这笔账认下来,换的是 +`TextBlock("hi")` 多五个字符,而它在写适配器和测试替身时会出现很多次。这笔账认下来,换的是 「字段顺序永远不是承诺」这一条对所有类型同时成立。 -**这不违反实验室惯例,因为那个惯例并不存在。** 七个仓库 223 个 `@dataclass` 里 `kw_only` 出现 -零次,但那是默认行为,不是任何一处做过的选择——没有一份文档或注释论证过位置构造。同一批仓库 -里另有 66 个 pydantic 模型,它们天生只收关键字,所以「关键字构造」本来就是这个实验室里更常见 -的那一种体验。 +**没有一条实验室惯例被违反,因为在这件事上没有惯例。** 扫过 `reference/` 下六个仓库加 +CHSAnalyzer 的活版本,223 个 `@dataclass` 里 `kw_only` 出现零次——但那是默认行为,没有一份 +文档或注释论证过位置构造。这不等于「没人做过选择」,只等于「没人写下过理由」,而没有理由 +可查的做法不构成需要遵守的先例。同一批仓库里另有 66 个 pydantic 模型,它们天生只收关键字, +所以「关键字构造」本来就是这个实验室里更常见的那一种体验。 **加 `kw_only` 本身是破坏性变更,所以只能现在做。** 一旦有下游按位置构造过任何一个公共类型, 再加就会让那行代码直接 `TypeError`。这也是本文不能推迟的理由:它的成本随时间从零跳到「发一个 @@ -86,10 +121,16 @@ major。) ## 机器保证 -`tests/unit/` 下一条扫描测试遍历 `polyloop` 包内所有数据类,断言每一个的 -`__dataclass_params__.kw_only` 为真。它带 fail-closed 守卫:先断言扫到的数据类数量不为零, -否则包被改名或搬走之后这条测试会扫到空列表然后安静地绿,而绿的含义从「全都合规」变成 -「什么都没检查」。 +`tests/unit/` 下一条扫描测试遍历 `polyloop` 包内所有数据类,**逐个查它的构造签名,断言每一个 +参数都是只收关键字的那一种**。 + +**查签名而不是查那个装饰器参数。** 要守的承诺是「按位置构造不了」,而 `kw_only=True` 只是 +达成它的一种写法:哨兵写法同样达成它、但那个装饰器标志是假,查标志会误杀一个完全合规的类; +反过来,`kw_only=True` 的类里用 `field(kw_only=False)` 给某个字段单独开一个口子,那个标志 +仍然是真,查标志漏得掉。签名是这两种偏差的共同下游,查它两边都不漏。 + +它带 fail-closed 守卫:先断言扫到的数据类数量不为零,否则包被改名或搬走之后这条测试会扫到 +空列表然后安静地绿,而绿的含义从「全都合规」变成「什么都没检查」。 **这条测试不违反「测试绑行为不绑实现」**(`../../CLAUDE.md` §1.8)。它断言的是一条对下游的 承诺——「你不能按位置构造,所以字段顺序不受任何保护」——而不是某个内部类有哪些方法。判据是 diff --git a/research-wiki/design/0010-context-assembly.md b/research-wiki/design/0010-context-assembly.md index 7859a62..2d06758 100644 --- a/research-wiki/design/0010-context-assembly.md +++ b/research-wiki/design/0010-context-assembly.md @@ -11,6 +11,32 @@ **触及** `../../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` 的排除条款留在项目侧 -——它是某个下游要扫的实验因子,库把它写死,那段文本就成了库定的实验刺激,而且逃出了项目自己 -的参数快照。**库决定的只有拼接顺序、槽位在哪、以及规模怎么量。** +——有下游要系统地改动这些文本并观测它带来的差异,库把它写死,那段文本就成了库定的变量,而且 +逃出了项目自己的参数快照。 + +**库决定的是拼接顺序、槽位在哪、消息边界的粒度、以及规模怎么量。** 「消息边界」这一项要单独 +点出来:决策三让每条注入各占一条消息、决策四让每个已完成的步占两条,这都是库在定形状。它和 +「片段里写什么」的分界线是**这个决定会不会随项目而变**——分隔符、标题、格式模板每家都不同, +而「一条注入是不是独立成一条消息」不改变任何一家看到的文字内容,只改变它落在哪条消息里。 +把边界也交给项目的话,库就没法保证空注入等同了(决策三),因为那时连「有没有多出一条消息」 +都由项目说了算。 ## 决策一:库不渲染工具段,`tool_section_template` 删掉 @@ -59,12 +92,21 @@ run 级片段 → 注入槽 → 目标级片段 → 逐步的「模型输出 / ``` **照变化频率从低到高排。** 模型供应商按前缀缓存计费,把逐次变化的东西排到前面会让缓存静默 -失效,而多付的幅度随注入内容的规模变化——于是缓存伪影会精确地伪装成下游想测的效应。run 级 -片段一次运行内不变,注入与目标级片段一次运行内也不变但随运行变,历史每一步都在长。 +失效,而多付的幅度随注入内容的规模变化——于是缓存伪影会精确地伪装成下游想测的效应。 -**注入槽排在两段之间,不是最前也不是最后。** 这和唯一一个跑通了的下游逐字一致:它的装配是 -`run_prefix → 工件 → item_suffix`。排到最前会把最稳定的那段挤到后面去,白丢缓存;排到最后会 -让注入内容跑到题面后面,而它通常是在给「怎么做这道题」的额外指引,位置在题面之后读起来是反的。 +**这里说的「变化」是跨运行的变化,不是一次运行内部的。** 四类片段里只有历史在一次运行内部 +增长,前三类在一次运行内都不变——所以排序的意义全在前缀缓存跨运行复用那一头:同一份 agent +定义跑几十次,run 级片段每次都一样,注入与目标级片段每次都不同。把不变的排在最前面,这几十 +次才共享得到同一段缓存前缀。 + +**注入槽排在两段之间,不是最前也不是最后。** 排到最前会把最稳定的那段挤到后面去、白丢缓存; +排到最后会让注入内容跑到题面后面,而它通常是在给「怎么做这道题」的额外指引,位置在题面之后 +读起来是反的。 + +**那个下游的装配代码里,槽位就在这个位置**(`run_prefix → 工件 → item_suffix`)。这条旁证要 +说准:它证明的是**有人在设计装配顺序时把槽位放在了这里**,不是「这个位置已经在真实提示词里 +验证过」——那个槽位的渲染至今没有实现,它的代码在收到非空注入时直接抛错(见决策三)。所以 +上面那两条理由才是这个位置的依据,这条旁证只说明我们和它想到一块去了。 **库不合成任何 system 消息。** 那个跑通了的下游全程没有 system 消息(它的模板只用 USER 与 ASSISTANT 两种角色,ReAct 那套惯例如此),另一个有。库自己加一条的话,前者的提示词凭空多出 @@ -98,14 +140,23 @@ ASSISTANT 两种角色,ReAct 那套惯例如此),另一个有。库自己 - `ASSISTANT`,内容是那一步的 `raw_output`。**这是解释器交回来的那段文本,不一定等于模型原文** ——解释器有权改写它,比如把第一个代码围栏之后的内容整段丢掉(模型常在代码块后面编造执行 结果,留着的话下一轮它会把那段幻想当成真发生过的事)。库照 `raw_output` 回填,不自己截断。 + + **回填的是这段文本,不是解释出来的那个动作。** 解释结果是库自己用的结构(工具名、参数、 + 或者一段代码),把它回填就等于让库替项目决定「模型在历史里看见的自己长什么样」——而那正是 + 渲染格式。项目要让模型看见规范化后的动作,自己在解释器里把 `history_text` 写成那样就行, + 这条路已经开着。 - `USER`,内容是 `observation_template.format(observation=step.observation)`。 **观察为什么要套模板。** 环境返回的是裸文本,模型需要一个边界才知道哪里是它自己说的、哪里是 环境说的。那个边界长什么样归项目——一个下游用的是 ``Output:\n```\n{observation}\n```\n\n``。 -**模板必须含 `{observation}` 占位符,不含就在装配时直接报错。** 不校验的话,一个写错的模板会让 -每一步的观察整个消失,而模型收到的是一段固定文本、看起来完全正常——它会以为每次执行都返回了 -同样的东西,然后开始瞎猜,而轨迹里那一列明明存着真的观察。 +**模板必须含 `{observation}` 占位符,不含就报错。** 不校验的话,一个写错的模板会让每一步的 +观察整个消失,而模型收到的是一段固定文本、看起来完全正常——它会以为每次执行都返回了同样的 +东西,然后开始瞎猜,而轨迹里那一列明明存着真的观察。 + +**校验在构造运行请求那一刻做,装配时再查一遍。** 前者是主闸:那时还没写运行开始记录、没调 +模型、没花钱,报错的代价只是一次构造失败。后者是因为装配函数是纯函数、可以被单独调用,它 +不该指望调用方已经查过。两处查的是同一件事,重复的代价是几微秒。 **用 `str.format` 而不是别的替换方式**,因为那个下游今天用的就是它,迁移时模板字符串一个字都 不用改。代价是模板里的其他花括号要转义,这一条写进那个字段的 docstring。 @@ -117,6 +168,10 @@ ASSISTANT 两种角色,ReAct 那套惯例如此),另一个有。库自己 提示词规模 = 所有消息、所有内容块的规模度量之和,单位是**字符**不是 token。 +**量出来给谁用**:停止判定的 B 档拿它和 `Budget.max_prompt_chars` 比,超了就以 +`context_overflow` 终止这次运行,不静默截断(`0004` 决策三 B)。上限由调用方按次设定,单位 +同样是字符。 + **为什么是字符。** `0003` 决策六定了消息内容是内容块序列而不是裸字符串,理由之一是库不需要 内容是字符串、只需要每个块能报出一个规模度量。文本块的度量是准确的字符数。用 token 要引一个 分词器,那是第三方依赖,而且不同模型的分词器不同——一个跨模型可比的上限用 token 量反而不可比。