docs(design): 落定网关转发改用保留前缀的方案
回答 GovDoc-SaaS 提的 issue #6,取代 0012 决策四。那一条的结论(只转发 session_id 与 parent_call_id)和它的理由(网关只有两个槽位)都不成立:0012 定于 2026-08-10,而依赖下界 polygateway>=1.1 里 registry 上唯一装得到的 1.1.1 发布于 08-06,那一版的 chat() 上已经有四个 str | None 的槽位。被挡在外面的 cache_namespace 恰恰是上游指定的租户隔离手段,挡掉之后两个 租户提交相同文本时,第二个会读到第一个的模型输出。 要换掉的不是那份白名单的取值,是「哪些键往下传由下游取的名字和网关取的名字偶然相同来决定」 这个机制——补成五个键只是把同一个陷阱重新上好膛。改成绑定里带 gateway. 前缀的键才转发,前缀 之后整段当 chat() 的关键字参数名。由此本库不必列举网关能接受哪些坐标维度,依赖下界照旧 >=1.1,<2,上游此后加维度也不用本库发版(限于取值是字符串的维度,绑定装不下别的类型)。 issue 给的三条路都不采纳,理由分别在决策一到决策三;meta 这一维不做,写在决策五。四条防御 (结构性参数、空串或纯空白、前缀后无参数名、不带前缀的两个历史裸键)都在 call() 里抛 ValueError,表现为一次「第 0 步就以 LLM_ERROR 收尾」的运行。 过了两轮硕士生冷读。第二轮抓出一处事实错误:初稿跟着 issue 写了「下游自己写的 ModelClient 契约套件覆盖不到」,而 polyloop.testing.ModelClientContract 1.0.2 起就随包发布,继承它就能跑, 取消传播恰恰是它覆盖的五条之一。背景那一段换成了成立的那条代价——两份网关适配器各自漂移。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
# Design 0017 · 网关适配器往下传哪些键
|
||||
|
||||
**日期** 2026-08-29 · **状态** 已接受(2026-08-29 项目负责人确认)
|
||||
|
||||
**回答** 实验室 Gitea 上 PolyLoop 仓库的 issue #6,标题是「转发白名单挡掉了 cache_namespace,
|
||||
而上游指定它做租户隔离」,提出者是下游项目 GovDoc-SaaS。
|
||||
|
||||
**取代** `0012-gateway-model-client.md` 决策四。那一条的结论(只传两个键)与它的理由(网关
|
||||
只有两个槽位)都不再成立,理由见背景。`0012` 的其余五条决策不受影响。
|
||||
|
||||
**触及** `../../src/polyloop/adapters/__init__.py`、
|
||||
`../../tests/integration/test_gateway_model_client.py`、`../../CHANGELOG.md`,以及
|
||||
`../../research-wiki/migrations/govdoc-saas.md`。逐处要改什么在文末的回写清单。
|
||||
|
||||
## 读本文需要的几个名字
|
||||
|
||||
**接缝** 是本库留给下游替换实现的接口点,一律是 `Protocol`。模型调用接缝
|
||||
(`polyloop.ports.ModelClient`)只有两个方法:发一次调用,以及上报自己的可复现参数。本文讲的
|
||||
`GatewayModelClient` 是这个接缝的一个实现,作用是把本库的一次调用翻译成 PolyGateway 的一次
|
||||
治理调用。
|
||||
|
||||
**装配分成两半。** 跨运行不变、可以并发复用的那一半是 `AgentDefinition`,四个接缝挂在它上面,
|
||||
模型调用接缝是其中之一。随每次运行变的那一半是 `RunRequest`,预算、工具、上下文、绑定挂在
|
||||
它上面。这条分界在决策三里是判据。
|
||||
|
||||
**绑定** 是下游项目自己的坐标,库不解释内容、原样透传给模型调用接缝。它在 `RunRequest` 上的
|
||||
字段名是 `model_binding`,类型 `Mapping[str, str]`,到了模型调用接缝手上叫 `ModelCall.binding`。
|
||||
**它进参数快照时的键前缀是 `request.binding.`,不是字段名那个 `model_binding`**——同一样东西
|
||||
在三处有三个写法。
|
||||
|
||||
**参数快照** 是一次运行开始时算出来、写进运行开始记录的一份字符串到字符串的映射,回答的是
|
||||
「这次运行是什么设置」。它汇的是绑定与配方指纹这两个请求字段,加上向四个接缝各问一次
|
||||
`parameters()` 得到的结果。**配方指纹**是挂在运行请求上的另一份字符串到字符串的映射,记的是
|
||||
这次运行用的材料是哪一版——提示词模板的哈希、技能库的版本这类;它和绑定的分界是「坐标还是
|
||||
配方版本」。**续跑**指的是一次运行崩了或被打断之后,用同一个运行标识接着往下跑;开工前会
|
||||
重算一次快照并与日志里那份逐字段比对,对不上就抛 `ParameterDriftError` 中止,免得跑出一条
|
||||
前后来自两套配置的轨迹。
|
||||
|
||||
**`parameters()`** 是接缝上那个上报可复现参数的方法。模型调用接缝这一侧,`GatewayModelClient`
|
||||
报的是 scope 与源列表。**scope** 是网关里一组模型源的命名分组,也是网关的治理单位——限流、
|
||||
熔断、缓存的账都按 scope 记。**源**是这个分组里的一个具体端点,一个源就是「供应商 + 地址 +
|
||||
模型名」那一组,多个源可以指向同一个模型。这两样合起来回答的是**这次运行用的是哪个模型、
|
||||
打到哪儿、带着哪些恒定采样参数**,这一整份叫「模型身份」。它进快照时的键前缀是
|
||||
`model_client.`。
|
||||
|
||||
网关 `chat()` 签名上的几个参数:
|
||||
|
||||
- **`cache_namespace`** 是缓存键的一段。缓存键由「模型指纹 + 消息摘要 + 命名空间」构成,
|
||||
命名空间不同的两次调用读不到彼此的缓存。**模型指纹**是网关自己算给缓存键用的那一份,按
|
||||
模型名去重;它和上面那个模型身份是两侧的两样东西,模型身份更严——改一个源名也要报出来。
|
||||
**不传不等于不缓存**:网关取值的写法是「本次调用给了就用本次的,没给就用网关装配时配的
|
||||
那个默认命名空间」,所以不传是「和所有人共用一格」。
|
||||
- **`cache_salt`** 是同一命名空间内再分一层的字符串。给同一批消息换一个盐,本该命中的调用
|
||||
就会打空、真的重新去问模型——需要同一批输入跑多轮且每轮都要真实调用的下游用得上它。
|
||||
- **`tenant_id`** 与 **`meta`** 是网关 1.3.0 起新增的两个调用方自定义维度,只进它的遥测、
|
||||
不进缓存键。前者是遥测表里的真实列(可挂行级安全、可进复合索引),后者是任意键值容器。
|
||||
- **`overlay`** 是采样参数覆盖层(`temperature`、`seed`、`max_tokens` 这类),优先级高于配置
|
||||
里那份恒定采样参数。**`structured`** 要求模型按一个给定的结构返回,网关会为此改写请求并在
|
||||
返回前校验、必要时重试。
|
||||
|
||||
## 背景
|
||||
|
||||
issue #6 指出的事实逐条核过都成立。
|
||||
|
||||
**适配器只往 `chat()` 传两个键。** `adapters/__init__.py` 里那份模块级白名单是
|
||||
`("session_id", "parent_call_id")`:绑定里键名与它们相同的那些被当作同名关键字参数传给
|
||||
`chat()`,其余的键留在参数快照里、不往下传。
|
||||
|
||||
**给出的理由是「网关只有两个槽位放得下这类东西」,而这句话在写下的那天就已经不成立。**
|
||||
`0012` 定于 2026-08-10;`pyproject.toml` 声明的依赖下界是 `polygateway>=1.1,<2`,而 1.1.x 里
|
||||
registry 上唯一存在的版本 1.1.1 发布于 2026-08-06——也就是说定 `0012` 的那天,装得到的最低
|
||||
版本上就已经有四个 `str | None` 的槽位:`session_id`、`parent_call_id`、`cache_salt`、
|
||||
`cache_namespace`。(1.1.0 有过版本号但从未上传,装不到,这正是 `CLAUDE.md` §1.10 记的那个
|
||||
教训。)所以这不是一条随上游演进而过期的判断,是当时就核错了。1.3.0 上又多了 `tenant_id` 与
|
||||
`meta`,1.1.1 上没有这两个。
|
||||
|
||||
**被挡掉的 `cache_namespace` 正是上游指定的租户隔离手段。** 网关自己把这件事写死了两处:
|
||||
网关装配时启用了缓存却没配命名空间,它直接拒绝装配,报错原文是「缓存 key 靠它做租户
|
||||
隔离」;`tenant_id` 的 docstring 明写它不进缓存键,租户隔离由 `cache_namespace` 负责。上游
|
||||
指派了一个机制来守这条边界,而经过本库之后那个机制传不进去。
|
||||
|
||||
失败场景具体:两个租户提交了内容相同的一段文字,两次调用的模型指纹、消息摘要、命名空间三项
|
||||
全部相同,于是第二个租户读到第一个租户那次的模型输出。这不是缓存效率变差,是跨租户读到别人
|
||||
的内容。这一条是本次判断里权重最大的那个事实。
|
||||
|
||||
**下游能绕过去,代价是多养一个适配器。** `ModelClient` 只有两个方法,下游自己写一个实现不
|
||||
难,而且不会因此掉出机器兜底:本库随包发布的公共契约套件里就有模型调用接缝那一份
|
||||
(`polyloop.testing.ModelClientContract`),自己写的实现继承它、覆盖 `model_client` 与
|
||||
`failing_call` 两个 fixture 就能跑。它守的是接缝层面的五条:返回三个字段、调用标识绝不为
|
||||
空串、失败以异常表达、取消要能穿过模型调用且 `CancelledError` 不许被吞、签名里不出现重试与
|
||||
限流参数——取消传播恰恰是套件覆盖了的那一条。
|
||||
|
||||
**套件覆盖不到的是翻译那一段。** 把本库的一次调用变成这个网关的一次调用——消息怎么拼、网关
|
||||
抛的异常怎么原样穿出、调用标识怎么从返回里取、模型身份怎么算——是这个适配器特有的行为,不是
|
||||
接缝对所有实现的承诺。所以任何一个自己写的网关适配器,在这部分没有机器兜底。
|
||||
|
||||
**真正的代价是两份适配器各自漂移**,这一条是提出者自己写的:他们会长期维护第二个网关适配器,
|
||||
和本库自带的那个唯一区别是多传几个参数;两份会各自往前走,而漂移的那天不会有任何东西提示。
|
||||
|
||||
issue 提了三条可能的做法:补白名单、把白名单做成构造参数、把这几个值做成构造参数。三条都不
|
||||
采纳。
|
||||
|
||||
## 决策一:转发不再按名字撞,改成绑定里的保留前缀
|
||||
|
||||
绑定里键名以 `gateway.` 开头的,前缀之后那一整段当作 `chat()` 的关键字参数名,值原样传下去。
|
||||
其余的键照旧不传、不报错。
|
||||
|
||||
```python
|
||||
model_binding={
|
||||
"book": "b7", # 项目自己的坐标,不传
|
||||
"gateway.cache_namespace": "acme:v1:tenant:x7", # 传成 chat(cache_namespace="acme:v1:tenant:x7")
|
||||
"gateway.tenant_id": "x7",
|
||||
}
|
||||
```
|
||||
|
||||
**前缀之后不再解析,点号也算参数名的一部分。** `gateway.meta.foo` 交给 `chat()` 的参数名是
|
||||
`meta.foo`,网关签名上没有这个名字,于是抛 `TypeError`。绑定这一侧不为点号编一层嵌套,理由
|
||||
在决策五。
|
||||
|
||||
**其余的键不报错,这是一条有意划的线。** 本库不知道下游的坐标该叫什么,所以对它不认识的键
|
||||
一律保持沉默——一个叫 `book` 的坐标不是一次写错了的转发。唯一的例外是 `session_id` 与
|
||||
`parent_call_id`:这两个名字今天真的会被转发,对它们保持沉默等于静默改变行为,所以它们要
|
||||
报错,改法见决策四第四条。
|
||||
|
||||
**要换掉的是「哪些键往下传」由两个命名空间的名字偶然相同来决定这件事。** 绑定这一侧的名字由
|
||||
下游取,`chat()` 那一侧的名字由网关取,白名单让两边撞名的那些自动接通。这个机制在两个方向都
|
||||
会坏:下游随手起一个叫 `tenant_id` 的坐标就被静默传下去;上游加一个参数,本库就得改一次
|
||||
常量,而漏改的表现是「这个参数传不进去」——issue #6 就是这么来的。**issue 的第一条路(把白
|
||||
名单从两个键补到五个)只改了那份常量的取值,没有改这个机制**,等于把同一个陷阱重新上好膛,
|
||||
下一个新参数出现时会再来一次。
|
||||
|
||||
前缀让转发变成下游显式声明的:不写前缀就不会往下传,写了就说明是有意的。它的直接后果是本库
|
||||
不必列举**网关能接受哪些坐标维度**,代码里也不必出现 `cache_namespace` 这个名字。本库手上
|
||||
仍然有一份网关参数名的清单,但那是另一份东西——决策四第一条那份「不许从绑定走的结构性参数」,
|
||||
四个名字,在依赖下界那一版上就已经存在,不随上游新增维度而增长。会随上游长的是坐标维度那
|
||||
份名单,而那份追不动,被拿掉的正是它。由此得到三件事:
|
||||
|
||||
**依赖下界不用抬,靠的正是「不列举坐标维度」这一件事。** `tenant_id` 只在 1.3.0 之后存在,
|
||||
本库若把它写进白名单常量,声明的下界就得从 `>=1.1` 抬到 `>=1.3`,于是每个下游都得跟着升
|
||||
网关——包括那些根本不需要这个维度的。用前缀的话本库一个坐标维度的名字都没提,下界照旧
|
||||
`>=1.1,<2`:网关支持的那个下游传得进去,装着旧网关的那个会拿到 `TypeError`,而错误信息里
|
||||
带着参数名。上游此后再加维度也是同一个待遇,本库不发版。这条免疫只覆盖取值是字符串的新
|
||||
维度:绑定的类型是 `Mapping[str, str]`,装不下别的,网关哪天加一个取 `int`、`bool` 或者
|
||||
嵌套映射的参数,前缀这条路传不了它。这个限制可以接受——调用方坐标这一类维度天然是字符串
|
||||
标识,租户、会话、命名空间、盐写出来都是名字;真出现一个非字符串的新维度,那是一次新的设计
|
||||
(放宽绑定的取值类型,或者另开一条通道),不是这个机制上的一个漏洞。
|
||||
|
||||
**转发的键照旧全程进参数快照。** 键名不变,`gateway.cache_namespace` 在快照里是
|
||||
`request.binding.gateway.cache_namespace`。所以「这次运行用的是哪个命名空间」事后查得到,
|
||||
而换一个命名空间续跑会撞 `ParameterDriftError`——那正是最该炸的一次,因为续跑读到的可能是
|
||||
另一个租户的缓存。
|
||||
|
||||
**升级不改变现存运行的行为。** 前缀今天不被任何东西解释,带前缀的键在今天只是一个普通坐标,
|
||||
所以装上新版之后往网关传的东西不变。补白名单则相反:一个绑定里本来就有 `tenant_id` 的下游,
|
||||
升级当天行为就变了,而快照里那一项一个字没改,续跑守卫也不会响——一次静默的行为变更,正是
|
||||
本库最怕的形态。
|
||||
|
||||
**残留风险照实认下:今天真有一个叫 `gateway.<某某>` 的坐标的下游,升级后它会被往下传。**
|
||||
这个风险以「装上就炸」的形态出现而不是静默扩散——网关不认得那个参数名会当场抛 `TypeError`,
|
||||
除非那个名字恰好也是网关的参数名,而那种巧合要同时撞中两层。
|
||||
|
||||
**前缀取 `gateway.` 而不是别的写法**,因为它说的正是「这个键是给网关的」,而读到它的人手上
|
||||
拿的就是网关适配器。更不容易撞的写法(加下划线、加更长的限定串)换来的是每次都要多打几个字
|
||||
和一个记不住的拼写,而撞名的代价上一段已经认下了。
|
||||
|
||||
**代价:`gateway.` 从此是绑定里的保留前缀。** 一个真的想用这个前缀当自己坐标的下游没法这么
|
||||
取名了。接受,因为绑定的键名本来就由下游自己定,改一个名字的成本是一行。
|
||||
|
||||
## 决策二:为什么不是「白名单做成构造参数」
|
||||
|
||||
issue 的第二条路是让调用方决定转发哪些键。它没有解决决策一说的那件事——转发仍然按名字撞,
|
||||
只是撞的范围可配。而且它多出一个后果:读一份参数快照不再能回答「网关那次收到了什么」,因为
|
||||
答案同时取决于绑定和构造时那份配置,而后者不在快照里。把它也塞进 `parameters()` 能补上,但
|
||||
那是为一个不必要的旋钮再加一层机制。
|
||||
|
||||
## 决策三:为什么不是「做成 `GatewayModelClient` 的构造参数」
|
||||
|
||||
issue 的第三条路(提出者自己最倾向的那条)是把这几个值直接做成适配器的构造参数。它和装配的
|
||||
两半相冲,而且冲的地方正是这次要传的那个值。
|
||||
|
||||
`GatewayModelClient` 挂在 `AgentDefinition` 上,也就是跨运行不变、可以并发复用的那一半。租户
|
||||
标识与租户命名空间恰恰**每次运行都可能不同**——一个多租户服务里,一次运行属于一个租户。把它
|
||||
放到构造参数上,等于要求每个租户各装配一份定义,而定义那一半存在的理由就是它不随运行变。
|
||||
|
||||
本库已经有一条专门的每次运行通道,就是绑定。租户坐标是坐标,走坐标那条路。
|
||||
|
||||
(提出者自己也写了「跨运行基本不变,`tenant_id` 除外」。那个例外不是边角,它是这次需求的主体。)
|
||||
|
||||
## 决策四:四条防御
|
||||
|
||||
前缀让下游能把任意参数名传给 `chat()`,所以适配器要挡住几类明显不该这么传的东西。挡不住的
|
||||
那些原样交给网关,由它抛 `TypeError`——错误信息里有参数名,比本库自己编一条更有用。
|
||||
|
||||
### 一、会改变请求本身的参数名一律拒绝
|
||||
|
||||
`messages`、`stream`、`structured`、`overlay` 四个。
|
||||
|
||||
**判据是「这个参数有没有一个已经存在的权威」,不是「它重不重要」。** 模型身份那份快照
|
||||
(`parameters()` 报的 scope 与源列表,含配置里的恒定采样参数)是「这次运行的模型行为是什么」
|
||||
的权威。`overlay` 改的是同一件事,从绑定走等于让同一件事有两处记录,而其中一处不是权威——
|
||||
`0012` 决策二把采样参数算进模型身份,为的就是「temperature 从 0 改成 1 之后续跑,模型的行为
|
||||
变了而轨迹上看不出来」,从绑定塞 `overlay` 会把那道守卫从旁边绕过去。`structured` 会让网关
|
||||
改写请求并按结构校验、必要时重试,`stream` 与 `messages` 决定发出去什么,同理。
|
||||
|
||||
**缓存维度(`cache_namespace`、`cache_salt`)不落在这一类,因为没有第二个地方记它们。**
|
||||
它们改变的是「这次调用会不会真的发出去、读到谁的那一份结果」,而这件事在本库这边只有绑定
|
||||
一处记录,逐字段进快照、逐字段守。所以它们是坐标,不是结构。
|
||||
|
||||
这份拒绝清单也会过期,但它过期的后果比今天那份软一档:漏掉一个新的结构性参数,表现是「该拦
|
||||
的没拦住」,而且要下游主动写出那个名字才会发生;今天那份漏掉一个新参数,表现是「该传的传不
|
||||
了」,下游什么都不做就中招。
|
||||
|
||||
### 二、取值是空串或纯空白时拒绝,带前缀的键一律如此
|
||||
|
||||
网关那边取命名空间的写法是「本次调用给了就用本次的,没给就用默认的」,而空串在 Python 里是
|
||||
假值——所以 `gateway.cache_namespace=""` 会静默落回默认命名空间,也就是悄悄关掉隔离。绑定
|
||||
那一侧的校验只管键和值都得是字符串,管不到空串。
|
||||
|
||||
**纯空白(`" "`、`"\t"`)比空串更坏,所以一起拒。** 空白在网关那边是真值,会被原样当成一个
|
||||
命名空间用——于是隔离看起来成立,实际是所有拿到这份坏配置的租户共用同一格,而这正是本文要
|
||||
修的那个跨租户串读场景换了个入口。空串至少还会落回默认命名空间那条众所周知的路,空白连这条
|
||||
路都不走。
|
||||
|
||||
**判据是这个取值带不带信息,不是这个命名空间格式对不对。** 空串与纯空白都不带,所以拒得掉;
|
||||
`"acme:v1:tenant:"`(租户标识拼空了)带信息但内容是错的,本库拦不住,也不该拦——它不解释绑定
|
||||
的取值,一旦开始判断「什么样的命名空间算合法」,就等于替下游定了编码规则。这条线划在这里是
|
||||
有意的,它挡的是「一个看着配了、实际什么都没配的隔离」,不是「配错了的隔离」。
|
||||
|
||||
**这一条只管带前缀的键。** 不带前缀的键根本到不了网关,它们的取值是下游自己的坐标,空不空
|
||||
由下游自己判——库对它们唯一的要求是「键和值都得是字符串」,那一条在别处已经有了。
|
||||
|
||||
### 三、前缀后面没有参数名时拒绝
|
||||
|
||||
键恰好是 `gateway.` 时,剥掉前缀之后是一个空的参数名。**这一条不补任何漏洞**:真交下去,
|
||||
`chat()` 的签名是纯关键字、没有 `**kwargs`,网关照样会抛 `TypeError`。它换掉的只是错误信息
|
||||
——`TypeError: chat() got an unexpected keyword argument ''` 说不出问题出在绑定的哪个键上,
|
||||
而这是一个纯粹的手滑,报错该直接指着那个键说「前缀后面要跟一个参数名」。
|
||||
|
||||
交给网关是默认,本库只在自己能给出明显更有用的信息时才拦。
|
||||
|
||||
### 四、不带前缀的 `session_id` 与 `parent_call_id` 拒绝,并在错误信息里给出改法
|
||||
|
||||
这两个键今天会被转发,前缀落地之后不再转发。直接静默不传是又一次静默的行为变更——下游的
|
||||
网关遥测会悄悄不再按会话分组,而没有任何东西会提示。报错则要求它把键名改成
|
||||
`gateway.session_id`,一行的事。这份清单只有历史遗留的那两个,此后永不增长——只有这两个键
|
||||
曾经被真的转发过,所以需要迁移提示的名字集合是封闭的,不会有第三个。下一个 major 可以整条
|
||||
删掉。
|
||||
|
||||
**不设弃用期(先警告一版、下一版再报错)**,因为弃用期要求这一版继续按旧机制转发这两个键,
|
||||
也就是把决策一要拆掉的那个撞名机制再留一个版本;而警告是可以被忽略的,日志里多一行
|
||||
`DeprecationWarning` 在一个跑批量运行的进程里没人会看见。用一次响亮的失败换掉一段没人读的
|
||||
警告,代价是撞上的人要改一行,收益是这一版之后再没有第二套转发机制活着。
|
||||
|
||||
### 这四条报错时会发生什么
|
||||
|
||||
绑定要到一次模型调用发生时才到适配器手上,所以这四条只能在 `call()` 里判,判不到构造那一刻。
|
||||
而 `session`——本库里驱动一次运行的那个模块,和网关那个 `session_id` 只是撞名——捕获模型
|
||||
调用接缝抛出的任何异常,写一条带失败说明的结果记录、记一步、以 `StopReason.LLM_ERROR` 收尾。
|
||||
也就是说这四条报的 `ValueError` 不会穿出 `run()`,它表现为一次「第 0 步就以模型调用失败
|
||||
结束」的运行,失败说明里带着 `ValueError` 这个类名和出问题的那个键名。
|
||||
|
||||
**判断发生在把请求交给网关之前**,所以出错的那次调用一次都没发出去:转发出来的那份关键字
|
||||
参数是 `chat()` 的实参,Python 求值完全部实参才进函数体。
|
||||
|
||||
**这个形态可以接受,因为绑定一次运行内不变**:错了就是第一次调用就错,不存在跑到一半才炸,
|
||||
也没有任何预算被花掉。代价是这次运行在下游的统计里落在「模型调用失败」那一格而不是「配置
|
||||
写错了」,要读失败说明才分得开——这一点和 `0016` 决策二说的那件事同族。那一条定的是:动作
|
||||
执行接缝抛异常时库不捕获,也不替它编一个结算结果,因为异常抛出的那一刻副作用发生没发生是
|
||||
未知的,库编一个出来就是把「不知道」改写成「知道,而且是这个值」。区别是那里库有得选(可以
|
||||
不接管,让异常穿出去),这里没得选——模型调用接缝的异常怎么处置早就定死了,而适配器没有
|
||||
更早的位置可以判。
|
||||
|
||||
## 决策五:`meta` 这一维不做
|
||||
|
||||
`meta` 的类型是 `Mapping[str, Any]`,而绑定是 `Mapping[str, str]`,装不下。要装下得二选一:
|
||||
给绑定加一层嵌套编码(`gateway.meta.<key>` 这类),或者放宽绑定的取值类型。后者动的是公共
|
||||
类型,而绑定被定成字符串映射是为了让它能逐字段进参数快照——`0006` 里那句「不透明对象是公共
|
||||
签名上一个永久的洞」说的就是这件事。前者是为一条需求引入第二套解析规则。
|
||||
|
||||
提出者自己把这一维标成最低优先级,说明的用途是带一个自己的 trace 标识。那个用
|
||||
`gateway.session_id` 就能带:网关那边 `session_id` 本来就是一个用来分组的字符串,而本库不往
|
||||
里面填任何东西——按 `0012` 那句「项目想让网关按运行分组,把它要的那个键放进绑定里」,这个
|
||||
槽位一直是留给下游的。
|
||||
|
||||
不做 `meta` 是一次决定,不是漏掉的一维。提出者明确要求「如果你们认为这不该由库来做,请在
|
||||
issue 里说一声」,所以 issue #6 的回复里要写上这一条。
|
||||
|
||||
## 公共契约套件不动
|
||||
|
||||
转发是这个适配器的行为,不是 `ModelClient` 这个接缝对所有实现的承诺——另一个下游写的实现接的
|
||||
可能根本不是这个网关,对它断言 `gateway.` 前缀没有意义。
|
||||
|
||||
真正的缺口在别处:一个下游被逼着自己写网关适配器时,它重写的正是套件覆盖不到的那一段翻译
|
||||
逻辑,而两份适配器此后会各自漂移。这一版之后那个前提没了——需要租户隔离的下游不必再自己写
|
||||
实现,留在自带适配器上就能把命名空间传下去,而自带适配器的转发行为由 `tests/integration/`
|
||||
里的用例钉着。**缺口是靠「消除下游离开这条路的理由」补上的,不是靠给套件加一条断言。**
|
||||
|
||||
**issue #6 末尾那个附带请求已经满足了。** 提出者问的是「能不能让准入契约套件也能套在自己写
|
||||
的 `ModelClient` 实现上」——`polyloop.testing.ModelClientContract` 就是它,1.0.2 起随包发布,
|
||||
继承它、覆盖两个 fixture 即可。
|
||||
|
||||
## 版本:1.0.3
|
||||
|
||||
裸 `session_id` 从「静默转发」变成「报错」是一次破坏性的行为变更,按语义化版本的字面它不该
|
||||
落在补丁号上。号是项目负责人拍的板,没有附理由;下面三条是这个选择为什么可以承受:
|
||||
|
||||
- 旧行为本身是缺陷。按名字撞的转发从来没有被任何人显式选择过,它是 `0012` 那个核错的前提留下
|
||||
来的;
|
||||
- 没有已发布的消费者依赖它。两个下游都在重建中,本库 1.0.2 发布于 2026-08-27,两天前;
|
||||
- 失败是响亮的。撞上的人拿到一条带迁移写法的 `ValueError`,不是一次静默的行为改变。
|
||||
|
||||
**次版本号按同样这三条一样安全**,而且不和语义化版本的字面打架,所以 1.1.0 是一个真的选项。
|
||||
取舍在别的地方:这次改动没有给库添任何新能力,它修的是一条本来就写错了的转发规则,用次版本
|
||||
号会把一次修缺陷宣传成一次加特性;而读到次版本号的人更容易判断「这一版我可以先不升」,可
|
||||
这一版恰恰是每个多租户消费者都该升的。落在补丁号是拍板的结果,上面那三条与这段取舍都是事后补的论证。
|
||||
|
||||
**残留风险照实认下**:万一有本次不知道的消费者正在依赖裸键转发,它会在一次补丁升级上撞到
|
||||
`ValueError`。用报错而不是静默不传,就是为了让这个风险以「装上就炸」而不是「跑了三个月才
|
||||
发现遥测一直没分组」的形态出现。
|
||||
|
||||
## 回写清单
|
||||
|
||||
**不回写下面这几处,本文就是死的**——写适配器的人读的是 docstring,而它现在还写着「网关只有
|
||||
两个槽位」。
|
||||
|
||||
- `adapters/__init__.py` 的模块常量:白名单换成前缀常量、结构性参数拒绝清单、历史裸键清单;
|
||||
- `_forwarded_binding` 的实现与 docstring:那句「网关只有两个槽位放得下这类东西」是本文推翻
|
||||
的那条,改成决策一的说法,并指向本文;
|
||||
- `GatewayModelClient` 的类 docstring:加一段说明保留前缀怎么用,含一个例子;
|
||||
- `tests/integration/test_gateway_model_client.py` 里钉住旧行为那条用例:改写成前缀语义,另加
|
||||
四条防御各一条;
|
||||
- `CHANGELOG.md`:单开一节写迁移写法,因为它是破坏性变更落在补丁号上;
|
||||
- `migrations/govdoc-saas.md`:那边要写的是租户命名空间怎么传;
|
||||
- `tools/soak/run_soak.py` 里那份空绑定上方的注释:它把「绑定为什么留空」归给了转发规则,
|
||||
而真正的理由是全部键值都进参数快照、每批不同的值会报假漂移,和转发哪些键无关。
|
||||
|
||||
## 留给后续的
|
||||
|
||||
**本库仍然不往网关的任何槽位里自动填东西。** 运行标识 `run_id`(续跑就是拿着它接着往下跑
|
||||
的那个)不会被自动填进 `session_id`:那个槽位是下游的,本库填进去就会和下游自己的会话概念
|
||||
撞,而撞了之后先写的那个赢。要按运行分组的下游自己写 `gateway.session_id`。
|
||||
|
||||
**`cache_salt` 顺带通了,没有为它写一行代码。** 需要同一批消息跑多轮、每轮都真的重新调用的
|
||||
下游写 `gateway.cache_salt` 就行。这是决策一那种「不列举坐标维度」的做法白拿的收益:白名单
|
||||
方案得为它多列一个名字,还得先判断该不该列。
|
||||
Reference in New Issue
Block a user