From 9745aa771c4d7aab698c13ed03b7118b6f5b546f Mon Sep 17 00:00:00 2001 From: iomgaa Date: Thu, 23 Jul 2026 03:52:59 -0400 Subject: [PATCH] docs: full documentation set (Diataxis: tutorial, how-to, reference, explanation) --- Home.md | 25 ++++++++++++- _Sidebar.md | 25 +++++++++++++ 参考-公共API.md | 60 ++++++++++++++++++++++++++++++ 参考-异常.md | 31 ++++++++++++++++ 参考-配置键.md | 52 ++++++++++++++++++++++++++ 指南-Embedding.md | 31 ++++++++++++++++ 指南-OCR.md | 42 +++++++++++++++++++++ 指南-响应缓存.md | 35 ++++++++++++++++++ 指南-多源与选源.md | 45 ++++++++++++++++++++++ 指南-结构化输出.md | 27 ++++++++++++++ 指南-迁移既有项目.md | 25 +++++++++++++ 指南-遥测与成本.md | 42 +++++++++++++++++++++ 指南-限流与熔断.md | 41 +++++++++++++++++++++ 教程-十分钟接入.md | 88 ++++++++++++++++++++++++++++++++++++++++++++ 解释-架构.md | 39 ++++++++++++++++++++ 解释-治理行为.md | 27 ++++++++++++++ 解释-错误四分类.md | 25 +++++++++++++ 解释-降级与取消.md | 28 ++++++++++++++ 18 files changed, 687 insertions(+), 1 deletion(-) create mode 100644 _Sidebar.md create mode 100644 参考-公共API.md create mode 100644 参考-异常.md create mode 100644 参考-配置键.md create mode 100644 指南-Embedding.md create mode 100644 指南-OCR.md create mode 100644 指南-响应缓存.md create mode 100644 指南-多源与选源.md create mode 100644 指南-结构化输出.md create mode 100644 指南-迁移既有项目.md create mode 100644 指南-遥测与成本.md create mode 100644 指南-限流与熔断.md create mode 100644 教程-十分钟接入.md create mode 100644 解释-架构.md create mode 100644 解释-治理行为.md create mode 100644 解释-错误四分类.md create mode 100644 解释-降级与取消.md diff --git a/Home.md b/Home.md index bb977aa..862dd6d 100644 --- a/Home.md +++ b/Home.md @@ -1 +1,24 @@ -初始化中 \ No newline at end of file +# 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` | diff --git a/_Sidebar.md b/_Sidebar.md new file mode 100644 index 0000000..26f87c9 --- /dev/null +++ b/_Sidebar.md @@ -0,0 +1,25 @@ +**[[Home|首页]]** + +**教程** +- [[教程-十分钟接入]] + +**操作指南** +- [[指南-多源与选源]] +- [[指南-限流与熔断]] +- [[指南-响应缓存]] +- [[指南-遥测与成本]] +- [[指南-结构化输出]] +- [[指南-OCR]] +- [[指南-Embedding]] +- [[指南-迁移既有项目]] + +**参考** +- [[参考-公共API]] +- [[参考-配置键]] +- [[参考-异常]] + +**解释** +- [[解释-架构]] +- [[解释-错误四分类]] +- [[解释-治理行为]] +- [[解释-降级与取消]] diff --git a/参考-公共API.md b/参考-公共API.md new file mode 100644 index 0000000..3d61262 --- /dev/null +++ b/参考-公共API.md @@ -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 键见 [[参考-配置键]]。 diff --git a/参考-异常.md b/参考-异常.md new file mode 100644 index 0000000..ca8f50c --- /dev/null +++ b/参考-异常.md @@ -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` 能穿出来的场景只有"单次尝试就是全部预算"的配置。 diff --git a/参考-配置键.md b/参考-配置键.md new file mode 100644 index 0000000..a7c6230 --- /dev/null +++ b/参考-配置键.md @@ -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` | diff --git a/指南-Embedding.md b/指南-Embedding.md new file mode 100644 index 0000000..3a1f12f --- /dev/null +++ b/指南-Embedding.md @@ -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。 diff --git a/指南-OCR.md b/指南-OCR.md new file mode 100644 index 0000000..3b417fc --- /dev/null +++ b/指南-OCR.md @@ -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/`。 diff --git a/指南-响应缓存.md b/指南-响应缓存.md new file mode 100644 index 0000000..4135d54 --- /dev/null +++ b/指南-响应缓存.md @@ -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 处理。缓存永远不会成为可用性瓶颈。 diff --git a/指南-多源与选源.md b/指南-多源与选源.md new file mode 100644 index 0000000..0bd4899 --- /dev/null +++ b/指南-多源与选源.md @@ -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。 diff --git a/指南-结构化输出.md b/指南-结构化输出.md new file mode 100644 index 0000000..896154f --- /dev/null +++ b/指南-结构化输出.md @@ -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`——**不熔断源**(坏结果 ≠ 坏服务),由业务决定丢弃还是别的处理。重问消耗真实调用(计费计遥测),每跳都有独立遥测行。 diff --git a/指南-迁移既有项目.md b/指南-迁移既有项目.md new file mode 100644 index 0000000..0ff3a34 --- /dev/null +++ b/指南-迁移既有项目.md @@ -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`:含删除清单、组件映射表、调用点清单、逐条行为审计(保留/替换/修复/有意放弃)与分步回滚点。照着结构写你自己项目的迁移清单,基本不会漏。 diff --git a/指南-遥测与成本.md b/指南-遥测与成本.md new file mode 100644 index 0000000..e32e423 --- /dev/null +++ b/指南-遥测与成本.md @@ -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)。 diff --git a/指南-限流与熔断.md b/指南-限流与熔断.md new file mode 100644 index 0000000..81f7fa7 --- /dev/null +++ b/指南-限流与熔断.md @@ -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=` 时钟注入(必须构造时传入);集成测试建议直接用真实等待(本库测试口径)。 diff --git a/教程-十分钟接入.md b/教程-十分钟接入.md new file mode 100644 index 0000000..1a9d8dc --- /dev/null +++ b/教程-十分钟接入.md @@ -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 → [[指南-结构化输出]] diff --git a/解释-架构.md b/解释-架构.md new file mode 100644 index 0000000..c570beb --- /dev/null +++ b/解释-架构.md @@ -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`。 diff --git a/解释-治理行为.md b/解释-治理行为.md new file mode 100644 index 0000000..7f6cd71 --- /dev/null +++ b/解释-治理行为.md @@ -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/源死全额退款。拒绝零副作用:六道闸任一不过,已过的闸不留计数。并发槽带租约,进程死亡后自动回收。 diff --git a/解释-错误四分类.md b/解释-错误四分类.md new file mode 100644 index 0000000..8dfa023 --- /dev/null +++ b/解释-错误四分类.md @@ -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 供任务队列延期)。 diff --git a/解释-降级与取消.md b/解释-降级与取消.md new file mode 100644 index 0000000..5cd61c2 --- /dev/null +++ b/解释-降级与取消.md @@ -0,0 +1,28 @@ +# 解释:降级方向与取消语义 + +## 降级方向:两类后端,两种反应 + +| 后端 | 掉线时 | 为什么 | +|---|---|---| +| 缓存 / 遥测 | **静默降级**(warning 一次,业务零感知) | 它们是增值件;为了省钱/观测把业务打挂,本末倒置 | +| 限流 / 熔断 | **报错(`GovernanceBackendError`),绝不放行** | 它们是保护件;"后端坏了就裸放"等于高峰期无限流打爆上游——恰好是最需要保护的时刻 | + +这条不对称是库铁律,所有后端实现必须遵守。遥测的静默降级还有细分:结构性失败(连不上)warning 一次后永久短路;单行写失败只丢那一行,不污染后续。 + +## 取消语义:CancelledError 全链路穿透 + +`asyncio.CancelledError` 在库内**永不捕获吞没**: + +| 环节 | 行为 | +|---|---| +| 重试循环 | 取消直接穿透,不算失败、不触发重试 | +| 限流等待 / 退避 sleep | 可被取消;已取得的 permit 在 finally 归还 | +| 流式读取 | 取消中断读取,连接在 finally 释放 | +| 半开探针 | 探针持有者被取消 → 归还探针(源保持开路,下一个调用可再探),不判成败 | +| 遥测 | 被取消的调用不记遥测行 | + +设计动机:上层(arq 任务超时、用户中断)取消时,库必须立刻让路且不留悬挂资源——租约归零、探针不悬挂、in-flight 清零在压测中是持续验证的不变量。 + +## 对业务代码的含义 + +不要在业务侧 `except Exception` 包住库调用(会吞掉取消);需要兜底时精确捕获 `PolyGatewayError` 层级。库的资源释放走 `aclose()` + finally,业务侧照做同样的模式即可获得同样的保证。