转发白名单挡掉了 cache_namespace,而上游指定它做租户隔离 #6

Closed
opened 2026-08-29 17:43:41 +08:00 by iomgaa · 2 comments
Owner

我们是谁

GovDoc-SaaS —— 一个多租户文书分析服务,正在重建,要接 PolyLoop 当 Agent 内核。
我们只提需求、不改这个仓库。下面每条行号都核过了(PolyLoop 读的是 main 上的
72cfba5,PolyGateway 读的是 tag v1.1.1v1.3.2)。

问题

GatewayModelClientchat() 转发的键被一份模块级白名单钉死成两个,
其中 cache_namespace 这一个从来没有被放行过,而它在你们自己声明的
PolyGateway 下界上就已经存在了

src/polyloop/adapters/__init__.py:22-23

#: 绑定里能被网关认下的那几个键。其余的键留在参数快照里,不往下传。
_FORWARDED_BINDING_KEYS = ("session_id", "parent_call_id")

src/polyloop/adapters/__init__.py:110-118 的 docstring 给的理由是:

绑定是项目自己的坐标(某个下游有五维),而网关只有两个槽位放得下这类东西。

这句话在写下的那一刻就不成立。 pyproject.toml:23 声明的下界是
polygateway>=1.1,<2,而 v1.1.1chat()src/polygateway/client.py
已经是这个签名:

    async def chat(
        self,
        messages: list[dict[str, Any]],
        *,
        session_id: str | None = None,
        parent_call_id: str | None = None,
        cache_salt: str | None = None,
        cache_namespace: str | None = None,
        structured: type[BaseModel] | Literal["json"] | None = None,
        stream: bool = True,
        overlay: Mapping[str, Any] | None = None,
    ) -> LLMResponse:

也就是说,在下界上就有四个 str | None 的槽位放得下这类坐标
session_idparent_call_idcache_saltcache_namespace),
不是两个。

1.3.0 起又多了两个:tenant_id: str | Nonemeta: Mapping[str, Any] | None
v1.3.2client.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.2client.py:301-303

tenant_idmeta 是调用方自定义维度,只进遥测、不进缓存 key
(租户隔离由 cache_namespace 负责,ARCH §7.5)

也就是说 PolyGateway 的架构文档指定了 cache_namespace 做租户隔离,
而 PolyLoop 恰好把它挡在了白名单外面——上游指定的那个机制,
在经过 PolyLoop 之后就用不上了

我们的设计记录里定死了这个编码:

govdoc:v1:tenant:{encode(tenant_id)}

传不进去的后果不是缓存效率变差,是跨租户读到别人的文书内容
两个租户提交了内容相同的一段文字,第二个会拿回第一个那次的模型输出。
在我们这个场景里那是最不能接受的一类故障。

顺带说明我们为什么不能靠「把缓存关掉」绕过去:关掉之后隔离确实成立,但那等于
用一个我们迟早要打开的开关换掉一道边界,而重新打开它的那天没有任何东西会提醒
我们这道边界不在。

tenant_id 我们同样要——1.3.x 的遥测表里它是真实列,可以挂行级安全;缺了它,
用量遥测按租户归因不了。meta 优先级最低,我们只想用它带一个自己的 trace 标识。

绕过去的代价

我们能绕:ModelClient 这个 Protocol 只有两个方法,自己写一个实现不难。
所以这不是死结。

代价是推翻我们自己已经批准的一条决定。我们的设计记录里有一条
「模型客户端用 PolyLoop 自带的那个,不自己写」,理由正是你们 docstring 里写的
那件事——它不捕获、不翻译、不重试,多一层就是多半套治理。绕过去之后我们会维护
第二个网关适配器,而它和你们那个的唯一区别是多传三个参数;两份会各自漂移,
漂移的那天不会有任何提示。

还有一层:我们自己写的那个实现,你们的下游准入契约套件覆盖不到。

一个可能的做法(不强加)

三个参数的性质不一样,可能要分开处理:

  • cache_namespacetenant_id 都是 strbinding 现在的类型
    Mapping[str, str]src/polyloop/ports/__init__.py:57)放得下,
    加进 _FORWARDED_BINDING_KEYS 就够。
  • metaMapping[str, Any],塞不进 Mapping[str, str],得另开一条路。

