From 20fd899d938ee0d561536d67e364554933d21d82 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 31 Jul 2026 11:40:14 -0400 Subject: [PATCH] docs: design sampling parameter passthrough (issue #4) Two-layer entry: per-call overlay on chat() and per-source extra_body. Covers the cache-key and telemetry interactions the issue omitted. --- .../2026-07-31-sampling-params-design.md | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 research-wiki/designs/2026-07-31-sampling-params-design.md diff --git a/research-wiki/designs/2026-07-31-sampling-params-design.md b/research-wiki/designs/2026-07-31-sampling-params-design.md new file mode 100644 index 0000000..04ee419 --- /dev/null +++ b/research-wiki/designs/2026-07-31-sampling-params-design.md @@ -0,0 +1,159 @@ +# 采样参数透传设计(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 profile `thinking_off={}` | `providers.py:49` | `enable_thinking=False` 对该源无效果 | + +**issue 未提及但必须一并处理的**: 缓存与遥测的交互。不处理的话,failure mode 恰是 issue 自己最担心的那种——数字悄悄不可比,且不报错。 + +--- + +## 2. 设计决策 + +### 决策 A: 两层入口,合并优先级由现有层序天然给出 + +| 层 | 载体 | 用途 | 生效点 | +|---|---|---|---| +| 调用级 | `chat(..., overlay: Mapping[str, Any] \| None = None)` | 逐次变化(每 rollout 不同的 `seed`) | 填入 `ChatRequest.overlay` | +| 配置级 | `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 无需改动任何端口签名。 + +### 决策 B: 保护键黑名单,构造期显式报错 + +`{model, messages, stream, stream_options}` 禁止出现在 overlay/extra_body 中。理由逐条: + +| 键 | 被覆盖的后果 | +|---|---| +| `model` | 遥测记录的 model 与实际请求分叉 → 成本按错单价算 | +| `messages` | 缓存 key 与遥测口径同时失真 | +| `stream` | 绕过流式看门狗(TTFT/inter-token 三层超时全失效) | +| `stream_options` | 丢 usage 帧 → 成本遥测归零、TPM 闸按预扣量结算失准 | + +校验函数落在 `types.py`(最内层,无依赖),两个入口各调一次:`chat()` 参数在进洋葱**之前**校验(与既有 `structured` 的 ImportError 同款先例),`SourceConfig.__post_init__` 在装配期校验(符合 §4.5「缺失/非法关键配置直接报错」)。抛裸 `ValueError`——这是调用方编程错误,不属 §6 四分类,不应被 RetryMW 当作可重试失败。 + +transport 不重复校验:三个 overlay 来源(chat 参数、SourceConfig 字段、库内策略)已全部在构造期收口,库内策略只注入 `response_format`。 + +### 决策 C: 调用级 overlay 进缓存 key —— 本设计的关键点 + +不做的话:同 messages 跑 5 个 seed,后 4 次命中第一次的缓存,返回同一 response,**标准差恒为 0**,实验静默作废。这正是「无缓存毒化」铁律的场景。 + +层序天然正确:`CacheMW` 在 `StructuredMW` **外侧**,它看到的 `request.overlay` 恰好只含调用方传入的部分,结构化注入不会污染 key。 + +key 公式扩展(ARCH §7.5 需同步修订): + +```text +key_obj = {model, messages_digest, namespace, [salt], [overlay]} + 仅非 None 仅非空 +``` + +`overlay` 沿用 `salt` 的「仅非空时参与」写法,保证**空 overlay 时旧键逐字不变**,不触发存量缓存全量冷启动。 + +配置级同理:`model_fingerprint` 从 `",".join(sorted(models))` 扩展为——所有源 `extra_body` 皆空时字面不变;否则追加 `"|" + sha256(canonical_json(sorted 去重的 (model, extra_body) 二元组))`。取 `(model, extra_body)` 而非 `(name, ...)`,语义是「本 scope 会用哪些(模型,解码参数)组合」,改源名不会误触冷启动。 + +**已知副作用(须写进 wiki)**: 逐 rollout 变化的 `seed` 进 key 后,该路径**天然全部 miss**。这是正确语义而非缺陷,但下游要知道缓存对这条路径不再省钱。 + +### 决策 D: 采样参数入遥测(端口 20 → 21 字段) + +「实验可复现」的另一半是参数落库。不记的话,同 messages 不同输出在审计表里无法解释。与 issue #3 新增 `model_reported` 同类动机(供应商把别名指向新权重时,复现必须认真实串)。 + +- 字段 `sampling: str | None`——`source.extra_body` 与 `request.overlay` 合并后的 canonical JSON;两者皆空时 `None`。 +- 记录的是**实际发出的合并结果**,含结构化注入的 `response_format`(RetryMW 的 emit 点在 StructuredMW 内侧)。schema 会让该列变大,但相对同行的完整 `messages` 增量有限,可接受。 +- 两个后端按 issue #3 已建立的套路幂等补列:**先探测缺列再 ALTER**、失败只逐行降级不置结构性失能标志、新列排在 `created_at` 之后。 + +一次做完而非分两步:「能传参数但没记」的中间状态最危险——数据已产生且事后无法追溯,且分步要做两遍 DDL 迁移。 + +### 决策 E: 入参拷贝语义 + +`chat()` 对传入 overlay 做 `dict(overlay)` 浅拷贝。issue 场景就是逐次改 `seed`——调用方复用同一 dict 对象改值是极可能的模式,不拷贝会出现「请求已发出、key 用了新 seed」的竞态。`ChatRequest` 虽 frozen 但 dict 是浅冻结,拦不住。`SourceConfig.extra_body` 在 `__post_init__` 转 `MappingProxyType` 同理(成本近零)。 + +### 决策 F: minimax profile 的诚实性缺口(issue 附带项) + +`minimax` 与 `openai` 的 `thinking_on/thinking_off` 均为空字典,但只有 `openai` 处有注释说明是有意为之。补一行注释说明 MiniMax 无已知关闭推理的请求参数、该档对本 provider 无效果——调用方以为关掉了实际没关,是诚实性问题。不改行为(有了 `extra_body` 需要时可绕过)。 + +--- + +## 3. 关键岔路与否决记录 + +| 岔路 | 否决方 | 理由 | +|---|---|---| +| `chat()` 展开为 `temperature=`/`seed=`/`max_tokens=` 具名参数 | 否决 | 供应商私有参数无穷尽(`top_k`/`repetition_penalty`/`thinking_budget`),具名等于永久追加签名;且违背「深模块窄接口」(ARCH §132) | +| 配置级放装配层全局字典而非 `SourceConfig` | 否决 | 采样参数与源强相关(不同供应商键名不同),全局字典会把无效键发给不认识它的源 | +| overlay 不进缓存 key,靠调用方传 `cache_salt` 区分 | 否决 | 把毒化防护的责任推给调用方,漏传不报错——正是 issue 抱怨的失败形态 | +| 采样参数不入遥测,由下游 run 快照自记 | 否决 | 见决策 D | +| transport 层再兜一次保护键校验 | 否决 | 三个入口已构造期收口,重复校验属 gold-plating | + +--- + +## 4. 非功能维度 + +| 维度 | 回答 | +|---|---| +| **并发** | 无新增共享状态。`extra_body` 装配后只读(MappingProxyType);调用级 overlay 每调用独立拷贝,并发调用互不可见 | +| **取消** | 无新增 await 点与等待循环,`CancelledError` 穿透路径完全不变 | +| **降级方向** | 不涉及新后端。遥测新列写失败沿用既有逐行 warning 降级;缓存 key 变更不影响 Redis 掉线的静默降级方向 | +| **幂等与重复** | 保护键校验是纯函数,重复调用安全;遥测补列先探测后 ALTER,重启幂等 | +| **持久化与原子性** | 遥测单行写入,无部分写入风险。缓存 value 结构不变(`sampling` 只进遥测不进 `LLMResponse`,避免动已被三项目消费的公共类型) | +| **重试交互** | overlay 在 RetryMW 循环外确定,换源重试时同一 overlay 应用到新源的 `extra_body` 之上——语义正确(调用级意图跨源保持) | + +--- + +## 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 | 遥测 `sampling` 落库正确;两后端对既有旧表幂等补列 | integration | +| 9 | 采样参数经全链路(chat → 选源 → transport payload)到达请求体 | integration | + +--- + +## 6. 配置与文档同步 + +env 键名沿用既有约定:`{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY`,值为 JSON 对象串;`_SOURCE_FIELDS` 增一项、`_cast` 增 `json` 分支(解析失败与非 dict 均报错)。 + +发版清单(docs-convention §2):ARCH §5.2 `chat()` 签名定稿段追加 overlay 要点、§7.5 key 公式补 overlay 项、§7.8 必录字段 20 → 21;wiki 的 how-to 增「固定解码参数」条目并写明 seed 进 key 导致缓存必 miss;CHANGELOG 记公共 API 新增与遥测端口扩列。 + +## 7. 实施范围 + +`types.py`(保护键校验函数 + `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 字段与补列)、`providers.py`(注释)。 + +不做:OCR/embedding 路径(走独立端口,issue 未提出诉求)、任何任务外重构。