docs: 修正第三轮审查发现的 10 处不一致(9 处系前两轮修正的次生偏差)

本轮每条数值/公式/签名断言均先实测再落笔:
- PROBE_TTL_S 缺省实为 max(2*slowest, cooldown, slowest+5),120/30 得 240 而非 125
- 退避最后一步与上游 Retry-After 取大,无本地上限
- register_provider 的 base 是 keyword-only;补 ProviderProfile 五字段清单
- RedisLimiter/RedisGate 不在顶层 __all__,须从 backends.redis 导入
- OCR-only 真必填只有 CACHE_BACKEND 与 TELEMETRY_BACKEND
- OCR scope 的 PROVIDER 段必须字面 MONKEY
- network_error 是兜底桶;stalled 的 retry_after 取 BACKOFF_BASE_S
- source_name=None 另有 OCR 结果包解析失败一条路径

唯一非次生的首发缺陷: 结构化输出页从未说明 schema 要调用方自己写进
messages,照抄示例会白烧 3 次调用后抛 ResultInvalidError。
2026-08-01 23:48:48 -04:00
parent db4a41e472
commit 07ec55eeb3
5 changed files with 16 additions and 10 deletions
+2 -2
@@ -9,7 +9,7 @@
| `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`(`async with` 等价) | 幂等释放 **transport 连接池、遥测连接、缓存客户端**三项。**不释放限流/熔断后端**——`PGW_LIMITER_BACKEND`/`PGW_BREAKER_BACKEND=redis` 且由工厂自建时,其 Redis 客户端只靠 GC 回收;要确定性释放请自建 `RedisLimiter.from_url`/`RedisGate.from_url` `limiter=`/`breaker=` 注入自行 aclose |
| `aclose` | `() -> None`(`async with` 等价) | 幂等释放 **transport 连接池、遥测连接、缓存客户端**三项。**不释放限流/熔断后端**——`PGW_LIMITER_BACKEND`/`PGW_BREAKER_BACKEND=redis` 且由工厂自建时,其 Redis 客户端只靠 GC 回收;要确定性释放请 `from polygateway.backends.redis import RedisLimiter, RedisGate`(**这两个类不在顶层导出内**),用 `from_url(...)` 自建后`limiter=`/`breaker=` 注入自行 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)
@@ -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)` 是**纯函数**——返回 `DEFAULT_PROFILES` + 新条目的**新表**,不改全局状态(`DEFAULT_PROFILES` 是 MappingProxyType,改不动)。新表必须经 `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>` 标签。新表必须经 `from_env(registry=...)` / `from_settings(registry=...)` 传入才生效;**只调 `register_provider()` 不传 `registry=`,装配期必抛 `ValueError: 未注册的 provider`**。内置四个 profile 的 `supports_native_schema` 均为 `False` |
| `PricingTable` / `ModelPrice` | 价格表(成本折算) |
异常层级见 [[参考-异常]];全部 env 键见 [[参考-配置键]]。
+3 -3
@@ -19,7 +19,7 @@
| `repair_error` | JSON 修复失败的原因 |
| `validation_errors` | tuple,pydantic 校验错误 |
结构化阶梯耗尽这条路径上 `source_name` / `status_code``None`(失败不归因于某个源)
`source_name` / `status_code``None` 的路径有**两条**:① 结构化阶梯耗尽(失败不归因于某个源);② **OCR `parse_layout` 的结果包解析失败**(坏 ZIP / 缺 `_middle.json` / 退化 bbox / 非有限数值)——三个溯源字段全为 `None`,所以别在 except 里读 `exc.source_name` 定位坏源(会拿到 `None`,`.lower()` 直接 AttributeError),OCR 多实例场景请用遥测行定位
## scope 级不可用(重试预算走完后)
@@ -29,8 +29,8 @@
|---|---|
| `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`;`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 拦截产生 |
| `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 / httpx 超时三类,其余 Transient(HTTP 5xx、空补全、SSE 截断、响应体非法 JSON、缺 choices)全落进它;要区分真实成因请看遥测行的 error 文本 |
## 基建故障
+3 -2
@@ -26,6 +26,7 @@
| FIELD | 必填 | 说明 |
|---|---|---|
| BASE_URL / API_KEY / MODEL | ✔ | 无鉴权服务 API_KEY 填占位 `none` |
| (PROVIDER 段) | ✔ | chat/embed scope 的 PROVIDER 必须是注册表里的键;**OCR scope 的 PROVIDER 段必须字面写 `MONKEY`**(大小写不敏感),否则装配期抛 `ValueError: OCR 装配仅支持 provider=monkey`——D9 的其余 OCR 后端尚未实现 |
| TIMEOUT_S | ✔(或平铺 `LLM_TIMEOUT` 兜底) | 单次调用墙钟上限;须 ≤ `PGW_LEASE_TTL_S` |
| MAX_CONCURRENCY / RPM / TPM | | 0/缺省=不启用;照供应商配额页填即可,无需搭配 EST_TOKENS |
| EST_TOKENS | | **可选调优覆盖**(v1.0.3 起由必填降为可选)。TPM 入场预扣量,按实际用量结算退款;不填时库按 `max(1, TPM // 60)` 派生——即"一次调用约占一秒钟的配额份额",任何配额规模都收敛到约 60 个在途 |
@@ -42,7 +43,7 @@
| `{SCOPE}__GLOBAL__MAX_CONCURRENCY / RPM / TPM` | 跨源合计闸。**全局 TPM 要成为准入闸有前置条件**:入场预扣量取自**源级**(`EST_TOKENS``max(1, 源TPM//60)` 派生)——只配 `{SCOPE}__GLOBAL__TPM` 而每个源上既无 TPM 也无 EST_TOKENS 时,预扣恒为 0、入场永远放行,该闸退化成事后计数 |
| `{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 可省,缺省由最慢源 `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}__BACKPRESSURE__STALL_WINDOW_S / POLL_INTERVAL_S` | 配额满等待的判死窗口(须 ≥ 最大源 TTFT);均可省,缺省 `300.0` / `0.05` |
| `{SCOPE}__QUOTA_FULL` | wait(默认)/ fail_fast |
@@ -71,4 +72,4 @@
| `EMBED__BATCH_SIZE` | 必填,每批条数 |
| `EMBED__NORMALIZE` | 可选,true = L2 归一化 |
| `EMBED__EXPECTED_DIM` | 可选,维度校验(不符抛 ResultInvalid) |
| OCR scope | 无专用键,api_key 惯例填 `none`。**cache 键仍是装配必填**——`PGW_CACHE_BACKEND` 缺失即启动 `ValueError`,取非 `none` 还会连带索要 `PGW_CACHE_NAMESPACE` / `PGW_CACHE_TTL_S`(>0)/(redis 时)`REDIS_URL`,尽管 OCR 路径根本不构造缓存组件、取值对运行时零影响。**OCR-only 部署请填 `PGW_CACHE_BACKEND=none`**。只有 structured 键(`PGW_STRUCTURED_MAX_RETRIES`)是真可省 |
| OCR scope | 无专用键,api_key 惯例填 `none`。**cache 键仍是装配必填**——`PGW_CACHE_BACKEND` 缺失即启动 `ValueError`,取非 `none` 还会连带索要 `PGW_CACHE_NAMESPACE` / `PGW_CACHE_TTL_S`(>0)/(redis 时)`REDIS_URL`,尽管 OCR 路径根本不构造缓存组件、取值对运行时零影响。**OCR-only 部署请填 `PGW_CACHE_BACKEND=none`**。OCR 路上真正必填的 `PGW_*` 只有 `PGW_CACHE_BACKEND``PGW_TELEMETRY_BACKEND`(及取非 `none` 时的连带键);`PGW_STRUCTURED_MAX_RETRIES``PGW_PRICING_PATH` 既可省又**对 OCR 不生效**(OCR 无计费,client 不持有 pricing);`PGW_LEASE_TTL_S` / `PGW_LIMITER_BACKEND` / `PGW_BREAKER_BACKEND` 可省但有缺省(`1500` / `memory` / `memory`)**且对 OCR 实际生效——多进程 OCR 仍须显式配 redis,否则治理形同虚设** |
+1 -1
@@ -37,7 +37,7 @@ LLM__QWEN__2__TIMEOUT_S=120
- 被**限流闸**拒绝 → **不写冷却**(记 `rate_limited`),拒绝本身零副作用不消耗配额,下一轮照常参与选源并重试 `try_acquire`;
- 上游返回 429 → 也记 `rate_limited`(**与本地限流闸拒绝同值,二者不可区分**)、不写冷却,同时触发 AIMD 并发削减;`adaptive_paced` 是 AIMD pacer 主动拦截时才记的值。
重试预算是**调用级跨源累计**的(`LLM_MAX_RETRIES` 含首次),不是每源各自一份。退避公式 `min(BASE × 2^(n-1), MAX) × jitter`,jitter ∈ [0.5, 1.5)——封顶的是抖动前的基数,单次实际最长等待是 `BACKOFF_MAX_S` 的 1.5 倍
重试预算是**调用级跨源累计**的(`LLM_MAX_RETRIES` 含首次),不是每源各自一份。退避公式 `max(min(BASE × 2^(n-1), MAX) × jitter, 上游 Retry-After)`,jitter ∈ [0.5, 1.5)。两点要注意:`BACKOFF_MAX_S` 封顶的是**抖动前**的基数,所以本地部分最长是它的 1.5 倍;而最后一步要**与上游 `Retry-After` 取大者**——这条路径**没有本地上限**,网关回 `Retry-After: 60` 库就会睡满 60s。按 `BACKOFF_MAX_S` 排任务软超时会漏算这一段
## 多逻辑角色(SCOPE)
+7 -2
@@ -11,16 +11,21 @@ class Verdict(BaseModel):
score: int
reason: str
# schema 必须自己写进 prompt——库不会替你发
messages = [{"role": "user", "content": f"{task}\n只输出 JSON: {Verdict.model_json_schema()}"}]
resp = await client.chat(messages, structured=Verdict)
verdict = resp.structured_data # 已校验的 Verdict 实例
raw = await client.chat(messages, structured="json") # 只要合法 JSON,不校验模型(单次即败,无重问兜底)
```
> **库不会把 pydantic schema 发给上游。** 默认(且经 `from_env` 装配时唯一可达)的 json_repair 策略 `request_overlay` 恒返回 `{}`,请求体与普通聊天**逐字相同**;带反馈重问也只回灌校验错误文本,不含 schema。所以 **prompt 里没有 JSON 指令时,`structured=Verdict` 必然走完 3 次调用后抛 `ResultInvalidError`**——表现像"功能坏了",实则每次白烧 3 次真实调用与 3 行遥测。
`structured="json"` 只要合法 JSON、不校验模型(单次即败,无重问兜底),同样需要自备 prompt 指令。
需要安装 `polygateway[structured]`(json-repair);未装时传 `structured=` 会显式报错。
## 策略如何选定
库有两个策略:`JsonRepairStrategy`(prompt 约定 + 事后修复,不改请求体)与 `NativeSchemaStrategy`(下发 `response_format`)。选原生的条件是 **scope 内全部源的 `ProviderProfile.supports_native_schema` 都为 `True`**——而**内置四个 profile(qwen / deepseek / openai / minimax)全是 `False`**。所以经 `from_env` / `from_settings` 装出来的**恒是 json_repair 策略,请求体从不带 `response_format`**;要用原生 schema 必须自定义 profile 并经 `registry=` 传入(见 [[参考-公共API]])。
库有两个策略:`JsonRepairStrategy`(**由调用方自备 prompt** + 事后修复,不改请求体)与 `NativeSchemaStrategy`(下发 `response_format`)。选原生的条件是 **scope 内全部源的 `ProviderProfile.supports_native_schema` 都为 `True`**——而**内置四个 profile(qwen / deepseek / openai / minimax)全是 `False`**。所以经 `from_env` / `from_settings` 装出来的**恒是 json_repair 策略,请求体从不带 `response_format`**;要用原生 schema 必须自定义 profile 并经 `registry=` 传入(见 [[参考-公共API]])。
## 阶梯行为