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

19 KiB
Raw Permalink Blame History

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__.pyRunRequest 的字段与两处 parameter_snapshot../../src/polyloop/_assembly/__init__.pyinjected_entry_ids 的返回 类型、../../src/polyloop/types/__init__.pyInjection.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 平级分开,而不是塞进同一个字典——和这里让 fingerprintsmodel_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__.pyInjection.entry_id 的字段注释、 session/__init__.pyRunRequest.parameter_snapshot 的 docstring、_assembly/__init__.pyinjected_entry_ids 的 docstring。这个说法是错的,本次一并改正。

轨迹是步记录的序列,一步一条,记的是这一步模型说了什么、动作是什么、观察是什么。条目标识不 在里面。它进的是运行开始记录里的参数快照,一次运行只写一条,写在开工之前。照现在的 docstring 去找,下游会在步记录里翻一个不存在的列,翻不到之后多半会得出「库没记这件事」的结论,而它 明明记了。

改正和决策二本来就要做的事发生在同一处:Injection.entry_id 那段注释还要补上下面那条分隔符 约束。

分隔符是一个已知的、不打算修的限制

快照的值是字符串,把一串条目标识压进一个值里就得选一个分隔符。条目标识里如果真含逗号,两组 不同的注入可能拼出同一个串,于是一次本该报出来的漂移没有报。通道名同理。

不修的理由是:快照的值只用于逐字段比对,从来不被解析回列表;而人要肉眼看快照排查漂移,换成 JSON 编码会让它读不动——一份几十行的快照里混着转义引号和方括号,「哪一项变了」这个问题的答案 就得靠工具才看得出来。已有的 request.tools 是同样的形状、同样的限制,这里不为新键单独定 一套。

代价是给下游留一句约束:标识里不要放逗号。这句话写进 Injection.entry_id 的注释,因为那是 写代码的人会读到的地方。

决策三:快照的取值必须是字符串,在开跑之前就守住

两个校验点。RunRequest.__post_init__ 校验 fingerprintsmodel_binding 的每一个键和每 一个值都是 str。两处 parameter_snapshot()——AgentDefinition 那个和 RunRequest 那个——在 聚合接缝上报的参数时,校验接缝返回的键值都是 str。不合格用显式异常拒绝。

用异常不用 assertpython -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 之后请求的字段数恰好变成十一, 第十节那个数字会重新对上,而组成完全不同。所以回写第十节时要照代码把那一段的字段逐项重写, 不是把数字改对——数字对上的那天,这笔欠账就再也没人看得见了。