docs: 修正第八轮 5 处不一致,其中 3 处是我第七轮引入的

我引入的三条:
- per_source_reasons 写入规则我写成 rate_limited 一律 setdefault,实际它有
  两个写入点——上游 429 走 _failure_reason 直接覆盖,本地限流闸拒绝才是
  setdefault。照我上轮的写法反推会得出相反结论
- Home 导语我写成"成本换算仅 chat 有",实际反了: EmbeddingClient 传 pricing、
  而 chat 的 LLMResponse.cost 被硬编码 None
- STALL_WINDOW_S 我只补了"可省/缺省值",没纠正它被窄化成"配额满等待"

原始缺陷两条:
- LLMResponse.cost 在 chat 路径恒为 None(retry.py:437 硬编码),wiki 一直写
  "按价格表折算"。sum(r.cost ...) 会 TypeError,写了 or 0 的静默算出 0 元
- SourceDeadError 同样不穿出,except SourceDeadError 是死分支,坏 key 告警
  会静默失效
2026-08-02 02:32:44 -04:00
parent 7d4a4ffde3
commit e78bfb90d3
5 changed files with 9 additions and 6 deletions
+1 -1
@@ -1,6 +1,6 @@
# PolyGateway 文档
实验室统一的大语言模型调度与中转库:LLM / VLM / OCR / Embedding 共用同一套生产级治理栈(多源多账号、限流、错误分类重试、熔断、流式看门狗、遥测与成本);**响应缓存、AIMD 自适应并发、成本换算等几项仅 chat 路径有**,逐项对照见 [[解释-治理行为]]。治理单位是**一次模型调用**;任务编排与业务解析留在业务侧。
实验室统一的大语言模型调度与中转库:LLM / VLM / OCR / Embedding 共用同一套生产级治理栈(多源多账号、限流、错误分类重试、熔断、流式看门狗、遥测与成本);**响应缓存、AIMD 自适应并发、健康降权等几项按路径而异**(如成本换算 OCR 没有、而 chat 的 `LLMResponse.cost` 恒 None、只有 `EmbeddingResponse.cost` 真填),逐项对照见 [[解释-治理行为]] 的适用性总表。治理单位是**一次模型调用**;任务编排与业务解析留在业务侧。
当前版本 **v1.0.5**,已通过 GovDoc-SaaS 与 CHSAnalyzer 两个真实项目的全量迁移验收。
+1 -1
@@ -24,7 +24,7 @@
| cache_hit | bool | **PolyGateway 自身响应缓存**是否命中(未产生网关调用);与供应商 prompt cache 无关,后者见 cached_prompt_tokens |
| call_id | str | 遥测主键 |
| source_name | str | 命中的源名。**注意它排在 call_id 之后**——库新增字段一律追加在末尾 |
| cost | float\|None | 按价格表折算(缺表为 None) |
| cost | float\|None | **chat 路径恒`None`**——成本不回填进响应对象,只写进遥测行 `llm_calls.cost`(配了 `PGW_PRICING_PATH` 也不变)。做预算控制请查遥测表,别用 `resp.cost`;注意 `EmbeddingResponse.cost` **是**真填的,从 embed 迁到 chat 时同名字段不同语义 |
| usage_source | str | measured / estimated / unavailable(v1.0.3 起三态,口径见 [[指南-遥测与成本]]) |
| structured_data | Any\|None | `structured=` 时的校验结果 |
| cached_prompt_tokens | int\|None | 供应商 prompt cache 命中的输入 token 数(v1.0.4 新增)。`None` = 该源未上报;`0` = 上报了真实零命中——两者不可混同,口径见 [[指南-遥测与成本]] |
+4 -2
@@ -30,7 +30,7 @@
| `scope` | 哪个 scope(小写) |
| `reason` | **scope 级**受控词表(越界值构造期报错): `circuit_open` / `retry_exhausted` / `stalled` / `quota_exhausted`(配额满且 `QUOTA_FULL=fail_fast`)/ `no_sources` |
| `retry_after_s` | 最早值得重试的秒数,**取值来源随 reason 而异**:`circuit_open` 与「无源可跑的 stalled」读熔断后端;`retry_exhausted` = `BACKOFF_BASE_S`;**429 持续 pushback 触发的调用级 `stalled` 也 = `BACKOFF_BASE_S`**(不读后端,故拿到的是 1-2s 量级而非源冷却期);`quota_exhausted` = `POLL_INTERVAL_S`(缺省 0.05s);`no_sources` = 0.0。这是**最短建议间隔而非源恢复时间**,队列重投请自加下限 |
| `per_source_reasons` | 逐源失败原因字典,诊断用。**值域与 `reason` 是两张表**: `network_error` / `timeout` / `rate_limited` / `source_dead` / `circuit_open` / `cooldown` / `adaptive_paced`。注意**上游 429 与本地限流闸拒绝都记 `rate_limited`,二者不可区分**;`adaptive_paced` 只由 **chat 路径**的 AIMD pacer 拦截产生(OCR/EMBED 无 pacer,永不出现该值)。另注意 **`network_error` 是兜底桶而非字面网络错误**——归类只识别 source_dead / 429 / 超时三类,其余 Transient(HTTP 5xx、空补全、SSE 截断、响应体非法 JSON、缺 choices)全落进它。而 `timeout` 桶也不止 httpx 超时:**库自身的 `StreamLivenessTimeout`(kind ∈ ttft / inter_token / total)同样记 `timeout`**,且 total 层不配也按 `TIMEOUT_S` 恒生效——「长生成超总预算」这条最常见路径根本不经 httpx 却仍落这个桶,两者在 `per_source_reasons` 上不可区分。**该字典只保留每个源的最后一次原因,且写入规则不统一**:`source_dead` / `circuit_open` / `cooldown` **直接覆盖**,`rate_limited` / `adaptive_paced``setdefault`(不覆盖已有值)。后果是 **`MAX_ATTEMPTS ≥ 3`(常规配置)时,401/403/欠费的 `source_dead` 会先被改写成 `circuit_open`、再多一轮变成 `cooldown`**——凭据失效的证据被彻底抹掉,读起来像良性退避,且该出口的 `__cause__``None`。**要区分「坏 key」与「上游抖动」不能只看这个字典,必须查遥测行的 error 文本**(逐次尝试都有记录,`SourceDeadError` 原文在里面)。**排障别急着加大 `LLM_TIMEOUT`**(它同时是 total 层看门狗的预算),先看遥测 error 文本区分「流活性超时(kind)」与「超时: …」,该调的通常是 `TTFT_TIMEOUT_S` / `INTER_TOKEN_TIMEOUT_S` |
| `per_source_reasons` | 逐源失败原因字典,诊断用。**值域与 `reason` 是两张表**: `network_error` / `timeout` / `rate_limited` / `source_dead` / `circuit_open` / `cooldown` / `adaptive_paced`。注意**上游 429 与本地限流闸拒绝都记 `rate_limited`,二者不可区分**;`adaptive_paced` 只由 **chat 路径**的 AIMD pacer 拦截产生(OCR/EMBED 无 pacer,永不出现该值)。另注意 **`network_error` 是兜底桶而非字面网络错误**——归类只识别 source_dead / 429 / 超时三类,其余 Transient(HTTP 5xx、空补全、SSE 截断、响应体非法 JSON、缺 choices)全落进它。而 `timeout` 桶也不止 httpx 超时:**库自身的 `StreamLivenessTimeout`(kind ∈ ttft / inter_token / total)同样记 `timeout`**,且 total 层不配也按 `TIMEOUT_S` 恒生效——「长生成超总预算」这条最常见路径根本不经 httpx 却仍落这个桶,两者在 `per_source_reasons` 上不可区分。**该字典只保留每个源的最后一次原因,且写入规则不统一**。分两类:**真发出过请求后归类的失败原因**(`source_dead` / `timeout` / `network_error` / **上游返回 429 的 `rate_limited`**)与选源阶段的 `circuit_open` / `cooldown` 一律**直接覆盖**;只有两种"没真发出请求"的跳过用 `setdefault` 不覆盖——本地限流闸拒绝(也记 `rate_limited`)与 AIMD pacer 拦截(`adaptive_paced`)。所以**看到 `rate_limited` 不能推断这个源从头到尾只是被限流**:先超时或先 5xx、随后撞上 429 的源,早先的证据已被覆盖。后果是 **`MAX_ATTEMPTS ≥ 3`(常规配置)时,401/403/欠费的 `source_dead` 会先被改写成 `circuit_open`、再多一轮变成 `cooldown`**——凭据失效的证据被彻底抹掉,读起来像良性退避,且该出口的 `__cause__``None`。**要区分「坏 key」与「上游抖动」不能只看这个字典,必须查遥测行的 error 文本**(逐次尝试都有记录,`SourceDeadError` 原文在里面)。**排障别急着加大 `LLM_TIMEOUT`**(它同时是 total 层看门狗的预算),先看遥测 error 文本区分「流活性超时(kind)」与「超时: …」,该调的通常是 `TTFT_TIMEOUT_S` / `INTER_TOKEN_TIMEOUT_S` |
## 基建故障
@@ -46,7 +46,7 @@
`GatewayUnavailableError` 做延期重投,捕 `RequestRejectedError`/`ResultInvalidError` 做确定性失败处理。
**`TransientError` 不会穿出 `chat()` / `embed()` / OCR**——无论 `MAX_ATTEMPTS` 配多少(哪怕 1),`except TransientError:` 的兜底分支都不会命中。它会变成 `GatewayUnavailableError` 族的三种出口之一:
**`TransientError` `SourceDeadError`不会穿出 `chat()` / `embed()` / OCR**——三处治理循环在同一分支吸收后转内部失败,`except TransientError:``except SourceDeadError:` 都是死分支(凭据失效告警若挂在后者上会静默失效,应改判 `CircuitOpenError` + 遥测 error 文本)。它会变成 `GatewayUnavailableError` 族的三种出口之一:
| 出口 | 何时 | `__cause__` |
|---|---|---|
@@ -54,4 +54,6 @@
| `CircuitOpenError(reason='circuit_open')` | 源已被熔断(故障稳态) | `None` |
| `AllSourcesExhausted(reason='stalled')` | 429 持续 pushback 直到判死 | `None` |
`SourceDeadError` 同理走这三个出口:`MAX_ATTEMPTS=1` 时是 `AllSourcesExhausted(retry_exhausted)`(`__cause__` 为原 `SourceDeadError`),**≥2 时恒为 `CircuitOpenError`**(常态,`__cause__=None`)。
所以 `__cause__` 只在 `retry_exhausted` 这一条路径上有值;通用的诊断入口是 `exc.per_source_reasons`
+1 -1
@@ -47,7 +47,7 @@
| `{SCOPE}__RETRY__MAX_ATTEMPTS / BACKOFF_BASE_S / BACKOFF_MAX_S` | **必填**(或用下方平铺简写);MAX_ATTEMPTS 含首次。退避公式 `max(min(BASE × 2^(n-1), MAX) × jitter, 上游 Retry-After)`,jitter ∈ [0.5, 1.5)。`BACKOFF_MAX_S` 只封顶**本地**那一段(抖动前的基数,故本地最长 = MAX × 1.5);与上游 `Retry-After` 取大的那一段**没有本地上限**——网关回 `Retry-After: 600` 库就睡满 600s,且 chat 路径下 429 不耗 `MAX_ATTEMPTS`。**不能**按 `MAX × 1.5` 排 arq/celery 软超时 |
| `{SCOPE}__BREAKER__FAIL_THRESHOLD / COOLDOWN_S / PROBE_TTL_S` | FAIL_THRESHOLD 与 COOLDOWN_S **必填**(或用平铺简写);PROBE_TTL_S 可省,缺省派生 `max(2 × 最慢源 timeout_s, COOLDOWN_S, 最慢源 timeout_s + 5)`——如 timeout 120 / cooldown 30 得 **240**(不是 125)。**显式配置值**另须 ≥ 最慢源 `timeout_s + 5`,否则装配期报错(派生公式与校验下限是两回事) |
| `{SCOPE}__BREAKER__MIN_CALLS / FAIL_RATE / WINDOW_S / MAX_COOLDOWN_S` | 失败率通道与开路退避封顶。**四个都可省且有默认值**:`10` / `0.6` / `60.0` / `max(300, COOLDOWN_S)`。注意它们**静默生效、缺失不报错**——不显式配就是在用这套缺省 |
| `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S / POLL_INTERVAL_S` | 配额满等待的判死窗口(须 ≥ 最大源 TTFT);均可省,缺省 `300.0` / `0.05` |
| `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S / POLL_INTERVAL_S` | 双条件 stall 判死窗口与轮询间隔;均可省,缺省 `300.0` / `0.05`,须 ≥ 最大源 TTFT。**chat scope 下它有两个作用点**:① 配额满 `wait` 时的等待判死;② **整次 `chat()` 调用的墙钟死线**——主循环每轮开头判"本调用已耗时 > 窗口 **且** 全局无进展 > 窗口",超窗抛 `AllSourcesExhausted(reason='stalled')`。因 429 不消耗 `MAX_ATTEMPTS`,该窗口是 429 持续 pushback 时**唯一**的终止条件:想给饱和期设容忍上限就调它,但调小(如 30)会把整次调用的死线一并压到 30s。**EMBED / OCR 只有作用点 ①**(它们的 429 照常计入 `MAX_ATTEMPTS`) |
| `{SCOPE}__QUOTA_FULL` | wait(默认)/ fail_fast |
平铺简写(单 scope 项目习惯,scope 键优先):`LLM_MAX_RETRIES` / `LLM_RETRY_BASE_DELAY` / `LLM_RETRY_MAX_DELAY` / `LLM_CIRCUIT_BREAKER_THRESHOLD` / `LLM_CIRCUIT_BREAKER_COOLDOWN` / `LLM_TIMEOUT` / `LLM_TTFT_TIMEOUT` / `LLM_INTER_TOKEN_TIMEOUT`
+2 -1
@@ -17,7 +17,8 @@
| AIMD 自适应并发(`adaptive_paced`) | ✔ | ✘ | ✘ | `AdaptivePacer` 只在 `retry.py` |
| 健康分喂数(跨调用降权/回流) | ✔ | ✔ | ✘ | `record_outcome``retry.py``ocr.py`;`embedding.py` 零命中 |
| 同一次调用内连败降权重排 | ✔ | ✘ | ✘ | `_demote_call_failures` 唯一调用点在 `retry.py:251` |
| 成本换算(`cost`) | ✔ | ✘ | ✔ | `OcrClient` 不持有 pricing,成功行 `cost` 恒 NULL 且不告警 |
| 成本换算(**遥测行**的 `cost`) | ✔ | ✘ | ✔ | `OcrClient` 不持有 pricing,成功行 `cost` 恒 NULL 且不告警 |
| 成本回填(**响应对象**的 `.cost`) | ✘ | ✘ | ✔ | `LLMResponse.cost` 被硬编码 `None`(`retry.py:437`),只有 `EmbeddingResponse.cost` 真填。**两件事别混**:chat 有遥测成本、没有响应成本 |
| 响应缓存 | ✔ | ✘ | ✘ | `CacheMW` 只在 `client.py` 装配;另两个 client 连 `cache=` 形参都没有,`cache_hit` 硬编码 False |
**EMBED 的 `health_aware` 不等于 `least_inflight`**:喂数缺失只让健康分恒为初值(全等),而 `HealthAwareSelector` 在分数全等时仍走 P2C 随机取二,头名近似**均匀随机**;`LeastInflightSelector` 是稳定排序,平局恒选第一个配置源。实测两源 200 次:health_aware ≈ 96/104,least_inflight = 200/0。要真的 least_inflight 语义必须显式配 `EMBED__SELECTOR=least_inflight`(单源 scope 不受影响)。