docs: 修正第六轮 7 处不一致,并按源码逐函数重建路径适用性总表

上一轮我新加的适用性总表本身带进两条错误,已用实测重写:
- 按 Retry-After 等待: EMBED 也有(backoff_delay 三路共用),只有 OCR 无
  (monkey_ocr 不解析 Retry-After);此前整表标 EMBED 为 ✘
- health_aware 失去喂数不等于 least_inflight: 分数全等时仍走 P2C 随机,
  实测两源 200 次 ≈96/104,而 least_inflight 稳定排序恒 200/0

其余: TransientError 永不穿出(迁移页仍在教人捕它)、取消的尝试层遥测行
只在 transport 调用中被取消时才有、registry 只作用于 chat、OCR 成功行
cost 恒 NULL 会污染漏价对账 SQL、平铺看门狗键成对才生效。

本轮改法调整: 不再逐页打补丁,先对 retry/ocr/embedding 三条循环逐机制
grep 对账建表,再全站 grep 同类断言一次改齐。
2026-08-02 01:13:48 -04:00
parent f8f85fb6e8
commit 7fd67086b5
8 changed files with 26 additions and 16 deletions
+1 -1
@@ -58,7 +58,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 新增)。字段 `extra_body`(v1.0.5新增):本源恒定的采样参数,构造后是只读视图——**该字段令 `SourceConfig` 不再 hashable**,`asdict()`/`deepcopy()` 亦不再适用(加任何 mapping 字段的固有代价);要可变副本用 `dict(source.extra_body)`,要改字段用 `dataclasses.replace` | | `SourceConfig` | 单源完整配置(构造期校验不变式)。方法 `effective_est_tokens() -> int`:TPM 入场预扣量,显式 `est_tokens > 0` 优先,否则按 `max(1, tpm // 60)` 派生,`tpm=0` 时为 0(v1.0.3 新增)。字段 `extra_body`(v1.0.5新增):本源恒定的采样参数,构造后是只读视图——**该字段令 `SourceConfig` 不再 hashable**,`asdict()`/`deepcopy()` 亦不再适用(加任何 mapping 字段的固有代价);要可变副本用 `dict(source.extra_body)`,要改字段用 `dataclasses.replace` |
| `ProviderProfile` / `DEFAULT_PROFILES` / `register_provider` | provider 方言注册表。`register_provider(profile, *, base=None)` 是**纯函数**——返回 `base`(缺省 `DEFAULT_PROFILES`)+ 新条目的**新表**(同名覆盖),不改全局状态(`DEFAULT_PROFILES` 是 MappingProxyType,改不动)。`base`**keyword-only**,位置传参会 `TypeError`。构造 profile 用 `ProviderProfile(name, thinking_on, thinking_off, strip_think_tags, supports_native_schema=False)`——**前四个必填无默认**;`thinking_on`/`thinking_off``ENABLE_THINKING` 为 True/False 时并入请求体的片段(两档皆填 `{}` 表示该 provider 无推理开关,见 [[指南-采样参数]]),`strip_think_tags` 声明是否剥离 `<think>` 标签。新表必须经 `from_env(registry=...)` / `from_settings(registry=...)` 传入才生效;**只调 `register_provider()` 不传 `registry=`,装配期`ValueError: 未注册的 provider`**。内置四个 profile 的 `supports_native_schema` 均为 `False` | | `ProviderProfile` / `DEFAULT_PROFILES` / `register_provider` | provider 方言注册表。`register_provider(profile, *, base=None)` 是**纯函数**——返回 `base`(缺省 `DEFAULT_PROFILES`)+ 新条目的**新表**(同名覆盖),不改全局状态(`DEFAULT_PROFILES` 是 MappingProxyType,改不动)。`base`**keyword-only**,位置传参会 `TypeError`。构造 profile 用 `ProviderProfile(name, thinking_on, thinking_off, strip_think_tags, supports_native_schema=False)`——**前四个必填无默认**;`thinking_on`/`thinking_off``ENABLE_THINKING` 为 True/False 时并入请求体的片段(两档皆填 `{}` 表示该 provider 无推理开关,见 [[指南-采样参数]]),`strip_think_tags` 声明是否剥离 `<think>` 标签。**注册表只作用于 chat**:新表必须经 **`GatewayClient`** 的 `from_env(registry=...)` / `from_settings(registry=...)` 传入才生效,只调 `register_provider()` 不传 `registry=`装配期抛 `ValueError: 未注册的 provider``EmbeddingClient` 虽有 `registry=` 形参但 `/embeddings` 路径**全程不读** `ProviderProfile`(填任何 provider 名都不报错,别为迁就注册表谎报成 qwen/openai,会毁掉成本归因);`OcrClient` **没有** `registry=` 形参(传了直接 `TypeError`),只认 `provider=monkey`。详见 [[参考-配置键]]。内置四个 profile 的 `supports_native_schema` 均为 `False` |
| `PricingTable` / `ModelPrice` | 价格表(成本折算) | | `PricingTable` / `ModelPrice` | 价格表(成本折算) |
版本号也走顶层导出:`polygateway.__version__`(当前 `1.0.5`),日志与 bug 报告可直接带上。异常层级见 [[参考-异常]];全部 env 键见 [[参考-配置键]]。 版本号也走顶层导出:`polygateway.__version__`(当前 `1.0.5`),日志与 bug 报告可直接带上。异常层级见 [[参考-异常]];全部 env 键见 [[参考-配置键]]。
+3 -1
@@ -44,7 +44,7 @@
|---|---| |---|---|
| `{SCOPE}__GLOBAL__MAX_CONCURRENCY / RPM / TPM` | 跨源合计闸。入场预扣量取自**源级**(`EST_TOKENS``max(1, 源TPM//60)` 派生)。只配 `{SCOPE}__GLOBAL__TPM` 而源上既无 TPM 也无 EST_TOKENS 时预扣恒为 0——闸失去的是**预留**能力(一批并发会被同时放行、可远超上限),但**它仍是准入闸**:结算把每次调用的真实 token 补记进全局分钟窗口,累计一旦超上限后续入场即被拒(判据是 `已用量 + 预扣 ≤ 上限`),直到窗口翻转才恢复。即**超额一次才刹车,不是完全失效**。`quota_full=wait`(默认)表现为轮询等待到窗口翻页(现象像"请求集体挂住、日志无错"),`fail_fast``AllSourcesExhausted(reason='quota_exhausted')`。要让它按预期节流,请给源配 `TPM``EST_TOKENS` | | `{SCOPE}__GLOBAL__MAX_CONCURRENCY / RPM / TPM` | 跨源合计闸。入场预扣量取自**源级**(`EST_TOKENS``max(1, 源TPM//60)` 派生)。只配 `{SCOPE}__GLOBAL__TPM` 而源上既无 TPM 也无 EST_TOKENS 时预扣恒为 0——闸失去的是**预留**能力(一批并发会被同时放行、可远超上限),但**它仍是准入闸**:结算把每次调用的真实 token 补记进全局分钟窗口,累计一旦超上限后续入场即被拒(判据是 `已用量 + 预扣 ≤ 上限`),直到窗口翻转才恢复。即**超额一次才刹车,不是完全失效**。`quota_full=wait`(默认)表现为轮询等待到窗口翻页(现象像"请求集体挂住、日志无错"),`fail_fast``AllSourcesExhausted(reason='quota_exhausted')`。要让它按预期节流,请给源配 `TPM``EST_TOKENS` |
| `{SCOPE}__SELECTOR` | health_aware(默认)/ round_robin / least_inflight | | `{SCOPE}__SELECTOR` | health_aware(默认)/ round_robin / least_inflight |
| `{SCOPE}__RETRY__MAX_ATTEMPTS / BACKOFF_BASE_S / BACKOFF_MAX_S` | **必填**(或用下方平铺简写);MAX_ATTEMPTS 含首次。退避公式 `min(BASE × 2^(n-1), MAX) × jitter`,**jitter ∈ [0.5, 1.5)**——`BACKOFF_MAX_S` 封顶的是抖动**前**的基数,单次实际等待最大是它的 1.5 倍,按它估算任务软超时请乘 1.5 | | `{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__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}__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` | 配额满等待的判死窗口(须 ≥ 最大源 TTFT);均可省,缺省 `300.0` / `0.05` |
@@ -52,6 +52,8 @@
平铺简写(单 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` 平铺简写(单 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`
> **`LLM_TTFT_TIMEOUT` 与 `LLM_INTER_TOKEN_TIMEOUT` 必须成对**,且仅当该源的 `TTFT_TIMEOUT_S` / `INTER_TOKEN_TIMEOUT_S` **两个都缺省**时才作为缺省填入。**只配其中一个会被静默忽略、不报错**——该源两道看门狗全空,流式活性只剩由 `TIMEOUT_S` 派生的 total 一层(首包挂住要等满 120~300s 才失败,期间一直占着并发租约与 TPM 预扣)。注意与源级行为**相反**:源级只配一半是装配期硬报错,且源级配了一个时平铺键不会补齐另一半,照样报错。
>
> **多 scope 项目注意**:这批键的 `LLM_` 是**硬编码字面量**,不随 scope 变化。任何 scope(LLM / EMBED / OCR / 自定义)缺对应 scope 级键时,都回落到同一批 `LLM_*`——为 LLM 配的 `LLM_TIMEOUT` / `LLM_MAX_RETRIES` / `LLM_CIRCUIT_BREAKER_*` 会被 OCR、EMBED **静默继承**且不报错。也不存在 `OCR_TIMEOUT` / `EMBED_MAX_RETRIES` 这类按 scope 派生的平铺键(配了既不报错也不生效)。**多 scope 项目请一律用四段式 `{SCOPE}__…` 键。** > **多 scope 项目注意**:这批键的 `LLM_` 是**硬编码字面量**,不随 scope 变化。任何 scope(LLM / EMBED / OCR / 自定义)缺对应 scope 级键时,都回落到同一批 `LLM_*`——为 LLM 配的 `LLM_TIMEOUT` / `LLM_MAX_RETRIES` / `LLM_CIRCUIT_BREAKER_*` 会被 OCR、EMBED **静默继承**且不报错。也不存在 `OCR_TIMEOUT` / `EMBED_MAX_RETRIES` 这类按 scope 派生的平铺键(配了既不报错也不生效)。**多 scope 项目请一律用四段式 `{SCOPE}__…` 键。**
## 装配键 `PGW_*` ## 装配键 `PGW_*`
+1 -1
@@ -2,7 +2,7 @@
与 chat 共用治理件:多源、重试、熔断、遥测;外加分批与维度校验。 与 chat 共用治理件:多源、重试、熔断、遥测;外加分批与维度校验。
> **两处与 chat 不同,都是静默的**:① **健康降权通道对 EMBED 不生效**——喂数只发生在 chat 与 OCR 路径,EMBED 的健康分恒为初值,`health_aware`(默认)实际退化成 `least_inflight`,隔离坏源**只能靠熔断**;② 429 照常计入 `MAX_ATTEMPTS`(chat 路径才有 pushback 豁免)。两者都不报错、遥测也看不出来。 > **两处与 chat 不同,都是静默的**:① **健康降权通道对 EMBED 不生效**——喂数只发生在 chat 与 OCR 路径,EMBED 的健康分恒为初值,`health_aware`(默认)失去区分坏源的能力——但**不等于 `least_inflight`**:分数全等时仍走 P2C 随机,多源下头名近似均匀随机(least_inflight 是稳定排序、平局恒选首源)。要那个语义须显式配 `EMBED__SELECTOR=least_inflight`;隔离坏源**只能靠熔断**;② 429 照常计入 `MAX_ATTEMPTS`(chat 路径才有 pushback 豁免)。两者都不报错、遥测也看不出来。
## 配置 ## 配置
+1 -1
@@ -27,7 +27,7 @@ LLM__QWEN__2__TIMEOUT_S=120
| `round_robin` | 轮询 | 想要严格均摊 | | `round_robin` | 轮询 | 想要严格均摊 |
| `least_inflight` | 选在途最少 | 源性能差异大 | | `least_inflight` | 选在途最少 | 源性能差异大 |
健康视图由每次尝试的结果喂数(真实成功=好;瞬时失败/429/源死=坏;**坏结果不算坏服务**,不降权)。**喂数只发生在 chat 与 OCR 路径**——`EmbeddingClient` 不喂,所以 EMBED scope 的健康分恒为初值`health_aware` 退化为 `least_inflight`(见 [[指南-Embedding]])。 健康视图由每次尝试的结果喂数(真实成功=好;瞬时失败/429/源死=坏;**坏结果不算坏服务**,不降权)。**喂数只发生在 chat 与 OCR 路径**——`EmbeddingClient` 不喂,所以 EMBED scope 的健康分恒为初值,`health_aware` 因此只剩 P2C 随机 + 在途加权(**不等于 `least_inflight`**,详见 [[解释-治理行为]] 的适用性总表)。
## 换源与冷却行为 ## 换源与冷却行为
+1 -1
@@ -17,7 +17,7 @@
## 三条高频经验 ## 三条高频经验
1. **业务端口保留,shim 转换**:项目自己的 `LLMProvider`/`VlmProvider` 协议不用动,写 10-30 行 shim 把库返回值映射回去,业务调用点零改动。`LLMResponse` 前 11 字段与旧三项目逐字保序,多数场景直接 re-export 即可。 1. **业务端口保留,shim 转换**:项目自己的 `LLMProvider`/`VlmProvider` 协议不用动,写 10-30 行 shim 把库返回值映射回去,业务调用点零改动。`LLMResponse` 前 11 字段与旧三项目逐字保序,多数场景直接 re-export 即可。
2. **步级重试要显式接线**:如果项目在治理层之外还有任务级重试(捕 `TimeoutError/OSError` 一类),库异常不是它们的子类——必须显式把 `(TransientError, GatewayUnavailableError)` 注入进去,否则那层重试**静默失效**。用父类 `GatewayUnavailableError` 而非单列 `AllSourcesExhausted`:它同时覆盖 `AllSourcesExhausted`(预算耗尽)与 `CircuitOpenError`(源被熔断),后者是单源场景下一次 401/403 之后的常态路径,漏了等于没接。 2. **步级重试要显式接线**:如果项目在治理层之外还有任务级重试(捕 `TimeoutError/OSError` 一类),库异常不是它们的子类——必须显式把 `GatewayUnavailableError` 注入进去,否则那层重试**静默失效**。用这个父类是因为它同时覆盖 `AllSourcesExhausted`(预算耗尽)与 `CircuitOpenError`(源被熔断),后者是单源场景下一次 401/403 之后的常态路径,漏了等于没接。**不要再写 `TransientError`**:它永不穿出 `chat()` / `embed()` / OCR(三处治理循环全量吸收转内部失败,耗尽统一抛 `AllSourcesExhausted`),写了是死分支——按它分流的项目会把 100% 的真实瞬时故障判进不重投的那一支(见 [[参考-异常]])。
3. **任务队列消费 `GatewayUnavailableError`**:scope 级不可用时按 `exc.retry_after_s` 延期重投、不消耗业务失败预算,是 arq/celery 场景的标准写法。 3. **任务队列消费 `GatewayUnavailableError`**:scope 级不可用时按 `exc.retry_after_s` 延期重投、不消耗业务失败预算,是 arq/celery 场景的标准写法。
## 范本 ## 范本
+4 -2
@@ -38,7 +38,7 @@ v1.0.4 新增的两列会**自动补到已存在的旧表上**,无需手工迁
| 值 | 含义 | 什么时候出现 | cost | | 值 | 含义 | 什么时候出现 | cost |
|---|---|---|---| |---|---|---|---|
| `measured` | 用量帧完整可信 | 正常路径;OCR 成功行(0 token 是事实,不是未知) | 按 token 换算 | | `measured` | 用量帧完整可信 | 正常路径;OCR 成功行(0 token 是事实,不是未知) | 按 token 换算。**但 OCR 成功行 cost 恒 NULL**——`OcrClient` 不持有价格表(`PGW_PRICING_PATH` 对它不注入),且不告警 |
| `estimated` | 有实测数字但可信度降级 | 打捞路径:收到 usage 帧但流被截断(`MISSING_DONE=salvage`) | 按 token 换算 | | `estimated` | 有实测数字但可信度降级 | 打捞路径:收到 usage 帧但流被截断(`MISSING_DONE=salvage`) | 按 token 换算 |
| `unavailable` | 用量信息不可得 | 上游没返回 usage 帧、失败的尝试、终态失败 | **NULL** | | `unavailable` | 用量信息不可得 | 上游没返回 usage 帧、失败的尝试、终态失败 | **NULL** |
@@ -61,7 +61,9 @@ PGW_PRICING_PATH=config/prices.json
> ```sql > ```sql
> SELECT model, COUNT(*) FROM llm_calls > SELECT model, COUNT(*) FROM llm_calls
> WHERE cost IS NULL AND cache_hit = false AND error IS NULL > WHERE cost IS NULL AND cache_hit = false AND error IS NULL
> AND usage_source <> 'unavailable' GROUP BY model; > AND usage_source <> 'unavailable'
> AND provider <> 'monkey' -- OCR 成功行 cost 恒 NULL,不是漏价
> GROUP BY model;
> ``` > ```
`cached_input_per_1m` 是**可选**的第三档(v1.0.4):供应商 prompt cache 命中的那部分输入按更低单价计费。配了它,cost 就按 `(prompt - cached) × input + cached × cached_input` 分段算;**不配就退化为全额输入价**——库不会替你猜一个折扣率,所以不配时 cost 会比实际账单偏高。命中数若超过输入总数(网关口径异常),按总数夹取并记一条 warning,不会算出负数。 `cached_input_per_1m` 是**可选**的第三档(v1.0.4):供应商 prompt cache 命中的那部分输入按更低单价计费。配了它,cost 就按 `(prompt - cached) × input + cached × cached_input` 分段算;**不配就退化为全额输入价**——库不会替你猜一个折扣率,所以不配时 cost 会比实际账单偏高。命中数若超过输入总数(网关口径异常),按总数夹取并记一条 warning,不会算出负数。
+14 -8
@@ -6,16 +6,22 @@
**病灶**:轮询把 1/N 的流量持续喂给坏源。**机制**:每源维护成功率 EWMA 与在途数,选源时随机取两个候选比较(P2C),分高者当头名,其余按分数降序。选源器**没有阈值分支**——坏源仍保有 1/N²(两源池 25%、四源池约 6%)的首选概率,这是有意留的探索通道,好让它恢复后能被发现;只有在**同一次调用内连败 ≥2 次**且存在可信替代(健康分 ≥ 失败源一半)时才被降权到替代之后。真正把坏源隔离掉的是熔断,选源只负责压低它的吸流占比。其中「同一次调用内连败后降权重排」是 **chat 路径限定**——OCR 照常喂健康分(跨调用选源受益)但同调用内不重排,EMBED 两者皆无。 **病灶**:轮询把 1/N 的流量持续喂给坏源。**机制**:每源维护成功率 EWMA 与在途数,选源时随机取两个候选比较(P2C),分高者当头名,其余按分数降序。选源器**没有阈值分支**——坏源仍保有 1/N²(两源池 25%、四源池约 6%)的首选概率,这是有意留的探索通道,好让它恢复后能被发现;只有在**同一次调用内连败 ≥2 次**且存在可信替代(健康分 ≥ 失败源一半)时才被降权到替代之后。真正把坏源隔离掉的是熔断,选源只负责压低它的吸流占比。其中「同一次调用内连败后降权重排」是 **chat 路径限定**——OCR 照常喂健康分(跨调用选源受益)但同调用内不重排,EMBED 两者皆无。
## 机制 × 路径适用性 ## 机制 × 路径适用性(单一事实源)
| 机制 | chat | OCR | EMBED | 本表逐函数对照 `retry.py` / `ocr.py` / `embedding.py` 三条治理循环得出;其他页涉及路径差异时请链回本表,不要各自复述。
|---|---|---|---|
| 429 pushback(不耗重试预算、按 Retry-After 等待) | ✔ | ✘(照常计入 MAX_ATTEMPTS) | ✘(同左) |
| AIMD 自适应并发(`adaptive_paced`) | ✔ | ✘ | ✘ |
| 健康分喂数(跨调用降权/回流) | ✔ | ✔ | ✘(health_aware 退化为 least_inflight) |
| 同一次调用内连败降权重排 | ✔ | ✘ | ✘ |
限流六道闸、熔断双通道、重试退避、遥测必录四项**三条路径一致**。 | 机制 | chat | OCR | EMBED | 依据 |
|---|---|---|---|---|
| 429 **不耗**重试预算 | ✔ | ✘ | ✘ | `retry.py:232``!= "rate_limited"` 判断只有 chat 有 |
| 退避**按 Retry-After 取大** | ✔ | ✘ | ✔ | `backoff_delay` 三路共用,但 OCR 的 transport 不解析 `Retry-After`、不带 `retry_after_s`,故对 OCR 恒为 0 |
| 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 且不告警 |
**EMBED 的 `health_aware` 不等于 `least_inflight`**:喂数缺失只让健康分恒为初值(全等),而 `HealthAwareSelector` 在分数全等时仍走 P2C 随机取二,头名近似**均匀随机**;`LeastInflightSelector` 是稳定排序,平局恒选第一个配置源。实测两源 200 次:health_aware ≈ 96/104,least_inflight = 200/0。要真的 least_inflight 语义必须显式配 `EMBED__SELECTOR=least_inflight`(单源 scope 不受影响)。
限流六道闸、熔断双通道、退避公式本身、冷却备忘、遥测必录**三条路径一致**。
## 熔断双通道 + 健康证据抑制 ## 熔断双通道 + 健康证据抑制
+1 -1
@@ -22,7 +22,7 @@
| 限流等待 / 退避 sleep | 可被取消;已取得的 permit 在 finally 归还 | | 限流等待 / 退避 sleep | 可被取消;已取得的 permit 在 finally 归还 |
| 流式读取 | 取消中断读取,连接在 finally 释放 | | 流式读取 | 取消中断读取,连接在 finally 释放 |
| 半开探针 | 探针持有者被取消 → 归还探针(源保持开路,下一个调用可再探),不判成败 | | 半开探针 | 探针持有者被取消 → 归还探针(源保持开路,下一个调用可再探),不判成败 |
| 遥测 | **取消照常留痕**:尝试层一`error='cancelled'`(带 source_name)+ 最外层一行终局 `error='cancelled'`(source_name 为空),两行均 `usage_source='unavailable'``cost=NULL`。统计成功率/失败率时请排除 `error='cancelled'`——取消既不算成功,也不代表调用真的失败 | | 遥测 | **取消留痕,但只有终局行是必有的**。最外层那`error='cancelled'`(source_name 为空)恒写;尝试层那行(带 source_name)**仅当取消恰好落在 transport 调用进行中**才有——退避 sleep、配额满轮询等待、选源准入这三段都在尝试层 try 之外被取消,不产生尝试行。而 arq 超时高发的正是配额满等待期。**统计取消一律以终局行为准**,别按 call_id 配对或按 source_name 归因;两类行均 `usage_source='unavailable'``cost=NULL`,算成功率时请排除 `error='cancelled'` |
设计动机:上层(arq 任务超时、用户中断)取消时,库必须立刻让路且不留悬挂资源——租约归零、探针不悬挂、in-flight 清零在压测中是持续验证的不变量。 设计动机:上层(arq 任务超时、用户中断)取消时,库必须立刻让路且不留悬挂资源——租约归零、探针不悬挂、in-flight 清零在压测中是持续验证的不变量。