Files
PolyLoop/research-wiki/design/0015-parameter-snapshot-contract.md
T
iomgaa e701563a0b docs(design): 落定第一个下游提的五个缺口的三份方案
0014 契约套件怎么发给下游、0015 参数快照的内容契约、0016 动作执行接缝抛异常时的契约。
三份都过了 CLAUDE.md §3 的硕士生冷读,冷读抓到的八处「在讲文档自己」的句子、五处缺前置
知识、三处只写结论没写理由、三处参数两地取值不同,全部采纳。

0016 否掉了提 issue 那一方倾向的方案(库捕获执行器异常转 ENV_ERROR)。三层理由:日志
「不自洽」这个前提本身不成立——异常抛出时副作用状态未知,日志停在「动作意图有、结果无」
正是照实记录;库替它写一条步记录反而是在编造,而那条记录会让恢复把未知状态抹掉;最后
它会把执行器里一个 AttributeError 变成一批环境故障,run() 照常返回正常结果,调用方拿不到
任何异常。
2026-08-27 03:58:08 -04:00

271 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design 0015 · 参数快照的内容契约
**日期** 2026-08-26 · **状态** 已接受(2026-08-26 项目负责人确认)
**回答** 实验室 Gitea 上 PolyLoop 仓库的两个 issue,提出者都是下游项目 dissect2:#3 标题是
「提示词模板的哈希在参数快照里没有位置」,#4 标题是「注入的『通道』维度在参数快照里被拍平」。
两份指的是同一类缺口——一件影响这次运行的事实没有进参数快照。
**补充** `0003-public-api-shape.md` 决策三里请求那张字段表,往上加一个字段;以及
`0006-public-names-and-signatures.md` 定的公共名字与签名,本文给新增的快照键定形状。这两份的
其余部分不受影响。
**触及** `../../src/polyloop/session/__init__.py``RunRequest` 的字段与两处
`parameter_snapshot``../../src/polyloop/_assembly/__init__.py``injected_entry_ids` 的返回
类型、`../../src/polyloop/types/__init__.py``Injection.entry_id` 的注释,以及
`../explanation/architecture.md` 第十节「装配形态」里讲请求持有什么的那一段。**不回写这几处,
本文就是死的**——写代码的人读的是代码和常青文档,不会为了传一个字段跑来翻 `design/`
## 背景
一次运行的配置分成两半,各是一个不可变对象,两个合起来叫这次运行的**装配**。跨运行不变、
可以并发复用的那一半是 `AgentDefinition`:模型调用、决策解释、存储、事件出口四个接缝,以及
库在动作被拒绝或环境故障时合成的那几段观察。每次运行都不同的那一半是 `RunRequest`:运行
标识、预算、动作执行接缝、本次可见的工具集、上下文、注入内容、模型绑定等等。参数快照就是从
这两个装配对象上现算出来的一份「这次跑的是什么设置」。
参数快照是续跑守卫的全部依据。`resume` 把当前装配现算的快照和日志里存着的那份逐字段比对,
任何一项对不上就抛 `ParameterDriftError`,拒绝往下跑。它拦的是一种没有失败现场的事故:崩溃
之后用同一个运行标识、换一份配置续跑,前几步和后几步来自两套配置,而全程零报错,两段轨迹在
文件里看起来是同一次运行。
守卫的强度完全由快照的内容决定。**一件影响这次运行的事实没有进快照,等于它换了也不会有人
知道**——比对的时候那一项根本不在场。
现在快照里有这些:预算的四项、模型调用的重放策略、观察模板、取消宽限期、本次可见的工具名
清单、模型绑定的全部键值、这次贴进上下文的那些注入条目的标识(键 `request.injected_entry_ids`),
以及五个接缝各自上报的参数(四个挂在定义上,动作执行接缝挂在请求上)。issue #3#4 各指出
一处漏在外面的事实,两份都来自同一个下游、同一档实验需求。
## 决策一:请求上开一个 `fingerprints` 字段,收「这次运行用的是哪一版配方」
`RunRequest` 新增字段 `fingerprints: Mapping[str, str]`,默认空映射。它的每一个键值都进快照,
键形如 `request.fingerprint.<name>`——单数,与已有的 `request.binding.<key>` 对齐。
**默认空映射时快照里一个键都不写**,不是写一个值为空串的键。这样今天已经在跑的配置算出来的
快照逐字节不变,只有真的传了指纹的运行才多出那几项。
### 为什么需要它
上下文的正文与注入条目的正文是刻意不进快照的,理由是它们属于这次运行的输入**数据**而不是
参数:进快照会让快照变成一份数据副本,而它们可能很大。
**注入这一侧不进快照的只有正文,条目的标识是进的。** 正文和标识是两样东西:正文是贴给模型
看的那段文本,标识是这条注入的名字。所以「这次贴了哪几条」事后查得到,查不到的只是那几条各自
写了什么。上下文那一侧没有对应的标识可以留,它是一段已经渲染好的消息序列,本身不带名字。
把正文挡在外面这条理由没错,但它顺手把**生成这些数据的东西**也挡在了外面。提示词模板不是
数据,是参数——它是一份跨运行复用的配方,每次运行拿它渲染出这一次的上下文。
提 issue 的下游研究的正是「改这份文本会让 agent 表现好多少」,模板是被系统地改动的东西之一。
不记的话,「这次用的是哪一版提示词」就只剩下 harness 的 git 提交这一个粒度,而同一个提交下
完全可以试好几份不同的模板。具体的失败场景是:换一份模板、用同一个运行标识续跑,前几步用
A、后几步用 B,全程零报错,那次运行的数据已经废了却没有任何东西提示。
### 为什么放请求不放定义
请求级能表达定义级能表达的一切,反过来不行。一样东西每次运行都一样,把它放在请求上、每次传
同样的值就是了,快照比对照样成立;而一样东西每次运行都不同,放在定义上就没有办法表达——定义
是跨运行并发复用的,它上面的值不能随运行变。
库无从知道下游会往 `fingerprints` 里放什么。已知的那个需求(提示词模板的 sha)确实跨运行不
变,但同一个字段将来会收「这次注入的技能库是哪一版」这类每次都变的东西。既然要选一个位置,
就选能覆盖两种情况的那个。
辅一条:缺口的位置本来就在请求这一侧。定义上那四个接缝各有一个 `parameters()` 方法,能自报
自己的指纹;请求这边只有动作执行接缝有。剩下没有人能替它们说话的是上下文和注入内容——它们是
纯数据,没有一个对象可以发问,而模板正是生成上下文的东西。
那定义与请求那个「跨运行变不变」的切点怎么办?它是切分两个装配对象的依据,但它不是一条能
逐字段套用的规则,`Context.run_level` 就是现成的反例:那一段装的是角色说明、示例演示、能力
描述,在一批运行里通常一字不变,它却住在请求上——因为它和逐题变化的 `goal_level` 是同一份
渲染的产物,拆到两个装配对象上会逼调用方在两个地方保持一致。模板的指纹和它渲染出来的上下文
是同一件事的两面,同理。
### 它和 `model_binding` 的区别
两个字段形状相同:都是下游自由定义键名的字符串映射,都进快照,库都不解释内容。含义不同。
| 字段 | 记的是什么 | 库怎么用 |
|---|---|---|
| `model_binding` | 这次运行属于哪一格:哪个账本、第几轮、哪道题、第几次尝试 | 进快照,并原样透传给每次模型调用 |
| `fingerprints` | 这次运行用的材料是哪一版 | 只进快照 |
坐标和配方版本混在一个字段里,事后分不开:一组键值里既有「第 3 轮」又有一个 sha,要靠键名的
命名约定去猜哪个是哪个,而命名约定不在任何一处被断言。提 issue 的下游已经在考虑把模板的 sha
塞进 `model_binding`——那样功能上是通的,透传出去的绑定里多一个键,网关也不在乎;但语义不
对,而且下一个下游看见了会跟着学,几个月后这个字段里什么都有。
### 值的形状库不解释,但 docstring 里给一条建议
建议值里带上算法前缀,形如 `sha256:<hex>`。理由是换算法的那天,不带前缀的旧记录和新记录会以
「两个不同的十六进制串」的形式参与比对,报出来的漂移看不出是换了算法还是内容真的变了;带前
缀则一眼看得出。
这是建议,不是校验。库不解释这个值,也就没有立场规定它长什么样——真去校验,等于替下游定了它
能用哪几种哈希。
### 业界怎么用 fingerprint 这个词
最贴的先例是实验记录框架 Sacred。它把「这次用到的源文件及其 md5」放在 `sources` 里,与参数
`config` 平级分开,而不是塞进同一个字典——和这里让 `fingerprints``model_binding` 各占一个
字段是同一个形状,理由也一样:材料版本和参数坐标混在一处,事后分不开。带算法前缀那条跟的是
OCI 镜像摘要的写法。
## 决策二:注入的通道维度在快照里保留,一个通道一个键
请求上的注入内容是一个映射:键是**通道名**,值是这个通道里的一串**条目**,每个条目由一个标识
和一段正文组成。通道名由调用方自己定,库里没有任何预定义的通道,也不校验通道名的形状;一个
通道里放几条同样由调用方决定。通道存在的意义是把来源不同的注入分开——同一次运行里,来自两个
不同挑选过程的材料各占一个通道。
**库只负责贴和记录贴了什么,不负责生成、评测、挑选。** 装配时 `_assembly.injection_messages()`
把这些条目摊平成一串消息贴进提示词,一个条目一条消息,正文原样,前后不加任何标题或分隔符;
`_assembly.injected_entry_ids()` 把同一批条目的标识收成快照要写的那份记录。挑哪几条进来是调用
方在调 `run()` 之前就做完的事。所以「声明了这个通道但一条都没选中」这种情形,从库这一侧看到的
只是一个条目为空的通道,那次筛选的过程库全程不在场。
注入按通道分组进快照,一个通道一项:键是 `request.injected_entry_ids.<通道名>`,值是那个通道
里的条目标识按 `injection_messages` 的同一顺序拼成的逗号串。原来那个把所有通道拍平成一个键的
写法作废。
`_assembly.injected_entry_ids()` 的返回类型跟着改:从扁平元组改成按通道名字典序排好的映射。
### 两个排序的理由不一样
**通道内的条目跟着 `injection_messages` 排,因为这个顺序有意义。** 它决定这几条注入贴进提示词
的先后,也就决定了模型看到的是什么。顺序变了就是配置变了,续跑该报漂移。两个函数同序还有一条
更硬的理由:不同序的话,快照记的贴入顺序和模型真正看到的顺序是两回事,而续跑守卫照样全绿。
**通道之间按通道名字典序,纯粹是为了确定性,这个顺序本身不承载任何含义。** 调用方传进来的是一
个映射,它的迭代顺序取决于调用方怎么构造它——用推导式从一个集合建出来的话,Python 的字符串哈希
每进程随机,于是同一份配置在不同进程里摊平出的消息顺序不同,渲染出来的提示词也就不同。字典序
把这个不确定性去掉。
### 拍平之后丢掉的两样东西
通道名在拍平的写法里只用来定顺序,排完就没了,于是「哪几条来自哪个通道」事后查不到。
更要紧的是另一样:「声明了这个通道但一条都没选中」和「压根没有这个通道」在拍平之后是同一个
结果——两种情况下这个通道对快照的贡献都是零,它在记录里彻底不出现。提 issue 的下游有一档实验
要测「注入的内容到底起没起作用」,它要比较的正是这两种情形:一组运行声明了通道而选中零条,
另一组连通道都不声明。两者在记录里长得一样,那一档就测不了。
按通道成键之后这两种情形分得开:声明了通道但为空,是一个值为空串的键;压根没有这个通道,是
这个键不存在。
### 为什么不在 `RunResult` 上另透一份
issue 里提到的另一个做法是把带通道的结构挂到 `RunResult` 上。否掉,两条理由。`RunResult` 是会
被下游存进数据库和实验数据集的持久化结构,往它上面加字段要同时抬 schema 版本,代价高一档。
而且同一个事实放两处,迟早有一处被改而另一处没改,到那时两处不一致,谁对没有答案。快照已经
承载了这个事实,让它承载全。
### 顺带改正三处把快照说成轨迹的 docstring
代码里有三处说条目标识「进轨迹」:`types/__init__.py``Injection.entry_id` 的字段注释、
`session/__init__.py``RunRequest.parameter_snapshot` 的 docstring、`_assembly/__init__.py`
`injected_entry_ids` 的 docstring。这个说法是错的,本次一并改正。
轨迹是步记录的序列,一步一条,记的是这一步模型说了什么、动作是什么、观察是什么。条目标识不
在里面。它进的是运行开始记录里的参数快照,一次运行只写一条,写在开工之前。照现在的 docstring
去找,下游会在步记录里翻一个不存在的列,翻不到之后多半会得出「库没记这件事」的结论,而它
明明记了。
改正和决策二本来就要做的事发生在同一处:`Injection.entry_id` 那段注释还要补上下面那条分隔符
约束。
### 分隔符是一个已知的、不打算修的限制
快照的值是字符串,把一串条目标识压进一个值里就得选一个分隔符。条目标识里如果真含逗号,两组
不同的注入可能拼出同一个串,于是一次本该报出来的漂移没有报。通道名同理。
不修的理由是:快照的值只用于逐字段比对,从来不被解析回列表;而人要肉眼看快照排查漂移,换成
JSON 编码会让它读不动——一份几十行的快照里混着转义引号和方括号,「哪一项变了」这个问题的答案
就得靠工具才看得出来。已有的 `request.tools` 是同样的形状、同样的限制,这里不为新键单独定
一套。
代价是给下游留一句约束:标识里不要放逗号。这句话写进 `Injection.entry_id` 的注释,因为那是
写代码的人会读到的地方。
## 决策三:快照的取值必须是字符串,在开跑之前就守住
两个校验点。`RunRequest.__post_init__` 校验 `fingerprints``model_binding` 的每一个键和每
一个值都是 `str`。两处 `parameter_snapshot()`——`AgentDefinition` 那个和 `RunRequest` 那个——在
聚合接缝上报的参数时,校验接缝返回的键值都是 `str`。不合格用显式异常拒绝。
**用异常不用 `assert`**`python -O` 会把断言整条移除,下游拿 `-O` 跑的那天这道校验就静默消失
了,而它守的正是一件静默出错的事。
### 只有第一个校验点在构造期
`RunRequest.__post_init__` 那个是真正的构造期:此时什么都还没发生,没有 I/O、没有日志、没有
模型调用,拒绝的代价是零。
聚合那个不在构造期。`AgentDefinition.parameter_snapshot()` 是方法不是字段,构造定义时不向任何
接缝发问,发问发生在首次算快照的时候,而那时 `run()` 已经读过一次存储日志了。它保证的不是零
代价,是**校验发生在写运行开始记录之前,也就是在任何一次模型调用之前**:不会跑完一整次运行、
把钱花光,才在续跑时发现快照里有一项存不下去。
两个点的强度不同:**一个构造得出来的定义对象并不保证算得出合法快照**——构造它的时候那四个
接缝一次都没被问过。某个接缝的 `parameters()` 返回一个整数,定义照样构造成功,要到首次算
快照时才被拒绝。
### 为什么要提前到这里
快照的取值类型已经是持久化契约的一部分:反序列化那一侧读到非字符串会直接失败,
`tests/unit/test_serialization.py` 有一条测试钉着它。但那个失败发生在**续跑读日志的时候**——
这次运行已经完整跑过一遍,钱花完了,日志也已经写下去了,才发现里面有一项读不回来。而且发现
它的前提是真的有人来续跑;没人续跑,那份存坏了的日志就一直躺着,直到有人拿它做统计。
### 为什么连接缝上报的参数一起校验
接缝实现由下游写,它返回什么算外部输入(`../../CLAUDE.md` §6:适配器返回算外部输入)。五个
接缝里任何一个的 `parameters()` 返回一个整数,症状都一样:这次运行照常跑完,续跑时才炸。
校验放在聚合的那一处,而不是分散到五个实现里,是为了让「快照的取值都是字符串」成为一条真的
被守住的不变量。写在五个实现里的话,它只是五份各自的自觉,而下游写的适配器根本不在我们的
自觉范围内。
### `model_binding` 一起改是有意的
它和 `fingerprints` 语义同族、形状相同,只给新字段加校验会让两个看起来一样的字段行为不一样。
那种不一致比两个都不校验更难查——查的人会先怀疑自己传错了字段,而不是怀疑库对两个同形状的
字段处置不同。
## 代价
**快照的键形状变了,跨版本续跑会报漂移。** 用旧版本跑到一半的运行,升级本库之后再 `resume`
会因为 `request.injected_entry_ids` 这个键消失、`request.injected_entry_ids.<通道名>` 那几个键
出现而抛 `ParameterDriftError`。这个失败是响亮的,不是静默的:错误信息会把漂移的键逐个列
出来。
**这不构成 `../../CLAUDE.md` §1.3 意义上的破坏性变更。** 快照的键集合从来不是公共承诺。下游
换一个存储实现、改一个接缝的 `parameters()` 返回什么,快照就变、续跑就报漂移——这本来就是
这套设计的一部分,也是它该有的行为。库自己改快照的键属于同一类事件,处置也一样:那次运行
重新开始,或者接受它跑不完。
**`fingerprints` 有变成垃圾桶的风险。** 一个「什么都能塞」的自由映射,判据不写清楚就会长成
第二个 `model_binding`:今天进去一个模板 sha,明天进去一个「本次实验的备注」,后天进去一个
时间戳,而时间戳每次都不同,续跑必然报漂移。缓解只有两条,都不是机器能查的——docstring 里
那条「坐标还是配方版本」的分界,以及评审。
**决策三的第一个校验点让请求的构造多了一次遍历。** 请求承诺构造廉价:无 I/O、无网络校验、
无哈希计算。遍历两个通常只有个位数条目的映射不违背这条承诺,但它确实不是零成本。记在这里,
是为了下次有人往 `__post_init__` 里加东西时能看见这笔账已经开过一次。
## 留给后续的
**快照的键空间没有任何机器保证不撞车。** `request.binding.<key>``request.fingerprint.<name>`
`request.injected_entry_ids.<通道名>` 三处的后半截都是下游给的自由字符串,库不校验它们的形状。
现在三个前缀互不相同,所以撞不了;再往快照里加一个带自由后缀的前缀时,要重新检查这件事。
**`0003` 决策三的请求字段表有一笔没做的欠账,而它即将被盖掉。** 那张表里有「工具段渲染
样式」这一项,`architecture.md` 第十节跟着写请求持有十一样数据;代码里 `RunRequest` 只有十个
字段,搜不到任何对应物。这是那张表里唯一一笔有表无码的欠账。`0014` 处理的另外两笔不在这张
表里:承诺过的内存存储实现来自 `0003` 否决方案那一节,契约套件发不出去则和 `0003` 无关。
危险不在这处漂移本身,在于它即将被盖住:加上 `fingerprints` 之后请求的字段数恰好变成十一,
第十节那个数字会重新对上,而组成完全不同。所以回写第十节时要照代码把那一段的字段逐项重写,
不是把数字改对——数字对上的那天,这笔欠账就再也没人看得见了。