iomgaa 25cb0a6e0c docs: document caller dimensions and the RLS boundary
Issue #11 Task 8, repo files only (the Gitea wiki pages are handled
separately at merge time). CHANGELOG gains an unreleased section covering
the two new keyword-only parameters on all four public methods, the two
new telemetry columns (JSONB on PG, TEXT on SQLite), why backfilled rows
read as an empty string rather than NULL, the validation limits, and the
boundary that the library ships columns only - no index, no RLS.

Telemetry field count re-measured via inspect.signature: 22 -> 24, README
updated accordingly. The cache-key row also dropped the sampling
component and called the namespace a tenant, which now reads as the new
tenant_id; both corrected.
2026-08-17 11:59:30 -04:00

PolyGateway

实验室统一的大语言模型调度与中转库:LLM / VLM / OCR / Embedding 四类调用共用同一套生产级治理栈——多源多账号、限流、错误分类重试、熔断、响应缓存、流式看门狗、遥测与成本。治理单位是一次模型调用;任务编排、业务解析、图像预处理都留在业务侧。

由三个真实项目(GovDoc-SaaS / CHSAnalyzer / Video-Tree-TRM5)各自手写的治理栈提炼而来,并以"能否全量迁移回这三个项目"作为验收标准。v1.0.0 已通过 GovDoc 与 CHSAnalyzer 两项目的全量迁移验收(约 −6800 行项目侧治理代码由本库继任)。

为什么需要它

每个接入大模型的项目都会重写同一批东西:重试循环、429 处理、熔断器、SSE 解析、遥测埋点——写三遍就有三份 bug。本库把这些收敛为一份经过压测验证的实现:

