Files
PolyLoop/research-wiki/design/0012-gateway-model-client.md
T
iomgaa 4f3f43d218 docs(design): 按两轮硕士生冷读改 0011 与 0012
0011 最要紧的一条是文档和代码对不上:Codex 那轮把坏行判据从「第一条解不开的行」改成了「有没有
被换行终结」,文档还停在旧规则上。改完顺带答掉冷读问的「末尾连着两条坏行算什么」——按新规则
第一条终结过的坏行就已经报错了。

三处确定性矛盾全部成立:标题写「fsync 在三处,其余三处不做」而正文写「那两次」、表格里 fsync=否
只有两行(改成按「一步之内四次写」重排,并把「处」的单位说清);「五个记录类」里没有「动作
结果」(它是逐步结果那条记录的一个字段,不是第六个记录类,表格行名会误导);「每步四次写」与
表格看着像五次(同一根因)。另外契约套件的状态从「24 条全跳过」改成不给会过期的数字,并把
skip 与 xfail 分开说——它们是「还没有实现」与「没有机器兜底」两回事。

还补了:一节名词解释(前缀持久性、耐久屏障、恢复判定、⑥ 都是首次出现即使用);「取消能穿过去」
那段原本自相矛盾(说线程会把写做完,又说没写完的是尾行);文件的字面约定(UTF-8、\n 结尾、
非 ASCII 不转义、目录不存在时创建)——这些恰恰是外部读取方必须知道的,而文档反复强调那份日志
要能离开这个库读懂;为什么保留键叫 record 而不是加下划线前缀;为什么用 to_thread;独占创建
只挡住一种撞车(两个进程同时续跑挡不住,登记为已知缺口);以及运行开始那次 fsync 真正的理由
是目录项而不是「读不出配置」。

最实的一条留到最后:那五维里的题目很可能带中文或空格,过不了运行标识的字符判据,而同一份
文档又规定不做转义。现在写明编码方式归下游自己选(要单射),并说清库为什么不替它选——库一旦
选了,文件名就不再等于运行标识,而它按标识去目录里找文件的用法就断了。migrations/dissect.md
同步登记。

0012:把 parent_card_id 这个笔误改成 parent_call_id(冷读的人不知道哪个对,只能问「card 是
什么」,正好把它顶出来);「每个源报四样」实际枚举了五样;补一节名词解释(scope、源、恒定采样
参数、推理开关全是首次出现即使用);补上本库这一侧的接缝签名与 ModelCall 的五个字段,并说明
中间那三个为什么一个都不往下传;补上非文本块报错、拼接不加分隔符的代价、1.1.1 那个 bug 到底
是什么、空串调用标识是防御而不是常规路径、以及为什么超时与重试次数不算模型身份。
2026-08-10 04:06:16 -04:00

152 lines
10 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),而在这之前没有任何东西连它。
## 读本文需要的几个名字
**scope** 是网关里一组模型源的命名分组,也是它的治理单位——限流、熔断、缓存的账都按 scope
记。一次装配对应一个 scope。
**源(`SourceConfig`** 是这个分组里的一个具体端点:一组「供应商 + 地址 + 密钥 + 模型名 +
超时」。**多个源可以指向同一个模型**(同一个模型在两家中转上各配一份),网关在它们之间做
选择、限流与故障切换。
**恒定采样参数(`extra_body`** 是配置里写死、每次调用都并进请求体的那些(`temperature`
`top_p` 之类),相对的是每次调用可以覆盖的那一层。**推理开关(`enable_thinking`** 决定要不要
往请求体里注入开启/关闭推理的参数。两者的共同点是**它们会改变真正发出去的请求体**。
## 这份适配器要跨的那条缝
本库这一侧的接缝是 `polyloop.ports.ModelClient`,两个方法:
```python
async def call(self, call: ModelCall) -> ModelReply: ...
def parameters(self) -> Mapping[str, str]: ...
```
`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_call_id` 两个槽位放得下这类东西。适配器把这两个键传下去,其余的键**不传**。
**这不是静默丢弃。** 绑定的首要消费者是参数快照——它的全部键值都进运行开始记录
`0006` 决策三),续跑时逐字段比对。也就是说那些键已经被记下来了,只是网关那边没有对应的
格子。适配器再报一次错,等于要求项目为了适配一个网关而裁剪自己的坐标系。
**认不得的键不报错,也是因为报错的那条路更糟**:项目换一个网关就要改绑定,而绑定同时是
续跑守卫的输入——改它会让所有在跑的运行续不上。
## 决策五:网关的异常原样穿出去
不捕获、不翻译、不重试。`session` 那一层已经定了模型调用失败怎么处置:记一条带失败说明的
结果记录、记一步、以模型调用失败收尾(`0004` 决策三 C 档),而失败说明取的是异常的类名与文本
——网关的异常类名(`AllSourcesExhausted``CircuitOpenError``GovernanceBackendError`……)
本身就是最有用的那部分信息,翻译成我们自己的名字只会把它盖掉。
**重试尤其不能做。** 网关内部已经有重试、退避、换源、熔断,外面再套一层会让两套预算重叠计费。
那不是假想:网关 1.1.1 修的正是这一类——它的「卡死判定」窗口与重试预算同时对真实尝试计时,
而前者更小,于是配了三次重试的调用一次都用不上就被判死,且没有任何报错。两套预算叠在同一段
时间上,总有一套先耗尽,而先耗尽的那套说了算。
`asyncio.CancelledError` 同样原样穿出去,它继承 `BaseException`,不会被任何 `except Exception`
接住。
## 决策六:空串的调用标识映射成空值
`LLMResponse.call_id` 的类型是 `str`,而本库的 `ModelReply.call_id``str | None` 且**绝不为
空串**——空串是个看起来合法的键,连表时静默匹配不上。所以拿到空串就映射成空值。
**这是防御,不是常规路径。** 正常情况下网关每次调用都会给出一个标识;空串意味着它在记账之前
就失败了,而那种情况通常直接抛异常、走不到这里。留这一下是因为两边的类型不同宽——它那边是
`str`,本库这边把「没有」和「空串」分得开,而分得开的那个区别只有在这里才落得下去。
## 留给后续的
**响应里那些字段本库不带走,靠调用标识连过去。** 网关的响应有二十来个字段(用量、延迟、
缓存命中、成本、供应商实际报告的模型串……),而 `ModelReply` 只取三个:调用标识、可见回复、
推理段。其余的留在网关自己的账目里,两边靠调用标识连表。
这条对**可复现性**尤其要紧:`model_reported` 是供应商在响应体里报的模型串,它和配置里那个
别名可能分叉(供应商把别名指向新权重时),而实验复现必须认这个串。它不进本库的轨迹,但它在
网关的账目里,按调用标识连得上——这正是 `ModelReply.call_id` 那句「与账目之间的连接键」的
用处。
**流式的中间事件本库拿不到,也不要。** 网关的 `chat` 默认走流式但返回的是一个完整响应;
本库的接缝是「一次调用返回一次回复」,中间的增量属于观察通道,等事件集那份 design doc 定了
再看要不要透出去。