形状上想到三种,哪种更合你们的判断你们定:

  1. 白名单直接补上前两个键,meta 单独想办法;
  2. 白名单做成 GatewayModelClient 的构造参数,由调用方决定转发哪些;
  3. 这几个值跨运行基本不变(tenant_id 除外),所以也可以不走 binding
    直接做成 GatewayModelClient 的构造参数。

第 3 种对我们最好用,但它会让这个适配器多认识几个网关概念,可能和你们
「适配器只做翻译」的定位有张力——这一点我们判断不了。

如果你们认为这不该由库来做

比如你们判断「转发哪些键是下游自己的事,下游该写自己的 ModelClient」——
那也请在 issue 里说一声,我们照做。我们只是想确认这是个有意的决定,
而不是那份白名单跟着一个已经过期的前提留下来的。

如果确实要我们自己写,有一个附带请求:能不能让准入契约套件也能套在自己写的
ModelClient 实现上?那样至少「取消能不能穿透」这类事情还有机器管着。

## 我们是谁 GovDoc-SaaS —— 一个多租户文书分析服务,正在重建,要接 PolyLoop 当 Agent 内核。 我们只提需求、不改这个仓库。下面每条行号都核过了(PolyLoop 读的是 main 上的 `72cfba5`,PolyGateway 读的是 tag `v1.1.1` 与 `v1.3.2`)。 ## 问题 `GatewayModelClient` 往 `chat()` 转发的键被一份模块级白名单钉死成两个, 其中 **`cache_namespace` 这一个从来没有被放行过,而它在你们自己声明的 PolyGateway 下界上就已经存在了**。 `src/polyloop/adapters/__init__.py:22-23`: ```python #: 绑定里能被网关认下的那几个键。其余的键留在参数快照里,不往下传。 _FORWARDED_BINDING_KEYS = ("session_id", "parent_call_id") ``` `src/polyloop/adapters/__init__.py:110-118` 的 docstring 给的理由是: > 绑定是项目自己的坐标(某个下游有五维),而网关只有两个槽位放得下这类东西。 **这句话在写下的那一刻就不成立。** `pyproject.toml:23` 声明的下界是 `polygateway>=1.1,<2`,而 `v1.1.1` 的 `chat()`(`src/polygateway/client.py`) 已经是这个签名: ```python async def chat( self, messages: list[dict[str, Any]], *, session_id: str | None = None, parent_call_id: str | None = None, cache_salt: str | None = None, cache_namespace: str | None = None, structured: type[BaseModel] | Literal["json"] | None = None, stream: bool = True, overlay: Mapping[str, Any] | None = None, ) -> LLMResponse: ``` 也就是说,在下界上就有**四个** `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`: > `tenant_id` 与 `meta` 是调用方自定义维度,只进遥测、**不进缓存 key** > (租户隔离由 `cache_namespace` 负责,ARCH §7.5) 也就是说 PolyGateway 的架构文档指定了 `cache_namespace` 做租户隔离, 而 PolyLoop 恰好把它挡在了白名单外面——**上游指定的那个机制, 在经过 PolyLoop 之后就用不上了**。 我们的设计记录里定死了这个编码: ```text govdoc:v1:tenant:{encode(tenant_id)} ``` 传不进去的后果不是缓存效率变差,是**跨租户读到别人的文书内容**: 两个租户提交了内容相同的一段文字,第二个会拿回第一个那次的模型输出。 在我们这个场景里那是最不能接受的一类故障。 顺带说明我们为什么不能靠「把缓存关掉」绕过去:关掉之后隔离确实成立,但那等于 用一个我们迟早要打开的开关换掉一道边界,而重新打开它的那天没有任何东西会提醒 我们这道边界不在。 `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]`,得另开一条路。 形状上想到三种,哪种更合你们的判断你们定: 1. 白名单直接补上前两个键,`meta` 单独想办法; 2. 白名单做成 `GatewayModelClient` 的构造参数,由调用方决定转发哪些; 3. 这几个值跨运行基本不变(`tenant_id` 除外),所以也可以不走 `binding`, 直接做成 `GatewayModelClient` 的构造参数。 第 3 种对我们最好用,但它会让这个适配器多认识几个网关概念,可能和你们 「适配器只做翻译」的定位有张力——这一点我们判断不了。 ## 如果你们认为这不该由库来做 比如你们判断「转发哪些键是下游自己的事,下游该写自己的 `ModelClient`」—— 那也请在 issue 里说一声,我们照做。我们只是想确认这是个有意的决定, 而不是那份白名单跟着一个已经过期的前提留下来的。 如果确实要我们自己写,有一个附带请求:能不能让准入契约套件也能套在自己写的 `ModelClient` 实现上?那样至少「取消能不能穿透」这类事情还有机器管着。
Author
Owner

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() 的关键字参数名