能力 说明
多源多账号 {SCOPE}__{PROVIDER}__{N}__* 配置任意多源;健康感知选源(EWMA×在途 P2C)自动避开坏源
限流 并发/RPM/TPM × 全局/单源六道闸;TPM 预扣入场、按实际用量结算退款;Redis 后端跨进程原子(Lua)
错误分类重试 一切失败落入四分类(见下),由分类决定重试/换源/熔断;429 属 pushback 不消耗重试预算;退避含 jitter 且尊重 Retry-After
熔断 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增
自适应并发 AIMD:429 削减、成功缓升,防止打爆上游
背压与判死 配额满可选等待或快速失败;等待期按双条件判死(本地非生产性等待与全局无进展同时超窗)。stall 窗口只计非生产性等待(429 退避/配额轮询/熔断冷却),与 TIMEOUT_S 无耦合
响应缓存 Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数,多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样)
流式看门狗 TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 [DONE])判瞬时不入缓存
遥测与成本 每次调用(含缓存命中与失败)必录 24 字段;SQLite / Postgres 后端(表已存在时不需要 schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 LLMResponse.cost 本身恒为 None,成本只进遥测);多模态内容摘要落库不存原图
调用方维度 每次调用可带 tenant_id(遥测表的真实列,可挂 RLS、可建复合索引)与 meta(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;库只交付列,不启用 RLS、不建索引
结构化输出 json_repair 修复 / 原生 schema 双策略 + 校验失败有界带反馈重问
OCR MonkeyOCR 双端点(文本转录 + 版面解析),bbox 数值防御下沉,逐源健康预检 check_health()
Embedding 分批、维度校验、与 chat 同一治理栈

降级方向是铁律:缓存/遥测后端掉线 → 静默降级(warning);限流/熔断后端掉线 → 报错而非放行(防击穿上游)。asyncio.CancelledError 全链路穿透,in-flight 资源在 finally 释放。

安装

发布在实验室 Gitea PyPI(公开包,匿名可装):

pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
    "polygateway[redis,postgres,structured]>=1.2,<2"

核心仅依赖 httpx + pydantic;按需选 extras:

extra 内容 何时需要
redis redis-py Redis 限流/熔断/缓存后端
postgres asyncpg Postgres 遥测后端
structured json-repair 结构化输出的修复策略
sdk openai 可选的 SDK transport(默认手写 httpx,不需要)

要求 Python ≥ 3.11。

快速开始

1. 配置 .env

LLM__MINIMAX__1__BASE_URL=https://your-gateway/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

缺任何关键键都会在装配时报错——本库禁止默认值兜底掩盖配置缺失。

2. 发起治理调用

from polygateway import GatewayClient

async def main() -> None:
    client = GatewayClient.from_env("LLM")   # 读 .env 装配整套治理栈
    try:
        resp = await client.chat([{"role": "user", "content": "你好"}])
        print(resp.content, resp.source_name, resp.latency_ms)
    finally:
        await client.aclose()                # 归还连接与治理后端资源

chat() 原生接受 OpenAI 多模态 content 数组(image_url data URL),VLM 调用无需专门客户端;session_id / parent_call_id / cache_salt 关键字参数用于链路追踪与缓存控制,tenant_id / meta 用于遥测归属(见下文「多租户与自定义维度」);overlay 传采样参数(temperature / seed / max_tokens 等,恒定值宜配在源的 EXTRA_BODY 上)——它会进缓存 key,故逐次变化的 seed 天然不命中缓存。

3. OCR 与 Embedding

from polygateway import EmbeddingClient
from polygateway.ocr import OcrClient

ocr = OcrClient.from_env("OCR")            # OCR__MONKEY__1__* 多源
text = await ocr.recognize_text(image_bytes)          # 文本转录
layout = await ocr.parse_layout(image_bytes)          # 版面解析(带 bbox 的元素列表)
health = await ocr.check_health()                     # 逐源预检 {"monkey_1": True, ...}

embed = EmbeddingClient.from_env("EMBED")  # EMBED__*__* + EMBED__BATCH_SIZE
vectors = (await embed.embed(["文本 a", "文本 b"])).vectors

4. 业务侧异常处理

from polygateway import GatewayUnavailableError, RequestRejectedError

try:
    resp = await client.chat(messages)
except GatewayUnavailableError as exc:
    # 整个 scope 暂时无源可用: 延期重投,不消耗业务失败预算
    schedule_retry(after_s=exc.retry_after_s)   # exc.reason / exc.per_source_reasons 供诊断
except RequestRejectedError:
    ...  # 请求本身有问题(400/格式拒绝): 不重试,直接失败

5. 多租户与自定义维度

resp = await client.chat(
    messages,
    tenant_id="acme-corp",                           # 遥测表的真实列,可挂 RLS
    meta={"batch_id": "b-42", "stage": "extract"},   # 任意自定义 KV,进 meta 列
)

四个公共方法(chat / embed / recognize_text / parse_layout)都接受这两个关键字参数,都可省略,既有调用点无需改动。校验在入口收口、超限报 ValueError 而非静默丢弃:tenant_id ≤128 字符且不含首尾空白;meta 最多 16 个键,键须匹配 [a-z0-9_.]{1,64}(pg_ 前缀保留给库),值仅限 str / int / float / bool 且字符串值 ≤256 字符。两者都不进缓存 key——缓存隔离由 cache_namespace 负责。

存储上 tenant_id 两端都是 TEXT NOT NULL DEFAULT '',meta 在 Postgres 是 JSONB、在 SQLite 是 TEXT;老表自动补列,老行读出是空串而非 NULL(NULL 在任何 RLS policy 下都对所有人不可见,空串则可用一条 SQL 审出还有多少行待归属)。

库只提供列,不启用 RLS、不建索引。 要数据库层的强制隔离,请自行执行 ENABLE / FORCE ROW LEVEL SECURITYCREATE POLICY,并建 (tenant_id, created_at) 复合索引;不执行则 tenant_id 只是一个可查可过滤的普通列。库不代劳的原因是 default-deny:启用 RLS 而没有匹配的 policy = 零行可写且静默不报错,会让非多租户部署的遥测全量写失败。

错误模型(四分类)

一切失败在 transport 层翻译为四类之一,治理行为由分类决定,业务侧不需要判断状态码:

分类 含义 库内行为
TransientError 超时/5xx/网络抖动/截断流 换源重试 + 退避
SourceDeadError 401/403/欠费(429+insufficient_quota) 立即熔断该源 + 换源
RequestRejectedError 400/内容拒绝/本地格式拒绝 不重试不换源,快速失败
ResultInvalidError 调用成功但结果不合格(坏 JSON/维度不符/坏 bbox) 不熔断("坏结果 ≠ 坏服务"),按策略有界重问或上抛

预算耗尽/全源熔断时抛 GatewayUnavailableError 族(CircuitOpenError / AllSourcesExhausted),携带 scope / reason / retry_after_s / per_source_reasons,供任务队列做延期重投。

网关拒绝的理由不会丢失(1.2.0 起):非 2xx 的响应体经折叠与截断后同时进入异常 message 与 exc.body_text,故遥测表的 error 列里就能看到网关的原话——不必再为查一次 400 单独埋点。截断保头保尾(总长 2048 字符),JSON 错误体尾部的 code / request_id 不会被切掉。经中转部署时请注意:第三方中转服务自身抖动也会回 400,从状态码上与"你的输入有问题"无法区分;库仍按确定性失败处理(直连供应商时重试只会白烧配额),批处理下游宜据 body_text 自备兜底分类。

哪些异常会到达调用方

上表的"库内行为"一列描述的是治理动作,不是调用方要处理的东西。四类里有两类根本到不了调用方——它们被重试循环接住,预算耗尽时统一包成 AllSourcesExhausted。这个区分只看类型树和 docstring 是读不出来的,曾让下游据此写错整段设计文档,故在此列明:

会到达调用方 库内吸收(不必 catch)
GatewayUnavailableError 族——CircuitOpenError / AllSourcesExhausted / GovernanceBackendError TransientError(退避后换源重试,耗尽即转为 AllSourcesExhausted)
RequestRejectedError SourceDeadError(立即熔断该源并换源,同上)
ResultInvalidError
SourceNotConfiguredError

GovernanceBackendError 属于第一列: 限流/熔断的状态后端(如 Redis)自身故障时库 fail-closed——一个请求都发不出去,这就是"整个 scope 暂时不可用"。它继承 GatewayUnavailableError,所以 §4 那段 except GatewayUnavailableError 一条即覆盖完整,无需为它单列分支。retry_after_s 默认 5 秒(后端恢复时间不可知,取 0 会让积压任务零延迟冲击已挂掉的后端)。

SourceNotConfiguredError 有意不在第一列的族内: 源名不在限流后端的配置字典中是装配缺陷而非暂时故障,它应当消耗失败预算、进死信、让人看见——归入可重投家族只会让配置写错的任务永远重投且无人告警。

配置参考

配置只有两条装配路径:from_env()(读 .env/环境变量)或构造函数全量注入(测试/高级);库内部任何组件不自读环境变量。键名全集见 .env.example,约定速览:

键形态 作用
{SCOPE}__{PROVIDER}__{N}__{FIELD} 第 N 个源;FIELD ∈ BASE_URL/API_KEY/MODEL/TIMEOUT_S/MAX_CONCURRENCY/RPM/TPM/EST_TOKENS/TTFT_TIMEOUT_S/INTER_TOKEN_TIMEOUT_S/ENABLE_THINKING/TRUST_ENV
{SCOPE}__GLOBAL__* scope 级全局限额(跨源并发/RPM/TPM)
{SCOPE}__RETRY__* / BREAKER__* / BACKPRESSURE__* / SELECTOR per-scope 韧性参数;缺省回落平铺键(LLM_MAX_RETRIES 等,兼容旧项目习惯)
PGW_LIMITER_BACKEND / PGW_BREAKER_BACKEND memory(单进程)或 redis(跨进程共享,需 REDIS_URL)
PGW_CACHE_BACKEND none / redis(需 PGW_CACHE_NAMESPACE + PGW_CACHE_TTL_S)
PGW_TELEMETRY_BACKEND none / sqlite(需 PGW_TELEMETRY_SQLITE_PATH)/ postgres(需 PGW_TELEMETRY_PG_DSN)

SCOPE 是逻辑角色(LLM/VLM/OCR/EMBED/JUDGE/SEARCH…任意大写名),同一进程可按角色装配多个 client,各自独立配置与治理状态。

架构

端口适配器 + 中间件洋葱:决策逻辑一份,状态存储可插拔。

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)]
模块 职责
types.py / errors.py / ports.py 内核:冻结类型、四分类异常、全部 Protocol(最内层,不依赖任何实现)
middleware/ 治理算法(重试/限流/熔断/缓存/遥测),只面向端口
transports/ 协议细节:OpenAI 兼容 SSE、MonkeyOCR 双端点;错误翻译在此层
backends/ 限流/熔断/缓存的内存与 Redis 实现(同一契约测试套件双后端共用)
telemetry/ SQLite / Postgres 遥测后端
structured/ 结构化输出策略

