docs: 全量下线文档站,留说明性占位页
八轮审查累计确认 93 处与源码不一致,近半落在参考区(手工镜像源码里已有 的事实,必然漂移),且修正本身在引入次生偏差,逐轮修补不收敛。 过期文档比没有文档更危险——它看起来权威。留空并指向源码 docstring、 .env.example、CHANGELOG、ARCHITECTURE.md 这些真实事实源。 历史内容未丢失,全在 git 历史里: git checkout e78bfb9 -- .
+20
-17
@@ -1,24 +1,27 @@
|
||||
# PolyGateway 文档
|
||||
# PolyGateway 文档站(重建中)
|
||||
|
||||
实验室统一的大语言模型调度与中转库:LLM / VLM / OCR / Embedding 共用同一套生产级治理栈(多源多账号、限流、错误分类重试、熔断、流式看门狗、遥测与成本);**响应缓存、AIMD 自适应并发、健康降权等几项按路径而异**(如成本换算 OCR 没有、而 chat 的 `LLMResponse.cost` 恒 None、只有 `EmbeddingResponse.cost` 真填),逐项对照见 [[解释-治理行为]] 的适用性总表。治理单位是**一次模型调用**;任务编排与业务解析留在业务侧。
|
||||
**本站内容已于 2026-08-02 全量下线,原因是准确性不足。**
|
||||
|
||||
当前版本 **v1.0.5**,已通过 GovDoc-SaaS 与 CHSAnalyzer 两个真实项目的全量迁移验收。
|
||||
八轮逐页审查(每轮六维度、对抗性复核)累计确认 93 处 wiki 与源码不一致,其中约半数落在参考区——那三页在手工维护源码里本已存在的事实(签名、字段、配置键、异常属性),这种镜像必然漂移。审查同时发现修正本身在引入次生偏差,继续逐轮修补不会收敛。
|
||||
|
||||
## 文档地图(按你此刻要干什么选入口)
|
||||
留空而非留旧内容,是因为**过期的文档比没有文档更危险**:它看起来权威,下游会照着写。已知踩坑包括 `LLMResponse.cost` 实际恒为 `None`(旧文档写"按价格表折算")、`except TransientError:` / `except SourceDeadError:` 是永不命中的死分支、`gather_bounded` 的并发参数是 keyword-only 的 `concurrency`。
|
||||
|
||||
| 你想 | 去 |
|
||||
## 在文档重建前,请以这些为准
|
||||
|
||||
| 需要 | 去哪 |
|
||||
|---|---|
|
||||
| 第一次用,想被领着走通一遍 | [[教程-十分钟接入]] |
|
||||
| 有明确任务,查怎么配 | 指南区: [[指南-多源与选源]] / [[指南-限流与熔断]] / [[指南-响应缓存]] / [[指南-遥测与成本]] / [[指南-结构化输出]] / [[指南-采样参数]] / [[指南-OCR]] / [[指南-Embedding]] / [[指南-迁移既有项目]] |
|
||||
| 查签名、字段、配置键 | 参考区: [[参考-公共API]] / [[参考-配置键]] / [[参考-异常]] |
|
||||
| 理解设计与行为逻辑 | 解释区: [[解释-架构]] / [[解释-错误四分类]] / [[解释-治理行为]] / [[解释-降级与取消]] |
|
||||
| 签名、字段、参数语义 | 源码 docstring(公共导出 26/27 有中文 docstring) |
|
||||
| 全部环境变量键 | 主仓库 `.env.example`(带注释的全量模板) |
|
||||
| 版本变更与下游注意事项 | 主仓库 `CHANGELOG.md` |
|
||||
| 架构决策与行为论证 | 主仓库 `research-wiki/ARCHITECTURE.md` |
|
||||
| 快速上手 | 主仓库 `README.md` |
|
||||
|
||||
## 快速事实
|
||||
## 历史内容
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 安装 | `pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ "polygateway[redis,postgres,structured]==1.0.*"` |
|
||||
| Python | ≥ 3.11,纯 asyncio |
|
||||
| 核心依赖 | 五项必装: httpx、pydantic、pydantic-settings、python-dotenv、loguru(redis / asyncpg / json-repair / openai 走 extras) |
|
||||
| 架构事实源 | 主仓库 `research-wiki/ARCHITECTURE.md`(含 D1-D14 全部决策论证) |
|
||||
| 变更记录 | 主仓库 `CHANGELOG.md` |
|
||||
未删除,在本仓库 git 历史中。全量恢复:
|
||||
|
||||
```bash
|
||||
git checkout e78bfb9 -- .
|
||||
```
|
||||
|
||||
单页查看:`git show e78bfb9:参考-公共API.md`。**恢复前请注意上述准确性问题。**
|
||||
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
**[[Home|首页]]**
|
||||
|
||||
**教程**
|
||||
- [[教程-十分钟接入]]
|
||||
|
||||
**操作指南**
|
||||
- [[指南-多源与选源]]
|
||||
- [[指南-限流与熔断]]
|
||||
- [[指南-响应缓存]]
|
||||
- [[指南-遥测与成本]]
|
||||
- [[指南-结构化输出]]
|
||||
- [[指南-采样参数]]
|
||||
- [[指南-OCR]]
|
||||
- [[指南-Embedding]]
|
||||
- [[指南-迁移既有项目]]
|
||||
|
||||
**参考**
|
||||
- [[参考-公共API]]
|
||||
- [[参考-配置键]]
|
||||
- [[参考-异常]]
|
||||
|
||||
**解释**
|
||||
- [[解释-架构]]
|
||||
- [[解释-错误四分类]]
|
||||
- [[解释-治理行为]]
|
||||
- [[解释-降级与取消]]
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
# 参考:公共 API
|
||||
|
||||
顶层导出全集(`from polygateway import ...`);公共类型承诺 `LLMResponse` **前 11 个字段逐字保序**(迁移项目按位置构造 fake 依赖这一点),此后新增字段只增不删不改名且必带默认值。
|
||||
|
||||
## GatewayClient
|
||||
|
||||
| 方法 | 签名 | 说明 |
|
||||
|---|---|---|
|
||||
| `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 回收;要确定性释放请 `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)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| content / thinking | str | 正文与思考流(thinking 模型) |
|
||||
| model / provider | str | 溯源(model 是配置里的别名) |
|
||||
| prompt_tokens / completion_tokens | int | 用量 |
|
||||
| latency_ms | int | 总耗时 |
|
||||
| ttft_ms / max_inter_token_ms | float\|None | 流式时延指标 |
|
||||
| cache_hit | bool | **PolyGateway 自身响应缓存**是否命中(未产生网关调用);与供应商 prompt cache 无关,后者见 cached_prompt_tokens |
|
||||
| call_id | str | 遥测主键 |
|
||||
| source_name | str | 命中的源名。**注意它排在 call_id 之后**——库新增字段一律追加在末尾 |
|
||||
| cost | float\|None | **chat 路径恒为 `None`**——成本不回填进响应对象,只写进遥测行 `llm_calls.cost`(配了 `PGW_PRICING_PATH` 也不变)。做预算控制请查遥测表,别用 `resp.cost`;注意 `EmbeddingResponse.cost` **是**真填的,从 embed 迁到 chat 时同名字段不同语义 |
|
||||
| usage_source | str | measured / estimated / unavailable(v1.0.3 起三态,口径见 [[指南-遥测与成本]]) |
|
||||
| structured_data | Any\|None | `structured=` 时的校验结果 |
|
||||
| cached_prompt_tokens | int\|None | 供应商 prompt cache 命中的输入 token 数(v1.0.4 新增)。`None` = 该源未上报;`0` = 上报了真实零命中——两者不可混同,口径见 [[指南-遥测与成本]] |
|
||||
| model_reported | str\|None | API 响应体实际返回的 model(v1.0.4 新增);`None` = 未上报。与 `model`(配置别名)可能分叉,实验复现应认这个串 |
|
||||
|
||||
## 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`) |
|
||||
|
||||
`EmbeddingResponse`:vectors(等长保序)/ dim / model / provider / prompt_tokens / usage_source / latency_ms / call_id / source_name / cost。
|
||||
|
||||
## OcrClient(`from polygateway import OcrClient`,亦可从 `polygateway.ocr` 导入)
|
||||
|
||||
| 方法 | 签名 |
|
||||
|---|---|
|
||||
| `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]`(逐源并发预检) |
|
||||
| `aclose` | `() -> None`(亦支持 `async with`) |
|
||||
|
||||
`OcrLayoutElement`:type(开放字符串)/ bbox(x1,y1,x2,y2 页面坐标)/ page_index。
|
||||
|
||||
## 配置与注册表类型
|
||||
|
||||
| 导出 | 用途 |
|
||||
|---|---|
|
||||
| `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` 声明是否剥离 `<think>` 标签。**注册表只作用于 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` | 价格表(成本折算) |
|
||||
|
||||
版本号也走顶层导出:`polygateway.__version__`(当前 `1.0.5`),日志与 bug 报告可直接带上。异常层级见 [[参考-异常]];全部 env 键见 [[参考-配置键]]。
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
# 参考:异常
|
||||
|
||||
全部继承 `PolyGatewayError`;凡携带 `source_name` 的异常都能定位到具体源。
|
||||
|
||||
## 四分类(单次尝试的失败定性)
|
||||
|
||||
| 异常 | 触发 | 库内治理行为 |
|
||||
|---|---|---|
|
||||
| `TransientError` | 超时 / 5xx / 网络抖动 / SSE 截断 / 429 / **空补全**(200 且流程完整但 content 空白)/ 响应体非法 JSON 或缺 choices | 换源重试 + 退避。429 的 pushback 待遇**三条路径不同**(不耗重试预算是 chat 独有,按 Retry-After 等待是 chat + EMBED 有、OCR 无),逐项见 [[解释-治理行为]] 的适用性总表 |
|
||||
| `SourceDeadError` | 401 / 403 / 429+insufficient_quota(欠费) | 立即熔断该源 + 换源 |
|
||||
| `RequestRejectedError` | **400 及其余未特判的非 200 状态码**(402/404/408/409/422…) / 内容拒绝 / **OCR 的 200 但 `success≠true`**(`status_code=200`) | 不重试不换源,直接抛给业务。base_url 配错导致的 404、上游用 402 表达欠费都走这里立即终态,`exc.status_code` 携原状态码 |
|
||||
| `ResultInvalidError` | 调用成功但结果不合格(坏 JSON / 维度不符 / 退化 bbox) | **不熔断**;结构化场景先有界重问,仍失败才抛 |
|
||||
|
||||
### `ResultInvalidError` 的诊断属性
|
||||
|
||||
| 属性 | 含义 |
|
||||
|---|---|
|
||||
| `raw_text` | 模型原始输出(结构化阶梯耗尽时是最后一次响应正文) |
|
||||
| `repair_error` | JSON 修复失败的原因 |
|
||||
| `validation_errors` | tuple,pydantic 校验错误 |
|
||||
|
||||
**判据(不是可数清单)**:凡由**结构化解析层**(`structured="json"` 档解析失败、pydantic 档阶梯耗尽)与 **OCR 结果包解析**(坏 ZIP / 缺 `_middle.json` / 退化 bbox / 非有限数值)抛出的 `ResultInvalidError`,三个溯源字段全为 `None`;只有 transport 与 embedding 层抛的才带 `source_name`。所以**不要**在 `except ResultInvalidError` 里直接写 `exc.source_name.lower()`(会 `AttributeError`)——定位坏源请用遥测行。注意 `structured="json"` 是常规用法且**单次即抛不走阶梯**,它的解析失败正命中这条。
|
||||
|
||||
## scope 级不可用(重试预算走完后)
|
||||
|
||||
`GatewayUnavailableError` 及子类 `CircuitOpenError`(整圈门全拒)/ `AllSourcesExhausted`(预算耗尽):
|
||||
|
||||
| 属性 | 含义 |
|
||||
|---|---|
|
||||
| `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` 上不可区分。**该字典只保留每个源的最后一次原因,且写入规则不统一**。分两类:**真发出过请求后归类的失败原因**(`source_dead` / `timeout` / `network_error` / **上游返回 429 的 `rate_limited`**)与选源阶段的 `circuit_open` / `cooldown` 一律**直接覆盖**;只有两种"没真发出请求"的跳过用 `setdefault` 不覆盖——本地限流闸拒绝(也记 `rate_limited`)与 AIMD pacer 拦截(`adaptive_paced`)。所以**看到 `rate_limited` 不能推断这个源从头到尾只是被限流**:先超时或先 5xx、随后撞上 429 的源,早先的证据已被覆盖。后果是 **`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` |
|
||||
|
||||
## 基建故障
|
||||
|
||||
`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 日志,而不是只盯异常率。
|
||||
|
||||
## 本地校验抛的是裸异常
|
||||
|
||||
`chat(overlay=)` 的保护键校验、`embed()` 的 `texts` 类型检查、OCR 的 `image` 检查抛的是裸 `ValueError` / `TypeError`,**不继承 `PolyGatewayError`**——它们发生在洋葱之外,属调用方编程错误,不入四分类、无遥测行、不触发任何治理。详见 [[指南-采样参数]]。
|
||||
|
||||
## 业务侧建议写法
|
||||
|
||||
捕 `GatewayUnavailableError` 做延期重投,捕 `RequestRejectedError`/`ResultInvalidError` 做确定性失败处理。
|
||||
|
||||
**`TransientError` 与 `SourceDeadError` 都不会穿出 `chat()` / `embed()` / OCR**——三处治理循环在同一分支吸收后转内部失败,`except TransientError:` 与 `except SourceDeadError:` 都是死分支(凭据失效告警若挂在后者上会静默失效,应改判 `CircuitOpenError` + 遥测 error 文本)。它会变成 `GatewayUnavailableError` 族的三种出口之一:
|
||||
|
||||
| 出口 | 何时 | `__cause__` |
|
||||
|---|---|---|
|
||||
| `AllSourcesExhausted(reason='retry_exhausted')` | 重试预算耗尽 | 原 `TransientError` |
|
||||
| `CircuitOpenError(reason='circuit_open')` | 源已被熔断(故障稳态) | `None` |
|
||||
| `AllSourcesExhausted(reason='stalled')` | 429 持续 pushback 直到判死 | `None` |
|
||||
|
||||
`SourceDeadError` 同理走这三个出口:`MAX_ATTEMPTS=1` 时是 `AllSourcesExhausted(retry_exhausted)`(`__cause__` 为原 `SourceDeadError`),**≥2 时恒为 `CircuitOpenError`**(常态,`__cause__=None`)。
|
||||
|
||||
所以 `__cause__` 只在 `retry_exhausted` 这一条路径上有值;通用的诊断入口是 `exc.per_source_reasons`。
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
# 参考:配置键
|
||||
|
||||
装配只有两条路:`from_env()`(读 .env + 环境变量,环境变量优先)或构造函数全量注入。缺关键键装配即报错。主仓库 `.env.example` 是带注释的全量模板,本页为速查表。
|
||||
|
||||
## 两条装配路的校验完全一致(v1.0.1 / v1.0.2)
|
||||
|
||||
**经 `from_env()` 装配的调用方不受这两版影响**——那条路本就跑全部校验。手工构造 `GatewaySettings`(或对它 `dataclasses.replace`)的调用方请读本节:这些校验此前只有 `from_env` 拦得住,`from_settings()` 与直接构造一律放行,故障留到运行时才表现出来。现已全部收进 `__post_init__`。
|
||||
|
||||
| 现在构造期就报错的 | 此前的运行时表现 |
|
||||
|---|---|
|
||||
| 源 `timeout_s` > `PGW_LEASE_TTL_S` | 租约先于请求过期,并发悄悄超出配额 |
|
||||
| `stall_window_s` < 最大源 TTFT | 正常的慢首包被误判卡死掐断 |
|
||||
| `probe_ttl_s` < 最慢源 `timeout_s` + 5 | 半开探针在途即被接管 |
|
||||
| `sources` 为空 | 装出必然选源失败的 client |
|
||||
| 六个后端/策略字段取值越界(`limiter_backend`/`breaker_backend`/`cache_backend`/`telemetry_backend`/`selector`/`quota_full`) | 静默不建后端,或裸 `AssertionError`(`python -O` 下退化为第三方库的天书报错) |
|
||||
| 取 `redis` 的后端缺 `redis_url`;启用缓存缺 namespace 或 TTL ≤ 0;`sqlite`/`postgres` 缺对应路径/DSN | 同上 |
|
||||
| `structured_max_retries` 为负、`scope` 为空 | 同上 |
|
||||
| `EmbeddingSettings` 的 `batch_size` / `expected_dim` 越界 | 推迟到 `EmbeddingClient` 构造时才报 |
|
||||
|
||||
**一处静默改值(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 带密码故整串不入日志。
|
||||
|
||||
## 源键 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`
|
||||
|
||||
| FIELD | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| BASE_URL / API_KEY / MODEL | ✔ | 无鉴权服务 API_KEY 填占位 `none` |
|
||||
| (PROVIDER 段) | ✔ | **三个 scope 的约束各不相同**。chat(`GatewayClient`): 必须是注册表里的键,装配期逐源 `get_provider`,未注册即 `ValueError: 未注册的 provider`。**EMBED: 不查注册表**——`EmbeddingClient` 与 `/embeddings` 调用全程不读 `ProviderProfile`,PROVIDER 段可填任意名(bge / jina / vllm 等自建端点照实填即可),它只是遥测 `provider` 列与 `EmbeddingResponse.provider` 的标签,**别为迁就注册表谎报成 qwen/openai**,否则成本无法按真实供应商归因。OCR: 必须字面写 `MONKEY`(大小写不敏感),否则装配期抛 `ValueError: OCR 装配仅支持 provider=monkey`(D9 其余后端未实现) |
|
||||
| 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 个在途 |
|
||||
| TTFT_TIMEOUT_S / INTER_TOKEN_TIMEOUT_S | | 流式看门狗,成对配;0 < inter < ttft < timeout |
|
||||
| ENABLE_THINKING | | 三态: 缺省不注入 / true 注入开 / false 注入关 |
|
||||
| MISSING_DONE | | SSE 缺 `[DONE]`: `retry`(默认)/ `salvage`(打捞已收内容) |
|
||||
| TRUST_ENV | | `false` = 绕过本地代理(LAN 直连) |
|
||||
| EXTRA_BODY | | 本源恒定的采样参数,JSON **对象**串(数组/标量报错),如 `{"temperature":0}`。并入请求体,优先级低于 `chat(overlay=...)`。禁用键 `model`/`messages`/`stream`/`stream_options`(会击穿治理),配了直接报错。**OCR/EMBED scope 不消费此键**——配了会被剥离并 warning。见 [[指南-采样参数]] |
|
||||
|
||||
## scope 级键
|
||||
|
||||
| 键 | 说明 |
|
||||
|---|---|
|
||||
| `{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 含首次。退避公式 `max(min(BASE × 2^(n-1), MAX) × jitter, 上游 Retry-After)`,jitter ∈ [0.5, 1.5)。`BACKOFF_MAX_S` 只封顶**本地**那一段(抖动前的基数,故本地最长 = MAX × 1.5);与上游 `Retry-After` 取大的那一段**没有本地上限**——网关回 `Retry-After: 600` 库就睡满 600s,且 chat 路径下 429 不耗 `MAX_ATTEMPTS`。**不能**按 `MAX × 1.5` 排 arq/celery 软超时 |
|
||||
| `{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` | 双条件 stall 判死窗口与轮询间隔;均可省,缺省 `300.0` / `0.05`,须 ≥ 最大源 TTFT。**chat scope 下它有两个作用点**:① 配额满 `wait` 时的等待判死;② **整次 `chat()` 调用的墙钟死线**——主循环每轮开头判"本调用已耗时 > 窗口 **且** 全局无进展 > 窗口",超窗抛 `AllSourcesExhausted(reason='stalled')`。因 429 不消耗 `MAX_ATTEMPTS`,该窗口是 429 持续 pushback 时**唯一**的终止条件:想给饱和期设容忍上限就调它,但调小(如 30)会把整次调用的死线一并压到 30s。**EMBED / OCR 只有作用点 ①**(它们的 429 照常计入 `MAX_ATTEMPTS`) |
|
||||
| `{SCOPE}__QUOTA_FULL` | wait(默认)/ fail_fast |
|
||||
|
||||
平铺简写(单 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`。
|
||||
|
||||
> **`LLM_TTFT_TIMEOUT` 与 `LLM_INTER_TOKEN_TIMEOUT` 必须成对**,且仅当该源的 `TTFT_TIMEOUT_S` / `INTER_TOKEN_TIMEOUT_S` **两个都缺省**时才作为缺省填入。**只配其中一个会被静默忽略、不报错**——该源两道看门狗全空,流式活性只剩由 `TIMEOUT_S` 派生的 total 一层(首包挂住要等满 120~300s 才失败,期间一直占着并发租约与 TPM 预扣)。注意与源级行为**相反**:源级只配一半是装配期硬报错,且源级配了一个时平铺键不会补齐另一半,照样报错。
|
||||
>
|
||||
> **多 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_*`
|
||||
|
||||
| 键 | 取值 | 备注 |
|
||||
|---|---|---|
|
||||
| `PGW_LIMITER_BACKEND` / `PGW_BREAKER_BACKEND` | memory / redis | 缺省 `memory`;多进程必须 redis(需 `REDIS_URL`) |
|
||||
| `PGW_CACHE_BACKEND` | none / memory / redis | 必填;redis 需 NAMESPACE + TTL_S + REDIS_URL |
|
||||
| `PGW_CACHE_NAMESPACE` / `PGW_CACHE_TTL_S` | | 缓存启用时必填;TTL 必须 > 0 |
|
||||
| `PGW_TELEMETRY_BACKEND` | none / sqlite / postgres | 必填;sqlite 需 `PGW_TELEMETRY_SQLITE_PATH`,postgres 需 `PGW_TELEMETRY_PG_DSN` |
|
||||
| `PGW_PRICING_PATH` | 路径 | 可选,价格表 JSON;缺省 cost 恒 None。条目支持可选第三档 `cached_input_per_1m`(v1.0.4,供应商 prompt cache 命中部分的单价;不填则命中部分也按 input 全额计) |
|
||||
| `PGW_STRUCTURED_MAX_RETRIES` | int | 结构化重问上限,缺省 2 |
|
||||
| `PGW_LEASE_TTL_S` | float | permit 租约,缺省 1500;须 ≥ 最大源 timeout |
|
||||
| `REDIS_URL` | dsn | redis 后端共用 |
|
||||
|
||||
## Embedding / OCR 专用
|
||||
|
||||
| 键 | 说明 |
|
||||
|---|---|
|
||||
| `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`**。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,否则治理形同虚设** |
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
# 指南:Embedding
|
||||
|
||||
与 chat 共用治理件:多源、重试、熔断、遥测;外加分批与维度校验。
|
||||
|
||||
> **与 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。
|
||||
>
|
||||
> 完整对照见 [[解释-治理行为]] 的机制 × 路径适用性总表(单一事实源,本页不再复述)。
|
||||
|
||||
## 配置
|
||||
|
||||
```bash
|
||||
EMBED__QWEN__1__BASE_URL=https://your-endpoint/v1
|
||||
EMBED__QWEN__1__API_KEY=sk-xxx
|
||||
EMBED__QWEN__1__MODEL=text-embedding-v3
|
||||
EMBED__QWEN__1__TIMEOUT_S=60
|
||||
EMBED__BATCH_SIZE=64 # 必填: 分批是行为关键,不设默认
|
||||
# EMBED__EXPECTED_DIM=1024 # 可选: 维度校验,不符抛 ResultInvalidError
|
||||
# EMBED__NORMALIZE=false # 可选: true = 库内 L2 归一化
|
||||
```
|
||||
|
||||
## 用法
|
||||
|
||||
```python
|
||||
from polygateway import EmbeddingClient
|
||||
|
||||
embed = EmbeddingClient.from_env("EMBED")
|
||||
resp = await embed.embed(["文本 a", "文本 b", "文本 c"])
|
||||
resp.vectors # list[list[float]],与输入等长保序(跨批合并)
|
||||
resp.dim # 维度
|
||||
await embed.aclose()
|
||||
```
|
||||
|
||||
- 超过 `BATCH_SIZE` 的输入自动分批发送、结果按序合并;每批独立走治理(某批瞬时失败只重试该批);
|
||||
- 上游缺 usage 时记 `usage_source="unavailable"` 且 cost 为 NULL,不静默填 0 也不编估算值(v1.0.3 起;分批场景下任一批不可得则整批响应记 `unavailable`,详见 [[指南-遥测与成本]]);
|
||||
- 需要 ndarray 的业务侧自己 `np.asarray(resp.vectors, dtype=np.float32)`——库不依赖 numpy。
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
# 指南:OCR
|
||||
|
||||
MonkeyOCR 两端点走完整治理栈(多源/重试/熔断/遥测);OCR 无 token 计费,Usage 恒 0,耗时看 latency_ms。
|
||||
|
||||
## 配置
|
||||
|
||||
```bash
|
||||
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 两段协议较慢,给足;不配会静默继承 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
|
||||
OCR__MONKEY__2__MODEL=monkey-ocr
|
||||
OCR__MONKEY__2__TIMEOUT_S=300
|
||||
OCR__MONKEY__2__TRUST_ENV=false # LAN 直连绕过本地代理
|
||||
```
|
||||
|
||||
## 三个方法
|
||||
|
||||
```python
|
||||
from polygateway.ocr import OcrClient
|
||||
|
||||
ocr = OcrClient.from_env("OCR")
|
||||
text_result = await ocr.recognize_text(image_bytes) # /ocr/text: 文本转录
|
||||
layout = await ocr.parse_layout(image_bytes) # /parse: 版面解析(ZIP 两段协议)
|
||||
health = await ocr.check_health() # 逐源预检 {"monkey_1": True, ...}
|
||||
await ocr.aclose()
|
||||
```
|
||||
|
||||
| 返回 | 关键字段 |
|
||||
|---|---|
|
||||
| `OcrTextResult` | `text`(空串=合法无文字)+ 溯源(source_name/latency_ms/call_id/raw) |
|
||||
| `OcrLayoutResult` | `elements`(带类型元素列表: type/bbox/page_index,type 为开放字符串如 table/text/image)+ `page_sizes` + 溯源 |
|
||||
| `check_health()` | 逐源 bool;探测 5s 级超时,启动门语义 = `all(值)`,可逐源定位坏端点 |
|
||||
|
||||
## 业务侧约定
|
||||
|
||||
- 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 差别很大,建议显式配全。
|
||||
|
||||
> **`/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 一致。总表见 [[解释-治理行为]]。
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
# 指南:响应缓存
|
||||
|
||||
相同请求直接返回缓存结果:零延迟、零费用。缓存命中同样记遥测(`cache_hit=True, latency_ms=0`)。
|
||||
|
||||
## 启用
|
||||
|
||||
```bash
|
||||
PGW_CACHE_BACKEND=redis # redis | memory | none(必填)
|
||||
PGW_CACHE_NAMESPACE=my-project # 启用时必填
|
||||
PGW_CACHE_TTL_S=604800 # 启用时必填,须 > 0(无"永不过期")
|
||||
REDIS_URL=redis://:pass@host:6379/3
|
||||
```
|
||||
|
||||
## key 公式与防毒化
|
||||
|
||||
key = `model + messages 摘要 + namespace + salt + sampling`;多模态 content(base64 图)**先摘要再 hash**,原图不进 key 也不进存储。设计约束:
|
||||
|
||||
| 要素 | 防什么 |
|
||||
|---|---|
|
||||
| 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 各源解码参数不同时,仍可能读到另一源的响应。详见 [[指南-采样参数]]。
|
||||
|
||||
## per-call 控制
|
||||
|
||||
```python
|
||||
resp = await client.chat(messages, cache_salt=f"epoch-{n}") # 换 salt = 强制重采样
|
||||
resp = await client.chat(messages, cache_namespace=tenant_id) # 多租户: 每请求换命名空间
|
||||
```
|
||||
|
||||
科研场景注意:做"同输入重复采样"类实验(信度/方差)时必须用 `cache_salt` 按轮次隔离,否则第二轮起全是缓存命中。
|
||||
|
||||
## 与结构化输出的交互
|
||||
|
||||
- `structured_data` **不进缓存存储**——命中时按**本次调用的 schema** 现场重跑 parse + 校验;
|
||||
- **schema 不进缓存 key**。改了 pydantic 模型后,旧缓存会被自动重校验,校验不过即按未命中回源(伴一条 warning「缓存命中重建失败」)——**无需手工清 Redis 或换 salt**;
|
||||
- **未装 `polygateway[structured]` 时 `structured=` 是硬失败**:`chat()` 在进洋葱之前就抛 `ImportError`,不存在"降级回源"这回事。真实的降级路径只有一条——命中条目重建失败(schema 变更、条目损坏),它带 warning「缓存命中重建失败」后回源,不静默。
|
||||
|
||||
> **`memory` 后端的限制**:纯进程内 dict,**无容量上限、无后台清扫**,过期条目只在同一个 key 被再次读到时才删除——长跑进程会单调增长。它只适合单进程与测试;多进程或长驻服务请用 redis。下面「降级方向」一节描述的是 redis 后端。
|
||||
|
||||
## 降级方向
|
||||
|
||||
Redis 掉线 → 静默当作全部 miss(warning 日志),调用照常走真实上游;损坏的缓存条目反序列化失败也按 miss 处理。缓存永远不会成为可用性瓶颈。
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
# 指南:多源与选源
|
||||
|
||||
多源多账号是一等能力:同一 scope 配任意多个源(不同账号、不同上游、不同参数),库自动选源、失败换源、坏源隔离。
|
||||
|
||||
## 配置多源
|
||||
|
||||
序号 `N` 从 1 递增,PROVIDER 决定协议方言(注册表:qwen/deepseek/openai/minimax 等):
|
||||
|
||||
```bash
|
||||
LLM__MINIMAX__1__BASE_URL=https://gateway-a/v1
|
||||
LLM__MINIMAX__1__API_KEY=sk-aaa
|
||||
LLM__MINIMAX__1__MODEL=MiniMax-M3
|
||||
LLM__MINIMAX__1__TIMEOUT_S=120
|
||||
LLM__QWEN__2__BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
LLM__QWEN__2__API_KEY=sk-bbb
|
||||
LLM__QWEN__2__MODEL=qwen-plus
|
||||
LLM__QWEN__2__TIMEOUT_S=120
|
||||
```
|
||||
|
||||
源名自动生成为 `minimax_1`、`qwen_2`(遥测与异常里的 `source_name` 即此)。
|
||||
|
||||
## 选源策略
|
||||
|
||||
| `LLM__SELECTOR` | 行为 | 何时用 |
|
||||
|---|---|---|
|
||||
| `health_aware`(默认) | EWMA 健康分 × 在途数,P2C 两选一;失败源自动降权,恢复自动回流 | 生产默认,不用配 |
|
||||
| `round_robin` | 轮询 | 想要严格均摊 |
|
||||
| `least_inflight` | 选在途最少 | 源性能差异大 |
|
||||
|
||||
健康视图由每次尝试的结果喂数(真实成功=好;瞬时失败/429/源死=坏;**坏结果不算坏服务**,不降权)。**喂数只发生在 chat 与 OCR 路径**——`EmbeddingClient` 不喂,所以 EMBED scope 的健康分恒为初值,`health_aware` 因此只剩 P2C 随机 + 在途加权(**不等于 `least_inflight`**,详见 [[解释-治理行为]] 的适用性总表)。
|
||||
|
||||
## 换源与冷却行为
|
||||
|
||||
一次调用的重试循环里,每次尝试都重新选源。**两种拒绝的后果不同**:
|
||||
|
||||
- 被**熔断开路**拒绝 → 写本地冷却备忘,冷却期内直接跳过(`per_source_reasons` 记 `cooldown`),不再白烧它的 RPM 去探测;
|
||||
- 被**限流闸**拒绝 → **不写冷却**(记 `rate_limited`),拒绝本身零副作用不消耗配额,下一轮照常参与选源并重试 `try_acquire`;
|
||||
- 上游返回 429 → 也记 `rate_limited`(**与本地限流闸拒绝同值,二者不可区分**)、不写冷却,同时触发 AIMD 并发削减;`adaptive_paced` 是 AIMD pacer 主动拦截时才记的值。
|
||||
|
||||
重试预算是**调用级跨源累计**的(`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)
|
||||
|
||||
SCOPE 是任意大写名,同一进程可装配多个互相独立的 client:
|
||||
|
||||
```python
|
||||
search = GatewayClient.from_env("SEARCH") # SEARCH__*__* 键
|
||||
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 无此约束(按源名懒建状态),可直接共享。
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
# 指南:结构化输出
|
||||
|
||||
让 LLM 返回可校验的结构化数据。策略可插拔:json_repair 事后修复(默认)或上游原生 schema,不二选一。
|
||||
|
||||
## 用法
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
|
||||
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 实例
|
||||
```
|
||||
|
||||
> **库不会把 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]])。
|
||||
|
||||
## 阶梯行为
|
||||
|
||||
以下三步阶梯**只适用于传 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 = 不重问直接抛)。
|
||||
|
||||
最终失败抛 `ResultInvalidError`——**不熔断源**(坏结果 ≠ 坏服务),由业务决定丢弃还是别的处理。重问消耗真实调用(计费计遥测),每跳都有独立遥测行。
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
# 指南:迁移既有项目
|
||||
|
||||
把项目里手写的治理代码(重试循环/熔断器/限流/遥测)删掉换成本库。两个真实项目已走完全程,它们的完整迁移文档是最好的范本。
|
||||
|
||||
## 迁移的标准形状
|
||||
|
||||
| 步骤 | 内容 |
|
||||
|---|---|
|
||||
| 1 基线 | 记录迁移前测试全绿证据(pass/skip 剖面),之后所有验收对照它 |
|
||||
| 2 依赖 | 安装本库(开发期可 editable,定稿 `==1.0.*` + Gitea index) |
|
||||
| 3 冒烟 | 不删任何旧代码,先用 `from_env()` 真实打通一次调用(验证配置与协议兼容) |
|
||||
| 4 配置 | `.env` 改多源键形;新增 `PGW_*` 后端键;韧性平铺键(`LLM_MAX_RETRIES` 等)通常原样保留 |
|
||||
| 5 装配 | 入口处(FastAPI lifespan / arq startup)`from_env()` + 退出 `aclose()`;业务端口后面包一层薄 shim |
|
||||
| 6 清场 | 删除旧治理文件与其自测;业务异常分支改 except 库异常 |
|
||||
| 7 验收 | 原测试全绿(skip 不增)+ 真实冒烟 + 独立复核 |
|
||||
|
||||
## 三条高频经验
|
||||
|
||||
1. **业务端口保留,shim 转换**:项目自己的 `LLMProvider`/`VlmProvider` 协议不用动,写 10-30 行 shim 把库返回值映射回去,业务调用点零改动。`LLMResponse` 前 11 字段与旧三项目逐字保序,多数场景直接 re-export 即可。
|
||||
2. **步级重试要显式接线**:如果项目在治理层之外还有任务级重试(捕 `TimeoutError/OSError` 一类),库异常不是它们的子类——必须显式把 `GatewayUnavailableError` 注入进去,否则那层重试**静默失效**。用这个父类是因为它同时覆盖 `AllSourcesExhausted`(预算耗尽)与 `CircuitOpenError`(源被熔断),后者是单源场景下一次 401/403 之后的常态路径,漏了等于没接。**不要再写 `TransientError`**:它永不穿出 `chat()` / `embed()` / OCR(三处治理循环全量吸收转内部失败,耗尽统一抛 `AllSourcesExhausted`),写了是死分支——按它分流的项目会把 100% 的真实瞬时故障判进不重投的那一支(见 [[参考-异常]])。
|
||||
3. **任务队列消费 `GatewayUnavailableError`**:scope 级不可用时按 `exc.retry_after_s` 延期重投、不消耗业务失败预算,是 arq/celery 场景的标准写法。
|
||||
|
||||
## 范本
|
||||
|
||||
主仓库 `research-wiki/migrations/govdoc-saas.md` 与 `chsanalyzer.md`:含删除清单、组件映射表、调用点清单、逐条行为审计(保留/替换/修复/有意放弃)与分步回滚点。照着结构写你自己项目的迁移清单,基本不会漏。
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
# 指南:遥测与成本
|
||||
|
||||
**每次调用必录**——成功、失败、缓存命中都写一行,这是库铁律。埋点收敛在库内单一 helper,业务侧零埋点代码。
|
||||
|
||||
注意**一行 = 一次尝试,不是一次 `chat()`**:重试/换源时每次尝试各写一行,scope 级失败或取消再多一行溯源字段置空的终态行。库不会自动把这些行串起来——要按业务调用聚合,必须自己每次 `chat()` 传 `session_id` / `parent_call_id`。
|
||||
|
||||
## 启用
|
||||
|
||||
```bash
|
||||
PGW_TELEMETRY_BACKEND=sqlite # sqlite | postgres | none(必填)
|
||||
PGW_TELEMETRY_SQLITE_PATH=logs/telemetry.db # sqlite 时必填
|
||||
# PGW_TELEMETRY_PG_DSN=postgresql://user:pass@host:5432/polygateway # postgres 时必填
|
||||
```
|
||||
|
||||
实验室纪律:PG DSN 只许指向专用库 `polygateway`,严禁在用业务库。
|
||||
|
||||
## 表结构(`llm_calls`,22 列)
|
||||
|
||||
| 字段组 | 字段 |
|
||||
|---|---|
|
||||
| 链路 | call_id(主键,幂等)/ parent_call_id / session_id |
|
||||
| 身份 | model / provider / source_name |
|
||||
| 内容 | messages / response / thinking(多模态 part 摘要落库,不存原图) |
|
||||
| 用量 | prompt_tokens / completion_tokens / usage_source(三态,见下) |
|
||||
| 时延 | latency_ms / ttft_ms / max_inter_token_ms |
|
||||
| 结果 | 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(库自动填,不由调用方传)。**两后端类型与时区不同**:SQLite 是 `TEXT` + `datetime('now')`,存的是 **UTC 且无时区标记**;Postgres 是 `TIMESTAMPTZ` + `now()`。按本地时间窗查 SQLite 需写 `datetime(created_at,'localtime')` |
|
||||
|
||||
表是 22 列,但 `TelemetryRecorder` 端口是 21 个参数——差的正是 `created_at`(由数据库默认值生成)。自定义遥测后端实现该端口时按 21 个关键字参数接。
|
||||
|
||||
v1.0.4 新增的两列会**自动补到已存在的旧表上**,无需手工迁移;历史行的新列为 NULL。两个后端都是先探测缺列、只在真缺列时才 ALTER——稳态下一条 ALTER 都不发(`ADD COLUMN IF NOT EXISTS` 即使列已存在也会先取排他锁,而遥测是内联写入,锁住共享审计表会拖慢业务调用);补列失败也只是这几行遥测被丢弃,不会让遥测整体停摆。
|
||||
|
||||
`session_id`/`parent_call_id` 由调用方传入(`client.chat(..., session_id=...)`),用于把一次业务任务下的多次调用串成链。
|
||||
|
||||
## usage_source 三态(v1.0.3 起)
|
||||
|
||||
| 值 | 含义 | 什么时候出现 | cost |
|
||||
|---|---|---|---|
|
||||
| `measured` | 用量帧完整可信 | 正常路径;OCR 成功行(0 token 是事实,不是未知) | 按 token 换算。**但 OCR 成功行 cost 恒 NULL**——`OcrClient` 不持有价格表(`PGW_PRICING_PATH` 对它不注入),且不告警 |
|
||||
| `estimated` | 有实测数字但可信度降级 | 打捞路径:收到 usage 帧但流被截断(`MISSING_DONE=salvage`) | 按 token 换算 |
|
||||
| `unavailable` | 用量信息不可得 | 上游没返回 usage 帧、失败的尝试、终态失败 | **NULL** |
|
||||
|
||||
v1.0.3 前只有前两态,且上游缺 usage 时库会拿配置的 `EST_TOKENS` 当实测值记账——那是个"最坏情形上界",按它计费只会系统性虚高。现在这类行如实记 `0/0` + `unavailable` + `cost=NULL`。历史数据里的 `estimated` 行语义不变、照常可读。
|
||||
|
||||
## 成本
|
||||
|
||||
```bash
|
||||
PGW_PRICING_PATH=config/prices.json
|
||||
```
|
||||
|
||||
```json
|
||||
{"MiniMax-M3": {"input_per_1m": 2.1, "output_per_1m": 8.4, "cached_input_per_1m": 0.42}}
|
||||
```
|
||||
|
||||
配了价格表后每行遥测带 `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'
|
||||
> AND provider <> 'monkey' -- OCR 成功行 cost 恒 NULL,不是漏价
|
||||
> 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`。
|
||||
|
||||
因此 `SUM(cost)` 天然跳过不可得的行,而账目缺口要这样量化:
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) FROM llm_calls
|
||||
WHERE usage_source = 'unavailable' AND cache_hit = false;
|
||||
```
|
||||
|
||||
`AND cache_hit = false` 不可省 —— 漏掉它会把本无缺口的缓存命中行灌进来,度量偏高。若你的成本汇总此前依赖"cost 非空"这个隐含假设,升级到 v1.0.3 时请复核。
|
||||
|
||||
## 降级方向
|
||||
|
||||
遥测后端不可用 → warning 后静默丢弃该行,**绝不影响业务调用**。共享后端注意:不要在真实批跑期间并发跑库的集成测试(时序隔离,详见主仓库 CLAUDE.md)。
|
||||
|
||||
## 供应商 prompt cache(v1.0.4)
|
||||
|
||||
`cached_prompt_tokens` 是**供应商服务器**复用了你的提示词前缀、按更低单价计费的那部分输入 token 数。它和 `cache_hit` 是两件事:
|
||||
|
||||
| | `cache_hit` | `cached_prompt_tokens` |
|
||||
|---|---|---|
|
||||
| 指的是 | **PolyGateway 自己的** Redis 响应缓存 | **供应商侧**的 prompt cache |
|
||||
| 有没有联网 | 没有,直接返回旧答案 | 联了,只是对方省了算力 |
|
||||
| 花不花钱 | 不花(cost 恒 0.0) | 花,但命中那部分打折 |
|
||||
|
||||
`None` 和 `0` 必须分开看:`None` = 这个源不上报这个数(你无法对它做缓存成本校正,论文里该声明),`0` = 它上报了,这次真的一次都没命中。
|
||||
|
||||
**统计命中率时 `WHERE cache_hit = false` 不可省**:
|
||||
|
||||
```sql
|
||||
-- 注: 下面用的是通用写法; ::float 是 Postgres 语法,sqlite 请用 1.0 * SUM(...)
|
||||
SELECT 1.0 * SUM(cached_prompt_tokens) / NULLIF(SUM(prompt_tokens), 0)
|
||||
FROM llm_calls
|
||||
WHERE cache_hit = false AND cached_prompt_tokens IS NOT NULL;
|
||||
```
|
||||
|
||||
原因和上面 cost 缺口的口径一样:缓存命中行里这两个字段是**原样回放**的历史值,计进去就是重复计数。命中行被覆写的只有:**换新 `call_id`**(主键幂等要求,与被复用的原始行没有任何关联,不能用于溯源 join)、`cache_hit=true`、时延三件套清零、`cost` 重算为 `0.0`;其余**响应侧**字段(`model` / `model_reported` / `prompt_tokens` / `cached_prompt_tokens` 等)是回放值。但 `session_id` / `parent_call_id` / `messages` 取自**本次调用**(`LLMResponse` 根本没有这两个字段)——所以命中行的溯源列归属当次会话,可以按 session 聚合,只是 `call_id` 是新 uuid、无法 join 回被复用的原始行。
|
||||
|
||||
`model_reported` 是 API 响应体里实际返回的 model,和 `.env` 里配的别名可能不是一个东西——供应商把别名指向新权重时,只有它认得出当时真正跑的版本。要做可复现的实验快照,记这一列。
|
||||
|
||||
## 采样参数 `sampling`(v1.0.5)
|
||||
|
||||
「调用方传的 ⊎ 生效源的 `EXTRA_BODY`」的规范化 JSON,没传则 NULL。有了它,"这批数据跑在什么解码条件下"才在事后可查——这和 `model_reported` 是同一类需求。
|
||||
|
||||
```sql
|
||||
SELECT DISTINCT sampling FROM llm_calls
|
||||
WHERE session_id = 'exp-42' AND cache_hit = false AND error IS NULL;
|
||||
```
|
||||
|
||||
两点口径:① 这一列**不含**结构化输出注入的 `response_format`(列名是采样参数,schema 不是,且数 KB 的 schema 逐行落库只会让审计表膨胀);② 缓存命中行与最终失败行只记调用级参数,不含源的 `EXTRA_BODY`——那两条路径没有"生效源"可言,与 `model`/`source_name` 在失败行置空是同一回事;命中行也不损失信息,因为采样参数已进缓存 key,能命中就意味着调用级参数与历史那次逐字相同。
|
||||
|
||||
OCR / Embedding 路径的这一列**恒为 NULL**:那两条路径不发采样参数,配了 `EXTRA_BODY` 也会在装配时被剥离。详见 [[指南-采样参数]]。
|
||||
|
||||
补列规则同 v1.0.4 那两列:自动补到旧表、先探测再 ALTER、失败只丢这几行。
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
# 指南:固定解码参数(temperature / seed / max_tokens)
|
||||
|
||||
受控实验要求解码行为可复现:温度必须钉死,每个 rollout 的 seed 必须显式受控并记进快照。本页讲怎么配、以及三个不配就会静默出错的地方。
|
||||
|
||||
## 两个层次,按参数是否随调用变化来选
|
||||
|
||||
| 层次 | 怎么配 | 适合 |
|
||||
|---|---|---|
|
||||
| 配置级 | `.env` 里 `{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY={"temperature":0}` | 全局恒定的参数。**推荐**:不必让每个调用点都记得传,而漏传一次不会报错,只会让数字悄悄不可比 |
|
||||
| 调用级 | `await client.chat(msgs, overlay={"seed": 42})` | 逐次变化的参数 |
|
||||
|
||||
```python
|
||||
# .env: LLM__QWEN__1__EXTRA_BODY={"temperature":0,"top_p":1}
|
||||
for seed in range(5):
|
||||
resp = await client.chat(messages, overlay={"seed": seed})
|
||||
# 请求体最终是 {"temperature":0, "top_p":1, "seed":<seed>, ...}
|
||||
```
|
||||
|
||||
优先级 **结构化输出注入 > 调用级 `overlay` > 源级 `EXTRA_BODY`**。结构化输出排最高是因为它关系到响应能否被解析:用**原生 schema 策略**时,`structured=` 会覆盖你传的 `overlay={"response_format": ...}`。但注意:该策略要求 scope 内全部源的 `supports_native_schema` 为真,而**内置 profile 全为 `False`**——经 `from_env` 装配的恒是 json_repair 策略,它不改请求体,所以实践中你传的 `response_format` 会照发(它不在保护键里,库不拦)。详见 [[指南-结构化输出]]。
|
||||
|
||||
## 三个坑
|
||||
|
||||
**① 逐次变化的 `seed` 会让缓存全部 miss。** 采样参数进缓存 key,这是有意的:不进的话,同样的 messages 跑 5 个 seed 会全部命中第一次的响应,**报出的标准差恒为 0 且不报错**,整批实验静默作废。代价是这条路径不再省钱。不传采样参数时 key 与旧版逐字相同,存量缓存不受影响。
|
||||
|
||||
**② 缓存身份是 scope 级的,不是源级的。** 源的 `EXTRA_BODY` 会并进缓存指纹,但那是**全 scope 所有源的合集指纹**。同一个 scope 下若各源的 `EXTRA_BODY` 不同,缓存仍可能把 A 源(temperature=0)的响应返回给本该走 B 源(temperature=1)的调用。要求逐源可复现的实验,请让每个源独享 scope,或用 `chat(cache_namespace=...)` 区分。
|
||||
|
||||
**③ OCR / Embedding scope 配了 `EXTRA_BODY` 不生效。** 这两条路径的请求根本不带它(embedding 请求体只有 `model` 和 `input`,OCR 走 multipart 表单)。配了会被剥离并发一条 warning,装配照常成功。剥离不只是打扫:不剥的话遥测的 `sampling` 列会记下一个从未发出去的参数,那比"参数没生效"更糟——审计表会说这次调用跑在 temperature=0 下,而事实并非如此。需要 `dimensions` 之类的 embedding 参数,请提 issue。
|
||||
|
||||
## 哪些键不能传
|
||||
|
||||
`model` / `messages` / `stream` / `stream_options` 由治理层拥有,传了直接 `ValueError`:
|
||||
|
||||
| 键 | 覆盖后果 |
|
||||
|---|---|
|
||||
| `model` | 遥测记录的 model 与实际请求分叉,成本按错单价换算 |
|
||||
| `messages` | 缓存 key 与遥测口径同时失真 |
|
||||
| `stream` | 绕过流式活性看门狗,TTFT / inter-token 超时全部失效 |
|
||||
| `stream_options` | 丢 usage 帧,成本遥测归零、TPM 闸结算失准 |
|
||||
|
||||
值也必须能 JSON 序列化——`numpy` 标量请先 `float()`。这条同样在调用入口就报错,不会等到运行中途:否则它会在缓存层的降级保护之外抛出裸 `TypeError`,连一行遥测都留不下。
|
||||
|
||||
## 事后复现:参数进了遥测
|
||||
|
||||
每行 `llm_calls` 有 `sampling` 列,内容是「调用方传的 ⊎ 生效源的 `EXTRA_BODY`」的规范化 JSON,没传则为 NULL。它**不含**结构化输出的 `response_format`(列名是采样参数,schema 不是,且逐行落库会让审计表膨胀)。
|
||||
|
||||
```sql
|
||||
-- 某批实验实际跑在什么解码条件下
|
||||
SELECT DISTINCT sampling FROM llm_calls
|
||||
WHERE session_id = 'exp-42' AND cache_hit = false AND error IS NULL;
|
||||
```
|
||||
|
||||
缓存命中行与最终失败行的这一列只记调用级参数,不含源的 `EXTRA_BODY`——那两条路径没有"生效源"可言(与 `model`/`source_name` 在失败行置空是同一回事)。命中行不损失信息:采样参数已进缓存 key,能命中就意味着调用级参数与历史那次逐字相同。
|
||||
|
||||
## 顺带一提:`ENABLE_THINKING` 对某些 provider 无效
|
||||
|
||||
`openai` 与 `minimax` 两个 provider 的 thinking 注入片段是空的——它们没有已知的推理开关参数,所以 `ENABLE_THINKING=false` 对这两类源**不产生任何效果**,而不是静默生效。真需要下发关闭推理的参数,用 `EXTRA_BODY`。
|
||||
|
||||
全部 env 键见 [[参考-配置键]];`chat()` 签名见 [[参考-公共API]];遥测列口径见 [[指南-遥测与成本]]。
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
# 指南:限流与熔断
|
||||
|
||||
## 限流:六道闸
|
||||
|
||||
并发 / RPM / TPM × 单源 / 全局,共六道,全过才放行;拒绝零副作用(不部分计数)。0 或缺省 = 该闸不启用。
|
||||
|
||||
> **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`。
|
||||
|
||||
```bash
|
||||
LLM__QWEN__1__MAX_CONCURRENCY=8 # 单源并发
|
||||
LLM__QWEN__1__RPM=60 # 单源每分钟请求
|
||||
LLM__QWEN__1__TPM=100000 # 单源每分钟 token;照配额页填即可,EST_TOKENS 可不填
|
||||
LLM__QWEN__1__EST_TOKENS=2000 # TPM 预扣依据;调用后按实际用量结算退款
|
||||
LLM__GLOBAL__MAX_CONCURRENCY=16 # scope 级跨源合计
|
||||
LLM__GLOBAL__RPM=120
|
||||
```
|
||||
|
||||
配额满时的行为由 `LLM__QUOTA_FULL` 决定:`wait`(默认,带 stall 判死的等待)或 `fail_fast`。
|
||||
|
||||
## 后端选择(关键决策)
|
||||
|
||||
| `PGW_LIMITER_BACKEND` / `PGW_BREAKER_BACKEND` | 语义 |
|
||||
|---|---|
|
||||
| `memory` | 单进程内计数。**多进程 worker 下限额是每进程各一份**,会超配 |
|
||||
| `redis` | 跨进程原子(Lua 脚本),多 worker 共享同一份限额与熔断状态;需 `REDIS_URL` |
|
||||
|
||||
**降级方向铁律**:Redis 掉线时限流/熔断**报错而不放行**(`GovernanceBackendError`)——宁可拒绝也不击穿上游。缓存/遥测则相反(静默降级)。注意这条 fail-closed **只覆盖准入侧**(问闸);调用已发出后的记账写回失败是 warning 降级不冒泡,故障期租约靠 TTL 回收、TPM 差额不结算,详见 [[解释-降级与取消]]。
|
||||
|
||||
## 熔断:双通道 + 半开单探针
|
||||
|
||||
| 通道 | 触发 | 键 |
|
||||
|---|---|---|
|
||||
| 连续失败 | 连续失败 ≥ 阈值。有效值 = 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`;冷却结束进入半开,**只放一个探针**(带租约,持有者崩溃后租约过期自动可再探);探针成功即闭合。**但本地冷却备忘不随之撤销**:备忘时长取自熔断后端返回的 `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)一击即熔,不走计数。
|
||||
|
||||
## 常见问答
|
||||
|
||||
- **单源也要配熔断吗?** 要。单源熔断的意义是把"反复打必死的上游"变成快速失败 + `retry_after_s`,让任务队列延期重投。
|
||||
- **测试怎么不等真实冷却?** 后端构造函数支持 `now=` 时钟注入(必须构造时传入);集成测试建议直接用真实等待(本库测试口径)。
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
# 教程:十分钟接入
|
||||
|
||||
目标:从零装好库,发起第一次治理调用,看到重试/缓存/遥测真的在工作。
|
||||
|
||||
## 1. 安装
|
||||
|
||||
```bash
|
||||
conda activate <你的项目环境> # Python ≥ 3.11
|
||||
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
|
||||
"polygateway[redis,structured]==1.0.*"
|
||||
```
|
||||
|
||||
extras 按需:`redis`(Redis 限流/熔断/缓存)、`postgres`(PG 遥测)、`structured`(结构化输出修复)。
|
||||
|
||||
## 2. 写最小 `.env`
|
||||
|
||||
项目根目录建 `.env`(不提交 git):
|
||||
|
||||
```bash
|
||||
LLM__MINIMAX__1__BASE_URL=https://你的网关/v1
|
||||
LLM__MINIMAX__1__API_KEY=sk-xxx
|
||||
LLM__MINIMAX__1__MODEL=MiniMax-M3
|
||||
LLM__MINIMAX__1__TIMEOUT_S=120
|
||||
LLM_MAX_RETRIES=3
|
||||
LLM_RETRY_BASE_DELAY=2.0
|
||||
LLM_RETRY_MAX_DELAY=30.0
|
||||
LLM_CIRCUIT_BREAKER_THRESHOLD=5
|
||||
LLM_CIRCUIT_BREAKER_COOLDOWN=60
|
||||
PGW_LIMITER_BACKEND=memory
|
||||
PGW_BREAKER_BACKEND=memory
|
||||
PGW_CACHE_BACKEND=none
|
||||
PGW_TELEMETRY_BACKEND=none
|
||||
```
|
||||
|
||||
键名含义先不管,记住一件事:**缺关键键会在装配时直接报错**——报什么补什么,不会有默认值悄悄兜底。全部键见 [[参考-配置键]]。
|
||||
|
||||
## 3. 第一次调用
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from polygateway import GatewayClient
|
||||
|
||||
async def main() -> None:
|
||||
client = GatewayClient.from_env("LLM")
|
||||
try:
|
||||
resp = await client.chat([{"role": "user", "content": "你好"}])
|
||||
print(resp.content)
|
||||
print(f"源={resp.source_name} 耗时={resp.latency_ms}ms tokens={resp.prompt_tokens}+{resp.completion_tokens}")
|
||||
finally:
|
||||
await client.aclose() # 或用 async with GatewayClient.from_env("LLM") as client:
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
`from_env("LLM")` 一行装配了整套治理栈:限流闸、熔断门、重试循环、看门狗、遥测。`aclose()` 归还 transport 连接池、遥测连接与缓存客户端(限流/熔断后端不在其中,见 [[参考-公共API]])(FastAPI 放 lifespan、arq 放 shutdown)。
|
||||
|
||||
## 4. 看见治理在工作
|
||||
|
||||
把 `.env` 里 API_KEY 改成错的再跑一次——你会得到 `CircuitOpenError` 而不是裸的 401:库先按分类判定(401 = 源失效)、立即熔断该源,下一轮发现无源可用即抛出带 `retry_after_s` 的结构化异常。**要一网打尽请捕父类 `GatewayUnavailableError`**:重试次数耗尽走的是 `AllSourcesExhausted`,源被熔断走 `CircuitOpenError`,两者同父不同类,词表见 [[参考-异常]]。改回正确 key,再把遥测打开:
|
||||
|
||||
```bash
|
||||
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'` 能看到每次**尝试**一行(含失败与缓存命中)——重试/换源时一次 `chat()` 会产生多行,要聚合请传 `session_id`。
|
||||
|
||||
## 5. 业务侧只需要认两个异常
|
||||
|
||||
```python
|
||||
from polygateway import GatewayUnavailableError, RequestRejectedError
|
||||
|
||||
try:
|
||||
resp = await client.chat(messages)
|
||||
except GatewayUnavailableError as exc: # 整个 scope 暂时无源: 延期重投
|
||||
print(exc.reason, exc.retry_after_s, exc.per_source_reasons)
|
||||
except RequestRejectedError: # 请求本身的问题: 别重试
|
||||
raise
|
||||
```
|
||||
|
||||
瞬时错误、换源、退避这些都在库内消化;能穿出来的只有"值得业务决策"的异常。全表见 [[参考-异常]]。
|
||||
|
||||
## 下一步
|
||||
|
||||
- 多个账号/多个上游 → [[指南-多源与选源]]
|
||||
- 跨进程 worker 共享限流 → [[指南-限流与熔断]]
|
||||
- 想省钱/加速重复调用 → [[指南-响应缓存]]
|
||||
- 要 LLM 返回可校验的 JSON → [[指南-结构化输出]]
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
# 解释:架构
|
||||
|
||||
## 一句话
|
||||
|
||||
端口适配器 + 中间件洋葱:**决策逻辑只有一份,状态存储可插拔**。限流算法不知道自己背后是进程内计数器还是 Redis Lua;换后端不改一行治理代码。
|
||||
|
||||
## 洋葱结构
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[业务代码] --> B[GatewayClient]
|
||||
B --> C[遥测 MW] --> D[缓存 MW] --> S[结构化 MW] --> E[重试·选源·限流·熔断 MW]
|
||||
E --> F[Transport httpx]
|
||||
F --> G[(上游网关)]
|
||||
E -.端口.-> H[(内存 / Redis 后端)]
|
||||
C -.端口.-> I[(SQLite / Postgres)]
|
||||
```
|
||||
|
||||
层序理由:**遥测最外**——缓存命中、scope 级失败、取消这些尝试层根本看不见的事件也必须留痕;缓存其次(命中则里面全免);结构化在缓存之内——带反馈重问经内层重试逐次照过限流/熔断并逐次遥测,而缓存只固化阶梯通过的最终响应;重试在最内包 transport(每次尝试都重新过选源/限流/熔断——多源语义的前提)。
|
||||
|
||||
## 模块与依赖纪律
|
||||
|
||||
| 模块 | 职责 | 依赖约束 |
|
||||
|---|---|---|
|
||||
| `types.py` / `errors.py` / `ports.py` | 冻结类型、四分类异常、全部 Protocol | 最内层,不 import 任何实现 |
|
||||
| `middleware/` | 治理算法 | 只依赖端口 |
|
||||
| `transports/` | 协议细节 + 错误翻译 | 只实现端口 |
|
||||
| `backends/` / `telemetry/` | 状态存储实现 | 只实现端口,互不依赖 |
|
||||
| `client.py` / `config.py` | 组装与配置解析 | 唯一知道具体实现的地方 |
|
||||
|
||||
import-linter 把这套约束做成 CI 硬门(`make lint`)。
|
||||
|
||||
## 三条设计铁律(为什么这样)
|
||||
|
||||
1. **零业务假设**:库内不出现任何下游领域词汇;扩展点一律 Protocol。它是被多项目依赖的库,一个业务假设就是对所有其他下游的污染。
|
||||
2. **纯 asyncio 中立**:无全局状态、无模块级单例;同一个 client 在 arq worker 和裸脚本里行为一致。框架适配是业务侧一层薄壳的事。
|
||||
3. **错误分类驱动**:transport 层把一切失败翻译成四分类,治理行为(重试/换源/熔断)只看分类——判断集中一处,不散落在各调用点(这正是旧三项目各写一份 ad-hoc 判断的教训)。
|
||||
|
||||
完整决策记录(D1-D14,含被否决的备选与论证过程)见主仓库 `research-wiki/ARCHITECTURE.md`。
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
# 解释:治理行为
|
||||
|
||||
这些机制不是拍脑袋设计的——来自对故障混编压测(8000 调用,坏 key/黑洞/慢源/限流源混编)从 58.1% 到 98.96% 的多轮数据驱动迭代。每条都对应一个真实观测到的病灶。
|
||||
|
||||
## 健康感知选源(缺省)
|
||||
|
||||
**病灶**:轮询把 1/N 的流量持续喂给坏源。**机制**:每源维护成功率 EWMA 与在途数,选源时随机取两个候选比较(P2C),分高者当头名,其余按分数降序。选源器**没有阈值分支**——坏源仍保有 1/N²(两源池 25%、四源池约 6%)的首选概率,这是有意留的探索通道,好让它恢复后能被发现;只有在**同一次调用内连败 ≥2 次**且存在可信替代(健康分 ≥ 失败源一半)时才被降权到替代之后。真正把坏源隔离掉的是熔断,选源只负责压低它的吸流占比。其中「同一次调用内连败后降权重排」是 **chat 路径限定**——OCR 照常喂健康分(跨调用选源受益)但同调用内不重排,EMBED 两者皆无。
|
||||
|
||||
## 机制 × 路径适用性(单一事实源)
|
||||
|
||||
本表逐函数对照 `retry.py` / `ocr.py` / `embedding.py` 三条治理循环得出;其他页涉及路径差异时请链回本表,不要各自复述。
|
||||
|
||||
| 机制 | chat | OCR | EMBED | 依据 |
|
||||
|---|---|---|---|---|
|
||||
| 429 **不耗**重试预算 | ✔ | ✘ | ✘ | `retry.py:232` 的 `!= "rate_limited"` 判断只有 chat 有 |
|
||||
| 退避**按 Retry-After 取大** | ✔ | ✘ | ✔ | `backoff_delay` 三路共用,但 OCR 的 transport 不解析 `Retry-After`、不带 `retry_after_s`,故对 OCR 恒为 0 |
|
||||
| AIMD 自适应并发(`adaptive_paced`) | ✔ | ✘ | ✘ | `AdaptivePacer` 只在 `retry.py` |
|
||||
| 健康分喂数(跨调用降权/回流) | ✔ | ✔ | ✘ | `record_outcome` 见 `retry.py`、`ocr.py`;`embedding.py` 零命中 |
|
||||
| 同一次调用内连败降权重排 | ✔ | ✘ | ✘ | `_demote_call_failures` 唯一调用点在 `retry.py:251` |
|
||||
| 成本换算(**遥测行**的 `cost`) | ✔ | ✘ | ✔ | `OcrClient` 不持有 pricing,成功行 `cost` 恒 NULL 且不告警 |
|
||||
| 成本回填(**响应对象**的 `.cost`) | ✘ | ✘ | ✔ | `LLMResponse.cost` 被硬编码 `None`(`retry.py:437`),只有 `EmbeddingResponse.cost` 真填。**两件事别混**:chat 有遥测成本、没有响应成本 |
|
||||
| 响应缓存 | ✔ | ✘ | ✘ | `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 即开路);同时连败通道受健康证据抑制——窗口样本充足且失败率低时,连败不开路。两通道互补,半死源提前隔离、健康源免误伤。
|
||||
|
||||
## 429 pushback
|
||||
|
||||
**病灶**:高峰期 429 烧光重试预算,调用在"其实再等等就好"的场景下失败。**机制**:429 不消耗重试预算、不计入熔断,按 Retry-After(与退避取大者)等待;防饿死靠调用级双条件 stall 判死——本地等待超窗**且**全局无任何进展才放弃。代价是饱和期延迟拉长,换来成功率。
|
||||
|
||||
## AIMD 自适应并发
|
||||
|
||||
**病灶**:冷启动瞬间全并发涌向单源,触发链式 429。**机制**:每源并发从 8 起步,成功缓升(+1/limit)、429 减半,封顶 max(64, 配置并发)。TCP 拥塞控制同款,库常量非配置项。**仅 chat 路径**:`OcrClient` / `EmbeddingClient` 的治理循环没有 pacer,并发只受 `MAX_CONCURRENCY` 闸约束,它们的 `per_source_reasons` 里**永远不会出现 `adaptive_paced`**。
|
||||
|
||||
## 半开单探针 + 租约 + epoch fencing
|
||||
|
||||
**病灶**:冷却结束的瞬间所有等待者同时探测(惊群);探针持有者崩溃导致源永久开路;上一世代的慢响应迟到后污染新状态。**机制**:半开只放一个探针,探针带 TTL 租约(死亡自动回收),每次开路递增 epoch,写回时 fencing 校验——迟到结果 applied=False 被拒。
|
||||
|
||||
## 限流的结算语义
|
||||
|
||||
TPM 按**有效预扣量**入场——显式配了 `EST_TOKENS` 就用它,没配则按 `max(1, TPM // 60)` 派生(v1.0.3 起;此前 TPM>0 时 `EST_TOKENS` 必填)。完成后按实际 usage **落回 acquire 时刻的窗口**多退少补;瞬时失败按预扣量保守结算,4xx/源死/取消全额退款。拒绝零副作用:六道闸任一不过,已过的闸不留计数。并发槽带租约,进程死亡后自动回收。
|
||||
|
||||
上游没返回 usage 帧时,结算仍按预扣量走(差额为 0,押金留存)——**这与遥测口径是两回事**:限流侧宁可保守占额,遥测侧则如实记 `usage_source='unavailable'` 且 cost 为 NULL,不拿预扣量冒充实测用量([[指南-遥测与成本]])。同一个数字曾同时充当这两个角色,而"保守"在限流语境是安全的、在计费语境只会让账单虚高,故 v1.0.3 拆开二者。
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
# 解释:错误四分类
|
||||
|
||||
## 问题
|
||||
|
||||
调用失败后该干什么?重试、换账号、熔断、还是直接放弃——如果每个调用点自己看状态码决定,判断会写得到处都是且互相矛盾(三个前身项目的实际教训:同一个 429 在不同文件里有三种处理)。
|
||||
|
||||
## 方案
|
||||
|
||||
失败定性收敛到 transport 层,输出四个语义类别;治理层只消费类别:
|
||||
|
||||
| 分类 | 本质问题 | 正确反应 | 为什么 |
|
||||
|---|---|---|---|
|
||||
| Transient | 这次运气不好 | 换源重试 + 退避 | 再试大概率就好;换源避开局部故障 |
|
||||
| SourceDead | 这个账号/源废了 | 立即熔断 + 换源 | 401/欠费重试一万次也不会好,快隔离止损 |
|
||||
| RequestRejected | 请求本身有问题 | 快速失败 | 换源重试只会烧钱重复同一个 400 |
|
||||
| ResultInvalid | 服务没问题,结果不合格 | 不熔断;按策略重问或上抛 | **坏结果 ≠ 坏服务**——因内容问题惩罚源会误杀健康源 |
|
||||
|
||||
## 几个边界裁决(容易搞错的)
|
||||
|
||||
- **429 归 Transient 但特殊**:它是上游的"慢点"信号(pushback),不计入熔断失败率——这一条三路径通用,否则高峰期会把健康源全熔掉。但 pushback 的其余待遇(不耗重试预算、按 Retry-After 等待、靠调用级 stall 判死)**按路径而异**,逐项对照见 [[解释-治理行为]] 的适用性总表,本页不复述。
|
||||
- **429 + insufficient_quota 归 SourceDead**:欠费不是限流,等多久都没用。
|
||||
- **HTTP 响应本身证明服务活着**:即使是业务层面的失败响应(如 OCR 返回 success=false),熔断记账也算成功——熔断度量的是"服务是否可达",不是"结果是否满意"。
|
||||
- **SSE 截断(收到内容但缺 [DONE])归 Transient** 且不写缓存——把半截响应当成功缓存住是前身项目的真实事故。**这条只在默认 `MISSING_DONE=retry` 下成立**:配成 `salvage` 时,有内容的截断会被打捞成正常响应返回**并照常写入缓存**——只有在截断前已收到 usage 帧时才标 `usage_source=estimated`,而库强制 `include_usage`、usage 帧紧邻 `[DONE]`,所以**常态是标 `unavailable`、tokens=0、cost=NULL**,打捞响应无法只靠 `usage_source` 识别,整个 TTL 内被复用——正是这句声称已防住的那起事故。零内容断流(early_eof)无论怎么配都是 Transient。
|
||||
- **空补全(200、流程完整但 content 空白)归 Transient**:按服务抖动处理,退避重试/换源,绝不缓存。库不会返回 `content=''` 的成功响应;模型合法返回空串的场景需业务侧改 prompt。
|
||||
|
||||
scope 级"无源可用"是另一层:四分类描述单次尝试,`GatewayUnavailableError` 族描述整个 scope 的暂时不可用(带 retry_after_s 供任务队列延期)。
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
# 解释:降级方向与取消语义
|
||||
|
||||
## 降级方向:两类后端,两种反应
|
||||
|
||||
| 后端 | 掉线时 | 为什么 |
|
||||
|---|---|---|
|
||||
| 缓存 / 遥测 | **静默降级**(记 warning,业务零感知) | 它们是增值件;为了省钱/观测把业务打挂,本末倒置 |
|
||||
| 限流 / 熔断(**准入侧**) | **报错(`GovernanceBackendError`),绝不放行** | 它们是保护件;"后端坏了就裸放"等于高峰期无限流打爆上游——恰好是最需要保护的时刻 |
|
||||
| 限流 / 熔断(**记账写回侧**) | 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 日志而非只盯异常率。
|
||||
|
||||
## 取消语义:CancelledError 全链路穿透
|
||||
|
||||
`asyncio.CancelledError` 在库内**永不捕获吞没**:
|
||||
|
||||
| 环节 | 行为 |
|
||||
|---|---|
|
||||
| 重试循环 | 取消直接穿透,不算失败、不触发重试 |
|
||||
| 限流等待 / 退避 sleep | 可被取消;已取得的 permit 在 finally 归还 |
|
||||
| 流式读取 | 取消中断读取,连接在 finally 释放 |
|
||||
| 半开探针 | 探针持有者被取消 → 归还探针(源保持开路,下一个调用可再探),不判成败 |
|
||||
| 遥测 | **取消留痕,但只有终局行是必有的**。最外层那行 `error='cancelled'`(source_name 为空)恒写;尝试层那行(带 source_name)**仅当取消恰好落在 transport 调用进行中**才有——退避 sleep、配额满轮询等待、选源准入这三段都在尝试层 try 之外被取消,不产生尝试行。而 arq 超时高发的正是配额满等待期。**统计取消一律以终局行为准**,别按 call_id 配对或按 source_name 归因;两类行均 `usage_source='unavailable'`、`cost=NULL`,算成功率时请排除 `error='cancelled'` |
|
||||
|
||||
设计动机:上层(arq 任务超时、用户中断)取消时,库必须立刻让路且不留悬挂资源——租约归零、探针不悬挂、in-flight 清零在压测中是持续验证的不变量。
|
||||
|
||||
## 对业务代码的含义
|
||||
|
||||
`asyncio.CancelledError` 继承 `BaseException`(Python 3.8+),所以业务侧的 `except Exception` **并不会**吞掉取消;真正会吞的是裸 `except:`、`except BaseException`,以及在 `finally` 里 await 阻塞操作。
|
||||
|
||||
不建议用 `except Exception` 包住库调用的真实理由是另一条:它会一并吞掉 `RequestRejectedError` 等四分类异常,让确定性失败被当成偶发错误重投。需要兜底时精确捕获 `PolyGatewayError` 层级。库的资源释放走 `aclose()` + finally(或 `async with`),业务侧照做同样的模式即可获得同样的保证。
|
||||
Reference in New Issue
Block a user