docs: document reasoning ownership and explicit cache migration
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
每个接入大模型的项目都会重写同一批东西:重试循环、429 处理、熔断器、SSE 解析、遥测埋点——写三遍就有三份 bug。本库把这些收敛为一份经过压测验证的实现:
|
||||
|
||||
| 能力 | 说明 |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| 多源多账号 | `{SCOPE}__{PROVIDER}__{N}__*` 配置任意多源;健康感知选源(EWMA×在途 P2C)自动避开坏源 |
|
||||
| 限流 | 并发/RPM/TPM × 全局/单源六道闸;TPM 预扣入场、按实际用量结算退款;Redis 后端跨进程原子(Lua) |
|
||||
| 错误分类重试 | 一切失败落入四分类(见下),由分类决定重试/换源/熔断;429 属 pushback 不消耗重试预算;退避含 jitter 且尊重 Retry-After |
|
||||
@@ -30,6 +30,42 @@
|
||||
|
||||
**降级方向是铁律**:缓存/遥测后端掉线 → 降级而不冒泡(业务调用照常返回);限流/熔断后端掉线 → 报错而非放行(防击穿上游)。遥测的降级**不是静默的**——进入/恢复各一条日志、期间按行数与时间节流复述,并随时可经 `client.telemetry_status` 读到。`asyncio.CancelledError` 全链路穿透,in-flight 资源在 finally 释放;**资源所有权的纪律是「谁建的谁关」**——`aclose()` 只关自己 `from_env()`/`from_settings()` 建出来的组件,注入进来的 transport / recorder / limiter / breaker / cache 一律不碰(由注入方自己关)。
|
||||
|
||||
## 1.3.4 推理配置迁移(未发布)
|
||||
|
||||
**先明确意图,再在首次新语义缓存读写前切换缓存身份。** `auto` 要求开启但不指定强度,不是 `None`(不表态),也不是库代选付费档位。已登记模型只有清单含 AUTO 才接受 True/auto;nearest 不把 AUTO 映射成强度。未知模型仍尽力+warning,空开启片段可能零推理字节,不保证开启。完整型号证据见[批准设计 §4/5](research-wiki/designs/2026-09-09-134-thinking-contracts-design.md)。
|
||||
|
||||
| 项 | 旧配置/受影响模型 | 用户明确选择的新配置(示例,不是成本推荐) |
|
||||
| --- | --- | --- |
|
||||
| M1 | MiniMax-M3 True/auto | 删除糖,`REASONING_EFFORT=medium` 可恢复旧 medium 字节;也可选表内其他档 |
|
||||
| M2 | deepseek-v4-pro/flash/flash-vision-exp、glm-5.2 True/auto | 删除糖,选 high 或 max;非空开关也不能豁免 AUTO 成员检查 |
|
||||
| M3 | glm-5.3/5.3-flash、kimi-k3/kimi-for-coding True/auto | 删除糖,选 low/high/max;nearest 不能修复 AUTO |
|
||||
| M4 | gpt-5.4/5.5、claude-opus-5/sonnet-5、gemini-3.1-pro True/auto | 删除糖,可选表内 medium;不可达不能补 AUTO,也不等于 live 证明 |
|
||||
| M5 | MiniMax-M2.5/M2.7 True/auto | 仍接受,但 on_base 不再偷带 medium,改为空片段;缓存须迁移,空 wire 真实语义待复验 |
|
||||
| M6 | qwen 五型、glm-5/5.1/4.6v True/auto | 保留;glm-5/5.1 历史身份不足仍未覆盖,不推及其他型号 |
|
||||
| M7 | 未登记模型 True/auto | 可保留尽力;确定保证须先独立取证再登记能力 |
|
||||
| M8 | 受管意图+任一层 raw 推理控制,即使同值/被遮蔽 | 保留受管档并删除源 extra_body、请求 overlay 的控制键;或清空源糖/档和请求意图,仅 raw(applied_effort=NULL) |
|
||||
| M9 | 如 glm-5.3,请求 medium,nearest 改 error | 同步更换 namespace/salt;旧身份仍可能回放 nearest 成功,不执行新拒绝 |
|
||||
|
||||
M8 包括 reasoning_effort、enable_thinking、thinking、thinking_budget、reasoning、thinkingConfig、output_config.effort 及当前 wire 声明的整个控制根。浅覆盖次序不改,不深合并;自定义 on_base 不能偷带自己的 effort_key 或标准强度字段,点号键仍是顶层字面键。工厂源级拒绝发生在装配期;请求显式档+已知 raw 可前置拒绝;全量注入默认 transport 在 HTTP 前 RequestRejected,但可能已经准入,沿既有 finally 结算。自定义 transport 由实现方履约。
|
||||
|
||||
### 显式缓存身份切换
|
||||
|
||||
**不增加**自动 revision、fallback/能力表/wire 版本指纹,不强制所有 chat 冷启动。受影响调用须选从未承载旧语义的 namespace 或 salt;同版本 fallback、能力表或 wire 变化亦须再次迁移。未迁移可能命中旧缓存并绕过新拒绝:这是操作前置,不是自动安全机制。
|
||||
|
||||
| 路径 | 切换示例/边界 |
|
||||
| --- | --- |
|
||||
| 工厂默认 | `PGW_CACHE_NAMESPACE=lab:tenant-a:thinking-134-a`,保留原租户前缀 |
|
||||
| per-call 覆盖 | `chat(..., cache_namespace="tenant-a:thinking-134-a", cache_salt="epoch-7")`;只改工厂默认无效 |
|
||||
| 请求级档 | M3 示例:源不表态,`chat(..., reasoning_effort="medium", cache_salt="epoch-7:thinking-134-a")` |
|
||||
| 全量注入/多源 | 构造参数 cache_namespace 同步切换;共享身份只要一个源受影响,该集合都要隔离或显式拆 scope |
|
||||
| 并行/回滚 | 新旧客户端不共用新身份;回滚旧 namespace 会重见旧值,旧键未清理;未来变更不能复用此标记包办 |
|
||||
|
||||
### 证据与遥测读法
|
||||
|
||||
真实成功尝试记 response.applied_effort;失败尝试记 effective 请求意图(可能零 HTTP);缓存命中和 scope 终态只记**本次请求级**档,不借历史 applied 或源级补值。embedding、OCR text/layout 成败行均 NULL。实际档分析须排除缓存命中与错误行,未知 AUTO 不证明上游能力。
|
||||
|
||||
测试侧默认 FAIL:404 只有请求、唯一尝试、完整无重复键 JSON、error.type=model_not_found 等独立证据全满足才 UNCOVERED;429/5xx/网络/解析错误不整类 skip。成功公共身份缺失无独立证据仍 FAIL;成功 SSE 不新增捕获器。关闭须完整合格轮次全 ABSENT,UNKNOWN 不能靠长度升格成功。不可关闭探测的 OBSERVED 仅支持本条件下未关闭;预期拒绝另按预声明类型、状态、机器字段判定。必需 live 的 SKIP/UNKNOWN/缺轮不因 pytest exit 0 通过发布门。三项目现行配置迁移仍需各负责人取证,合成兼容测试不能代替。
|
||||
|
||||
## 安装
|
||||
|
||||
发布在实验室 Gitea PyPI(公开包,匿名可装):
|
||||
@@ -42,7 +78,7 @@ pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/py
|
||||
核心仅依赖 `httpx` + `pydantic`;按需选 extras:
|
||||
|
||||
| extra | 内容 | 何时需要 |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| `redis` | redis-py | Redis 限流/熔断/缓存后端 |
|
||||
| `postgres` | asyncpg | Postgres 遥测后端 |
|
||||
| `structured` | json-repair | 结构化输出的修复策略 |
|
||||
@@ -138,7 +174,7 @@ resp = await client.chat(
|
||||
`llm_calls` 是**下游的表**,不是库的私有存储。库对它发出的语句只有三类,别的一概不发:
|
||||
|
||||
| 库会发 | 库不发 |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| 列/表探测:PG 走 `to_regclass` + `pg_attribute`,SQLite 走 `PRAGMA table_info`(都只读 catalog) | `SELECT` 表数据——**库只写不读**,故你加多少列、建多少索引、怎么分区都不影响它 |
|
||||
| `INSERT`,**永远显式列名**,冲突处理不绑定具体约束(PG `ON CONFLICT DO NOTHING` / SQLite `INSERT OR IGNORE`) | `UPDATE` / `DELETE` / `TRUNCATE` / `DROP`——保留期与清理全归下游 |
|
||||
| 表不存在时 `CREATE TABLE IF NOT EXISTS`(PG 侧先探测,表在就不发) | `ALTER TABLE`,**除非**该后端处于 auto 档(见下);manual 档一条 DDL 都不发 |
|
||||
@@ -146,7 +182,7 @@ resp = await client.chat(
|
||||
### 补列档位 `PGW_TELEMETRY_SCHEMA_MODE`
|
||||
|
||||
| 取值 | 含义 |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| 不设(**缺省**) | 按后端派生:`sqlite` → auto、`postgres` → **manual** |
|
||||
| `auto` | 旧表缺列时库逐列 `ALTER TABLE ADD COLUMN` 补齐 |
|
||||
| `manual` | 库一条 `ALTER` 都不发;缺列只发**一条** warning(点名缺的维度 + 附上可直接执行的 SQL),并按现有列裁剪 `INSERT` 继续写 |
|
||||
@@ -154,7 +190,7 @@ resp = await client.chat(
|
||||
**缺省为什么两端不对称**:PG 侧是共享的生产表,`ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,会排在长事务后阻塞该表其后的**所有**查询,而遥测是业务路径上的内联 `await`;这类部署有 DBA、有迁移工具、讲最小权限,DDL 的执行时机该由他们挑。SQLite 侧是下游自己的本地文件(现有下游典型是 `runs/*.db`):没有 DBA、没有迁移工具、没有第二个系统碰它,`ALTER` 是毫秒级元数据操作,要求"升级后手工跑一条 SQL"是给零运维场景强加运维步骤。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里,**没有一个**把"库在下游库里自动 ALTER 出列"作为默认行为。同一个键两侧都可显式覆盖。
|
||||
|
||||
| 表状态 | `auto` | `manual` |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| 不存在 | 建表 | **仍然建表**(新表无既有数据、无并发访问者,不存在锁队列风险;停掉它会让"零配置起步"断掉) |
|
||||
| 存在、列齐 | 不发任何 DDL | 不发任何 DDL |
|
||||
| 存在、缺列 | 逐列 `ALTER`;**失败不裁剪**,缺列以逐行 warning 暴露(承诺的是"把列补上",补不上就让问题可见;要降级写入请显式选 `manual`) | 不发 DDL,裁剪写入,缺的维度不落库 |
|
||||
@@ -184,7 +220,7 @@ PG 变体的补列语句带 `ADD COLUMN IF NOT EXISTS`,**整段可重复执行**
|
||||
这张表的演进只走 expand,不走 contract。以下五条既是当前实现,也是**库对下游的承诺**——库此后的演进受它们约束:
|
||||
|
||||
| 承诺 | 你可以据此做什么 |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| 新列**只增不删不改名**,一律追加在既有列**之后** | 已有的视图、报表、ETL 不会因升级而失效 |
|
||||
| 新列必**可空**,或带**非易失常量默认值** | PG 11+ 补列不重写全表,SQLite 补列是元数据操作——大表升级也是秒级 |
|
||||
| `INSERT` **永远显式写出列名** | 你可以自行加列(业务维度、生成列),库的写入不受影响 |
|
||||
@@ -200,7 +236,7 @@ PG 变体的补列语句带 `ADD COLUMN IF NOT EXISTS`,**整段可重复执行**
|
||||
模板按下表顺序执行,标识符(角色名、schema、分区月份、密码)按你的环境改;`llm_calls` 一律不写 schema 限定,靠 `search_path` 解析,与库的写入口径一致。
|
||||
|
||||
| # | 锚点 | 做什么 |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| 1 | `roles` | 建三角色并授 schema 级权限 |
|
||||
| 2 | `table` | 把 `llm_calls` 改造成按 `created_at` 的 RANGE 分区表,属主归 `polygateway_owner` |
|
||||
| 3 | `partition` | 建一个月分区(生产用 `pg_partman` 自动滚动) |
|
||||
@@ -212,7 +248,7 @@ PG 变体的补列语句带 `ADD COLUMN IF NOT EXISTS`,**整段可重复执行**
|
||||
### 1. 三角色
|
||||
|
||||
| 角色 | 拿到什么 | 谁在用 |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| `polygateway_owner` | 表属主:DDL、加分区、删分区 | DBA / 定时任务;**不用它连库跑业务** |
|
||||
| `polygateway_app` | `INSERT` + 受 RLS 约束的 `SELECT` | 库的连接串用这个 |
|
||||
| `polygateway_report` | 受 RLS 约束的 `SELECT` | BI、对账、成本报表 |
|
||||
@@ -319,7 +355,7 @@ CREATE INDEX idx_llm_calls_tenant_created ON llm_calls (tenant_id, created_at);
|
||||
四个陷阱,每一个的失败形态都是**静默的**:
|
||||
|
||||
| 陷阱 | 后果 |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| 表属主默认**豁免** RLS | 只写 `ENABLE` 而漏 `FORCE`,用属主角色连库时隔离形同虚设,且查询一切正常看不出来 |
|
||||
| `FORCE` 之后属主自己也被 policy 管 | 模板没给 `polygateway_owner` 任何 policy,故它读不到、也写不进任何行——这是有意的(它只用来做 DDL),但别拿它跑报表 |
|
||||
| 租户上下文必须在**显式事务内**用 `set_config('app.tenant_id', ..., true)` | asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效,而 PG **只发 warning 不报错**;表现是 policy 永远拿不到租户 → fail-closed 到零行 |
|
||||
@@ -330,7 +366,7 @@ CREATE INDEX idx_llm_calls_tenant_created ON llm_calls (tenant_id, created_at);
|
||||
按上面的模板部署后,库的连接串用 `polygateway_app`,它需要的权限恰好是下表这些——多一分都不必给:
|
||||
|
||||
| 库会发的语句 | 需要什么 |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| 连库 | 数据库 `CONNECT` + schema `USAGE` |
|
||||
| `SELECT to_regclass('llm_calls')`、查 `pg_attribute`(列探测) | 无需额外授权(系统 catalog 默认对 `PUBLIC` 可读) |
|
||||
| `INSERT INTO llm_calls (...)` | 表 `INSERT`;RLS 打开后还须有一条允许写的 policy |
|
||||
@@ -349,7 +385,7 @@ PGW_TELEMETRY_TEXT_CAP=2000 # 落库正文的字符上限;不设 = 存全
|
||||
```
|
||||
|
||||
| 层 | 配置 |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| 正文体量 | `PGW_TELEMETRY_TEXT_CAP=2000`(按需调);超出部分头部硬切并附 `…(略 N 字)` |
|
||||
| 保留期 | 上面的分区模板 + `pg_partman` 的 `retention`,过期分区整块 `DROP` |
|
||||
| 访问控制 | 上面的三角色 + `REVOKE UPDATE, DELETE` + `FORCE` RLS |
|
||||
@@ -372,7 +408,7 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**
|
||||
一切失败在 transport 层翻译为四类之一,治理行为由分类决定,业务侧不需要判断状态码:
|
||||
|
||||
| 分类 | 含义 | 库内行为 |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| `TransientError` | 超时/5xx/网络抖动/截断流 | 换源重试 + 退避 |
|
||||
| `SourceDeadError` | 401/403/欠费(429+insufficient_quota) | 立即熔断该源 + 换源 |
|
||||
| `RequestRejectedError` | 400/内容拒绝/本地格式拒绝 | 不重试不换源,快速失败 |
|
||||
@@ -387,7 +423,7 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**
|
||||
上表的"库内行为"一列描述的是**治理动作**,不是调用方要处理的东西。四类里有两类**根本到不了调用方**——它们被重试循环接住,预算耗尽时统一包成 `AllSourcesExhausted`。这个区分只看类型树和 docstring 是读不出来的,曾让下游据此写错整段设计文档,故在此列明:
|
||||
|
||||
| 会到达调用方 | 库内吸收(不必 catch) |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| `GatewayUnavailableError` 族——`CircuitOpenError` / `AllSourcesExhausted` / `GovernanceBackendError` | `TransientError`(退避后换源重试,耗尽即转为 `AllSourcesExhausted`) |
|
||||
| `RequestRejectedError` | `SourceDeadError`(立即熔断该源并换源,同上) |
|
||||
| `ResultInvalidError` | |
|
||||
@@ -402,7 +438,7 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**
|
||||
配置只有两条装配路径:`from_env()`(读 `.env`/环境变量)或构造函数全量注入(测试/高级);库内部任何组件不自读环境变量。键名全集见 [.env.example](.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/REASONING_EFFORT/EFFORT_FALLBACK/MISSING_DONE/TRUST_ENV/EXTRA_BODY(表外的 FIELD 直接报错) |
|
||||
| `{SCOPE}__GLOBAL__*` | scope 级全局限额(跨源并发/RPM/TPM) |
|
||||
| `{SCOPE}__RETRY__*` / `BREAKER__*` / `BACKPRESSURE__*` / `SELECTOR` / `QUOTA_FULL` / `CIRCUIT_OPEN` | per-scope 韧性参数;缺省回落平铺键(`LLM_MAX_RETRIES` 等,兼容旧项目习惯) |
|
||||
@@ -443,7 +479,7 @@ graph LR
|
||||
```
|
||||
|
||||
| 模块 | 职责 |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| `types.py` / `errors.py` / `ports.py` | 内核:冻结类型、四分类异常、全部 Protocol(最内层,不依赖任何实现) |
|
||||
| `middleware/` | 治理算法(重试/限流/熔断/缓存/遥测),只面向端口 |
|
||||
| `transports/` | 协议细节:OpenAI 兼容 SSE、MonkeyOCR 双端点;错误翻译在此层 |
|
||||
@@ -458,7 +494,7 @@ graph LR
|
||||
行为不是宣称出来的,是压测出来的(数字见 `research-wiki/findings/`):
|
||||
|
||||
| 场景 | 结果 |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| 故障混编 soak(坏 key/黑洞/慢源/限流源混合,8000 调用) | 成功率 98.96%,坏源吸流被压制,真实源零误熔 |
|
||||
| OCR 故障池 soak(1500 调用,redis 双后端跨进程) | 成功率 99.73%,13 项不变量全过(租约归零/探针不悬挂/零取消泄漏等) |
|
||||
| 两项目全量迁移回归 | 原测试全绿 + 真实链路冒烟 + 50 样本批跑 100% 解析 |
|
||||
@@ -480,7 +516,7 @@ make ci # 只读全量验证
|
||||
## 文档导航
|
||||
|
||||
| 想了解 | 看 |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| 全部架构决策及理由(单一事实源) | `research-wiki/ARCHITECTURE.md` |
|
||||
| 里程碑与状态 | `research-wiki/ROADMAP.md` |
|
||||
| 项目迁移指南(删除清单/组件映射/行为审计) | `research-wiki/migrations/` |
|
||||
|
||||
Reference in New Issue
Block a user