fix(stores): 修 Codex 对抗审查报的五条,其中两条同一根因
最实的一条:读取端只要一段能解析成 JSON 就收下,没检查它后面有没有换行。而短写完全可能
正好写完整个 JSON 对象、只差那个换行——那次写从来没被确认过(调用方的 await 还没返回),
按契约就是「没发生」,但它会被当成一条有效的动作意图读回来,恢复据此判成「状态未知」并可能
重放,而那个动作一定没执行过(调用方是在写意图返回之后才去执行的)。
判据改成「这一行有没有被换行终结」,不是「能不能解析」。同一个改动顺带修掉第三条:一行完整
终结的坏行(比如被外部追加的 {})此前会被当成撕裂尾行吞掉,读成「少了一条记录但看起来完整」
的日志;现在终结过的行解不开就是损坏,直接报错。
其余三条:
- 新建日志文件不 fsync 父目录。os.fsync(fd) 刷的是文件内容,刷不到「这个目录里多了一个
文件」这条目录项;掉电后内容可能在而文件不存在,read_log 走「文件不存在」返回空日志,
驱动入口判成全新运行,一次已经花过钱的运行静默没了留痕。只在新建时刷。
- 同一运行标识上的并发写会交错:一条记录可能由不止一次 os.write 写完,而 O_APPEND 只保证
每次 write 的追加位置原子,保证不了一条逻辑行整体原子。按运行标识加锁串起来(不同运行
照样并行),跨进程那一半仍靠独占创建挡。有一条用短写逼出那个窗口的测试。
- 往返测试的 TOTAL_WRITES 是硬编码,而且漏写结束标记它发现不了(恢复会把最后一步之后那次
停止判定重演一遍,得出同样结果)。加一条把十次写的记录类型序列整个钉死的测试。
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
# Design 0012 · 模型调用接缝的网关适配器
|
||||
|
||||
**日期** 2026-08-10 · **状态** 待确认
|
||||
|
||||
**落实** `0003-public-api-shape.md` 决策四里的模型调用接缝,与 `../../CLAUDE.md` §1.5
|
||||
「模型调用一律走 PolyGateway,不在本项目里另写重试 / 限流 / 熔断 / 缓存 / 遥测」。
|
||||
|
||||
**触及** `../../src/polyloop/adapters/`,以及 `../../tests/integration/`——那一层到现在还是空的,
|
||||
因为「连真 PolyGateway 的是 integration」(`../../CLAUDE.md` §1.9),而在这之前没有任何东西连它。
|
||||
|
||||
## 这份适配器要跨的那条缝
|
||||
|
||||
一边是本库的 `ModelCall` 与 `ModelReply`:消息是内容块序列、绑定是字符串映射、失败以异常
|
||||
表达。另一边是网关的 `GatewayClient.chat`:消息是 `list[dict[str, Any]]`、返回一个有二十来个
|
||||
字段的 `LLMResponse`、失败抛一族它自己的异常。
|
||||
|
||||
**缝两边的形状都不归我们定**,所以这份文档定的全是「怎么对上」,不是「该长什么样」。
|
||||
|
||||
## 决策一:收一个已经装配好的客户端,不自己装配
|
||||
|
||||
```python
|
||||
GatewayModelClient(client=..., settings=...)
|
||||
```
|
||||
|
||||
网关的装配入口收十几个参数(限流器、熔断器、缓存后端、遥测记录器、重试与背压策略……),
|
||||
而**那些全是治理配置,按 §1.5 不归本库管**。适配器自己调那个工厂等于替项目决定了这些,而且
|
||||
项目常常要在多个用途之间共享同一个限流器和缓存——它自己装配才做得到。
|
||||
|
||||
**代价是调用方多写一行。** 接受,因为另一条路是把网关的工厂签名抄进我们的签名里:他们加一个
|
||||
参数,我们就得跟着加一个,而漏跟的表现是「这个参数传不进去」。
|
||||
|
||||
## 决策二:同时收一份配置,只为算出可复现的模型身份
|
||||
|
||||
`parameters()` 要回答「这次运行用的是哪个模型配置」,续跑时逐字段比对。但**客户端不公开
|
||||
它的源列表与 scope**(构造时收下,只留在内部),所以从客户端本身问不出这个答案。
|
||||
|
||||
于是适配器同时收那份 `GatewaySettings`,用**网关自己的** `build_model_fingerprint(sources)`
|
||||
算指纹。那个函数是它的公开函数,语义是「本 scope 会用哪些(模型、请求形态)组合」,并且把
|
||||
采样参数与推理开关也算进去——把 temperature 从 0 改成 1 之后重启,指纹会变。
|
||||
|
||||
**用它而不是我们自己拼一串**,因为模型身份怎么算是网关的事:他们哪天认为某个新字段也该参与
|
||||
身份,改在他们那里,我们跟着变。自己拼的话,那个定义会和他们的悄悄分叉。
|
||||
|
||||
**残留风险照实认下:客户端与配置必须真的是同一对。** 传一个客户端加另一份配置,指纹会说谎,
|
||||
而续跑守卫就白设了。库验不了这件事——客户端不公开它是按哪份配置装的。这条写进那个类的
|
||||
docstring,让传参的人看得见。
|
||||
|
||||
## 决策三:消息按块拼成一个字符串
|
||||
|
||||
`Message.content` 是内容块序列,网关那边一条消息的 `content` 是一个值。第一版只有文本块,
|
||||
所以把它们按顺序拼起来——不加分隔符,因为块之间本来就没有分隔符这个概念,加了就是往模型看见
|
||||
的文字里塞东西。
|
||||
|
||||
将来有图片块时,这里改成网关/供应商的多模态数组形态。**那是加分支,不是改签名**,正是
|
||||
`0003` 决策六把内容定成序列而不是裸字符串换来的。
|
||||
|
||||
## 决策四:绑定里只有网关认得的那两个键会传下去,其余留给参数快照
|
||||
|
||||
`ModelCall.binding` 是项目自己的坐标(某个下游有五维),网关只有 `session_id` 与
|
||||
`parent_card_id` 两个槽位放得下这类东西。适配器把这两个键传下去,其余的键**不传**。
|
||||
|
||||
**这不是静默丢弃。** 绑定的首要消费者是参数快照——它的全部键值都进运行开始记录
|
||||
(`0006` 决策三),续跑时逐字段比对。也就是说那些键已经被记下来了,只是网关那边没有对应的
|
||||
格子。适配器再报一次错,等于要求项目为了适配一个网关而裁剪自己的坐标系。
|
||||
|
||||
**认不得的键不报错,也是因为报错的那条路更糟**:项目换一个网关就要改绑定,而绑定同时是
|
||||
续跑守卫的输入——改它会让所有在跑的运行续不上。
|
||||
|
||||
## 决策五:网关的异常原样穿出去
|
||||
|
||||
不捕获、不翻译、不重试。`session` 那一层已经定了模型调用失败怎么处置:记一条带失败说明的
|
||||
结果记录、记一步、以模型调用失败收尾(`0004` 决策三 C 档),而失败说明取的是异常的类名与文本
|
||||
——网关的异常类名(`AllSourcesExhausted`、`CircuitOpenError`、`GovernanceBackendError`……)
|
||||
本身就是最有用的那部分信息,翻译成我们自己的名字只会把它盖掉。
|
||||
|
||||
**重试尤其不能做。** 网关内部已经有重试、退避、换源、熔断,外面再套一层会让两套预算重叠计费
|
||||
——那正是网关自己在 1.1.1 里修掉的那类 bug。
|
||||
|
||||
`asyncio.CancelledError` 同样原样穿出去,它继承 `BaseException`,不会被任何 `except Exception`
|
||||
接住。
|
||||
|
||||
## 决策六:空串的调用标识映射成空值
|
||||
|
||||
`LLMResponse.call_id` 的类型是 `str`,而本库的 `ModelReply.call_id` 是 `str | None` 且**绝不为
|
||||
空串**——空串是个看起来合法的键,连表时静默匹配不上。所以拿到空串就映射成空值。
|
||||
|
||||
## 留给后续的
|
||||
|
||||
**响应里那些字段本库不带走,靠调用标识连过去。** 网关的响应有二十来个字段(用量、延迟、
|
||||
缓存命中、成本、供应商实际报告的模型串……),而 `ModelReply` 只取三个:调用标识、可见回复、
|
||||
推理段。其余的留在网关自己的账目里,两边靠调用标识连表。
|
||||
|
||||
这条对**可复现性**尤其要紧:`model_reported` 是供应商在响应体里报的模型串,它和配置里那个
|
||||
别名可能分叉(供应商把别名指向新权重时),而实验复现必须认这个串。它不进本库的轨迹,但它在
|
||||
网关的账目里,按调用标识连得上——这正是 `ModelReply.call_id` 那句「与账目之间的连接键」的
|
||||
用处。
|
||||
|
||||
**流式的中间事件本库拿不到,也不要。** 网关的 `chat` 默认走流式但返回的是一个完整响应;
|
||||
本库的接缝是「一次调用返回一次回复」,中间的增量属于观察通道,等事件集那份 design doc 定了
|
||||
再看要不要透出去。
|
||||
Reference in New Issue
Block a user