diff --git a/参考-公共API.md b/参考-公共API.md index 7c7d21a..c9a24ad 100644 --- a/参考-公共API.md +++ b/参考-公共API.md @@ -9,8 +9,8 @@ | `from_env` | `(scope="LLM", *, limiter=None, breaker=None, cache=None, telemetry=None, registry=None, env=None) -> GatewayClient` | 从 .env/环境变量装配;关键字参数可注入自定义后端(测试/共享状态) | | `from_settings` | `(settings: GatewaySettings, ...) -> GatewayClient` | 从已解析配置装配 | | `chat` | `(messages, *, session_id=None, parent_call_id=None, cache_salt=None, cache_namespace=None, structured=None, stream=True, overlay=None) -> LLMResponse` | 一次治理调用;messages 为 OpenAI 形态(原生支持多模态 content 数组);`structured` 传 pydantic 模型类或 `"json"`;`overlay` 传采样参数(v1.0.5新增,见 [[指南-采样参数]]) | -| `aclose` | `() -> None` | 幂等释放连接与后端资源 | -| `gather_bounded`(模块级函数) | `(coros, limit) -> list` | 有界并发跑一批协程,顶层导出;用它替代裸 `asyncio.gather`,避免一次性把几百个请求压进治理栈 | +| `aclose` | `() -> None`(亦支持 `async with`) | 幂等释放连接与后端资源。三个 client 均实现 `__aenter__`/`__aexit__`,推荐 `async with GatewayClient.from_env("LLM") as client:` 让退出时自动 aclose | +| `gather_bounded`(模块级函数) | `(aws: Iterable[Awaitable[T]], *, concurrency: int) -> list[T]` | 有界并发跑一批协程,顶层导出。`concurrency` 是 **keyword-only**,位置传参会 `TypeError`;`< 1` 抛 `ValueError`。语义同 `asyncio.gather`(结果保序、首个异常上抛),只多一道并发上限。用法 `await gather_bounded((client.chat(m) for m in batch), concurrency=8)` | ## LLMResponse(frozen dataclass) @@ -35,7 +35,7 @@ |---|---| | `from_env` | `(scope="EMBED", ..., env=None) -> EmbeddingClient` | | `embed` | `(texts: list[str], *, session_id=None, parent_call_id=None) -> EmbeddingResponse` | -| `aclose` | `() -> None` | +| `aclose` | `() -> None`(亦支持 `async with`) | `EmbeddingResponse`:vectors(等长保序)/ dim / model / provider / prompt_tokens / usage_source / latency_ms / call_id / source_name / cost。 @@ -47,7 +47,7 @@ | `recognize_text` | `(image: bytes, *, session_id=None, parent_call_id=None) -> OcrTextResult` | | `parse_layout` | `(image: bytes, *, session_id=None, parent_call_id=None) -> OcrLayoutResult` | | `check_health` | `() -> dict[str, bool]`(逐源并发预检) | -| `aclose` | `() -> None` | +| `aclose` | `() -> None`(亦支持 `async with`) | `OcrLayoutElement`:type(开放字符串)/ bbox(x1,y1,x2,y2 页面坐标)/ page_index。 diff --git a/参考-异常.md b/参考-异常.md index 08dd00a..3fa6a7a 100644 --- a/参考-异常.md +++ b/参考-异常.md @@ -6,11 +6,21 @@ | 异常 | 触发 | 库内治理行为 | |---|---|---| -| `TransientError` | 超时 / 5xx / 网络抖动 / SSE 截断 / 429 | 换源重试 + 退避(429 走 pushback,不耗预算) | +| `TransientError` | 超时 / 5xx / 网络抖动 / SSE 截断 / 429 / **空补全**(200 且流程完整但 content 空白)/ 响应体非法 JSON 或缺 choices | 换源重试 + 退避。429 pushback(按 Retry-After 等待、不耗重试预算)**只在 chat 路径**;`EmbeddingClient`/`OcrClient` 的 429 照常计入 `MAX_ATTEMPTS` | | `SourceDeadError` | 401 / 403 / 429+insufficient_quota(欠费) | 立即熔断该源 + 换源 | -| `RequestRejectedError` | 400 / 内容拒绝 / 本地格式拒绝 | 不重试不换源,直接抛给业务 | +| `RequestRejectedError` | **400 及其余未特判的非 200 状态码**(402/404/408/409/422…) / 内容拒绝 / 本地格式拒绝 | 不重试不换源,直接抛给业务。base_url 配错导致的 404、上游用 402 表达欠费都走这里立即终态,`exc.status_code` 携原状态码 | | `ResultInvalidError` | 调用成功但结果不合格(坏 JSON / 维度不符 / 退化 bbox) | **不熔断**;结构化场景先有界重问,仍失败才抛 | +### `ResultInvalidError` 的诊断属性 + +| 属性 | 含义 | +|---|---| +| `raw_text` | 模型原始输出(结构化阶梯耗尽时是最后一次响应正文) | +| `repair_error` | JSON 修复失败的原因 | +| `validation_errors` | tuple,pydantic 校验错误 | + +结构化阶梯耗尽这条路径上 `source_name` / `status_code` 为 `None`(失败不归因于某个源)。 + ## scope 级不可用(重试预算走完后) `GatewayUnavailableError` 及子类 `CircuitOpenError`(整圈门全拒)/ `AllSourcesExhausted`(预算耗尽): @@ -26,6 +36,10 @@ `GovernanceBackendError`:限流/熔断后端(Redis)不可用。**故意冒泡**——降级方向铁律:治理后端坏了宁可拒绝也不放行裸打上游。缓存/遥测后端故障不会以异常出现(静默降级)。 +**冒泡只覆盖准入侧**(`source_stats` / `try_acquire` / `try_enter` / `retry_after_s`)——闸没问上就绝不放行。调用已真实发出后的**记账写回**(`record_success` / `record_failure` / `mark_progress` / `release_probe`、permit 的 settle/release)遇到后端故障是 **warning 降级不冒泡**:不能因为写回失败就丢掉已经拿到的响应,或掩盖原始的尝试异常。代价是这期间并发租约靠 TTL 回收、TPM 差额不结算,限额短期漂移——Redis 抖动时请盯 warning 日志,而不是只盯异常率。 + ## 业务侧建议写法 -捕 `GatewayUnavailableError` 做延期重投,捕 `RequestRejectedError`/`ResultInvalidError` 做确定性失败处理,其余让它冒——`TransientError` 能穿出来的场景只有"单次尝试就是全部预算"的配置。 +捕 `GatewayUnavailableError` 做延期重投,捕 `RequestRejectedError`/`ResultInvalidError` 做确定性失败处理。 + +**`TransientError` 不会穿出 `chat()` / `embed()` / OCR**——无论 `MAX_ATTEMPTS` 配多少(哪怕 1),它一律被包成 `AllSourcesExhausted(reason='retry_exhausted')`,原异常留在 `exc.__cause__`。所以 `except TransientError:` 的兜底分支永远不会命中;要拿单次尝试的细节请读 `exc.__cause__` 与 `exc.per_source_reasons`。 diff --git a/参考-配置键.md b/参考-配置键.md index 7c8a131..63e1189 100644 --- a/参考-配置键.md +++ b/参考-配置键.md @@ -49,6 +49,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 项目注意**:这批键的 `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/指南-OCR.md b/指南-OCR.md index 3b417fc..0655d24 100644 --- a/指南-OCR.md +++ b/指南-OCR.md @@ -8,7 +8,7 @@ MonkeyOCR 两端点走完整治理栈(多源/重试/熔断/遥测);OCR 无 token OCR__MONKEY__1__BASE_URL=http://10.77.0.20:7866 OCR__MONKEY__1__API_KEY=none # 服务无鉴权,占位惯例 OCR__MONKEY__1__MODEL=monkey-ocr -OCR__MONKEY__1__TIMEOUT_S=300 # /parse 两段协议较慢,给足 +OCR__MONKEY__1__TIMEOUT_S=300 # /parse 两段协议较慢,给足;不配会静默继承 LLM_TIMEOUT OCR__MONKEY__1__MAX_CONCURRENCY=4 OCR__MONKEY__2__BASE_URL=http://10.77.0.20:7867 # 第二实例即多源 OCR__MONKEY__2__API_KEY=none @@ -40,3 +40,5 @@ await ocr.aclose() - bbox 是 OCR 原生页面坐标 `(x1,y1,x2,y2)`;裁剪偏移/坐标映射/取首表这类几何逻辑留业务侧(库零业务假设); - 图内容确定性不可解析(退化 bbox 等)抛 `ResultInvalidError`——不熔断、消耗业务失败预算;服务本身的故障(超时/连接拒绝)才是 Transient/换源; - CHSAnalyzer 的 `table_locator` 与两项目迁移写法见主仓库 `research-wiki/migrations/`。 + +> **别忘了 scope 级韧性键**:`OCR__RETRY__MAX_ATTEMPTS/BACKOFF_BASE_S/BACKOFF_MAX_S` 与 `OCR__BREAKER__FAIL_THRESHOLD/COOLDOWN_S` 不配的话会回落到 `LLM_*` 平铺键(见 [[参考-配置键]])——OCR 的超时与重试特性和 LLM 差别很大,建议显式配全。 diff --git a/指南-响应缓存.md b/指南-响应缓存.md index 6665e7c..fc9863f 100644 --- a/指南-响应缓存.md +++ b/指南-响应缓存.md @@ -33,6 +33,12 @@ resp = await client.chat(messages, cache_namespace=tenant_id) # 多租户: 科研场景注意:做"同输入重复采样"类实验(信度/方差)时必须用 `cache_salt` 按轮次隔离,否则第二轮起全是缓存命中。 +## 与结构化输出的交互 + +- `structured_data` **不进缓存存储**——命中时按**本次调用的 schema** 现场重跑 parse + 校验; +- **schema 不进缓存 key**。改了 pydantic 模型后,旧缓存会被自动重校验,校验不过即按未命中回源(伴一条 warning「缓存命中重建失败」)——**无需手工清 Redis 或换 salt**; +- 未装配 structured strategy 时,structured 调用的命中会被静默降级为回源。 + ## 降级方向 Redis 掉线 → 静默当作全部 miss(warning 日志),调用照常走真实上游;损坏的缓存条目反序列化失败也按 miss 处理。缓存永远不会成为可用性瓶颈。 diff --git a/指南-多源与选源.md b/指南-多源与选源.md index 0bd4899..abf5a2c 100644 --- a/指南-多源与选源.md +++ b/指南-多源与选源.md @@ -31,7 +31,13 @@ LLM__QWEN__2__TIMEOUT_S=120 ## 换源与冷却行为 -一次调用的重试循环里,每次尝试都重新选源;某源被熔断/限流拒绝后进入本地冷却备忘,冷却期内不再消耗它的配额。重试预算是**调用级跨源累计**的(`LLM_MAX_RETRIES` 含首次),不是每源各自一份。 +一次调用的重试循环里,每次尝试都重新选源。**两种拒绝的后果不同**: + +- 被**熔断开路**拒绝 → 写本地冷却备忘,冷却期内直接跳过(`per_source_reasons` 记 `cooldown`),不再白烧它的 RPM 去探测; +- 被**限流闸**拒绝 → **不写冷却**(记 `rate_limited`),拒绝本身零副作用不消耗配额,下一轮照常参与选源并重试 `try_acquire`; +- 上游返回 429 → 走 AIMD 并发削减(记 `adaptive_paced`),也不是冷却。 + +重试预算是**调用级跨源累计**的(`LLM_MAX_RETRIES` 含首次),不是每源各自一份。 ## 多逻辑角色(SCOPE) @@ -43,3 +49,5 @@ judge = GatewayClient.from_env("JUDGE") # JUDGE__*__* 键 ``` 各角色的源、限额、韧性参数、熔断状态全部独立;要共享治理状态(如两个角色合用一个全局并发闸)时,把同一个 limiter/breaker 实例经构造函数注入两个 client。 + +**limiter 有个坑**:它是**按源名注册**的,必须手工构造一个 `sources` 含两个 scope **全部源名并集**的实例再注入两边——直接把 A 角色 client 的 limiter 拿给 B 角色用,会在 **B 的第一次调用**就 `GovernanceBackendError: 未知源 'xxx'` 全线失败(限流 fail-closed,不降级放行),而且构造期不报错。另外构造时的 `scope` 字符串决定 Redis key 命名空间,共享实例只有一个命名空间。breaker 无此约束(按源名懒建状态),可直接共享。 diff --git a/指南-结构化输出.md b/指南-结构化输出.md index 896154f..1a5ca27 100644 --- a/指南-结构化输出.md +++ b/指南-结构化输出.md @@ -13,13 +13,15 @@ class Verdict(BaseModel): resp = await client.chat(messages, structured=Verdict) verdict = resp.structured_data # 已校验的 Verdict 实例 -raw = await client.chat(messages, structured="json") # 只要合法 JSON,不校验模型 +raw = await client.chat(messages, structured="json") # 只要合法 JSON,不校验模型(单次即败,无重问兜底) ``` 需要安装 `polygateway[structured]`(json-repair);未装时传 `structured=` 会显式报错。 ## 阶梯行为 +以下三步阶梯**只适用于传 pydantic 模型这一档**。`structured="json"` 只做第 1 步修复:调一次上游、json_repair 解析,失败即抛 `ResultInvalidError`——不重问,`PGW_STRUCTURED_MAX_RETRIES` 对它不起作用,因此也不会产生额外调用与额外遥测行。 + 1. **修复**:LLM 返回的文本先过 json_repair(补引号/去尾逗号/剥 markdown 围栏); 2. **校验**:按传入的 pydantic 模型验证; 3. **有界带反馈重问**:仍失败则把校验错误喂回模型重问,最多 `PGW_STRUCTURED_MAX_RETRIES` 次(缺省 2;设 0 = 不重问直接抛)。 diff --git a/指南-迁移既有项目.md b/指南-迁移既有项目.md index 0ff3a34..906baf3 100644 --- a/指南-迁移既有项目.md +++ b/指南-迁移既有项目.md @@ -17,7 +17,7 @@ ## 三条高频经验 1. **业务端口保留,shim 转换**:项目自己的 `LLMProvider`/`VlmProvider` 协议不用动,写 10-30 行 shim 把库返回值映射回去,业务调用点零改动。`LLMResponse` 前 11 字段与旧三项目逐字保序,多数场景直接 re-export 即可。 -2. **步级重试要显式接线**:如果项目在治理层之外还有任务级重试(捕 `TimeoutError/OSError` 一类),库异常不是它们的子类——必须显式把 `(TransientError, AllSourcesExhausted)` 注入进去,否则那层重试**静默失效**。 +2. **步级重试要显式接线**:如果项目在治理层之外还有任务级重试(捕 `TimeoutError/OSError` 一类),库异常不是它们的子类——必须显式把 `(TransientError, GatewayUnavailableError)` 注入进去,否则那层重试**静默失效**。用父类 `GatewayUnavailableError` 而非单列 `AllSourcesExhausted`:它同时覆盖 `AllSourcesExhausted`(预算耗尽)与 `CircuitOpenError`(源被熔断),后者是单源场景下一次 401/403 之后的常态路径,漏了就等于没接。 3. **任务队列消费 `GatewayUnavailableError`**:scope 级不可用时按 `exc.retry_after_s` 延期重投、不消耗业务失败预算,是 arq/celery 场景的标准写法。 ## 范本 diff --git a/指南-遥测与成本.md b/指南-遥测与成本.md index a2ac792..2d42ed5 100644 --- a/指南-遥测与成本.md +++ b/指南-遥测与成本.md @@ -2,6 +2,8 @@ **每次调用必录**——成功、失败、缓存命中都写一行,这是库铁律。埋点收敛在库内单一 helper,业务侧零埋点代码。 +注意**一行 = 一次尝试,不是一次 `chat()`**:重试/换源时每次尝试各写一行,scope 级失败或取消再多一行溯源字段置空的终态行。库不会自动把这些行串起来——要按业务调用聚合,必须自己每次 `chat()` 传 `session_id` / `parent_call_id`。 + ## 启用 ```bash @@ -21,7 +23,7 @@ PGW_TELEMETRY_SQLITE_PATH=logs/telemetry.db # sqlite 时必填 | 内容 | messages / response / thinking(多模态 part 摘要落库,不存原图) | | 用量 | prompt_tokens / completion_tokens / usage_source(三态,见下) | | 时延 | latency_ms / ttft_ms / max_inter_token_ms | -| 结果 | cache_hit / error(异常类名前缀,如 `TransientError: ...`)/ cost | +| 结果 | cache_hit / error / cost。**error 的格式两条路径不同**:chat 与 embedding 记异常消息原文、**无类名前缀**(如 `s1 瞬时错误: 500`、`llm 网关暂时不可用: retry_exhausted`);仅 **OCR** 带类名前缀(`TransientError: ...`)。按四分类聚合请勿依赖 error 前缀 | | 可观测(v1.0.4) | cached_prompt_tokens / model_reported(见下) | | 复现(v1.0.5) | sampling —— 本次调用的采样参数(见下) | | 落库时刻 | created_at(库自动填,不由调用方传;做时间窗聚合直接用它) | @@ -52,7 +54,7 @@ PGW_PRICING_PATH=config/prices.json {"MiniMax-M3": {"input_per_1m": 2.1, "output_per_1m": 8.4, "cached_input_per_1m": 0.42}} ``` -配了价格表后每行遥测带 `cost`(元);缓存命中 token=0 天然零成本。缺价格表时 cost 恒 None,不报错。 +配了价格表后每行遥测带 `cost`(元)。缓存命中行的 cost 恒为 `0.0`——它没产生新调用;但 `prompt_tokens` / `completion_tokens` / `cached_prompt_tokens` 是**原样回放的历史值,不是 0**,所以任何 token 汇总都必须带 `WHERE cache_hit = false`。缺价格表时 cost 恒 None,不报错。 `cached_input_per_1m` 是**可选**的第三档(v1.0.4):供应商 prompt cache 命中的那部分输入按更低单价计费。配了它,cost 就按 `(prompt - cached) × input + cached × cached_input` 分段算;**不配就退化为全额输入价**——库不会替你猜一个折扣率,所以不配时 cost 会比实际账单偏高。命中数若超过输入总数(网关口径异常),按总数夹取并记一条 warning,不会算出负数。 @@ -91,7 +93,7 @@ FROM llm_calls WHERE cache_hit = false AND cached_prompt_tokens IS NOT NULL; ``` -原因和上面 cost 缺口的口径一样:缓存命中行里这两个字段是**原样回放**的历史值(与 `model`、`prompt_tokens` 同一规则——命中时只有时延类字段被清零),计进去就是重复计数。 +原因和上面 cost 缺口的口径一样:缓存命中行里这两个字段是**原样回放**的历史值,计进去就是重复计数。命中行被覆写的只有:**换新 `call_id`**(主键幂等要求,与被复用的原始行没有任何关联,不能用于溯源 join)、`cache_hit=true`、时延三件套清零、`cost` 重算为 `0.0`;其余字段(`model` / `model_reported` / `prompt_tokens` / `cached_prompt_tokens` 等)全是回放值。 `model_reported` 是 API 响应体里实际返回的 model,和 `.env` 里配的别名可能不是一个东西——供应商把别名指向新权重时,只有它认得出当时真正跑的版本。要做可复现的实验快照,记这一列。 diff --git a/指南-限流与熔断.md b/指南-限流与熔断.md index 5f67368..ea053bb 100644 --- a/指南-限流与熔断.md +++ b/指南-限流与熔断.md @@ -22,15 +22,17 @@ LLM__GLOBAL__RPM=120 | `memory` | 单进程内计数。**多进程 worker 下限额是每进程各一份**,会超配 | | `redis` | 跨进程原子(Lua 脚本),多 worker 共享同一份限额与熔断状态;需 `REDIS_URL` | -**降级方向铁律**:Redis 掉线时限流/熔断**报错而不放行**(`GovernanceBackendError`)——宁可拒绝也不击穿上游。缓存/遥测则相反(静默降级)。 +**降级方向铁律**:Redis 掉线时限流/熔断**报错而不放行**(`GovernanceBackendError`)——宁可拒绝也不击穿上游。缓存/遥测则相反(静默降级)。注意这条 fail-closed **只覆盖准入侧**(问闸);调用已发出后的记账写回失败是 warning 降级不冒泡,故障期租约靠 TTL 回收、TPM 差额不结算,详见 [[解释-降级与取消]]。 ## 熔断:双通道 + 半开单探针 | 通道 | 触发 | 键 | |---|---|---| -| 连续失败 | 连续失败 ≥ 阈值(有效值 = max(配置, 源并发×2),防并发误熔) | `LLM_CIRCUIT_BREAKER_THRESHOLD` | +| 连续失败 | 连续失败 ≥ 阈值。有效值 = max(配置值, **本 scope 最大源并发** × 2),**scope 级单份**、对该 scope 每个源同样生效(防并发误熔) | `LLM_CIRCUIT_BREAKER_THRESHOLD` | | 失败率窗口 | 窗口样本 ≥ MIN_CALLS 且失败率 ≥ FAIL_RATE(429 不计入) | `LLM__BREAKER__MIN_CALLS/FAIL_RATE/WINDOW_S` | +混编大小源时,低并发的备用源也吃这个被主源抬高的阈值——想让备用源早点熔断,需另开 scope 或调低主源并发。 + 开路后冷却 `COOLDOWN` 秒,重复开路指数递增、封顶 `MAX_COOLDOWN_S`;冷却结束进入半开,**只放一个探针**(带租约,持有者崩溃后租约过期自动可再探);探针成功即闭合。写回带 epoch fencing,迟到结果不会污染新状态。 401/403/欠费类失败(SourceDead)一击即熔,不走计数。 diff --git a/教程-十分钟接入.md b/教程-十分钟接入.md index db52828..1effea5 100644 --- a/教程-十分钟接入.md +++ b/教程-十分钟接入.md @@ -47,7 +47,7 @@ async def main() -> None: print(resp.content) print(f"源={resp.source_name} 耗时={resp.latency_ms}ms tokens={resp.prompt_tokens}+{resp.completion_tokens}") finally: - await client.aclose() + await client.aclose() # 或用 async with GatewayClient.from_env("LLM") as client: asyncio.run(main()) ``` @@ -63,7 +63,7 @@ PGW_TELEMETRY_BACKEND=sqlite PGW_TELEMETRY_SQLITE_PATH=logs/telemetry.db ``` -重跑后 `sqlite3 logs/telemetry.db 'SELECT call_id, model, latency_ms, error FROM llm_calls'` 能看到每次调用一行(含失败与缓存命中)。 +重跑后 `sqlite3 logs/telemetry.db 'SELECT call_id, model, latency_ms, error FROM llm_calls'` 能看到每次**尝试**一行(含失败与缓存命中)——重试/换源时一次 `chat()` 会产生多行,要聚合请传 `session_id`。 ## 5. 业务侧只需要认两个异常 diff --git a/解释-架构.md b/解释-架构.md index c570beb..a343544 100644 --- a/解释-架构.md +++ b/解释-架构.md @@ -9,14 +9,14 @@ ```mermaid graph LR A[业务代码] --> B[GatewayClient] - B --> C[缓存 MW] --> D[遥测 MW] --> E[重试·选源·限流·熔断 MW] + B --> C[遥测 MW] --> D[缓存 MW] --> S[结构化 MW] --> E[重试·选源·限流·熔断 MW] E --> F[Transport httpx] F --> G[(上游网关)] E -.端口.-> H[(内存 / Redis 后端)] - D -.端口.-> I[(SQLite / Postgres)] + C -.端口.-> I[(SQLite / Postgres)] ``` -层序理由:缓存最外(命中则里面全免);遥测其次(缓存命中也要记);重试在最内包 transport(每次尝试都重新过选源/限流/熔断——多源语义的前提)。 +层序理由:**遥测最外**——缓存命中、scope 级失败、取消这些尝试层根本看不见的事件也必须留痕;缓存其次(命中则里面全免);结构化在缓存之内——带反馈重问经内层重试逐次照过限流/熔断并逐次遥测,而缓存只固化阶梯通过的最终响应;重试在最内包 transport(每次尝试都重新过选源/限流/熔断——多源语义的前提)。 ## 模块与依赖纪律 diff --git a/解释-治理行为.md b/解释-治理行为.md index 0d1f9f5..6d03cd1 100644 --- a/解释-治理行为.md +++ b/解释-治理行为.md @@ -4,7 +4,7 @@ ## 健康感知选源(缺省) -**病灶**:轮询把 1/N 的流量持续喂给坏源。**机制**:每源维护成功率 EWMA 与在途数,选源时随机取两个候选比较(P2C),健康分低者降权;低于门槛的源仅在无更健康候选时才被选中。坏源吸流占比被压到个位数,恢复后自动回流,无需人工摘除。 +**病灶**:轮询把 1/N 的流量持续喂给坏源。**机制**:每源维护成功率 EWMA 与在途数,选源时随机取两个候选比较(P2C),分高者当头名,其余按分数降序。选源器**没有阈值分支**——坏源仍保有 1/N²(两源池 25%、四源池约 6%)的首选概率,这是有意留的探索通道,好让它恢复后能被发现;只有在**同一次调用内连败 ≥2 次**且存在可信替代(健康分 ≥ 失败源一半)时才被降权到替代之后。真正把坏源隔离掉的是熔断,选源只负责压低它的吸流占比。 ## 熔断双通道 + 健康证据抑制 diff --git a/解释-错误四分类.md b/解释-错误四分类.md index 8dfa023..1b0a0e5 100644 --- a/解释-错误四分类.md +++ b/解释-错误四分类.md @@ -17,9 +17,10 @@ ## 几个边界裁决(容易搞错的) -- **429 归 Transient 但特殊**:它是上游的"慢点"信号(pushback),不消耗重试预算、不计入熔断失败率——否则高峰期会把健康源全熔掉;真正的兜底是调用级 stall 判死。 +- **429 归 Transient 但特殊**:它是上游的"慢点"信号(pushback),不计入熔断失败率(三条路径通用)——否则高峰期会把健康源全熔掉。但"**不消耗重试预算、按 Retry-After 等待、靠调用级 stall 判死**"这套**只在 chat 路径生效**;`EmbeddingClient` / `OcrClient` 的治理循环对 429 照常 `fails += 1` 计入 `MAX_ATTEMPTS`,耗尽后的失败原因是 `retry_exhausted` 而非 `stalled`。 - **429 + insufficient_quota 归 SourceDead**:欠费不是限流,等多久都没用。 - **HTTP 响应本身证明服务活着**:即使是业务层面的失败响应(如 OCR 返回 success=false),熔断记账也算成功——熔断度量的是"服务是否可达",不是"结果是否满意"。 -- **SSE 截断(收到内容但缺 [DONE])归 Transient** 且不写缓存——把半截响应当成功缓存住是前身项目的真实事故。 +- **SSE 截断(收到内容但缺 [DONE])归 Transient** 且不写缓存——把半截响应当成功缓存住是前身项目的真实事故。**这条只在默认 `MISSING_DONE=retry` 下成立**:配成 `salvage` 时,有内容的截断会被打捞成正常响应返回(`usage_source=estimated`)**并照常写入缓存**,整个 TTL 内被复用——正是这句声称已防住的那起事故。零内容断流(early_eof)无论怎么配都是 Transient。 +- **空补全(200、流程完整但 content 空白)归 Transient**:按服务抖动处理,退避重试/换源,绝不缓存。库不会返回 `content=''` 的成功响应;模型合法返回空串的场景需业务侧改 prompt。 scope 级"无源可用"是另一层:四分类描述单次尝试,`GatewayUnavailableError` 族描述整个 scope 的暂时不可用(带 retry_after_s 供任务队列延期)。 diff --git a/解释-降级与取消.md b/解释-降级与取消.md index 5cd61c2..e83d4d0 100644 --- a/解释-降级与取消.md +++ b/解释-降级与取消.md @@ -5,10 +5,13 @@ | 后端 | 掉线时 | 为什么 | |---|---|---| | 缓存 / 遥测 | **静默降级**(warning 一次,业务零感知) | 它们是增值件;为了省钱/观测把业务打挂,本末倒置 | -| 限流 / 熔断 | **报错(`GovernanceBackendError`),绝不放行** | 它们是保护件;"后端坏了就裸放"等于高峰期无限流打爆上游——恰好是最需要保护的时刻 | +| 限流 / 熔断(**准入侧**) | **报错(`GovernanceBackendError`),绝不放行** | 它们是保护件;"后端坏了就裸放"等于高峰期无限流打爆上游——恰好是最需要保护的时刻 | +| 限流 / 熔断(**记账写回侧**) | warning 降级不冒泡 | 调用已真实发出,不能因为写回失败就丢掉已拿到的响应、或掩盖原始尝试异常 | 这条不对称是库铁律,所有后端实现必须遵守。遥测的静默降级还有细分:结构性失败(连不上)warning 一次后永久短路;单行写失败只丢那一行,不污染后续。 +**fail-closed 只在准入侧**(`source_stats` / `try_acquire` / `try_enter`)——闸没问上就绝不放行。记账写回(`record_success` / `record_failure` / `release_probe` / `mark_progress`、permit 的 settle/release)失败只记 warning。代价是这期间并发租约靠 TTL 回收、TPM 差额不结算,限额短期漂移;Redis 抖动时请盯 warning 日志而非只盯异常率。 + ## 取消语义:CancelledError 全链路穿透 `asyncio.CancelledError` 在库内**永不捕获吞没**: @@ -19,10 +22,12 @@ | 限流等待 / 退避 sleep | 可被取消;已取得的 permit 在 finally 归还 | | 流式读取 | 取消中断读取,连接在 finally 释放 | | 半开探针 | 探针持有者被取消 → 归还探针(源保持开路,下一个调用可再探),不判成败 | -| 遥测 | 被取消的调用不记遥测行 | +| 遥测 | **取消照常留痕**:尝试层一行 `error='cancelled'`(带 source_name)+ 最外层一行终局 `error='cancelled'`(source_name 为空),两行均 `usage_source='unavailable'`、`cost=NULL`。统计成功率/失败率时请排除 `error='cancelled'`——取消既不算成功,也不代表调用真的失败 | 设计动机:上层(arq 任务超时、用户中断)取消时,库必须立刻让路且不留悬挂资源——租约归零、探针不悬挂、in-flight 清零在压测中是持续验证的不变量。 ## 对业务代码的含义 -不要在业务侧 `except Exception` 包住库调用(会吞掉取消);需要兜底时精确捕获 `PolyGatewayError` 层级。库的资源释放走 `aclose()` + finally,业务侧照做同样的模式即可获得同样的保证。 +`asyncio.CancelledError` 继承 `BaseException`(Python 3.8+),所以业务侧的 `except Exception` **并不会**吞掉取消;真正会吞的是裸 `except:`、`except BaseException`,以及在 `finally` 里 await 阻塞操作。 + +不建议用 `except Exception` 包住库调用的真实理由是另一条:它会一并吞掉 `RequestRejectedError` 等四分类异常,让确定性失败被当成偶发错误重投。需要兜底时精确捕获 `PolyGatewayError` 层级。库的资源释放走 `aclose()` + finally(或 `async with`),业务侧照做同样的模式即可获得同样的保证。