docs: full documentation set (Diataxis: tutorial, how-to, reference, explanation)

iomgaa
2026-07-23 03:52:59 -04:00
parent 7b3777a094
commit 9745aa771c
18 changed files with 687 additions and 1 deletions
+24 -1
@@ -1 +1,24 @@
初始化中 # PolyGateway 文档
实验室统一的大语言模型调度与中转库:LLM / VLM / OCR / Embedding 共用同一套生产级治理栈(多源多账号、限流、错误分类重试、熔断、响应缓存、流式看门狗、遥测与成本)。治理单位是**一次模型调用**;任务编排与业务解析留在业务侧。
当前版本 **v1.0.0**,已通过 GovDoc-SaaS 与 CHSAnalyzer 两个真实项目的全量迁移验收。
## 文档地图(按你此刻要干什么选入口)
| 你想 | 去 |
|---|---|
| 第一次用,想被领着走通一遍 | [[教程-十分钟接入]] |
| 有明确任务,查怎么配 | 指南区: [[指南-多源与选源]] / [[指南-限流与熔断]] / [[指南-响应缓存]] / [[指南-遥测与成本]] / [[指南-结构化输出]] / [[指南-OCR]] / [[指南-Embedding]] / [[指南-迁移既有项目]] |
| 查签名、字段、配置键 | 参考区: [[参考-公共API]] / [[参考-配置键]] / [[参考-异常]] |
| 理解设计与行为逻辑 | 解释区: [[解释-架构]] / [[解释-错误四分类]] / [[解释-治理行为]] / [[解释-降级与取消]] |
## 快速事实
| | |
|---|---|
| 安装 | `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(redis/asyncpg/json-repair 走 extras) |
| 架构事实源 | 主仓库 `research-wiki/ARCHITECTURE.md`(含 D1-D14 全部决策论证) |
| 变更记录 | 主仓库 `CHANGELOG.md` |
+25
@@ -0,0 +1,25 @@
**[[Home|首页]]**
**教程**
- [[教程-十分钟接入]]
**操作指南**
- [[指南-多源与选源]]
- [[指南-限流与熔断]]
- [[指南-响应缓存]]
- [[指南-遥测与成本]]
- [[指南-结构化输出]]
- [[指南-OCR]]
- [[指南-Embedding]]
- [[指南-迁移既有项目]]
**参考**
- [[参考-公共API]]
- [[参考-配置键]]
- [[参考-异常]]
**解释**
- [[解释-架构]]
- [[解释-错误四分类]]
- [[解释-治理行为]]
- [[解释-降级与取消]]
+60
@@ -0,0 +1,60 @@
# 参考:公共 API
顶层导出全集(`from polygateway import ...`);公共类型承诺**字段只增不删不改名**,新增字段必带默认值。
## 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) -> LLMResponse` | 一次治理调用;messages 为 OpenAI 形态(原生支持多模态 content 数组);`structured` 传 pydantic 模型类或 `"json"` |
| `aclose` | `() -> None` | 幂等释放连接与后端资源 |
## LLMResponse(frozen dataclass)
| 字段 | 类型 | 说明 |
|---|---|---|
| content / thinking | str | 正文与思考流(thinking 模型) |
| model / provider / source_name | str | 溯源 |
| prompt_tokens / completion_tokens | int | 用量 |
| latency_ms | int | 总耗时 |
| ttft_ms / max_inter_token_ms | float\|None | 流式时延指标 |
| cache_hit | bool | 是否缓存命中 |
| call_id | str | 遥测主键 |
| cost | float\|None | 按价格表折算(缺表为 None) |
| usage_source | str | measured / estimated |
| structured_data | Any\|None | `structured=` 时的校验结果 |
## EmbeddingClient
| 方法 | 签名 |
|---|---|
| `from_env` | `(scope="EMBED", ..., env=None) -> EmbeddingClient` |
| `embed` | `(texts: list[str], *, session_id=None, parent_call_id=None) -> EmbeddingResponse` |
| `aclose` | `() -> None` |
`EmbeddingResponse`:vectors(等长保序)/ dim / model / provider / prompt_tokens / usage_source / latency_ms / call_id / source_name / cost。
## OcrClient(`from polygateway.ocr import OcrClient`)
| 方法 | 签名 |
|---|---|
| `from_env` | `(scope="OCR", ..., env=None) -> OcrClient` |
| `recognize_text` | `(image: bytes) -> OcrTextResult` |
| `parse_layout` | `(image: bytes) -> OcrLayoutResult` |
| `check_health` | `() -> dict[str, bool]`(逐源并发预检) |
| `aclose` | `() -> None` |
`OcrLayoutElement`:type(开放字符串)/ bbox(x1,y1,x2,y2 页面坐标)/ page_index。
## 配置与注册表类型
| 导出 | 用途 |
|---|---|
| `GatewaySettings` / `EmbeddingSettings` / `OcrSettings` | `from_env` 的解析产物;高级场景可自行构造后走 `from_settings` |
| `SourceConfig` | 单源完整配置(构造期校验不变式) |
| `ProviderProfile` / `DEFAULT_PROFILES` | provider 方言注册表(thinking 注入方式、思考流字段等);自定义 provider 经 `registry=` 传入 |
| `PricingTable` / `ModelPrice` | 价格表(成本折算) |
异常层级见 [[参考-异常]];全部 env 键见 [[参考-配置键]]。
+31
@@ -0,0 +1,31 @@
# 参考:异常
全部继承 `PolyGatewayError`;凡携带 `source_name` 的异常都能定位到具体源。
## 四分类(单次尝试的失败定性)
| 异常 | 触发 | 库内治理行为 |
|---|---|---|
| `TransientError` | 超时 / 5xx / 网络抖动 / SSE 截断 / 429 | 换源重试 + 退避(429 走 pushback,不耗预算) |
| `SourceDeadError` | 401 / 403 / 429+insufficient_quota(欠费) | 立即熔断该源 + 换源 |
| `RequestRejectedError` | 400 / 内容拒绝 / 本地格式拒绝 | 不重试不换源,直接抛给业务 |
| `ResultInvalidError` | 调用成功但结果不合格(坏 JSON / 维度不符 / 退化 bbox) | **不熔断**;结构化场景先有界重问,仍失败才抛 |
## scope 级不可用(重试预算走完后)
`GatewayUnavailableError` 及子类 `CircuitOpenError`(整圈门全拒)/ `AllSourcesExhausted`(预算耗尽):
| 属性 | 含义 |
|---|---|
| `scope` | 哪个 scope(小写) |
| `reason` | 受控词表: network_error / timeout / rate_limited / source_dead / circuit_open / retry_exhausted / stalled |
| `retry_after_s` | 最早值得重试的秒数(读熔断后端;0=可立即);任务队列按它延期重投 |
| `per_source_reasons` | 逐源失败原因字典,诊断用 |
## 基建故障
`GovernanceBackendError`:限流/熔断后端(Redis)不可用。**故意冒泡**——降级方向铁律:治理后端坏了宁可拒绝也不放行裸打上游。缓存/遥测后端故障不会以异常出现(静默降级)。
## 业务侧建议写法
`GatewayUnavailableError` 做延期重投,捕 `RequestRejectedError`/`ResultInvalidError` 做确定性失败处理,其余让它冒——`TransientError` 能穿出来的场景只有"单次尝试就是全部预算"的配置。
+52
@@ -0,0 +1,52 @@
# 参考:配置键
装配只有两条路:`from_env()`(读 .env + 环境变量,环境变量优先)或构造函数全量注入。缺关键键装配即报错。主仓库 `.env.example` 是带注释的全量模板,本页为速查表。
## 源键 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`
| FIELD | 必填 | 说明 |
|---|---|---|
| BASE_URL / API_KEY / MODEL | ✔ | 无鉴权服务 API_KEY 填占位 `none` |
| TIMEOUT_S | ✔(或平铺 `LLM_TIMEOUT` 兜底) | 单次调用墙钟上限;须 ≤ `PGW_LEASE_TTL_S` |
| MAX_CONCURRENCY / RPM / TPM | | 0/缺省=不启用;TPM>0 时 EST_TOKENS 必填 |
| EST_TOKENS | TPM 启用时 ✔ | TPM 预扣依据,按实际结算退款 |
| 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 直连) |
## scope 级键
| 键 | 说明 |
|---|---|
| `{SCOPE}__GLOBAL__MAX_CONCURRENCY / RPM / TPM` | 跨源合计闸 |
| `{SCOPE}__SELECTOR` | health_aware(默认)/ round_robin / least_inflight |
| `{SCOPE}__RETRY__MAX_ATTEMPTS / BACKOFF_BASE_S / BACKOFF_MAX_S` | 重试(MAX_ATTEMPTS 含首次) |
| `{SCOPE}__BREAKER__FAIL_THRESHOLD / COOLDOWN_S / PROBE_TTL_S` | 熔断基本参数 |
| `{SCOPE}__BREAKER__MIN_CALLS / FAIL_RATE / WINDOW_S / MAX_COOLDOWN_S` | 失败率通道与开路退避封顶 |
| `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S / POLL_INTERVAL_S` | 配额满等待的判死窗口(须 ≥ 最大源 TTFT) |
| `{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`
## 装配键 `PGW_*`
| 键 | 取值 | 备注 |
|---|---|---|
| `PGW_LIMITER_BACKEND` / `PGW_BREAKER_BACKEND` | memory / redis | 必填;多进程必须 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 |
| `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 | 无专用键;cache/structured 键对 OCR 无意义被忽略,api_key 惯例填 `none` |
+31
@@ -0,0 +1,31 @@
# 指南:Embedding
与 chat 同一治理栈:多源、重试、熔断、遥测;外加分批与维度校验。
## 配置
```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="estimated"`,不静默填 0;
- 需要 ndarray 的业务侧自己 `np.asarray(resp.vectors, dtype=np.float32)`——库不依赖 numpy。
+42
@@ -0,0 +1,42 @@
# 指南: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 两段协议较慢,给足
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/`
+35
@@ -0,0 +1,35 @@
# 指南:响应缓存
相同请求直接返回缓存结果:零延迟、零费用。缓存命中同样记遥测(`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`;多模态 content(base64 图)**先摘要再 hash**,原图不进 key 也不进存储。设计约束:
| 要素 | 防什么 |
|---|---|
| namespace 必填 | 跨项目/跨租户互相读到对方缓存 |
| salt(per-call) | 需要强制重采样的场景命中旧缓存 |
| 坏结果不写缓存 | 截断流/解析失败被固化 |
## 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` 按轮次隔离,否则第二轮起全是缓存命中。
## 降级方向
Redis 掉线 → 静默当作全部 miss(warning 日志),调用照常走真实上游;损坏的缓存条目反序列化失败也按 miss 处理。缓存永远不会成为可用性瓶颈。
+45
@@ -0,0 +1,45 @@
# 指南:多源与选源
多源多账号是一等能力:同一 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/源死=坏;**坏结果不算坏服务**,不降权)。
## 换源与冷却行为
一次调用的重试循环里,每次尝试都重新选源;某源被熔断/限流拒绝后进入本地冷却备忘,冷却期内不再消耗它的配额。重试预算是**调用级跨源累计**的(`LLM_MAX_RETRIES` 含首次),不是每源各自一份。
## 多逻辑角色(SCOPE)
SCOPE 是任意大写名,同一进程可装配多个互相独立的 client:
```python
search = GatewayClient.from_env("SEARCH") # SEARCH__*__* 键
judge = GatewayClient.from_env("JUDGE") # JUDGE__*__* 键
```
各角色的源、限额、韧性参数、熔断状态全部独立;要共享治理状态(如两个角色合用一个全局并发闸)时,把同一个 limiter/breaker 实例经构造函数注入两个 client。
+27
@@ -0,0 +1,27 @@
# 指南:结构化输出
让 LLM 返回可校验的结构化数据。策略可插拔:json_repair 事后修复(默认)或上游原生 schema,不二选一。
## 用法
```python
from pydantic import BaseModel
class Verdict(BaseModel):
score: int
reason: str
resp = await client.chat(messages, structured=Verdict)
verdict = resp.structured_data # 已校验的 Verdict 实例
raw = await client.chat(messages, structured="json") # 只要合法 JSON,不校验模型
```
需要安装 `polygateway[structured]`(json-repair);未装时传 `structured=` 会显式报错。
## 阶梯行为
1. **修复**:LLM 返回的文本先过 json_repair(补引号/去尾逗号/剥 markdown 围栏);
2. **校验**:按传入的 pydantic 模型验证;
3. **有界带反馈重问**:仍失败则把校验错误喂回模型重问,最多 `PGW_STRUCTURED_MAX_RETRIES` 次(缺省 2;设 0 = 不重问直接抛)。
最终失败抛 `ResultInvalidError`——**不熔断源**(坏结果 ≠ 坏服务),由业务决定丢弃还是别的处理。重问消耗真实调用(计费计遥测),每跳都有独立遥测行。
+25
@@ -0,0 +1,25 @@
# 指南:迁移既有项目
把项目里手写的治理代码(重试循环/熔断器/限流/遥测)删掉换成本库。两个真实项目已走完全程,它们的完整迁移文档是最好的范本。
## 迁移的标准形状
| 步骤 | 内容 |
|---|---|
| 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` 一类),库异常不是它们的子类——必须显式把 `(TransientError, AllSourcesExhausted)` 注入进去,否则那层重试**静默失效**。
3. **任务队列消费 `GatewayUnavailableError`**:scope 级不可用时按 `exc.retry_after_s` 延期重投、不消耗业务失败预算,是 arq/celery 场景的标准写法。
## 范本
主仓库 `research-wiki/migrations/govdoc-saas.md``chsanalyzer.md`:含删除清单、组件映射表、调用点清单、逐条行为审计(保留/替换/修复/有意放弃)与分步回滚点。照着结构写你自己项目的迁移清单,基本不会漏。
+42
@@ -0,0 +1,42 @@
# 指南:遥测与成本
**每次调用必录**——成功、失败、缓存命中都写一行,这是库铁律。埋点收敛在库内单一 helper,业务侧零埋点代码。
## 启用
```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`,18 字段冻结)
| 字段组 | 字段 |
|---|---|
| 链路 | call_id(主键,幂等)/ parent_call_id / session_id |
| 身份 | model / provider / source_name |
| 内容 | messages / response / thinking(多模态 part 摘要落库,不存原图) |
| 用量 | prompt_tokens / completion_tokens / usage_source(measured/estimated) |
| 时延 | latency_ms / ttft_ms / max_inter_token_ms |
| 结果 | cache_hit / error(异常类名前缀,如 `TransientError: ...`)/ cost |
`session_id`/`parent_call_id` 由调用方传入(`client.chat(..., session_id=...)`),用于把一次业务任务下的多次调用串成链。
## 成本
```bash
PGW_PRICING_PATH=config/prices.json
```
```json
{"MiniMax-M3": {"input_per_1m": 2.1, "output_per_1m": 8.4}}
```
配了价格表后每行遥测带 `cost`(元);缓存命中 token=0 天然零成本。缺价格表时 cost 恒 None,不报错。
## 降级方向
遥测后端不可用 → warning 后静默丢弃该行,**绝不影响业务调用**。共享后端注意:不要在真实批跑期间并发跑库的集成测试(时序隔离,详见主仓库 CLAUDE.md)。
+41
@@ -0,0 +1,41 @@
# 指南:限流与熔断
## 限流:六道闸
并发 / RPM / TPM × 单源 / 全局,共六道,全过才放行;拒绝零副作用(不部分计数)。0 或缺省 = 该闸不启用。
```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`)——宁可拒绝也不击穿上游。缓存/遥测则相反(静默降级)。
## 熔断:双通道 + 半开单探针
| 通道 | 触发 | 键 |
|---|---|---|
| 连续失败 | 连续失败 ≥ 阈值(有效值 = max(配置, 源并发×2),防并发误熔) | `LLM_CIRCUIT_BREAKER_THRESHOLD` |
| 失败率窗口 | 窗口样本 ≥ MIN_CALLS 且失败率 ≥ FAIL_RATE(429 不计入) | `LLM__BREAKER__MIN_CALLS/FAIL_RATE/WINDOW_S` |
开路后冷却 `COOLDOWN` 秒,重复开路指数递增、封顶 `MAX_COOLDOWN_S`;冷却结束进入半开,**只放一个探针**(带租约,持有者崩溃后租约过期自动可再探);探针成功即闭合。写回带 epoch fencing,迟到结果不会污染新状态。
401/403/欠费类失败(SourceDead)一击即熔,不走计数。
## 常见问答
- **单源也要配熔断吗?** 要。单源熔断的意义是把"反复打必死的上游"变成快速失败 + `retry_after_s`,让任务队列延期重投。
- **测试怎么不等真实冷却?** 后端构造函数支持 `now=` 时钟注入(必须构造时传入);集成测试建议直接用真实等待(本库测试口径)。
+88
@@ -0,0 +1,88 @@
# 教程:十分钟接入
目标:从零装好库,发起第一次治理调用,看到重试/缓存/遥测真的在工作。
## 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()
asyncio.run(main())
```
`from_env("LLM")` 一行装配了整套治理栈:限流闸、熔断门、重试循环、看门狗、遥测。`aclose()` 归还连接与后端资源(FastAPI 放 lifespan、arq 放 shutdown)。
## 4. 看见治理在工作
`.env` 里 API_KEY 改成错的再跑一次——你会得到 `AllSourcesExhausted` 而不是裸的 401:库先按分类判定(401=源失效)、熔断该源、发现无源可换后抛出带 `retry_after_s` 的结构化异常。改回正确 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'` 能看到每次调用一行(含失败与缓存命中)。
## 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
@@ -0,0 +1,39 @@
# 解释:架构
## 一句话
端口适配器 + 中间件洋葱:**决策逻辑只有一份,状态存储可插拔**。限流算法不知道自己背后是进程内计数器还是 Redis Lua;换后端不改一行治理代码。
## 洋葱结构
```mermaid
graph LR
A[业务代码] --> B[GatewayClient]
B --> C[缓存 MW] --> D[遥测 MW] --> E[重试·选源·限流·熔断 MW]
E --> F[Transport httpx]
F --> G[(上游网关)]
E -.端口.-> H[(内存 / Redis 后端)]
D -.端口.-> I[(SQLite / Postgres)]
```
层序理由:缓存最外(命中则里面全免);遥测其次(缓存命中也要记);重试在最内包 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`
+27
@@ -0,0 +1,27 @@
# 解释:治理行为
这些机制不是拍脑袋设计的——来自对故障混编压测(8000 调用,坏 key/黑洞/慢源/限流源混编)从 58.1% 到 98.96% 的多轮数据驱动迭代。每条都对应一个真实观测到的病灶。
## 健康感知选源(缺省)
**病灶**:轮询把 1/N 的流量持续喂给坏源。**机制**:每源维护成功率 EWMA 与在途数,选源时随机取两个候选比较(P2C),健康分低者降权;低于门槛的源仅在无更健康候选时才被选中。坏源吸流占比被压到个位数,恢复后自动回流,无需人工摘除。
## 熔断双通道 + 健康证据抑制
**病灶**:纯"连续失败 N 次"通道对成功率 10% 的半死源永不触发(偶尔成功就清零计数);反过来,高流量健康源偶发 5 连败又会被误熔。**机制**:增设失败率窗口通道(样本 ≥ MIN_CALLS 且失败率 ≥ FAIL_RATE 即开路);同时连败通道受健康证据抑制——窗口样本充足且失败率低时,连败不开路。两通道互补,半死源提前隔离、健康源免误伤。
## 429 pushback
**病灶**:高峰期 429 烧光重试预算,调用在"其实再等等就好"的场景下失败。**机制**:429 不消耗重试预算、不计入熔断,按 Retry-After(与退避取大者)等待;防饿死靠调用级双条件 stall 判死——本地等待超窗**且**全局无任何进展才放弃。代价是饱和期延迟拉长,换来成功率。
## AIMD 自适应并发
**病灶**:冷启动瞬间全并发涌向单源,触发链式 429。**机制**:每源并发从 8 起步,成功缓升(+1/limit)、429 减半,封顶 max(64, 配置并发)。TCP 拥塞控制同款,库常量非配置项。
## 半开单探针 + 租约 + epoch fencing
**病灶**:冷却结束的瞬间所有等待者同时探测(惊群);探针持有者崩溃导致源永久开路;上一世代的慢响应迟到后污染新状态。**机制**:半开只放一个探针,探针带 TTL 租约(死亡自动回收),每次开路递增 epoch,写回时 fencing 校验——迟到结果 applied=False 被拒。
## 限流的结算语义
TPM 预扣入场(按 EST_TOKENS),完成后按实际 usage **落回 acquire 时刻的窗口**多退少补;瞬时失败按估算保守结算,4xx/源死全额退款。拒绝零副作用:六道闸任一不过,已过的闸不留计数。并发槽带租约,进程死亡后自动回收。
+25
@@ -0,0 +1,25 @@
# 解释:错误四分类
## 问题
调用失败后该干什么?重试、换账号、熔断、还是直接放弃——如果每个调用点自己看状态码决定,判断会写得到处都是且互相矛盾(三个前身项目的实际教训:同一个 429 在不同文件里有三种处理)。
## 方案
失败定性收敛到 transport 层,输出四个语义类别;治理层只消费类别:
| 分类 | 本质问题 | 正确反应 | 为什么 |
|---|---|---|---|
| Transient | 这次运气不好 | 换源重试 + 退避 | 再试大概率就好;换源避开局部故障 |
| SourceDead | 这个账号/源废了 | 立即熔断 + 换源 | 401/欠费重试一万次也不会好,快隔离止损 |
| RequestRejected | 请求本身有问题 | 快速失败 | 换源重试只会烧钱重复同一个 400 |
| ResultInvalid | 服务没问题,结果不合格 | 不熔断;按策略重问或上抛 | **坏结果 ≠ 坏服务**——因内容问题惩罚源会误杀健康源 |
## 几个边界裁决(容易搞错的)
- **429 归 Transient 但特殊**:它是上游的"慢点"信号(pushback),不消耗重试预算、不计入熔断失败率——否则高峰期会把健康源全熔掉;真正的兜底是调用级 stall 判死。
- **429 + insufficient_quota 归 SourceDead**:欠费不是限流,等多久都没用。
- **HTTP 响应本身证明服务活着**:即使是业务层面的失败响应(如 OCR 返回 success=false),熔断记账也算成功——熔断度量的是"服务是否可达",不是"结果是否满意"。
- **SSE 截断(收到内容但缺 [DONE])归 Transient** 且不写缓存——把半截响应当成功缓存住是前身项目的真实事故。
scope 级"无源可用"是另一层:四分类描述单次尝试,`GatewayUnavailableError` 族描述整个 scope 的暂时不可用(带 retry_after_s 供任务队列延期)。
+28
@@ -0,0 +1,28 @@
# 解释:降级方向与取消语义
## 降级方向:两类后端,两种反应
| 后端 | 掉线时 | 为什么 |
|---|---|---|
| 缓存 / 遥测 | **静默降级**(warning 一次,业务零感知) | 它们是增值件;为了省钱/观测把业务打挂,本末倒置 |
| 限流 / 熔断 | **报错(`GovernanceBackendError`),绝不放行** | 它们是保护件;"后端坏了就裸放"等于高峰期无限流打爆上游——恰好是最需要保护的时刻 |
这条不对称是库铁律,所有后端实现必须遵守。遥测的静默降级还有细分:结构性失败(连不上)warning 一次后永久短路;单行写失败只丢那一行,不污染后续。
## 取消语义:CancelledError 全链路穿透
`asyncio.CancelledError` 在库内**永不捕获吞没**:
| 环节 | 行为 |
|---|---|
| 重试循环 | 取消直接穿透,不算失败、不触发重试 |
| 限流等待 / 退避 sleep | 可被取消;已取得的 permit 在 finally 归还 |
| 流式读取 | 取消中断读取,连接在 finally 释放 |
| 半开探针 | 探针持有者被取消 → 归还探针(源保持开路,下一个调用可再探),不判成败 |
| 遥测 | 被取消的调用不记遥测行 |
设计动机:上层(arq 任务超时、用户中断)取消时,库必须立刻让路且不留悬挂资源——租约归零、探针不悬挂、in-flight 清零在压测中是持续验证的不变量。
## 对业务代码的含义
不要在业务侧 `except Exception` 包住库调用(会吞掉取消);需要兜底时精确捕获 `PolyGatewayError` 层级。库的资源释放走 `aclose()` + finally,业务侧照做同样的模式即可获得同样的保证。