0014 契约套件怎么发给下游、0015 参数快照的内容契约、0016 动作执行接缝抛异常时的契约。 三份都过了 CLAUDE.md §3 的硕士生冷读,冷读抓到的八处「在讲文档自己」的句子、五处缺前置 知识、三处只写结论没写理由、三处参数两地取值不同,全部采纳。 0016 否掉了提 issue 那一方倾向的方案(库捕获执行器异常转 ENV_ERROR)。三层理由:日志 「不自洽」这个前提本身不成立——异常抛出时副作用状态未知,日志停在「动作意图有、结果无」 正是照实记录;库替它写一条步记录反而是在编造,而那条记录会让恢复把未知状态抹掉;最后 它会把执行器里一个 AttributeError 变成一批环境故障,run() 照常返回正常结果,调用方拿不到 任何异常。
19 KiB
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 之后请求的字段数恰好变成十一,
第十节那个数字会重新对上,而组成完全不同。所以回写第十节时要照代码把那一段的字段逐项重写,
不是把数字改对——数字对上的那天,这笔欠账就再也没人看得见了。