# 采样参数透传设计(issue #4) - **日期**: 2026-07-31 - **状态**: 待人类审批 - **触发**: issue #4 —— `chat()` 无法设置 `temperature`/`seed`/`max_tokens`,下游受控实验无法固定解码 - **影响面**: `chat()` 公共签名、`SourceConfig` 公共类型、缓存 key 公式(ARCH §7.5)、遥测端口(20 → 21 字段) --- ## 1. 诉求与现状审计 下游 dissect 是一组受控实验:解码固定 `temperature=0`,每格配置跑 5 个 seed 报标准差。标准差必须只反映被研究的变量,不能混进解码随机性。 代码事实(本会话核实): | 事实 | 位置 | 后果 | |---|---|---| | 全库 `temperature` 零命中 | `grep -rn temperature src/` | 解码跑在供应商默认值上,不可复现 | | `chat()` 签名无 overlay 入口 | `client.py:143-153` | 调用方够不着 `ChatRequest.overlay` | | `overlay` 唯一写入点是结构化中间件 | `middleware/structured.py:98` | 字段存在但只服务库内 | | `payload.update(overlay)` 是最后一步 | `transports/openai_compat.py:297` | overlay 可覆盖 `model`/`messages`/`stream`/`stream_options` | | 缓存 key 公式不含 overlay | `middleware/cache.py:52-64` | **见 §2 决策 C** | | `model_fingerprint` 只由源 `model` 名算 | `client.py:117` | 配置级采样参数变更不改 key | | minimax / openai profile 均 `thinking_off={}` | `providers.py:49,56` | `enable_thinking=False` 对两源均无效果 | **issue 未提及但必须一并处理的**: 缓存与遥测的交互。不处理的话,failure mode 恰是 issue 自己最担心的那种——数字悄悄不可比,且不报错。 --- ## 2. 设计决策 ### 决策 A: 两层入口,合并优先级由现有层序天然给出 | 层 | 载体 | 用途 | 生效点 | |---|---|---|---| | 调用级 | `chat(..., overlay: Mapping[str, Any] \| None = None)` | 逐次变化(每 rollout 不同的 `seed`) | 填入 `ChatRequest` | | 配置级 | `SourceConfig.extra_body: Mapping[str, Any]` | 全局恒定(`temperature=0`) | transport `_build_payload` | 优先级 **结构化注入 > 调用级 > 配置级**,无需任何新机制: ```text _build_payload: payload{model,messages,stream} → thinking_profile → source.extra_body ← 配置级(新增一行) → overlay ← 调用级 ⊎ 结构化注入 StructuredMW: {**request.overlay, **strategy_overlay} ← 结构化已在最右,天然最高 ``` 配置级放在 transport 而非装配层合并,是因为 `extra_body` 是 per-source 的,选源在 RetryMW 之后才确定;放 transport 无需改动任何端口签名。 **`ChatRequest` 增第二个字段 `sampling: Mapping[str, Any] = field(default_factory=dict)`**(调用方原始采样意图的快照,库内中间件**永不修改**),与 `overlay`(请求体覆盖层,会被结构化注入)分开。`chat()` 同时填两者。理由是 `overlay` 在洋葱不同深度取值不同——`StructuredMW` 内侧含 `response_format`、外侧不含——缓存 key 与遥测若各自依赖"在哪一层读"就会口径分叉(见决策 C/D)。`sampling` 提供一个跨层恒定的读取点。 类型定死为 `Mapping` 而非 `dict[str, Any] | None`:空 dict 与 `None` 在此无语义差别(都是"没传采样参数"),多一种表示只会让 key 公式与 `merge()` 签名各选各的。因此决策 C 的 key 公式一律按**仅非空**参与(注意与同处的 `salt` 不同——`salt` 是"仅非 None",空串是有意义的 salt)。 ### 决策 B: 保护键黑名单,构造期显式报错 `{model, messages, stream, stream_options}` 禁止出现在 overlay/extra_body 中。理由逐条: | 键 | 被覆盖的后果 | |---|---| | `model` | 遥测记录的 model 与实际请求分叉 → 成本按错单价算 | | `messages` | 缓存 key 与遥测口径同时失真 | | `stream` | 绕过流式看门狗(TTFT/inter-token 三层超时全失效) | | `stream_options` | 丢 usage 帧 → 成本遥测归零、TPM 闸按预扣量结算失准 | 同一校验函数还必须验**值可 JSON 序列化**。理由:`CacheMW.__call__` 第 95 行的 `build_cache_key` 内部 `json.dumps`,**不在 `_safe_get`/`_safe_set` 的降级 try 内**;`TelemetryMW` 只捕 `GatewayUnavailableError`/`GovernanceBackendError`/`CancelledError`。调用方传 `{"temperature": np.float32(0)}`(温度扫描用 numpy 生成极自然)会抛裸 `TypeError`:不属四分类、一行遥测都没有、RetryMW 从未执行。构造期一次校验即可保住"overlay 错误全部发生在进洋葱之前"这条不变式。 校验函数落在 `types.py`(最内层,无依赖),两个入口各调一次:`chat()` 参数在进洋葱**之前**校验(与既有 `structured` 的 ImportError 同款先例),`SourceConfig.__post_init__` 在装配期校验(符合 §4.5「缺失/非法关键配置直接报错」)。抛裸 `ValueError`——这是调用方编程错误,不属 §6 四分类,不应被 RetryMW 当作可重试失败。 transport 不重复校验:三个 overlay 来源(chat 参数、SourceConfig 字段、库内策略)已全部在构造期收口,库内策略只注入 `response_format`(`json_repair.py:41` 恒空,`native_schema.py:21-29` 只产该键)。 ### 决策 C: 调用级 overlay 进缓存 key —— 本设计的关键点 不做的话:同 messages 跑 5 个 seed,后 4 次命中第一次的缓存,返回同一 response,**标准差恒为 0**,实验静默作废。这正是「无缓存毒化」铁律的场景。 key 公式扩展(ARCH §7.5 需同步修订),读 `request.sampling` 而非 `request.overlay`——语义明确、不依赖"CacheMW 恰在 StructuredMW 外侧"这一层序巧合: ```text key_obj = {model, messages_digest, namespace, [salt], [sampling]} 仅非 None 仅非空 ``` 沿用 `salt` 的「仅非空时参与」写法,保证**空采样参数时旧键逐字不变**,不触发存量缓存全量冷启动。 配置级同理:`model_fingerprint` 从 `",".join(sorted(models))` 扩展为——所有源 `extra_body` 皆空时字面不变;否则追加 `"|" + sha256(...)`,摘要对象是「每个源的 `(model, extra_body)` 先各自 canonical-JSON 化成字符串,再排序去重」(dict 本身既不可排序也不可哈希,必须先序列化;`extra_body` 若存为 `MappingProxyType` 需 `dict(...)` 后再 `json.dumps`)。取 `(model, extra_body)` 而非 `(name, ...)`,语义是「本 scope 会用哪些(模型,解码参数)组合」,改源名不会误触冷启动。该计算在 `client.py:117` 且不在任何降级 try 内,写错即装配期崩——实施时须有直接单测。 **两条已知副作用(须写进 wiki)**: 1. 逐 rollout 变化的 `seed` 进 key 后,该路径**天然全部 miss**。这是正确语义而非缺陷,但下游要知道缓存对这条路径不再省钱。 2. `model_fingerprint` 是**集合级**指纹,不是本次实际选中源的指纹。同 scope 下各源 `extra_body` 不同时,缓存仍可能返回另一源、另一组解码参数下产生的响应。这是既有取舍的延续(`cache.py:68-72` 对 `model` 已如此),不是本设计引入的新缺口,但"配置级采样参数进 key"容易被读成更强的保证,须写明边界。受控实验若要求逐源可复现,应让每个源独享 scope 或 namespace。 ### 决策 D: 采样参数入遥测(端口 20 → 21 字段) 「实验可复现」的另一半是参数落库。不记的话,同 messages 不同输出在审计表里无法解释。与 issue #3 新增 `model_reported` 同类动机(供应商把别名指向新权重时,复现必须认真实串)。 **列语义定死**:`sampling: str | None` = 「调用方采样意图 ⊎ 生效源的 `extra_body`」的 canonical JSON,**不含库内结构化注入的 `response_format`**。两个理由:该列名叫采样参数,`response_format` 不是;schema 可达数 KB,逐行记会让审计表无谓膨胀。 `TelemetryEmitter` 有三个入口且都汇入同一个 `_record`(显式关键字参数,加列必须三处都传),必须逐个定死,否则同一列在不同行口径分叉——这正是 1.0.4 里 `cached_prompt_tokens` 不得不写"下游请读"警告的同类坑: | 入口 | 调用者 | 有 `source`? | `sampling` 记什么 | |---|---|---|---| | `emit_attempt` | RetryMW(最内) | 有 | `merge(source.extra_body, request.sampling)` | | `emit_cache_hit` | TelemetryMW(最外) | **无** | 仅 `request.sampling` | | `emit_terminal_failure` | TelemetryMW | **无** | 仅 `request.sampling` | 后两行缺 `extra_body` 是**客观事实而非口径瑕疵**:它们没有"生效源"可言——与 `model`/`provider`/`source_name` 在终态行置空是同一先例。缓存命中行尤其无损:`sampling` 已进缓存 key,能命中就意味着历史那次的调用级采样参数与本次逐字相同;`extra_body` 亦已进 `model_fingerprint`,命中意味着源集合的配置指纹相同。 三个入口统一读 `request.sampling`(决策 A 的新字段)而非 `request.overlay`,是因为后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处则未被污染,直接用会让三行天然分叉。 **共用范围写清楚**:`types.py` 提供「2 参 dict 合并 + canonical 序列化」这一个原语,transport 与 emitter 共用它。**不追求统一到两者之上**——transport 是往更大的 payload 上依次 `update(thinking_profile) → update(extra_body) → update(overlay)`,emitter 算的是 `merge(extra_body, sampling)`,参与方与顺序本就不同,强行统一是错的。这不影响正确性:该列语义已定义为「调用方意图 ⊎ 生效源 `extra_body`」,而非 payload 的逐字回显。共用原语的目的只是让"合并语义与序列化口径"这一件事不出现两份实现。 两个后端按 issue #3 已建立的套路幂等补列:**先探测缺列再 ALTER**、失败只逐行降级不置结构性失能标志、新列排在 `created_at` 之后。 一次做完而非分两步:「能传参数但没记」的中间状态最危险——数据已产生且事后无法追溯,且分步要做两遍 DDL 迁移。 **OCR/Embedding 的 emit 调用点零改动**:`ocr.py:418` 与 `embedding.py:372` 也调 `emit_attempt` 且都传 `source`,只要 `sampling` 由 emitter 内部推导(而非作为新必填参数由调用者传入),这两处调用不动一行——反之立刻 TypeError,实施时必须走推导路线。两个文件本身仍有改动,即决策 G 的构造期剥离(它正是让这里的推导对 OCR/embedding 恒得 NULL 的前提)。 ### 决策 E: 入参拷贝语义与两条只读约束 `chat()` 对传入 overlay 做**一次** `dict(overlay)` 浅拷贝,同一份快照对象同时填 `overlay` 与 `sampling` 两个字段(不做两份独立拷贝——它们在进入 `StructuredMW` 之前本就应当逐字相同,两份拷贝反而给"两者可以分叉"留了口子)。 issue 场景就是逐次改 `seed`——调用方复用同一 dict 对象改值是极可能的模式,不拷贝会出现「请求已发出、key 用了新 seed」的竞态。`ChatRequest` 虽 frozen 但 dict 是浅冻结,拦不住。`SourceConfig.extra_body` 在 `__post_init__` 转 `MappingProxyType` 同理(成本近零)。 拷贝之外的第二条约束:**任何中间件不得就地修改这两个 dict**,只能经 `dataclasses.replace` 派生新请求。现状已满足(`StructuredMW` 用 `{**a, **b}` 生成新 dict,`_build_payload` 只往 payload 上 `update`,全库无就地改写),本设计只是把它写成明文约束——决策 C 与 D 都建立在 `sampling` 跨层恒定之上,这条被破坏则两者同时失效(测试 #14 为此加机械执法)。 ### 决策 F: 空 thinking profile 的诚实性缺口(issue 附带项) `minimax` 与 `openai` 的 `thinking_on/thinking_off` 均为空字典。`providers.py:52` 那条「OpenAI 兼容基线,无已知注入差异」的注释在词法上属于紧随其后的 **minimax** 条目,`openai` 条目没有任何注释。所以现状是:已有的注释解释了"为何为空",但两个 provider 都没点明**后果**——`enable_thinking=False` 对它们不产生任何效果,调用方以为关掉了实际没关。 补的是这一句后果说明(覆盖两个 provider),不是重复已有的"为何为空"。不改行为:真需要关时经 `extra_body` 绕过。 ### 决策 G: 非 chat 路径的 `extra_body` —— 剥离并 warning,不中断装配 `_SOURCE_FIELDS`(`config.py:33-47`)是**跨 scope 共用**的一张表,加了 `EXTRA_BODY` 之后 `OCR__MONKEY__1__EXTRA_BODY` / `EMBED__QWEN__1__EXTRA_BODY` 会被合法接受、进 `SourceConfig`、进遥测 `sampling` 列,但两条路径都不消费它:`monkey_ocr.py:225,247` 只发 multipart `files=`(**根本没有 JSON body**),`OpenAICompatTransport.embed`(`openai_compat.py:343`)payload 硬编码 `{"model", "input"}`。放任即**静默无效**,正是 §4.5 要禁的形态。 **处置(2026-07-31 人类拍板改此档)**:`EmbeddingClient` / `OcrClient` 构造期发现源带非空 `extra_body` → 记 warning 并 `dataclasses.replace(source, extra_body={})` **剥离后放行**,不抛异常。 剥离是这一档的**必要组成部分,不是顺手清理**。`ocr.py:390` 与 `embedding.py:350` 构造 `ChatRequest` 时不带 `sampling`,但传给 `emit_attempt` 的 `source` 是真实配置对象;若不剥离,决策 D 的 `merge(source.extra_body, request.sampling)` 会让遥测**记录一个从未发出的参数**——审计表显示该次 OCR 调用带了 `temperature=0`,实际请求体里没有。那不是"参数不生效",是遥测造假,污染的恰是事后复现的唯一依据。替代方案是在 emitter 里特判调用方身份,直接违背「遥测调用点收敛为单一 helper」铁律,否决。 剥离后该列在 OCR/embedding 行恒为 NULL,语义干净,emitter 零特判。 **被否决的原方案**: 装配期 `ValueError` 直接拒绝。理由是这两条路径本无采样语义,配错的后果远轻于 chat 路径,不值得让下游整个装配起不来。**残余风险须写进 wiki**: loguru warning 在生产中容易被淹没,运维可能仍以为参数生效——这是"不中断装配"换来的代价,故 warning 文案必须**指路**:`dimensions` 是 OpenAI embeddings 的正式参数,下游想调向量维度时会第一个撞上,文案应写明"embedding 路径暂不支持 `extra_body`,该配置已被忽略;需要 `dimensions` 等参数请提 issue"。 不顺手给 embed 加透传:embedding 没有采样一说,issue 也未提出诉求(YAGNI);真有需求时单独设计。 --- ## 3. 关键岔路与否决记录 | 岔路 | 否决方 | 理由 | |---|---|---| | `chat()` 展开为 `temperature=`/`seed=`/`max_tokens=` 具名参数 | 否决 | 供应商私有参数无穷尽(`top_k`/`repetition_penalty`/`thinking_budget`),具名等于永久追加签名;且违背「深模块窄接口」(ARCH §132) | | 配置级放装配层全局字典而非 `SourceConfig` | 否决 | 采样参数与源强相关(不同供应商键名不同),全局字典会把无效键发给不认识它的源 | | overlay 不进缓存 key,靠调用方传 `cache_salt` 区分 | 否决 | 把毒化防护的责任推给调用方,漏传不报错——正是 issue 抱怨的失败形态 | | 采样参数不入遥测,由下游 run 快照自记 | 否决 | 见决策 D | | 缓存 key 与遥测都直接读 `request.overlay`,不加 `sampling` 字段 | 否决 | `overlay` 在洋葱不同深度取值不同(结构化注入),三个 emit 入口与 CacheMW 会各记各的,同一列口径分叉 | | `sampling` 列记「实际发出的完整合并结果」(含 `response_format`) | 否决 | 该列名为采样参数,schema 不是;且数 KB schema 逐行落库无谓膨胀 | | 给 embedding 路径也加 `extra_body` 透传 | 否决 | embedding 无采样一说,issue 未提诉求(决策 G) | | 非 chat 路径带 `extra_body` 时装配期 `ValueError` | 否决(人类拍板) | 这两条路径无采样语义,配错后果远轻于 chat,不值得让下游装配起不来;改为剥离 + warning | | 允许放行但**不剥离** `extra_body` | 否决 | 遥测会记录一个从未发出的参数(决策 D 的 merge 读 `source.extra_body`),是数据造假而非参数失效 | | 放行不剥离,改在 emitter 内特判 OCR/embedding 不记 | 否决 | emitter 是「遥测调用点收敛单一 helper」的产物,让它识别调用方身份是开倒车 | | transport 层再兜一次保护键校验 | 否决 | 三个入口已构造期收口,重复校验属 gold-plating | --- ## 4. 非功能维度 | 维度 | 回答 | |---|---| | **并发** | 无新增共享状态。`extra_body` 装配后只读(MappingProxyType);调用级 overlay 每调用独立拷贝,并发调用互不可见 | | **取消** | 无新增 await 点与等待循环,`CancelledError` 穿透路径完全不变 | | **降级方向** | 不涉及新后端。遥测新列写失败沿用既有逐行 warning 降级;缓存 key 变更不影响 Redis 掉线的静默降级方向。决策 G 的剥离 + warning 是**配置面**降级(装配期一次性、可复现、部署即暴露),与铁律里"限流/熔断后端不可用须报错"的**运行时**降级方向是两回事,不冲突 | | **幂等与重复** | 保护键校验是纯函数,重复调用安全;遥测补列先探测后 ALTER,重启幂等 | | **持久化与原子性** | 遥测单行写入,无部分写入风险。缓存 value 结构不变(`sampling` 只进遥测不进 `LLMResponse`,避免动已被三项目消费的公共类型) | | **重试交互** | overlay 在 RetryMW 循环外确定,换源重试时同一 overlay 应用到新源的 `extra_body` 之上——语义正确(调用级意图跨源保持) | | **限流交互** | overlay 里的 `max_tokens` 不影响入场预扣(取 `effective_est_tokens()`)。调用方把 `max_tokens` 抬到远超预扣量时 TPM 入场保护会短暂失真,结算侧(`retry.py:338-343`)按实测用量回填自愈。已知且可接受,不为此加机制 | --- ## 5. 错误处理与测试策略 **错误分类**: 保护键违规与 `EXTRA_BODY` JSON 解析失败均为裸 `ValueError`,发生在进入洋葱之前/装配期,不入四分类、不触发重试或熔断。运行时若供应商拒绝某个采样参数(如不支持 `seed`),网关返回 4xx,由既有 `RequestRejectedError` 路径处置——无需新增分类。 **测试清单**(每条须先失败后通过): | # | 用例 | 层 | |---|---|---| | 1 | 同 messages 不同 `seed` → 两次 miss、两个不同 key(issue 场景直接回归) | unit | | 2 | 空 overlay 时 key 与旧实现逐字相同(防存量冷启动) | unit | | 3 | 全源 `extra_body` 为空时 fingerprint 与旧实现逐字相同 | unit | | 4 | 保护键:`chat(overlay={"stream": False})`、`SourceConfig(extra_body={"model": "x"})` 均 `ValueError` | unit | | 5 | 优先级:配置 `temperature=0` + 调用级 `temperature=1` → payload 为 1;结构化 `response_format` 覆盖调用级同名键 | unit | | 6 | 调用方在 `chat()` 返回前修改自己的 dict,不影响已发请求与已算 key(拷贝语义) | unit | | 7 | env 解析:`EXTRA_BODY` 合法 JSON 对象 → dict;非法 JSON / 非对象 → `ValueError` | unit | | 8 | 不可 JSON 序列化的值(如 `np.float32`)在 `chat()` 入口即 `ValueError`,不进洋葱 | unit | | 9 | 三个 emit 入口的 `sampling` 口径:attempt 含 `extra_body`、cache_hit 与 terminal 只含调用级、结构化注入的 `response_format` **三行都不出现** | unit | | 10 | `EmbeddingClient`/`OcrClient` 装配时源带 `extra_body` → 记 warning、装配成功、源上 `extra_body` 已被剥空,且该路径遥测 `sampling` 为 NULL(决策 G;后半段是防遥测造假的真正断言) | unit | | 11 | `_EXPECTED_COLUMNS` 断言更新后仍逐字匹配实际列序(见 §6,两处会直接红) | unit + integration | | 12 | 遥测 `sampling` 落库正确;两后端对既有旧表幂等补列 | integration | | 13 | 采样参数经全链路(chat → 选源 → transport payload)到达请求体 | integration | | 14 | **地基不变式**:走结构化重问阶梯(至少重问一次)后,RetryMW 每次尝试看到的 `request.sampling` 与 `chat()` 传入值逐字相同,且同一时刻 `request.overlay` 含 `response_format` | unit | 第 14 条是决策 C/D 共同的承重前提。它现在只靠"`dataclasses.replace` 恰好保留未提及字段"这一约定成立,无任何机械执法;缺这条测试则决策 E 的只读约束被破坏时不会有人发现。 第 2 条(空采样参数时旧键逐字不变)需自行先固化旧 key 值再比对——现有 `tests/unit/test_cache.py:39-54` 只有相等/不等与前缀断言,没有 golden hash 可依。 --- ## 6. 配置与文档同步 env 键名沿用既有约定:`{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY`,值为 JSON 对象串;`_SOURCE_FIELDS` 增一项、`_cast` 增 `json` 分支(解析失败与非 dict 均报错)。 同步清单(docs-convention §2): | 目标 | 改什么 | |---|---| | ARCH §5.2 | `chat()` 签名定稿段追加 `overlay` 要点 | | ARCH §7.5 | key 公式补 `sampling` 项 + 两条已知副作用 | | ARCH §7.7 | 该节逐字段枚举 `SourceConfig` 构成(`ARCHITECTURE.md:452`),补 `extra_body` | | ARCH §7.8 | 必录字段 20 → 21 | | ARCH §9 | 配置面键族事实源(`:519-527`),登记 `{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY` | | `.env.example` | `client.py:247` docstring 声明它是键名清单的事实源,新键不写进去等于无处可查 | | `README.md:83` | 该行逐一列举 `chat()` 关键字参数,补 `overlay` | | wiki how-to | 增「固定解码参数」条目,写明 seed 进 key 导致缓存必 miss、以及 OCR/embedding 路径的 `extra_body` 会被忽略(仅 warning) | | CHANGELOG | 公共 API 新增 + 遥测端口扩列 | ## 7. 实施范围 `types.py`(保护键与 JSON 可序列化校验、合并纯函数、`ChatRequest.sampling`、`SourceConfig.extra_body`)、`client.py`(`chat()` 参数 + fingerprint)、`middleware/cache.py`(key 公式)、`transports/openai_compat.py`(`_build_payload` 一行)、`config.py`(env 解析)、`ports.py` + `middleware/telemetry.py` + `telemetry/{sqlite,postgres}.py`(第 21 字段与补列)、`ocr.py` + `embedding.py`(仅决策 G 的构造期剥离 + warning)、`providers.py`(注释)。 **测试侧必改**(否则直接红):`tests/unit/test_telemetry.py:18,113` 与 `tests/integration/test_postgres_telemetry.py:22,210,231` 的 `_EXPECTED_COLUMNS` 断言完整列表与列序。 无需改动:import-linter 契约(校验函数落最内层 `types.py`,分层关系不变)。 不做:给 embedding/OCR 加采样参数透传(决策 G)、任何任务外重构。