From 7fd67086b567fa9409304a4d1050fbbef3c7e4bf Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sun, 2 Aug 2026 01:13:48 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20=E4=BF=AE=E6=AD=A3=E7=AC=AC=E5=85=AD?= =?UTF-8?q?=E8=BD=AE=207=20=E5=A4=84=E4=B8=8D=E4=B8=80=E8=87=B4,=E5=B9=B6?= =?UTF-8?q?=E6=8C=89=E6=BA=90=E7=A0=81=E9=80=90=E5=87=BD=E6=95=B0=E9=87=8D?= =?UTF-8?q?=E5=BB=BA=E8=B7=AF=E5=BE=84=E9=80=82=E7=94=A8=E6=80=A7=E6=80=BB?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮我新加的适用性总表本身带进两条错误,已用实测重写: - 按 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 同类断言一次改齐。 --- 参考-公共API.md | 2 +- 参考-配置键.md | 4 +++- 指南-Embedding.md | 2 +- 指南-多源与选源.md | 2 +- 指南-迁移既有项目.md | 2 +- 指南-遥测与成本.md | 6 ++++-- 解释-治理行为.md | 22 ++++++++++++++-------- 解释-降级与取消.md | 2 +- 8 files changed, 26 insertions(+), 16 deletions(-) diff --git a/参考-公共API.md b/参考-公共API.md index 6ec9c5a..587232e 100644 --- a/参考-公共API.md +++ b/参考-公共API.md @@ -58,7 +58,7 @@ |---|---| | `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` | -| `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` 声明是否剥离 `` 标签。新表必须经 `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` 声明是否剥离 `` 标签。**注册表只作用于 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` | 价格表(成本折算) | 版本号也走顶层导出:`polygateway.__version__`(当前 `1.0.5`),日志与 bug 报告可直接带上。异常层级见 [[参考-异常]];全部 env 键见 [[参考-配置键]]。 diff --git a/参考-配置键.md b/参考-配置键.md index 012c5c0..8e94e00 100644 --- a/参考-配置键.md +++ b/参考-配置键.md @@ -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}__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__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` | @@ -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`。 +> **`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}__…` 键。** ## 装配键 `PGW_*` diff --git a/指南-Embedding.md b/指南-Embedding.md index 03c13fc..d87cd09 100644 --- a/指南-Embedding.md +++ b/指南-Embedding.md @@ -2,7 +2,7 @@ 与 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 豁免)。两者都不报错、遥测也看不出来。 ## 配置 diff --git a/指南-多源与选源.md b/指南-多源与选源.md index b4f81f9..1360f38 100644 --- a/指南-多源与选源.md +++ b/指南-多源与选源.md @@ -27,7 +27,7 @@ LLM__QWEN__2__TIMEOUT_S=120 | `round_robin` | 轮询 | 想要严格均摊 | | `least_inflight` | 选在途最少 | 源性能差异大 | -健康视图由每次尝试的结果喂数(真实成功=好;瞬时失败/429/源死=坏;**坏结果不算坏服务**,不降权)。**喂数只发生在 chat 与 OCR 路径**——`EmbeddingClient` 不喂,所以 EMBED scope 的健康分恒为初值、`health_aware` 退化为 `least_inflight`(见 [[指南-Embedding]])。 +健康视图由每次尝试的结果喂数(真实成功=好;瞬时失败/429/源死=坏;**坏结果不算坏服务**,不降权)。**喂数只发生在 chat 与 OCR 路径**——`EmbeddingClient` 不喂,所以 EMBED scope 的健康分恒为初值,`health_aware` 因此只剩 P2C 随机 + 在途加权(**不等于 `least_inflight`**,详见 [[解释-治理行为]] 的适用性总表)。 ## 换源与冷却行为 diff --git a/指南-迁移既有项目.md b/指南-迁移既有项目.md index 906baf3..26f9211 100644 --- a/指南-迁移既有项目.md +++ b/指南-迁移既有项目.md @@ -17,7 +17,7 @@ ## 三条高频经验 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 场景的标准写法。 ## 范本 diff --git a/指南-遥测与成本.md b/指南-遥测与成本.md index 4d6dac7..415fa23 100644 --- a/指南-遥测与成本.md +++ b/指南-遥测与成本.md @@ -38,7 +38,7 @@ v1.0.4 新增的两列会**自动补到已存在的旧表上**,无需手工迁 | 值 | 含义 | 什么时候出现 | cost | |---|---|---|---| -| `measured` | 用量帧完整可信 | 正常路径;OCR 成功行(0 token 是事实,不是未知) | 按 token 换算 | +| `measured` | 用量帧完整可信 | 正常路径;OCR 成功行(0 token 是事实,不是未知) | 按 token 换算。**但 OCR 成功行 cost 恒 NULL**——`OcrClient` 不持有价格表(`PGW_PRICING_PATH` 对它不注入),且不告警 | | `estimated` | 有实测数字但可信度降级 | 打捞路径:收到 usage 帧但流被截断(`MISSING_DONE=salvage`) | 按 token 换算 | | `unavailable` | 用量信息不可得 | 上游没返回 usage 帧、失败的尝试、终态失败 | **NULL** | @@ -61,7 +61,9 @@ PGW_PRICING_PATH=config/prices.json > ```sql > SELECT model, COUNT(*) FROM llm_calls > 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,不会算出负数。 diff --git a/解释-治理行为.md b/解释-治理行为.md index f6ab26d..68bf2f1 100644 --- a/解释-治理行为.md +++ b/解释-治理行为.md @@ -6,16 +6,22 @@ **病灶**:轮询把 1/N 的流量持续喂给坏源。**机制**:每源维护成功率 EWMA 与在途数,选源时随机取两个候选比较(P2C),分高者当头名,其余按分数降序。选源器**没有阈值分支**——坏源仍保有 1/N²(两源池 25%、四源池约 6%)的首选概率,这是有意留的探索通道,好让它恢复后能被发现;只有在**同一次调用内连败 ≥2 次**且存在可信替代(健康分 ≥ 失败源一半)时才被降权到替代之后。真正把坏源隔离掉的是熔断,选源只负责压低它的吸流占比。其中「同一次调用内连败后降权重排」是 **chat 路径限定**——OCR 照常喂健康分(跨调用选源受益)但同调用内不重排,EMBED 两者皆无。 -## 机制 × 路径适用性 +## 机制 × 路径适用性(单一事实源) -| 机制 | chat | OCR | EMBED | -|---|---|---|---| -| 429 pushback(不耗重试预算、按 Retry-After 等待) | ✔ | ✘(照常计入 MAX_ATTEMPTS) | ✘(同左) | -| AIMD 自适应并发(`adaptive_paced`) | ✔ | ✘ | ✘ | -| 健康分喂数(跨调用降权/回流) | ✔ | ✔ | ✘(health_aware 退化为 least_inflight) | -| 同一次调用内连败降权重排 | ✔ | ✘ | ✘ | +本表逐函数对照 `retry.py` / `ocr.py` / `embedding.py` 三条治理循环得出;其他页涉及路径差异时请链回本表,不要各自复述。 -限流六道闸、熔断双通道、重试退避、遥测必录四项**三条路径一致**。 +| 机制 | 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 不受影响)。 + +限流六道闸、熔断双通道、退避公式本身、冷却备忘、遥测必录**三条路径一致**。 ## 熔断双通道 + 健康证据抑制 diff --git a/解释-降级与取消.md b/解释-降级与取消.md index d7b93e5..2c14ec8 100644 --- a/解释-降级与取消.md +++ b/解释-降级与取消.md @@ -22,7 +22,7 @@ | 限流等待 / 退避 sleep | 可被取消;已取得的 permit 在 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 清零在压测中是持续验证的不变量。