# 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.` 这类),或者放宽绑定的取值类型。后者动的是公共 类型,而绑定被定成字符串映射是为了让它能逐字段进参数快照——`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` 就行。这是决策一那种「不列举坐标维度」的做法白拿的收益:白名单 方案得为它多列一个名字,还得先判断该不该列。