Pin the telemetry column semantics across all three emitter entry points, add the cross-layer sampling snapshot, and reject extra_body on the embedding and OCR paths instead of accepting it silently.
20 KiB
采样参数透传设计(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 |
优先级 结构化注入 > 调用级 > 配置级,无需任何新机制:
_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 外侧"这一层序巧合:
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):
- 逐 rollout 变化的
seed进 key 后,该路径天然全部 miss。这是正确语义而非缺陷,但下游要知道缓存对这条路径不再省钱。 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 路径零改动:ocr.py:418 与 embedding.py:372 也调 emit_attempt 且都传 source,只要 sampling 由 emitter 内部推导(而非作为新必填参数由调用者传入),这两个文件不动一行。反之则立刻 TypeError——实施时必须走推导路线。
决策 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 装配期拒绝
_SOURCE_FIELDS(config.py:33-47)是跨 scope 共用的一张表,加了 EXTRA_BODY 之后 OCR__MONKEY__1__EXTRA_BODY / EMBED__QWEN__1__EXTRA_BODY 会被合法接受、进 SourceConfig、进遥测 sampling 列,但 OpenAICompatTransport.embed(openai_compat.py:343,payload 硬编码 {"model", "input"})与 monkey_ocr(multipart 表单)都不消费它——静默无效,正是 §4.5 要禁的形态。
处置:EmbeddingClient / OcrClient 构造期若发现源带非空 extra_body → ValueError,明说该路径不支持。不顺手给 embed 加透传:embedding 没有采样一说,issue 也未提出诉求(YAGNI);真有需求时再单独设计,届时报错会把人引到正确的地方,而静默不会。
报错文案必须指路而非只说不支持——dimensions 是 OpenAI embeddings 的正式参数,下游想调向量维度时会第一个撞上这道门,文案应写明"embedding 路径暂不支持 extra_body,需要 dimensions 等参数请提 issue"。
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) |
| transport 层再兜一次保护键校验 | 否决 | 三个入口已构造期收口,重复校验属 gold-plating |
4. 非功能维度
| 维度 | 回答 |
|---|---|
| 并发 | 无新增共享状态。extra_body 装配后只读(MappingProxyType);调用级 overlay 每调用独立拷贝,并发调用互不可见 |
| 取消 | 无新增 await 点与等待循环,CancelledError 穿透路径完全不变 |
| 降级方向 | 不涉及新后端。遥测新列写失败沿用既有逐行 warning 降级;缓存 key 变更不影响 Redis 掉线的静默降级方向 |
| 幂等与重复 | 保护键校验是纯函数,重复调用安全;遥测补列先探测后 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 → ValueError(决策 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 |
| 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 的装配期拒绝)、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)、任何任务外重构。