④ 与 ⑤ 都勾上但各自注明了欠账:e2e 那一层还是空的(它要打真实模型网关);事件出口没有调用 点(Event 还没有字段);stores 只有逐行追加那一种形态。勾上是因为骨架与十个模块确实都落地 了,注明欠账是因为清单是进度的权威处,含糊会让人以为这两件事已经做完。
10 KiB
Design 0012 · 模型调用接缝的网关适配器
日期 2026-08-10 · 状态 已接受(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,两个方法:
async def call(self, call: ModelCall) -> ModelReply: ...
def parameters(self) -> Mapping[str, str]: ...
ModelCall 有五个字段:消息序列、本次运行内的调用序号、运行标识、库预分配的结果标识、以及
项目自己的绑定。ModelReply 只有三个:调用标识、可见回复、推理段。
另一边是网关的 GatewayClient.chat:消息是 list[dict[str, Any]],返回一个有二十来个字段的
LLMResponse,失败抛一族它自己的异常。
缝两边的形状都不归我们定,所以这份文档定的全是「怎么对上」,不是「该长什么样」。下面 决策三管消息、决策四管绑定;中间那三个字段一个都不往下传——调用序号、运行标识、结果标识 都是本库自己的坐标,网关有它自己的调用标识,两边靠那个标识连表(见文末)。项目想让网关按 运行分组,把它要的那个键放进绑定里,那条路是通的。
决策一:收一个已经装配好的客户端,不自己装配
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 定了
再看要不要透出去。