diff --git a/Home.md b/Home.md index 9c0153d..01c55f0 100644 --- a/Home.md +++ b/Home.md @@ -1,6 +1,6 @@ # PolyGateway 文档 -实验室统一的大语言模型调度与中转库:LLM / VLM / OCR / Embedding 共用同一套生产级治理栈(多源多账号、限流、错误分类重试、熔断、响应缓存、流式看门狗、遥测与成本)。治理单位是**一次模型调用**;任务编排与业务解析留在业务侧。 +实验室统一的大语言模型调度与中转库:LLM / VLM / OCR / Embedding 共用同一套生产级治理栈(多源多账号、限流、错误分类重试、熔断、流式看门狗、遥测与成本);**响应缓存、AIMD 自适应并发、成本换算等几项仅 chat 路径有**,逐项对照见 [[解释-治理行为]]。治理单位是**一次模型调用**;任务编排与业务解析留在业务侧。 当前版本 **v1.0.5**,已通过 GovDoc-SaaS 与 CHSAnalyzer 两个真实项目的全量迁移验收。 diff --git a/参考-公共API.md b/参考-公共API.md index 587232e..f87e896 100644 --- a/参考-公共API.md +++ b/参考-公共API.md @@ -34,7 +34,7 @@ | 方法 | 签名 | |---|---| -| `from_env` | `(scope="EMBED", ..., env=None) -> EmbeddingClient` | +| `from_env` | `(scope="EMBED", *, limiter=None, breaker=None, telemetry=None, registry=None, env=None) -> EmbeddingClient` —— **没有 `cache=` 形参**(传了 `TypeError`),治理循环也不构造响应缓存:`PGW_CACHE_BACKEND` 对 `embed()` 零作用,`cache_hit` 恒 False | | `embed` | `(texts: list[str], *, session_id=None, parent_call_id=None) -> EmbeddingResponse` | | `aclose` | `() -> None`(亦支持 `async with`) | @@ -44,7 +44,7 @@ | 方法 | 签名 | |---|---| -| `from_env` | `(scope="OCR", ..., env=None) -> OcrClient` | +| `from_env` | `(scope="OCR", *, limiter=None, breaker=None, telemetry=None, env=None) -> OcrClient` —— **无 `cache=` 也无 `registry=`**(传了 `TypeError`);同样不构造响应缓存 | | `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]`(逐源并发预检) | @@ -56,7 +56,7 @@ | 导出 | 用途 | |---|---| -| `GatewaySettings` / `EmbeddingSettings` / `OcrSettings` | `from_env` 的解析产物;高级场景可自行构造后走 `from_settings` | +| `GatewaySettings` / `EmbeddingSettings` / `OcrSettings` | `from_env` 的解析产物;高级场景可自行构造后走 `from_settings`。**注意两点**:`GatewaySettings` 的 20 个字段**全部必填无默认**(按"只填关心的几个"构造会连撞多次 `TypeError`);其中 `global_limits` / `retry` / `breaker` / `backpressure` 的类型 `GlobalLimits` / `RetryPolicy` / `BreakerConfig` / `BackpressurePolicy` **不在顶层导出内**,须 `from polygateway.types import ...`——该导入面**不受**"字段只增不删不改名"的迁移兼容承诺覆盖。除非确有需要,优先用 `from_env()` 或对既有 settings 做 `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` 声明是否剥离 `` 标签。**注册表只作用于 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` | 价格表(成本折算) | diff --git a/参考-异常.md b/参考-异常.md index f8c3098..55576e4 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` 只由 **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` | +| `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` | ## 基建故障 diff --git a/指南-Embedding.md b/指南-Embedding.md index d87cd09..e72ef18 100644 --- a/指南-Embedding.md +++ b/指南-Embedding.md @@ -2,7 +2,9 @@ 与 chat 共用治理件:多源、重试、熔断、遥测;外加分批与维度校验。 -> **两处与 chat 不同,都是静默的**:① **健康降权通道对 EMBED 不生效**——喂数只发生在 chat 与 OCR 路径,EMBED 的健康分恒为初值,`health_aware`(默认)失去区分坏源的能力——但**不等于 `least_inflight`**:分数全等时仍走 P2C 随机,多源下头名近似均匀随机(least_inflight 是稳定排序、平局恒选首源)。要那个语义须显式配 `EMBED__SELECTOR=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`;③ **无 AIMD 自适应并发**——不会因 429 自动降并发,`per_source_reasons` 永不出现 `adaptive_paced`,多路批量向量化**必须显式配 `MAX_CONCURRENCY`**,否则并发原样打向单源、叠加②会成片 `AllSourcesExhausted`;④ **无响应缓存**——`from_env` 没有 `cache=` 形参,`PGW_CACHE_BACKEND` 对 `embed()` 零作用,`cache_hit` 恒 False。 +> +> 完整对照见 [[解释-治理行为]] 的机制 × 路径适用性总表(单一事实源,本页不再复述)。 ## 配置 diff --git a/指南-响应缓存.md b/指南-响应缓存.md index 18f50ab..164352c 100644 --- a/指南-响应缓存.md +++ b/指南-响应缓存.md @@ -20,7 +20,9 @@ key = `model + messages 摘要 + namespace + salt + sampling`;多模态 content( | namespace 必填 | 跨项目/跨租户互相读到对方缓存 | | salt(per-call) | 需要强制重采样的场景命中旧缓存 | | sampling(v1.0.5) | 不同解码参数的响应互相污染——尤其是同 messages 跑多个 seed 时全部命中第一次的结果,标准差恒为 0 且不报错 | -| 坏结果不写缓存 | 截断流/解析失败被固化 | +| 坏结果不写缓存(**默认档**) | 截断流/解析失败被固化 | + +> **`MISSING_DONE=salvage` 是这条防毒化约束的例外**:该档下有内容的 SSE 截断会被打捞成正常响应返回**并照常写入缓存**,按你配的 TTL(示例里 604800s = 7 天)反复回放。遥测上只见 `cache_hit=true`、常态 `usage_source=unavailable`/tokens=0,字段上识别不出是打捞结果;清除只能手工清 Redis 或换 `cache_salt`。详见 [[解释-错误四分类]]。 `sampling` **仅在非空时参与**,不传采样参数时 key 与旧版逐字相同,升级不会作废存量缓存。反过来,逐次变化的 `seed` 会让这条路径全部 miss——这是正确语义,但要知道缓存对它不再省钱。另注意 key 里的 `model` 是**全 scope 所有源的合集指纹**(含各源的 `EXTRA_BODY`),不是本次实际选中那个源的指纹:同 scope 各源解码参数不同时,仍可能读到另一源的响应。详见 [[指南-采样参数]]。 diff --git a/指南-限流与熔断.md b/指南-限流与熔断.md index 582b077..e5b37d4 100644 --- a/指南-限流与熔断.md +++ b/指南-限流与熔断.md @@ -37,7 +37,7 @@ LLM__GLOBAL__RPM=120 混编大小源时,低并发的备用源也吃这个被主源抬高的阈值——想让备用源早点熔断,需另开 scope 或调低主源并发。 -开路后冷却 `COOLDOWN` 秒,重复开路指数递增、封顶 `MAX_COOLDOWN_S`;冷却结束进入半开,**只放一个探针**(带租约,持有者崩溃后租约过期自动可再探);探针成功即闭合。写回带 epoch fencing,迟到结果不会污染新状态。 +开路后冷却 `COOLDOWN` 秒,重复开路指数递增、封顶 `MAX_COOLDOWN_S`;冷却结束进入半开,**只放一个探针**(带租约,持有者崩溃后租约过期自动可再探);探针成功即闭合。**但本地冷却备忘不随之撤销**:备忘时长取自熔断后端返回的 `retry_after_s`,半开被拒时约等于 `PROBE_TTL_S`(`TIMEOUT_S=120`/`COOLDOWN_S=30` 时是 **240s** 而非 30s),`set_until` 只取更晚者。该窗口内单源 scope 持续抛 `CircuitOpenError(reasons={'x':'cooldown'})` 且自带 `retry_after_s=0.0`——照它立即重投会空转,而查熔断后端只会看到"熔断是好的"。选源健康分、AIMD 上限、这个备忘三样**永远进程本地、无 redis 实现**,详见 [[解释-治理行为]]。写回带 epoch fencing,迟到结果不会污染新状态。 401/403/欠费类失败(SourceDead)一击即熔,不走计数。 diff --git a/解释-治理行为.md b/解释-治理行为.md index 68bf2f1..fd8bb0c 100644 --- a/解释-治理行为.md +++ b/解释-治理行为.md @@ -18,11 +18,24 @@ | 健康分喂数(跨调用降权/回流) | ✔ | ✔ | ✘ | `record_outcome` 见 `retry.py`、`ocr.py`;`embedding.py` 零命中 | | 同一次调用内连败降权重排 | ✔ | ✘ | ✘ | `_demote_call_failures` 唯一调用点在 `retry.py:251` | | 成本换算(`cost`) | ✔ | ✘ | ✔ | `OcrClient` 不持有 pricing,成功行 `cost` 恒 NULL 且不告警 | +| 响应缓存 | ✔ | ✘ | ✘ | `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 不受影响)。 限流六道闸、熔断双通道、退避公式本身、冷却备忘、遥测必录**三条路径一致**。 +## 作用域边界:哪些状态跨进程,哪些不跨 + +后端表的 memory/redis 二分只覆盖**限流闸**与**熔断门**——`backends/redis/` 下只有这两个实现。下面三样**永远是进程本地的,没有也从未有过 redis 后端**: + +| 状态 | 后果(多 worker 部署) | +|---|---| +| 健康分 EWMA(选源) | 坏源要被每个 worker 各自重学一遍 | +| AIMD 每源并发上限 | 保护按 worker 数稀释;但它只会更严不会更松,真正的天花板仍是共享的 `MAX_CONCURRENCY` 闸 | +| 熔断本地冷却备忘 | 各写各的,现象是"一部分请求通、一部分持续 CircuitOpenError",难复现 | + +其中**冷却备忘的时长取自熔断后端返回的 `retry_after_s`**,半开被拒时约等于 `PROBE_TTL_S`(`TIMEOUT_S=120` / `COOLDOWN_S=30` 时是 **240s**,不是 30s);`set_until` 只取更晚者,**熔断闭合也不撤销备忘**。该窗口内单源 scope 会持续抛 `CircuitOpenError(reasons={'x':'cooldown'})` 且自带 `retry_after_s=0.0`(读的是已闭合的熔断后端)——照它立即重投会空转,而查熔断后端只会看到"熔断是好的"。 + ## 熔断双通道 + 健康证据抑制 **病灶**:纯"连续失败 N 次"通道对成功率 10% 的半死源永不触发(偶尔成功就清零计数);反过来,高流量健康源偶发 5 连败又会被误熔。**机制**:增设失败率窗口通道(样本 ≥ MIN_CALLS 且失败率 ≥ FAIL_RATE 即开路);同时连败通道受健康证据抑制——窗口样本充足且失败率低时,连败不开路。两通道互补,半死源提前隔离、健康源免误伤。