From 20fd899d938ee0d561536d67e364554933d21d82 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 31 Jul 2026 11:40:14 -0400 Subject: [PATCH 01/16] 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 未提出诉求)、任何任务外重构。 From 0cc89fb03c457f6d1430f62560ab87958e67cdf5 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 31 Jul 2026 11:57:39 -0400 Subject: [PATCH 02/16] docs: harden the sampling design against the reviewer findings 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. --- .../2026-07-31-sampling-params-design.md | 112 ++++++++++++++---- 1 file changed, 89 insertions(+), 23 deletions(-) diff --git a/research-wiki/designs/2026-07-31-sampling-params-design.md b/research-wiki/designs/2026-07-31-sampling-params-design.md index 04ee419..d85a3bc 100644 --- a/research-wiki/designs/2026-07-31-sampling-params-design.md +++ b/research-wiki/designs/2026-07-31-sampling-params-design.md @@ -21,7 +21,7 @@ | `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` 对该源无效果 | +| minimax / openai profile 均 `thinking_off={}` | `providers.py:49,56` | `enable_thinking=False` 对两源均无效果 | **issue 未提及但必须一并处理的**: 缓存与遥测的交互。不处理的话,failure mode 恰是 issue 自己最担心的那种——数字悄悄不可比,且不报错。 @@ -33,7 +33,7 @@ | 层 | 载体 | 用途 | 生效点 | |---|---|---|---| -| 调用级 | `chat(..., overlay: Mapping[str, Any] \| None = None)` | 逐次变化(每 rollout 不同的 `seed`) | 填入 `ChatRequest.overlay` | +| 调用级 | `chat(..., overlay: Mapping[str, Any] \| None = None)` | 逐次变化(每 rollout 不同的 `seed`) | 填入 `ChatRequest` | | 配置级 | `SourceConfig.extra_body: Mapping[str, Any]` | 全局恒定(`temperature=0`) | transport `_build_payload` | 优先级 **结构化注入 > 调用级 > 配置级**,无需任何新机制: @@ -47,6 +47,10 @@ 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 中。理由逐条: @@ -58,46 +62,79 @@ StructuredMW: {**request.overlay, **strategy_overlay} ← 结构化已在 | `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`。 +transport 不重复校验:三个 overlay 来源(chat 参数、SourceConfig 字段、库内策略)已全部在构造期收口,库内策略只注入 `response_format`(`json_repair.py:41` 恒空,`native_schema.py:21-29` 只产该键)。 ### 决策 C: 调用级 overlay 进缓存 key —— 本设计的关键点 不做的话:同 messages 跑 5 个 seed,后 4 次命中第一次的缓存,返回同一 response,**标准差恒为 0**,实验静默作废。这正是「无缓存毒化」铁律的场景。 -层序天然正确:`CacheMW` 在 `StructuredMW` **外侧**,它看到的 `request.overlay` 恰好只含调用方传入的部分,结构化注入不会污染 key。 - -key 公式扩展(ARCH §7.5 需同步修订): +key 公式扩展(ARCH §7.5 需同步修订),读 `request.sampling` 而非 `request.overlay`——语义明确、不依赖"CacheMW 恰在 StructuredMW 外侧"这一层序巧合: ```text -key_obj = {model, messages_digest, namespace, [salt], [overlay]} - 仅非 None 仅非空 +key_obj = {model, messages_digest, namespace, [salt], [sampling]} + 仅非 None 仅非空 ``` -`overlay` 沿用 `salt` 的「仅非空时参与」写法,保证**空 overlay 时旧键逐字不变**,不触发存量缓存全量冷启动。 +沿用 `salt` 的「仅非空时参与」写法,保证**空采样参数时旧键逐字不变**,不触发存量缓存全量冷启动。 -配置级同理:`model_fingerprint` 从 `",".join(sorted(models))` 扩展为——所有源 `extra_body` 皆空时字面不变;否则追加 `"|" + sha256(canonical_json(sorted 去重的 (model, extra_body) 二元组))`。取 `(model, extra_body)` 而非 `(name, ...)`,语义是「本 scope 会用哪些(模型,解码参数)组合」,改源名不会误触冷启动。 +配置级同理:`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**。这是正确语义而非缺陷,但下游要知道缓存对这条路径不再省钱。 +**两条已知副作用(须写进 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`——`source.extra_body` 与 `request.overlay` 合并后的 canonical JSON;两者皆空时 `None`。 -- 记录的是**实际发出的合并结果**,含结构化注入的 `response_format`(RetryMW 的 emit 点在 StructuredMW 内侧)。schema 会让该列变大,但相对同行的完整 `messages` 增量有限,可接受。 -- 两个后端按 issue #3 已建立的套路幂等补列:**先探测缺列再 ALTER**、失败只逐行降级不置结构性失能标志、新列排在 `created_at` 之后。 +**列语义定死**:`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 迁移。 -### 决策 E: 入参拷贝语义 +**OCR/Embedding 路径零改动**:`ocr.py:418` 与 `embedding.py:372` 也调 `emit_attempt` 且都传 `source`,只要 `sampling` 由 emitter 内部推导(而非作为新必填参数由调用者传入),这两个文件不动一行。反之则立刻 TypeError——实施时必须走推导路线。 -`chat()` 对传入 overlay 做 `dict(overlay)` 浅拷贝。issue 场景就是逐次改 `seed`——调用方复用同一 dict 对象改值是极可能的模式,不拷贝会出现「请求已发出、key 用了新 seed」的竞态。`ChatRequest` 虽 frozen 但 dict 是浅冻结,拦不住。`SourceConfig.extra_body` 在 `__post_init__` 转 `MappingProxyType` 同理(成本近零)。 +### 决策 E: 入参拷贝语义与两条只读约束 -### 决策 F: minimax profile 的诚实性缺口(issue 附带项) +`chat()` 对传入 overlay 做**一次** `dict(overlay)` 浅拷贝,同一份快照对象同时填 `overlay` 与 `sampling` 两个字段(不做两份独立拷贝——它们在进入 `StructuredMW` 之前本就应当逐字相同,两份拷贝反而给"两者可以分叉"留了口子)。 -`minimax` 与 `openai` 的 `thinking_on/thinking_off` 均为空字典,但只有 `openai` 处有注释说明是有意为之。补一行注释说明 MiniMax 无已知关闭推理的请求参数、该档对本 provider 无效果——调用方以为关掉了实际没关,是诚实性问题。不改行为(有了 `extra_body` 需要时可绕过)。 +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"。 --- @@ -109,6 +146,9 @@ key_obj = {model, messages_digest, namespace, [salt], [overlay]} | 配置级放装配层全局字典而非 `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 | --- @@ -123,6 +163,7 @@ key_obj = {model, messages_digest, namespace, [salt], [overlay]} | **幂等与重复** | 保护键校验是纯函数,重复调用安全;遥测补列先探测后 ALTER,重启幂等 | | **持久化与原子性** | 遥测单行写入,无部分写入风险。缓存 value 结构不变(`sampling` 只进遥测不进 `LLMResponse`,避免动已被三项目消费的公共类型) | | **重试交互** | overlay 在 RetryMW 循环外确定,换源重试时同一 overlay 应用到新源的 `extra_body` 之上——语义正确(调用级意图跨源保持) | +| **限流交互** | overlay 里的 `max_tokens` 不影响入场预扣(取 `effective_est_tokens()`)。调用方把 `max_tokens` 抬到远超预扣量时 TPM 入场保护会短暂失真,结算侧(`retry.py:338-343`)按实测用量回填自愈。已知且可接受,不为此加机制 | --- @@ -141,8 +182,17 @@ key_obj = {model, messages_digest, namespace, [salt], [overlay]} | 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 | +| 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 可依。 --- @@ -150,10 +200,26 @@ key_obj = {model, messages_digest, namespace, [salt], [overlay]} 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 新增与遥测端口扩列。 +同步清单(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`(保护键校验函数 + `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`(注释)。 +`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`(注释)。 -不做:OCR/embedding 路径(走独立端口,issue 未提出诉求)、任何任务外重构。 +**测试侧必改**(否则直接红):`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)、任何任务外重构。 From 09e77f11f8d7cfe2fc7c9e6b0d59a0a5b2ba92cf Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 31 Jul 2026 12:00:20 -0400 Subject: [PATCH 03/16] docs: register the sampling design in the research wiki --- research-wiki/designs/sampling-params.md | 17 +++++++++++++++++ research-wiki/graph/edges.json | 5 +++++ research-wiki/index.md | 6 ++++-- research-wiki/log.md | 3 +++ 4 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 research-wiki/designs/sampling-params.md diff --git a/research-wiki/designs/sampling-params.md b/research-wiki/designs/sampling-params.md new file mode 100644 index 0000000..44a8c4f --- /dev/null +++ b/research-wiki/designs/sampling-params.md @@ -0,0 +1,17 @@ +--- +type: design +node_id: design:sampling-params +title: "采样参数透传设计(issue #4)" +date: 2026-07-31 +--- + +# 采样参数透传设计(issue #4) + +正文: `2026-07-31-sampling-params-design.md`。状态: 待人类审批。 + +- **选定方案**: 两层入口——调用级 `chat(..., overlay=)` 供逐 rollout 变化的 `seed`,配置级 `SourceConfig.extra_body`(env 键 `{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY`,JSON 串)供恒定的 `temperature=0`。优先级 **结构化注入 > 调用级 > 配置级** 由现有层序天然给出,不加新机制。 +- **issue 未提但必须一并处理的四件事**: ① 采样参数进缓存 key(否则 5 个 seed 全命中同一缓存、标准差恒为 0,实验静默作废——「无缓存毒化」铁律);② 保护键黑名单 `{model, messages, stream, stream_options}` 与值可 JSON 序列化,均在构造期报错(覆盖它们会击穿流式看门狗、成本遥测与 TPM 结算;不可序列化的值会在 `CacheMW` 降级 try 之外抛裸 `TypeError`,一行遥测都没有);③ 采样参数入遥测(端口 20 → 21 字段,列名 `sampling`);④ 入参拷贝语义。 +- **关键结构决策**: `ChatRequest` 增 `sampling` 快照字段作为**跨洋葱层恒定的读取点**。`request.overlay` 在 `StructuredMW` 内侧含 `response_format`、外侧不含,缓存 key 与三个遥测 emit 入口若各读各的层就会口径分叉。`sampling` 列语义定死为「调用方意图 ⊎ 生效源 `extra_body`」,**不含**结构化注入。 +- **被否决备选及理由**: `chat()` 展开为 `temperature=`/`seed=` 具名参数(供应商私有参数无穷尽,等于永久追加签名,违「深模块窄接口」);配置级放装配层全局字典(采样参数与源强相关,会把无效键发给不认识它的源);overlay 不进 key 靠调用方传 `cache_salt`(把毒化防护责任推给调用方,漏传不报错——正是 issue 抱怨的失败形态);采样参数不入遥测由下游 run 快照自记(中间态数据不可追溯,且分两步要做两遍 DDL 迁移);缓存与遥测直接读 `request.overlay` 不加 `sampling` 字段(口径必分叉);`sampling` 记含 `response_format` 的完整合并结果(列名为采样参数,且数 KB schema 逐行落库无谓膨胀);给 embedding 加 `extra_body` 透传(embedding 无采样一说,装配期报错比静默无效更能指路);transport 层重复校验保护键(三入口已构造期收口,属 gold-plating)。 +- **附带修正**: `providers.py` 的 `minimax`/`openai` 空 thinking profile 补后果说明(`enable_thinking=False` 对两者不产生效果,调用方以为关掉了实际没关);`_SOURCE_FIELDS` 跨 scope 共用导致 `EXTRA_BODY` 在 OCR/EMBED scope 静默无效,改为装配期拒绝。 +- **审查留痕**: Codex CLI 不可用(vendor 二进制缺失),改派全新上下文 subagent 两轮只读审查。首轮报 5 项必修(三个 emit 入口口径分叉、OCR/embedding 耦合、JSON 序列化缺口、注释归属写反、同步清单漏 4 处),逐条核实后全部采纳;次轮结论通过,其 5 条建议(承重不变式测试、`sampling` 类型定死、拷贝语义跟进、共用范围收窄、报错文案指路)亦已就地收进。 diff --git a/research-wiki/graph/edges.json b/research-wiki/graph/edges.json index d044ef8..a138225 100644 --- a/research-wiki/graph/edges.json +++ b/research-wiki/graph/edges.json @@ -120,6 +120,11 @@ "id": "plan:response-observability-fields", "label": "响应可观测字段扩展实现计划", "type": "plan" + }, + { + "id": "design:sampling-params", + "label": "采样参数透传设计(issue #4)", + "type": "design" } ], "links": [ diff --git a/research-wiki/index.md b/research-wiki/index.md index f3ea093..31e40b5 100644 --- a/research-wiki/index.md +++ b/research-wiki/index.md @@ -1,8 +1,8 @@ # Research Wiki 索引 -> 自动生成,更新时间:2026-07-31 12:25 UTC +> 自动生成,更新时间:2026-07-31 16:00 UTC -## design (18) +## design (20) - [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design` - [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design` - [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design` @@ -12,6 +12,7 @@ - [2026-07-30-est-tokens-decoupling-design](designs/2026-07-30-est-tokens-decoupling-design.md) `design:2026-07-30-est-tokens-decoupling-design` - [2026-07-30-settings-invariants-round-2-design](designs/2026-07-30-settings-invariants-round-2-design.md) `design:2026-07-30-settings-invariants-round-2-design` - [2026-07-31-response-observability-fields-design](designs/2026-07-31-response-observability-fields-design.md) `design:2026-07-31-response-observability-fields-design` +- [2026-07-31-sampling-params-design](designs/2026-07-31-sampling-params-design.md) `design:2026-07-31-sampling-params-design` - [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling` - [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2` - [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards` @@ -21,6 +22,7 @@ - [M3 OCR 端口族设计](designs/m3-ocr.md) `design:m3-ocr` - [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration` - [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields` +- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params` ## finding (11) - [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload` diff --git a/research-wiki/log.md b/research-wiki/log.md index 50cc40f..619407c 100644 --- a/research-wiki/log.md +++ b/research-wiki/log.md @@ -64,3 +64,6 @@ - [2026-07-31 11:10 UTC] 重建索引: 46 篇页面 - [2026-07-31 11:11 UTC] 重建索引: 46 篇页面 - [2026-07-31 12:25 UTC] 重建索引: 46 篇页面 +- [2026-07-31 15:57 UTC] 新增 design: 采样参数透传设计(issue #4) (design:sampling-params) +- [2026-07-31 15:57 UTC] 重建索引: 48 篇页面 +- [2026-07-31 16:00 UTC] 重建索引: 48 篇页面 From b24e224beb2c24fcde8d9f897a419728aa7f6731 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 31 Jul 2026 12:31:31 -0400 Subject: [PATCH 04/16] docs: soften the non-chat extra_body gate to strip-and-warn Stripping is load-bearing: without it telemetry would record a sampling parameter that was never sent on the OCR and embedding paths. --- .../2026-07-31-sampling-params-design.md | 29 ++++++++++++------- research-wiki/designs/sampling-params.md | 2 +- research-wiki/index.md | 2 +- research-wiki/log.md | 1 + 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/research-wiki/designs/2026-07-31-sampling-params-design.md b/research-wiki/designs/2026-07-31-sampling-params-design.md index d85a3bc..55776ef 100644 --- a/research-wiki/designs/2026-07-31-sampling-params-design.md +++ b/research-wiki/designs/2026-07-31-sampling-params-design.md @@ -112,7 +112,7 @@ key_obj = {model, messages_digest, namespace, [salt], [sampling]} 一次做完而非分两步:「能传参数但没记」的中间状态最危险——数据已产生且事后无法追溯,且分步要做两遍 DDL 迁移。 -**OCR/Embedding 路径零改动**:`ocr.py:418` 与 `embedding.py:372` 也调 `emit_attempt` 且都传 `source`,只要 `sampling` 由 emitter 内部推导(而非作为新必填参数由调用者传入),这两个文件不动一行。反之则立刻 TypeError——实施时必须走推导路线。 +**OCR/Embedding 的 emit 调用点零改动**:`ocr.py:418` 与 `embedding.py:372` 也调 `emit_attempt` 且都传 `source`,只要 `sampling` 由 emitter 内部推导(而非作为新必填参数由调用者传入),这两处调用不动一行——反之立刻 TypeError,实施时必须走推导路线。两个文件本身仍有改动,即决策 G 的构造期剥离(它正是让这里的推导对 OCR/embedding 恒得 NULL 的前提)。 ### 决策 E: 入参拷贝语义与两条只读约束 @@ -128,13 +128,19 @@ issue 场景就是逐次改 `seed`——调用方复用同一 dict 对象改值 补的是这一句后果说明(覆盖两个 provider),不是重复已有的"为何为空"。不改行为:真需要关时经 `extra_body` 绕过。 -### 决策 G: 非 chat 路径的 `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` 列,但 `OpenAICompatTransport.embed`(`openai_compat.py:343`,payload 硬编码 `{"model", "input"}`)与 `monkey_ocr`(multipart 表单)都不消费它——**静默无效**,正是 §4.5 要禁的形态。 +`_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 要禁的形态。 -处置:`EmbeddingClient` / `OcrClient` 构造期若发现源带非空 `extra_body` → `ValueError`,明说该路径不支持。不顺手给 embed 加透传:embedding 没有采样一说,issue 也未提出诉求(YAGNI);真有需求时再单独设计,届时报错会把人引到正确的地方,而静默不会。 +**处置(2026-07-31 人类拍板改此档)**:`EmbeddingClient` / `OcrClient` 构造期发现源带非空 `extra_body` → 记 warning 并 `dataclasses.replace(source, extra_body={})` **剥离后放行**,不抛异常。 -报错文案必须**指路**而非只说不支持——`dimensions` 是 OpenAI embeddings 的正式参数,下游想调向量维度时会第一个撞上这道门,文案应写明"embedding 路径暂不支持 `extra_body`,需要 `dimensions` 等参数请提 issue"。 +剥离是这一档的**必要组成部分,不是顺手清理**。`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);真有需求时单独设计。 --- @@ -148,7 +154,10 @@ issue 场景就是逐次改 `seed`——调用方复用同一 dict 对象改值 | 采样参数不入遥测,由下游 run 快照自记 | 否决 | 见决策 D | | 缓存 key 与遥测都直接读 `request.overlay`,不加 `sampling` 字段 | 否决 | `overlay` 在洋葱不同深度取值不同(结构化注入),三个 emit 入口与 CacheMW 会各记各的,同一列口径分叉 | | `sampling` 列记「实际发出的完整合并结果」(含 `response_format`) | 否决 | 该列名为采样参数,schema 不是;且数 KB schema 逐行落库无谓膨胀 | -| 给 embedding 路径也加 `extra_body` 透传 | 否决 | embedding 无采样一说,issue 未提诉求;装配期报错比静默无效更能把人引到对的地方(决策 G) | +| 给 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 | --- @@ -159,7 +168,7 @@ issue 场景就是逐次改 `seed`——调用方复用同一 dict 对象改值 |---|---| | **并发** | 无新增共享状态。`extra_body` 装配后只读(MappingProxyType);调用级 overlay 每调用独立拷贝,并发调用互不可见 | | **取消** | 无新增 await 点与等待循环,`CancelledError` 穿透路径完全不变 | -| **降级方向** | 不涉及新后端。遥测新列写失败沿用既有逐行 warning 降级;缓存 key 变更不影响 Redis 掉线的静默降级方向 | +| **降级方向** | 不涉及新后端。遥测新列写失败沿用既有逐行 warning 降级;缓存 key 变更不影响 Redis 掉线的静默降级方向。决策 G 的剥离 + warning 是**配置面**降级(装配期一次性、可复现、部署即暴露),与铁律里"限流/熔断后端不可用须报错"的**运行时**降级方向是两回事,不冲突 | | **幂等与重复** | 保护键校验是纯函数,重复调用安全;遥测补列先探测后 ALTER,重启幂等 | | **持久化与原子性** | 遥测单行写入,无部分写入风险。缓存 value 结构不变(`sampling` 只进遥测不进 `LLMResponse`,避免动已被三项目消费的公共类型) | | **重试交互** | overlay 在 RetryMW 循环外确定,换源重试时同一 overlay 应用到新源的 `extra_body` 之上——语义正确(调用级意图跨源保持) | @@ -184,7 +193,7 @@ issue 场景就是逐次改 `seed`——调用方复用同一 dict 对象改值 | 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 | +| 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 | @@ -211,12 +220,12 @@ env 键名沿用既有约定:`{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY`,值为 JSON | 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 | +| 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 的装配期拒绝)、`providers.py`(注释)。 +`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` 断言完整列表与列序。 diff --git a/research-wiki/designs/sampling-params.md b/research-wiki/designs/sampling-params.md index 44a8c4f..18cd798 100644 --- a/research-wiki/designs/sampling-params.md +++ b/research-wiki/designs/sampling-params.md @@ -13,5 +13,5 @@ date: 2026-07-31 - **issue 未提但必须一并处理的四件事**: ① 采样参数进缓存 key(否则 5 个 seed 全命中同一缓存、标准差恒为 0,实验静默作废——「无缓存毒化」铁律);② 保护键黑名单 `{model, messages, stream, stream_options}` 与值可 JSON 序列化,均在构造期报错(覆盖它们会击穿流式看门狗、成本遥测与 TPM 结算;不可序列化的值会在 `CacheMW` 降级 try 之外抛裸 `TypeError`,一行遥测都没有);③ 采样参数入遥测(端口 20 → 21 字段,列名 `sampling`);④ 入参拷贝语义。 - **关键结构决策**: `ChatRequest` 增 `sampling` 快照字段作为**跨洋葱层恒定的读取点**。`request.overlay` 在 `StructuredMW` 内侧含 `response_format`、外侧不含,缓存 key 与三个遥测 emit 入口若各读各的层就会口径分叉。`sampling` 列语义定死为「调用方意图 ⊎ 生效源 `extra_body`」,**不含**结构化注入。 - **被否决备选及理由**: `chat()` 展开为 `temperature=`/`seed=` 具名参数(供应商私有参数无穷尽,等于永久追加签名,违「深模块窄接口」);配置级放装配层全局字典(采样参数与源强相关,会把无效键发给不认识它的源);overlay 不进 key 靠调用方传 `cache_salt`(把毒化防护责任推给调用方,漏传不报错——正是 issue 抱怨的失败形态);采样参数不入遥测由下游 run 快照自记(中间态数据不可追溯,且分两步要做两遍 DDL 迁移);缓存与遥测直接读 `request.overlay` 不加 `sampling` 字段(口径必分叉);`sampling` 记含 `response_format` 的完整合并结果(列名为采样参数,且数 KB schema 逐行落库无谓膨胀);给 embedding 加 `extra_body` 透传(embedding 无采样一说,装配期报错比静默无效更能指路);transport 层重复校验保护键(三入口已构造期收口,属 gold-plating)。 -- **附带修正**: `providers.py` 的 `minimax`/`openai` 空 thinking profile 补后果说明(`enable_thinking=False` 对两者不产生效果,调用方以为关掉了实际没关);`_SOURCE_FIELDS` 跨 scope 共用导致 `EXTRA_BODY` 在 OCR/EMBED scope 静默无效,改为装配期拒绝。 +- **附带修正**: `providers.py` 的 `minimax`/`openai` 空 thinking profile 补后果说明(`enable_thinking=False` 对两者不产生效果,调用方以为关掉了实际没关);`_SOURCE_FIELDS` 跨 scope 共用导致 `EXTRA_BODY` 在 OCR/EMBED scope 静默无效,改为构造期**剥离 + warning**(2026-07-31 人类拍板由原「装配期 `ValueError`」改此档: 这两条路径无采样语义,不值得让下游装配起不来)。**剥离不可省**——不剥离则遥测会记录一个从未发出的参数(决策 D 的 merge 读 `source.extra_body`,而 `monkey_ocr` 只发 multipart、`embed` payload 硬编码),那是数据造假而非参数失效;在 emitter 内特判调用方身份则违「遥测调用点收敛单一 helper」铁律。 - **审查留痕**: Codex CLI 不可用(vendor 二进制缺失),改派全新上下文 subagent 两轮只读审查。首轮报 5 项必修(三个 emit 入口口径分叉、OCR/embedding 耦合、JSON 序列化缺口、注释归属写反、同步清单漏 4 处),逐条核实后全部采纳;次轮结论通过,其 5 条建议(承重不变式测试、`sampling` 类型定死、拷贝语义跟进、共用范围收窄、报错文案指路)亦已就地收进。 diff --git a/research-wiki/index.md b/research-wiki/index.md index 31e40b5..cda038b 100644 --- a/research-wiki/index.md +++ b/research-wiki/index.md @@ -1,6 +1,6 @@ # Research Wiki 索引 -> 自动生成,更新时间:2026-07-31 16:00 UTC +> 自动生成,更新时间:2026-07-31 16:31 UTC ## design (20) - [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design` diff --git a/research-wiki/log.md b/research-wiki/log.md index 619407c..28d7178 100644 --- a/research-wiki/log.md +++ b/research-wiki/log.md @@ -67,3 +67,4 @@ - [2026-07-31 15:57 UTC] 新增 design: 采样参数透传设计(issue #4) (design:sampling-params) - [2026-07-31 15:57 UTC] 重建索引: 48 篇页面 - [2026-07-31 16:00 UTC] 重建索引: 48 篇页面 +- [2026-07-31 16:31 UTC] 重建索引: 48 篇页面 From b12bf6ce79c4ef51592d6df40db164b3dcf11e46 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 31 Jul 2026 13:01:53 -0400 Subject: [PATCH 05/16] docs: plan the sampling parameter implementation (issue #4) Eleven verifiable tasks covering decisions A-G and the 14 test items, with the reviewer-found execution traps written into the tasks. --- research-wiki/graph/edges.json | 12 + research-wiki/index.md | 6 +- research-wiki/log.md | 4 + .../plans/2026-07-31-sampling-params.md | 396 ++++++++++++++++++ research-wiki/plans/sampling-params-plan.md | 17 + 5 files changed, 433 insertions(+), 2 deletions(-) create mode 100644 research-wiki/plans/2026-07-31-sampling-params.md create mode 100644 research-wiki/plans/sampling-params-plan.md diff --git a/research-wiki/graph/edges.json b/research-wiki/graph/edges.json index a138225..2e98396 100644 --- a/research-wiki/graph/edges.json +++ b/research-wiki/graph/edges.json @@ -125,6 +125,11 @@ "id": "design:sampling-params", "label": "采样参数透传设计(issue #4)", "type": "design" + }, + { + "id": "plan:sampling-params-plan", + "label": "采样参数透传实现计划(issue #4)", + "type": "plan" } ], "links": [ @@ -211,6 +216,13 @@ "relation": "implements", "evidence": "计划 T1-T7 逐条实现设计的 A2/B1/C1/D1 四个决策", "added": "2026-07-31T11:10:03.872049+00:00" + }, + { + "source": "plan:sampling-params-plan", + "target": "design:sampling-params", + "relation": "implements", + "evidence": "11 个任务逐条覆盖设计的决策 A-G 与 §5 的 14 条测试清单", + "added": "2026-07-31T16:59:35.657367+00:00" } ] } \ No newline at end of file diff --git a/research-wiki/index.md b/research-wiki/index.md index cda038b..87592ab 100644 --- a/research-wiki/index.md +++ b/research-wiki/index.md @@ -1,6 +1,6 @@ # Research Wiki 索引 -> 自动生成,更新时间:2026-07-31 16:31 UTC +> 自动生成,更新时间:2026-07-31 17:01 UTC ## design (20) - [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design` @@ -37,7 +37,7 @@ - [P6 混合浸泡首跑基线与记分板三重伪击穿修复](findings/p6-soak-baseline.md) `finding:p6-soak-baseline` - [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak` -## plan (14) +## plan (16) - [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan` - [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan` - [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan` @@ -45,6 +45,7 @@ - [2026-07-22-m4-migration-plan](plans/2026-07-22-m4-migration-plan.md) `plan:2026-07-22-m4-migration-plan` - [2026-07-30-est-tokens-decoupling-plan](plans/2026-07-30-est-tokens-decoupling-plan.md) `plan:2026-07-30-est-tokens-decoupling-plan` - [2026-07-31-response-observability-fields](plans/2026-07-31-response-observability-fields.md) `plan:2026-07-31-response-observability-fields` +- [2026-07-31-sampling-params](plans/2026-07-31-sampling-params.md) `plan:2026-07-31-sampling-params` - [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling` - [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan` - [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed` @@ -52,6 +53,7 @@ - [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr` - [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration` - [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields` +- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan` ## schema (1) - [表结构: llm_calls(遥测 20 字段)](schemas/llm-calls.md) `schema:llm-calls` diff --git a/research-wiki/log.md b/research-wiki/log.md index 28d7178..1a27b91 100644 --- a/research-wiki/log.md +++ b/research-wiki/log.md @@ -68,3 +68,7 @@ - [2026-07-31 15:57 UTC] 重建索引: 48 篇页面 - [2026-07-31 16:00 UTC] 重建索引: 48 篇页面 - [2026-07-31 16:31 UTC] 重建索引: 48 篇页面 +- [2026-07-31 16:59 UTC] 新增 plan: 采样参数透传实现计划(issue #4) (plan:sampling-params-plan) +- [2026-07-31 16:59 UTC] 新增边: plan:sampling-params-plan --implements--> design:sampling-params +- [2026-07-31 16:59 UTC] 重建索引: 50 篇页面 +- [2026-07-31 17:01 UTC] 重建索引: 50 篇页面 diff --git a/research-wiki/plans/2026-07-31-sampling-params.md b/research-wiki/plans/2026-07-31-sampling-params.md new file mode 100644 index 0000000..854dd1d --- /dev/null +++ b/research-wiki/plans/2026-07-31-sampling-params.md @@ -0,0 +1,396 @@ +# 实现计划: 采样参数透传(issue #4) + +- **设计**: `research-wiki/designs/2026-07-31-sampling-params-design.md`(2026-07-31 人类批准) +- **分支**: `feat/issue-4-sampling-params` +- **目标**: 让下游能固定解码参数(`temperature`/`seed`/`max_tokens`),且不破坏缓存隔离与遥测诚实性。 +- **方案概述**: `chat()` 增 keyword-only `overlay` 参数(调用级),`SourceConfig` 增 `extra_body` 字段(配置级)。`ChatRequest` 增 `sampling` 快照字段作为跨洋葱层恒定读取点,供缓存 key 与遥测消费。遥测端口 20 → 21 字段。 +- **技术**: Python 3.11+,frozen dataclass,`MappingProxyType`,sqlite3 / asyncpg DDL 幂等补列。 + +**保真校验**: 本计划不涉及 `reference/` 参考实现迁移,保真校验不适用。 + +--- + +## 1. 文件结构 + +| 文件 | 职责变更 | +|---|---| +| `src/polygateway/types.py` | 新增 `validate_request_overlay()` 与 `merge_sampling()` 两个纯函数;`ChatRequest.sampling` 字段;`SourceConfig.extra_body` 字段与构造期校验 | +| `src/polygateway/client.py` | `chat()` 增 `overlay` 参数;`model_fingerprint` 计算纳入 `extra_body` | +| `src/polygateway/middleware/cache.py` | `build_cache_key()` 增 `sampling` 入参并纳入 key | +| `src/polygateway/transports/openai_compat.py` | `_build_payload` 在 thinking profile 之后、overlay 之前应用 `source.extra_body` | +| `src/polygateway/config.py` | `_SOURCE_FIELDS` 增 `EXTRA_BODY`;`_cast` 增 `json` 分支 | +| `src/polygateway/ports.py` | `TelemetryRecorder.record_llm_call` 增第 21 参 `sampling` | +| `src/polygateway/middleware/telemetry.py` | 三个 emit 入口按设计表格产出 `sampling`;`_record` 透传 | +| `src/polygateway/telemetry/sqlite.py` | DDL / `_BACKFILL_COLUMNS` / `_COLUMNS` 增 `sampling` | +| `src/polygateway/telemetry/postgres.py` | DDL / `_BACKFILL` / `_COLUMNS` 增 `sampling` | +| `src/polygateway/ocr.py` / `embedding.py` | 构造期剥离 `extra_body` + warning(决策 G) | +| `src/polygateway/providers.py` | minimax/openai 空 thinking profile 补后果注释(决策 F) | +| `.env.example` / `README.md` / `CHANGELOG.md` / `research-wiki/ARCHITECTURE.md` | 文档同步(设计 §6) | + +**各任务需新增的 import**(现状核实,不加即 NameError): + +| 文件 | 需新增 | +|---|---| +| `types.py` | `from collections.abc import Mapping`、`from types import MappingProxyType`、`import json`。**该文件无 `from __future__ import annotations`**,注解在类体求值,`Mapping` 必须真导入 | +| `client.py` | `import json`、`import hashlib` | +| `config.py` | `import json` | +| `ocr.py` / `embedding.py` | `import dataclasses`(现只有 `from dataclasses import dataclass`)、`from loguru import logger`(若未导入) | +| `middleware/telemetry.py` | `merge_sampling`/`canonical_sampling_json` 需**运行时**导入(现对 `polygateway.types` 只在 `TYPE_CHECKING` 下导入) | + +**关键接口**(跨任务消费,此处定死): + +```python +# types.py —— 两个纯函数 + 两个字段 +_PROTECTED_OVERLAY_KEYS = frozenset({"model", "messages", "stream", "stream_options"}) + +def validate_request_overlay(overlay: Mapping[str, Any], *, origin: str) -> dict[str, Any]: + """校验采样参数覆盖层并返回浅拷贝;origin 用于错误信息定位来源。 + + 保护键会击穿治理(model→成本算错、messages→缓存与遥测口径失真、 + stream/stream_options→绕过看门狗与 usage 帧);值必须 JSON 可序列化, + 否则会在 CacheMW 的降级 try 之外抛裸 TypeError(设计 §决策 B)。 + """ + +def merge_sampling(extra_body: Mapping[str, Any], sampling: Mapping[str, Any]) -> dict[str, Any]: + """合并配置级与调用级采样参数(调用级优先);两者皆空返回空 dict。""" + +def canonical_sampling_json(merged: Mapping[str, Any]) -> str | None: + """遥测列与缓存 key 共用的序列化口径;空 mapping → None。""" + +@dataclass(frozen=True) +class ChatRequest: + ... + overlay: dict[str, Any] = field(default_factory=dict) + sampling: Mapping[str, Any] = field(default_factory=dict) # 新增 + +@dataclass(frozen=True) +class SourceConfig: + ... + extra_body: Mapping[str, Any] = field(default_factory=dict) # 新增,__post_init__ 转 MappingProxyType +``` + +```python +# middleware/cache.py —— 签名扩展(sampling 为 keyword-only) +# 默认值用 None 而非 {}: dict 字面量作默认参数会被 ruff B006 拦下 +def build_cache_key( + model_fingerprint: str, + messages: list[dict[str, Any]], + namespace: str, + salt: str | None, + *, + sampling: Mapping[str, Any] | None = None, +) -> str: ... +``` + +```python +# client.py —— chat() 新签名 +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: ... +``` + +--- + +## 2. 任务清单 + +任务按依赖排序;每个任务一次提交、独立可验证。每个任务合并前必须出示**先失败后通过**的测试证据(先写测试跑红,再实现跑绿)。 + +统一验证命令前缀:`conda run -n PolyGateway --no-capture-output pytest`。 + +> **共享后端纪律**: 涉及 Redis/Postgres 的 integration 测试严禁与其他会话并跑(含 git 钩子触发的测试)。Task 7、Task 11 受此约束。 + +--- + +### - [ ] Task 1: `types.py` 内核 —— 校验与合并纯函数 + 两个新字段 + +**文件**: 改 `src/polygateway/types.py`;测试 `tests/unit/test_types.py` + +**实现行为**: + +1. `validate_request_overlay(overlay, *, origin)`,**校验顺序即下列顺序**: + - 键必须是 `str`,否则 `ValueError`(canonical JSON 要求)。**必须排在序列化试探之前**——`{1: "a", "b": 2}` 在 `sort_keys=True` 下抛的是 `TypeError: '<' not supported between 'str' and 'int'`,若先试序列化会被误报成"值不可 JSON 序列化",指错方向; + - 命中 `_PROTECTED_OVERLAY_KEYS` 任一键 → `ValueError`,信息含 origin、违规键名、以及**为什么**(如 `stream` 会绕过流式看门狗); + - 对整个 mapping 做 `json.dumps(..., sort_keys=True)` 试序列化,`TypeError` → 转 `ValueError` 并指出该值不可 JSON 序列化(信息提示改用 `float(x)` 等原生类型); + - 返回 `dict(overlay)` 浅拷贝。 +2. `merge_sampling(extra_body, sampling)` → `{**extra_body, **sampling}`(调用级优先)。 +3. `canonical_sampling_json(merged)` → 空则 `None`,否则 `json.dumps(merged, sort_keys=True, ensure_ascii=False)`。 +4. `ChatRequest` 增 `sampling` 字段(见 §1 关键接口)。 +5. `SourceConfig` 增 `extra_body` 字段;`__post_init__` 新增 `_validate_extra_body()`:调 `validate_request_overlay(self.extra_body, origin=f"SourceConfig({self.name}).extra_body")`,再 `object.__setattr__(self, "extra_body", MappingProxyType(dict(...)))`(frozen dataclass 需用 `object.__setattr__`)。 + +**已知后果(必须显式接受,不是疏漏)**: `SourceConfig` 加 mapping 字段后**不再 hashable**(`hash()` → `TypeError`),且因 `MappingProxyType` 不可 pickle,`dataclasses.asdict()` / `copy.deepcopy()` 也会失败。 + +- 不可 hash 是**加任何 mapping 字段的固有代价**,与是否用 `MappingProxyType` 无关(裸 `dict` 同样不可 hash),无法规避; +- 库内当前无调用点会踩:`asdict` 只用于 `LLMResponse`/`EmbeddingResponse`(`cache.py:140`),全库无 `set(sources)` 或以源作 dict key 的写法; +- 保留 `MappingProxyType` 而非裸 dict,是因为决策 E 的只读约束值得这个代价;下游要可变副本用 `dict(source.extra_body)`,要改字段用 `dataclasses.replace(source, ...)`(已验证可行,会重跑 `__post_init__` 重新包 proxy,不递归)。 + +**验收标准**: 四个保护键各自触发 `ValueError` 且信息含原因;非 str 键报的是"键必须是 str"而非"不可序列化";`{"temperature": object()}` 类不可序列化值报 `ValueError` 而非 `TypeError`;合法 `{"temperature": 0, "seed": 42}` 通过并返回独立副本(改原 dict 不影响返回值);`SourceConfig.extra_body` 构造后为 `MappingProxyType` 且不可改。 + +**测试要求**: 新增 `tests/unit/test_types.py::TestSamplingValidation`,覆盖上述每条。不可序列化值用 `object()` 实例即可,不引入 numpy 依赖。**另加一条锁定测试**:`pytest.raises(TypeError): hash(source_config)`,把"不再 hashable"钉成有意行为——否则将来有人踩到时会以为是 bug 并"修"回去。 + +**验证**: `pytest tests/unit/test_types.py -v` → 全 PASS + +--- + +### - [ ] Task 2: `config.py` —— `EXTRA_BODY` env 解析 + +**文件**: 改 `src/polygateway/config.py`;测试 `tests/unit/test_config.py` + +**实现行为**: +- `_SOURCE_FIELDS` 增 `"EXTRA_BODY": ("extra_body", "json")`; +- `_cast` 增 `json` 分支:`json.loads` 失败 → `ValueError`(沿用既有 `配置 {key} 解析失败: {exc}` 包装);解析结果**非 dict** → `ValueError`,信息说明必须是 JSON 对象(而非数组/标量)。 + +**验收标准**: `LLM__QWEN__1__EXTRA_BODY={"temperature":0}` → `SourceConfig.extra_body == {"temperature": 0}`;`{invalid` → `ValueError`;`[1,2]` → `ValueError`;`{"model":"x"}` → `ValueError`(经 Task 1 的 `SourceConfig.__post_init__` 保护键校验)。 + +**测试要求**: 新增 4 个 case 覆盖上述。**注意**: 这里同时验证了 Task 1 的校验确实挂在装配路径上。 + +**验证**: `pytest tests/unit/test_config.py -v` → 全 PASS + +--- + +### - [ ] Task 3: `chat()` 入口 + transport 应用 + fingerprint + +**文件**: 改 `src/polygateway/client.py`、`src/polygateway/transports/openai_compat.py`;测试 `tests/unit/test_client.py`、`tests/unit/test_openai_compat.py` + +**实现行为**: + +1. `chat()` 增 `overlay` 参数(见 §1 签名)。进洋葱**之前**: + ```python + validated = validate_request_overlay(overlay or {}, origin="chat(overlay=...)") + ``` + 同一份 `validated` 对象同时填 `ChatRequest.overlay` 与 `.sampling`(设计决策 E:一次拷贝、两个字段指向同一快照,不做两份独立拷贝)。 +2. `_build_payload`:在 thinking profile 之后、`payload.update(overlay)` 之前插入 `payload.update(source.extra_body)`。**顺序即优先级,不可调换**。 +3. `model_fingerprint`(`client.py:117`)改为: + ```python + fingerprint = ",".join(sorted({s.model for s in sources})) + marks = sorted({json.dumps([s.model, dict(s.extra_body)], sort_keys=True, ensure_ascii=False) + for s in sources if s.extra_body}) + if marks: + fingerprint += "|" + hashlib.sha256("".join(marks).encode()).hexdigest() + ``` + 全源 `extra_body` 皆空时字面量与旧实现**逐字相同**。`dict(...)` 是因为 `MappingProxyType` 不能直接进 `json.dumps`。 + +**验收标准**: 配置 `temperature=0` + 调用级 `temperature=1` → payload 中为 1;结构化注入的 `response_format` 覆盖调用级同名键;保护键在 `chat()` 入口即 `ValueError`(未进洋葱,可用 mock handler 断言未被调用);全源无 `extra_body` 时 fingerprint 与旧值逐字相同;有 `extra_body` 时不同;改源 `name` 不改变 fingerprint。 + +**测试要求**: 覆盖设计 §5 测试 #3、#4(chat 侧)、#5、#6(拷贝语义:调用方在 `chat()` 返回后修改自己的 dict,不影响已构造的 request)、#8(不可 JSON 序列化的值在 `chat()` 入口即 `ValueError`,断言洋葱 handler 未被调用)。 + +**验证**: `pytest tests/unit/test_client.py tests/unit/test_openai_compat.py -v` → 全 PASS + +--- + +### - [ ] Task 4: 缓存 key 纳入 `sampling` + +**文件**: 改 `src/polygateway/middleware/cache.py`;测试 `tests/unit/test_cache.py` + +**实现行为**: +- `build_cache_key` 增 keyword-only `sampling` 参数(见 §1 签名),非空时以 `"sampling"` 键并入 `key_obj`(**仅非空参与**,与 `salt` 的"仅非 None"不同——见设计决策 A 末段); +- `CacheMW.__call__` 传 `sampling=request.sampling`(**不是 `request.overlay`**——后者在此层虽尚未被结构化注入污染,但读 `sampling` 才是语义正确且不依赖层序巧合的写法)。 + +**验收标准**: +- 同 messages、不同 `seed` → 两个不同 key,第二次 miss(**issue 场景的直接回归**); +- 空 `sampling` 时 key 与旧实现**逐字相同**——测试须先把旧实现的 key 值固化为常量再比对(现有 `tests/unit/test_cache.py:39-54` 只有相等/不等断言,无 golden hash 可依); +- 同 `sampling` 不同键序 → 同一 key(canonical 序列化)。 + +**测试要求**: 覆盖设计 §5 测试 #1、#2。golden hash 的取法:在改动前先运行一次现有 `build_cache_key` 打印结果,写死进测试。 + +**验证**: `pytest tests/unit/test_cache.py -v` → 全 PASS + +--- + +### - [ ] Task 5: 地基不变式回归(承重) + +**文件**: 测试 `tests/unit/test_structured.py`(或就近的洋葱集成测试文件) + +**实现行为**: 纯测试任务,不改产品代码。 + +落点:`tests/unit/test_structured.py` 里既有的 `ScriptedTerminal` 恰好站在 RetryMW 的位置(`client.py:91` 的 `terminal = RetryMW(...)`,StructuredMW 是最内中间件),扩写它即可,**无需搭全洋葱**。 + +断言:走结构化重问阶梯(强制至少重问一次,用先返回坏 JSON 再返回好 JSON 的 scripted terminal)后—— +1. terminal 每次收到的 `request.sampling` 与**构造 `ChatRequest` 时传入的 `sampling`** 逐字相同; +2. 同一时刻 `request.overlay` **含** `response_format`(证明两者确实分叉,`sampling` 不是冗余字段)。 + +**为什么单列一个任务**: 决策 C 与 D 都建立在"`sampling` 跨层恒定"之上,而这条目前只靠"`dataclasses.replace` 恰好保留未提及字段"的约定成立,无任何机械执法。这条测试同时钉死决策 A 的"库内中间件永不修改"与决策 E 的只读约束。缺它则约束被破坏时无人发现。 + +**验收标准**: 该测试在故意把 `structured.py` 的 `replace` 改成重建 `ChatRequest`(丢掉 `sampling`)时**必须变红**——实施时须实际验证这一点,否则测试是空的。 + +**验证**: `pytest tests/unit/test_structured.py -v` → 全 PASS,且上述"故意破坏"实验红过一次 + +--- + +### - [ ] Task 6: 遥测端口扩至 21 字段 + 三入口口径 + +**文件**: 改 `src/polygateway/ports.py`、`src/polygateway/middleware/telemetry.py`;测试 `tests/unit/test_telemetry.py` + +**实现行为**: + +1. `ports.TelemetryRecorder.record_llm_call` 增第 21 参 `sampling: str | None`(排在 `model_reported` 之后)。 +2. `TelemetryEmitter._record` 增同名参数并透传给 recorder。 +3. 三个入口按设计决策 D 的表格产出(**不含**结构化注入的 `response_format`): + + | 入口 | `sampling` 取值 | + |---|---| + | `emit_attempt` | `canonical_sampling_json(merge_sampling(source.extra_body, request.sampling))` | + | `emit_cache_hit` | `canonical_sampling_json(request.sampling)` | + | `emit_terminal_failure` | `canonical_sampling_json(request.sampling)` | + + 后两者无 `source` 可言(由最外层 TelemetryMW 调用),与 `model`/`provider`/`source_name` 在终态行置空是同一先例。 + +**关键约束**: `sampling` 必须由 emitter **内部推导**,**不得**作为新必填参数由调用者传入——否则 `ocr.py:418` 与 `embedding.py:372` 立刻 TypeError。 + +**验收标准**: 三个入口各自的 `sampling` 值符合上表;`response_format` **三行都不出现**;`request.sampling` 与 `source.extra_body` 皆空时为 `None`。 + +**测试要求**: 覆盖设计 §5 测试 #9。用 fake recorder 捕获 kwargs 断言。 + +**验证**: `pytest tests/unit/test_telemetry.py -v` → 全 PASS + +--- + +### - [ ] Task 7: 两个遥测后端落列 + 幂等补列 + +**文件**: 改 `src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`;测试 `tests/unit/test_telemetry.py`、`tests/integration/test_postgres_telemetry.py` + +**实现行为**(逐字沿用 issue #3 建立的套路): + +- **sqlite.py**: DDL 在 `model_reported` **之后**加 `sampling TEXT`;`_BACKFILL_COLUMNS` 追加 `("sampling", "TEXT")`;`_COLUMNS` 末尾追加 `"sampling"`。 +- **postgres.py**: DDL 同位置加 `sampling TEXT`;`_BACKFILL` 追加 `("sampling", "ALTER TABLE llm_calls ADD COLUMN sampling TEXT")`;`_COLUMNS` 末尾追加。 +- 两处 `record_llm_call(**fields)` 按 `_COLUMNS` 取值,**无需改动**。 + +**硬约束**: 新列必须排在 `created_at` **之后**(两文件既有注释已说明理由:旧表只能 ALTER 追加到末尾,新建库若插在前面,两条路径物理列序分叉)。补列一律**先探测缺列再 ALTER**;失败只逐行降级,**绝不置结构性失能标志**(postgres 的 `_failed`)。 + +**连带必改**(不改则直接红): + +| 位置 | 改什么 | 不改的后果 | +|---|---|---| +| `tests/unit/test_telemetry.py:78-102` 的 `_record_minimal()` | `fields` dict 加 `"sampling": None` | **两侧所有落库测试全红**:`sqlite.py:126` / `postgres.py:161` 的 `row = tuple(fields[col] for col in _COLUMNS)` **在 try 之外**,`_COLUMNS` 加列后抛裸 `KeyError: 'sampling'` 冒泡出 `record_llm_call` | +| `tests/integration/test_postgres_telemetry.py:88-109` 的 `_record_minimal()` | 同上 | 同上 | +| `tests/unit/test_telemetry.py:18` 的 `_EXPECTED_COLUMNS` | 追加 `"sampling"` | 列序断言红 | +| `tests/integration/test_postgres_telemetry.py:22` 的 `_EXPECTED_COLUMNS` | 追加(另见 `:210,231` 引用点) | 列序断言红 | +| `tests/unit/test_ports.py:95-119` 的 `_DummyRecorder.record_llm_call` | 显式 20 参签名同步为 21 | **不会红**(`runtime_checkable` 的 isinstance 只查方法存在不查签名),但会与端口脱节,顺带同步 | +| `sqlite.py:123` docstring、`test_telemetry.py:1` 文案 | "20 字段" → "21 字段" | 无功能影响,文案与事实脱节 | + +**验收标准**: 新建库列序正确;对**已存在的 20 列旧表**能幂等补列且补后列序与新建库一致;重复初始化不报错;补列失败(模拟只有 INSERT 权限)时仅 warning、后续写入不被禁用。 + +**测试要求**: 覆盖设计 §5 测试 #11、#12。Postgres 部分是 integration,**须独占 PG `polygateway` 库时序,严禁并跑**。 + +**验证**: +- `pytest tests/unit/test_telemetry.py -v` → 全 PASS +- `pytest tests/integration/test_postgres_telemetry.py -v` → 全 PASS(确认无其他会话在用 PG) + +--- + +### - [ ] Task 8: 决策 G —— OCR/embedding 构造期剥离 + warning + +**文件**: 改 `src/polygateway/ocr.py`、`src/polygateway/embedding.py`;测试 `tests/unit/test_ocr_client.py`、`tests/unit/test_embedding.py` + +**实现行为**: 两个 `__init__` 在既有校验块(`quota_full` 域校验附近)之后、`self._sources = list(sources)` 之前: + +```python +stripped = [] +for src in sources: + if src.extra_body: + logger.warning( + "{} 路径暂不支持 extra_body,源 {} 的该配置已被忽略" + "(需要 dimensions 等参数请提 issue): {}", + <"embedding"|"OCR">, src.name, dict(src.extra_body), + ) + src = dataclasses.replace(src, extra_body={}) + stripped.append(src) +self._sources = stripped +``` + +**剥离不是顺手清理,是承重的**: 不剥离则 Task 6 的 `merge_sampling(source.extra_body, ...)` 会让遥测**记录一个从未发出的参数**——`monkey_ocr.py:225,247` 只发 multipart `files=`(根本没有 JSON body),`openai_compat.py:343` 的 embed payload 硬编码 `{"model","input"}`。那是数据造假而非参数失效。替代方案(emitter 内特判调用方身份)违「遥测调用点收敛单一 helper」铁律,已否决。 + +**验收标准**: 带 `extra_body` 的源 → 装配**成功**(不抛异常)、记一条 warning、`client._sources` 上 `extra_body` 为空;该路径遥测 `sampling` 列为 `None`;不带 `extra_body` 时无 warning。 + +**测试要求**: 覆盖设计 §5 测试 #10。**后半段(遥测 `sampling` 为 None)是防遥测造假的真正断言,不可省**——只断言"装配成功 + 有 warning"是不够的。用 `caplog`/loguru 捕获断言 warning 存在。 + +**验证**: `pytest tests/unit/test_ocr_client.py tests/unit/test_embedding.py -v` → 全 PASS + +--- + +### - [ ] Task 9: 决策 F —— 空 thinking profile 的后果注释 + +**文件**: 改 `src/polygateway/providers.py` + +**实现行为**: 给 `openai`(`:46-51`)与 `minimax`(`:53-58`)两个 profile 各补一句**后果**说明:`enable_thinking=False` 对本 provider 不产生任何效果,需要关闭推理请用 `SourceConfig.extra_body`。 + +**注意**: `:52` 那条既有注释(「OpenAI 兼容基线,无已知注入差异」)在词法上属于紧随其后的 **minimax** 条目,`openai` 条目**没有**任何注释。补的是"后果"而非重复"为何为空"——不要写出与既有注释重复或矛盾的内容。 + +**验收标准**: 两个 profile 都能让读者明白 `enable_thinking=False` 对它们无效。纯注释变更,无行为变化。 + +**测试要求**: 无(纯注释)。此任务不单独提交,与 Task 10 合并提交。 + +**验证**: `make lint` → PASS + +--- + +### - [ ] Task 10: 文档同步(设计 §6 清单) + +**文件**: 改 `.env.example`、`README.md`、`CHANGELOG.md`、`research-wiki/ARCHITECTURE.md` + +| 目标 | 具体改动 | +|---|---| +| `.env.example` | 在 `LLM__QWEN__1__TRUST_ENV` 注释行(`:20`)后加 `# LLM__QWEN__1__EXTRA_BODY={"temperature":0}` 及说明(JSON 对象串;保护键会报错;OCR/EMBED scope 会被忽略并 warning)。`client.py:251` docstring 声明本文件是键名清单事实源,漏写等于新键无处可查 | +| `README.md:83` | 该行逐一列举 `chat()` 关键字参数,补 `overlay` 及一句用途 | +| ARCH §5.2 | `chat()` 签名定稿段追加 `overlay` 要点(带默认值的 keyword-only,不破坏"调用点零改动"承诺) | +| ARCH §7.5 | key 公式补 `sampling` 项 + 两条已知副作用(seed 进 key 导致该路径必 miss;`model_fingerprint` 是集合级指纹,同 scope 各源 `extra_body` 不同时仍可能跨源命中) | +| ARCH §7.7(`:451`) | 该节逐字段枚举 `SourceConfig` 构成(`name/provider/.../enable_thinking`),补 `extra_body` | +| ARCH §7.8(`:463`) | 必录字段 20 → 21,补 `sampling` 及其列语义(不含 `response_format`) | +| ARCH §9(`:519-527`) | 配置面键族事实源,登记 `{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY` | +| `CHANGELOG.md` | 公共 API 新增(`chat(overlay=)`、`SourceConfig.extra_body`)+ 遥测端口扩列 | + +**Gitea Wiki 同步**(`docs-convention.md` §2,CLAUDE.md §6 标为硬门)。本次同时命中该表两行: + +| 命中行 | 必同步页 | +|---|---| +| 新公共 API / 新能力 | 对应指南页(新增「固定解码参数」内容,落在 `指南-遥测与成本` 或新页)+ `参考-公共API`(`chat()` 签名、`SourceConfig.extra_body`)+ `_Sidebar.md` + CHANGELOG | +| 新增配置键 | `参考-配置键`(登记 `{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY`)+ 相关指南页的配置片段 + `.env.example` | + +指南页必须写明三条坑:① `seed` 逐次变化时该路径缓存**必 miss**;② `model_fingerprint` 是集合级指纹,同 scope 各源 `extra_body` 不同时仍可能跨源命中(要逐源可复现需每源独享 scope 或 namespace);③ OCR/EMBED scope 的 `EXTRA_BODY` 会被忽略并 warning。 + +**验收标准**: 每条都能在文件中指到具体位置;ARCH 的改动与设计文档不矛盾;wiki 两行清单逐页落实。 + +**测试要求**: 无(纯文档)。与 Task 9 合并提交。 + +**验证**: `make lint` → PASS + +--- + +### - [ ] Task 11: 全链路集成验证与合并前检查 + +**文件**: 测试 `tests/integration/`(就近文件或新增) + +**实现行为**: 端到端断言采样参数经 `chat()` → 选源 → transport payload 到达请求体(设计 §5 测试 #13),用 fake HTTP 层捕获实际 payload。 + +**合并前门(逐条出示证据)**: +1. `make ci` → 全绿(`make lint` + `make test` + 覆盖率) +2. import-linter 契约无新违规(校验函数落最内层 `types.py`,分层关系不变) +3. 设计 §5 的 14 条测试全部有对应实现,逐条对应到具体测试函数名 +4. 派**全新上下文** verifier subagent 独立验证(CLAUDE.md §3.2 里程碑级/跨多文件硬门) + +**验证**: +- `make ci` → 全 PASS(**不要**在外面套 `conda run`:`Makefile` 每条 target 内部已是 `conda run -n PolyGateway ...`,嵌套会让内层输出被缓冲) +- verifier 报告无 blocking 问题 + +--- + +## 3. 提交节奏 + +| 提交 | 内容 | +|---|---| +| 1 | Task 1(types 内核) | +| 2 | Task 2(env 解析) | +| 3 | Task 3(chat 入口 + transport + fingerprint) | +| 4 | Task 4(缓存 key) | +| 5 | Task 5(地基不变式测试) | +| 6 | Task 6(遥测三入口) | +| 7 | Task 7(两后端落列) | +| 8 | Task 8(决策 G) | +| 9 | Task 9 + 10(注释与文档) | +| 10 | Task 11(集成验证,如有修补) | + +每次提交调 `commit` skill。Task 1-4 是 issue 诉求的最小闭环;Task 5-8 是设计中"issue 未提但必须处理"的部分,**不可跳过**。 diff --git a/research-wiki/plans/sampling-params-plan.md b/research-wiki/plans/sampling-params-plan.md new file mode 100644 index 0000000..8d85d69 --- /dev/null +++ b/research-wiki/plans/sampling-params-plan.md @@ -0,0 +1,17 @@ +--- +type: plan +node_id: plan:sampling-params-plan +title: "采样参数透传实现计划(issue #4)" +date: 2026-07-31 +--- + +# 采样参数透传实现计划(issue #4) + +正文: `2026-07-31-sampling-params.md`。实现 `design:sampling-params`。 + +- **任务数**: 11 个,每个一次提交、独立可验证。Task 1-4 是 issue 诉求的最小闭环;Task 5-8 是设计中「issue 未提但必须处理」的部分(地基不变式、遥测三入口、两后端落列、决策 G 剥离),不可跳过。 +- **关键接口已在计划 §1 定死**: `validate_request_overlay()` / `merge_sampling()` / `canonical_sampling_json()` 三个纯函数落 `types.py`(最内层),`ChatRequest.sampling`、`SourceConfig.extra_body` 两个新字段,`build_cache_key()` 与 `chat()` 的新签名。 +- **审查暴露的执行陷阱(已写进计划)**: ① 两个 `_record_minimal()` 的硬编码 20 键 fields dict 必须同步,否则 `row = tuple(fields[col] for col in _COLUMNS)`(在 try 之外)抛裸 `KeyError` 让两侧落库测试全红;② `SourceConfig` 加 mapping 字段后不再 hashable、`asdict`/`deepcopy` 失效——已核实库内无调用点会踩,作为已知后果显式接受并加锁定测试;③ 各文件需新增的 import 逐一列出(`types.py` 无 `from __future__ import annotations`,注解在类体求值);④ `validate_request_overlay` 的校验顺序必须先查 str 键再试序列化,否则非 str 键会被误报成"值不可序列化"。 +- **测试证据门**: 每个任务合并前须出示先失败后通过的证据。Task 5 单列一条地基不变式回归——决策 C/D 都建立在「`sampling` 跨层恒定」之上,而这条目前只靠 `dataclasses.replace` 的约定,无机械执法;该测试须在故意破坏 `structured.py` 时验证过确实变红。 +- **共享后端纪律**: Task 7、Task 11 涉及 PG `polygateway` 库,严禁与其他会话并跑(含 git 钩子触发的测试)。 +- **审查留痕**: Codex CLI 不可用(vendor 二进制缺失),派全新上下文 subagent 只读审查。报 5 项必修(两处测试文件路径不存在、`_record_minimal` 漏项、Gitea Wiki 同步漏整块、`SourceConfig` 可哈希性后果未声明),逐条核实后全部采纳;5 条建议(import 清单、校验顺序、Task 5 落点表述、`make ci` 勿嵌套 `conda run`、`test_ports.py` 的 `_DummyRecorder` 同步)亦已收进。 From 6023d11bfb0f8dbcfc8218be61bf7db4adf3ae21 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 31 Jul 2026 21:09:03 -0400 Subject: [PATCH 06/16] feat: add sampling overlay validation and source extra_body Three pure helpers in the innermost layer plus ChatRequest.sampling as a cross-layer snapshot, so cache keys and telemetry read one stable value. --- src/polygateway/types.py | 74 +++++++++++++++++++++++++++++++++ tests/unit/test_types.py | 89 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/src/polygateway/types.py b/src/polygateway/types.py index 6b8980a..0aa2326 100644 --- a/src/polygateway/types.py +++ b/src/polygateway/types.py @@ -4,11 +4,24 @@ fake,字段顺序即公共承诺;新增字段只增不删且必带默认值。 """ +import json +from collections.abc import Mapping from dataclasses import dataclass, field +from types import MappingProxyType from typing import Any _MISSING_DONE_DOMAIN = frozenset({"retry", "salvage"}) +_PROTECTED_OVERLAY_KEYS: Mapping[str, str] = MappingProxyType( + { + "model": "会让遥测记录的 model 与实际请求分叉,成本按错单价换算", + "messages": "会同时破坏缓存 key 与遥测的 messages 口径", + "stream": "会绕过流式活性看门狗(TTFT/inter-token 超时全部失效)", + "stream_options": "会丢 usage 帧,导致成本遥测归零、TPM 闸按预扣量结算失准", + } +) +"""禁止出现在采样参数覆盖层里的键: 它们由治理层拥有,被覆盖即击穿治理。""" + USAGE_SOURCES = frozenset({"measured", "estimated", "unavailable"}) """usage_source 值域;仅约束库内生产侧取值,不在 frozen dataclass 上做运行时校验。""" @@ -16,6 +29,47 @@ _EST_TOKENS_QUOTA_DIVISOR = 60 """未显式配置时的预扣量除数: 假定一次调用约占一秒钟的 TPM 配额份额。""" +def validate_request_overlay(overlay: Mapping[str, Any], *, origin: str) -> dict[str, Any]: + """校验采样参数覆盖层并返回浅拷贝;origin 用于把错误指回配置/调用点。 + + 两类校验缺一不可(issue #4 设计决策 B):保护键会击穿治理;不可 JSON + 序列化的值会在 `CacheMW` 的降级 try **之外**抛裸 `TypeError`——那条路径 + 不属错误四分类、`TelemetryMW` 也不捕,结果是一行遥测都没有就崩了。 + 两者都在进洋葱之前收口,故抛裸 `ValueError`(调用方编程错误,不可重试)。 + """ + # Phase 1: 键形态——必须先于序列化试探,否则非 str 键会因 sort_keys 的 + # 比较失败被误报成"值不可序列化",把人指向错误的方向 + for key in overlay: + if not isinstance(key, str): + raise ValueError(f"{origin} 的键必须是 str: {key!r}(canonical JSON 要求)") + # Phase 2: 保护键 + for key, reason in _PROTECTED_OVERLAY_KEYS.items(): + if key in overlay: + raise ValueError(f"{origin} 不得覆盖 {key!r}: {reason}") + # Phase 3: 值可序列化(缓存 key 与遥测列都要 json.dumps) + try: + json.dumps(dict(overlay), sort_keys=True, ensure_ascii=False) + except (TypeError, ValueError) as exc: + raise ValueError( + f"{origin} 的值必须可 JSON 序列化(如 numpy 标量请先转 float/int): {exc}" + ) from exc + return dict(overlay) + + +def merge_sampling( + extra_body: Mapping[str, Any], sampling: Mapping[str, Any] +) -> dict[str, Any]: + """合并配置级与调用级采样参数;调用级优先(issue #4 设计决策 A)。""" + return {**extra_body, **sampling} + + +def canonical_sampling_json(merged: Mapping[str, Any]) -> str | None: + """缓存 key 与遥测 sampling 列共用的序列化口径;空 mapping → None。""" + if not merged: + return None + return json.dumps(dict(merged), sort_keys=True, ensure_ascii=False) + + @dataclass(frozen=True) class LLMResponse: """一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。""" @@ -58,6 +112,12 @@ class ChatRequest: structured: Any | None = None stream: bool = True overlay: dict[str, Any] = field(default_factory=dict) + sampling: Mapping[str, Any] = field(default_factory=dict) + """调用方采样意图的快照,库内中间件**永不修改**(issue #4 设计决策 A)。 + + 与 `overlay` 分开是因为后者会被结构化中间件注入 `response_format`,在洋葱 + 不同深度取值不同;缓存 key 与三个遥测入口需要一个跨层恒定的读取点,否则 + 同一列在不同行口径分叉。""" @dataclass(frozen=True) @@ -118,11 +178,18 @@ class SourceConfig: enable_thinking: bool | None = None missing_done: str = "retry" trust_env: bool = True + extra_body: Mapping[str, Any] = field(default_factory=dict) + """本源恒定的采样参数(如 `temperature=0`),并入请求体(issue #4)。 + + 优先级低于调用级 overlay。注: 本字段令 `SourceConfig` 不再 hashable + (加任何 mapping 字段的固有代价,裸 dict 亦然),库内无以源作 key 的写法; + 要可变副本用 `dict(source.extra_body)`,要改字段用 `dataclasses.replace`。""" def __post_init__(self) -> None: self._validate_identity() self._validate_gates() self._validate_watchdog() + self._freeze_extra_body() def effective_est_tokens(self) -> int: """TPM 入场预扣量: 显式配置优先,否则按 tpm 派生(设计 §2.2)。""" @@ -159,6 +226,13 @@ class SourceConfig: ): raise ValueError("看门狗不变式要求 0 < inter_token < ttft < timeout_s") + def _freeze_extra_body(self) -> None: + """校验后转只读视图: 装配完成的源不应再被就地改采样参数(设计决策 E)。""" + validated = validate_request_overlay( + self.extra_body, origin=f"SourceConfig({self.name}).extra_body" + ) + object.__setattr__(self, "extra_body", MappingProxyType(validated)) + @dataclass(frozen=True) class RetryPolicy: diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index f377268..1eae3b8 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -305,3 +305,92 @@ class TestOcrTypes: with pytest.raises(TypeError): OcrTextResult(text="x") # 溯源件不可省略 + + +class TestSamplingValidation: + """采样参数覆盖层的构造期校验(issue #4 设计决策 B)。""" + + @pytest.mark.parametrize("key", ["model", "messages", "stream", "stream_options"]) + def test_protected_keys_rejected(self, key): + """保护键会击穿治理: 成本算错/口径失真/绕过看门狗与 usage 帧。""" + from polygateway.types import validate_request_overlay + + with pytest.raises(ValueError) as exc: + validate_request_overlay({key: "x"}, origin="chat(overlay=...)") + assert key in str(exc.value) + assert "chat(overlay=...)" in str(exc.value) # 信息须能定位来源 + + def test_non_str_key_reports_key_problem(self): + """非 str 键须报"键必须是 str",不能被 sort_keys 的比较错误误报成不可序列化。""" + from polygateway.types import validate_request_overlay + + with pytest.raises(ValueError, match="str"): + validate_request_overlay({1: "a", "b": 2}, origin="test") + + def test_unserializable_value_becomes_value_error(self): + """裸 TypeError 会逃出 CacheMW 的降级 try 且一行遥测都没有(设计决策 B)。""" + from polygateway.types import validate_request_overlay + + with pytest.raises(ValueError, match="JSON"): + validate_request_overlay({"temperature": object()}, origin="test") + + def test_returns_independent_copy(self): + """调用方逐次改 seed 复用同一 dict 是预期模式,不拷贝会有竞态(决策 E)。""" + from polygateway.types import validate_request_overlay + + caller_dict = {"temperature": 0, "seed": 42} + validated = validate_request_overlay(caller_dict, origin="test") + caller_dict["seed"] = 43 + assert validated == {"temperature": 0, "seed": 42} + + def test_merge_prefers_call_level(self): + """优先级: 调用级 > 配置级(设计决策 A)。""" + from polygateway.types import merge_sampling + + merged = merge_sampling({"temperature": 0, "top_p": 1}, {"temperature": 1}) + assert merged == {"temperature": 1, "top_p": 1} + + def test_canonical_json_is_key_order_stable(self): + """缓存 key 与遥测列共用同一序列化口径,键序不得影响结果。""" + from polygateway.types import canonical_sampling_json + + assert canonical_sampling_json({"b": 1, "a": 2}) == canonical_sampling_json( + {"a": 2, "b": 1} + ) + assert canonical_sampling_json({}) is None + + +class TestSourceConfigExtraBody: + """配置级采样参数(issue #4 设计决策 A/E)。""" + + def test_defaults_to_empty_and_is_read_only(self): + source = _make_source() + assert source.extra_body == {} + with pytest.raises(TypeError): + source.extra_body["temperature"] = 0 # MappingProxyType 只读 + + def test_protected_key_rejected_at_construction(self): + """装配期报错,不放到运行时才炸(CLAUDE.md §4.5)。""" + with pytest.raises(ValueError, match="model"): + _make_source(extra_body={"model": "sneaky"}) + + def test_accepts_sampling_params(self): + source = _make_source(extra_body={"temperature": 0}) + assert source.extra_body["temperature"] == 0 + + def test_replace_rebuilds_proxy(self): + """决策 G 的剥离依赖 replace 能重跑 __post_init__ 且不递归。""" + source = _make_source(extra_body={"temperature": 0}) + stripped = dataclasses.replace(source, extra_body={}) + assert stripped.extra_body == {} + with pytest.raises(TypeError): + stripped.extra_body["x"] = 1 + + def test_no_longer_hashable_is_intentional(self): + """加 mapping 字段的固有代价(裸 dict 亦然),库内无调用点会踩。 + + 锁定为有意行为: 将来踩到的人不应把它当 bug"修"回去——要可变副本用 + dict(source.extra_body),要改字段用 dataclasses.replace(设计 Task 1)。 + """ + with pytest.raises(TypeError): + hash(_make_source()) From 152fa264edce88fee061a32c59eedd4a30115889 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 31 Jul 2026 21:12:06 -0400 Subject: [PATCH 07/16] feat: parse EXTRA_BODY as a JSON object per source --- src/polygateway/config.py | 8 ++++++++ tests/unit/test_config.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/polygateway/config.py b/src/polygateway/config.py index 5cfbc67..cee290f 100644 --- a/src/polygateway/config.py +++ b/src/polygateway/config.py @@ -11,6 +11,7 @@ fail-loud 校验语义与 pydantic-settings 一致。 from __future__ import annotations +import json import os from dataclasses import dataclass from typing import TYPE_CHECKING @@ -44,6 +45,7 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = { "ENABLE_THINKING": ("enable_thinking", "bool"), "MISSING_DONE": ("missing_done", "str"), "TRUST_ENV": ("trust_env", "bool"), + "EXTRA_BODY": ("extra_body", "json"), } _RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE"}) _SELECTORS = frozenset({"round_robin", "least_inflight", "health_aware"}) @@ -74,6 +76,12 @@ def _cast(raw: str, kind: str, key: str) -> object: if lowered in ("0", "false", "no", "off"): return False raise ValueError(f"非法布尔值: {raw!r}") + if kind == "json": + # JSONDecodeError 是 ValueError 子类,复用下方的统一包装 + parsed = json.loads(raw) + if not isinstance(parsed, dict): + raise ValueError(f"必须是 JSON 对象(而非数组/标量): {raw!r}") + return parsed return raw except ValueError as exc: raise ValueError(f"配置 {key} 解析失败: {exc}") from exc diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 7ab9a60..579af0c 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -103,6 +103,35 @@ class TestSourceAggregation: GatewaySettings.from_env("LLM", env=env) +class TestExtraBodyParsing: + """配置级采样参数的 env 解析(issue #4 Task 2)。""" + + def test_json_object_parsed(self): + env = _env(**{"LLM__QWEN__1__EXTRA_BODY": '{"temperature": 0, "seed": 42}'}) + s = GatewaySettings.from_env("LLM", env=env) + assert s.sources[0].extra_body == {"temperature": 0, "seed": 42} + + def test_absent_defaults_to_empty(self): + assert GatewaySettings.from_env("LLM", env=_env()).sources[0].extra_body == {} + + def test_invalid_json_fails_loudly(self): + env = _env(**{"LLM__QWEN__1__EXTRA_BODY": "{invalid"}) + with pytest.raises(ValueError, match="EXTRA_BODY"): + GatewaySettings.from_env("LLM", env=env) + + def test_non_object_json_fails(self): + """数组/标量都不是请求体片段,静默接受会让参数悄悄不生效。""" + env = _env(**{"LLM__QWEN__1__EXTRA_BODY": "[1, 2]"}) + with pytest.raises(ValueError, match="JSON 对象"): + GatewaySettings.from_env("LLM", env=env) + + def test_protected_key_rejected_through_assembly(self): + """校验确实挂在装配路径上(而非只在 types.py 里孤立存在)。""" + env = _env(**{"LLM__QWEN__1__EXTRA_BODY": '{"model": "sneaky"}'}) + with pytest.raises(ValueError, match="model"): + GatewaySettings.from_env("LLM", env=env) + + class TestResilienceKeys: def test_flat_legacy_keys(self): s = GatewaySettings.from_env("LLM", env=_env()) From 6bb64ca9387f0d76466637507392e0b4f0f2dd50 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 31 Jul 2026 21:17:49 -0400 Subject: [PATCH 08/16] feat: accept sampling overlay on chat() and per-source extra_body Priority is structured injection > per-call overlay > per-source config, and extra_body now takes part in the cache fingerprint. --- src/polygateway/client.py | 47 ++++++++- src/polygateway/transports/openai_compat.py | 3 + tests/unit/test_client.py | 101 ++++++++++++++++++++ tests/unit/test_openai_compat.py | 21 ++++ 4 files changed, 167 insertions(+), 5 deletions(-) diff --git a/src/polygateway/client.py b/src/polygateway/client.py index 7ea7837..7ce8e67 100644 --- a/src/polygateway/client.py +++ b/src/polygateway/client.py @@ -9,6 +9,8 @@ from __future__ import annotations import asyncio +import hashlib +import json import random import time from typing import TYPE_CHECKING, Any, Literal, TypeVar @@ -32,7 +34,7 @@ from polygateway.sources import ( SourceCooldownMemo, ) from polygateway.transports.openai_compat import OpenAICompatTransport -from polygateway.types import ChatRequest, LLMResponse +from polygateway.types import ChatRequest, LLMResponse, validate_request_overlay if TYPE_CHECKING: from collections.abc import Awaitable, Iterable, Mapping @@ -59,6 +61,29 @@ if TYPE_CHECKING: _T = TypeVar("_T") +def build_model_fingerprint(sources: Iterable[SourceConfig]) -> str: + """缓存 key 的模型身份: 多源 scope = 排序去重的 model 合集。 + + 配置级采样参数(`extra_body`)必须参与,否则把 temperature 从 0 改成 1 + 后重启仍会读到旧缓存(issue #4 设计决策 C)。全源 `extra_body` 皆空时 + 字面量与历史实现逐字相同,不触发存量缓存冷启动。 + """ + fingerprint = ",".join(sorted({s.model for s in sources})) + # 按 (model, extra_body) 而非源名摘要: 语义是"本 scope 会用哪些 + # (模型, 解码参数)组合",改源名不该误触全量冷启动 + marks = sorted( + { + json.dumps([s.model, dict(s.extra_body)], sort_keys=True, ensure_ascii=False) + for s in sources + if s.extra_body + } + ) + if marks: + digest = hashlib.sha256("".join(marks).encode("utf-8")).hexdigest() + fingerprint = f"{fingerprint}|{digest}" + return fingerprint + + class GatewayClient: """统一治理入口;构造函数全量注入(测试/高级),工厂覆盖 90% 场景。""" @@ -113,12 +138,11 @@ class GatewayClient: if cache is not None: if cache_namespace is None or cache_ttl_s is None: raise ValueError("启用缓存必须提供 cache_namespace 与 cache_ttl_s") - # 多源 scope 的 key 身份 = 排序去重的 model 合集;源集合变化 → 一次性冷启动 - fingerprint = ",".join(sorted({s.model for s in sources})) + # 多源 scope 的 key 身份;源集合或其 extra_body 变化 → 一次性冷启动 middlewares.append( CacheMW( backend=cache, - model_fingerprint=fingerprint, + model_fingerprint=build_model_fingerprint(sources), default_namespace=cache_namespace, ttl_s=cache_ttl_s, strategy=structured_strategy, @@ -150,12 +174,23 @@ class GatewayClient: cache_namespace: str | None = None, structured: type[BaseModel] | Literal["json"] | None = None, stream: bool = True, + overlay: Mapping[str, Any] | None = None, ) -> LLMResponse: - """一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。""" + """一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。 + + `overlay` 是采样参数覆盖层(`temperature`/`seed`/`max_tokens` 等),优先级 + 高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only + 参数不影响既有调用点(issue #4)。 + """ if structured is not None and not self._structured_available: raise ImportError( "结构化输出未启用: 安装 pip install 'polygateway[structured]' 后重新装配" ) + # 进洋葱之前校验并拷贝: 保护键/不可序列化值在此收口(否则会在 CacheMW + # 的降级 try 之外抛裸 TypeError);拷贝防调用方复用同一 dict 逐次改 seed + # 造成的竞态。同一份快照填 overlay 与 sampling——前者会被结构化注入, + # 后者跨层恒定,供缓存 key 与遥测读取(设计决策 A/B/E) + sampling = validate_request_overlay(overlay or {}, origin="chat(overlay=...)") request = ChatRequest( messages=messages, session_id=session_id, @@ -164,6 +199,8 @@ class GatewayClient: cache_namespace=cache_namespace, structured=structured, stream=stream, + overlay=sampling, + sampling=sampling, ) return await self._handler(request) diff --git a/src/polygateway/transports/openai_compat.py b/src/polygateway/transports/openai_compat.py index f0164e9..c8111df 100644 --- a/src/polygateway/transports/openai_compat.py +++ b/src/polygateway/transports/openai_compat.py @@ -294,6 +294,9 @@ class OpenAICompatTransport: payload.update(profile.thinking_on) elif source.enable_thinking is False: payload.update(profile.thinking_off) + # 顺序即优先级(issue #4 设计决策 A): 配置级 extra_body 在前,调用级 + # overlay(含结构化注入)在后覆盖之。两行不可调换 + payload.update(source.extra_body) payload.update(overlay) return payload diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index f7ceaec..637c668 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -19,6 +19,7 @@ from polygateway.backends.memory.cache import InMemoryCache from polygateway.backends.memory.limiter import InMemoryLimiter from polygateway.sources import RoundRobinSelector from polygateway.structured.json_repair import JsonRepairStrategy +from polygateway.structured.native_schema import NativeSchemaStrategy from polygateway.transports.openai_compat import OpenAICompatTransport from polygateway.types import ( BackpressurePolicy, @@ -117,6 +118,106 @@ class TestChatEndToEnd: await client.chat([{"role": "user", "content": "hi"}], structured="json") +class TestSamplingOverlay: + """调用级采样参数入口(issue #4 Task 3)。""" + + def _capturing_client(self, captured, **overrides): + def handler(request): + captured.append(json.loads(request.content)) + return _sse() + + return _client(handler=handler, **overrides) + + async def test_overlay_reaches_request_body(self): + captured = [] + async with self._capturing_client(captured) as client: + await client.chat([{"role": "user", "content": "hi"}], overlay={"seed": 42}) + assert captured[0]["seed"] == 42 + + async def test_call_level_beats_config_level(self): + """优先级: 调用级 > 配置级(设计决策 A)。""" + captured = [] + source = _source(extra_body={"temperature": 0, "top_p": 0.9}) + async with self._capturing_client(captured, sources=[source]) as client: + await client.chat([{"role": "user", "content": "hi"}], overlay={"temperature": 1}) + assert captured[0]["temperature"] == 1 # 调用级覆盖 + assert captured[0]["top_p"] == 0.9 # 配置级未被顶掉的键保留 + + async def test_structured_injection_beats_call_level(self): + """结构化注入优先级最高: 它关系到响应能否被解析(设计决策 A)。""" + captured = [] + client = self._capturing_client(captured, structured_strategy=NativeSchemaStrategy()) + async with client: + await client.chat( + [{"role": "user", "content": "hi"}], + structured="json", + overlay={"response_format": {"type": "text"}}, + ) + assert captured[0]["response_format"] != {"type": "text"} + + async def test_protected_key_rejected_before_onion(self): + """保护键在进洋葱之前就报错,transport 一次都不该被碰到。""" + captured = [] + async with self._capturing_client(captured) as client: + with pytest.raises(ValueError, match="stream"): + await client.chat([{"role": "user", "content": "hi"}], overlay={"stream": False}) + assert captured == [] + + async def test_unserializable_value_rejected_before_onion(self): + """裸 TypeError 会在 CacheMW 的降级 try 之外炸且无遥测(设计决策 B)。""" + captured = [] + async with self._capturing_client(captured) as client: + with pytest.raises(ValueError, match="JSON"): + await client.chat( + [{"role": "user", "content": "hi"}], overlay={"temperature": object()} + ) + assert captured == [] + + async def test_caller_dict_mutation_does_not_leak(self): + """调用方逐次改 seed 复用同一 dict 是预期模式(设计决策 E)。""" + captured = [] + caller_overlay = {"seed": 1} + async with self._capturing_client(captured) as client: + await client.chat([{"role": "user", "content": "hi"}], overlay=caller_overlay) + caller_overlay["seed"] = 2 + await client.chat([{"role": "user", "content": "hi"}], overlay=caller_overlay) + assert [c["seed"] for c in captured] == [1, 2] + + +class TestModelFingerprint: + """配置级采样参数须进缓存身份,否则改 temperature 后仍读旧缓存(决策 C)。""" + + def test_empty_extra_body_keeps_legacy_fingerprint(self): + """全源无 extra_body 时字面量与旧实现逐字相同,不触发存量缓存冷启动。""" + from polygateway.client import build_model_fingerprint + + sources = [_source(), _source(name="qwen_2", model="qwen-plus")] + assert build_model_fingerprint(sources) == "qwen-max,qwen-plus" + + def test_extra_body_changes_fingerprint(self): + from polygateway.client import build_model_fingerprint + + plain = build_model_fingerprint([_source()]) + tuned = build_model_fingerprint([_source(extra_body={"temperature": 0})]) + assert plain != tuned + assert tuned.startswith("qwen-max|") # 旧字面量仍是前缀,便于人眼辨认 + + def test_source_rename_does_not_change_fingerprint(self): + """指纹按 (model, extra_body) 而非源名: 改名不该误触全量冷启动。""" + from polygateway.client import build_model_fingerprint + + a = build_model_fingerprint([_source(name="qwen_1", extra_body={"temperature": 0})]) + b = build_model_fingerprint([_source(name="renamed", extra_body={"temperature": 0})]) + assert a == b + + def test_differing_extra_body_across_sources_is_distinguished(self): + from polygateway.client import build_model_fingerprint + + a = build_model_fingerprint([_source(extra_body={"temperature": 0})]) + b = build_model_fingerprint([_source(extra_body={"temperature": 1})]) + assert a != b + + class TestFactories: def test_from_env_assembles(self): client = GatewayClient.from_env("LLM", env=_ENV) diff --git a/tests/unit/test_openai_compat.py b/tests/unit/test_openai_compat.py index 65c5f48..5bce879 100644 --- a/tests/unit/test_openai_compat.py +++ b/tests/unit/test_openai_compat.py @@ -445,6 +445,27 @@ class TestRequestShaping: ) assert seen["response_format"] == {"type": "json_object"} + async def test_extra_body_merged_and_outranked_by_overlay(self): + """顺序即优先级: thinking profile → extra_body → overlay(issue #4)。""" + seen = {} + + def handler(request): + seen.update(json.loads(request.content)) + return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE)) + + await _complete( + _transport_for(handler), + _source(extra_body={"temperature": 0, "top_p": 0.9}), + overlay={"temperature": 1}, + ) + assert seen["temperature"] == 1 # 调用级覆盖配置级 + assert seen["top_p"] == 0.9 # 未被顶掉的配置级键保留 + + async def test_extra_body_cannot_break_governed_keys(self): + """治理键由 payload 骨架拥有;extra_body 的保护键在构造期已被拦下。""" + with pytest.raises(ValueError, match="stream"): + _source(extra_body={"stream": False}) + class TestErrorTranslation: @pytest.mark.parametrize( From c31cc1adad909581517ac738302b75c2d7cae1f7 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 31 Jul 2026 21:20:56 -0400 Subject: [PATCH 09/16] feat: fold sampling parameters into the cache key Without this, five seeds over identical messages all hit the first cached response and the reported standard deviation is silently always zero. --- src/polygateway/middleware/cache.py | 28 +++++++++++++-- tests/unit/test_cache.py | 55 +++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/polygateway/middleware/cache.py b/src/polygateway/middleware/cache.py index 252474c..88a63a7 100644 --- a/src/polygateway/middleware/cache.py +++ b/src/polygateway/middleware/cache.py @@ -20,6 +20,8 @@ from loguru import logger from polygateway.types import ChatRequest, LLMResponse if TYPE_CHECKING: + from collections.abc import Mapping + from polygateway.ports import CacheBackend, CallNext, StructuredOutputStrategy _KEY_PREFIX = "pgw:cache:" @@ -50,9 +52,19 @@ def _digest_part(part: Any) -> Any: def build_cache_key( - model_fingerprint: str, messages: list[dict[str, Any]], namespace: str, salt: str | None + model_fingerprint: str, + messages: list[dict[str, Any]], + namespace: str, + salt: str | None, + *, + sampling: Mapping[str, Any] | None = None, ) -> str: - """缓存 key 公式;salt 仅非 None 时参与(VT 旧键语义: 不传 salt 键形不变)。""" + """缓存 key 公式;salt 仅非 None 时参与(VT 旧键语义: 不传 salt 键形不变)。 + + `sampling` 仅**非空**时参与(与 salt 的"仅非 None"不同——空串是有意义的 + salt,而空采样参数与不传无语义差别)。它必须进 key: 否则同 messages 跑 5 个 + seed 会全部命中第一次的响应,标准差恒为 0 且不报错(issue #4 决策 C)。 + """ key_obj: dict[str, Any] = { "model": model_fingerprint, "messages": digest_messages(messages), @@ -60,6 +72,8 @@ def build_cache_key( } if salt is not None: key_obj["salt"] = salt + if sampling: + key_obj["sampling"] = dict(sampling) payload = json.dumps(key_obj, sort_keys=True, ensure_ascii=False) return _KEY_PREFIX + hashlib.sha256(payload.encode("utf-8")).hexdigest() @@ -92,7 +106,15 @@ class CacheMW: async def __call__(self, request: ChatRequest, call_next: CallNext) -> LLMResponse: namespace = request.cache_namespace or self._namespace - key = build_cache_key(self._fingerprint, request.messages, namespace, request.cache_salt) + # 读 sampling 而非 overlay: 语义明确,且不依赖"CacheMW 恰在 StructuredMW + # 外侧"这一层序巧合——结构化注入不该改变缓存身份(设计决策 C) + key = build_cache_key( + self._fingerprint, + request.messages, + namespace, + request.cache_salt, + sampling=request.sampling, + ) cached = await self._safe_get(key) if cached is not None: hit = self._rehydrate(cached, request) diff --git a/tests/unit/test_cache.py b/tests/unit/test_cache.py index 59d7c8a..c23340c 100644 --- a/tests/unit/test_cache.py +++ b/tests/unit/test_cache.py @@ -53,6 +53,35 @@ class TestKeyFormula: def test_any_dimension_change_changes_key(self, a, b): assert build_cache_key(*a) != build_cache_key(*b) + def test_empty_sampling_keeps_legacy_key(self): + """空采样参数时键形逐字不变,存量缓存不被全量作废(issue #4 决策 C)。 + + golden 值取自加 sampling 维度之前的实现,不得随实现漂移。 + """ + assert build_cache_key("qwen-max", [{"role": "user", "content": "hi"}], "proj", None) == ( + "pgw:cache:c54544e8672f4c91373b4a72716a88497445b440b89445aa5379b356b228f58b" + ) + assert build_cache_key("qwen-max", [{"role": "user", "content": "hi"}], "proj", "s1") == ( + "pgw:cache:eed9cd9cc06acc0dedf4f337b74e06ed3482afdc30fa2acedd194f6cc1df33bf" + ) + + def test_differing_seed_changes_key(self): + """issue #4 的直接回归: 5 个 seed 若共用一个 key,标准差会恒为 0。""" + k1 = build_cache_key("m", _MSGS, "proj", None, sampling={"seed": 1}) + k2 = build_cache_key("m", _MSGS, "proj", None, sampling={"seed": 2}) + assert k1 != k2 + + def test_sampling_key_order_irrelevant(self): + k1 = build_cache_key("m", _MSGS, "proj", None, sampling={"seed": 1, "temperature": 0}) + k2 = build_cache_key("m", _MSGS, "proj", None, sampling={"temperature": 0, "seed": 1}) + assert k1 == k2 + + def test_empty_sampling_equals_omitted(self): + """空 dict 与不传须同键,否则升级后存量缓存全部 miss。""" + assert build_cache_key("m", _MSGS, "proj", None, sampling={}) == build_cache_key( + "m", _MSGS, "proj", None + ) + def test_multimodal_part_digested_not_inlined(self): big_b64 = "data:image/png;base64," + "A" * 1_000_000 messages = [ @@ -120,6 +149,32 @@ class TestCacheFlow: assert second.call_id != first.call_id # 命中生成独立 cache_call_id assert terminal.calls == 1 # 未再触达内层 + async def test_differing_sampling_does_not_hit(self): + """issue #4 的中间件层回归: 逐 rollout 变 seed 必须回源,不得复用响应。""" + backend = InMemoryCache() + mw = _mw(backend) + terminal = _Terminal(_resp()) + await mw(ChatRequest(messages=_MSGS, sampling={"seed": 1}), terminal) + await mw(ChatRequest(messages=_MSGS, sampling={"seed": 2}), terminal) + assert terminal.calls == 2 # 两次都回源 + # 同 seed 才命中 + third = await mw(ChatRequest(messages=_MSGS, sampling={"seed": 1}), terminal) + assert third.cache_hit is True and terminal.calls == 2 + + async def test_structured_injection_does_not_pollute_key(self): + """CacheMW 读 sampling 而非 overlay: 结构化注入不该改变缓存身份。""" + backend = InMemoryCache() + mw = _mw(backend) + terminal = _Terminal(_resp()) + await mw(ChatRequest(messages=_MSGS, sampling={"seed": 1}), terminal) + polluted = ChatRequest( + messages=_MSGS, + sampling={"seed": 1}, + overlay={"seed": 1, "response_format": {"type": "json_object"}}, + ) + assert (await mw(polluted, terminal)).cache_hit is True + assert terminal.calls == 1 + async def test_per_call_namespace_overrides_default(self): backend = InMemoryCache() mw = _mw(backend) From b6e4cc3f3b44ae817df83d7f9e6c09c0899ce694 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 31 Jul 2026 21:23:50 -0400 Subject: [PATCH 10/16] test: lock the sampling snapshot invariant across the onion Verified by breaking structured.py so the reask drops sampling: the test goes red for the right reason, not merely because something errored. --- tests/unit/test_structured.py | 36 +++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/unit/test_structured.py b/tests/unit/test_structured.py index 889da54..2b4df28 100644 --- a/tests/unit/test_structured.py +++ b/tests/unit/test_structured.py @@ -177,3 +177,39 @@ class TestNativeOverlayFirstAttempt: resp = await _mw()(ChatRequest(messages=_MSGS, structured=Verdict), terminal) with pytest.raises(dataclasses.FrozenInstanceError): resp.structured_data = None + + +class TestSamplingSnapshotInvariant: + """地基不变式: `sampling` 跨洋葱层恒定,`overlay` 会被结构化注入(issue #4)。 + + 缓存 key(决策 C)与三个遥测入口(决策 D)都建立在这条之上,而它此前只靠 + "dataclasses.replace 恰好保留未提及字段"的约定成立,无任何机械执法。 + 这个测试是那份执法——它红了就意味着两个决策同时失效。 + """ + + async def test_sampling_survives_feedback_ladder_while_overlay_diverges(self): + caller_sampling = {"temperature": 0, "seed": 42} + # 先坏后好,强制走一次带反馈重问(重问会 replace messages) + terminal = ScriptedTerminal(["not json at all", '{"answer": 1, "reason": "r"}']) + mw = _mw(strategy=NativeSchemaStrategy(), max_retries=1) + await mw( + ChatRequest(messages=_MSGS, structured=Verdict, sampling=caller_sampling), + terminal, + ) + assert len(terminal.requests) == 2 # 确实重问过 + for seen in terminal.requests: + # ① 跨层恒定: 每次尝试看到的 sampling 与调用方传入的逐字相同 + assert seen.sampling == caller_sampling + # ② 确实分叉: 同一时刻 overlay 已被注入 response_format + assert seen.overlay["response_format"]["type"] == "json_schema" + assert "response_format" not in seen.sampling + + async def test_middleware_does_not_mutate_caller_mapping(self): + """决策 E 的第二条约束: 中间件只能 replace 派生,不得就地改这两个 dict。""" + caller_sampling = {"seed": 7} + terminal = ScriptedTerminal(['{"answer": 1, "reason": "r"}']) + await _mw(strategy=NativeSchemaStrategy())( + ChatRequest(messages=_MSGS, structured=Verdict, sampling=caller_sampling), + terminal, + ) + assert caller_sampling == {"seed": 7} # 调用方的对象未被污染 From 4516761dbe5f3f9cb7d5de6f2d7aa4e61997341f Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 31 Jul 2026 21:30:45 -0400 Subject: [PATCH 11/16] feat: record sampling parameters in telemetry (port 20 to 21 fields) Each of the three emitter entry points has a pinned meaning: only the attempt path has an effective source, so only it merges extra_body. --- src/polygateway/middleware/telemetry.py | 12 ++ src/polygateway/ports.py | 1 + src/polygateway/telemetry/postgres.py | 5 +- src/polygateway/telemetry/sqlite.py | 12 +- tests/integration/test_postgres_telemetry.py | 2 + tests/unit/test_telemetry.py | 125 +++++++++++++++++-- 6 files changed, 141 insertions(+), 16 deletions(-) diff --git a/src/polygateway/middleware/telemetry.py b/src/polygateway/middleware/telemetry.py index d19c1f1..81e155f 100644 --- a/src/polygateway/middleware/telemetry.py +++ b/src/polygateway/middleware/telemetry.py @@ -18,6 +18,7 @@ from loguru import logger from polygateway.errors import GatewayUnavailableError, GovernanceBackendError from polygateway.middleware.cache import digest_messages +from polygateway.types import canonical_sampling_json, merge_sampling if TYPE_CHECKING: from collections.abc import Callable @@ -63,6 +64,10 @@ class TelemetryEmitter: error=error, cached_prompt_tokens=response.cached_prompt_tokens if response else None, model_reported=response.model_reported if response else None, + # 唯一有"生效源"的入口,故是唯一能并上 extra_body 的(设计决策 D) + sampling=canonical_sampling_json( + merge_sampling(source.extra_body, request.sampling) + ), ) async def emit_cache_hit(self, *, request: ChatRequest, response: LLMResponse) -> None: @@ -87,6 +92,9 @@ class TelemetryEmitter: # 统计供应商缓存命中率必须带 WHERE cache_hit = false,否则重复计数。 cached_prompt_tokens=response.cached_prompt_tokens, model_reported=response.model_reported, + # 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损: + # sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同 + sampling=canonical_sampling_json(request.sampling), ) async def emit_terminal_failure( @@ -111,6 +119,8 @@ class TelemetryEmitter: error=error, cached_prompt_tokens=None, model_reported=None, + # 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D) + sampling=canonical_sampling_json(request.sampling), ) async def _record( @@ -133,6 +143,7 @@ class TelemetryEmitter: error: str | None, cached_prompt_tokens: int | None, model_reported: str | None, + sampling: str | None, ) -> None: try: # 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用); @@ -172,6 +183,7 @@ class TelemetryEmitter: cost=cost, cached_prompt_tokens=cached_prompt_tokens, model_reported=model_reported, + sampling=sampling, ) except asyncio.CancelledError: raise diff --git a/src/polygateway/ports.py b/src/polygateway/ports.py index d94b8cf..5425dfe 100644 --- a/src/polygateway/ports.py +++ b/src/polygateway/ports.py @@ -274,4 +274,5 @@ class TelemetryRecorder(Protocol): cost: float | None, cached_prompt_tokens: int | None, model_reported: str | None, + sampling: str | None, ) -> None: ... diff --git a/src/polygateway/telemetry/postgres.py b/src/polygateway/telemetry/postgres.py index f8777f2..f3fac3c 100644 --- a/src/polygateway/telemetry/postgres.py +++ b/src/polygateway/telemetry/postgres.py @@ -41,7 +41,8 @@ CREATE TABLE IF NOT EXISTS llm_calls ( cost DOUBLE PRECISION, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), cached_prompt_tokens INTEGER, - model_reported TEXT + model_reported TEXT, + sampling TEXT ); """ @@ -49,6 +50,7 @@ CREATE TABLE IF NOT EXISTS llm_calls ( _BACKFILL = ( ("cached_prompt_tokens", "ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER"), ("model_reported", "ALTER TABLE llm_calls ADD COLUMN model_reported TEXT"), + ("sampling", "ALTER TABLE llm_calls ADD COLUMN sampling TEXT"), ) # 探测现有列;尊重 search_path(to_regclass 按当前 search_path 解析) @@ -78,6 +80,7 @@ _COLUMNS = ( "cost", "cached_prompt_tokens", "model_reported", + "sampling", ) _INSERT = ( diff --git a/src/polygateway/telemetry/sqlite.py b/src/polygateway/telemetry/sqlite.py index 91eadb0..a53b7d9 100644 --- a/src/polygateway/telemetry/sqlite.py +++ b/src/polygateway/telemetry/sqlite.py @@ -36,13 +36,18 @@ CREATE TABLE IF NOT EXISTS llm_calls ( cost REAL, created_at TEXT NOT NULL DEFAULT (datetime('now')), cached_prompt_tokens INTEGER, - model_reported TEXT + model_reported TEXT, + sampling TEXT ); """ # 新列必须排在 created_at 之后: 旧表只能经 ALTER 追加到末尾,新建库若把它们 # 插在前面,两条路径的物理列序会分叉(列序断言测试无合规修法)。 -_BACKFILL_COLUMNS = (("cached_prompt_tokens", "INTEGER"), ("model_reported", "TEXT")) +_BACKFILL_COLUMNS = ( + ("cached_prompt_tokens", "INTEGER"), + ("model_reported", "TEXT"), + ("sampling", "TEXT"), +) _COLUMNS = ( "call_id", @@ -65,6 +70,7 @@ _COLUMNS = ( "cost", "cached_prompt_tokens", "model_reported", + "sampling", ) _INSERT = ( @@ -120,7 +126,7 @@ class SQLiteRecorder: logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc) async def record_llm_call(self, **fields: object) -> None: - """写一行遥测;字段集合即 20 字段冻结签名(ports.TelemetryRecorder)。""" + """写一行遥测;字段集合即 21 字段冻结签名(ports.TelemetryRecorder)。""" if self._conn is None: return row = tuple(fields[col] for col in _COLUMNS) diff --git a/tests/integration/test_postgres_telemetry.py b/tests/integration/test_postgres_telemetry.py index 49d6981..50286c5 100644 --- a/tests/integration/test_postgres_telemetry.py +++ b/tests/integration/test_postgres_telemetry.py @@ -41,6 +41,7 @@ _EXPECTED_COLUMNS = [ "created_at", "cached_prompt_tokens", "model_reported", + "sampling", ] # run 级前缀: 同库并存的其他运行(迁移批跑/另一开发机)互不可见 @@ -104,6 +105,7 @@ async def _record_minimal( "cost": None, "cached_prompt_tokens": None, "model_reported": None, + "sampling": None, } fields.update(overrides) await recorder.record_llm_call(**fields) diff --git a/tests/unit/test_telemetry.py b/tests/unit/test_telemetry.py index e3aa4ed..25128af 100644 --- a/tests/unit/test_telemetry.py +++ b/tests/unit/test_telemetry.py @@ -1,6 +1,7 @@ -"""遥测子系统测试: SQLiteRecorder(20 列)+ TelemetryEmitter 单一 helper + TelemetryMW。""" +"""遥测子系统测试: SQLiteRecorder(21 列)+ TelemetryEmitter 单一 helper + TelemetryMW。""" import asyncio +import json import sqlite3 import subprocess from pathlib import Path @@ -37,6 +38,7 @@ _EXPECTED_COLUMNS = [ "created_at", "cached_prompt_tokens", "model_reported", + "sampling", ] @@ -60,15 +62,17 @@ def _resp(**overrides): return LLMResponse(**base) -def _source(): - return SourceConfig( - name="s1", - provider="p", - base_url="https://gw.example/v1", - api_key="sk", - model="m", - timeout_s=10.0, - ) +def _source(**overrides): + base = { + "name": "s1", + "provider": "p", + "base_url": "https://gw.example/v1", + "api_key": "sk", + "model": "m", + "timeout_s": 10.0, + } + base.update(overrides) + return SourceConfig(**base) # 输出单价 8 元/百万: 改前 `unavailable` 行按兜底的 0/4000 换算恰好是 0.032 @@ -97,6 +101,7 @@ async def _record_minimal(recorder, call_id="c1", **overrides): "cost": None, "cached_prompt_tokens": None, "model_reported": None, + "sampling": None, } fields.update(overrides) await recorder.record_llm_call(**fields) @@ -153,6 +158,20 @@ class TestSQLiteRecorder: assert rows["c-zero"] == 0 # 真实零命中,读回仍是 0 而非 NULL assert rows["c-none"] is None + async def test_sampling_column_round_trips(self, tmp_path): + """issue #4: 采样参数落库,否则事后无法证明某批数据跑在什么温度下。""" + recorder = SQLiteRecorder(tmp_path / "t.db") + await _record_minimal(recorder, call_id="c-s", sampling='{"seed": 42, "temperature": 0}') + await _record_minimal(recorder, call_id="c-plain") + recorder.close() + rows = dict( + sqlite3.connect(tmp_path / "t.db") + .execute("SELECT call_id, sampling FROM llm_calls") + .fetchall() + ) + assert json.loads(rows["c-s"]) == {"seed": 42, "temperature": 0} + assert rows["c-plain"] is None # 无采样参数为 NULL,便于 SQL 过滤 + class TestSQLiteColumnBackfill: """issue #3: 已存在的 18 列旧表必须自动补列,否则每行写入都被丢弃。""" @@ -258,7 +277,14 @@ class TestPostgresBackfillDiscipline: """PG 补列必须与 SQLite 侧对称: 失败只逐行降级,且稳态不抢排他锁(issue #3)。""" _LEGACY = ["call_id", "cost", "created_at"] - _CURRENT = ["call_id", "cost", "created_at", "cached_prompt_tokens", "model_reported"] + _CURRENT = [ + "call_id", + "cost", + "created_at", + "cached_prompt_tokens", + "model_reported", + "sampling", + ] def _recorder(self, conn): from polygateway.telemetry.postgres import PostgresRecorder @@ -286,8 +312,10 @@ class TestPostgresBackfillDiscipline: async def test_missing_columns_are_added_once(self): conn = _FakePgConn(self._LEGACY) await _record_minimal(self._recorder(conn)) + from polygateway.telemetry.postgres import _BACKFILL + altered = [s for s in conn.statements if s.startswith("ALTER TABLE")] - assert len(altered) == 2 + assert len(altered) == len(_BACKFILL) # 旧表缺全部补列,故一列一条 ALTER assert all("IF NOT EXISTS" not in s for s in altered) # 探测已确认缺列,无需再判 @@ -394,6 +422,79 @@ class TestEmitterObservabilityFields: assert rec.rows[0]["model_reported"] is None +class TestEmitterSamplingColumn: + """issue #4: sampling 列在三个入口的口径(设计决策 D 表格)。 + + 列语义 = 「调用方采样意图 ⊎ 生效源 extra_body」,**不含**结构化注入的 + response_format(列名是采样参数,schema 不是;且数 KB schema 逐行落库会让 + 审计表无谓膨胀)。三入口若各读各的层,同一列在不同行含义就不同。 + """ + + _SAMPLED = ChatRequest( + messages=[{"role": "user", "content": "hi"}], + sampling={"seed": 42}, + overlay={"seed": 42, "response_format": {"type": "json_object"}}, + ) + + async def test_attempt_merges_source_extra_body(self): + rec = _MemoryRecorder() + await TelemetryEmitter(rec).emit_attempt( + request=self._SAMPLED, + source=_source(extra_body={"temperature": 0}), + call_id="c", + latency_ms=1, + response=_resp(), + error=None, + ) + assert json.loads(rec.rows[0]["sampling"]) == {"seed": 42, "temperature": 0} + + async def test_response_format_never_leaks_into_the_column(self): + """三行都不得出现 response_format——它不是采样参数。""" + rec = _MemoryRecorder() + emitter = TelemetryEmitter(rec) + await emitter.emit_attempt( + request=self._SAMPLED, + source=_source(), + call_id="c", + latency_ms=1, + response=_resp(), + error=None, + ) + await emitter.emit_cache_hit(request=self._SAMPLED, response=_resp()) + await emitter.emit_terminal_failure( + request=self._SAMPLED, call_id="c", latency_ms=1, error="dead" + ) + assert len(rec.rows) == 3 + for row in rec.rows: + assert "response_format" not in row["sampling"] + + @pytest.mark.parametrize("emit", ["cache_hit", "terminal_failure"]) + async def test_sourceless_entries_record_call_level_only(self, emit): + """两个最外层入口没有"生效源"可言,与 model/source_name 置空同一先例。""" + rec = _MemoryRecorder() + emitter = TelemetryEmitter(rec) + if emit == "cache_hit": + await emitter.emit_cache_hit(request=self._SAMPLED, response=_resp()) + else: + await emitter.emit_terminal_failure( + request=self._SAMPLED, call_id="c", latency_ms=1, error="dead" + ) + assert json.loads(rec.rows[0]["sampling"]) == {"seed": 42} + + async def test_absent_sampling_is_null(self): + """无采样参数时为 NULL,而非空字符串或 "{}"——便于 SQL 过滤。""" + rec = _MemoryRecorder() + await TelemetryEmitter(rec).emit_attempt( + request=_REQ, + source=_source(), + call_id="c", + latency_ms=1, + response=_resp(), + error=None, + ) + assert rec.rows[0]["sampling"] is None + + class TestCostWithCachedTier: """issue #3: 命中部分按缓存单价计费,避免 cost 系统性高估。""" From a5ebf72f173eba85e2fab10e0e31893bc23e87e3 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 31 Jul 2026 21:36:50 -0400 Subject: [PATCH 12/16] feat: strip extra_body on the embedding and OCR paths with a warning Stripping is load-bearing, not tidying: those transports never send the value, so leaving it would make telemetry record a parameter never sent. --- src/polygateway/embedding.py | 11 +++++++-- src/polygateway/ocr.py | 5 +++- src/polygateway/types.py | 31 ++++++++++++++++++++++++ tests/unit/test_embedding.py | 44 +++++++++++++++++++++++++++++++++++ tests/unit/test_ocr_client.py | 24 +++++++++++++++++++ 5 files changed, 112 insertions(+), 3 deletions(-) diff --git a/src/polygateway/embedding.py b/src/polygateway/embedding.py index 804d61d..ae47716 100644 --- a/src/polygateway/embedding.py +++ b/src/polygateway/embedding.py @@ -41,7 +41,12 @@ from polygateway.middleware.ratelimit import QuotaGate from polygateway.middleware.retry import _failure_reason, backoff_delay from polygateway.middleware.telemetry import TelemetryEmitter from polygateway.sources import SourceCooldownMemo -from polygateway.types import ChatRequest, EmbeddingResponse, LLMResponse +from polygateway.types import ( + ChatRequest, + EmbeddingResponse, + LLMResponse, + strip_unsupported_extra_body, +) if TYPE_CHECKING: from collections.abc import Awaitable, Callable, Mapping @@ -111,7 +116,9 @@ class EmbeddingClient: if expected_dim is not None and expected_dim < 1: raise ValueError("expected_dim 必须 ≥ 1") self._scope = scope - self._sources = list(sources) + # embed payload 硬编码 {model, input},带 extra_body 的源必须先剥离, + # 否则遥测会记录一个从未发出的采样参数(issue #4 决策 G) + self._sources = strip_unsupported_extra_body(list(sources), path="embedding") self._selector = selector self._quota = QuotaGate(limiter) self._breaker = BreakerGate(breaker) diff --git a/src/polygateway/ocr.py b/src/polygateway/ocr.py index 864a7c3..cbbc54d 100644 --- a/src/polygateway/ocr.py +++ b/src/polygateway/ocr.py @@ -44,6 +44,7 @@ from polygateway.types import ( OcrLayoutResult, OcrTextResult, Usage, + strip_unsupported_extra_body, ) if TYPE_CHECKING: @@ -113,7 +114,9 @@ class OcrClient: if quota_full not in ("wait", "fail_fast"): raise ValueError(f"quota_full 必须是 wait|fail_fast: {quota_full!r}") self._scope = scope - self._sources = list(sources) + # MonkeyOCR 只发 multipart 表单,带 extra_body 的源必须先剥离,否则 + # 遥测会记录一个从未发出的采样参数(issue #4 决策 G) + self._sources = strip_unsupported_extra_body(list(sources), path="OCR") self._selector = selector self._feed_health = isinstance(selector, OutcomeAwareSelector) self._quota = QuotaGate(limiter) diff --git a/src/polygateway/types.py b/src/polygateway/types.py index 0aa2326..96f71e5 100644 --- a/src/polygateway/types.py +++ b/src/polygateway/types.py @@ -4,12 +4,15 @@ fake,字段顺序即公共承诺;新增字段只增不删且必带默认值。 """ +import dataclasses import json from collections.abc import Mapping from dataclasses import dataclass, field from types import MappingProxyType from typing import Any +from loguru import logger + _MISSING_DONE_DOMAIN = frozenset({"retry", "salvage"}) _PROTECTED_OVERLAY_KEYS: Mapping[str, str] = MappingProxyType( @@ -234,6 +237,34 @@ class SourceConfig: object.__setattr__(self, "extra_body", MappingProxyType(validated)) +def strip_unsupported_extra_body( + sources: list[SourceConfig], *, path: str +) -> list[SourceConfig]: + """剥离非 chat 路径不消费的 `extra_body` 并 warning(issue #4 决策 G)。 + + 剥离是必需的而非顺手清理: embedding 的 payload 硬编码 `{model, input}`、 + MonkeyOCR 只发 multipart 表单,两者都不会把 `extra_body` 发出去;但遥测的 + `sampling` 列会并上 `source.extra_body`,不剥离就等于**记录一个从未发出的 + 参数**——那是数据造假,污染的恰是事后复现的唯一依据。 + + 选择 warning 放行而非报错: 这两条路径本无采样语义,配错的后果远轻于 chat + 路径,不值得让下游整个装配起不来(2026-07-31 人类拍板)。 + """ + stripped = [] + for source in sources: + if source.extra_body: + logger.warning( + "{} 路径暂不支持 extra_body,源 {} 的该配置已被忽略" + "(需要 dimensions 等参数请提 issue): {}", + path, + source.name, + dict(source.extra_body), + ) + source = dataclasses.replace(source, extra_body={}) + stripped.append(source) + return stripped + + @dataclass(frozen=True) class RetryPolicy: """重试策略;max_attempts = 总尝试次数(含首次,M1 设计 §2.3 统一语义)。""" diff --git a/tests/unit/test_embedding.py b/tests/unit/test_embedding.py index d6c9f95..c95b9b7 100644 --- a/tests/unit/test_embedding.py +++ b/tests/unit/test_embedding.py @@ -4,11 +4,13 @@ VT adapters/embedding.py(归一化);库裁决见设计 §7.3 表。 """ +import contextlib import dataclasses import json import httpx import pytest +from loguru import logger from polygateway.errors import ( RequestRejectedError, @@ -350,6 +352,48 @@ class TestEmbedTelemetry: assert len(rec.rows[1]["messages"]) < 1000 # 长文本截断后入库 +@contextlib.contextmanager +def _captured_warnings(): + """捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。""" + messages: list[str] = [] + sink_id = logger.add(messages.append, level="WARNING") + try: + yield messages + finally: + logger.remove(sink_id) + + +class TestExtraBodyStripped: + """issue #4 决策 G: embedding 路径不消费 extra_body,剥离并 warning。""" + + async def test_stripped_with_warning_but_assembly_succeeds(self): + """报错会让下游整个装配起不来,而这条路径本无采样语义(人类拍板)。""" + with _captured_warnings() as warnings: + client, _ = _embed_client([_src(extra_body={"temperature": 0})], ["ok"]) + assert client._sources[0].extra_body == {} + assert any("extra_body" in m for m in warnings) + assert any("dimensions" in m for m in warnings) # 文案须指路,不能只说不支持 + await client.embed(["hi"]) # 装配后可正常工作 + + async def test_telemetry_never_records_a_parameter_that_was_not_sent(self): + """剥离的真正理由: embed payload 硬编码 {model, input},不剥离则审计表 + + 会显示这次调用带了 temperature=0——那是数据造假,比参数失效更坏。 + """ + rec = _MemoryRecorder() + client, _ = _embed_client( + [_src(extra_body={"temperature": 0})], ["ok"], telemetry=rec + ) + await client.embed(["hi"]) + assert rec.rows[0]["sampling"] is None + + async def test_no_warning_without_extra_body(self): + with _captured_warnings() as warnings: + client, _ = _embed_client([_src()], ["ok"]) + assert client._sources[0].extra_body == {} + assert not [m for m in warnings if "extra_body" in m] + + class TestEmbeddingSettings: _ENV = { "EMBED__QWEN__1__BASE_URL": "https://gw.example/v1", diff --git a/tests/unit/test_ocr_client.py b/tests/unit/test_ocr_client.py index 9d9cc54..ba1f6d9 100644 --- a/tests/unit/test_ocr_client.py +++ b/tests/unit/test_ocr_client.py @@ -7,6 +7,7 @@ retry_exhausted/circuit_open/stalled 三组断言即设计 §6 ③ 的 G1 契约 import asyncio import pytest +from loguru import logger from polygateway.backends.memory.breaker import InMemoryGate from polygateway.backends.memory.limiter import InMemoryLimiter @@ -377,6 +378,29 @@ class TestCheckHealth: await task +class TestExtraBodyStripped: + """issue #4 决策 G: OCR 路径只发 multipart 表单,剥离 extra_body 并 warning。""" + + def test_stripped_with_warning_but_assembly_succeeds(self): + messages: list[str] = [] + sink_id = logger.add(messages.append, level="WARNING") + try: + client, _, _ = _client([_src(extra_body={"temperature": 0})], ["text"]) + finally: + logger.remove(sink_id) + assert client._sources[0].extra_body == {} + assert any("extra_body" in m for m in messages) + + async def test_telemetry_never_records_a_parameter_that_was_not_sent(self): + """不剥离则审计表会显示这次 OCR 带了 temperature=0——数据造假。""" + recorder = _MemoryRecorder() + client, _, _ = _client( + [_src(extra_body={"temperature": 0})], ["text"], telemetry=recorder + ) + await client.recognize_text(b"IMG") + assert recorder.rows[0]["sampling"] is None + + class TestTelemetry: async def test_success_and_failure_recorded_without_image_bytes(self): recorder = _MemoryRecorder() From 2958dc8231039d853195adc6f46cd7103069222a Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 31 Jul 2026 21:41:34 -0400 Subject: [PATCH 13/16] docs: document sampling passthrough and the empty thinking profiles --- .env.example | 4 ++++ CHANGELOG.md | 20 ++++++++++++++++++++ README.md | 2 +- research-wiki/ARCHITECTURE.md | 15 ++++++++++++--- src/polygateway/providers.py | 9 ++++++++- 5 files changed, 45 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index cabee47..41bf6e1 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,10 @@ LLM__QWEN__1__TIMEOUT_S=120 # LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不注入 / true=注入开启 / false=注入关闭 # LLM__QWEN__1__MISSING_DONE=retry # SSE 缺 [DONE]: retry(默认) | salvage # LLM__QWEN__1__TRUST_ENV=true # false = 绕过本地代理(LAN 直连) +# LLM__QWEN__1__EXTRA_BODY={"temperature":0} # 本源恒定的采样参数(JSON 对象串) +# 并入请求体,优先级低于 chat(overlay=...);受控实验固定解码用它,免得漏传 +# 禁用键 model/messages/stream/stream_options(会击穿治理),配了直接报错 +# OCR/EMBED scope 不消费该键: 配了会被忽略并 warning(见 issue #4 决策 G) # ══ scope 级全局闸(跨源合计;0/缺省 = 不启用)══ # LLM__GLOBAL__MAX_CONCURRENCY=8 diff --git a/CHANGELOG.md b/CHANGELOG.md index a80b948..b7787ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 未发布 + +采样参数透传(issue #4)。`chat()` 此前没有任何途径设置 `temperature` / `seed` / `max_tokens`——全库检索 `temperature` 零命中,`ChatRequest.overlay` 虽会被并进请求体却只由结构化中间件填充,调用方够不着。对受控实验而言这是阻塞性的:解码温度未知且可能随供应商默认值变化,每格配置跑 5 个 seed 报出的标准差无从解释。 + +### 新增(纯增,不破坏任何现有调用方) + +- **`chat()` 新增 keyword-only 参数 `overlay: Mapping[str, Any] | None = None`**,承载逐次变化的采样参数(每个 rollout 不同的 `seed`)。带默认值的 keyword-only 参数不改变既有调用点。 +- **`SourceConfig` 新增 `extra_body` 字段**,对应环境键 `{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY`(JSON **对象**串),承载全局恒定的参数(`temperature=0`)——免得每个调用点都要记得传,而漏传一次不会报错、只会让数字悄悄不可比。 +- **优先级为 结构化注入 > 调用级 `overlay` > 源级 `extra_body`。** 由现有层序天然给出,未引入新机制。 +- **遥测表 `llm_calls` 新增 `sampling` 列**,`TelemetryRecorder` 端口由 20 字段扩为 21;补列走 1.0.4 已建立的"先探测缺列再 ALTER、失败只逐行降级"套路。列语义是「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,**不含**结构化输出注入的 `response_format`(列名是采样参数,而数 KB 的 schema 逐行落库只会让审计表膨胀)。 + +### 下游请读 + +- **采样参数进缓存 key,所以逐次变化的 `seed` 天然全部 miss。** 这是正确语义而非缺陷:不进 key 的话,同 messages 跑 5 个 seed 会全部命中第一次的响应,标准差恒为 0 且不报错。代价是缓存对这条路径不再省钱。**不传采样参数时 key 逐字不变**,存量缓存不受影响。 +- **`model_fingerprint` 是集合级指纹,不是本次选中源的指纹。** 同 scope 下各源 `extra_body` 不同时,缓存仍可能返回另一源、另一组解码参数下产生的响应(这是既有取舍的延续,`model` 一直如此)。要求逐源可复现的实验应让每个源独享 scope 或 namespace。 +- **`{model, messages, stream, stream_options}` 是保护键,配了直接报 `ValueError`。** 它们由治理层拥有:`model` 被覆盖会让成本按错单价算,`stream`/`stream_options` 会绕过流式看门狗、丢掉 usage 帧。不可 JSON 序列化的值(如 numpy 标量)同样在进洋葱之前报错——否则会在缓存层的降级保护之外抛裸 `TypeError`,连一行遥测都留不下。 +- **`SourceConfig` 不再 hashable**,`dataclasses.asdict()` / `copy.deepcopy()` 也不再适用(加任何 mapping 字段的固有代价,裸 dict 亦然)。要可变副本用 `dict(source.extra_body)`,要改字段用 `dataclasses.replace(source, ...)`。 +- **OCR / embedding 路径不消费 `extra_body`**:配了会被**剥离并 warning**,装配照常成功。这两条路径的 transport 根本不发这个值(embed payload 硬编码 `{model, input}`、MonkeyOCR 只发 multipart 表单),剥离是为了让遥测不至于记录一个从未发出的参数。需要 `dimensions` 等 embedding 参数请提 issue。 +- **`enable_thinking` 对 `openai` / `minimax` 两个 provider 不产生任何效果**(它们的 thinking profile 两档皆空)。此前没有任何地方说明这一点,调用方可能以为自己关掉了推理。需要下发自定义参数请用 `extra_body`。 + ## 1.0.4(2026-07-31) 响应可观测字段扩展(issue #3)。下游 dissect 要把每次调用落成一行审计记录,其中两列拿不到值:供应商侧 prompt cache 命中了多少 token、这次调用实际跑的是哪个模型版本。前者关系到能否把「缓存命中率差异带来的成本」与「实验条件本身带来的成本」分开,后者关系到实验快照的可复现性。本次把两者暴露到公共类型与遥测表,并让成本换算认识缓存单价。 diff --git a/README.md b/README.md index b04ba99..2494b0f 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ async def main() -> None: await client.aclose() # 归还连接与治理后端资源 ``` -`chat()` 原生接受 OpenAI 多模态 content 数组(`image_url` data URL),VLM 调用无需专门客户端;`session_id` / `parent_call_id` / `cache_salt` 关键字参数用于链路追踪与缓存控制。 +`chat()` 原生接受 OpenAI 多模态 content 数组(`image_url` data URL),VLM 调用无需专门客户端;`session_id` / `parent_call_id` / `cache_salt` 关键字参数用于链路追踪与缓存控制;`overlay` 传采样参数(`temperature` / `seed` / `max_tokens` 等,恒定值宜配在源的 `EXTRA_BODY` 上)——它会进缓存 key,故逐次变化的 `seed` 天然不命中缓存。 ### 3. OCR 与 Embedding diff --git a/research-wiki/ARCHITECTURE.md b/research-wiki/ARCHITECTURE.md index 5fdc635..0afda18 100644 --- a/research-wiki/ARCHITECTURE.md +++ b/research-wiki/ARCHITECTURE.md @@ -361,6 +361,8 @@ flowchart TB **`chat()` 公共签名定稿(2026-07-20,GovDoc 迁移缺口 G1/G2)**: `chat(messages, *, session_id=None, parent_call_id=None, cache_salt=None, cache_namespace=None, structured=None, stream=True)`。要点: ① `session_id`/`parent_call_id` 与三项目现有 `LLMProvider.chat` Protocol 逐字兼容——这是"调用点零改动"承诺的前提;② **per-call `cache_namespace`**: GovDoc 是单 client 服务多租户、tenant 每请求变化,装配级 namespace 只是默认值,per-call 传入时覆盖并进入缓存 key(§7.5);③ `cache_salt` per-call 可传(Video-Tree 跨 epoch 重采样);④ `structured` 三档语义(D14),类型定稿 `type[BaseModel] | Literal["json"] | None`(M1 设计): 不传 = 原始文本,`"json"` = 仅修复,pydantic 模型 = 完整阶梯(修复+形态校验+有界带反馈重问)。 +**`overlay` 追加(2026-07-31,issue #4)**: 签名末尾增 `overlay: Mapping[str, Any] | None = None`,承载采样参数(`temperature`/`seed`/`max_tokens` 等)。带默认值的 keyword-only 参数不改变既有调用点,"签名冻结"承诺不破。要点: ① 优先级 **结构化注入 > 调用级 overlay > 源级 `extra_body`**,由 `StructuredMW` 的 `{**request.overlay, **strategy_overlay}` 与 transport `_build_payload` 的 update 顺序天然给出,无新机制;② 保护键 `{model, messages, stream, stream_options}` 与不可 JSON 序列化的值在**进洋葱之前**报 `ValueError`(前者被覆盖会击穿成本换算/缓存口径/流式看门狗/usage 帧,后者会在 `CacheMW` 的降级 try 之外抛裸 `TypeError` 且一行遥测都没有);③ 同时填 `ChatRequest.sampling` 快照字段——`overlay` 在洋葱不同深度取值不同(内层含 `response_format`),缓存 key 与遥测需要一个跨层恒定的读取点,否则同一列在不同行口径分叉。 + --- ## 6. 错误模型 @@ -435,11 +437,13 @@ flowchart TB ### 7.5 响应缓存 -**key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt}))`,前缀 `pgw:cache:`。 +**key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt, sampling}))`,前缀 `pgw:cache:`。 - `messages_digest`: 文本部分原文参与;多模态 content part(base64 图像等)先各自 sha256 摘要再参与——修正 Video-Tree 把整段 base64 进 hash 的开销问题,且 key 稳定性不变。 - `namespace`: 必填(项目名/租户 id),修正 GovDoc 缓存 key 缺租户隔离与多项目共用 Redis 时的互相毒化风险。 - `salt`: 可选,跨 epoch 强制重采样(Video-Tree 需求)。 +- `sampling`(2026-07-31,issue #4): 调用级采样参数,**仅非空时参与**(注意与 `salt` 的"仅非 None"不同——空串是有意义的 salt,而空采样参数与不传无差别),故空 overlay 时旧键逐字不变、存量缓存不冷启动。读 `request.sampling` 而非 `request.overlay`,不依赖"CacheMW 恰在 StructuredMW 外侧"的层序巧合。**不进 key 的后果**: 同 messages 跑 5 个 seed 会全部命中第一次的响应,标准差恒为 0 且不报错——受控实验静默作废。源级 `extra_body` 同理并入 `model_fingerprint`(全源皆空时字面量不变,否则追加 `|sha256(...)`,摘要对象是各源 `(model, extra_body)` 的 canonical JSON 排序去重——按模型而非源名,改源名不误触冷启动)。 +- **两条已知副作用**: ① 逐 rollout 变化的 `seed` 进 key 后该路径天然全部 miss(正确语义,但缓存对它不再省钱);② `model_fingerprint` 是**集合级**指纹而非本次选中源的指纹,同 scope 各源 `extra_body` 不同时仍可能返回另一源的响应(既有取舍的延续,与 `model` 同),要求逐源可复现应让每源独享 scope 或 namespace。 - value = `LLMResponse` 的 JSON;TTL 必填且 > 0(禁止永不过期,继承 Video-Tree 校验);Redis 不可用 → get 返回 None、set 吞异常记 warning(静默降级)。**只缓存成功响应**;`ResultInvalidError` 的原始响应不缓存(避免固化坏结果)。 ### 7.6 流式活性看门狗 @@ -448,7 +452,7 @@ flowchart TB ### 7.7 多源与选源 -`SourceConfig`: name/provider/base_url/api_key/model/超时组/限额组(单源并发/RPM/TPM)/`est_tokens`(TPM 预扣量的**可选调优覆盖**,移植 CHS `config.py:55`;2026-07-20 缺口 G2 补,2026-07-30 由必填降为可选)/enable_thinking。聚合自环境变量 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`(§9)。 +`SourceConfig`: name/provider/base_url/api_key/model/超时组/限额组(单源并发/RPM/TPM)/`est_tokens`(TPM 预扣量的**可选调优覆盖**,移植 CHS `config.py:55`;2026-07-20 缺口 G2 补,2026-07-30 由必填降为可选)/enable_thinking/`extra_body`(2026-07-31 issue #4: 本源恒定的采样参数,构造期校验保护键后转 `MappingProxyType`;**该字段令 SourceConfig 不再 hashable**——加任何 mapping 字段的固有代价,库内无以源作 dict key/set 元素的写法,要可变副本用 `dict(...)`、要改字段用 `dataclasses.replace`)。聚合自环境变量 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`(§9)。 **TPM 有效预扣量(2026-07-30,est_tokens 解耦设计,G2 闭环)**: `try_acquire`(§7.3)传入的 est 来自 `SourceConfig.effective_est_tokens()` 这一份纯方法,五个调用点(`QuotaGate` 入场 + chat/embedding 各自的成功侧与失败侧结算)共用,保证预扣与结算恒取同一值(`delta == 0`,否则押金会被整笔退回、TPM 闸退化成进门即放行)。规则:显式 `est_tokens > 0` 则原样用;否则 `tpm > 0` 时派生 `max(1, tpm // 60)`;`tpm == 0`(该闸不启用)时为 0。 @@ -460,7 +464,11 @@ flowchart TB ### 7.8 遥测与成本 -**必录字段**(继承三项目 15 字段规范): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**(后两者 2026-07-31 issue #3 新增,端口由 18 字段扩为 20;两个后端在初始化期对已存在的旧表幂等补列——`CREATE TABLE IF NOT EXISTS` 不会给旧表加列,不补则每行写入都被逐行 warning 丢弃。补列一律**先探测缺列再 ALTER**(`ADD COLUMN IF NOT EXISTS` 即使列已存在也先取 ACCESS EXCLUSIVE 锁,而遥测内联 await,锁共享审计表会拖垮业务调用),且**失败只逐行降级、绝不置结构性失能标志**。新列在 DDL 里必须排在 `created_at` **之后**,与 `ALTER TABLE ADD COLUMN` 的追加位置一致,否则新建库与升级库的物理列序分叉)。链路: `session_id`/`parent_call_id` 由调用方传入贯穿(agent step → LLM call)。`messages` 落库前对多模态 part 先摘要(与缓存 key 共用同一摘要函数,§7.5)——Video-Tree 现状 base64 整段进 SQLite 导致 db 膨胀(`llm.py:330`),库内修复(2026-07-20,VT 迁移缺口 R12)。 +**必录字段**(继承三项目 15 字段规范): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**。 + +**`sampling` 列(2026-07-31,issue #4,端口 20 → 21)**: 列语义 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。三个 emit 入口口径必须各自定死,否则同一列在不同行含义不同: `emit_attempt`(RetryMW 调用,**唯一**有生效源者)并上 `source.extra_body`;`emit_cache_hit` / `emit_terminal_failure`(TelemetryMW 最外层调用)无 source 可言,只记调用级——与 `model`/`source_name` 在终态行置空是同一先例,且缓存命中行无损(`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同)。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。OCR/embedding 路径因决策 G 剥离 `extra_body`,该列恒 NULL。 + +(`cached_prompt_tokens`/`model_reported` 为 2026-07-31 issue #3 新增,端口由 18 字段扩为 20;两个后端在初始化期对已存在的旧表幂等补列——`CREATE TABLE IF NOT EXISTS` 不会给旧表加列,不补则每行写入都被逐行 warning 丢弃。补列一律**先探测缺列再 ALTER**(`ADD COLUMN IF NOT EXISTS` 即使列已存在也先取 ACCESS EXCLUSIVE 锁,而遥测内联 await,锁共享审计表会拖垮业务调用),且**失败只逐行降级、绝不置结构性失能标志**。新列在 DDL 里必须排在 `created_at` **之后**,与 `ALTER TABLE ADD COLUMN` 的追加位置一致,否则新建库与升级库的物理列序分叉)。链路: `session_id`/`parent_call_id` 由调用方传入贯穿(agent step → LLM call)。`messages` 落库前对多模态 part 先摘要(与缓存 key 共用同一摘要函数,§7.5)——Video-Tree 现状 base64 整段进 SQLite 导致 db 膨胀(`llm.py:330`),库内修复(2026-07-20,VT 迁移缺口 R12)。 - 后端: `SQLiteRecorder`(默认;WAL + busy_timeout、`INSERT OR IGNORE` 幂等、`asyncio.to_thread` 桥接、初始化/写入失败全降级不冒泡)与 `PostgresRecorder`。 - **单一 helper 铁律**: 遥测调用点收敛为一个内部函数/上下文管理器;Video-Tree 与 GovDoc 各有 4-5 处逐字复制的 `record_llm_call(15 个参数)` 是本条的直接教训。 @@ -520,6 +528,7 @@ src/polygateway/ - **载体**: `.env` + 环境变量(工程配置);缺失关键配置直接报错,严禁硬编码默认值兜底(三项目共同铁律)。**实现勘误(2026-07-20 M1,人类确认)**: 多源 `{SCOPE}__{PROVIDER}__{N}__{FIELD}` 是动态键族,pydantic-settings 的静态字段模型无法表达,故 `GatewaySettings` 为 frozen dataclass + python-dotenv(显式核心依赖)读取,fail-loud 校验语义与 pydantic-settings 一致。 - **多源命名**: `{SCOPE}__{PROVIDER}__{N}__{FIELD}`(如 `LLM__QWEN__1__API_KEY`、`OCR__MONKEY__1__BASE_URL`),聚合为 `list[SourceConfig]`;SCOPE 支持逻辑角色前缀(§7.7)。 +- **`{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY`(2026-07-31,issue #4)**: 值为 JSON **对象**串(数组/标量报错),解析为源级恒定采样参数。`_SOURCE_FIELDS` 是跨 scope 共用的一张表,故该键在 `OCR__`/`EMBED__` 下也语法合法,但那两条路径不消费它(embed payload 硬编码 `{model, input}`、MonkeyOCR 只发 multipart)——处置为**构造期剥离 + warning 放行**而非报错(2026-07-31 人类拍板: 这两条路径本无采样语义,配错后果远轻于 chat,不值得让下游装配起不来)。剥离本身是承重的: 不剥离则遥测 `sampling` 列会记录一个从未发出的参数(§7.8),那是数据造假而非参数失效。 - **韧性参数键名**沿用三项目习惯(`LLM_TIMEOUT` / `LLM_MAX_RETRIES` / `LLM_RETRY_BASE_DELAY` / `LLM_RETRY_MAX_DELAY` / `LLM_CIRCUIT_BREAKER_THRESHOLD` / `LLM_CIRCUIT_BREAKER_COOLDOWN` / `LLM_TTFT_TIMEOUT` / `LLM_INTER_TOKEN_TIMEOUT`),降低三项目迁移改名成本。 - **per-scope 韧性配置(2026-07-20,CHS 迁移缺口 G4)**: 韧性参数支持按 scope 覆盖——`{SCOPE}__RETRY__MAX_ATTEMPTS` / `{SCOPE}__BREAKER__FAIL_THRESHOLD` / `{SCOPE}__BREAKER__COOLDOWN_S` / `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S` / `{SCOPE}__SELECTOR` / `{SCOPE}__GLOBAL__MAX_CONCURRENCY|RPM|TPM`(CHS 现状: VLM 与 OCR 两 scope 参数各异)。平铺键(`LLM_*`)是单 scope 场景的简写;两者并存时 scope 键优先。 - **装配只有两条路**: `GatewayClient.from_env()`/`from_settings(settings)`(工厂,覆盖 90% 用户;补上三项目每次手写、GovDoc 缺失的"配置→client"一段)或构造函数全量依赖注入(测试/高级用户)。库内部任何组件**不得自读环境变量**(显式优于隐式)。 diff --git a/src/polygateway/providers.py b/src/polygateway/providers.py index 9405988..d523c54 100644 --- a/src/polygateway/providers.py +++ b/src/polygateway/providers.py @@ -19,6 +19,10 @@ class ProviderProfile: True/False 时并入请求体的参数片段(None 时二者都不注入,用模型默认); strip_think_tags 声明响应 content 需剥离 ```` 标签(qwen 系); supports_native_schema 供 D14 阶梯选择原生 response_format 策略。 + + 注: 某个 provider 的两档若皆为空字典(如 openai/minimax),说明该 provider + 无已知的推理开关参数——此时 `enable_thinking` 对它**不产生任何效果**, + 而非静默生效。需要下发自定义参数时用 `SourceConfig.extra_body`。 """ name: str @@ -43,13 +47,16 @@ DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType( thinking_off={"thinking": {"type": "disabled"}}, strip_think_tags=False, ), + # 两档皆空 ⇒ `enable_thinking` 对本 provider **不产生任何效果**(调用方 + # 以为关掉了实际没关)。真需要控制推理时经 `SourceConfig.extra_body` 下发 "openai": ProviderProfile( name="openai", thinking_on={}, thinking_off={}, strip_think_tags=False, ), - # OpenAI 兼容基线,无已知注入差异;reasoning_content 由 transport 通用处理 + # OpenAI 兼容基线,无已知注入差异;reasoning_content 由 transport 通用处理。 + # 同上: 两档皆空 ⇒ `enable_thinking` 对 MiniMax 源不产生任何效果 "minimax": ProviderProfile( name="minimax", thinking_on={}, From cce7562d07ad17c2789f8c3da1785158b5d0e841 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 31 Jul 2026 21:46:05 -0400 Subject: [PATCH 14/16] test: verify sampling parameters through the full governance stack --- src/polygateway/middleware/telemetry.py | 6 +- src/polygateway/types.py | 8 +-- tests/integration/test_governance_stack.py | 72 ++++++++++++++++++++++ tests/unit/test_embedding.py | 4 +- tests/unit/test_ocr_client.py | 4 +- 5 files changed, 78 insertions(+), 16 deletions(-) diff --git a/src/polygateway/middleware/telemetry.py b/src/polygateway/middleware/telemetry.py index 81e155f..b2093c7 100644 --- a/src/polygateway/middleware/telemetry.py +++ b/src/polygateway/middleware/telemetry.py @@ -29,7 +29,7 @@ if TYPE_CHECKING: class TelemetryEmitter: - """从请求与结果组装 20 字段并写入 recorder;一切写失败降级 warning。""" + """从请求与结果组装 21 字段并写入 recorder;一切写失败降级 warning。""" def __init__(self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None) -> None: self._recorder = recorder @@ -65,9 +65,7 @@ class TelemetryEmitter: cached_prompt_tokens=response.cached_prompt_tokens if response else None, model_reported=response.model_reported if response else None, # 唯一有"生效源"的入口,故是唯一能并上 extra_body 的(设计决策 D) - sampling=canonical_sampling_json( - merge_sampling(source.extra_body, request.sampling) - ), + sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)), ) async def emit_cache_hit(self, *, request: ChatRequest, response: LLMResponse) -> None: diff --git a/src/polygateway/types.py b/src/polygateway/types.py index 96f71e5..723131f 100644 --- a/src/polygateway/types.py +++ b/src/polygateway/types.py @@ -59,9 +59,7 @@ def validate_request_overlay(overlay: Mapping[str, Any], *, origin: str) -> dict return dict(overlay) -def merge_sampling( - extra_body: Mapping[str, Any], sampling: Mapping[str, Any] -) -> dict[str, Any]: +def merge_sampling(extra_body: Mapping[str, Any], sampling: Mapping[str, Any]) -> dict[str, Any]: """合并配置级与调用级采样参数;调用级优先(issue #4 设计决策 A)。""" return {**extra_body, **sampling} @@ -237,9 +235,7 @@ class SourceConfig: object.__setattr__(self, "extra_body", MappingProxyType(validated)) -def strip_unsupported_extra_body( - sources: list[SourceConfig], *, path: str -) -> list[SourceConfig]: +def strip_unsupported_extra_body(sources: list[SourceConfig], *, path: str) -> list[SourceConfig]: """剥离非 chat 路径不消费的 `extra_body` 并 warning(issue #4 决策 G)。 剥离是必需的而非顺手清理: embedding 的 payload 硬编码 `{model, input}`、 diff --git a/tests/integration/test_governance_stack.py b/tests/integration/test_governance_stack.py index 86eb237..1b31d69 100644 --- a/tests/integration/test_governance_stack.py +++ b/tests/integration/test_governance_stack.py @@ -4,6 +4,7 @@ """ import asyncio +import dataclasses import json import sqlite3 @@ -204,3 +205,74 @@ class TestTransientErrorExport: assert isinstance(ei.value, polygateway.AllSourcesExhausted) assert isinstance(ei.value.__cause__, TransientError) + + +class TestSamplingThroughStack: + """issue #4: 采样参数经完整洋葱到达请求体,且缓存/遥测口径一致。""" + + async def test_reaches_wire_and_lands_in_telemetry(self, tmp_path): + seen = [] + + def handler(request): + seen.append(json.loads(request.content)) + return _sse() + + db = tmp_path / "t.db" + recorder = SQLiteRecorder(db) + client = _full_client(handler, telemetry=recorder) + await client.chat([{"role": "user", "content": "hi"}], overlay={"seed": 42}) + recorder.close() + + assert seen[0]["seed"] == 42 # 穿过全栈到达线上 + rows = sqlite3.connect(db).execute("SELECT sampling FROM llm_calls").fetchall() + assert json.loads(rows[0][0]) == {"seed": 42} + + async def test_config_level_merges_and_records(self, tmp_path): + """源级 extra_body 只有 emit_attempt 记得到(唯一有生效源的入口)。""" + seen = [] + + def handler(request): + seen.append(json.loads(request.content)) + return _sse() + + src = dataclasses.replace(_source(), extra_body={"temperature": 0}) + db = tmp_path / "t.db" + recorder = SQLiteRecorder(db) + client = GatewayClient( + scope="llm", + sources=[src], + selector=RoundRobinSelector(), + limiter=InMemoryLimiter( + scope="llm", sources={src.name: src}, global_limits=GlobalLimits(0, 0, 0) + ), + breaker=InMemoryGate(config=_BREAKER), + transport=OpenAICompatTransport( + client_factory=lambda s: httpx.AsyncClient(transport=httpx.MockTransport(handler)) + ), + retry=RetryPolicy(2, 2.0, 30.0), + backpressure=BackpressurePolicy(300.0, 0.01), + telemetry=recorder, + structured_strategy=JsonRepairStrategy(), + sleep=_noop_sleep, + ) + await client.chat([{"role": "user", "content": "hi"}], overlay={"seed": 1}) + recorder.close() + + assert seen[0]["temperature"] == 0 and seen[0]["seed"] == 1 + rows = sqlite3.connect(db).execute("SELECT sampling FROM llm_calls").fetchall() + assert json.loads(rows[0][0]) == {"seed": 1, "temperature": 0} + + async def test_differing_seed_bypasses_cache_end_to_end(self): + """issue 场景全栈回归: 逐 rollout 变 seed 必须真的回源。""" + calls = [] + + def handler(request): + calls.append(json.loads(request.content)["seed"]) + return _sse() + + client = _full_client(handler, cache=InMemoryCache()) + await client.chat([{"role": "user", "content": "hi"}], overlay={"seed": 1}) + await client.chat([{"role": "user", "content": "hi"}], overlay={"seed": 2}) + second_same = await client.chat([{"role": "user", "content": "hi"}], overlay={"seed": 1}) + assert calls == [1, 2] # 两个不同 seed 各自回源 + assert second_same.cache_hit is True # 同 seed 才命中 diff --git a/tests/unit/test_embedding.py b/tests/unit/test_embedding.py index c95b9b7..a33f5a8 100644 --- a/tests/unit/test_embedding.py +++ b/tests/unit/test_embedding.py @@ -381,9 +381,7 @@ class TestExtraBodyStripped: 会显示这次调用带了 temperature=0——那是数据造假,比参数失效更坏。 """ rec = _MemoryRecorder() - client, _ = _embed_client( - [_src(extra_body={"temperature": 0})], ["ok"], telemetry=rec - ) + client, _ = _embed_client([_src(extra_body={"temperature": 0})], ["ok"], telemetry=rec) await client.embed(["hi"]) assert rec.rows[0]["sampling"] is None diff --git a/tests/unit/test_ocr_client.py b/tests/unit/test_ocr_client.py index ba1f6d9..1c737d6 100644 --- a/tests/unit/test_ocr_client.py +++ b/tests/unit/test_ocr_client.py @@ -394,9 +394,7 @@ class TestExtraBodyStripped: async def test_telemetry_never_records_a_parameter_that_was_not_sent(self): """不剥离则审计表会显示这次 OCR 带了 temperature=0——数据造假。""" recorder = _MemoryRecorder() - client, _, _ = _client( - [_src(extra_body={"temperature": 0})], ["text"], telemetry=recorder - ) + client, _, _ = _client([_src(extra_body={"temperature": 0})], ["text"], telemetry=recorder) await client.recognize_text(b"IMG") assert recorder.rows[0]["sampling"] is None From 15b9b02e967f83e380fb2df2882ac0dcc5546f76 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 31 Jul 2026 22:01:51 -0400 Subject: [PATCH 15/16] fix: make the sampling invariant test actually enforce the constraint The test passed overlay and sampling as separate objects while production aliases them, so an in-place mutation slipped through it. Also syncs the telemetry schema page and adds the missing postgres round-trip assertion. --- research-wiki/index.md | 4 +-- research-wiki/log.md | 1 + research-wiki/schemas/llm-calls.md | 28 ++++++++++++++++++-- tests/integration/test_postgres_telemetry.py | 9 ++++++- tests/unit/test_ports.py | 1 + tests/unit/test_structured.py | 22 ++++++++++++--- 6 files changed, 57 insertions(+), 8 deletions(-) diff --git a/research-wiki/index.md b/research-wiki/index.md index 87592ab..7b60264 100644 --- a/research-wiki/index.md +++ b/research-wiki/index.md @@ -1,6 +1,6 @@ # Research Wiki 索引 -> 自动生成,更新时间:2026-07-31 17:01 UTC +> 自动生成,更新时间:2026-08-01 01:58 UTC ## design (20) - [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design` @@ -56,7 +56,7 @@ - [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan` ## schema (1) -- [表结构: llm_calls(遥测 20 字段)](schemas/llm-calls.md) `schema:llm-calls` +- [表结构: llm_calls(遥测 21 字段)](schemas/llm-calls.md) `schema:llm-calls` ## metric (2) - [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success` diff --git a/research-wiki/log.md b/research-wiki/log.md index 1a27b91..c71e234 100644 --- a/research-wiki/log.md +++ b/research-wiki/log.md @@ -72,3 +72,4 @@ - [2026-07-31 16:59 UTC] 新增边: plan:sampling-params-plan --implements--> design:sampling-params - [2026-07-31 16:59 UTC] 重建索引: 50 篇页面 - [2026-07-31 17:01 UTC] 重建索引: 50 篇页面 +- [2026-08-01 01:58 UTC] 重建索引: 50 篇页面 diff --git a/research-wiki/schemas/llm-calls.md b/research-wiki/schemas/llm-calls.md index d27b372..236478e 100644 --- a/research-wiki/schemas/llm-calls.md +++ b/research-wiki/schemas/llm-calls.md @@ -1,11 +1,11 @@ --- type: schema node_id: schema:llm-calls -title: "表结构: llm_calls(遥测 20 字段)" +title: "表结构: llm_calls(遥测 21 字段)" date: 2026-07-20 --- -# 表结构: llm_calls(遥测 20 字段) +# 表结构: llm_calls(遥测 21 字段) ## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8) @@ -26,6 +26,7 @@ date: 2026-07-20 | created_at | TEXT NOT NULL DEFAULT (datetime('now')) | 落库时刻 | | cached_prompt_tokens | INTEGER | 供应商 prompt cache 命中的输入 token(2026-07-31,issue #3);NULL = 该源未上报,`0` = 上报了真实零命中,两者不可混同 | | model_reported | TEXT | API 响应体实际返回的 model;NULL = 未上报。与 `model`(配置别名)可能分叉 | +| sampling | TEXT | 本次调用的采样参数 canonical JSON(2026-07-31,issue #4);NULL = 未传。见下方口径 | ## usage/成本口径(2026-07-30,est_tokens 解耦) @@ -50,6 +51,29 @@ FROM llm_calls WHERE cache_hit = false AND cached_prompt_tokens IS NOT NULL; `WHERE cache_hit = false` 不可省,理由与上面 cost 缺口口径同源:回放行计入即重复计数。 +## 采样参数口径(2026-07-31,issue #4) + +`sampling` 列 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化输出注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。补列纪律与 issue #3 两列逐字相同(排在末尾、先探测再 ALTER、失败只逐行降级)。 + +三个 emit 入口的取值必须各自定死,否则同一列在不同行含义不同: + +| 入口 | 调用者 | 有生效源? | 记什么 | +|---|---|---|---| +| `emit_attempt` | RetryMW(最内) | 有 | `merge(source.extra_body, request.sampling)` | +| `emit_cache_hit` | TelemetryMW(最外) | 无 | 仅 `request.sampling` | +| `emit_terminal_failure` | TelemetryMW | 无 | 仅 `request.sampling` | + +后两行缺 `extra_body` 是客观事实而非口径瑕疵——它们没有"生效源"可言,与 `model`/`source_name` 在终态行置空是同一先例;缓存命中行亦无损:`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。 + +OCR / embedding 路径的该列**恒为 NULL**:两条路径的 transport 不发 `extra_body`(embed payload 硬编码 `{model, input}`、MonkeyOCR 只发 multipart),故其源在构造期就被剥离——不剥离则该列会记录一个从未发出的参数,那是数据造假而非参数失效。 + +复现某批实验的解码条件: + +```sql +SELECT DISTINCT sampling FROM llm_calls +WHERE session_id = $1 AND cache_hit = false AND error IS NULL; +``` + ## 埋点位置(单一 helper 铁律) - `middleware/telemetry.py::TelemetryEmitter` 是全库**唯一** `record_llm_call` 调用点; diff --git a/tests/integration/test_postgres_telemetry.py b/tests/integration/test_postgres_telemetry.py index 50286c5..3b1203c 100644 --- a/tests/integration/test_postgres_telemetry.py +++ b/tests/integration/test_postgres_telemetry.py @@ -11,6 +11,7 @@ DSN 走 .env `PGW_TELEMETRY_PG_DSN`,缺则 skip。该实例上有 app/chs_prod from __future__ import annotations import asyncio +import json import os from uuid import uuid4 @@ -180,9 +181,12 @@ class TestObservabilityColumns: await _record_minimal(recorder, call_id=_cid("hit"), cached_prompt_tokens=64) await _record_minimal(recorder, call_id=_cid("zero"), cached_prompt_tokens=0) await _record_minimal(recorder, call_id=_cid("model"), model_reported="MiniMax-01") + await _record_minimal( + recorder, call_id=_cid("samp"), sampling='{"seed": 42, "temperature": 0}' + ) rows = await _fetch( dsn, - "SELECT call_id, cached_prompt_tokens, model_reported FROM llm_calls " + "SELECT call_id, cached_prompt_tokens, model_reported, sampling FROM llm_calls " "WHERE call_id LIKE $1", f"{_RUN_PREFIX}-%", ) @@ -191,6 +195,9 @@ class TestObservabilityColumns: assert by_id[_cid("zero")]["cached_prompt_tokens"] == 0 # 真实零命中 ≠ NULL assert by_id[_cid("model")]["cached_prompt_tokens"] is None assert by_id[_cid("model")]["model_reported"] == "MiniMax-01" + # issue #4: PG 侧也须验非空 sampling 能读回原值(不只是列存在) + assert json.loads(by_id[_cid("samp")]["sampling"]) == {"seed": 42, "temperature": 0} + assert by_id[_cid("hit")]["sampling"] is None finally: await recorder.aclose() diff --git a/tests/unit/test_ports.py b/tests/unit/test_ports.py index cb60efd..74bb5f9 100644 --- a/tests/unit/test_ports.py +++ b/tests/unit/test_ports.py @@ -116,6 +116,7 @@ class _DummyRecorder: cost, cached_prompt_tokens, model_reported, + sampling, ) -> None: ... diff --git a/tests/unit/test_structured.py b/tests/unit/test_structured.py index 2b4df28..0400ee0 100644 --- a/tests/unit/test_structured.py +++ b/tests/unit/test_structured.py @@ -193,7 +193,14 @@ class TestSamplingSnapshotInvariant: terminal = ScriptedTerminal(["not json at all", '{"answer": 1, "reason": "r"}']) mw = _mw(strategy=NativeSchemaStrategy(), max_retries=1) await mw( - ChatRequest(messages=_MSGS, structured=Verdict, sampling=caller_sampling), + # overlay 与 sampling 传**同一个对象**,复现 client.py 的别名关系 + # ——否则中间件就地改写 overlay 时不会波及 sampling,这条执法就是空的 + ChatRequest( + messages=_MSGS, + structured=Verdict, + overlay=caller_sampling, + sampling=caller_sampling, + ), terminal, ) assert len(terminal.requests) == 2 # 确实重问过 @@ -205,11 +212,20 @@ class TestSamplingSnapshotInvariant: assert "response_format" not in seen.sampling async def test_middleware_does_not_mutate_caller_mapping(self): - """决策 E 的第二条约束: 中间件只能 replace 派生,不得就地改这两个 dict。""" + """决策 E 的第二条约束: 中间件只能 replace 派生,不得就地改这两个 dict。 + + 同样传同一对象: 生产中 overlay 与 sampling 是别名,任何对 overlay 的 + 就地改写都会同步毒化缓存 key 与遥测列。 + """ caller_sampling = {"seed": 7} terminal = ScriptedTerminal(['{"answer": 1, "reason": "r"}']) await _mw(strategy=NativeSchemaStrategy())( - ChatRequest(messages=_MSGS, structured=Verdict, sampling=caller_sampling), + ChatRequest( + messages=_MSGS, + structured=Verdict, + overlay=caller_sampling, + sampling=caller_sampling, + ), terminal, ) assert caller_sampling == {"seed": 7} # 调用方的对象未被污染 From dfda59fec23f8eddc719780d56430a646414b299 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 31 Jul 2026 23:52:49 -0400 Subject: [PATCH 16/16] chore: release 1.0.5 with sampling parameter passthrough --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- src/polygateway/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7787ad..baa7f95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 未发布 +## 1.0.5(2026-07-31) 采样参数透传(issue #4)。`chat()` 此前没有任何途径设置 `temperature` / `seed` / `max_tokens`——全库检索 `temperature` 零命中,`ChatRequest.overlay` 虽会被并进请求体却只由结构化中间件填充,调用方够不着。对受控实验而言这是阻塞性的:解码温度未知且可能随供应商默认值变化,每格配置跑 5 个 seed 报出的标准差无从解释。 diff --git a/pyproject.toml b/pyproject.toml index 034a9ba..c6cfc37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "polygateway" -version = "1.0.4" +version = "1.0.5" description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测" requires-python = ">=3.11" dependencies = [ diff --git a/src/polygateway/__init__.py b/src/polygateway/__init__.py index f38041b..88d63f9 100644 --- a/src/polygateway/__init__.py +++ b/src/polygateway/__init__.py @@ -31,7 +31,7 @@ from polygateway.types import ( SourceConfig, ) -__version__ = "1.0.4" +__version__ = "1.0.5" __all__ = [ "DEFAULT_PROFILES",