feat: pass sampling parameters through to the provider (issue #4)

This commit is contained in:
2026-08-01 00:00:44 -04:00
38 changed files with 1595 additions and 42 deletions
+4
View File
@@ -18,6 +18,10 @@ LLM__QWEN__1__TIMEOUT_S=120
# LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不注入 / true=注入开启 / false=注入关闭 # LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不注入 / true=注入开启 / false=注入关闭
# LLM__QWEN__1__MISSING_DONE=retry # SSE 缺 [DONE]: retry(默认) | salvage # LLM__QWEN__1__MISSING_DONE=retry # SSE 缺 [DONE]: retry(默认) | salvage
# LLM__QWEN__1__TRUST_ENV=true # false = 绕过本地代理(LAN 直连) # 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/缺省 = 不启用)══ # ══ scope 级全局闸(跨源合计;0/缺省 = 不启用)══
# LLM__GLOBAL__MAX_CONCURRENCY=8 # LLM__GLOBAL__MAX_CONCURRENCY=8
+20
View File
@@ -1,5 +1,25 @@
# Changelog # Changelog
## 1.0.5(2026-07-31)
采样参数透传(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) ## 1.0.4(2026-07-31)
响应可观测字段扩展(issue #3)。下游 dissect 要把每次调用落成一行审计记录,其中两列拿不到值:供应商侧 prompt cache 命中了多少 token、这次调用实际跑的是哪个模型版本。前者关系到能否把「缓存命中率差异带来的成本」与「实验条件本身带来的成本」分开,后者关系到实验快照的可复现性。本次把两者暴露到公共类型与遥测表,并让成本换算认识缓存单价。 响应可观测字段扩展(issue #3)。下游 dissect 要把每次调用落成一行审计记录,其中两列拿不到值:供应商侧 prompt cache 命中了多少 token、这次调用实际跑的是哪个模型版本。前者关系到能否把「缓存命中率差异带来的成本」与「实验条件本身带来的成本」分开,后者关系到实验快照的可复现性。本次把两者暴露到公共类型与遥测表,并让成本换算认识缓存单价。
+1 -1
View File
@@ -80,7 +80,7 @@ async def main() -> None:
await client.aclose() # 归还连接与治理后端资源 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 ### 3. OCR 与 Embedding
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "polygateway" name = "polygateway"
version = "1.0.4" version = "1.0.5"
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测" description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+12 -3
View File
@@ -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 模型 = 完整阶梯(修复+形态校验+有界带反馈重问)。 **`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. 错误模型 ## 6. 错误模型
@@ -435,11 +437,13 @@ flowchart TB
### 7.5 响应缓存 ### 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 稳定性不变。 - `messages_digest`: 文本部分原文参与;多模态 content part(base64 图像等)先各自 sha256 摘要再参与——修正 Video-Tree 把整段 base64 进 hash 的开销问题,且 key 稳定性不变。
- `namespace`: 必填(项目名/租户 id),修正 GovDoc 缓存 key 缺租户隔离与多项目共用 Redis 时的互相毒化风险。 - `namespace`: 必填(项目名/租户 id),修正 GovDoc 缓存 key 缺租户隔离与多项目共用 Redis 时的互相毒化风险。
- `salt`: 可选,跨 epoch 强制重采样(Video-Tree 需求)。 - `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` 的原始响应不缓存(避免固化坏结果)。 - value = `LLMResponse` 的 JSON;TTL 必填且 > 0(禁止永不过期,继承 Video-Tree 校验);Redis 不可用 → get 返回 None、set 吞异常记 warning(静默降级)。**只缓存成功响应**;`ResultInvalidError` 的原始响应不缓存(避免固化坏结果)。
### 7.6 流式活性看门狗 ### 7.6 流式活性看门狗
@@ -448,7 +452,7 @@ flowchart TB
### 7.7 多源与选源 ### 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。 **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 遥测与成本 ### 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` - 后端: `SQLiteRecorder`(默认;WAL + busy_timeout、`INSERT OR IGNORE` 幂等、`asyncio.to_thread` 桥接、初始化/写入失败全降级不冒泡)与 `PostgresRecorder`
- **单一 helper 铁律**: 遥测调用点收敛为一个内部函数/上下文管理器;Video-Tree 与 GovDoc 各有 4-5 处逐字复制的 `record_llm_call(15 个参数)` 是本条的直接教训。 - **单一 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 一致。 - **载体**: `.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}__{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`),降低三项目迁移改名成本。 - **韧性参数键名**沿用三项目习惯(`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 键优先。 - **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"一段)或构造函数全量依赖注入(测试/高级用户)。库内部任何组件**不得自读环境变量**(显式优于隐式)。 - **装配只有两条路**: `GatewayClient.from_env()`/`from_settings(settings)`(工厂,覆盖 90% 用户;补上三项目每次手写、GovDoc 缺失的"配置→client"一段)或构造函数全量依赖注入(测试/高级用户)。库内部任何组件**不得自读环境变量**(显式优于隐式)。
@@ -0,0 +1,234 @@
# 采样参数透传设计(issue #4)
- **日期**: 2026-07-31
- **状态**: 待人类审批
- **触发**: issue #4 —— `chat()` 无法设置 `temperature`/`seed`/`max_tokens`,下游受控实验无法固定解码
- **影响面**: `chat()` 公共签名、`SourceConfig` 公共类型、缓存 key 公式(ARCH §7.5)、遥测端口(20 → 21 字段)
---
## 1. 诉求与现状审计
下游 dissect 是一组受控实验:解码固定 `temperature=0`,每格配置跑 5 个 seed 报标准差。标准差必须只反映被研究的变量,不能混进解码随机性。
代码事实(本会话核实):
| 事实 | 位置 | 后果 |
|---|---|---|
| 全库 `temperature` 零命中 | `grep -rn temperature src/` | 解码跑在供应商默认值上,不可复现 |
| `chat()` 签名无 overlay 入口 | `client.py:143-153` | 调用方够不着 `ChatRequest.overlay` |
| `overlay` 唯一写入点是结构化中间件 | `middleware/structured.py:98` | 字段存在但只服务库内 |
| `payload.update(overlay)` 是最后一步 | `transports/openai_compat.py:297` | overlay 可覆盖 `model`/`messages`/`stream`/`stream_options` |
| 缓存 key 公式不含 overlay | `middleware/cache.py:52-64` | **见 §2 决策 C** |
| `model_fingerprint` 只由源 `model` 名算 | `client.py:117` | 配置级采样参数变更不改 key |
| minimax / openai profile 均 `thinking_off={}` | `providers.py:49,56` | `enable_thinking=False` 对两源均无效果 |
**issue 未提及但必须一并处理的**: 缓存与遥测的交互。不处理的话,failure mode 恰是 issue 自己最担心的那种——数字悄悄不可比,且不报错。
---
## 2. 设计决策
### 决策 A: 两层入口,合并优先级由现有层序天然给出
| 层 | 载体 | 用途 | 生效点 |
|---|---|---|---|
| 调用级 | `chat(..., overlay: Mapping[str, Any] \| None = None)` | 逐次变化(每 rollout 不同的 `seed`) | 填入 `ChatRequest` |
| 配置级 | `SourceConfig.extra_body: Mapping[str, Any]` | 全局恒定(`temperature=0`) | transport `_build_payload` |
优先级 **结构化注入 > 调用级 > 配置级**,无需任何新机制:
```text
_build_payload: payload{model,messages,stream} → thinking_profile
→ source.extra_body ← 配置级(新增一行)
→ overlay ← 调用级 ⊎ 结构化注入
StructuredMW: {**request.overlay, **strategy_overlay} ← 结构化已在最右,天然最高
```
配置级放在 transport 而非装配层合并,是因为 `extra_body` 是 per-source 的,选源在 RetryMW 之后才确定;放 transport 无需改动任何端口签名。
**`ChatRequest` 增第二个字段 `sampling: Mapping[str, Any] = field(default_factory=dict)`**(调用方原始采样意图的快照,库内中间件**永不修改**),与 `overlay`(请求体覆盖层,会被结构化注入)分开。`chat()` 同时填两者。理由是 `overlay` 在洋葱不同深度取值不同——`StructuredMW` 内侧含 `response_format`、外侧不含——缓存 key 与遥测若各自依赖"在哪一层读"就会口径分叉(见决策 C/D)。`sampling` 提供一个跨层恒定的读取点。
类型定死为 `Mapping` 而非 `dict[str, Any] | None`:空 dict 与 `None` 在此无语义差别(都是"没传采样参数"),多一种表示只会让 key 公式与 `merge()` 签名各选各的。因此决策 C 的 key 公式一律按**仅非空**参与(注意与同处的 `salt` 不同——`salt` 是"仅非 None",空串是有意义的 salt)。
### 决策 B: 保护键黑名单,构造期显式报错
`{model, messages, stream, stream_options}` 禁止出现在 overlay/extra_body 中。理由逐条:
| 键 | 被覆盖的后果 |
|---|---|
| `model` | 遥测记录的 model 与实际请求分叉 → 成本按错单价算 |
| `messages` | 缓存 key 与遥测口径同时失真 |
| `stream` | 绕过流式看门狗(TTFT/inter-token 三层超时全失效) |
| `stream_options` | 丢 usage 帧 → 成本遥测归零、TPM 闸按预扣量结算失准 |
同一校验函数还必须验**值可 JSON 序列化**。理由:`CacheMW.__call__` 第 95 行的 `build_cache_key` 内部 `json.dumps`,**不在 `_safe_get`/`_safe_set` 的降级 try 内**;`TelemetryMW` 只捕 `GatewayUnavailableError`/`GovernanceBackendError`/`CancelledError`。调用方传 `{"temperature": np.float32(0)}`(温度扫描用 numpy 生成极自然)会抛裸 `TypeError`:不属四分类、一行遥测都没有、RetryMW 从未执行。构造期一次校验即可保住"overlay 错误全部发生在进洋葱之前"这条不变式。
校验函数落在 `types.py`(最内层,无依赖),两个入口各调一次:`chat()` 参数在进洋葱**之前**校验(与既有 `structured` 的 ImportError 同款先例),`SourceConfig.__post_init__` 在装配期校验(符合 §4.5「缺失/非法关键配置直接报错」)。抛裸 `ValueError`——这是调用方编程错误,不属 §6 四分类,不应被 RetryMW 当作可重试失败。
transport 不重复校验:三个 overlay 来源(chat 参数、SourceConfig 字段、库内策略)已全部在构造期收口,库内策略只注入 `response_format`(`json_repair.py:41` 恒空,`native_schema.py:21-29` 只产该键)。
### 决策 C: 调用级 overlay 进缓存 key —— 本设计的关键点
不做的话:同 messages 跑 5 个 seed,后 4 次命中第一次的缓存,返回同一 response,**标准差恒为 0**,实验静默作废。这正是「无缓存毒化」铁律的场景。
key 公式扩展(ARCH §7.5 需同步修订),读 `request.sampling` 而非 `request.overlay`——语义明确、不依赖"CacheMW 恰在 StructuredMW 外侧"这一层序巧合:
```text
key_obj = {model, messages_digest, namespace, [salt], [sampling]}
仅非 None 仅非空
```
沿用 `salt` 的「仅非空时参与」写法,保证**空采样参数时旧键逐字不变**,不触发存量缓存全量冷启动。
配置级同理:`model_fingerprint``",".join(sorted(models))` 扩展为——所有源 `extra_body` 皆空时字面不变;否则追加 `"|" + sha256(...)`,摘要对象是「每个源的 `(model, extra_body)` 先各自 canonical-JSON 化成字符串,再排序去重」(dict 本身既不可排序也不可哈希,必须先序列化;`extra_body` 若存为 `MappingProxyType``dict(...)` 后再 `json.dumps`)。取 `(model, extra_body)` 而非 `(name, ...)`,语义是「本 scope 会用哪些(模型,解码参数)组合」,改源名不会误触冷启动。该计算在 `client.py:117` 且不在任何降级 try 内,写错即装配期崩——实施时须有直接单测。
**两条已知副作用(须写进 wiki)**:
1. 逐 rollout 变化的 `seed` 进 key 后,该路径**天然全部 miss**。这是正确语义而非缺陷,但下游要知道缓存对这条路径不再省钱。
2. `model_fingerprint` 是**集合级**指纹,不是本次实际选中源的指纹。同 scope 下各源 `extra_body` 不同时,缓存仍可能返回另一源、另一组解码参数下产生的响应。这是既有取舍的延续(`cache.py:68-72``model` 已如此),不是本设计引入的新缺口,但"配置级采样参数进 key"容易被读成更强的保证,须写明边界。受控实验若要求逐源可复现,应让每个源独享 scope 或 namespace。
### 决策 D: 采样参数入遥测(端口 20 → 21 字段)
「实验可复现」的另一半是参数落库。不记的话,同 messages 不同输出在审计表里无法解释。与 issue #3 新增 `model_reported` 同类动机(供应商把别名指向新权重时,复现必须认真实串)。
**列语义定死**:`sampling: str | None` = 「调用方采样意图 ⊎ 生效源的 `extra_body`」的 canonical JSON,**不含库内结构化注入的 `response_format`**。两个理由:该列名叫采样参数,`response_format` 不是;schema 可达数 KB,逐行记会让审计表无谓膨胀。
`TelemetryEmitter` 有三个入口且都汇入同一个 `_record`(显式关键字参数,加列必须三处都传),必须逐个定死,否则同一列在不同行口径分叉——这正是 1.0.4 里 `cached_prompt_tokens` 不得不写"下游请读"警告的同类坑:
| 入口 | 调用者 | 有 `source`? | `sampling` 记什么 |
|---|---|---|---|
| `emit_attempt` | RetryMW(最内) | 有 | `merge(source.extra_body, request.sampling)` |
| `emit_cache_hit` | TelemetryMW(最外) | **无** | 仅 `request.sampling` |
| `emit_terminal_failure` | TelemetryMW | **无** | 仅 `request.sampling` |
后两行缺 `extra_body` 是**客观事实而非口径瑕疵**:它们没有"生效源"可言——与 `model`/`provider`/`source_name` 在终态行置空是同一先例。缓存命中行尤其无损:`sampling` 已进缓存 key,能命中就意味着历史那次的调用级采样参数与本次逐字相同;`extra_body` 亦已进 `model_fingerprint`,命中意味着源集合的配置指纹相同。
三个入口统一读 `request.sampling`(决策 A 的新字段)而非 `request.overlay`,是因为后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处则未被污染,直接用会让三行天然分叉。
**共用范围写清楚**:`types.py` 提供「2 参 dict 合并 + canonical 序列化」这一个原语,transport 与 emitter 共用它。**不追求统一到两者之上**——transport 是往更大的 payload 上依次 `update(thinking_profile) → update(extra_body) → update(overlay)`,emitter 算的是 `merge(extra_body, sampling)`,参与方与顺序本就不同,强行统一是错的。这不影响正确性:该列语义已定义为「调用方意图 ⊎ 生效源 `extra_body`」,而非 payload 的逐字回显。共用原语的目的只是让"合并语义与序列化口径"这一件事不出现两份实现。
两个后端按 issue #3 已建立的套路幂等补列:**先探测缺列再 ALTER**、失败只逐行降级不置结构性失能标志、新列排在 `created_at` 之后。
一次做完而非分两步:「能传参数但没记」的中间状态最危险——数据已产生且事后无法追溯,且分步要做两遍 DDL 迁移。
**OCR/Embedding 的 emit 调用点零改动**:`ocr.py:418``embedding.py:372` 也调 `emit_attempt` 且都传 `source`,只要 `sampling` 由 emitter 内部推导(而非作为新必填参数由调用者传入),这两处调用不动一行——反之立刻 TypeError,实施时必须走推导路线。两个文件本身仍有改动,即决策 G 的构造期剥离(它正是让这里的推导对 OCR/embedding 恒得 NULL 的前提)。
### 决策 E: 入参拷贝语义与两条只读约束
`chat()` 对传入 overlay 做**一次** `dict(overlay)` 浅拷贝,同一份快照对象同时填 `overlay``sampling` 两个字段(不做两份独立拷贝——它们在进入 `StructuredMW` 之前本就应当逐字相同,两份拷贝反而给"两者可以分叉"留了口子)。
issue 场景就是逐次改 `seed`——调用方复用同一 dict 对象改值是极可能的模式,不拷贝会出现「请求已发出、key 用了新 seed」的竞态。`ChatRequest` 虽 frozen 但 dict 是浅冻结,拦不住。`SourceConfig.extra_body``__post_init__``MappingProxyType` 同理(成本近零)。
拷贝之外的第二条约束:**任何中间件不得就地修改这两个 dict**,只能经 `dataclasses.replace` 派生新请求。现状已满足(`StructuredMW``{**a, **b}` 生成新 dict,`_build_payload` 只往 payload 上 `update`,全库无就地改写),本设计只是把它写成明文约束——决策 C 与 D 都建立在 `sampling` 跨层恒定之上,这条被破坏则两者同时失效(测试 #14 为此加机械执法)。
### 决策 F: 空 thinking profile 的诚实性缺口(issue 附带项)
`minimax``openai``thinking_on/thinking_off` 均为空字典。`providers.py:52` 那条「OpenAI 兼容基线,无已知注入差异」的注释在词法上属于紧随其后的 **minimax** 条目,`openai` 条目没有任何注释。所以现状是:已有的注释解释了"为何为空",但两个 provider 都没点明**后果**——`enable_thinking=False` 对它们不产生任何效果,调用方以为关掉了实际没关。
补的是这一句后果说明(覆盖两个 provider),不是重复已有的"为何为空"。不改行为:真需要关时经 `extra_body` 绕过。
### 决策 G: 非 chat 路径的 `extra_body` —— 剥离并 warning,不中断装配
`_SOURCE_FIELDS`(`config.py:33-47`)是**跨 scope 共用**的一张表,加了 `EXTRA_BODY` 之后 `OCR__MONKEY__1__EXTRA_BODY` / `EMBED__QWEN__1__EXTRA_BODY` 会被合法接受、进 `SourceConfig`、进遥测 `sampling` 列,但两条路径都不消费它:`monkey_ocr.py:225,247` 只发 multipart `files=`(**根本没有 JSON body**),`OpenAICompatTransport.embed`(`openai_compat.py:343`)payload 硬编码 `{"model", "input"}`。放任即**静默无效**,正是 §4.5 要禁的形态。
**处置(2026-07-31 人类拍板改此档)**:`EmbeddingClient` / `OcrClient` 构造期发现源带非空 `extra_body` → 记 warning 并 `dataclasses.replace(source, extra_body={})` **剥离后放行**,不抛异常。
剥离是这一档的**必要组成部分,不是顺手清理**。`ocr.py:390``embedding.py:350` 构造 `ChatRequest` 时不带 `sampling`,但传给 `emit_attempt``source` 是真实配置对象;若不剥离,决策 D 的 `merge(source.extra_body, request.sampling)` 会让遥测**记录一个从未发出的参数**——审计表显示该次 OCR 调用带了 `temperature=0`,实际请求体里没有。那不是"参数不生效",是遥测造假,污染的恰是事后复现的唯一依据。替代方案是在 emitter 里特判调用方身份,直接违背「遥测调用点收敛为单一 helper」铁律,否决。
剥离后该列在 OCR/embedding 行恒为 NULL,语义干净,emitter 零特判。
**被否决的原方案**: 装配期 `ValueError` 直接拒绝。理由是这两条路径本无采样语义,配错的后果远轻于 chat 路径,不值得让下游整个装配起不来。**残余风险须写进 wiki**: loguru warning 在生产中容易被淹没,运维可能仍以为参数生效——这是"不中断装配"换来的代价,故 warning 文案必须**指路**:`dimensions` 是 OpenAI embeddings 的正式参数,下游想调向量维度时会第一个撞上,文案应写明"embedding 路径暂不支持 `extra_body`,该配置已被忽略;需要 `dimensions` 等参数请提 issue"。
不顺手给 embed 加透传:embedding 没有采样一说,issue 也未提出诉求(YAGNI);真有需求时单独设计。
---
## 3. 关键岔路与否决记录
| 岔路 | 否决方 | 理由 |
|---|---|---|
| `chat()` 展开为 `temperature=`/`seed=`/`max_tokens=` 具名参数 | 否决 | 供应商私有参数无穷尽(`top_k`/`repetition_penalty`/`thinking_budget`),具名等于永久追加签名;且违背「深模块窄接口」(ARCH §132) |
| 配置级放装配层全局字典而非 `SourceConfig` | 否决 | 采样参数与源强相关(不同供应商键名不同),全局字典会把无效键发给不认识它的源 |
| overlay 不进缓存 key,靠调用方传 `cache_salt` 区分 | 否决 | 把毒化防护的责任推给调用方,漏传不报错——正是 issue 抱怨的失败形态 |
| 采样参数不入遥测,由下游 run 快照自记 | 否决 | 见决策 D |
| 缓存 key 与遥测都直接读 `request.overlay`,不加 `sampling` 字段 | 否决 | `overlay` 在洋葱不同深度取值不同(结构化注入),三个 emit 入口与 CacheMW 会各记各的,同一列口径分叉 |
| `sampling` 列记「实际发出的完整合并结果」(含 `response_format`) | 否决 | 该列名为采样参数,schema 不是;且数 KB schema 逐行落库无谓膨胀 |
| 给 embedding 路径也加 `extra_body` 透传 | 否决 | embedding 无采样一说,issue 未提诉求(决策 G) |
| 非 chat 路径带 `extra_body` 时装配期 `ValueError` | 否决(人类拍板) | 这两条路径无采样语义,配错后果远轻于 chat,不值得让下游装配起不来;改为剥离 + warning |
| 允许放行但**不剥离** `extra_body` | 否决 | 遥测会记录一个从未发出的参数(决策 D 的 merge 读 `source.extra_body`),是数据造假而非参数失效 |
| 放行不剥离,改在 emitter 内特判 OCR/embedding 不记 | 否决 | emitter 是「遥测调用点收敛单一 helper」的产物,让它识别调用方身份是开倒车 |
| transport 层再兜一次保护键校验 | 否决 | 三个入口已构造期收口,重复校验属 gold-plating |
---
## 4. 非功能维度
| 维度 | 回答 |
|---|---|
| **并发** | 无新增共享状态。`extra_body` 装配后只读(MappingProxyType);调用级 overlay 每调用独立拷贝,并发调用互不可见 |
| **取消** | 无新增 await 点与等待循环,`CancelledError` 穿透路径完全不变 |
| **降级方向** | 不涉及新后端。遥测新列写失败沿用既有逐行 warning 降级;缓存 key 变更不影响 Redis 掉线的静默降级方向。决策 G 的剥离 + warning 是**配置面**降级(装配期一次性、可复现、部署即暴露),与铁律里"限流/熔断后端不可用须报错"的**运行时**降级方向是两回事,不冲突 |
| **幂等与重复** | 保护键校验是纯函数,重复调用安全;遥测补列先探测后 ALTER,重启幂等 |
| **持久化与原子性** | 遥测单行写入,无部分写入风险。缓存 value 结构不变(`sampling` 只进遥测不进 `LLMResponse`,避免动已被三项目消费的公共类型) |
| **重试交互** | overlay 在 RetryMW 循环外确定,换源重试时同一 overlay 应用到新源的 `extra_body` 之上——语义正确(调用级意图跨源保持) |
| **限流交互** | overlay 里的 `max_tokens` 不影响入场预扣(取 `effective_est_tokens()`)。调用方把 `max_tokens` 抬到远超预扣量时 TPM 入场保护会短暂失真,结算侧(`retry.py:338-343`)按实测用量回填自愈。已知且可接受,不为此加机制 |
---
## 5. 错误处理与测试策略
**错误分类**: 保护键违规与 `EXTRA_BODY` JSON 解析失败均为裸 `ValueError`,发生在进入洋葱之前/装配期,不入四分类、不触发重试或熔断。运行时若供应商拒绝某个采样参数(如不支持 `seed`),网关返回 4xx,由既有 `RequestRejectedError` 路径处置——无需新增分类。
**测试清单**(每条须先失败后通过):
| # | 用例 | 层 |
|---|---|---|
| 1 | 同 messages 不同 `seed` → 两次 miss、两个不同 key(issue 场景直接回归) | unit |
| 2 | 空 overlay 时 key 与旧实现逐字相同(防存量冷启动) | unit |
| 3 | 全源 `extra_body` 为空时 fingerprint 与旧实现逐字相同 | unit |
| 4 | 保护键:`chat(overlay={"stream": False})``SourceConfig(extra_body={"model": "x"})``ValueError` | unit |
| 5 | 优先级:配置 `temperature=0` + 调用级 `temperature=1` → payload 为 1;结构化 `response_format` 覆盖调用级同名键 | unit |
| 6 | 调用方在 `chat()` 返回前修改自己的 dict,不影响已发请求与已算 key(拷贝语义) | unit |
| 7 | env 解析:`EXTRA_BODY` 合法 JSON 对象 → dict;非法 JSON / 非对象 → `ValueError` | unit |
| 8 | 不可 JSON 序列化的值(如 `np.float32`)在 `chat()` 入口即 `ValueError`,不进洋葱 | unit |
| 9 | 三个 emit 入口的 `sampling` 口径:attempt 含 `extra_body`、cache_hit 与 terminal 只含调用级、结构化注入的 `response_format` **三行都不出现** | unit |
| 10 | `EmbeddingClient`/`OcrClient` 装配时源带 `extra_body` → 记 warning、装配成功、源上 `extra_body` 已被剥空,且该路径遥测 `sampling` 为 NULL(决策 G;后半段是防遥测造假的真正断言) | unit |
| 11 | `_EXPECTED_COLUMNS` 断言更新后仍逐字匹配实际列序(见 §6,两处会直接红) | unit + integration |
| 12 | 遥测 `sampling` 落库正确;两后端对既有旧表幂等补列 | integration |
| 13 | 采样参数经全链路(chat → 选源 → transport payload)到达请求体 | integration |
| 14 | **地基不变式**:走结构化重问阶梯(至少重问一次)后,RetryMW 每次尝试看到的 `request.sampling``chat()` 传入值逐字相同,且同一时刻 `request.overlay``response_format` | unit |
第 14 条是决策 C/D 共同的承重前提。它现在只靠"`dataclasses.replace` 恰好保留未提及字段"这一约定成立,无任何机械执法;缺这条测试则决策 E 的只读约束被破坏时不会有人发现。
第 2 条(空采样参数时旧键逐字不变)需自行先固化旧 key 值再比对——现有 `tests/unit/test_cache.py:39-54` 只有相等/不等与前缀断言,没有 golden hash 可依。
---
## 6. 配置与文档同步
env 键名沿用既有约定:`{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY`,值为 JSON 对象串;`_SOURCE_FIELDS` 增一项、`_cast``json` 分支(解析失败与非 dict 均报错)。
同步清单(docs-convention §2):
| 目标 | 改什么 |
|---|---|
| ARCH §5.2 | `chat()` 签名定稿段追加 `overlay` 要点 |
| ARCH §7.5 | key 公式补 `sampling` 项 + 两条已知副作用 |
| ARCH §7.7 | 该节逐字段枚举 `SourceConfig` 构成(`ARCHITECTURE.md:452`),补 `extra_body` |
| ARCH §7.8 | 必录字段 20 → 21 |
| ARCH §9 | 配置面键族事实源(`:519-527`),登记 `{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY` |
| `.env.example` | `client.py:247` docstring 声明它是键名清单的事实源,新键不写进去等于无处可查 |
| `README.md:83` | 该行逐一列举 `chat()` 关键字参数,补 `overlay` |
| wiki how-to | 增「固定解码参数」条目,写明 seed 进 key 导致缓存必 miss、以及 OCR/embedding 路径的 `extra_body` 会被忽略(仅 warning) |
| CHANGELOG | 公共 API 新增 + 遥测端口扩列 |
## 7. 实施范围
`types.py`(保护键与 JSON 可序列化校验、合并纯函数、`ChatRequest.sampling``SourceConfig.extra_body`)、`client.py`(`chat()` 参数 + fingerprint)、`middleware/cache.py`(key 公式)、`transports/openai_compat.py`(`_build_payload` 一行)、`config.py`(env 解析)、`ports.py` + `middleware/telemetry.py` + `telemetry/{sqlite,postgres}.py`(第 21 字段与补列)、`ocr.py` + `embedding.py`(仅决策 G 的构造期剥离 + warning)、`providers.py`(注释)。
**测试侧必改**(否则直接红):`tests/unit/test_telemetry.py:18,113``tests/integration/test_postgres_telemetry.py:22,210,231``_EXPECTED_COLUMNS` 断言完整列表与列序。
无需改动:import-linter 契约(校验函数落最内层 `types.py`,分层关系不变)。
不做:给 embedding/OCR 加采样参数透传(决策 G)、任何任务外重构。
+17
View File
@@ -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 静默无效,改为构造期**剥离 + 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` 类型定死、拷贝语义跟进、共用范围收窄、报错文案指路)亦已就地收进。
+17
View File
@@ -120,6 +120,16 @@
"id": "plan:response-observability-fields", "id": "plan:response-observability-fields",
"label": "响应可观测字段扩展实现计划", "label": "响应可观测字段扩展实现计划",
"type": "plan" "type": "plan"
},
{
"id": "design:sampling-params",
"label": "采样参数透传设计(issue #4)",
"type": "design"
},
{
"id": "plan:sampling-params-plan",
"label": "采样参数透传实现计划(issue #4)",
"type": "plan"
} }
], ],
"links": [ "links": [
@@ -206,6 +216,13 @@
"relation": "implements", "relation": "implements",
"evidence": "计划 T1-T7 逐条实现设计的 A2/B1/C1/D1 四个决策", "evidence": "计划 T1-T7 逐条实现设计的 A2/B1/C1/D1 四个决策",
"added": "2026-07-31T11:10:03.872049+00:00" "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"
} }
] ]
} }
+8 -4
View File
@@ -1,8 +1,8 @@
# Research Wiki 索引 # Research Wiki 索引
> 自动生成,更新时间:2026-07-31 12:25 UTC > 自动生成,更新时间:2026-08-01 01:58 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-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-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` - [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-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-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-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` - [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-invariants-round-2.md) `design:settings-invariants-round-2`
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards` - [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
@@ -21,6 +22,7 @@
- [M3 OCR 端口族设计](designs/m3-ocr.md) `design:m3-ocr` - [M3 OCR 端口族设计](designs/m3-ocr.md) `design:m3-ocr`
- [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration` - [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration`
- [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields` - [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields`
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
## finding (11) ## finding (11)
- [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload` - [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload`
@@ -35,7 +37,7 @@
- [P6 混合浸泡首跑基线与记分板三重伪击穿修复](findings/p6-soak-baseline.md) `finding:p6-soak-baseline` - [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` - [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-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-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` - [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
@@ -43,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-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-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-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` - [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan` - [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
- [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed` - [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed`
@@ -50,9 +53,10 @@
- [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr` - [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr`
- [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration` - [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration`
- [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields` - [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields`
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
## schema (1) ## schema (1)
- [表结构: llm_calls(遥测 20 字段)](schemas/llm-calls.md) `schema:llm-calls` - [表结构: llm_calls(遥测 21 字段)](schemas/llm-calls.md) `schema:llm-calls`
## metric (2) ## metric (2)
- [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success` - [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success`
+9
View File
@@ -64,3 +64,12 @@
- [2026-07-31 11:10 UTC] 重建索引: 46 篇页面 - [2026-07-31 11:10 UTC] 重建索引: 46 篇页面
- [2026-07-31 11:11 UTC] 重建索引: 46 篇页面 - [2026-07-31 11:11 UTC] 重建索引: 46 篇页面
- [2026-07-31 12:25 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 篇页面
- [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 篇页面
- [2026-08-01 01:58 UTC] 重建索引: 50 篇页面
@@ -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 未提但必须处理"的部分,**不可跳过**。
@@ -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` 同步)亦已收进。
+26 -2
View File
@@ -1,11 +1,11 @@
--- ---
type: schema type: schema
node_id: schema:llm-calls node_id: schema:llm-calls
title: "表结构: llm_calls(遥测 20 字段)" title: "表结构: llm_calls(遥测 21 字段)"
date: 2026-07-20 date: 2026-07-20
--- ---
# 表结构: llm_calls(遥测 20 字段) # 表结构: llm_calls(遥测 21 字段)
## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8) ## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8)
@@ -26,6 +26,7 @@ date: 2026-07-20
| created_at | TEXT NOT NULL DEFAULT (datetime('now')) | 落库时刻 | | created_at | TEXT NOT NULL DEFAULT (datetime('now')) | 落库时刻 |
| cached_prompt_tokens | INTEGER | 供应商 prompt cache 命中的输入 token(2026-07-31,issue #3);NULL = 该源未上报,`0` = 上报了真实零命中,两者不可混同 | | cached_prompt_tokens | INTEGER | 供应商 prompt cache 命中的输入 token(2026-07-31,issue #3);NULL = 该源未上报,`0` = 上报了真实零命中,两者不可混同 |
| model_reported | TEXT | API 响应体实际返回的 model;NULL = 未上报。与 `model`(配置别名)可能分叉 | | model_reported | TEXT | API 响应体实际返回的 model;NULL = 未上报。与 `model`(配置别名)可能分叉 |
| sampling | TEXT | 本次调用的采样参数 canonical JSON(2026-07-31,issue #4);NULL = 未传。见下方口径 |
## usage/成本口径(2026-07-30,est_tokens 解耦) ## 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 缺口口径同源:回放行计入即重复计数。 `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 铁律) ## 埋点位置(单一 helper 铁律)
- `middleware/telemetry.py::TelemetryEmitter` 是全库**唯一** `record_llm_call` 调用点; - `middleware/telemetry.py::TelemetryEmitter` 是全库**唯一** `record_llm_call` 调用点;
+1 -1
View File
@@ -31,7 +31,7 @@ from polygateway.types import (
SourceConfig, SourceConfig,
) )
__version__ = "1.0.4" __version__ = "1.0.5"
__all__ = [ __all__ = [
"DEFAULT_PROFILES", "DEFAULT_PROFILES",
+42 -5
View File
@@ -9,6 +9,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib
import json
import random import random
import time import time
from typing import TYPE_CHECKING, Any, Literal, TypeVar from typing import TYPE_CHECKING, Any, Literal, TypeVar
@@ -32,7 +34,7 @@ from polygateway.sources import (
SourceCooldownMemo, SourceCooldownMemo,
) )
from polygateway.transports.openai_compat import OpenAICompatTransport 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: if TYPE_CHECKING:
from collections.abc import Awaitable, Iterable, Mapping from collections.abc import Awaitable, Iterable, Mapping
@@ -59,6 +61,29 @@ if TYPE_CHECKING:
_T = TypeVar("_T") _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: class GatewayClient:
"""统一治理入口;构造函数全量注入(测试/高级),工厂覆盖 90% 场景。""" """统一治理入口;构造函数全量注入(测试/高级),工厂覆盖 90% 场景。"""
@@ -113,12 +138,11 @@ class GatewayClient:
if cache is not None: if cache is not None:
if cache_namespace is None or cache_ttl_s is None: if cache_namespace is None or cache_ttl_s is None:
raise ValueError("启用缓存必须提供 cache_namespace 与 cache_ttl_s") raise ValueError("启用缓存必须提供 cache_namespace 与 cache_ttl_s")
# 多源 scope 的 key 身份 = 排序去重的 model 合集;源集合变化 → 一次性冷启动 # 多源 scope 的 key 身份;源集合或其 extra_body 变化 → 一次性冷启动
fingerprint = ",".join(sorted({s.model for s in sources}))
middlewares.append( middlewares.append(
CacheMW( CacheMW(
backend=cache, backend=cache,
model_fingerprint=fingerprint, model_fingerprint=build_model_fingerprint(sources),
default_namespace=cache_namespace, default_namespace=cache_namespace,
ttl_s=cache_ttl_s, ttl_s=cache_ttl_s,
strategy=structured_strategy, strategy=structured_strategy,
@@ -150,12 +174,23 @@ class GatewayClient:
cache_namespace: str | None = None, cache_namespace: str | None = None,
structured: type[BaseModel] | Literal["json"] | None = None, structured: type[BaseModel] | Literal["json"] | None = None,
stream: bool = True, stream: bool = True,
overlay: Mapping[str, Any] | None = None,
) -> LLMResponse: ) -> 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: if structured is not None and not self._structured_available:
raise ImportError( raise ImportError(
"结构化输出未启用: 安装 pip install 'polygateway[structured]' 后重新装配" "结构化输出未启用: 安装 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( request = ChatRequest(
messages=messages, messages=messages,
session_id=session_id, session_id=session_id,
@@ -164,6 +199,8 @@ class GatewayClient:
cache_namespace=cache_namespace, cache_namespace=cache_namespace,
structured=structured, structured=structured,
stream=stream, stream=stream,
overlay=sampling,
sampling=sampling,
) )
return await self._handler(request) return await self._handler(request)
+8
View File
@@ -11,6 +11,7 @@ fail-loud 校验语义与 pydantic-settings 一致。
from __future__ import annotations from __future__ import annotations
import json
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -44,6 +45,7 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = {
"ENABLE_THINKING": ("enable_thinking", "bool"), "ENABLE_THINKING": ("enable_thinking", "bool"),
"MISSING_DONE": ("missing_done", "str"), "MISSING_DONE": ("missing_done", "str"),
"TRUST_ENV": ("trust_env", "bool"), "TRUST_ENV": ("trust_env", "bool"),
"EXTRA_BODY": ("extra_body", "json"),
} }
_RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE"}) _RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE"})
_SELECTORS = frozenset({"round_robin", "least_inflight", "health_aware"}) _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"): if lowered in ("0", "false", "no", "off"):
return False return False
raise ValueError(f"非法布尔值: {raw!r}") 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 return raw
except ValueError as exc: except ValueError as exc:
raise ValueError(f"配置 {key} 解析失败: {exc}") from exc raise ValueError(f"配置 {key} 解析失败: {exc}") from exc
+9 -2
View File
@@ -41,7 +41,12 @@ from polygateway.middleware.ratelimit import QuotaGate
from polygateway.middleware.retry import _failure_reason, backoff_delay from polygateway.middleware.retry import _failure_reason, backoff_delay
from polygateway.middleware.telemetry import TelemetryEmitter from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.sources import SourceCooldownMemo 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: if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Mapping from collections.abc import Awaitable, Callable, Mapping
@@ -111,7 +116,9 @@ class EmbeddingClient:
if expected_dim is not None and expected_dim < 1: if expected_dim is not None and expected_dim < 1:
raise ValueError("expected_dim 必须 ≥ 1") raise ValueError("expected_dim 必须 ≥ 1")
self._scope = scope 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._selector = selector
self._quota = QuotaGate(limiter) self._quota = QuotaGate(limiter)
self._breaker = BreakerGate(breaker) self._breaker = BreakerGate(breaker)
+25 -3
View File
@@ -20,6 +20,8 @@ from loguru import logger
from polygateway.types import ChatRequest, LLMResponse from polygateway.types import ChatRequest, LLMResponse
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Mapping
from polygateway.ports import CacheBackend, CallNext, StructuredOutputStrategy from polygateway.ports import CacheBackend, CallNext, StructuredOutputStrategy
_KEY_PREFIX = "pgw:cache:" _KEY_PREFIX = "pgw:cache:"
@@ -50,9 +52,19 @@ def _digest_part(part: Any) -> Any:
def build_cache_key( 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: ) -> 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] = { key_obj: dict[str, Any] = {
"model": model_fingerprint, "model": model_fingerprint,
"messages": digest_messages(messages), "messages": digest_messages(messages),
@@ -60,6 +72,8 @@ def build_cache_key(
} }
if salt is not None: if salt is not None:
key_obj["salt"] = salt key_obj["salt"] = salt
if sampling:
key_obj["sampling"] = dict(sampling)
payload = json.dumps(key_obj, sort_keys=True, ensure_ascii=False) payload = json.dumps(key_obj, sort_keys=True, ensure_ascii=False)
return _KEY_PREFIX + hashlib.sha256(payload.encode("utf-8")).hexdigest() 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: async def __call__(self, request: ChatRequest, call_next: CallNext) -> LLMResponse:
namespace = request.cache_namespace or self._namespace 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) cached = await self._safe_get(key)
if cached is not None: if cached is not None:
hit = self._rehydrate(cached, request) hit = self._rehydrate(cached, request)
+11 -1
View File
@@ -18,6 +18,7 @@ from loguru import logger
from polygateway.errors import GatewayUnavailableError, GovernanceBackendError from polygateway.errors import GatewayUnavailableError, GovernanceBackendError
from polygateway.middleware.cache import digest_messages from polygateway.middleware.cache import digest_messages
from polygateway.types import canonical_sampling_json, merge_sampling
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@@ -28,7 +29,7 @@ if TYPE_CHECKING:
class TelemetryEmitter: class TelemetryEmitter:
"""从请求与结果组装 20 字段并写入 recorder;一切写失败降级 warning。""" """从请求与结果组装 21 字段并写入 recorder;一切写失败降级 warning。"""
def __init__(self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None) -> None: def __init__(self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None) -> None:
self._recorder = recorder self._recorder = recorder
@@ -63,6 +64,8 @@ class TelemetryEmitter:
error=error, error=error,
cached_prompt_tokens=response.cached_prompt_tokens if response else None, cached_prompt_tokens=response.cached_prompt_tokens if response else None,
model_reported=response.model_reported 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: async def emit_cache_hit(self, *, request: ChatRequest, response: LLMResponse) -> None:
@@ -87,6 +90,9 @@ class TelemetryEmitter:
# 统计供应商缓存命中率必须带 WHERE cache_hit = false,否则重复计数。 # 统计供应商缓存命中率必须带 WHERE cache_hit = false,否则重复计数。
cached_prompt_tokens=response.cached_prompt_tokens, cached_prompt_tokens=response.cached_prompt_tokens,
model_reported=response.model_reported, model_reported=response.model_reported,
# 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损:
# sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同
sampling=canonical_sampling_json(request.sampling),
) )
async def emit_terminal_failure( async def emit_terminal_failure(
@@ -111,6 +117,8 @@ class TelemetryEmitter:
error=error, error=error,
cached_prompt_tokens=None, cached_prompt_tokens=None,
model_reported=None, model_reported=None,
# 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D)
sampling=canonical_sampling_json(request.sampling),
) )
async def _record( async def _record(
@@ -133,6 +141,7 @@ class TelemetryEmitter:
error: str | None, error: str | None,
cached_prompt_tokens: int | None, cached_prompt_tokens: int | None,
model_reported: str | None, model_reported: str | None,
sampling: str | None,
) -> None: ) -> None:
try: try:
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用); # 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
@@ -172,6 +181,7 @@ class TelemetryEmitter:
cost=cost, cost=cost,
cached_prompt_tokens=cached_prompt_tokens, cached_prompt_tokens=cached_prompt_tokens,
model_reported=model_reported, model_reported=model_reported,
sampling=sampling,
) )
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
+4 -1
View File
@@ -44,6 +44,7 @@ from polygateway.types import (
OcrLayoutResult, OcrLayoutResult,
OcrTextResult, OcrTextResult,
Usage, Usage,
strip_unsupported_extra_body,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -113,7 +114,9 @@ class OcrClient:
if quota_full not in ("wait", "fail_fast"): if quota_full not in ("wait", "fail_fast"):
raise ValueError(f"quota_full 必须是 wait|fail_fast: {quota_full!r}") raise ValueError(f"quota_full 必须是 wait|fail_fast: {quota_full!r}")
self._scope = scope 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._selector = selector
self._feed_health = isinstance(selector, OutcomeAwareSelector) self._feed_health = isinstance(selector, OutcomeAwareSelector)
self._quota = QuotaGate(limiter) self._quota = QuotaGate(limiter)
+1
View File
@@ -274,4 +274,5 @@ class TelemetryRecorder(Protocol):
cost: float | None, cost: float | None,
cached_prompt_tokens: int | None, cached_prompt_tokens: int | None,
model_reported: str | None, model_reported: str | None,
sampling: str | None,
) -> None: ... ) -> None: ...
+8 -1
View File
@@ -19,6 +19,10 @@ class ProviderProfile:
True/False 时并入请求体的参数片段(None 时二者都不注入,用模型默认); True/False 时并入请求体的参数片段(None 时二者都不注入,用模型默认);
strip_think_tags 声明响应 content 需剥离 ``<think>`` 标签(qwen 系); strip_think_tags 声明响应 content 需剥离 ``<think>`` 标签(qwen 系);
supports_native_schema 供 D14 阶梯选择原生 response_format 策略。 supports_native_schema 供 D14 阶梯选择原生 response_format 策略。
注: 某个 provider 的两档若皆为空字典(如 openai/minimax),说明该 provider
无已知的推理开关参数——此时 `enable_thinking` 对它**不产生任何效果**,
而非静默生效。需要下发自定义参数时用 `SourceConfig.extra_body`。
""" """
name: str name: str
@@ -43,13 +47,16 @@ DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType(
thinking_off={"thinking": {"type": "disabled"}}, thinking_off={"thinking": {"type": "disabled"}},
strip_think_tags=False, strip_think_tags=False,
), ),
# 两档皆空 ⇒ `enable_thinking` 对本 provider **不产生任何效果**(调用方
# 以为关掉了实际没关)。真需要控制推理时经 `SourceConfig.extra_body` 下发
"openai": ProviderProfile( "openai": ProviderProfile(
name="openai", name="openai",
thinking_on={}, thinking_on={},
thinking_off={}, thinking_off={},
strip_think_tags=False, strip_think_tags=False,
), ),
# OpenAI 兼容基线,无已知注入差异;reasoning_content 由 transport 通用处理 # OpenAI 兼容基线,无已知注入差异;reasoning_content 由 transport 通用处理
# 同上: 两档皆空 ⇒ `enable_thinking` 对 MiniMax 源不产生任何效果
"minimax": ProviderProfile( "minimax": ProviderProfile(
name="minimax", name="minimax",
thinking_on={}, thinking_on={},
+4 -1
View File
@@ -41,7 +41,8 @@ CREATE TABLE IF NOT EXISTS llm_calls (
cost DOUBLE PRECISION, cost DOUBLE PRECISION,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
cached_prompt_tokens INTEGER, cached_prompt_tokens INTEGER,
model_reported TEXT model_reported TEXT,
sampling TEXT
); );
""" """
@@ -49,6 +50,7 @@ CREATE TABLE IF NOT EXISTS llm_calls (
_BACKFILL = ( _BACKFILL = (
("cached_prompt_tokens", "ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER"), ("cached_prompt_tokens", "ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER"),
("model_reported", "ALTER TABLE llm_calls ADD COLUMN model_reported TEXT"), ("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 解析) # 探测现有列;尊重 search_path(to_regclass 按当前 search_path 解析)
@@ -78,6 +80,7 @@ _COLUMNS = (
"cost", "cost",
"cached_prompt_tokens", "cached_prompt_tokens",
"model_reported", "model_reported",
"sampling",
) )
_INSERT = ( _INSERT = (
+9 -3
View File
@@ -36,13 +36,18 @@ CREATE TABLE IF NOT EXISTS llm_calls (
cost REAL, cost REAL,
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
cached_prompt_tokens INTEGER, cached_prompt_tokens INTEGER,
model_reported TEXT model_reported TEXT,
sampling TEXT
); );
""" """
# 新列必须排在 created_at 之后: 旧表只能经 ALTER 追加到末尾,新建库若把它们 # 新列必须排在 created_at 之后: 旧表只能经 ALTER 追加到末尾,新建库若把它们
# 插在前面,两条路径的物理列序会分叉(列序断言测试无合规修法)。 # 插在前面,两条路径的物理列序会分叉(列序断言测试无合规修法)。
_BACKFILL_COLUMNS = (("cached_prompt_tokens", "INTEGER"), ("model_reported", "TEXT")) _BACKFILL_COLUMNS = (
("cached_prompt_tokens", "INTEGER"),
("model_reported", "TEXT"),
("sampling", "TEXT"),
)
_COLUMNS = ( _COLUMNS = (
"call_id", "call_id",
@@ -65,6 +70,7 @@ _COLUMNS = (
"cost", "cost",
"cached_prompt_tokens", "cached_prompt_tokens",
"model_reported", "model_reported",
"sampling",
) )
_INSERT = ( _INSERT = (
@@ -120,7 +126,7 @@ class SQLiteRecorder:
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc) logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
async def record_llm_call(self, **fields: object) -> None: async def record_llm_call(self, **fields: object) -> None:
"""写一行遥测;字段集合即 20 字段冻结签名(ports.TelemetryRecorder)。""" """写一行遥测;字段集合即 21 字段冻结签名(ports.TelemetryRecorder)。"""
if self._conn is None: if self._conn is None:
return return
row = tuple(fields[col] for col in _COLUMNS) row = tuple(fields[col] for col in _COLUMNS)
@@ -294,6 +294,9 @@ class OpenAICompatTransport:
payload.update(profile.thinking_on) payload.update(profile.thinking_on)
elif source.enable_thinking is False: elif source.enable_thinking is False:
payload.update(profile.thinking_off) payload.update(profile.thinking_off)
# 顺序即优先级(issue #4 设计决策 A): 配置级 extra_body 在前,调用级
# overlay(含结构化注入)在后覆盖之。两行不可调换
payload.update(source.extra_body)
payload.update(overlay) payload.update(overlay)
return payload return payload
+101
View File
@@ -4,11 +4,27 @@
fake,字段顺序即公共承诺;新增字段只增不删且必带默认值。 fake,字段顺序即公共承诺;新增字段只增不删且必带默认值。
""" """
import dataclasses
import json
from collections.abc import Mapping
from dataclasses import dataclass, field from dataclasses import dataclass, field
from types import MappingProxyType
from typing import Any from typing import Any
from loguru import logger
_MISSING_DONE_DOMAIN = frozenset({"retry", "salvage"}) _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_SOURCES = frozenset({"measured", "estimated", "unavailable"})
"""usage_source 值域;仅约束库内生产侧取值,不在 frozen dataclass 上做运行时校验。""" """usage_source 值域;仅约束库内生产侧取值,不在 frozen dataclass 上做运行时校验。"""
@@ -16,6 +32,45 @@ _EST_TOKENS_QUOTA_DIVISOR = 60
"""未显式配置时的预扣量除数: 假定一次调用约占一秒钟的 TPM 配额份额。""" """未显式配置时的预扣量除数: 假定一次调用约占一秒钟的 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) @dataclass(frozen=True)
class LLMResponse: class LLMResponse:
"""一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。""" """一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。"""
@@ -58,6 +113,12 @@ class ChatRequest:
structured: Any | None = None structured: Any | None = None
stream: bool = True stream: bool = True
overlay: dict[str, Any] = field(default_factory=dict) 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) @dataclass(frozen=True)
@@ -118,11 +179,18 @@ class SourceConfig:
enable_thinking: bool | None = None enable_thinking: bool | None = None
missing_done: str = "retry" missing_done: str = "retry"
trust_env: bool = True 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: def __post_init__(self) -> None:
self._validate_identity() self._validate_identity()
self._validate_gates() self._validate_gates()
self._validate_watchdog() self._validate_watchdog()
self._freeze_extra_body()
def effective_est_tokens(self) -> int: def effective_est_tokens(self) -> int:
"""TPM 入场预扣量: 显式配置优先,否则按 tpm 派生(设计 §2.2)。""" """TPM 入场预扣量: 显式配置优先,否则按 tpm 派生(设计 §2.2)。"""
@@ -159,6 +227,39 @@ class SourceConfig:
): ):
raise ValueError("看门狗不变式要求 0 < inter_token < ttft < timeout_s") 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))
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) @dataclass(frozen=True)
class RetryPolicy: class RetryPolicy:
@@ -4,6 +4,7 @@
""" """
import asyncio import asyncio
import dataclasses
import json import json
import sqlite3 import sqlite3
@@ -204,3 +205,74 @@ class TestTransientErrorExport:
assert isinstance(ei.value, polygateway.AllSourcesExhausted) assert isinstance(ei.value, polygateway.AllSourcesExhausted)
assert isinstance(ei.value.__cause__, TransientError) 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 才命中
+10 -1
View File
@@ -11,6 +11,7 @@ DSN 走 .env `PGW_TELEMETRY_PG_DSN`,缺则 skip。该实例上有 app/chs_prod
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import os import os
from uuid import uuid4 from uuid import uuid4
@@ -41,6 +42,7 @@ _EXPECTED_COLUMNS = [
"created_at", "created_at",
"cached_prompt_tokens", "cached_prompt_tokens",
"model_reported", "model_reported",
"sampling",
] ]
# run 级前缀: 同库并存的其他运行(迁移批跑/另一开发机)互不可见 # run 级前缀: 同库并存的其他运行(迁移批跑/另一开发机)互不可见
@@ -104,6 +106,7 @@ async def _record_minimal(
"cost": None, "cost": None,
"cached_prompt_tokens": None, "cached_prompt_tokens": None,
"model_reported": None, "model_reported": None,
"sampling": None,
} }
fields.update(overrides) fields.update(overrides)
await recorder.record_llm_call(**fields) await recorder.record_llm_call(**fields)
@@ -178,9 +181,12 @@ class TestObservabilityColumns:
await _record_minimal(recorder, call_id=_cid("hit"), cached_prompt_tokens=64) 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("zero"), cached_prompt_tokens=0)
await _record_minimal(recorder, call_id=_cid("model"), model_reported="MiniMax-01") 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( rows = await _fetch(
dsn, 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", "WHERE call_id LIKE $1",
f"{_RUN_PREFIX}-%", f"{_RUN_PREFIX}-%",
) )
@@ -189,6 +195,9 @@ class TestObservabilityColumns:
assert by_id[_cid("zero")]["cached_prompt_tokens"] == 0 # 真实零命中 ≠ NULL 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")]["cached_prompt_tokens"] is None
assert by_id[_cid("model")]["model_reported"] == "MiniMax-01" 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: finally:
await recorder.aclose() await recorder.aclose()
+55
View File
@@ -53,6 +53,35 @@ class TestKeyFormula:
def test_any_dimension_change_changes_key(self, a, b): def test_any_dimension_change_changes_key(self, a, b):
assert build_cache_key(*a) != build_cache_key(*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): def test_multimodal_part_digested_not_inlined(self):
big_b64 = "data:image/png;base64," + "A" * 1_000_000 big_b64 = "data:image/png;base64," + "A" * 1_000_000
messages = [ messages = [
@@ -120,6 +149,32 @@ class TestCacheFlow:
assert second.call_id != first.call_id # 命中生成独立 cache_call_id assert second.call_id != first.call_id # 命中生成独立 cache_call_id
assert terminal.calls == 1 # 未再触达内层 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): async def test_per_call_namespace_overrides_default(self):
backend = InMemoryCache() backend = InMemoryCache()
mw = _mw(backend) mw = _mw(backend)
+101
View File
@@ -19,6 +19,7 @@ from polygateway.backends.memory.cache import InMemoryCache
from polygateway.backends.memory.limiter import InMemoryLimiter from polygateway.backends.memory.limiter import InMemoryLimiter
from polygateway.sources import RoundRobinSelector from polygateway.sources import RoundRobinSelector
from polygateway.structured.json_repair import JsonRepairStrategy from polygateway.structured.json_repair import JsonRepairStrategy
from polygateway.structured.native_schema import NativeSchemaStrategy
from polygateway.transports.openai_compat import OpenAICompatTransport from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import ( from polygateway.types import (
BackpressurePolicy, BackpressurePolicy,
@@ -117,6 +118,106 @@ class TestChatEndToEnd:
await client.chat([{"role": "user", "content": "hi"}], structured="json") 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: class TestFactories:
def test_from_env_assembles(self): def test_from_env_assembles(self):
client = GatewayClient.from_env("LLM", env=_ENV) client = GatewayClient.from_env("LLM", env=_ENV)
+29
View File
@@ -103,6 +103,35 @@ class TestSourceAggregation:
GatewaySettings.from_env("LLM", env=env) 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: class TestResilienceKeys:
def test_flat_legacy_keys(self): def test_flat_legacy_keys(self):
s = GatewaySettings.from_env("LLM", env=_env()) s = GatewaySettings.from_env("LLM", env=_env())
+42
View File
@@ -4,11 +4,13 @@
VT adapters/embedding.py(归一化);库裁决见设计 §7.3 表。 VT adapters/embedding.py(归一化);库裁决见设计 §7.3 表。
""" """
import contextlib
import dataclasses import dataclasses
import json import json
import httpx import httpx
import pytest import pytest
from loguru import logger
from polygateway.errors import ( from polygateway.errors import (
RequestRejectedError, RequestRejectedError,
@@ -350,6 +352,46 @@ class TestEmbedTelemetry:
assert len(rec.rows[1]["messages"]) < 1000 # 长文本截断后入库 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: class TestEmbeddingSettings:
_ENV = { _ENV = {
"EMBED__QWEN__1__BASE_URL": "https://gw.example/v1", "EMBED__QWEN__1__BASE_URL": "https://gw.example/v1",
+22
View File
@@ -7,6 +7,7 @@ retry_exhausted/circuit_open/stalled 三组断言即设计 §6 ③ 的 G1 契约
import asyncio import asyncio
import pytest import pytest
from loguru import logger
from polygateway.backends.memory.breaker import InMemoryGate from polygateway.backends.memory.breaker import InMemoryGate
from polygateway.backends.memory.limiter import InMemoryLimiter from polygateway.backends.memory.limiter import InMemoryLimiter
@@ -377,6 +378,27 @@ class TestCheckHealth:
await task 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: class TestTelemetry:
async def test_success_and_failure_recorded_without_image_bytes(self): async def test_success_and_failure_recorded_without_image_bytes(self):
recorder = _MemoryRecorder() recorder = _MemoryRecorder()
+21
View File
@@ -445,6 +445,27 @@ class TestRequestShaping:
) )
assert seen["response_format"] == {"type": "json_object"} 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: class TestErrorTranslation:
@pytest.mark.parametrize( @pytest.mark.parametrize(
+1
View File
@@ -116,6 +116,7 @@ class _DummyRecorder:
cost, cost,
cached_prompt_tokens, cached_prompt_tokens,
model_reported, model_reported,
sampling,
) -> None: ... ) -> None: ...
+52
View File
@@ -177,3 +177,55 @@ class TestNativeOverlayFirstAttempt:
resp = await _mw()(ChatRequest(messages=_MSGS, structured=Verdict), terminal) resp = await _mw()(ChatRequest(messages=_MSGS, structured=Verdict), terminal)
with pytest.raises(dataclasses.FrozenInstanceError): with pytest.raises(dataclasses.FrozenInstanceError):
resp.structured_data = None 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(
# overlay 与 sampling 传**同一个对象**,复现 client.py 的别名关系
# ——否则中间件就地改写 overlay 时不会波及 sampling,这条执法就是空的
ChatRequest(
messages=_MSGS,
structured=Verdict,
overlay=caller_sampling,
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。
同样传同一对象: 生产中 overlay 与 sampling 是别名,任何对 overlay 的
就地改写都会同步毒化缓存 key 与遥测列。
"""
caller_sampling = {"seed": 7}
terminal = ScriptedTerminal(['{"answer": 1, "reason": "r"}'])
await _mw(strategy=NativeSchemaStrategy())(
ChatRequest(
messages=_MSGS,
structured=Verdict,
overlay=caller_sampling,
sampling=caller_sampling,
),
terminal,
)
assert caller_sampling == {"seed": 7} # 调用方的对象未被污染
+113 -12
View File
@@ -1,6 +1,7 @@
"""遥测子系统测试: SQLiteRecorder(20 列)+ TelemetryEmitter 单一 helper + TelemetryMW。""" """遥测子系统测试: SQLiteRecorder(21 列)+ TelemetryEmitter 单一 helper + TelemetryMW。"""
import asyncio import asyncio
import json
import sqlite3 import sqlite3
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -37,6 +38,7 @@ _EXPECTED_COLUMNS = [
"created_at", "created_at",
"cached_prompt_tokens", "cached_prompt_tokens",
"model_reported", "model_reported",
"sampling",
] ]
@@ -60,15 +62,17 @@ def _resp(**overrides):
return LLMResponse(**base) return LLMResponse(**base)
def _source(): def _source(**overrides):
return SourceConfig( base = {
name="s1", "name": "s1",
provider="p", "provider": "p",
base_url="https://gw.example/v1", "base_url": "https://gw.example/v1",
api_key="sk", "api_key": "sk",
model="m", "model": "m",
timeout_s=10.0, "timeout_s": 10.0,
) }
base.update(overrides)
return SourceConfig(**base)
# 输出单价 8 元/百万: 改前 `unavailable` 行按兜底的 0/4000 换算恰好是 0.032 # 输出单价 8 元/百万: 改前 `unavailable` 行按兜底的 0/4000 换算恰好是 0.032
@@ -97,6 +101,7 @@ async def _record_minimal(recorder, call_id="c1", **overrides):
"cost": None, "cost": None,
"cached_prompt_tokens": None, "cached_prompt_tokens": None,
"model_reported": None, "model_reported": None,
"sampling": None,
} }
fields.update(overrides) fields.update(overrides)
await recorder.record_llm_call(**fields) await recorder.record_llm_call(**fields)
@@ -153,6 +158,20 @@ class TestSQLiteRecorder:
assert rows["c-zero"] == 0 # 真实零命中,读回仍是 0 而非 NULL assert rows["c-zero"] == 0 # 真实零命中,读回仍是 0 而非 NULL
assert rows["c-none"] is None 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: class TestSQLiteColumnBackfill:
"""issue #3: 已存在的 18 列旧表必须自动补列,否则每行写入都被丢弃。""" """issue #3: 已存在的 18 列旧表必须自动补列,否则每行写入都被丢弃。"""
@@ -258,7 +277,14 @@ class TestPostgresBackfillDiscipline:
"""PG 补列必须与 SQLite 侧对称: 失败只逐行降级,且稳态不抢排他锁(issue #3)。""" """PG 补列必须与 SQLite 侧对称: 失败只逐行降级,且稳态不抢排他锁(issue #3)。"""
_LEGACY = ["call_id", "cost", "created_at"] _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): def _recorder(self, conn):
from polygateway.telemetry.postgres import PostgresRecorder from polygateway.telemetry.postgres import PostgresRecorder
@@ -286,8 +312,10 @@ class TestPostgresBackfillDiscipline:
async def test_missing_columns_are_added_once(self): async def test_missing_columns_are_added_once(self):
conn = _FakePgConn(self._LEGACY) conn = _FakePgConn(self._LEGACY)
await _record_minimal(self._recorder(conn)) await _record_minimal(self._recorder(conn))
from polygateway.telemetry.postgres import _BACKFILL
altered = [s for s in conn.statements if s.startswith("ALTER TABLE")] 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) # 探测已确认缺列,无需再判 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 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: class TestCostWithCachedTier:
"""issue #3: 命中部分按缓存单价计费,避免 cost 系统性高估。""" """issue #3: 命中部分按缓存单价计费,避免 cost 系统性高估。"""
+89
View File
@@ -305,3 +305,92 @@ class TestOcrTypes:
with pytest.raises(TypeError): with pytest.raises(TypeError):
OcrTextResult(text="x") # 溯源件不可省略 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())