docs: 采样参数透传(issue #4)+ 补 v1.0.1/v1.0.2 遗漏的装配校验

issue #4: 新增指南-采样参数,并同步公共API/配置键/响应缓存/遥测与成本/侧边栏。

v1.0.1 与 v1.0.2 当初只 bump 了 Home 的版本号,两版的实质内容(装配守卫
在所有构造路径生效、scope 规范化为小写会切 Redis key 命名空间)一行未进
wiki,本次一并补进参考-配置键。
2026-07-31 22:37:34 -04:00
parent 9bd2061f26
commit edc73607e9
6 changed files with 102 additions and 4 deletions
+1
@@ -9,6 +9,7 @@
- [[指南-响应缓存]] - [[指南-响应缓存]]
- [[指南-遥测与成本]] - [[指南-遥测与成本]]
- [[指南-结构化输出]] - [[指南-结构化输出]]
- [[指南-采样参数]]
- [[指南-OCR]] - [[指南-OCR]]
- [[指南-Embedding]] - [[指南-Embedding]]
- [[指南-迁移既有项目]] - [[指南-迁移既有项目]]
+2 -2
@@ -8,7 +8,7 @@
|---|---|---| |---|---|---|
| `from_env` | `(scope="LLM", *, limiter=None, breaker=None, cache=None, telemetry=None, registry=None, env=None) -> GatewayClient` | 从 .env/环境变量装配;关键字参数可注入自定义后端(测试/共享状态) | | `from_env` | `(scope="LLM", *, limiter=None, breaker=None, cache=None, telemetry=None, registry=None, env=None) -> GatewayClient` | 从 .env/环境变量装配;关键字参数可注入自定义后端(测试/共享状态) |
| `from_settings` | `(settings: GatewaySettings, ...) -> GatewayClient` | 从已解析配置装配 | | `from_settings` | `(settings: GatewaySettings, ...) -> GatewayClient` | 从已解析配置装配 |
| `chat` | `(messages, *, session_id=None, parent_call_id=None, cache_salt=None, cache_namespace=None, structured=None, stream=True) -> LLMResponse` | 一次治理调用;messages 为 OpenAI 形态(原生支持多模态 content 数组);`structured` 传 pydantic 模型类或 `"json"` | | `chat` | `(messages, *, session_id=None, parent_call_id=None, cache_salt=None, cache_namespace=None, structured=None, stream=True, overlay=None) -> LLMResponse` | 一次治理调用;messages 为 OpenAI 形态(原生支持多模态 content 数组);`structured` 传 pydantic 模型类或 `"json"`;`overlay` 传采样参数(未发布版新增,见 [[指南-采样参数]]) |
| `aclose` | `() -> None` | 幂等释放连接与后端资源 | | `aclose` | `() -> None` | 幂等释放连接与后端资源 |
## LLMResponse(frozen dataclass) ## LLMResponse(frozen dataclass)
@@ -55,7 +55,7 @@
| 导出 | 用途 | | 导出 | 用途 |
|---|---| |---|---|
| `GatewaySettings` / `EmbeddingSettings` / `OcrSettings` | `from_env` 的解析产物;高级场景可自行构造后走 `from_settings` | | `GatewaySettings` / `EmbeddingSettings` / `OcrSettings` | `from_env` 的解析产物;高级场景可自行构造后走 `from_settings` |
| `SourceConfig` | 单源完整配置(构造期校验不变式)。方法 `effective_est_tokens() -> int`:TPM 入场预扣量,显式 `est_tokens > 0` 优先,否则按 `max(1, tpm // 60)` 派生,`tpm=0` 时为 0(v1.0.3 新增) | | `SourceConfig` | 单源完整配置(构造期校验不变式)。方法 `effective_est_tokens() -> int`:TPM 入场预扣量,显式 `est_tokens > 0` 优先,否则按 `max(1, tpm // 60)` 派生,`tpm=0` 时为 0(v1.0.3 新增)。字段 `extra_body`(未发布版新增):本源恒定的采样参数,构造后是只读视图——**该字段令 `SourceConfig` 不再 hashable**,`asdict()`/`deepcopy()` 亦不再适用(加任何 mapping 字段的固有代价);要可变副本用 `dict(source.extra_body)`,要改字段用 `dataclasses.replace` |
| `ProviderProfile` / `DEFAULT_PROFILES` | provider 方言注册表(thinking 注入方式、思考流字段等);自定义 provider 经 `registry=` 传入 | | `ProviderProfile` / `DEFAULT_PROFILES` | provider 方言注册表(thinking 注入方式、思考流字段等);自定义 provider 经 `registry=` 传入 |
| `PricingTable` / `ModelPrice` | 价格表(成本折算) | | `PricingTable` / `ModelPrice` | 价格表(成本折算) |
+20
@@ -2,6 +2,25 @@
装配只有两条路:`from_env()`(读 .env + 环境变量,环境变量优先)或构造函数全量注入。缺关键键装配即报错。主仓库 `.env.example` 是带注释的全量模板,本页为速查表。 装配只有两条路:`from_env()`(读 .env + 环境变量,环境变量优先)或构造函数全量注入。缺关键键装配即报错。主仓库 `.env.example` 是带注释的全量模板,本页为速查表。
## 两条装配路的校验完全一致(v1.0.1 / v1.0.2)
**经 `from_env()` 装配的调用方不受这两版影响**——那条路本就跑全部校验。手工构造 `GatewaySettings`(或对它 `dataclasses.replace`)的调用方请读本节:这些校验此前只有 `from_env` 拦得住,`from_settings()` 与直接构造一律放行,故障留到运行时才表现出来。现已全部收进 `__post_init__`
| 现在构造期就报错的 | 此前的运行时表现 |
|---|---|
| 源 `timeout_s` > `PGW_LEASE_TTL_S` | 租约先于请求过期,并发悄悄超出配额 |
| `stall_window_s` < 最大源 TTFT | 正常的慢首包被误判卡死掐断 |
| `probe_ttl_s` < 最慢源 `timeout_s` + 5 | 半开探针在途即被接管 |
| `sources` 为空 | 装出必然选源失败的 client |
| 六个后端/策略字段取值越界(`limiter_backend`/`breaker_backend`/`cache_backend`/`telemetry_backend`/`selector`/`quota_full`) | 静默不建后端,或裸 `AssertionError`(`python -O` 下退化为第三方库的天书报错) |
| 取 `redis` 的后端缺 `redis_url`;启用缓存缺 namespace 或 TTL ≤ 0;`sqlite`/`postgres` 缺对应路径/DSN | 同上 |
| `structured_max_retries` 为负、`scope` 为空 | 同上 |
| `EmbeddingSettings``batch_size` / `expected_dim` 越界 | 推迟到 `EmbeddingClient` 构造时才报 |
**一处静默改值(v1.0.2)**:手工构造传 `scope="LLM"` 这类非全小写值时,scope 会被**规范化为小写**。这直接改变 Redis key——`pgw:limit:LLM:…` 切到 `pgw:limit:llm:…`。旧行为下这批 key 与 `from_env` 装配的进程根本不在同一命名空间,同一逻辑 scope 的限流与熔断状态分裂成两套、各记各的配额,分布式治理静默失效;修复即为此。但**切换发生的那一刻,旧键上的在途租约会被遗弃**,靠 TTL 自愈,滚动升级期间限流配额会短暂偏松。
同批规范化:`redis_url` / `pricing_path` 的空串归 `None`(留着空串会骗过 `is None` 判断,把错误推迟成连接串解析异常或 `Is a directory: '.'`);Postgres DSN 剥掉 SQLAlchemy 驱动后缀(`postgresql+asyncpg://``+asyncpg` asyncpg 不认)。剥离时会发一条 warning——库动了调用方给的值不该静默;日志只出现 scheme 段,DSN 带密码故整串不入日志。
## 源键 `{SCOPE}__{PROVIDER}__{N}__{FIELD}` ## 源键 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`
| FIELD | 必填 | 说明 | | FIELD | 必填 | 说明 |
@@ -14,6 +33,7 @@
| ENABLE_THINKING | | 三态: 缺省不注入 / true 注入开 / false 注入关 | | ENABLE_THINKING | | 三态: 缺省不注入 / true 注入开 / false 注入关 |
| MISSING_DONE | | SSE 缺 `[DONE]`: `retry`(默认)/ `salvage`(打捞已收内容) | | MISSING_DONE | | SSE 缺 `[DONE]`: `retry`(默认)/ `salvage`(打捞已收内容) |
| TRUST_ENV | | `false` = 绕过本地代理(LAN 直连) | | TRUST_ENV | | `false` = 绕过本地代理(LAN 直连) |
| EXTRA_BODY | | 本源恒定的采样参数,JSON **对象**串(数组/标量报错),如 `{"temperature":0}`。并入请求体,优先级低于 `chat(overlay=...)`。禁用键 `model`/`messages`/`stream`/`stream_options`(会击穿治理),配了直接报错。**OCR/EMBED scope 不消费此键**——配了会被剥离并 warning。见 [[指南-采样参数]] |
## scope 级键 ## scope 级键
+4 -1
@@ -13,14 +13,17 @@ REDIS_URL=redis://:pass@host:6379/3
## key 公式与防毒化 ## key 公式与防毒化
key = `model + messages 摘要 + namespace + salt`;多模态 content(base64 图)**先摘要再 hash**,原图不进 key 也不进存储。设计约束: key = `model + messages 摘要 + namespace + salt + sampling`;多模态 content(base64 图)**先摘要再 hash**,原图不进 key 也不进存储。设计约束:
| 要素 | 防什么 | | 要素 | 防什么 |
|---|---| |---|---|
| namespace 必填 | 跨项目/跨租户互相读到对方缓存 | | namespace 必填 | 跨项目/跨租户互相读到对方缓存 |
| salt(per-call) | 需要强制重采样的场景命中旧缓存 | | salt(per-call) | 需要强制重采样的场景命中旧缓存 |
| sampling(未发布版) | 不同解码参数的响应互相污染——尤其是同 messages 跑多个 seed 时全部命中第一次的结果,标准差恒为 0 且不报错 |
| 坏结果不写缓存 | 截断流/解析失败被固化 | | 坏结果不写缓存 | 截断流/解析失败被固化 |
`sampling` **仅在非空时参与**,不传采样参数时 key 与旧版逐字相同,升级不会作废存量缓存。反过来,逐次变化的 `seed` 会让这条路径全部 miss——这是正确语义,但要知道缓存对它不再省钱。另注意 key 里的 `model` 是**全 scope 所有源的合集指纹**(含各源的 `EXTRA_BODY`),不是本次实际选中那个源的指纹:同 scope 各源解码参数不同时,仍可能读到另一源的响应。详见 [[指南-采样参数]]。
## per-call 控制 ## per-call 控制
```python ```python
+17 -1
@@ -12,7 +12,7 @@ PGW_TELEMETRY_SQLITE_PATH=logs/telemetry.db # sqlite 时必填
实验室纪律:PG DSN 只许指向专用库 `polygateway`,严禁在用业务库。 实验室纪律:PG DSN 只许指向专用库 `polygateway`,严禁在用业务库。
## 表结构(`llm_calls`,20 字段冻结) ## 表结构(`llm_calls`,21 字段冻结)
| 字段组 | 字段 | | 字段组 | 字段 |
|---|---| |---|---|
@@ -23,6 +23,7 @@ PGW_TELEMETRY_SQLITE_PATH=logs/telemetry.db # sqlite 时必填
| 时延 | latency_ms / ttft_ms / max_inter_token_ms | | 时延 | latency_ms / ttft_ms / max_inter_token_ms |
| 结果 | cache_hit / error(异常类名前缀,如 `TransientError: ...`)/ cost | | 结果 | cache_hit / error(异常类名前缀,如 `TransientError: ...`)/ cost |
| 可观测(v1.0.4) | cached_prompt_tokens / model_reported(见下) | | 可观测(v1.0.4) | cached_prompt_tokens / model_reported(见下) |
| 复现(未发布版) | sampling —— 本次调用的采样参数(见下) |
v1.0.4 新增的两列会**自动补到已存在的旧表上**,无需手工迁移;历史行的新列为 NULL。两个后端都是先探测缺列、只在真缺列时才 ALTER——稳态下一条 ALTER 都不发(`ADD COLUMN IF NOT EXISTS` 即使列已存在也会先取排他锁,而遥测是内联写入,锁住共享审计表会拖慢业务调用);补列失败也只是这几行遥测被丢弃,不会让遥测整体停摆。 v1.0.4 新增的两列会**自动补到已存在的旧表上**,无需手工迁移;历史行的新列为 NULL。两个后端都是先探测缺列、只在真缺列时才 ALTER——稳态下一条 ALTER 都不发(`ADD COLUMN IF NOT EXISTS` 即使列已存在也会先取排他锁,而遥测是内联写入,锁住共享审计表会拖慢业务调用);补列失败也只是这几行遥测被丢弃,不会让遥测整体停摆。
@@ -90,3 +91,18 @@ WHERE cache_hit = false AND cached_prompt_tokens IS NOT NULL;
原因和上面 cost 缺口的口径一样:缓存命中行里这两个字段是**原样回放**的历史值(与 `model``prompt_tokens` 同一规则——命中时只有时延类字段被清零),计进去就是重复计数。 原因和上面 cost 缺口的口径一样:缓存命中行里这两个字段是**原样回放**的历史值(与 `model``prompt_tokens` 同一规则——命中时只有时延类字段被清零),计进去就是重复计数。
`model_reported` 是 API 响应体里实际返回的 model,和 `.env` 里配的别名可能不是一个东西——供应商把别名指向新权重时,只有它认得出当时真正跑的版本。要做可复现的实验快照,记这一列。 `model_reported` 是 API 响应体里实际返回的 model,和 `.env` 里配的别名可能不是一个东西——供应商把别名指向新权重时,只有它认得出当时真正跑的版本。要做可复现的实验快照,记这一列。
## 采样参数 `sampling`(未发布版)
「调用方传的 ⊎ 生效源的 `EXTRA_BODY`」的规范化 JSON,没传则 NULL。有了它,"这批数据跑在什么解码条件下"才在事后可查——这和 `model_reported` 是同一类需求。
```sql
SELECT DISTINCT sampling FROM llm_calls
WHERE session_id = 'exp-42' AND cache_hit = false AND error IS NULL;
```
两点口径:① 这一列**不含**结构化输出注入的 `response_format`(列名是采样参数,schema 不是,且数 KB 的 schema 逐行落库只会让审计表膨胀);② 缓存命中行与最终失败行只记调用级参数,不含源的 `EXTRA_BODY`——那两条路径没有"生效源"可言,与 `model`/`source_name` 在失败行置空是同一回事;命中行也不损失信息,因为采样参数已进缓存 key,能命中就意味着调用级参数与历史那次逐字相同。
OCR / Embedding 路径的这一列**恒为 NULL**:那两条路径不发采样参数,配了 `EXTRA_BODY` 也会在装配时被剥离。详见 [[指南-采样参数]]。
补列规则同 v1.0.4 那两列:自动补到旧表、先探测再 ALTER、失败只丢这几行。
+58
@@ -0,0 +1,58 @@
# 指南:固定解码参数(temperature / seed / max_tokens)
受控实验要求解码行为可复现:温度必须钉死,每个 rollout 的 seed 必须显式受控并记进快照。本页讲怎么配、以及三个不配就会静默出错的地方。
## 两个层次,按参数是否随调用变化来选
| 层次 | 怎么配 | 适合 |
|---|---|---|
| 配置级 | `.env``{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY={"temperature":0}` | 全局恒定的参数。**推荐**:不必让每个调用点都记得传,而漏传一次不会报错,只会让数字悄悄不可比 |
| 调用级 | `await client.chat(msgs, overlay={"seed": 42})` | 逐次变化的参数 |
```python
# .env: LLM__QWEN__1__EXTRA_BODY={"temperature":0,"top_p":1}
for seed in range(5):
resp = await client.chat(messages, overlay={"seed": seed})
# 请求体最终是 {"temperature":0, "top_p":1, "seed":<seed>, ...}
```
优先级 **结构化输出注入 > 调用级 `overlay` > 源级 `EXTRA_BODY`**。结构化输出排最高是因为它关系到响应能否被解析——`structured=` 时传 `overlay={"response_format": ...}` 会被覆盖。
## 三个坑
**① 逐次变化的 `seed` 会让缓存全部 miss。** 采样参数进缓存 key,这是有意的:不进的话,同样的 messages 跑 5 个 seed 会全部命中第一次的响应,**报出的标准差恒为 0 且不报错**,整批实验静默作废。代价是这条路径不再省钱。不传采样参数时 key 与旧版逐字相同,存量缓存不受影响。
**② 缓存身份是 scope 级的,不是源级的。** 源的 `EXTRA_BODY` 会并进缓存指纹,但那是**全 scope 所有源的合集指纹**。同一个 scope 下若各源的 `EXTRA_BODY` 不同,缓存仍可能把 A 源(temperature=0)的响应返回给本该走 B 源(temperature=1)的调用。要求逐源可复现的实验,请让每个源独享 scope,或用 `chat(cache_namespace=...)` 区分。
**③ OCR / Embedding scope 配了 `EXTRA_BODY` 不生效。** 这两条路径的请求根本不带它(embedding 请求体只有 `model``input`,OCR 走 multipart 表单)。配了会被剥离并发一条 warning,装配照常成功。剥离不只是打扫:不剥的话遥测的 `sampling` 列会记下一个从未发出去的参数,那比"参数没生效"更糟——审计表会说这次调用跑在 temperature=0 下,而事实并非如此。需要 `dimensions` 之类的 embedding 参数,请提 issue。
## 哪些键不能传
`model` / `messages` / `stream` / `stream_options` 由治理层拥有,传了直接 `ValueError`:
| 键 | 覆盖后果 |
|---|---|
| `model` | 遥测记录的 model 与实际请求分叉,成本按错单价换算 |
| `messages` | 缓存 key 与遥测口径同时失真 |
| `stream` | 绕过流式活性看门狗,TTFT / inter-token 超时全部失效 |
| `stream_options` | 丢 usage 帧,成本遥测归零、TPM 闸结算失准 |
值也必须能 JSON 序列化——`numpy` 标量请先 `float()`。这条同样在调用入口就报错,不会等到运行中途:否则它会在缓存层的降级保护之外抛出裸 `TypeError`,连一行遥测都留不下。
## 事后复现:参数进了遥测
每行 `llm_calls``sampling` 列,内容是「调用方传的 ⊎ 生效源的 `EXTRA_BODY`」的规范化 JSON,没传则为 NULL。它**不含**结构化输出的 `response_format`(列名是采样参数,schema 不是,且逐行落库会让审计表膨胀)。
```sql
-- 某批实验实际跑在什么解码条件下
SELECT DISTINCT sampling FROM llm_calls
WHERE session_id = 'exp-42' AND cache_hit = false AND error IS NULL;
```
缓存命中行与最终失败行的这一列只记调用级参数,不含源的 `EXTRA_BODY`——那两条路径没有"生效源"可言(与 `model`/`source_name` 在失败行置空是同一回事)。命中行不损失信息:采样参数已进缓存 key,能命中就意味着调用级参数与历史那次逐字相同。
## 顺带一提:`ENABLE_THINKING` 对某些 provider 无效
`openai``minimax` 两个 provider 的 thinking 注入片段是空的——它们没有已知的推理开关参数,所以 `ENABLE_THINKING=false` 对这两类源**不产生任何效果**,而不是静默生效。真需要下发关闭推理的参数,用 `EXTRA_BODY`
全部 env 键见 [[参考-配置键]];`chat()` 签名见 [[参考-公共API]];遥测列口径见 [[指南-遥测与成本]]。