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
12 KiB
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 出实施计划,不得直接进实现。