# 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.`——单数,与已有的 `request.binding.` 对齐。 **默认空映射时快照里一个键都不写**,不是写一个值为空串的键。这样今天已经在跑的配置算出来的 快照逐字节不变,只有真的传了指纹的运行才多出那几项。 ### 为什么需要它 上下文的正文与注入条目的正文是刻意不进快照的,理由是它们属于这次运行的输入**数据**而不是 参数:进快照会让快照变成一份数据副本,而它们可能很大。 **注入这一侧不进快照的只有正文,条目的标识是进的。** 正文和标识是两样东西:正文是贴给模型 看的那段文本,标识是这条注入的名字。所以「这次贴了哪几条」事后查得到,查不到的只是那几条各自 写了什么。上下文那一侧没有对应的标识可以留,它是一段已经渲染好的消息序列,本身不带名字。 把正文挡在外面这条理由没错,但它顺手把**生成这些数据的东西**也挡在了外面。提示词模板不是 数据,是参数——它是一份跨运行复用的配方,每次运行拿它渲染出这一次的上下文。 提 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:`。理由是换算法的那天,不带前缀的旧记录和新记录会以 「两个不同的十六进制串」的形式参与比对,报出来的漂移看不出是换了算法还是内容真的变了;带前 缀则一眼看得出。 这是建议,不是校验。库不解释这个值,也就没有立场规定它长什么样——真去校验,等于替下游定了它 能用哪几种哈希。 ### 业界怎么用 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.`、`request.fingerprint.`、 `request.injected_entry_ids.<通道名>` 三处的后半截都是下游给的自由字符串,库不校验它们的形状。 现在三个前缀互不相同,所以撞不了;再往快照里加一个带自由后缀的前缀时,要重新检查这件事。 **`0003` 决策三的请求字段表有一笔没做的欠账,而它即将被盖掉。** 那张表里有「工具段渲染 样式」这一项,`architecture.md` 第十节跟着写请求持有十一样数据;代码里 `RunRequest` 只有十个 字段,搜不到任何对应物。这是那张表里唯一一笔有表无码的欠账。`0014` 处理的另外两笔不在这张 表里:承诺过的内存存储实现来自 `0003` 否决方案那一节,契约套件发不出去则和 `0003` 无关。 危险不在这处漂移本身,在于它即将被盖住:加上 `fingerprints` 之后请求的字段数恰好变成十一, 第十节那个数字会重新对上,而组成完全不同。所以回写第十节时要照代码把那一段的字段逐项重写, 不是把数字改对——数字对上的那天,这笔欠账就再也没人看得见了。