Files
PolyGateway/research-wiki/designs/2026-07-30-est-tokens-decoupling-design.md
T
iomgaa fd7d9d330b docs: design est_tokens decoupling from usage fallback
Split the two jobs SourceConfig.est_tokens has been doing: TPM entry
pre-deduction, where conservative means safe, and the telemetry usage
fallback, where feeding a worst-case upper bound through the output
price inflates cost by ~26x.

Records the approved decisions: usage_source gains an "unavailable"
state whose cost is NULL, and an unset est_tokens derives from
tpm//60 so the in-flight ceiling stays scale-invariant. Also declares
the CHS "conservative accounting" migration item as intentionally
dropped, and the pre-existing global-TPM gap as knowingly unfixed.

Refs: gitea issue #2
2026-07-30 04:10:52 -04:00

138 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# est_tokens 解耦设计(issue #2)
- **日期**: 2026-07-30
- **触发**: Gitea issue #2《est_tokens 应由库按实测自估,而不是让调用方填一个没有正确取值的常量》
- **档位**: 强制档(改公共 API 语义 + `usage_source` 公共值域 + 推翻一条已声明保留的迁移行为)→ 需人类审批门
- **修订的权威文档**: ARCHITECTURE.md §7.7(`SourceConfig.est_tokens` 描述)、§5.1(`usage_source` 值域)、`migrations/chsanalyzer.md` 行 151 与 G2
## 1. 问题:一个常量被派了两份互相矛盾的差事
`SourceConfig.est_tokens` 同时承担两个职责,而两者对"保守"的定义方向相反:
| 职责 | 语境 | "保守"意味着 | 填大的后果 |
|---|---|---|---|
| TPM 入场预扣 | 限流 | 多押金,宁可压吞吐也不击穿网关 | 安全(只是慢) |
| usage 缺失时的用量兜底 | 计费 | **不存在保守方向** | 账单虚高 |
CHS 原版 `config.py:55` 把它定义为"须 ≥ 最坏情形 token"——按定义是**上界**。拿上界当实测值记账,必然系统性高估。库把遥测拆成 `prompt_tokens`/`completion_tokens` 两列后又把整个估值塞进 `completion`(`openai_compat.py:146`),而 `pricing.py:70-72``prompt×input价 + completion×output价` 换算,输出单价通常是输入的数倍——**双重高估**。
实测算例:`est_tokens=4000`,单价输入 1 元/百万、输出 8 元/百万,真实消耗 400+100:
| | 记账 token | cost |
|---|---|---|
| 真实 | 400 / 100 | 0.0012 元 |
| 现状 | 0 / 4000 | 0.032 元(**26 倍**) |
第二个症状是装配约束:`types.py:125``tpm > 0 ⇒ est_tokens > 0` 把供应商配额(运维可从配额页抄到)与库的实现细节(预扣量,无人能正确取值)绑死。下游 CHSAnalyzer 删掉 `est_tokens` 配置项后,`tpm` 就再也不能填非 0,只能在自己的配置模型里把 `tpm` 限死为 0 绕开——库把内部细节泄漏进了配置面。
## 2. 备选方案对比
### 2.1 决策点一:usage 不可得时遥测记什么
| 方案 | 做法 | 权衡 |
|---|---|---|
| **A(选定)** | 记 `0/0`,`usage_source` 扩一个 `unavailable`,cost 记 NULL | 缺数据可被统计:`SUM(cost)` 跳过 NULL,`COUNT(*) WHERE usage_source='unavailable'` 能量化账的缺口。代价:公共值域变更,需进 CHANGELOG |
| B | 记 `0/0`,沿用 `estimated` | 改动最小(等于把 `est_tokens>0` 路径统一到 `est_tokens=0` 的现状行为)。**否决**:cost 算出 `0.0`,"免费"与"未知"在数据上不可区分,缺口不可量化 |
| C | 保留 est 兜底,只修 `prompt`/`completion` 分配比例 | 保住 CHS"保守计量"意图。**否决**:比例是又一个没有正确取值的魔数,且未触及"拿上界当实测"这个根因,仍高估约 9 倍 |
### 2.2 决策点二:`est_tokens` 未填时的默认预扣量
先排除"不预扣":`try_acquire` 传 0 会让 TPM 窗口在请求飞出到 settle 回来的整段时间形同虚设,大批请求可同时入场,正是"防击穿网关"要防的场景,与 CLAUDE.md 降级方向铁律相悖。
| 方案 | 源甲 `tpm=6000` | 源乙 `tpm=600000` | 权衡 |
|---|---|---|---|
| **派生 `tpm//60`(选定)** | 押 100 → 60 个在途 | 押 10000 → 60 个在途 | 尺度无关:任何配额规模都给出同一行为上限,语义可写进 docstring("一次调用约占一秒钟的配额份额") |
| 固定常量 1000 | 押金占配额 1/6 → 仅 6 个在途,小请求场景白慢数倍 | 押金占 1/600 → 600 个在途,大请求场景照样撞 429 | **否决**:常量与配额规模无关,在途上限随配额乱飘,无法解释取值 |
### 2.3 派生逻辑的落点
| 方案 | 权衡 |
|---|---|
| **`SourceConfig.effective_est_tokens()`(选定)** | 纯方法只读自身字段,落 `types.py` 内核不违反依赖铁律;零装配变更、零端口变更;三个调用点(`QuotaGate`、retry/embedding 失败结算)共用一份 |
| 注入 `GlobalLimits``QuotaGate`,派生取全局与单源 tpm 的较紧者 | 能覆盖"单源 `tpm=0` 而全局 `tpm>0`"的场景。**否决**:需改三处装配(`retry.py:186`/`embedding.py:116`/`ocr.py:119`),且它修的是一个**既有**缺口(见 §7),超出本任务范围 |
| 派生下沉到两个 limiter 后端 | **否决**:`try_acquire(source_key, est_tokens)` 的入参会变成谎言(后端忽略它),且逻辑要写两遍,违反 D3"语义契约只有一份"与 P7"决策与存储分离" |
## 3. 选定方案
### 3.1 `usage_source` 三态值域
| 值 | 含义 | 生产者 | cost |
|---|---|---|---|
| `measured` | usage 帧完整可信 | 正常路径 | 按 token 换算 |
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断) | 按 token 换算 |
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败、OCR 端点 | **NULL** |
`estimated` 保留且有真实生产者(打捞),同时保证历史库里既有的 `estimated` 行读兼容。值域定为 `types.py` 模块级 frozenset 常量,替代目前"注释里写 `measured | estimated`、代码不校验"的状态(P5)。
### 3.2 逐处改动
| # | 位置 | 改动 |
|---|---|---|
| 1 | `types.py:125` | 删除 `tpm > 0 ⇒ est_tokens > 0`;`est_tokens` 保留字段、语义降为"可选调优覆盖" |
| 2 | `types.py` `SourceConfig` | 新增 `effective_est_tokens()`:显式值 > 0 则原样返回;否则 `tpm > 0` 时返回 `max(1, tpm // 60)`,`tpm == 0` 时返回 0 |
| 3 | `openai_compat.py:146,176` | 两处兜底改为 `(0, 0, "unavailable")` / `(0, "unavailable")`,不再读 `source.est_tokens` |
| 4 | `openai_compat.py:336` | 打捞覆盖加条件:仅当 `usage_source == "measured"` 时降级为 `estimated`,否则保持 `unavailable`(否则 `0/0` 会被标 `estimated` 而算出假的 `0.0`) |
| 5 | `middleware/telemetry.py:130-135` | cost 分支增加短路:`usage_source == "unavailable"``None`(唯一换算点,改一处即全库生效) |
| 6 | `middleware/telemetry.py:58,100` | 失败尝试与终态失败的 `usage_source``estimated``unavailable`(用量确实不可得;这两行 cost 本已是 None,语义对齐不改金额) |
| 7 | `middleware/ratelimit.py:26` | `source.est_tokens``source.effective_est_tokens()` |
| 8 | `retry.py:370``embedding.py:294` | 失败保守结算同步改用 `effective_est_tokens()`。**必须同改**:预扣用派生值而结算退 `est_tokens=0` 会让 `delta` 为负、退掉全部押金,反而丢掉"失败可能已被计费"的保守意图 |
| 9 | `ocr.py:411` | 硬编码的 `measured``unavailable`:OCR 端点不返回 usage,声称"实测 0 token"是假陈述 |
| 10 | `embedding.py:383,390` | 二值合并扩为三态:任一批 `unavailable` → 整体 `unavailable`;否则任一 `estimated``estimated`;否则 `measured` |
| 11 | `embedding.py:397` `_total_cost` | 跳过 `unavailable` 批;若存在 `unavailable` 批则整体 cost 记 NULL(部分求和会给出一个偏低却看似有效的金额) |
### 3.3 明确不改的
`retry.py`/`embedding.py` 失败路径按预扣量做**限流**结算的行为保留——那是限流语境,保守方向正确(失败请求可能已被网关计费),且该值只流向 `_settle_and_release`,不进遥测。`SourceConfig.est_tokens` 字段与 `{SCOPE}__{PROVIDER}__{N}__EST_TOKENS` 环境键**保留不删不改名**(迁移兼容硬约束,ARCHITECTURE §5.1)。`RateLimiter` 端口签名不变。
## 4. 旧版行为审计(迁移保留项的推翻声明)
| 旧版行为 | 出处 | 本次处置 |
|---|---|---|
| usage 缺失按 `est_tokens` 估算并标 `estimated`,不静默用 0 | CHS `invokers.py:241-254`;`migrations/chsanalyzer.md:151` 标记为**保留** | **有意放弃**。理由:CHS 只记单个 `total_tokens`,不存在 prompt/completion 分配问题;库拆两列后无法忠实分配,且 `est_tokens` 按 CHS 自身定义是最坏情形上界。"保守"在限流语境安全、在计费语境只有错误一个方向 |
| 缺失时不静默用 0(拒绝 VT 的"填 0 且不标注") | 同上;`m1-core-design.md:222` 行 10 | **保留**。本方案记 0 但带 `unavailable` 显式标记且 cost 为 NULL,反静默的原始意图完整保留——被放弃的只是"编一个数字"这个手段 |
| `est_tokens` 作 TPM 入场预扣常量 | CHS `config.py:55` | **保留**,仅由必填降为可选覆盖 |
| `tpm > 0 ⇒ est_tokens > 0` 装配校验 | `m1-core-plan.md:93` | **替换**为库内派生,校验删除 |
| 打捞路径强制 `estimated` | `m1-core-design.md` §6 | **保留**,补一个前置条件(§3.2 #4) |
| 遥测 `INSERT OR IGNORE` 幂等、写失败降级不冒泡、列只增 | `m1-core-design.md:218` | **保留**,本次无 DDL 变更 |
## 5. 非功能维度
**并发与取消**: `effective_est_tokens()` 是无状态纯方法(只读 frozen dataclass 字段),并发安全、无锁、可重复调用。本次改动不新增 `await` 点、不改变任何 `try/finally` 结构,取消穿透路径与 in-flight 释放语义原样不动。#8 的同步修改反而消除了一处"取消/失败后押金被过度退还"的结算偏差。
**降级方向**: 不改变任何后端的降级方向。遥测侧仍是静默降级(`telemetry.py:160` 的 warning 不冒泡);限流侧仍是 `GovernanceBackendError` 上抛而非放行。需要强调的是本方案**不引入对遥测后端的读依赖**——这正是否决 issue 建议的 p90 自估的核心理由:那会让限流预扣依赖一个允许静默降级的后端,把两条方向相反的降级铁律焊在一起。
**幂等与重复**: `Permit.settle()`/`release()` 的幂等 flag 语义不变。#8 使预扣与结算取自同一派生函数,同一请求重复结算仍是 no-op。
**持久化与原子性**: 无 DDL 变更(两 schema 的 `cost` 列已可空);无新增落盘点;Redis Lua 脚本不改(仍接收调用方算好的 est)。历史数据不迁移:旧行的 `estimated` 语义在新值域中依然合法可读。
## 6. 错误处理与测试策略
值域校验失败属配置/内部不变量违反 → `ValueError`(装配期 fail-loud),不进四分类运行时错误。本次不改变任何调用失败的分类归属。
| 测试 | 断言要点 | 文件 |
|---|---|---|
| 约束解绑 | `tpm=6000, est_tokens=0` 构造成功(改前抛 ValueError) | `tests/unit/test_types.py` |
| 派生尺度无关 | `tpm=6000→100``tpm=600000→10000``tpm=0→0`、显式值优先、`tpm=30→max(1,·)` 不为 0 | 同上 |
| cost 不再造假 | `est_tokens=4000` + usage 缺失 → `0/0/unavailable``record_llm_call` 收到 `cost=None`(改前 `0.032`) | `tests/unit/test_openai_compat.py``test_telemetry.py` |
| 打捞前置条件 | 打捞 + usage 帧存在 → `estimated` 且 cost 非 None;打捞 + usage 缺失 → `unavailable` 且 cost 为 None(回归 §3.2 #4) | `test_openai_compat.py` |
| 结算不退多 | 未填 `est_tokens``tpm>0` 时失败请求,TPM 窗口残留量等于派生预扣量而非 0(回归 §3.2 #8) | `tests/contracts/test_limiter_contract.py` |
| 三态合并 | 混合批 `measured+unavailable` → 整体 `unavailable` 且 cost 为 NULL | `tests/unit/test_embedding.py` |
| OCR | OCR 成功行 `usage_source == "unavailable"` | `tests/unit/test_ocr_client.py` |
| 值域封闭 | 越界字符串被拒;三态皆被接受 | `test_types.py` |
限流侧断言随 `tests/contracts/test_limiter_contract.py` 同时覆盖内存与 Redis 两后端(Redis 走真实实例,遵守共享后端不并跑纪律)。
## 7. 已知限制(本次不修,显式声明)
单源 `tpm == 0` 而全局 `tpm > 0` 时,`effective_est_tokens()` 返回 0,全局 TPM 闸拿 0 预扣、入场保护形同虚设。**这是既有行为**(现状约束只管 `cfg.tpm > 0`,该场景下 `est_tokens=0` 本就合法),本方案不引入也不修复它。修它需要把 `GlobalLimits` 注入 `QuotaGate`(§2.3 备选二),属独立议题,建议另开 issue。
## 8. 下游影响与发布
`est_tokens` 从必填降为可选后,CHSAnalyzer 可删掉"`tpm` 必须为 0"的绕行校验并填真实 TPM。`usage_source` 出现第三个值、且不可得行的 cost 由数值变 NULL,是下游可见的行为变更:成本汇总若此前依赖"cost 非空"隐含假设需复核。按 `docs-convention.md` §2,发版须同步 CHANGELOG 与 wiki 的 usage/成本口径说明,并在 issue #2 回帖结论。
遥测驱动的自适应预估(issue 原建议)不在本次范围,待默认派生值在真实负载下出现实测问题后再评估。
## 9. 规模判定
改动跨 8 个源文件 + 2 份权威文档 + 6 个测试文件,属跨多文件功能 → 本设计经人类审批后须走 `writing-plans` 出实施计划,不得直接进实现。