model_binding = {
    "case": "...",                                    # 你们自己的坐标,不传
    "gateway.cache_namespace": "govdoc:v1:tenant:x",  # 传成 chat(cache_namespace=...)
    "gateway.tenant_id": "x",
}

你们那条「我们判断不了这会不会和你们『适配器只做翻译』的定位有张力」——判断是:翻译不需要认识对方的词汇表。前缀之后那个名字本库一个字都不解释,认不认得由网关决定,不认得就抛 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——那正是最该炸的一次,因为续跑读到的可能是另一个租户的缓存。

四条防御,都抛 ValueError

  1. 前缀后面是 messages / stream / structured / overlay —— 这些改变请求本身,另有权威(overlay 尤其:从绑定走会绕开 parameters() 那份模型身份,而续跑守卫盯的就是它);
  2. 取值是空串或纯空白 —— 空串在网关那边和「没传」分不开,纯空白更坏:它是真值,会被原样当成命名空间用,于是所有拿到这份坏配置的租户共用同一格,正是你们这个 issue 换了个入口。判据是这个值带不带信息,不是格式对不对——"govdoc:v1:tenant:"(租户标识拼空了)本库拦不住也不该拦;
  3. 键恰好是 gateway.、后面没跟参数名;
  4. 不带前缀的 session_idparent_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_clientfailing_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 已发布**,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()` 的关键字参数名**: ```python model_binding = { "case": "...", # 你们自己的坐标,不传 "gateway.cache_namespace": "govdoc:v1:tenant:x", # 传成 chat(cache_namespace=...) "gateway.tenant_id": "x", } ``` 你们那条「我们判断不了这会不会和你们『适配器只做翻译』的定位有张力」——判断是:**翻译不需要认识对方的词汇表**。前缀之后那个名字本库一个字都不解释,认不认得由网关决定,不认得就抛 `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`——那正是最该炸的一次,因为续跑读到的可能是另一个租户的缓存。 ## 四条防御,都抛 `ValueError` 1. 前缀后面是 `messages` / `stream` / `structured` / `overlay` —— 这些改变请求本身,另有权威(`overlay` 尤其:从绑定走会绕开 `parameters()` 那份模型身份,而续跑守卫盯的就是它); 2. **取值是空串或纯空白** —— 空串在网关那边和「没传」分不开,纯空白更坏:它是真值,会被原样当成命名空间用,于是所有拿到这份坏配置的租户共用同一格,正是你们这个 issue 换了个入口。判据是这个值带不带信息,不是格式对不对——`"govdoc:v1:tenant:"`(租户标识拼空了)本库拦不住也不该拦; 3. 键恰好是 `gateway.`、后面没跟参数名; 4. **不带前缀的 `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。
iomgaa reopened this issue 2026-08-30 23:19:03 +08:00
Author
Owner

补一条核实:1.0.3 的前缀方案对 cache_namespace 与 tenant_id 都成立——两个都是 str,进得了 Mapping[str, str] 的绑定。meta 我们这次用不到(trace 标识走 session_id),所以 Mapping[str, Any] 进不了绑定这条不堵我们。谢谢,比我们写文档还快。

补一条核实:1.0.3 的前缀方案对 cache_namespace 与 tenant_id 都成立——两个都是 str,进得了 Mapping[str, str] 的绑定。meta 我们这次用不到(trace 标识走 session_id),所以 Mapping[str, Any] 进不了绑定这条不堵我们。谢谢,比我们写文档还快。
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: iomgaa/PolyLoop#6