转发白名单挡掉了 cache_namespace,而上游指定它做租户隔离 #6
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
我们是谁
GovDoc-SaaS —— 一个多租户文书分析服务,正在重建,要接 PolyLoop 当 Agent 内核。
我们只提需求、不改这个仓库。下面每条行号都核过了(PolyLoop 读的是 main 上的
72cfba5,PolyGateway 读的是 tagv1.1.1与v1.3.2)。问题
GatewayModelClient往chat()转发的键被一份模块级白名单钉死成两个,其中
cache_namespace这一个从来没有被放行过,而它在你们自己声明的PolyGateway 下界上就已经存在了。
src/polyloop/adapters/__init__.py:22-23:src/polyloop/adapters/__init__.py:110-118的 docstring 给的理由是:这句话在写下的那一刻就不成立。
pyproject.toml:23声明的下界是polygateway>=1.1,<2,而v1.1.1的chat()(src/polygateway/client.py)已经是这个签名:
也就是说,在下界上就有四个
str | None的槽位放得下这类坐标(
session_id、parent_call_id、cache_salt、cache_namespace),不是两个。
1.3.0 起又多了两个:
tenant_id: str | None和meta: Mapping[str, Any] | None(
v1.3.2的client.py:281-294)。现在的行为由
tests/integration/test_gateway_model_client.py:144-155钉死:传
{"session_id": "s1", "book": "b7", "task": "t3"},断言kwargs == {"session_id": "s1"}。为什么我们需要
cache_namespace它是我们唯一的租户隔离手段,而不是一个可选的优化。
这一点 PolyGateway 自己已经写死了。
v1.3.2的client.py:301-303:也就是说 PolyGateway 的架构文档指定了
cache_namespace做租户隔离,而 PolyLoop 恰好把它挡在了白名单外面——上游指定的那个机制,
在经过 PolyLoop 之后就用不上了。
我们的设计记录里定死了这个编码:
传不进去的后果不是缓存效率变差,是跨租户读到别人的文书内容:
两个租户提交了内容相同的一段文字,第二个会拿回第一个那次的模型输出。
在我们这个场景里那是最不能接受的一类故障。
顺带说明我们为什么不能靠「把缓存关掉」绕过去:关掉之后隔离确实成立,但那等于
用一个我们迟早要打开的开关换掉一道边界,而重新打开它的那天没有任何东西会提醒
我们这道边界不在。
tenant_id我们同样要——1.3.x 的遥测表里它是真实列,可以挂行级安全;缺了它,用量遥测按租户归因不了。
meta优先级最低,我们只想用它带一个自己的 trace 标识。绕过去的代价
我们能绕:
ModelClient这个 Protocol 只有两个方法,自己写一个实现不难。所以这不是死结。
代价是推翻我们自己已经批准的一条决定。我们的设计记录里有一条
「模型客户端用 PolyLoop 自带的那个,不自己写」,理由正是你们 docstring 里写的
那件事——它不捕获、不翻译、不重试,多一层就是多半套治理。绕过去之后我们会维护
第二个网关适配器,而它和你们那个的唯一区别是多传三个参数;两份会各自漂移,
漂移的那天不会有任何提示。
还有一层:我们自己写的那个实现,你们的下游准入契约套件覆盖不到。
一个可能的做法(不强加)
三个参数的性质不一样,可能要分开处理:
cache_namespace和tenant_id都是str,binding现在的类型(
Mapping[str, str],src/polyloop/ports/__init__.py:57)放得下,加进
_FORWARDED_BINDING_KEYS就够。meta是Mapping[str, Any],塞不进Mapping[str, str],得另开一条路。形状上想到三种,哪种更合你们的判断你们定:
meta单独想办法;GatewayModelClient的构造参数,由调用方决定转发哪些;tenant_id除外),所以也可以不走binding,直接做成
GatewayModelClient的构造参数。第 3 种对我们最好用,但它会让这个适配器多认识几个网关概念,可能和你们
「适配器只做翻译」的定位有张力——这一点我们判断不了。
如果你们认为这不该由库来做
比如你们判断「转发哪些键是下游自己的事,下游该写自己的
ModelClient」——那也请在 issue 里说一声,我们照做。我们只是想确认这是个有意的决定,
而不是那份白名单跟着一个已经过期的前提留下来的。
如果确实要我们自己写,有一个附带请求:能不能让准入契约套件也能套在自己写的
ModelClient实现上?那样至少「取消能不能穿透」这类事情还有机器管着。1.0.3 已发布,registry 上有
polyloop-1.0.3-py3-none-any.whl与 sdist。核过的结论:你们说的三条事实全成立,而且有一条比你们写的更重——给出那份白名单的理由在写下的那天就已经不成立。0012定于 2026-08-10,而polygateway>=1.1里 registry 上唯一装得到的 1.1.1 发布于 08-06,那一版的chat()上就已经有四个槽位。所以这不是随上游演进而过期,是当时核错了。做法和你们给的三条都不一样
没有补白名单,是把「按名字撞」这个机制换掉了。 补成五个键只改了那份常量的取值:转发仍然靠「你们取的键名」和「网关取的参数名」偶然相同来决定,下一个新参数出现时会再来一次这个 issue。
新规则是绑定里键名以
gateway.开头的才转发,前缀之后整段当chat()的关键字参数名:你们那条「我们判断不了这会不会和你们『适配器只做翻译』的定位有张力」——判断是:翻译不需要认识对方的词汇表。前缀之后那个名字本库一个字都不解释,认不认得由网关决定,不认得就抛
TypeError。由此三件事,第一件对你们最实际:
依赖下界没动,还是
>=1.1,<2。 如果按第一条路把tenant_id写进白名单常量,本库就得抬到>=1.3,于是每个下游都得跟着升网关——包括不需要这个维度的。现在是各自装各自的:你们的网关 ≥1.3.0 就能用gateway.tenant_id,别人装着 1.1.x 也不受影响。升级不改变任何现存运行的行为。 补白名单则相反:一个绑定里本来就有
tenant_id的下游,升级当天行为就变了,而快照里那一项一个字没改、续跑守卫也不会响。转发的键照旧全部进参数快照,键名不变:
request.binding.gateway.cache_namespace。所以换一个命名空间续跑会撞ParameterDriftError——那正是最该炸的一次,因为续跑读到的可能是另一个租户的缓存。四条防御,都抛
ValueErrormessages/stream/structured/overlay—— 这些改变请求本身,另有权威(overlay尤其:从绑定走会绕开parameters()那份模型身份,而续跑守卫盯的就是它);"govdoc:v1:tenant:"(租户标识拼空了)本库拦不住也不该拦;gateway.、后面没跟参数名;session_id与parent_call_id—— 见下。判断都在把请求交给网关之前完成,出错那次调用一次都没发出去。
你们要动的一处
绑定里不带前缀的
session_id/parent_call_id现在会抛ValueError,错误信息里直接给出gateway.session_id这个改法。这是一次破坏性的行为变更,落在补丁号上:旧行为本身是缺陷,没有已发布的消费者依赖它,而且失败是响亮的。静默不传是又一次静默的行为变更——你们的网关遥测会悄悄不再按会话分组,而没有任何东西会提示。meta不做,这是有意的决定它是
Mapping[str, Any],绑定是Mapping[str, str],装不下。要装下得给绑定加一层嵌套编码,或者放宽它的取值类型——后者动的是公共类型,而绑定被定成字符串映射就是为了让它能逐字段进参数快照。你们说
meta只想带一个自己的 trace 标识,那个用gateway.session_id就能带:网关那边session_id本来就是一个用来分组的字符串,而本库不往里面填任何东西,这个槽位一直留给下游。有一条要纠正你们
你们写「我们自己写的那个实现,你们的下游准入契约套件覆盖不到」,以及末尾那个附带请求「能不能让准入契约套件也能套在自己写的 ModelClient 实现上」——这条不成立,而且那个请求 1.0.2 就已经满足了。
polyloop.testing.ModelClientContract随包发布,你们自己写的实现继承它、覆盖model_client与failing_call两个 fixture 就能跑。它守五条:返回三个字段、调用标识绝不为空串、失败以异常表达、取消要能穿过模型调用且CancelledError不许被吞、签名里不出现重试与限流参数。你们最担心的取消传播恰恰是被覆盖的那条。套件真正覆盖不到的是翻译那一段——消息怎么拼、网关抛的异常怎么原样穿出、调用标识怎么取、模型身份怎么算——那是这个适配器特有的行为,不是接缝对所有实现的承诺。所以你们说的真代价是成立的那条:两份适配器各自漂移,而漂移的那天不会有任何提示。这一版之后你们不必再写第二份。
其它
方案与被否掉的三条路在
research-wiki/design/0017-gateway-forwarding.md(取代0012决策四),你们那边要做什么记在research-wiki/migrations/govdoc-saas.md。这一版发出去之前跑了一轮完整压测,与 1.0.2 逐项可比:正常负载 400 次运行、3501 次真实模型调用,十一条不变量零击穿;九类故障注入 58 条判据全部通过。
有一件事顺带被验到了:第一次开跑那轮撞上模型中转连返 503,网关熔断打开,400 次运行全部快速失败——库这一侧每个 run 记一条带失败说明的结果记录、以
llm_error收尾,十一条不变量一条没击穿。那是拿真实网关故障验的,不是故障注入。形状不合用就直接重开这个 issue。
补一条核实:1.0.3 的前缀方案对 cache_namespace 与 tenant_id 都成立——两个都是 str,进得了 Mapping[str, str] 的绑定。meta 我们这次用不到(trace 标识走 session_id),所以 Mapping[str, Any] 进不了绑定这条不堵我们。谢谢,比我们写文档还快。