diff --git a/docs/volc-quota-notes.md b/docs/volc-quota-notes.md index b6e3400..2d130fd 100644 --- a/docs/volc-quota-notes.md +++ b/docs/volc-quota-notes.md @@ -259,7 +259,7 @@ limit = balance_at_sync + usage_at_sync - SMALL_BUFFER(5 万) = B0 + L - SMALL_B 真实剩余 = `B0 − usage` = **`SMALL_BUFFER + reserve − L`**。 - L=0(滞后收敛):剩余 = SMALL_BUFFER + reserve > 0 → **永远不扣费** ✓(原论证仅此情形成立) -- L > SMALL_BUFFER:剩余可 < 0 → **已进扣费区** ✗ +- L > SMALL_BUFFER + reserve:剩余 < 0 → **已扣费**(超扣下界,取决于 reserve;保守写 L > SMALL_BUFFER) - **今日为何安全(08-13)**:真实 B0=2,242,178 > 静态 2M → `min(2M, B0+L−S)` 被静态 2M 压住, 撞线发生在 usage=2M,真实剩余 242,178 > 0——是**静态墙**兜底,不是 v6 不变量。 **若某日 B0 < 2M(包变小),静态墙失效,L > SMALL_BUFFER 即可能超扣。** @@ -357,8 +357,9 @@ reserve = input_tokens + max_out **为什么中文偏高更安全**:DeepSeek 自有 tokenizer 对中文 ~1.5 token/字, cl100k_base ~2-3 token/字。估算偏高 = 预留更多 = 提前撞线 = 更保守。 -**效果**:输入 200k 的请求,旧 reserve 只有 50k,新 reserve 250k+, -在 usage=1.8M 时就拦住(而非放行后超 2M)。 +**效果**:输入 200k 的请求,旧 reserve 只有 50k,新 reserve 250k+,在 usage=1.8M 时拦住 +(而非放行后超 2M)。⚠️ 该阈值仅对 `input ≥ 150k` 成立(由 `1.8M + input + 50k ≥ 2M` 反推); +小输入请求(如 126k)仍会在 usage<2M 时放行,封口实际依赖后置 `soft_evict`(见下节)。 #### 后置闸门兜底 @@ -378,8 +379,12 @@ if current_total >= effective_limit: | **前置** | `reserve = token_counter(messages) + max_tokens` | 预估超限的请求 | 实际消耗 > reserve 的请求 | | **后置** | `current_total >= effective_limit → soft_evict` | 超限后的后续请求 | 已发出正在执行的请求 | -**遗留**:若 `max_tokens` 未声明且模型输出无上限,前置仍可能预估过低。 -此时只有后置 `soft_evict` 兜底(挡后续不挡本次)。 +**遗留解决(2026-08-13 短文本实证)**:`max_tokens` 未声明时前置仍可能预估过低;更隐蔽的是 +**思考模型** —— deepseek-v4-flash(ga-260731) 默认思考模式,usage.total_tokens 含 reasoning tokens +且不受 `max_tokens` 限制(max_tokens 只约束正文 completion)。实测「彩虹有几种颜色」: +reserve = 18输入 + 16声明 = 34,实际消耗 +167/+150(正文 ~16 + 思考 ~120-130)。 +修复:输出侧取 `max(声明 max_tokens, THINKING_RESERVE_FLOOR=4096)` 兜底思考开销。 +大输入请求仍偏保守(cl100k_base 中文 2-3 token/字高估),方向安全。 --- @@ -396,6 +401,7 @@ if current_total >= effective_limit: 与消耗扣减区分(如 08-12 余额 1,558,307 → 596,869,其中约 50 万为滞后修正 + 过期) 6. 2M 采集上限可被单请求 + 并发击穿(§7.6):reserve 估算不足时已放行的请求继续消耗, `soft_evict` 后置兜底只能挡后续。修复:reserve = `litellm.token_counter(messages)` + `max_tokens` + (输出侧另加 `THINKING_RESERVE_FLOOR` 兜底思考模型 reasoning tokens,§7.6) 7. B0 守恒仅在 API 滞后收敛后成立;同步快照期间 `balance + usage = B0 + L`(虚高,§4.2), 不是真实 B0。判扣费用网页真实初始余额或收敛时点反推 @@ -415,5 +421,9 @@ if current_total >= effective_limit: - [ ] 数据库泄漏:08-12 一天 deepseek-v4 消耗记录量级约 5 万条导致 Redis 内存膨胀;触发监控/清理机制 - [ ] v6 无静态 2M 兜底时的安全:08-13 幸由 B0(2,242,178) > 2M 静态墙兜底;若某日 B0 < 2M, 滞后 L > SMALL_BUFFER 时 v6 限额虚高 → 可能放行到余额 0 → 扣费(§7.5 遗留风险 1) -- [ ] reserve 精度验证:token_counter fallback cl100k_base 对中文偏高,需实测确认偏差范围 - (DeepSeek 自有 tokenizer ~1.5 token/字 vs cl100k_base ~2-3 token/字,§7.6) +- [x] reserve 精度验证(2026-08-13 短文本实证:估算 34 vs 实际 167 的根因 = + 思考模型 reasoning tokens 不计入 reserve,已用 THINKING_RESERVE_FLOOR 兜底; + token_counter cl100k_base 对中文偏高对输入侧仍是安全方向) +- [ ] consume clamp2 余额快照口径不同步:`plan ≤ balance − SMALL_BUFFER` 用拉包时刻余额快照 + (volc_consume.py:455),**不含 23:00→00:00 真实用户消耗**;clamp1 用共享 daily_usage 实时精准, + 但余额维度两方不同步。深夜真实消耗大时两方合计可超烧余额。建议运行时重取余额或扣减预估 diff --git a/volc_limit_sync.py b/volc_limit_sync.py index 531dc9e..7ac28e4 100644 --- a/volc_limit_sync.py +++ b/volc_limit_sync.py @@ -101,6 +101,14 @@ CHECK_INTERVAL = 10 # 未设置时 fallback 本常量。堵住"并发请求穿过闸门后集体撞线"的窗口。 PREBLOCK_RESERVE_TOKENS = int(os.getenv("PREBLOCK_RESERVE_TOKENS", "50000")) +# 思考模型输出侧兜底下限:deepseek-v4-flash 等默认思考模式, +# usage.total_tokens 含 reasoning tokens,且不受 max_tokens 限制 +# (max_tokens 只约束正文 completion,思考 token 另计照常计费)。 +# 短文本请求若客户端声明 max_tokens=16,reserve 输出侧仅 16, +# 实际消耗 ~150+(思考占大头),闸门形同虚设。取 +# max(声明值, 本下限) 兜住思考开销;2M 限额下 4096 偏差仅 0.2%。 +THINKING_RESERVE_FLOOR = int(os.getenv("THINKING_RESERVE_FLOOR", "4096")) + # stable_limit 缺失时(首次同步前/新增模型/TTL 过期)的保守兜底限额。 # 免费模型余额常态 < 2M(昨日采多少返多少),若 fallback 裸奔到采集上限 2M, # 余额不足的模型会被烧穿扣费。宁可短时收紧,不可裸奔。 @@ -424,7 +432,8 @@ def format_limit_debug_fields(static_limit=None, balance_at_sync=None, usage_at_ def get_effective_limit(clean_model, api_base, current_usage=None): """ Calculate effective limit using the Snapshot-Offset strategy. - limit = balance_at_sync + usage_at_sync - SMALL_BUFFER (= B0 - SMALL_BUFFER) + limit = balance_at_sync + usage_at_sync - SMALL_BUFFER + = B0 + L - SMALL_BUFFER # L = 同步时点滞后窗口未结算消耗(滞后收敛时 L=0, 虚高随消费增长, 见 docs/volc-quota-notes.md §4.2/§7.5) _check_request_block 用 current_usage + reserve >= limit 撞线,语义正确。 current_usage 参数仅用于无同步数据的 fallback 路径,非公式必需。 """ @@ -435,8 +444,9 @@ def get_effective_limit(clean_model, api_base, current_usage=None): static_limit = get_static_limit(clean_model, api_base) # 2. Get Dynamic Limit from Snapshot - # balance_at_sync + usage_at_sync = 当日初始免费池 B0(常数) - # 累计限额 = B0 - SMALL_BUFFER,两次同步间不变 + # balance_at_sync + usage_at_sync = B0 + L(B0 当日初始免费池, L 同步时点滞后未结算消耗; + # 仅滞后收敛 L=0 时恰为 B0, 未收敛时虚高 L, 见 docs/volc-quota-notes.md §7.5) + # 累计限额 = B0 + L - SMALL_BUFFER,两次同步间不变 balance_key = f"limit:dynamic:volcengine:balance_at_sync:{clean_model}" usage_key = f"limit:dynamic:volcengine:usage_at_sync:{clean_model}" @@ -449,7 +459,7 @@ def get_effective_limit(clean_model, api_base, current_usage=None): try: balance_at_sync = float(balance_val) usage_at_sync = float(usage_val) - # 累计限额 = B0 - SMALL_BUFFER,不依赖 current_usage + # 累计限额 = B0 + L - SMALL_BUFFER(L=同步时点滞后未结算消耗),不依赖 current_usage dynamic_limit = balance_at_sync + usage_at_sync - SMALL_BUFFER dynamic_limit = max(0, dynamic_limit) @@ -827,17 +837,23 @@ class DailyLimitHandler(CustomLogger): if not data or not data.get("model"): return None - # 预留本次请求的预估消耗 = 输入 token(精确) + 输出上限(max_tokens) + # 预留本次请求的预估消耗 = 输入 token(精确) + 输出上限(max_tokens)。 # 输入用 litellm.token_counter 精确计数(未知模型 fallback cl100k_base, - # 对中文偏高更安全);输出用请求声明的 max_tokens,未声明时 fallback 常量。 + # 对中文偏高更安全)。 + # 输出侧:思考模型 usage 含 reasoning tokens 且不受 max_tokens 限制, + # 故取 max(请求声明 max_tokens, THINKING_RESERVE_FLOOR) 兜底思考开销, + # 防短文本 + 小 max_tokens 请求 reserve 低估(实测 34 vs 实际 150+)。 try: - max_out = float( + declared = ( data.get("max_tokens") or data.get("max_completion_tokens") or PREBLOCK_RESERVE_TOKENS ) + max_out = max(float(declared), float(THINKING_RESERVE_FLOOR)) except (TypeError, ValueError): - max_out = float(PREBLOCK_RESERVE_TOKENS) + max_out = max( + float(THINKING_RESERVE_FLOOR), float(PREBLOCK_RESERVE_TOKENS) + ) input_tokens = 0 try: