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
This commit is contained in:
@@ -0,0 +1,137 @@
|
|||||||
|
# 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` 出实施计划,不得直接进实现。
|
||||||
Reference in New Issue
Block a user