依赖纪律由 import-linter 机械化执法(make lint)。完整架构决策(D1-D14 含论证过程)见 research-wiki/ARCHITECTURE.md

可靠性证据

行为不是宣称出来的,是压测出来的(数字见 research-wiki/findings/):

场景 结果
故障混编 soak(坏 key/黑洞/慢源/限流源混合,8000 调用) 成功率 98.96%,坏源吸流被压制,真实源零误熔
OCR 故障池 soak(1500 调用,redis 双后端跨进程) 成功率 99.73%,13 项不变量全过(租约归零/探针不悬挂/零取消泄漏等)
两项目全量迁移回归 原测试全绿 + 真实链路冒烟 + 50 样本批跑 100% 解析

时间语义测试(租约过期、窗口滚动、半开探针)全部真实等待不缩放;Redis/Postgres 测试打真实实验室后端,不 mock Lua。

开发

conda create -n PolyGateway python=3.11 && conda activate PolyGateway
make install   # editable 安装(dev + 全部 extras)
make test      # pytest + 覆盖率(目标 ≥80%)
make lint      # ruff + import-linter
make ci        # 只读全量验证

测试组织:tests/{unit,integration,e2e} + 双后端契约测试;并发/取消/降级方向是一等测试对象。压测 harness 在 tools/soak/。贡献流程与项目纪律见 CLAUDE.md

文档导航

想了解
全部架构决策及理由(单一事实源) research-wiki/ARCHITECTURE.md
里程碑与状态 research-wiki/ROADMAP.md
项目迁移指南(删除清单/组件映射/行为审计) research-wiki/migrations/
每个功能的设计与验收记录 research-wiki/designs/research-wiki/findings/
版本变更 CHANGELOG.md

兼容性承诺

LLMResponse 等被下游消费的公共类型,字段只增不删不改名且新增字段必带默认值;{SCOPE}__{PROVIDER}__{N}__{FIELD} 与平铺韧性键名(LLM_TIMEOUT 等)沿用三项目既有习惯,不做破坏性改名。实验室内部库,随实验室项目需求演进。

S
Description
No description provided
Readme 4.8 MiB
2026-09-06 00:43:43 +08:00
Languages
Python 98.7%
Shell 0.8%
TypeScript 0.4%
Makefile 0.1%