Files
PolyLoop/CHANGELOG.md
T
iomgaa c0d9d7b66e docs: 回写 CHANGELOG、GovDoc 迁移,以及压测那条过期注释
CHANGELOG 落在「未发布」段,按那一段自己的规矩不提前写版本号。写清了四条会抛 ValueError 的
情形与迁移写法,因为这是一次破坏性变更。

migrations/govdoc-saas.md 加一节讲租户命名空间怎么传,含迁完算不算数的四条判据,最终判据是
「同一段文本由两个租户各提交一次,各自拿到自己的那份输出」。参数一律指向 0017 不复述。这份
文档原来说 GovDoc 侧「还没有可迁移的东西」,现在有了第一条能逐条验的接入动作,文件头那句
状态说明跟着补了一句例外。

tools/soak/run_soak.py 那份空绑定上方的注释在复述旧转发规则,顺手改对。它给的理由本来就不准
——让绑定留空的真正原因是绑定的全部键值都进参数快照,每批都不同的值会让故障注入那一步续跑时
报一次假的参数漂移,和转发哪些键无关。三份压测绑定常量里都没有裸键,所以这次变更打不到压测。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 11:53:20 -04:00

191 lines
13 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.
# CHANGELOG
**这份文件是「哪个版本改了什么」的唯一权威**`CLAUDE.md` §0)。未发布的改动攒在「未发布」
那一段,发布时改成 `## X.Y.Z(日期)`。发布的完整步骤在
`research-wiki/guides/releasing.md`
版本号语义按 `CLAUDE.md` §1.3:公共类型的字段只增不删不改名,新增字段必带默认值;要删要改
就发新 major 并写迁移指引。
## 未发布
**版本号在真的要发布的那一刻才定,这里不提前写。** 只 bump 版本号不叫发布(`CLAUDE.md`
§1.10):PolyGateway 的 1.0.6 与 1.1.0 都完成了版本号 bump 与 CHANGELOG,却从未上传,registry
长期停在 1.0.5,下游 `pip install` 拿不到任何修复且无人发现。提前把号写进这一段就是在重演
那个形态——读到号的人会以为那一版已经在 registry 上,而它不在。
### 网关适配器改按保留前缀转发绑定,而不是按键名撞
**这是一次破坏性的行为变更。** 绑定里不带前缀的 `session_id``parent_call_id` 从「静默转发
给网关」变成「抛 `ValueError` 并给出改法」,改法是把键名写成 `gateway.session_id`
```python
model_binding={
"book": "b7", # 项目自己的坐标,不传
"gateway.cache_namespace": "acme:v1:tenant:x7", # 传成 chat(cache_namespace=...)
"gateway.tenant_id": "x7",
}
```
绑定里键名以 `gateway.` 开头的,前缀之后那一段当作网关 `chat()` 的关键字参数名,值原样传下去;
其余的键照旧不传、也不报错。**本库不再持有一份网关参数名单**——上游哪天再加一个字符串维度,
下游当天就能用,本库不发版,声明的依赖下界也不用跟着抬。
原来那份写死的白名单只认两个键,把 `cache_namespace` 挡在外面,而那是 PolyGateway 指定的租户
隔离手段:挡掉之后,两个租户提交内容相同的一段文字,第二个会读到第一个那次的模型输出。给出
那份白名单的理由是「网关只有两个槽位放得下这类东西」,而这句话在写下的那天就已经不成立——
当时装得到的最低版本上已经有四个。
四条会抛 `ValueError` 的情形:带前缀的键名指向 `messages``stream``structured``overlay`
之一(这些改变请求本身,另有权威);带前缀的键取值是空串或纯空白(在网关那边和「没传」分不
开,或者不带任何信息,而一个看着配了、实际没配的隔离比没配更糟);键恰好是 `gateway.`、前缀
后面没跟参数名;以及不带前缀的那两个历史裸键。
判断发生在把请求交给网关之前,所以出错的那次调用不会真的发出去。
转发的键照旧全部进参数快照,键名不变(`request.binding.gateway.cache_namespace`),所以换一个
命名空间续跑会照常撞参数漂移。
方案与被否掉的三条路见 `research-wiki/design/0017-gateway-forwarding.md`
## 1.0.22026-08-27
回应下游项目在 PolyLoop 仓库上提的五个 issue。
**这一版发出去之前跑了一轮完整压测**,与 1.0.1 那次验收逐项可比:正常负载 197 次运行、1845 次
真实模型调用,十一条不变量全部通过、零击穿;九类故障注入 58 条判据全部通过、零击穿。库本身
没有被压出 bug。
**升级时唯一会变的行为在参数快照**(见下面「注入内容的通道维度」那一节):用 1.0.1 跑了一半的
运行,升上来之后续跑会抛 `ParameterDriftError`,错误信息里逐个列出漂移的键。失败是响亮的,
不会静默跑出两段来自不同配置的轨迹。
### 契约套件随包发布
五个接缝的公共行为一致性用例从 `tests/contract/` 搬进 `polyloop.testing`,跟着 wheel 一起
装到下游去。**`pip install polyloop` 之后 site-packages 里没有 `tests/`**,所以在此之前那套
被称作「任何新适配器的准入标准」的用例,第一个下游根本拿不到。
**接法同时换了**:原来是在自己的 `conftest.py` 里覆盖同名 fixture,现在是继承契约基类。
```python
from polyloop.testing import RunStoreContract
class TestMyPostgresStore(RunStoreContract):
@pytest.fixture
def store(self, pg_pool): return MyPostgresStore(pg_pool)
```
换掉是因为覆盖同名 fixture 在装到 site-packages 之后无处落脚——pytest 的 `conftest.py` 只沿着
被收集文件的目录链往上找,而套件所在的那条链在 site-packages 里,看不见下游仓库里的
`conftest.py`。继承则不需要任何 `conftest.py` 魔法:子类定义在下游自己的测试文件里。顺带一个
接缝可以接多个实现,各写一个子类。
跑它要装 `polyloop[testing]`pytest 与 pytest-asyncio,版本只写下界,不和下游已经在用的
pytest 打架)。async 用例的事件循环归下游管:把 `asyncio_mode` 设成 `"auto"`,或者自己给子类
打标记。本包还声明了一个 pytest 插件入口,它唯一的作用是让 pytest 重写这些模块里的
`assert`,失败时打印出等号两边的实际值。
**基类名、基类上的 fixture 名、每一条用例的方法名从此是公共承诺**,改名的代价和改公共类型的
字段一样。
### 新增一个内存存储实现
`polyloop.stores.VolatileRunStore`:日志攒在进程内存里,进程一退就没了。它不提供的是**跨进程
恢复**,不是「读不回来」——同一个进程里写进去的意图照样读得回来,它和逐行追加那个实现跑的是
同一套存储契约。桶里存的是编码后的载荷、读的时候才解码,所以调用方后来改自己手里那个 dict
改不到已经写下去的快照,读回来的日志被就地改动也污染不了存储本身——落盘那个实现每次都重新
解析文件,天然如此,这个实现靠同一条路径对齐它。一条字段类型不对的记录在两个实现上的下场也
一样:写得进去,读的时候抛同一个解码错误。
对下游的意义是测试和「我不要跨进程恢复」那一档不必再自己写一个存储:存储接缝是必填的,
在此之前不想落盘的人只能自己造一个。关系数据库那种形态仍然由下游自己实现。
### `RunRequest` 新增 `fingerprints` 字段
`Mapping[str, str]`,默认空映射,**已有代码不受影响**。它记的是这次运行用的材料是哪一版——
提示词模板的哈希、技能库的版本这类——全部键值以 `request.fingerprint.<name>` 进参数快照,
不透传给模型调用。
它和 `model_binding` 的分界是「坐标还是配方版本」:绑定记这次运行属于哪一格(哪个账本、
第几轮、哪道题),指纹记这次用的材料是哪一版。**一条指纹都没有时快照里一个键都不写**,
所以今天已经在跑的配置算出来的快照逐字节不变。
同时补上一道校验:`fingerprints``model_binding` 的键值必须都是字符串,在构造请求时就拒绝
非字符串;五个接缝 `parameters()` 上报的键值在聚合成快照时同样校验。在此之前这类值要等到续跑
读日志的那一刻才炸——那时这次运行已经跑完、钱已经花了。
### ⚠ 参数快照里注入内容的键形状变了
**这是这一批里唯一一处会让已有行为变化的改动。** 注入的条目标识原来拍平成一个键
`request.injected_entry_ids`,现在按通道分组,一个通道一个键
`request.injected_entry_ids.<通道名>`
**用旧版本跑了一半的运行,升级之后 `resume` 会抛 `ParameterDriftError`。** 失败是响亮的,不是
静默的:错误信息把漂移的键逐个列出来,能看到旧的那个键消失、新的那几个键出现。处置有两条,
和快照里任何一项变了时一样——那次运行重新开始,或者接受它跑不完。跑完了的运行不受影响,
参数快照只在续跑时被比对。
改形状是因为拍平之后「声明了这个通道但一条都没选中」和「压根没有这个通道」得出同一个结果,
而有下游要比较的正是这两种情形。分组之后前者是一个值为空串的键,后者是这个键不存在。通道
之间按通道名字典序,通道内的顺序和贴进提示词的顺序一致。
**快照的键集合从来不是公共承诺**,所以这不是 `CLAUDE.md` §1.3 意义上的破坏性变更:下游换一个
存储实现、改一个接缝的 `parameters()` 返回什么,快照照样会变、续跑照样报漂移,这本来就是这
套设计的一部分。
### 动作执行接缝的契约补上「抛异常时会怎样」
`ActionExecutor` 的 docstring 原来只说了动作本身报错算「已执行」,实现方干脆不返回、直接抛出
时会怎样一个字都没写,而库这一侧不捕获。现在正面写清三条:环境自己坏了(连不上、协议不对、
会话没了)返回 `ActionStatus.ENV_ERROR`,不要以异常表达;真抛出来的异常库不捕获,原样穿出
`run()``resume()``asyncio.CancelledError` 必须原样穿过。
**行为没有变,变的是它被写下来了。** 库不把执行器抛出的异常转成 `ENV_ERROR`,是因为那样会
把适配器自己的 bug 伪装成环境故障送进下游的统计,而且要替这一步编一条步记录——那条记录会被
恢复读成「上一步走完了」,把一个真正未知的状态抹成一个具体的值。异常抛出时日志停在「动作
意图有、步记录无」,恢复照实把它读成状态未知。
把可预期的环境异常(连接超时、会话已关闭)捕获并返回 `ENV_ERROR` 是适配器的正常工作,不算
`CLAUDE.md` §1.7 禁的那种吞错误——错误没有被吞,它变成了一个明确的状态值。
## 1.0.12026-08-11
首个发布版本。十个模块全部落地,四层测试都在跑。
**为什么首个版本是 1.0.1 而不是 0.x**`CLAUDE.md` §1.3 那条「公共类型的字段只增不删不改名」
从第一个下游装上它的那天起就生效,而 0.x 在语义化版本里意味着「随时可以破坏兼容」——两者
对不上。用 1.x 开头是在声明那条承诺现在就算数。
### 公共 API
- **`polyloop.types`**:消息与内容块、上下文与注入、动作结果、预算、停止原因、逐步轨迹的
一行(`StepRecord`)、一次运行的结果(`RunResult`),以及五种持久化日志记录。持久化结构带
独立的 schema 版本,读到不认得的版本直接失败,不靠默认值补齐(`CLAUDE.md` §1.4)。
- **`polyloop.ports`**:五个接缝的 Protocol——模型调用、决策解释、动作执行、存储、事件出口。
每个都带一个同步的 `parameters()`,装配时聚合成参数快照写进运行开始记录,续跑时逐字段比对。
- **`polyloop.session`**`run``resume` 两个入口,以及它们收的两个装配对象
`AgentDefinition` 跨运行不变、`RunRequest` 每次运行一份)。
- **`polyloop.tools`**:工具注册表。注册、模型可见的 schema 生成、存在性与参数校验、分发,
四件事由同一个注册表实例驱动,所以「模型看得见但调不到」这种状态构造不出来。
- **`polyloop.serialization`**:持久化记录的编解码。读到没有版本字段的载荷直接失败。
- **`polyloop.stores`**:逐行追加的 jsonl 存储。必须显式 import,不进顶层。
- **`polyloop.adapters`**PolyGateway 的模型调用适配器,装它要 `polyloop[gateway]`
必须显式 import——顺手导出会让每个进程在 import 本库时把网关连同它的 provider 目录一起拉起来。
### 这一版保证了什么
- **一次运行是有界的**:步数、动作数、连续解析失败次数、提示词规模四个预算,停止判定的顺序
写死在主循环里,判定结果随每一步落盘——崩在中间也不会把「恰好用满预算完成」记成「预算耗尽」。
- **崩溃之后能从断点续跑**:一步之内四次写,其中两次是耐久屏障;恢复读意图日志判断上一步
处在哪一档(还没开始 / 执行完了 / 状态未知 / 日志损坏),按工具声明的重放策略处置。
十个写入边界逐个崩过一遍,续跑结果与不中断跑完逐字段相等。
- **取消能穿透**`asyncio.CancelledError` 不被捕获吞没,取消进来之后在宽限期内写下结束记录
——不写的话恢复会把一次被主动叫停的运行当成可以续跑。
- **并发跑同一份定义互不干扰**:装配对象不持有任何一次运行的状态。
### 已知欠账
- `stores` 只有 jsonl 一种形态,关系数据库那种由下游自己实现,`tests/contract/` 是它的准入标准。
- 契约套件里解释器、执行器、模型客户端那几条等下游把实现接进来才跑得到。
- 原子写的「崩在中间时两者都不可见」与前缀持久性这两条承诺没有机器兜底,标成 `xfail`