Files
PolyLoop/research-wiki/design/0012-gateway-model-client.md
T
iomgaa 39417a21ec feat(adapters): 落成网关适配器,十个模块全部有内容
design 0012(待确认)定六条:收一个已经装配好的客户端而不自己装配(治理参数按 §1.5 不归本库
管,而且项目常要在多个用途间共享同一个限流器和缓存);同时收那份配置只为算模型身份(客户端
把源列表与 scope 收在内部不公开);内容块按顺序拼成一个字符串不加分隔符;绑定里只有网关认得
的两个键往下传、其余留在参数快照里且不报错;网关异常原样穿出去不翻译不重试;空串的调用标识
映射成空值。

参数快照不用网关内部那个 build_model_fingerprint:它不在 __all__ 里(用它就得从子模块 import,
他们重排一次我们就断),而且它是为缓存键设计的、按模型名去重。续跑守卫怕的是「配置变了而我
没发现」,所以宁可更严——改一个源名也报出来,那意味着这次运行打的可能是另一个端点。

tests/integration/ 这一层第一次有内容:用的是网关真实的 GatewaySettings / LLMResponse /
异常类型,装配守卫也真的跑了,只把「真的发出去」那一下换成受控替身。没装 polyloop[gateway]
时整份文件跳过——一个因为可选依赖没装而常年红的套件会训练所有人忽略红。

环境:从 ~/Projects/PolyGateway(活版本 1.1.2,比 reference/ 那份 1.1.1 新)复制一份装进
conda 环境。没有配私有源,polygateway 不在任何可达的 index 上。
2026-08-10 03:57:50 -04:00

105 lines
6.7 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`,从它的 `scope``sources` 里读出来。每个源报四样:
源名、供应商、模型名,以及会改变请求体的那两项——恒定采样参数与推理开关。
**不用网关内部那个 `build_model_fingerprint`。** 它确实算的是「模型身份」,但它不在网关的
`__all__` 里,用它就得从子模块 import,而那是它的内部布局、他们重排一次我们就断。更要紧的是
**它是为缓存键设计的**:它按模型名去重(多个源同一个模型算一份),因为缓存怕的是「不同配置
读到同一份缓存」。续跑守卫怕的是另一件事——「配置变了而我没发现」,所以宁可更严:改一个源名
也该让它报出来,因为那意味着这次运行打的可能是另一个端点。
读的全是 `SourceConfig` 的公开字段,那个类在网关的 `__all__` 里。
**残留风险照实认下:客户端与配置必须真的是同一对。** 传一个客户端加另一份配置,指纹会说谎,
而续跑守卫就白设了。库验不了这件事——客户端不公开它是按哪份配置装的。这条写进那个类的
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 定了
再看要不要透出去。