feat(session): 参数快照补上配方指纹与注入通道,并写定执行器抛异常的契约
四件事,都来自第一个下游的 issue。 **RunRequest 新增 fingerprints。** 上下文与注入内容刻意不进快照(它们是数据不是参数), 而这条规则把生成它们的提示词模板也一起挡在外面了。具体的失败场景:换一份模板续跑不报错, 前几步用 A 模板、后几步用 B,那次运行的数据已经废了却没有任何提示。键形状 request.fingerprint.<name>,和 model_binding 那个坐标分开——一个是「这次运行属于哪一格」, 一个是「用的配方是哪一版」,混在一个字段里事后分不开。默认空映射时一个键都不写,所以已有 配置算出来的快照逐字节不变。 **注入的通道维度不再拍平。** 一通道一键,于是「声明了通道但一条都没选中」和「压根没有这个 通道」分得开:前者是值为空串的键,后者是键不存在。有一档实验要比较的正是这两种情形。 **ActionExecutor 的 docstring 写定抛异常时会怎样**:环境故障走 ENV_ERROR 返回值,实现方真 抛了库不接管、异常原样穿出。理由在 design 0016;简言之库替它编一个结算结果就是在编造,而 副作用状态在那一刻是未知的。 **三个映射字段在构造期冻成只读。** 对抗审查发现 frozen=True 不禁止改字段里那个 dict,于是 构造期那道「快照取值必须是字符串」的校验能被绕过去:构造完往 fingerprints 里塞一个整数, 它一路进日志,要到续跑读日志时才炸——而那时这次运行已经完整跑过一遍。复用 tools 里已有的 _frozen,没另写一套。
This commit is contained in:
@@ -18,7 +18,7 @@ import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from polyloop._assembly import (
|
||||
assemble,
|
||||
@@ -48,7 +48,7 @@ from polyloop.ports import (
|
||||
RunLog,
|
||||
RunStore,
|
||||
)
|
||||
from polyloop.tools import RegistryExecutor, ToolRegistry
|
||||
from polyloop.tools import RegistryExecutor, ToolRegistry, _frozen
|
||||
from polyloop.types import (
|
||||
ActionOutcome,
|
||||
ActionStatus,
|
||||
@@ -90,6 +90,29 @@ class ParameterDriftError(Exception):
|
||||
"""
|
||||
|
||||
|
||||
def _checked_str_mapping(mapping: Mapping[str, str], owner: str) -> Mapping[str, str]:
|
||||
"""快照的每一个键和每一个值都必须是字符串,不是就当场拒绝。
|
||||
|
||||
取值类型已经是持久化契约的一部分:反序列化那一侧读到非字符串直接失败。只靠那一侧的话,
|
||||
失败发生在续跑读日志的时候——这次运行已经完整跑过一遍、钱花完了、日志也写下去了,才发现
|
||||
里面有一项读不回来;而且发现它的前提是真的有人来续跑,没人续跑那份存坏了的日志就一直躺着
|
||||
直到有人拿它做统计。
|
||||
|
||||
**用异常不用 `assert`**:`python -O` 会把断言整条移除,而这道校验守的正是一件静默出错的事。
|
||||
|
||||
`owner` 进错误信息,因为快照汇的是五个接缝加两个请求字段;只说「快照必须是字符串」的报错
|
||||
在七个来源里指不出是谁。
|
||||
"""
|
||||
for key, value in mapping.items():
|
||||
if not isinstance(key, str):
|
||||
raise ValueError(f"{owner} 的键必须是字符串,收到 {type(key).__name__}:{key!r}")
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(
|
||||
f"{owner} 里 {key!r} 的取值必须是字符串,收到 {type(value).__name__}:{value!r}"
|
||||
)
|
||||
return mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class AgentDefinition:
|
||||
"""跨运行不变的那一半装配,可以并发复用。
|
||||
@@ -114,6 +137,11 @@ class AgentDefinition:
|
||||
方法则每次现问,快照永远是从真实对象上读出来的**事实**而不是一份**声明**。
|
||||
|
||||
键带接缝名前缀,免得「哪一侧报的这个键」要靠约定记住。
|
||||
|
||||
**接缝上报的键值在这里校验类型,构造定义的时候不校验。** 构造时不向任何接缝发问,
|
||||
发问发生在首次算快照的时候,那时 `run()` 已经读过一次存储日志了。所以这道校验保证的
|
||||
不是零代价,是它发生在写运行开始记录之前,也就是在任何一次模型调用之前——不会跑完
|
||||
一整次运行、把钱花光,才在续跑时发现快照里有一项存不下去。
|
||||
"""
|
||||
snapshot: dict[str, str] = {}
|
||||
for prefix, seam in (
|
||||
@@ -122,14 +150,29 @@ class AgentDefinition:
|
||||
("store", self.store),
|
||||
("event_sink", self.event_sink),
|
||||
):
|
||||
for key, value in seam.parameters().items():
|
||||
for key, value in _checked_str_mapping(
|
||||
seam.parameters(), f"{prefix}.parameters()"
|
||||
).items():
|
||||
snapshot[f"{prefix}.{key}"] = value
|
||||
return snapshot
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class RunRequest:
|
||||
"""一次运行独有的那一半装配。构造廉价:无 I/O、无网络校验、无哈希计算。"""
|
||||
"""一次运行独有的那一半装配。构造廉价:无 I/O、无网络校验、无哈希计算。
|
||||
|
||||
三个映射字段(`injections`、`model_binding`、`fingerprints`)在构造时被逐层冻成只读的
|
||||
形状存下来,用的是 `ToolSpec.parameters` 那同一个函数。`frozen=True` 只挡住「把字段重新
|
||||
绑到另一个对象上」,挡不住「原地改那个字段里的 dict」,而这三个字段全都进参数快照:不冻
|
||||
的话,`__post_init__` 那道「键和值都得是字符串」的校验可以被构造完之后往 dict 里塞一个
|
||||
整数绕过去,那个整数一路进到运行开始记录,要到续跑读日志反序列化时才炸——那时这次运行
|
||||
已经完整跑过一遍、钱也花完了。`injections` 更凶一档:构造后往里加一个通道,会同时改掉
|
||||
参数快照和装配出来的消息序列,而两者都是「这次运行是什么设置」的证据。
|
||||
|
||||
**三个字段的类型注解仍然是 `Mapping`,签名的形状没有变。** `Mapping` 本来就是只读接口,
|
||||
冻结没有对下游多要求什么:照旧传普通 dict 进来,变的只是「传进来之后再改那个 dict,这个
|
||||
请求不跟着变」。
|
||||
"""
|
||||
|
||||
#: 不透明字符串,库不解析。它同时是日志的主键。
|
||||
run_id: str
|
||||
@@ -140,10 +183,27 @@ class RunRequest:
|
||||
#: 项目传一个空注册表加一个环境句柄,注册了工具的项目传 `tools.executor()`。
|
||||
tools: ToolRegistry
|
||||
context: Context
|
||||
#: 本次要贴进上下文的条目,按通道分组。
|
||||
#: 本次要贴进上下文的条目,按通道分组。构造时冻成只读映射(见类 docstring)。
|
||||
injections: Mapping[str, tuple[Injection, ...]]
|
||||
#: 项目自己的标识,库不解释,原样透传给每次模型调用。它的全部键值都进参数快照。
|
||||
#: 构造时冻成只读映射(见类 docstring)。
|
||||
model_binding: Mapping[str, str]
|
||||
#: 这次运行用的材料是哪一版:提示词模板的哈希、技能库的版本这类。键名由项目自己定,库不
|
||||
#: 解释内容,全部键值以 `request.fingerprint.<name>` 进参数快照,**不透传给模型调用**。
|
||||
#:
|
||||
#: **和 `model_binding` 的分界是「坐标还是配方版本」**:那个记的是这次运行属于哪一格
|
||||
#: (哪个账本、第几轮、哪道题),这个记的是这次用的材料是哪一版。混在一个字段里事后分不
|
||||
#: 开——一组键值里既有「第 3 轮」又有一个 sha,要靠键名的命名约定去猜哪个是哪个,而命名
|
||||
#: 约定不在任何一处被断言。完整论证在
|
||||
#: `research-wiki/design/0015-parameter-snapshot-contract.md` 决策一。
|
||||
#:
|
||||
#: 建议值里带上算法前缀,形如 `sha256:<hex>`。不带的话,换算法那天旧记录和新记录会以
|
||||
#: 「两个不同的十六进制串」的形式参与比对,报出来的漂移看不出是换了算法还是内容真的变了。
|
||||
#: 这是建议不是校验:库不解释这个值,也就没有立场规定下游能用哪几种哈希。
|
||||
#:
|
||||
#: 构造时冻成只读映射(见类 docstring)。默认值那个空 dict 同样会被冻——不冻的话,一个
|
||||
#: 没传指纹的请求手上是一个改得动的空 dict,往里塞什么都不经过校验。
|
||||
fingerprints: Mapping[str, str] = field(default_factory=dict)
|
||||
#: 必填无默认。工具的重放策略能从注册表查到,模型调用的查不到——只有调用方知道这次调用
|
||||
#: 能不能重来。
|
||||
model_replay_policy: ReplayPolicy
|
||||
@@ -155,6 +215,16 @@ class RunRequest:
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
check_observation_template(self.observation_template)
|
||||
# 这两个字段整个进快照,取值不是字符串的话这次运行照常跑完,续跑读日志时才炸。这里
|
||||
# 拒绝的代价是零:什么都还没发生,没有 I/O、没有日志、没有模型调用。两个一起校验是
|
||||
# 有意的——它们语义同族、形状相同,只校验其中一个会让两个看起来一样的字段行为不一样。
|
||||
_checked_str_mapping(self.model_binding, "RunRequest.model_binding")
|
||||
_checked_str_mapping(self.fingerprints, "RunRequest.fingerprints")
|
||||
# 校验完当场冻起来。校验和冻结是一对,缺了后半截前半截只在构造那一瞬间成立:
|
||||
# `frozen=True` 拦不住 `request.fingerprints["x"] = 7`,而那之后快照里就有一个整数了。
|
||||
object.__setattr__(self, "model_binding", _frozen(dict(self.model_binding)))
|
||||
object.__setattr__(self, "fingerprints", _frozen(dict(self.fingerprints)))
|
||||
object.__setattr__(self, "injections", _frozen(dict(self.injections)))
|
||||
if self.cancel_grace_seconds < 0:
|
||||
raise ValueError(f"取消宽限期不能为负:{self.cancel_grace_seconds}")
|
||||
# 模型看见的 schema 来自一个注册表、实际分发走另一个,表现是「模型调了一个它看得见的
|
||||
@@ -173,8 +243,9 @@ class RunRequest:
|
||||
"""请求这一侧的快照,加上向动作执行接缝问的那一次。
|
||||
|
||||
**上下文与注入内容不进快照。** 它们是这次运行的输入数据不是参数,进快照会让快照变成
|
||||
一份数据副本,而它们可能很大。注入的**条目标识**另行进轨迹,所以「这次贴了哪几条」
|
||||
事后查得到,查不到的只是正文。
|
||||
一份数据副本,而它们可能很大。注入的**条目标识**进快照,所以「这次贴了哪几条」事后
|
||||
查得到,查不到的只是正文。生成上下文的东西(提示词模板这类)不是数据是参数,它的版本
|
||||
走 `fingerprints`。
|
||||
"""
|
||||
snapshot: dict[str, str] = {
|
||||
"request.max_steps": str(self.budget.max_steps),
|
||||
@@ -187,14 +258,24 @@ class RunRequest:
|
||||
"request.observation_template": self.observation_template,
|
||||
"request.cancel_grace_seconds": str(self.cancel_grace_seconds),
|
||||
"request.tools": ",".join(self.tools.names()),
|
||||
"request.injected_entry_ids": ",".join(injected_entry_ids(self.injections)),
|
||||
}
|
||||
# 一个通道一个键,不把所有通道拍平成一个。拍平之后「声明了这个通道但一条都没选中」
|
||||
# 与「压根没有这个通道」是同一个结果,而有下游要比较的正是这两种情形:前者是一个值为
|
||||
# 空串的键,后者是这个键不存在。
|
||||
for channel, entry_ids in injected_entry_ids(self.injections).items():
|
||||
snapshot[f"request.injected_entry_ids.{channel}"] = ",".join(entry_ids)
|
||||
# 模型绑定必须进快照,否则给它选字符串映射的那条理由就落空了。失败场景很具体:崩溃后
|
||||
# 用同一个运行标识、换一组绑定续跑,后面每一次调用被记到另一套坐标上,而两段轨迹在
|
||||
# 文件里看起来是同一次运行。
|
||||
for key, value in self.model_binding.items():
|
||||
snapshot[f"request.binding.{key}"] = value
|
||||
for key, value in self.action_executor.parameters().items():
|
||||
# 一条指纹都没有时一个键都不写,不写一个值为空串的键:今天已经在跑的配置算出来的快照
|
||||
# 因此逐字节不变,只有真的传了指纹的运行才多出这几项。
|
||||
for key, value in self.fingerprints.items():
|
||||
snapshot[f"request.fingerprint.{key}"] = value
|
||||
for key, value in _checked_str_mapping(
|
||||
self.action_executor.parameters(), "action_executor.parameters()"
|
||||
).items():
|
||||
snapshot[f"action_executor.{key}"] = value
|
||||
return snapshot
|
||||
|
||||
@@ -676,6 +757,7 @@ class _Driver:
|
||||
replay_policy=ReplayPolicy.NEVER if spec is None else spec.replay_policy,
|
||||
)
|
||||
)
|
||||
# 这里不包 try 是契约,不是漏了:执行器抛出的异常原样穿出去(`design/0016` 决策一)。
|
||||
outcome = await self._request.action_executor.execute(action)
|
||||
return outcome, bool(spec is not None and spec.completes_run)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user