From 07ec55eeb3437818ac26a7ac15f58c5cf6fe6823 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sat, 1 Aug 2026 23:48:48 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20=E4=BF=AE=E6=AD=A3=E7=AC=AC=E4=B8=89?= =?UTF-8?q?=E8=BD=AE=E5=AE=A1=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=84=2010=20?= =?UTF-8?q?=E5=A4=84=E4=B8=8D=E4=B8=80=E8=87=B4(9=20=E5=A4=84=E7=B3=BB?= =?UTF-8?q?=E5=89=8D=E4=B8=A4=E8=BD=AE=E4=BF=AE=E6=AD=A3=E7=9A=84=E6=AC=A1?= =?UTF-8?q?=E7=94=9F=E5=81=8F=E5=B7=AE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本轮每条数值/公式/签名断言均先实测再落笔: - 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。 --- 参考-公共API.md | 4 ++-- 参考-异常.md | 6 +++--- 参考-配置键.md | 5 +++-- 指南-多源与选源.md | 2 +- 指南-结构化输出.md | 9 +++++++-- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/参考-公共API.md b/参考-公共API.md index 857db36..62ab586 100644 --- a/参考-公共API.md +++ b/参考-公共API.md @@ -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` 声明是否剥离 `` 标签。新表必须经 `from_env(registry=...)` / `from_settings(registry=...)` 传入才生效;**只调 `register_provider()` 不传 `registry=`,装配期必抛 `ValueError: 未注册的 provider`**。内置四个 profile 的 `supports_native_schema` 均为 `False` | | `PricingTable` / `ModelPrice` | 价格表(成本折算) | 异常层级见 [[参考-异常]];全部 env 键见 [[参考-配置键]]。 diff --git a/参考-异常.md b/参考-异常.md index 3293bdf..bde35ea 100644 --- a/参考-异常.md +++ b/参考-异常.md @@ -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 文本 | ## 基建故障 diff --git a/参考-配置键.md b/参考-配置键.md index 79b89e1..714bd41 100644 --- a/参考-配置键.md +++ b/参考-配置键.md @@ -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,否则治理形同虚设** | diff --git a/指南-多源与选源.md b/指南-多源与选源.md index d018a0d..5a2d603 100644 --- a/指南-多源与选源.md +++ b/指南-多源与选源.md @@ -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) diff --git a/指南-结构化输出.md b/指南-结构化输出.md index 7083938..bd34390 100644 --- a/指南-结构化输出.md +++ b/指南-结构化输出.md @@ -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]])。 ## 阶梯行为