From f8f85fb6e866588a38a9b6e489788430c7afeb4b Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sun, 2 Aug 2026 00:41:49 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20=E4=BF=AE=E6=AD=A3=E7=AC=AC=E4=BA=94?= =?UTF-8?q?=E8=BD=AE=E5=AE=A1=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=84=205=20?= =?UTF-8?q?=E5=A4=84=E4=B8=8D=E4=B8=80=E8=87=B4,=E5=B9=B6=E5=8A=A0?= =?UTF-8?q?=E6=9C=BA=E5=88=B6=E9=80=82=E7=94=A8=E6=80=A7=E6=80=BB=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P3 的错误源头在源码注释(config.py:152-155),已一并修正: 两个 Redis 后端 自 v1.0.0 起在构造函数各自 lower,scope 大小写从不影响 key;真正会改 key 的是前后空白(后端只 lower 不 strip)。 P1 窗口相位随后端而异(memory 用 monotonic 非墙钟)、P2 价格表查不到 model 时 cost 静默 NULL 而缺口 SQL 查不到、P4 缓存侧无短路(2×QPS 条 warning)、 P5 AIMD 与调用内降权是 chat 独有。 按报告建议在 解释-治理行为 加「机制 × 路径适用性」总表,替代此前发现一处 补一处的分散补丁。 --- 参考-异常.md | 2 +- 参考-配置键.md | 6 ++++-- 指南-OCR.md | 2 ++ 指南-遥测与成本.md | 8 ++++++++ 指南-限流与熔断.md | 4 ++-- 解释-治理行为.md | 15 +++++++++++++-- 解释-降级与取消.md | 4 ++-- 7 files changed, 32 insertions(+), 9 deletions(-) diff --git a/参考-异常.md b/参考-异常.md index 5903dd2..f8c3098 100644 --- a/参考-异常.md +++ b/参考-异常.md @@ -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` 只由 AIMD 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` 上不可区分。**排障别急着加大 `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` 上不可区分。**排障别急着加大 `LLM_TIMEOUT`**(它同时是 total 层看门狗的预算),先看遥测 error 文本区分「流活性超时(kind)」与「超时: …」,该调的通常是 `TTFT_TIMEOUT_S` / `INTER_TOKEN_TIMEOUT_S` | ## 基建故障 diff --git a/参考-配置键.md b/参考-配置键.md index cf8ba33..012c5c0 100644 --- a/参考-配置键.md +++ b/参考-配置键.md @@ -17,7 +17,9 @@ | `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 自愈,滚动升级期间限流配额会短暂偏松。 +**一处静默改值(v1.0.2)**:手工构造传 `scope="LLM"` 或带前后空白的值时,scope 会被 **strip 并转小写**,两条装配路自此产出同一个值。 + +**大小写不影响 Redis key**——`RedisLimiter` / `RedisGate` 自 **v1.0.0** 起就在各自构造函数里对 scope 做 `.lower()`,key 恒为 `pgw:limit:llm:…` / `pgw:gate:llm:…`。所以升级到 ≥1.0.2 **没有 key 迁移、没有被遗弃的租约**,此前传大写 scope 的进程也一直与 `from_env` 进程共用同一命名空间。真正会改 key 的是 scope 里的**前后空白**:后端只 lower **不 strip**,`"llm "` 会产出 `pgw:limit:llm :…`;v1.0.2 起空白被 strip 掉,key 随之变化——若曾用带空白的 scope 跑过 Redis 后端,升级即等于换命名空间,旧键靠 TTL 自愈。 同批规范化:`redis_url` / `pricing_path` 的空串归 `None`(留着空串会骗过 `is None` 判断,把错误推迟成连接串解析异常或 `Is a directory: '.'`);Postgres DSN 剥掉 SQLAlchemy 驱动后缀(`postgresql+asyncpg://` 的 `+asyncpg` asyncpg 不认)。剥离时会发一条 warning——库动了调用方给的值不该静默;日志只出现 scheme 段,DSN 带密码故整串不入日志。 @@ -40,7 +42,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}__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}__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`,否则装配期报错(派生公式与校验下限是两回事) | diff --git a/指南-OCR.md b/指南-OCR.md index 6c3b3b6..95b8aca 100644 --- a/指南-OCR.md +++ b/指南-OCR.md @@ -44,3 +44,5 @@ await ocr.aclose() > **别忘了 scope 级韧性键**:`OCR__RETRY__MAX_ATTEMPTS/BACKOFF_BASE_S/BACKOFF_MAX_S` 与 `OCR__BREAKER__FAIL_THRESHOLD/COOLDOWN_S` 不配的话会回落到 `LLM_*` 平铺键(见 [[参考-配置键]])——OCR 的超时与重试特性和 LLM 差别很大,建议显式配全。 > **`/parse` 的第三类终态**:MonkeyOCR 返回 **HTTP 200 但 `success=false`** 时抛的是 `RequestRejectedError`(`status_code=200`),不是 `ResultInvalidError`——只写 `except ResultInvalidError` 会漏接。 + +> **与 chat 的治理差异**:OCR 循环没有 AIMD pacer(并发只受 `MAX_CONCURRENCY` 约束、`per_source_reasons` 不会出现 `adaptive_paced`),同一次调用内连败也不重排选源;429 照常计入 `MAX_ATTEMPTS`。健康分喂数则与 chat 一致。总表见 [[解释-治理行为]]。 diff --git a/指南-遥测与成本.md b/指南-遥测与成本.md index 36e7066..4d6dac7 100644 --- a/指南-遥测与成本.md +++ b/指南-遥测与成本.md @@ -56,6 +56,14 @@ PGW_PRICING_PATH=config/prices.json 配了价格表后每行遥测带 `cost`(元)。缓存命中行的 cost 恒为 `0.0`——它没产生新调用;但 `prompt_tokens` / `completion_tokens` / `cached_prompt_tokens` 是**原样回放的历史值,不是 0**,所以任何 token 汇总都必须带 `WHERE cache_hit = false`。缺价格表时 cost 恒 None,不报错。 +> **还有第三档容易漏**:价格表按 `.env` 里 `MODEL` 的**字面值**查(不是 `model_reported`),表里没有该 model 时记一条 warning(每个 model 只首次)后 cost 记 NULL,而这类行的 `usage_source` 仍是 `measured`、`cache_hit=false`——**下面那条按 `usage_source='unavailable'` 查缺口的 SQL 完全查不到它**。价格表漏写或写错一个模型名,该模型全部行的 cost 就静默为 NULL,`SUM(cost)` 系统性少算。对账请另跑: +> +> ```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; +> ``` + `cached_input_per_1m` 是**可选**的第三档(v1.0.4):供应商 prompt cache 命中的那部分输入按更低单价计费。配了它,cost 就按 `(prompt - cached) × input + cached × cached_input` 分段算;**不配就退化为全额输入价**——库不会替你猜一个折扣率,所以不配时 cost 会比实际账单偏高。命中数若超过输入总数(网关口径异常),按总数夹取并记一条 warning,不会算出负数。 **cost 的口径**:产生了真实调用、但用量不可得的行 `cost` 为 NULL——库不会编一个数字,免得"免费"与"未知"在数据上混为一谈。缓存命中行**不在此列**:它没产生新调用,`0.0` 是事实,所以即便 `usage_source='unavailable'`,cost 仍是 `0.0`。 diff --git a/指南-限流与熔断.md b/指南-限流与熔断.md index 01ac9f3..582b077 100644 --- a/指南-限流与熔断.md +++ b/指南-限流与熔断.md @@ -4,9 +4,9 @@ 并发 / RPM / TPM × 单源 / 全局,共六道,全过才放行;拒绝零副作用(不部分计数)。0 或缺省 = 该闸不启用。 -> **RPM/TPM 是分钟固定窗口**(按 `int(now/60)` 分桶,整分钟到点归零),不是滑动窗口。照抄供应商的滑动窗口配额会在**整分钟交界处出现约 2 倍瞬时速率**(上一分钟末尾打满 + 新分钟开头再打满),建议配成配额的一半左右。 +> **RPM/TPM 是分钟固定窗口**(按 `int(now/60)` 分桶,满 60 秒翻页归零),不是滑动窗口。**窗口相位随后端而异**:`redis` 后端取 Redis 服务器 `TIME` 的 epoch 秒,边界对齐墙钟 `:00`;`memory` 后端(单进程默认)用 `time.monotonic`,原点是**开机时刻**、与墙钟无关——单进程部署**不要**按墙钟整分编排批量投递或断言计数清零。照抄供应商的滑动窗口配额会在**窗口交界处出现约 2 倍瞬时速率**(上一分钟末尾打满 + 新分钟开头再打满),建议配成配额的一半左右。 > -> **全局 TPM 的预扣量取自源级**:入场预扣是每个源的 `EST_TOKENS`(或由源 `TPM // 60` 派生)。只配 `LLM__GLOBAL__TPM` 而源上既无 `TPM` 也无 `EST_TOKENS` 时预扣恒为 0——闸失去的是**预留**能力(一批并发会被同时放行、可远超上限),但**它仍是准入闸**:结算把真实 token 补记进全局分钟窗口,累计超上限后入场即被拒,直到窗口翻转才恢复。即**超额一次才刹车,不是完全失效**;`quota_full=wait` 下表现为轮询等待到整分钟翻页(像"请求集体挂住、日志无错")。要按预期节流请给源配 `TPM` 或 `EST_TOKENS`。 +> **全局 TPM 的预扣量取自源级**:入场预扣是每个源的 `EST_TOKENS`(或由源 `TPM // 60` 派生)。只配 `LLM__GLOBAL__TPM` 而源上既无 `TPM` 也无 `EST_TOKENS` 时预扣恒为 0——闸失去的是**预留**能力(一批并发会被同时放行、可远超上限),但**它仍是准入闸**:结算把真实 token 补记进全局分钟窗口,累计超上限后入场即被拒,直到窗口翻转才恢复。即**超额一次才刹车,不是完全失效**;`quota_full=wait` 下表现为轮询等待到窗口翻页(像"请求集体挂住、日志无错")。要按预期节流请给源配 `TPM` 或 `EST_TOKENS`。 ```bash LLM__QWEN__1__MAX_CONCURRENCY=8 # 单源并发 diff --git a/解释-治理行为.md b/解释-治理行为.md index 6d03cd1..f6ab26d 100644 --- a/解释-治理行为.md +++ b/解释-治理行为.md @@ -4,7 +4,18 @@ ## 健康感知选源(缺省) -**病灶**:轮询把 1/N 的流量持续喂给坏源。**机制**:每源维护成功率 EWMA 与在途数,选源时随机取两个候选比较(P2C),分高者当头名,其余按分数降序。选源器**没有阈值分支**——坏源仍保有 1/N²(两源池 25%、四源池约 6%)的首选概率,这是有意留的探索通道,好让它恢复后能被发现;只有在**同一次调用内连败 ≥2 次**且存在可信替代(健康分 ≥ 失败源一半)时才被降权到替代之后。真正把坏源隔离掉的是熔断,选源只负责压低它的吸流占比。 +**病灶**:轮询把 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) | +| 同一次调用内连败降权重排 | ✔ | ✘ | ✘ | + +限流六道闸、熔断双通道、重试退避、遥测必录四项**三条路径一致**。 ## 熔断双通道 + 健康证据抑制 @@ -16,7 +27,7 @@ ## AIMD 自适应并发 -**病灶**:冷启动瞬间全并发涌向单源,触发链式 429。**机制**:每源并发从 8 起步,成功缓升(+1/limit)、429 减半,封顶 max(64, 配置并发)。TCP 拥塞控制同款,库常量非配置项。 +**病灶**:冷启动瞬间全并发涌向单源,触发链式 429。**机制**:每源并发从 8 起步,成功缓升(+1/limit)、429 减半,封顶 max(64, 配置并发)。TCP 拥塞控制同款,库常量非配置项。**仅 chat 路径**:`OcrClient` / `EmbeddingClient` 的治理循环没有 pacer,并发只受 `MAX_CONCURRENCY` 闸约束,它们的 `per_source_reasons` 里**永远不会出现 `adaptive_paced`**。 ## 半开单探针 + 租约 + epoch fencing diff --git a/解释-降级与取消.md b/解释-降级与取消.md index e83d4d0..d7b93e5 100644 --- a/解释-降级与取消.md +++ b/解释-降级与取消.md @@ -4,11 +4,11 @@ | 后端 | 掉线时 | 为什么 | |---|---|---| -| 缓存 / 遥测 | **静默降级**(warning 一次,业务零感知) | 它们是增值件;为了省钱/观测把业务打挂,本末倒置 | +| 缓存 / 遥测 | **静默降级**(记 warning,业务零感知) | 它们是增值件;为了省钱/观测把业务打挂,本末倒置 | | 限流 / 熔断(**准入侧**) | **报错(`GovernanceBackendError`),绝不放行** | 它们是保护件;"后端坏了就裸放"等于高峰期无限流打爆上游——恰好是最需要保护的时刻 | | 限流 / 熔断(**记账写回侧**) | warning 降级不冒泡 | 调用已真实发出,不能因为写回失败就丢掉已拿到的响应、或掩盖原始尝试异常 | -这条不对称是库铁律,所有后端实现必须遵守。遥测的静默降级还有细分:结构性失败(连不上)warning 一次后永久短路;单行写失败只丢那一行,不污染后续。 +这条不对称是库铁律,所有后端实现必须遵守。两侧的降级形态不同:**遥测**的结构性失败(连不上)warning 一次后**永久短路**,单行写失败只丢那一行;**缓存侧没有任何短路**——Redis 掉线期间每次调用都打两条 warning(读一条「缓存读取失败,降级为未命中」、写一条「缓存写入失败,跳过缓存」),即约 **2 × QPS 条/秒**直到恢复。按「只有一条」做日志容量规划会低估数量级,把它直接接告警会被风暴淹没。 **fail-closed 只在准入侧**(`source_stats` / `try_acquire` / `try_enter`)——闸没问上就绝不放行。记账写回(`record_success` / `record_failure` / `release_probe` / `mark_progress`、permit 的 settle/release)失败只记 warning。代价是这期间并发租约靠 TTL 回收、TPM 差额不结算,限额短期漂移;Redis 抖动时请盯 warning 日志而非只盯异常率。