The value-domain table still listed the OCR endpoint as a producer of "unavailable" while section 3.3 had just decided to keep its "measured" label -- an implementer following the normative table would have redone the change that was explicitly dropped, and the guard test would fail. Also corrects the derivation call-site count to five, qualifies the retained conservative settlement to the non-dead transient branch only, and pins the gap metric to "AND cache_hit = false" so cache hits, which carry cost 0.0 by design, do not inflate it.
17 KiB
est_tokens 解耦设计(issue #2)
- 日期: 2026-07-30
- 触发: Gitea issue #2《est_tokens 应由库按实测自估,而不是让调用方填一个没有正确取值的常量》
- 档位: 强制档(改公共 API 语义 +
usage_source公共值域 + 推翻一条已声明保留的迁移行为)→ 需人类审批门 - 修订的权威文档(经独立审查补全):
ARCHITECTURE.md§7.7 行 428(SourceConfig.est_tokens描述)、§5.1 行 331(usage_source值域)、§4.4 行 305("token 按est_tokens预扣")、§7.1 行 384("打捞路径强制usage_source="estimated"",因 §3.2 #4 变为有条件)migrations/chsanalyzer.md行 151 与 G2(行 185).env.example行 11("TPM > 0 时 EST_TOKENS 必填 > 0",约束已废除)
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' AND cache_hit = false 能量化账的缺口(必须带 cache_hit 限定:按 §3.2 #5 的裁决,缓存命中行可以既是 unavailable 又有 cost=0.0,它们本无账目缺口,不加限定就会灌水——与 §3.3 剔出 OCR 用的是同一把尺子)。代价:公共值域变更,需进 CHANGELOG,且该查询口径要一并写进 wiki(§8) |
| 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 内核不违反依赖铁律;零装配变更、零端口变更;5 个调用点(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 帧缺失、失败尝试、终态失败 | NULL(缓存命中行例外,见 §3.2 #5) |
estimated 保留且有真实生产者(打捞),同时保证历史库里既有的 estimated 行读兼容。
不变式的准确表述: 产生了真实网关调用、但用量不可得的行 → cost 为 NULL。缓存命中行不在此列(见 §3.2 #5)。
值域的强制落点: types.py 模块级 frozenset 常量,仅约束库内生产侧——所有写入 usage_source 的位置从该常量取值,测试断言库内产出恒在三态内。不在 LLMResponse/Usage/TransportResult 等 frozen dataclass 上加 __post_init__ 值域校验,两条理由:① 它们是运行时构造点(如 retry.py:418),裸 ValueError 不属 errors.py 四分类,RetryMW 不捕它,会直接逃出 chat(),违反错误分类驱动铁律;② LLMResponse 是三项目已消费的公共类型,新增运行时校验是下游可见行为变更,超出本任务。故 §6 的值域测试断言"库内所有生产点的产出值落在三态内",而非"越界字符串被拒"。
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。插在 cache_hit 分支之后:缓存命中未产生新调用,0.0 是事实而非未知,既有"缓存命中 0.0"语义保持不动。故 cache_hit=True 且 usage_source="unavailable" 的行 cost 仍是 0.0,与 §3.1 不变式不冲突(那条只管产生了真实调用的行) |
| 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 | retry.py:338、embedding.py:271 |
成功侧结算:usage_source == "unavailable" 时按 effective_est_tokens() 结算,而非 prompt+completion(此时恒为 0)。这条是保持既有行为、不是新增保守:改前 _resolve_usage 恰好返回 est_tokens,使 actual == 预扣量、delta == 0、押金留存;#3 把它改成 (0, 0) 后若不同改,成功调用的押金会被整笔退回,对"从不返回 usage 帧的网关源"构成系统性 TPM 计量失效——闸门退化成进门即放行、出门即清账,正是降级方向铁律要防的击穿 |
| 10 | embedding.py:383,390 |
二值合并扩为三态:任一批 unavailable → 整体 unavailable;否则任一 estimated → estimated;否则 measured。同步更新 types.py:273 的行内注释 `# measured |
| 11 | embedding.py:397 _total_cost |
存在 unavailable 批时整体 cost 记 NULL(逐批求和会给出一个偏低却看似有效的金额) |
3.3 明确不改的
非 dead 的瞬时失败路径(retry.py:369 的 if not dead 分支)按预扣量做限流结算的行为保留——那是限流语境,保守方向正确(失败请求可能已被网关计费),且该值只流向 _settle_and_release,不进遥测。其余三条失败分支(RequestRejectedError/ResultInvalidError/SourceDeadError)的 actual 停在初值 0(retry.py:329),属既有行为,本次不动——#8 已把行号钉死,实现时不要顺手把这三条也改成保守结算。SourceConfig.est_tokens 字段与 {SCOPE}__{PROVIDER}__{N}__EST_TOKENS 环境键保留不删不改名(迁移兼容硬约束,ARCHITECTURE §5.1)。RateLimiter 端口签名不变。
ocr.py:411 的 usage_source="measured" 保留不改(初稿曾列为改动项,独立审查后剔出)。库既有立场是 OCR 的 0 token 属事实而非未知——types.py:51 "token 用量;OCR 等无计费调用填 0"、ocr.py:9 "settle 恒为 0(OCR 无 token 计费)"——故 measured 是准确陈述。改成 unavailable 还会反噬 §2.1 的核心度量:COUNT(*) WHERE usage_source='unavailable' 本用于量化账目缺口,灌进本无缺口的 OCR 行就失去意义。
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 与 #9 合起来保证预扣与结算恒取同一派生值,成功/失败/取消三侧均不产生押金偏差——这是本设计里最容易漏的一致性约束(初稿只写了失败侧,独立审查发现成功侧缺口)。
降级方向: 不改变任何后端的降级方向。遥测侧仍是静默降级(telemetry.py:161 的 warning 不冒泡);限流侧仍是 GovernanceBackendError 上抛而非放行;TPM 计量不因 usage 帧缺失而静默失效(#9)。
否决 issue 建议的 p90 自估,主论据是 TelemetryRecorder 目前是纯只写端口,自估需要新增读接口并强制所有后端(含 none)实现,公共 API 扩张远大于它要省掉的一个可选字段,且尚无实测证据表明派生默认值不够用(§8)。初稿曾论证"那会把两条方向相反的降级铁律焊在一起",此论据经审查后撤回:p90 方案完全可以在遥测读失败时回退到纯派生值,限流侧仍能保持 fail-closed,故并非必然冲突。结论不变,理由收窄。
幂等与重复: Permit.settle()/release() 的幂等 flag 语义不变。#8 与 #9 使预扣与结算取自同一派生函数,同一请求重复结算仍是 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 |
| 缓存命中不受牵连 | cache_hit=True 且 unavailable → cost 仍为 0.0(锁定 §3.2 #5 的分支次序) |
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 |
| 成功侧结算不退多 | usage 缺失的成功调用后,TPM 窗口残留量等于派生预扣量而非 0(回归 §3.2 #9,本设计最易漏的一条)。现有锚点 test_retry.py:149 的 _src("a", tpm=1000, est_tokens=400) 旁加一个 est_tokens=0 + usage 缺失的用例 |
tests/unit/test_retry.py、test_limiter_contract.py |
| 三态合并 | 混合批 measured+unavailable → 整体 unavailable 且 cost 为 NULL |
tests/unit/test_embedding.py |
| OCR 不变 | OCR 成功行仍为 measured 且 settle 恒 0(防回归,锁定 §3.3 的剔出决定) |
tests/unit/test_ocr_client.py |
| 值域封闭 | 库内所有生产点的产出恒落在三态内;公共 dataclass 不因越界值抛异常(锁定 §3.1 的落点决定) | 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. 规模判定
改动面(独立审查后重算):6 个源文件(types.py、transports/openai_compat.py、middleware/telemetry.py、middleware/ratelimit.py、middleware/retry.py、embedding.py;ocr.py 已剔出)、7 个测试文件、3 份权威文档(ARCHITECTURE.md、migrations/chsanalyzer.md、.env.example),外加按 docs-convention.md §2 必须同步的 CHANGELOG 与用户文档站 wiki(版本 bump 不得裸发)。
属跨多文件功能 → 本设计经人类审批后须走 writing-plans 出实施计划,不得直接进实现。