622b17f90c
CHANGELOG 的「未发布」段落定成 1.0.3(2026-08-29),上面留一个空段给下一版接着攒;两处版本号 (pyproject.toml 与 polyloop/__init__.py)同步。 发之前跑了一轮完整压测,与 1.0.2 那次验收逐项可比:正常负载 400 次运行、3501 次真实模型调用, 十一条不变量全部通过、零击穿、零无法判定;九类故障注入 58 条判据全部通过、零击穿,剩下那条 无法判定与 1.0.2 是同一条。产物在 tools/soak/runs/v1.0.3/(该目录在 gitignore 里)。 第一次开跑那轮作废:模型中转连返 20 次 503 打开了网关熔断,400 次运行全部快速失败以 llm_error 收尾,整轮 181 秒「跑完」。正常负载那一轮的记分板不开 --allow-undetermined,当场拦下、退出码 非零——一次什么都没验到的跑没有冒充通过,那道闸是对的。产物留在 v1.0.3-aborted-503/。 README 的三条安装命令不用改:约束是 polyloop==1.0.*,1.0.3 落在里面。这意味着钉了那个约束的 下游一次例行升级就会拿到这一版,而这一版有一次破坏性的行为变更(绑定里不带前缀的 session_id 与 parent_call_id 从静默转发变成抛 ValueError)——眼下没有下游在用,所以实际影响为零, CHANGELOG 那一节把迁移写法写清楚了。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
207 lines
14 KiB
Markdown
207 lines
14 KiB
Markdown
# 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 上,而它不在。
|
||
|
||
## 1.0.3(2026-08-29)
|
||
|
||
回应 GovDoc-SaaS 提的 issue #6:网关适配器的转发白名单把 `cache_namespace` 挡在外面,而那是
|
||
PolyGateway 指定的租户隔离手段。
|
||
|
||
**这一版发出去之前跑了一轮完整压测**,与 1.0.2 那次验收逐项可比:正常负载 400 次运行、3501 次
|
||
真实模型调用,十一条不变量全部通过、零击穿、零无法判定;九类故障注入 58 条判据全部通过、零
|
||
击穿,剩下那一条无法判定与 1.0.2 那次是同一条(`cancel_env` 那次运行一条事件都没发出过,
|
||
事件文件的完整性无从判起)。库本身没有被压出 bug。产物在 `tools/soak/runs/v1.0.3/`
|
||
(该目录在 gitignore 里)。
|
||
|
||
第一次开跑那轮作废了,原因不在库:模型中转连返 20 次 503 打开了网关的熔断,其后每次调用都
|
||
快速失败,400 次运行全部以 `llm_error` 收尾。正常负载那一轮的记分板不开「允许无法判定」,
|
||
于是它当场拦下、退出码非零——一次什么都没验到的跑没有冒充通过。那批产物留在
|
||
`tools/soak/runs/v1.0.3-aborted-503/`。
|
||
|
||
### 网关适配器改按保留前缀转发绑定,而不是按键名撞
|
||
|
||
**这是一次破坏性的行为变更。** 绑定里不带前缀的 `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.2(2026-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.1(2026-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`。
|