Files
PolyGateway/research-wiki/designs/2026-07-30-est-tokens-decoupling-design.md
T
iomgaa 61231f7f6e docs: fold plan review into the est_tokens plan
The reviewer confirmed the T1-T4 ordering holds -- it re-derived every
intermediate state and checked that no construction path can produce
est_tokens=0 with tpm>0 before T4 -- but found four gaps.

Two existing tests go red and the plan never said so: test_types.py:94
asserts the very constraint T4 deletes, and test_embedding.py:105 is a
transport-level case for the fallback T3 rewrites, easy to miss while
looking only at test_openai_compat.py.

The T4 acceptance line claimed all three settlement sides use the
derived value, but the cancel branch never assigns actual and leaves it
at the retry.py:329 initial zero -- an implementer would have "fixed"
a branch the design freezes. Corrected here and in the design section
5 sentence it came from.

USAGE_SOURCES would have landed with no consumer, so T1 now carries the
two value-domain assertions the design asks for, including the one that
pins the no-runtime-validation ruling.
2026-07-30 09:51:01 -04:00

17 KiB
Raw Permalink Blame History

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-72prompt×input价 + completion×output价 换算,输出单价通常是输入的数倍——双重高估

实测算例:est_tokens=4000,单价输入 1 元/百万、输出 8 元/百万,真实消耗 400+100:

记账 token cost
真实 400 / 100 0.0012 元
现状 0 / 4000 0.032 元(26 倍)

第二个症状是装配约束:types.py:125tpm > 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 各自的成功侧与失败侧结算)共用一份
注入 GlobalLimitsQuotaGate,派生取全局与单源 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=Trueusage_source="unavailable" 的行 cost 仍是 0.0,与 §3.1 不变式不冲突(那条只管产生了真实调用的行)
6 middleware/telemetry.py:58,100 失败尝试与终态失败的 usage_sourceestimatedunavailable(用量确实不可得;这两行 cost 本已是 None,语义对齐不改金额)
7 middleware/ratelimit.py:26 source.est_tokenssource.effective_est_tokens()
8 retry.py:370embedding.py:294 失败侧保守结算改用 effective_est_tokens()。必须同改:预扣派生值而结算退 est_tokens=0 会让 delta 为负、退掉全部押金,丢掉"失败可能已被计费"的保守意图
9 retry.py:338embedding.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;否则任一 estimatedestimated;否则 measured。同步更新 types.py:273 的行内注释 `# measured
11 embedding.py:397 _total_cost 存在 unavailable 批时整体 cost 记 NULL(逐批求和会给出一个偏低却看似有效的金额)

3.3 明确不改的

非 dead 的瞬时失败路径(retry.py:369if 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:411usage_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 合起来保证成功侧与非 dead 瞬时失败侧的预扣与结算恒取同一派生值(delta == 0)——这是本设计里最容易漏的一致性约束(初稿只写了失败侧,独立审查发现成功侧缺口)。取消 / RequestRejected / ResultInvalid / SourceDead 四侧不在此列:它们的 actual 停在 retry.py:329 的初值 0、全额退回,属 §3.3 声明不动的既有行为。

降级方向: 不改变任何后端的降级方向。遥测侧仍是静默降级(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→100tpm=600000→10000tpm=0→0、显式值优先、tpm=30→max(1,·) 不为 0 同上
cost 不再造假 est_tokens=4000 + usage 缺失 → 0/0/unavailablerecord_llm_call 收到 cost=None(改前 0.032) tests/unit/test_openai_compat.pytest_telemetry.py
缓存命中不受牵连 cache_hit=Trueunavailable → 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_tokenstpm>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.pytest_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.pytransports/openai_compat.pymiddleware/telemetry.pymiddleware/ratelimit.pymiddleware/retry.pyembedding.py;ocr.py 已剔出)、7 个测试文件3 份权威文档(ARCHITECTURE.md、migrations/chsanalyzer.md.env.example),外加按 docs-convention.md §2 必须同步的 CHANGELOG 与用户文档站 wiki(版本 bump 不得裸发)。

属跨多文件功能 → 本设计经人类审批后须走 writing-plans 出实施计划,不得直接进实现。