docs: soften the non-chat extra_body gate to strip-and-warn

Stripping is load-bearing: without it telemetry would record a sampling
parameter that was never sent on the OCR and embedding paths.
This commit is contained in:
2026-07-31 12:31:31 -04:00
parent 09e77f11f8
commit b24e224beb
4 changed files with 22 additions and 12 deletions
@@ -112,7 +112,7 @@ key_obj = {model, messages_digest, namespace, [salt], [sampling]}
一次做完而非分两步:「能传参数但没记」的中间状态最危险——数据已产生且事后无法追溯,且分步要做两遍 DDL 迁移。
**OCR/Embedding 路径零改动**:`ocr.py:418``embedding.py:372` 也调 `emit_attempt` 且都传 `source`,只要 `sampling` 由 emitter 内部推导(而非作为新必填参数由调用者传入),这两个文件不动一行反之立刻 TypeError——实施时必须走推导路线。
**OCR/Embedding 的 emit 调用点零改动**:`ocr.py:418``embedding.py:372` 也调 `emit_attempt` 且都传 `source`,只要 `sampling` 由 emitter 内部推导(而非作为新必填参数由调用者传入),这两处调用不动一行——反之立刻 TypeError,实施时必须走推导路线。两个文件本身仍有改动,即决策 G 的构造期剥离(它正是让这里的推导对 OCR/embedding 恒得 NULL 的前提)。
### 决策 E: 入参拷贝语义与两条只读约束
@@ -128,13 +128,19 @@ issue 场景就是逐次改 `seed`——调用方复用同一 dict 对象改值
补的是这一句后果说明(覆盖两个 provider),不是重复已有的"为何为空"。不改行为:真需要关时经 `extra_body` 绕过。
### 决策 G: 非 chat 路径的 `extra_body` 装配期拒绝
### 决策 G: 非 chat 路径的 `extra_body` —— 剥离并 warning,不中断装配
`_SOURCE_FIELDS`(`config.py:33-47`)是**跨 scope 共用**的一张表,加了 `EXTRA_BODY` 之后 `OCR__MONKEY__1__EXTRA_BODY` / `EMBED__QWEN__1__EXTRA_BODY` 会被合法接受、进 `SourceConfig`、进遥测 `sampling` 列,但 `OpenAICompatTransport.embed`(`openai_compat.py:343`,payload 硬编码 `{"model", "input"}`)与 `monkey_ocr`(multipart 表单)都不消费它——**静默无效**,正是 §4.5 要禁的形态。
`_SOURCE_FIELDS`(`config.py:33-47`)是**跨 scope 共用**的一张表,加了 `EXTRA_BODY` 之后 `OCR__MONKEY__1__EXTRA_BODY` / `EMBED__QWEN__1__EXTRA_BODY` 会被合法接受、进 `SourceConfig`、进遥测 `sampling` 列,但两条路径都不消费它:`monkey_ocr.py:225,247` 只发 multipart `files=`(**根本没有 JSON body**),`OpenAICompatTransport.embed`(`openai_compat.py:343`)payload 硬编码 `{"model", "input"}`。放任即**静默无效**,正是 §4.5 要禁的形态。
处置:`EmbeddingClient` / `OcrClient` 构造期发现源带非空 `extra_body``ValueError`,明说该路径不支持。不顺手给 embed 加透传:embedding 没有采样一说,issue 也未提出诉求(YAGNI);真有需求时再单独设计,届时报错会把人引到正确的地方,而静默不会
**处置(2026-07-31 人类拍板改此档)**:`EmbeddingClient` / `OcrClient` 构造期发现源带非空 `extra_body`记 warning 并 `dataclasses.replace(source, extra_body={})` **剥离后放行**,不抛异常
报错文案必须**指路**而非只说不支持——`dimensions` 是 OpenAI embeddings 的正式参数,下游想调向量维度时会第一个撞上这道门,文案应写明"embedding 路径暂不支持 `extra_body`,需要 `dimensions` 等参数请提 issue"
剥离是这一档的**必要组成部分,不是顺手清理**。`ocr.py:390``embedding.py:350` 构造 `ChatRequest` 时不带 `sampling`,但传给 `emit_attempt``source` 是真实配置对象;若不剥离,决策 D 的 `merge(source.extra_body, request.sampling)` 会让遥测**记录一个从未发出的参数**——审计表显示该次 OCR 调用带了 `temperature=0`,实际请求体里没有。那不是"参数不生效",是遥测造假,污染的恰是事后复现的唯一依据。替代方案是在 emitter 里特判调用方身份,直接违背「遥测调用点收敛为单一 helper」铁律,否决
剥离后该列在 OCR/embedding 行恒为 NULL,语义干净,emitter 零特判。
**被否决的原方案**: 装配期 `ValueError` 直接拒绝。理由是这两条路径本无采样语义,配错的后果远轻于 chat 路径,不值得让下游整个装配起不来。**残余风险须写进 wiki**: loguru warning 在生产中容易被淹没,运维可能仍以为参数生效——这是"不中断装配"换来的代价,故 warning 文案必须**指路**:`dimensions` 是 OpenAI embeddings 的正式参数,下游想调向量维度时会第一个撞上,文案应写明"embedding 路径暂不支持 `extra_body`,该配置已被忽略;需要 `dimensions` 等参数请提 issue"。
不顺手给 embed 加透传:embedding 没有采样一说,issue 也未提出诉求(YAGNI);真有需求时单独设计。
---
@@ -148,7 +154,10 @@ issue 场景就是逐次改 `seed`——调用方复用同一 dict 对象改值
| 采样参数不入遥测,由下游 run 快照自记 | 否决 | 见决策 D |
| 缓存 key 与遥测都直接读 `request.overlay`,不加 `sampling` 字段 | 否决 | `overlay` 在洋葱不同深度取值不同(结构化注入),三个 emit 入口与 CacheMW 会各记各的,同一列口径分叉 |
| `sampling` 列记「实际发出的完整合并结果」(含 `response_format`) | 否决 | 该列名为采样参数,schema 不是;且数 KB schema 逐行落库无谓膨胀 |
| 给 embedding 路径也加 `extra_body` 透传 | 否决 | embedding 无采样一说,issue 未提诉求;装配期报错比静默无效更能把人引到对的地方(决策 G) |
| 给 embedding 路径也加 `extra_body` 透传 | 否决 | embedding 无采样一说,issue 未提诉求(决策 G) |
| 非 chat 路径带 `extra_body` 时装配期 `ValueError` | 否决(人类拍板) | 这两条路径无采样语义,配错后果远轻于 chat,不值得让下游装配起不来;改为剥离 + warning |
| 允许放行但**不剥离** `extra_body` | 否决 | 遥测会记录一个从未发出的参数(决策 D 的 merge 读 `source.extra_body`),是数据造假而非参数失效 |
| 放行不剥离,改在 emitter 内特判 OCR/embedding 不记 | 否决 | emitter 是「遥测调用点收敛单一 helper」的产物,让它识别调用方身份是开倒车 |
| transport 层再兜一次保护键校验 | 否决 | 三个入口已构造期收口,重复校验属 gold-plating |
---
@@ -159,7 +168,7 @@ issue 场景就是逐次改 `seed`——调用方复用同一 dict 对象改值
|---|---|
| **并发** | 无新增共享状态。`extra_body` 装配后只读(MappingProxyType);调用级 overlay 每调用独立拷贝,并发调用互不可见 |
| **取消** | 无新增 await 点与等待循环,`CancelledError` 穿透路径完全不变 |
| **降级方向** | 不涉及新后端。遥测新列写失败沿用既有逐行 warning 降级;缓存 key 变更不影响 Redis 掉线的静默降级方向 |
| **降级方向** | 不涉及新后端。遥测新列写失败沿用既有逐行 warning 降级;缓存 key 变更不影响 Redis 掉线的静默降级方向。决策 G 的剥离 + warning 是**配置面**降级(装配期一次性、可复现、部署即暴露),与铁律里"限流/熔断后端不可用须报错"的**运行时**降级方向是两回事,不冲突 |
| **幂等与重复** | 保护键校验是纯函数,重复调用安全;遥测补列先探测后 ALTER,重启幂等 |
| **持久化与原子性** | 遥测单行写入,无部分写入风险。缓存 value 结构不变(`sampling` 只进遥测不进 `LLMResponse`,避免动已被三项目消费的公共类型) |
| **重试交互** | overlay 在 RetryMW 循环外确定,换源重试时同一 overlay 应用到新源的 `extra_body` 之上——语义正确(调用级意图跨源保持) |
@@ -184,7 +193,7 @@ issue 场景就是逐次改 `seed`——调用方复用同一 dict 对象改值
| 7 | env 解析:`EXTRA_BODY` 合法 JSON 对象 → dict;非法 JSON / 非对象 → `ValueError` | unit |
| 8 | 不可 JSON 序列化的值(如 `np.float32`)在 `chat()` 入口即 `ValueError`,不进洋葱 | unit |
| 9 | 三个 emit 入口的 `sampling` 口径:attempt 含 `extra_body`、cache_hit 与 terminal 只含调用级、结构化注入的 `response_format` **三行都不出现** | unit |
| 10 | `EmbeddingClient`/`OcrClient` 装配时源带 `extra_body``ValueError`(决策 G) | unit |
| 10 | `EmbeddingClient`/`OcrClient` 装配时源带 `extra_body`记 warning、装配成功、源上 `extra_body` 已被剥空,且该路径遥测 `sampling` 为 NULL(决策 G;后半段是防遥测造假的真正断言) | unit |
| 11 | `_EXPECTED_COLUMNS` 断言更新后仍逐字匹配实际列序(见 §6,两处会直接红) | unit + integration |
| 12 | 遥测 `sampling` 落库正确;两后端对既有旧表幂等补列 | integration |
| 13 | 采样参数经全链路(chat → 选源 → transport payload)到达请求体 | integration |
@@ -211,12 +220,12 @@ env 键名沿用既有约定:`{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY`,值为 JSON
| ARCH §9 | 配置面键族事实源(`:519-527`),登记 `{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY` |
| `.env.example` | `client.py:247` docstring 声明它是键名清单的事实源,新键不写进去等于无处可查 |
| `README.md:83` | 该行逐一列举 `chat()` 关键字参数,补 `overlay` |
| wiki how-to | 增「固定解码参数」条目,写明 seed 进 key 导致缓存必 miss |
| wiki how-to | 增「固定解码参数」条目,写明 seed 进 key 导致缓存必 miss、以及 OCR/embedding 路径的 `extra_body` 会被忽略(仅 warning) |
| CHANGELOG | 公共 API 新增 + 遥测端口扩列 |
## 7. 实施范围
`types.py`(保护键与 JSON 可序列化校验、合并纯函数、`ChatRequest.sampling``SourceConfig.extra_body`)、`client.py`(`chat()` 参数 + fingerprint)、`middleware/cache.py`(key 公式)、`transports/openai_compat.py`(`_build_payload` 一行)、`config.py`(env 解析)、`ports.py` + `middleware/telemetry.py` + `telemetry/{sqlite,postgres}.py`(第 21 字段与补列)、`ocr.py` + `embedding.py`(仅决策 G 的装配期拒绝)、`providers.py`(注释)。
`types.py`(保护键与 JSON 可序列化校验、合并纯函数、`ChatRequest.sampling``SourceConfig.extra_body`)、`client.py`(`chat()` 参数 + fingerprint)、`middleware/cache.py`(key 公式)、`transports/openai_compat.py`(`_build_payload` 一行)、`config.py`(env 解析)、`ports.py` + `middleware/telemetry.py` + `telemetry/{sqlite,postgres}.py`(第 21 字段与补列)、`ocr.py` + `embedding.py`(仅决策 G 的构造期剥离 + warning)、`providers.py`(注释)。
**测试侧必改**(否则直接红):`tests/unit/test_telemetry.py:18,113``tests/integration/test_postgres_telemetry.py:22,210,231``_EXPECTED_COLUMNS` 断言完整列表与列序。