Files
PolyLoop/research-wiki/design/0012-gateway-model-client.md
T
iomgaa aeb575e0f7 fix(stores): 修 Codex 对抗审查报的五条,其中两条同一根因
最实的一条:读取端只要一段能解析成 JSON 就收下,没检查它后面有没有换行。而短写完全可能
正好写完整个 JSON 对象、只差那个换行——那次写从来没被确认过(调用方的 await 还没返回),
按契约就是「没发生」,但它会被当成一条有效的动作意图读回来,恢复据此判成「状态未知」并可能
重放,而那个动作一定没执行过(调用方是在写意图返回之后才去执行的)。

判据改成「这一行有没有被换行终结」,不是「能不能解析」。同一个改动顺带修掉第三条:一行完整
终结的坏行(比如被外部追加的 {})此前会被当成撕裂尾行吞掉,读成「少了一条记录但看起来完整」
的日志;现在终结过的行解不开就是损坏,直接报错。

其余三条:
- 新建日志文件不 fsync 父目录。os.fsync(fd) 刷的是文件内容,刷不到「这个目录里多了一个
  文件」这条目录项;掉电后内容可能在而文件不存在,read_log 走「文件不存在」返回空日志,
  驱动入口判成全新运行,一次已经花过钱的运行静默没了留痕。只在新建时刷。
- 同一运行标识上的并发写会交错:一条记录可能由不止一次 os.write 写完,而 O_APPEND 只保证
  每次 write 的追加位置原子,保证不了一条逻辑行整体原子。按运行标识加锁串起来(不同运行
  照样并行),跨进程那一半仍靠独占创建挡。有一条用短写逼出那个窗口的测试。
- 往返测试的 TOTAL_WRITES 是硬编码,而且漏写结束标记它发现不了(恢复会把最后一步之后那次
  停止判定重演一遍,得出同样结果)。加一条把十次写的记录类型序列整个钉死的测试。
2026-08-10 03:52:35 -04:00

101 lines
6.4 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.
# 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 定了
再看要不要透出去。