Compare commits

...

31 Commits

Author SHA1 Message Date
vibe-code 10d3c7f88c fix(config): 为火山渠道白名单 reasoning_effort,修复思考强度被静默丢弃
LiteLLM 的 VolcEngineChatConfig.get_supported_openai_params() 白名单不含
reasoning_effort,客户端一传就抛 UnsupportedParamsError;叠加 drop_params
后更糟 —— Router._deployment_params_with_request_reasoning_override 会先剥掉
部署侧 extra_body/thinking,drop_params 再把客户端参数丢掉,两者同归于尽,
上游 body 皆无该字段,方舟回落默认 high(静默失效)。

改动:17 个 volcengine 条目全部加 allowed_openai_params: [reasoning_effort]。
- 透传组(huoshan/deepseek-v4-*、doubao-seed-2-1-*、glm-5.2):客户端显式指定生效
- 档位别名组(doubao-seed-nothink/-minimal/-low/-medium/-high、fanyi):
  extra_body 钉死值为默认档位,客户端显式传参可覆盖;不传则走钉死档位

语义为「默认档位 + 客户端可覆盖」,非硬锁:LiteLLM 无纯配置手段实现强制覆盖。
实测依据见 config.yaml 注释(本地 echo 抓上游请求体)。
2026-09-24 15:37:56 +08:00
vibe-code fafcf6e732 fix: DATABASE_URL 加 client_encoding=UTF8,修复 SQL_ASCII 库启动崩溃
新镜像 main-latest 自 2026-09-20 起默认启用 v2 migration resolver
(上游 fac518dbf7),v2 启动时调用 _warn_if_db_ahead_of_head() 读
_prisma_migrations。本库 litellm 为 SQL_ASCII(server/client_encoding
均 SQL_ASCII,datcollate=C),psycopg 的 TextLoader 对此直接返回 bytes
而非 str(psycopg/types/string.py:110-120),导致 utils.py:174 的
_MIGRATION_TS_RE.match(bytes) 抛 TypeError,容器启动即崩。

同一缺陷还潜伏在 repair_invalid_indexes/_index_repair
(utils.py:760,_TRANSIENT_INDEX_SUFFIX_RE.search(index.name))与
_failed_migration_logs(utils.py:1023,str in bytes),二者 v1/v2 双路径
均会经过,只在对应数据非空时触发(CREATE INDEX CONCURRENTLY 中断或
迁移死锁)。

根因修复:DATABASE_URL 追加 client_encoding=UTF8。libpq 将其作为
启动参数下发(fe-protocol3.c:2503),psycopg 按服务端确认值决定解码
(pq_ctypes.py:229-232),恢复返回 str。Prisma 侧无影响:quaint 丢弃
未知参数(url.rs:459),其驱动 tokio-postgres 本就固定发
client_encoding=UTF8。已全库校验 450 个文本列/43548 行/14.5MB 全部
为合法 UTF-8,无解码失败风险。

另保留 USE_V2_MIGRATION_RESOLVER=false 作独立兜底,沿用更新前的 v1
路径;根因已除后该项可移除以启用 v2。
2026-09-24 12:53:19 +08:00
root f0aa78c904 删除下线的模型 2026-09-12 15:26:49 +08:00
vibe-code 6dfaa3f34c fix: 幂等检查提前到 token_counter 之前,第二次 pre-hook 不打印 Pre-reserve 2026-08-18 11:05:48 +00:00
vibe-code 84828646bf docs: 幂等检查注释标注 data 引用假设与失效条件 2026-08-18 11:01:48 +00:00
vibe-code 5869d311c9 fix: pre-hook 幂等防重,litellm 双调用不重复预扣
litellm 对同一请求调用两次 async_pre_call_hook(proxy 级 +
deployment 级),第二次重复预扣 inflight 但结算只释放一次,
残留累积。已在日志确认(18:46:07 每个请求 Pre-reserve 打印两次)。

修复:pre-hook 入口检查 data["metadata"] 是否已有
PREBLOCK_RESERVE_META_KEY,有则跳过预扣直接放行(幂等)。

验证:新增场景7(幂等防重),7 场景 ALL PASS。
2026-08-18 11:00:18 +00:00
vibe-code 69d144ba34 fix: _release_inflight 改用 Lua 原子释放,floor 0 防幽灵误删
TTL 过期后迟到结算的 DECRBY 会把 inflight 打到负数,旧代码 DEL 整个
key → 误删别的请求的预留。改用 RELEASE_SCRIPT 在 Redis 内原子
DECRBY + floor 0:结果 <=0 则 DEL,否则 SET new_val + EXPIRE 保留 TTL。

_release_inflight eval 补第三个参数 PREBLOCK_INFLIGHT_TTL,与
RELEASE_SCRIPT 的 ARGV[2] 对应。

验证:新增场景6(幽灵释放),6 场景 ALL PASS。
2026-08-18 10:42:21 +00:00
vibe-code 2502a6b507 fix: 软踢用量墙从 1.95M 降到 1.9M 增加容错缓冲 2026-08-18 09:33:18 +00:00
vibe-code 4479e179bb fix: Lua 原子闸门堵死并发 TOCTOU 击穿
根因:_check_request_block 的"读 usage + 比较 + 放行"是纯读检查,
与结算 incrby 之间无原子性。并发请求在同一 usage 快照上各自判定
"未超限"集体放行,叠加消耗击穿限额(08-18 13:14:41-50,5 个并发
请求从 1.49M 冲到 2.11M,超支 11.2 万 token)。

修复:Lua 原子闸门把"读 usage + 读 inflight + 判 limit + INCRBY
预扣"合并为单个 Redis 原子操作,并发请求串行看到彼此的在途预留:
  - 放行:INCRBY inflight reserve,返回 granted
  - 拦截:不预扣,返回 blocked

配对释放:pre-hook 放行时把 reserve 写入 data["metadata"]
(PREBLOCK_RESERVE_META_KEY),litellm 透传到 log_success_event
的 kwargs["litellm_params"]["metadata"],结算时 _release_inflight
配对 DECRBY,usage 只计实际消耗。

兜底:inflight key 设 PREBLOCK_INFLIGHT_TTL(1h),失败/丢失的
预扣由 TTL 自动归零,不永久占额度。

验证:打桩 redis/litellm 跑 5 场景,含复现 08-18 击穿的并发测试
(5 并发 reserve,旧代码全放行击穿,新代码仅放行 2 个且
usage+inflight < 2M)。ALL PASS ✅
2026-08-18 09:30:10 +00:00
vibe-code 9eaafdec6b feat: 新增 huoshan/deepseek-v4-pro 火山模型
- config.yaml 增加 deepseek-v4-pro-ga-260813 模型条目
- volc_limit_sync.py MANUAL_MONITORED_MODEL_ALIASES 增加
  deepseek-v4-pro-0813 -> deepseek-v4-pro-ga-260813 映射
  (资源包名 MMDD 与上游模型名 -ga-YYMMDD 不一致,不映射则余额不同步)
2026-08-18 07:22:58 +00:00
vibe-code 9423adeea6 feat: manual_evict 自动解除(余额增量阈值)
- 首次命中 manual_evict key 时惰性记录 balance_at_sync 快照
  (manual_evict:snapshot:{raw_model}),后续每次请求比较当前余额,
  上升 ≥ AUTO_RELEASE_BALANCE_MARGIN(默认 50 万)时自动 DEL key 恢复
- 小额波动不误放,余额大幅上升(=免费额度已返还)才触发
- 幂等、可 env 覆盖(AUTO_RELEASE_BALANCE_MARGIN=500000)
- 自测 9/9 passed
2026-08-13 10:24:05 +00:00
vibe-code 0d1776c8a1 docs: README 补充手动软踢 manual_evict 用法 2026-08-13 10:08:53 +00:00
vibe-code 9338070562 fix: manual_evict key 改用 config model 字段全匹配防误伤
此前 key 用 clean 名(model 去渠道前缀), 同 clean 不同渠道(如
openai/ 前缀同名模型)会被误伤。改为 manual_evict:{raw_model}
(config.yaml 完整 model 字段, 含渠道前缀)全匹配, monitored pairs
过滤后仍精确到具体条目。用法:
  SET manual_evict:volcengine/<model> 1
2026-08-13 10:07:38 +00:00
vibe-code e91e2d1d5e feat: 手动软踢 Redis key (manual_evict) + redis 卷固定名称
- docker-compose: redis_data 卷加 name: litellm_redis_data, 卷名不再随
  compose 项目名(目录名)漂移, 目录迁移不会新建空卷丢数据
- _check_request_block: 监控条目命中后检查 Redis manual_evict:{clean} key,
  存在即返回 (clean,-2,-2) 恒 429; key 落持久卷, 重启后仍生效,
  显式 DEL 才恢复, 幂等
- async_pre_call_hook: 识别 -2 标记, 返回 manually soft-evicted 429
- 用法: SET/DEL/KEYS manual_evict:* (docker exec litellm_redis redis-cli)
2026-08-13 10:05:02 +00:00
vibe-code 5fddea126e fix: redis 挂载 redis_data 持久化卷, down/up 不再丢今日用量
此前 volumes 段仅注释未挂载, docker compose down 重建容器时
RDB/AOF 随容器层删除, daily_usage 与量快照全丢, 重启后闸门
以余额重算限额(limit=Bal-BUFFER)虽不烧穿, 但当日用量统计归零
且 soft-evict 状态丢失
2026-08-13 09:34:49 +00:00
vibe-code 9d46fd4aa5 fix: 闸门 fail-closed 堵重启/Redis 故障窗口漏放
- _check_request_block: Redis 不可用/无限额/读取异常时监控条目恒 429(标记 -1,-1),非监控渠道不受影响;原实现任何异常 fail-open
- async_pre_call_hook: 识别 fail-closed 标记返回 quota unavailable 429
- FALLBACK_LIMIT 900k→200k(免费池 2M 的 10%),收窄 0 余额模型兜底泄露
- docker-compose: redis 开启 AOF(--appendonly yes everysec),防重启丢快照/用量
- active_monitor_loop: 首轮跳过 CHECK_INTERVAL,额度未同步空窗 12-20s 缩至 2-5s
2026-08-13 09:18:23 +00:00
vibe-code 4e98c14920 perf: 已软踢模型短路跳过 token_counter 预估
- _soft_evicted_block: 扫描 router 中 {model}-limit-reached 条目返回
  (usage, limit), 供 async_pre_call_hook 直接 429
- 省去注定被拒请求的 token_counter 调用(含多模态图片 URL 下载等昂贵路径)
- 与 _check_request_block 的 limit-reached 恒 429 双保险
- stub 验证: 已踢/未踢/不存在三场景符合预期
2026-08-13 09:04:30 +00:00
vibe-code 795cc99faa feat: 软踢预警线提前至 1.95M 用量墙与 10万余额墙
- should_soft_evict: usage ≥ min(effective_limit, 1.95M) 或
  (balance_at_sync < 10万 且 usage>0)——usage>0 防小额初始池(B0<10万)
  模型误杀永久死锁
- should_restore_model 恢复对称: usage < min(额度,1.95M) 且
  (余额≥10万 或 usage==0),否则踢了立即被同步恢复成抖动
- _check_request_block 对已软踢条目(*-limit-reached)恒 429,
  防踢后 1.95M~2M 区间小请求放行→路由原名缺失→400
- startup_audit / restore_blocked_models / restore_model_if_healthy
  统一接入判定; SOFT_EVICT_USAGE_LIMIT/SOFT_EVICT_BALANCE_LIMIT env 可调
- docs §7.6 新增软踢预警线小节(两墙条件/恢复对称/前置联动/2.5% 采集代价)
- 数字用例 9/9 验证(踢/不踢/死锁防护/防抖动/动态墙取 min)
2026-08-13 08:56:26 +00:00
vibe-code f15b64e31f fix: 消耗任务 clamp2 改守恒口径,消除余额快照滞后超烧风险
- b0_est = 拉包时刻 余额+usage (= B0+L,与 v6 闸门同构)
- clamp2: plan ≤ b0_est − SMALL_BUFFER − usage_now; 停止条件 2 改
  usage ≥ b0_est − SMALL_BUFFER,每轮实时读共享 key,真实用户消耗
  结构性计入,两方合计超烧杜绝(残余风险收敛为 §7.5 L 同款)
- 数字用例验证: L=0 撞墙剩 SMALL_BUFFER(旧方案执行中用户耗 100k 超扣 100k)
- docs: §8 排障 4/§9 条目翻转为已实施;新增视觉图片 reserve 估算
  口径校准待确认项(LiteLLM 85/765 OpenAI 口径 vs 火山未公开公式)
2026-08-13 08:31:37 +00:00
vibe-code 660efdfbeb docs: 明确 consume clamp2 余额滞后修复方向
推荐 C 守恒口径(与 v6 闸门同构: b0_est = 拉包时刻 balance+usage,
clamp2/停止条件均改 usage 实时撞 b0_est-SMALL_BUFFER,零 API 成本,
两方不同步结构性消除);A' 单次收敛重取可选叠加;否决 B 扣减 L 预估
(v5 BUFFER 盲猜教训, L 实测浮动 15 倍)
2026-08-13 06:14:11 +00:00
vibe-code 6a2760bd03 feat: 每日消耗任务触发时点 23:00 改为 23:20
- volc_limit_sync:_maybe_trigger_daily_consume 改分钟级判断(23:20 起触发),
  线程内 0-10 分钟随机抖动保留,实际执行窗口 23:20~23:30
- volc_consume:main 手动运行门卫同步 23:20 窗口(--force/--dry-run 不受限)
- docs/volc-quota-notes §9:consume clamp2 余额快照滞后在最新思路(§4.2 量化
  L=149,616)下确认为必改项,方向待定;消耗任务口径同步收紧标记 [x]
- 全仓文档/注释 23:00 统一同步为 23:20(AGENTS/README/docker-compose/两 docs)
2026-08-13 06:08:26 +00:00
vibe-code f86aa59af1 docs: 采纳独立复核精化 v6 注释与不变量(reserve 阈值/consume 快照口径) 2026-08-13 05:51:41 +00:00
vibe-code 729b1a3987 chore: 移除撞墙验证临时 80w 覆盖,恢复统一 2M 默认限额 2026-08-13 05:19:33 +00:00
vibe-code b246393be4 docs: 通读整理 volc-quota-notes.md
- §2 扣费案例标注已被 §5 推翻,消除误导
- §5 日志示例标注为 v2/v5 历史,补充 v6 当前格式
- §7 重编号: v6 Snapshot-Offset 从 §7.6→§7.5, 2M 击穿从 §7.7→§7.6
- §7.1 标题修正(不再写 'v5 已恢复')
- 删除过时的 §7.4 待观察项(BUFFER 已不存在)
- 修正 §7.5 SMALL_BUFFER(5k) 笔误为 5 万
- 修正全章节交叉引用编号
- 统一半角标点风格
2026-08-13 04:26:44 +00:00
vibe-code 65c52442c8 docs: 补充 §7.7 2M 击穿事件 + reserve 改进 + 排障速查更新
- 新增 §7.7: 2M 采集上限击穿事件(20:53 实测)与 reserve 改进
  (token_counter + max_tokens + 后置 soft_evict)
- §8 排障速查: 补充 2M 击穿(§7.7)、B0 守恒前提(§4.2)
- §9 待确认: 新增 reserve 精度验证项,标记 2M 击穿修复完成
- 文件头日期更新
2026-08-13 04:16:52 +00:00
vibe-code 9f8c6dc44c docs: 修正 v6 限额「B0 常数」论证缺陷,记录 08-13 余额滞后实测与 512 亿笔误 2026-08-13 03:55:40 +00:00
vibe-code 6edb40d01e chore: 临时 80w 限额覆盖用于撞墙验证(验证后移除) 2026-08-13 03:21:22 +00:00
vibe-code 8a6416f28f feat: 前置闸门预计算输入 token 增加 DEBUG 日志 2026-08-13 03:21:22 +00:00
vibe-code 82502dda05 fix: 前置闸门 reserve 改用 token_counter 精确估算输入 token
reserve 从单凭 max_tokens fallback 50k 改为:
  reserve = litellm.token_counter(messages) + max_tokens(声明值)

- 输入 token 精确计数(未知模型 fallback cl100k_base,对中文偏高更安全)
- 输出用请求声明的 max_tokens,未声明时 fallback PREBLOCK_RESERVE_TOKENS
- 修复 2M 采集上限被单个大请求(176k output)击穿的问题
2026-08-12 14:58:04 +00:00
vibe-code 23778a8f7c feat: Snapshot-Offset 限额策略替换 BUFFER 盲猜
核心改动:不再用 BUFFER 猜滞后窗口消耗,而是用同步时点快照
 直接计算累计限额。

- BUFFER=300000 → SMALL_BUFFER=50000(仅覆盖非消耗性跳降)
- get_effective_limit: limit = B0 - SMALL_BUFFER,不依赖 current_usage
- 同步循环:写 balance_at_sync + usage_at_sync 替代 remaining/stable_limit
- 突发检测/低额度判断:读 balance_at_sync 替代 remaining
- volc_consume.py: vls.BUFFER → vls.SMALL_BUFFER
- debug/debug_volc_sync.py: 同步更新
- docs/volc-quota-notes.md: 新增 §7.6 v6 设计文档,更新各节口径
2026-08-12 12:22:22 +00:00
vibe-code 97484cb7f8 fix: v5 恢复 v2 骨架,修正 v4 误拦
v4(stable = balance - BUFFER)把「剩余余额」当「从 0 累计的上限」,
U>0 时低估初始池:15:09:51 实测 Bal=596,869(剩 59.7 万)却被
usage 1,696,135 >= 296,869 全拦。守恒反推(U+B=2,293,004)证明
v2 公式数学正确——B 滞后收敛后满足 B = B0 - U,今日消耗 1.696M <
B0 2.293M 无扣费(此前「超余额 13.8 万扣费」基于滞后快照的推断
不成立)。v5 = 恢复 v2(stable = usage + balance - BUFFER),
残留风险仅同步时点 B 滞后(13:36 高估 ~50 万),由 BUFFER + 低余额
加密同步覆盖。文档同步:volc-quota-notes §7.3/§7.5、merge-auto-consume §4.5。
2026-08-12 07:21:13 +00:00
9 changed files with 1113 additions and 532 deletions
+5 -5
View File
@@ -5,10 +5,10 @@
## 1. 项目上下文
此仓库包含用于 **LiteLLM** 的自定义回调脚本,核心功能是限制模型用量(特别是火山引擎/VolcEngine)。
- **核心逻辑**:通过 `quota_interceptor` 拦截请求,并通过 `DailyLimitHandler` 记录用量到 Redis。后台 `active_monitor_loop` 守护线程定期从火山引擎 API 同步配额到 Redis。
- **架构**:主逻辑在 `volc_limit_sync.py`(限额 + 同步守护 + 每日消耗调度),消耗任务拆分在 `volc_consume.py`(每日北京时间 23:00 由守护线程延迟导入触发;见 `merge-auto-consume.md`)。LiteLLM 通过 `config.yaml` 中 `callbacks: ["volc_limit_sync.proxy_handler_instance"]` 加载回调,模块加载时自动启动后台同步线程。
- **架构**:主逻辑在 `volc_limit_sync.py`(限额 + 同步守护 + 每日消耗调度),消耗任务拆分在 `volc_consume.py`(每日北京时间 23:20 由守护线程延迟导入触发;见 `merge-auto-consume.md`)。LiteLLM 通过 `config.yaml` 中 `callbacks: ["volc_limit_sync.proxy_handler_instance"]` 加载回调,模块加载时自动启动后台同步线程。
- **关键文件**:
- `volc_limit_sync.py`: **核心文件**,包含同步守护线程(`active_monitor_loop`)、LiteLLM 回调(`DailyLimitHandler` + `quota_interceptor`)、限额计算(v4 冻结快照:`stable_limit = balance − BUFFER`,`effective_limit = min(2M 采集配额, stable_limit)`;扣费红线 = 余额,与采集量/返还无关,见 `docs/volc-quota-notes.md`)、模型屏蔽/恢复、以及每日 23:00 消耗任务调度(`_maybe_trigger_daily_consume`,SETNX 去重)。
- `volc_consume.py`: 消耗任务模块(每日 23:00 触发;手动 `python volc_consume.py --dry-run` / `--force`)。复用 volc_limit_sync 的拉包/归一化/用量记账设施,直连 Ark API 洗即将过期的免费资源包(防 30 天过期蒸发)。
- `volc_limit_sync.py`: **核心文件**,包含同步守护线程(`active_monitor_loop`)、LiteLLM 回调(`DailyLimitHandler` + `quota_interceptor`)、限额计算(v5:`stable_limit = usage + balance − BUFFER`,`effective_limit = min(2M 采集配额, stable_limit)`;当日初始免费池守恒,扣费判定见 `docs/volc-quota-notes.md`)、模型屏蔽/恢复、以及每日 23:20 消耗任务调度(`_maybe_trigger_daily_consume`,SETNX 去重)。
- `volc_consume.py`: 消耗任务模块(每日 23:20 触发;手动 `python volc_consume.py --dry-run` / `--force`)。复用 volc_limit_sync 的拉包/归一化/用量记账设施,直连 Ark API 洗即将过期的免费资源包(防 30 天过期蒸发)。
- `config.yaml`: LiteLLM 配置文件,包含模型定义和回调配置。
- `docker-compose.yml`: 容器编排,仅挂载 `config.yaml`、`volc_limit_sync.py`、`volc_consume.py`。
- `docs/`: 设计文档与额度机制速查(`merge-auto-consume.md` 消耗任务/限额设计、`volc-quota-notes.md` 火山额度机制事实基础、`merge-volc-limit-into-newapi.md` 未实施的迁移方案)。
@@ -45,9 +45,9 @@ pip install litellm redis requests
```bash
# 需要 HUOSHAN_API_KEY(Ark API Key)与 HUOSHAN_BASE_URL
python volc_consume.py --dry-run # 只计算每个模型的消耗计划,不发送请求
python volc_consume.py --force # 立即执行(绕过 23:00 时间限制)
python volc_consume.py --force # 立即执行(绕过 23:20 时间限制)
```
每日 23:00 由 `volc_limit_sync.py` 守护线程自动触发(`consume:last_run:{日期}` 去重)。
每日 23:20 由 `volc_limit_sync.py` 守护线程自动触发(`consume:last_run:{日期}` 去重)。
## 3. 代码风格指南 (Code Style)
+33 -2
View File
@@ -165,7 +165,7 @@ python scripts/generate_keys.py
```python
HARD_DAILY_LIMITS = {
# 火山免费额度规则:每模型每日采集上限 2M,次日按采集量发放资源包。
# 采集上限封顶;真正决定今日可采的是 v4 动态层:
# 采集上限封顶;真正决定今日可采的是 v5 动态层:
# effective = min(2M, stable_limit),stable_limit = 余额快照 − BUFFER(见 docs/volc-quota-notes.md)
"__DEFAULT__": 2_000_000,
}
@@ -228,6 +228,37 @@ python volc_limit_sync.py
DAILY_LIMIT_LOG_LEVEL=DEBUG python volc_limit_sync.py
```
### 手动软踢某个模型(运维应急,立即 429)
模型踩采集上限/异常时,无需等脚本同步阈值,直接往 Redis 写一个 `manual_evict` key 即可将该模型**立即、持续地**软踢(恒 429,直到显式解除)。
key 格式:`manual_evict:<config.yaml 中该条目的完整 model 字段>`(**含渠道前缀,全匹配**,如 `volcengine/deepseek-v4-flash-ga-260731`;同一 clean 名但不同渠道的模型不会被误伤)。
```bash
# 软踢:立即对该模型返回 429(key 落 Redis 持久卷,重启 litellm 后仍生效)
docker exec litellm_redis redis-cli SET manual_evict:volcengine/<model> 1
# 解除软踢
docker exec litellm_redis redis-cli DEL manual_evict:volcengine/<model>
# 查看当前手动软踢的模型
docker exec litellm_redis redis-cli KEYS manual_evict:*
```
示例(软踢 deepseek-v4-flash-ga-260731):
```bash
docker exec litellm_redis redis-cli SET manual_evict:volcengine/deepseek-v4-flash-ga-260731 1
```
注意:
- 软踢只作用于**纳入监控**的火山模型(`MONITORED_MODEL_PAIRS` 内);非监控渠道不受影响。
- 幂等:key 在期间每个请求都 429,重复 SET 无副作用。
- 解除后立即恢复按正常限额判定。
- **自动解除**:当脚本检测到当前 `balance_at_sync` 比踢除时记录的余额快照**上升 ≥ 50 万**(`AUTO_RELEASE_BALANCE_MARGIN`,可调),即视为免费额度已返还,自动 DEL 该 key 并恢复请求。日常运维无需手动 DEL;仅当模型已被自动解除但你想立即恢复软踢时,重新 SET 即可。
- 自动解除阈值默认 50 万,可在 `.env` 中覆盖:`AUTO_RELEASE_BALANCE_MARGIN=1000000`。
---
## 文件职责
@@ -237,7 +268,7 @@ litellm-config/
├── config.yaml # 纳入监控模型的主入口
├── docker-compose.yml # 容器编排(挂载 config.yaml / volc_limit_sync.py / volc_consume.py)
├── volc_limit_sync.py # 当前生效的监控、同步、限额脚本(v4 冻结快照限额)
├── volc_consume.py # 每日 23:00 自动消耗任务(防免费资源包过期蒸发)
├── volc_consume.py # 每日 23:20 自动消耗任务(防免费资源包过期蒸发)
├── AGENTS.md # 仓库 Agent 协议(代码风格/运行/提交规范)
├── README.md
├── docs/
+68 -212
View File
@@ -36,40 +36,6 @@ model_list:
###
#日常使用ds v3.2 ,强制think
# - model_name: deepseek-v3.2
# litellm_params:
# <<: *token_pricing
# model: volcengine/deepseek-v3-2-251201
# api_base: os.environ/HUOSHAN_BASE_URL
# api_key: os.environ/HUOSHAN_API_KEY
# 说明中建议设置30分钟(1800秒)以上,避免深度思考过程中断连接
# timeout: 1800
# 关键配置2:启用思考模式
# extra_body 中的参数会被直接合并到请求的 JSON body 中
# extra_headers:
# Connection: "close"
# extra_body:
# thinking:
# type: "enabled"
# model_info:
# max_input_tokens: 128000 # 允许的最大输入长度(单位:token)
# max_context_length: 128000 # 上下文窗口上限
# - model_name: deepseek-v3.2-nothink
# litellm_params:
# <<: *token_pricing
# model: volcengine/deepseek-v3-2-251201
# api_base: os.environ/HUOSHAN_BASE_URL
# api_key: os.environ/HUOSHAN_API_KEY
# extra_headers:
# Connection: "close"
# extra_body:
# thinking:
# type: "disabled"
- model_name: huoshan/deepseek-v4-flash
@@ -78,102 +44,20 @@ model_list:
model: volcengine/deepseek-v4-flash-ga-260731
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
allowed_openai_params: ["reasoning_effort"]
drop_params: true
- model_name: huoshan/deepseek-v4-pro
litellm_params:
<<: *token_pricing
model: volcengine/deepseek-v4-pro-ga-260813
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
allowed_openai_params: ["reasoning_effort"]
drop_params: true
- model_name: doubao-seed-1-8
litellm_params:
<<: *token_pricing
model: volcengine/doubao-seed-1-8-251228
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
drop_params: true
extra_headers:
Connection: "close"
- model_name: doubao-seed-1-8-nothink
litellm_params:
<<: *token_pricing
model: volcengine/doubao-seed-1-8-251228
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
extra_headers:
Connection: "close"
drop_params: true
extra_body:
reasoning_effort: "minimal"
# - model_name: doubao-seed-2-0-pro
# litellm_params:
# <<: *token_pricing
# model: volcengine/doubao-seed-2-0-pro-260215
# api_base: os.environ/HUOSHAN_BASE_URL
# api_key: os.environ/HUOSHAN_API_KEY
# timeout: 1800
# drop_params: true
# extra_headers:
# Connection: "close"
# - model_name: doubao-seed-2-0-lite
# litellm_params:
# <<: *token_pricing
# model: volcengine/doubao-seed-2-0-lite-260215
# api_base: os.environ/HUOSHAN_BASE_URL
# api_key: os.environ/HUOSHAN_API_KEY
# drop_params: true
# extra_headers:
# Connection: "close"
- model_name: doubao-seed-2-0-code
litellm_params:
<<: *token_pricing
model: volcengine/doubao-seed-2-0-code-preview-260215
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
drop_params: true
extra_headers:
Connection: "close"
model_info:
max_input_tokens: 224000 # 允许的最大输入长度(单位:token)
max_context_length: 256000 # 上下文窗口上限
#禁用思考,用于标题等
- model_name: doubao-seed-2-0-code-nothink
litellm_params:
<<: *token_pricing
model: volcengine/doubao-seed-2-0-code-preview-260215
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
drop_params: true
extra_headers:
Connection: "close"
extra_body:
# thinking:
# type: "disable"
reasoning_effort: "minimal"
model_info:
max_input_tokens: 224000 # 允许的最大输入长度(单位:token)
max_context_length: 256000 # 上下文窗口上限
# doubao-seed-2-0-code 强制中等思考,用作回退
- model_name: doubao-seed-2-0-code-medium
litellm_params:
<<: *token_pricing
model: volcengine/doubao-seed-2-0-code-preview-260215
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
timeout: 1800
drop_params: true
extra_headers:
Connection: "close"
extra_body:
reasoning_effort: "medium"
model_info:
max_input_tokens: 224000 # 允许的最大输入长度(单位:token)
max_context_length: 256000 # 上下文窗口上限
- model_name: doubao-seed-2-1-turbo
litellm_params:
@@ -181,6 +65,7 @@ model_list:
model: volcengine/doubao-seed-2-1-turbo-260628
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
allowed_openai_params: ["reasoning_effort"]
drop_params: true
extra_headers:
Connection: "close"
@@ -194,6 +79,7 @@ model_list:
model: volcengine/doubao-seed-2-1-pro-260628
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
allowed_openai_params: ["reasoning_effort"]
drop_params: true
extra_headers:
Connection: "close"
@@ -208,6 +94,17 @@ model_list:
model: volcengine/glm-5-2-260617
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
# 方舟 glm-5-2-260617 支持 reasoning_effort(none/minimal/high/max,默认 high;
# 官方参数页 + 实测 curl 均确认),但 LiteLLM 的 VolcEngineChatConfig
# get_supported_openai_params() 白名单里没有它 —— 它是 openai-like 的固定
# 参数表,不含 reasoning_effort —— 客户端一传就抛 UnsupportedParamsError。
# allowed_openai_params 是官方逃生口:get_optional_params 用它在校验前
# extend supported_params,随后 _apply_openai_param_overrides 把该参数从
# non_default_params 原样写回 optional_params,最终透传到请求体。
# 不用 drop_params:那是把该模型所有不支持参数静默丢弃,会掩盖其它真错。
# 注意 thinking.type 无需此项,VolcEngineChatConfig 已原生白名单。
allowed_openai_params:
- reasoning_effort
extra_headers:
Connection: "close"
model_info:
@@ -215,18 +112,22 @@ model_list:
max_context_length: 1024000 # 上下文窗口上限
#=======================豆包汇总nothink
# 【思考档位别名组】doubao-seed-nothink / -minimal / -low / -medium / -high / fanyi
# 语义:别名后缀即【默认】档位,非硬锁。
# - 客户端不传 reasoning_effort:按 extra_body 钉的档位发给方舟。
# - 客户端显式传 reasoning_effort:以客户端为准(覆盖默认档位)。
# 为什么钉不成硬锁(实测,本地 echo 抓上游 body):
# 请求带 reasoning_effort 时,Router._deployment_params_with_request_reasoning_override
# 会【无条件】剥掉部署侧 extra_body.reasoning_effort / thinking;
# 此时若没有 allowed_openai_params 放行,drop_params 又把客户端这个参数丢掉,
# 两者同归于尽 ⇒ 上游 body 两者都没有 ⇒ 方舟回落默认 high(静默失效)。
# 故必须加 allowed_openai_params:让客户端值能通过校验,使结果至少是
# 「客户端说了算」而不是「悄悄变回 high」。
# LiteLLM 当前无纯配置手段做到「不论客户端传什么一律覆盖」;要硬锁只能加
# pre-call hook(本仓库当前不采用)。
# 注:方舟 doubao 上 minimal 等价于关闭思考(实测 reasoning_tokens=0),
# 故 nothink/minimal/fanyi 三个别名默认档位统一用 minimal。
- model_name: doubao-seed-nothink
litellm_params:
<<: *token_pricing
model: volcengine/doubao-seed-2-0-code-preview-260215
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
drop_params: true
extra_headers:
Connection: "close"
extra_body:
reasoning_effort: "minimal"
- model_name: doubao-seed-nothink
litellm_params:
@@ -234,6 +135,7 @@ model_list:
model: volcengine/doubao-seed-2-1-pro-260628
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
allowed_openai_params: ["reasoning_effort"]
drop_params: true
extra_headers:
Connection: "close"
@@ -246,6 +148,7 @@ model_list:
model: volcengine/doubao-seed-2-1-turbo-260628
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
allowed_openai_params: ["reasoning_effort"]
drop_params: true
extra_headers:
Connection: "close"
@@ -256,17 +159,6 @@ model_list:
#=======================豆包汇总思考等级minimal
- model_name: doubao-seed-minimal
litellm_params:
<<: *token_pricing
model: volcengine/doubao-seed-2-0-code-preview-260215
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
drop_params: true
extra_headers:
Connection: "close"
extra_body:
reasoning_effort: "minimal"
- model_name: doubao-seed-minimal
litellm_params:
@@ -274,6 +166,7 @@ model_list:
model: volcengine/doubao-seed-2-1-pro-260628
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
allowed_openai_params: ["reasoning_effort"]
drop_params: true
extra_headers:
Connection: "close"
@@ -286,6 +179,7 @@ model_list:
model: volcengine/doubao-seed-2-1-turbo-260628
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
allowed_openai_params: ["reasoning_effort"]
drop_params: true
extra_headers:
Connection: "close"
@@ -295,29 +189,7 @@ model_list:
#=======================豆包汇总思考等级low
- model_name: doubao-seed-1-8
litellm_params:
<<: *token_pricing
model: volcengine/doubao-seed-1-8-251228
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
drop_params: true
extra_headers:
Connection: "close"
extra_body:
reasoning_effort: "high"
- model_name: doubao-seed-low
litellm_params:
<<: *token_pricing
model: volcengine/doubao-seed-2-0-code-preview-260215
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
drop_params: true
extra_headers:
Connection: "close"
extra_body:
reasoning_effort: "low"
- model_name: doubao-seed-low
litellm_params:
@@ -325,6 +197,7 @@ model_list:
model: volcengine/doubao-seed-2-1-pro-260628
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
allowed_openai_params: ["reasoning_effort"]
drop_params: true
extra_headers:
Connection: "close"
@@ -337,6 +210,7 @@ model_list:
model: volcengine/doubao-seed-2-1-turbo-260628
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
allowed_openai_params: ["reasoning_effort"]
drop_params: true
extra_headers:
Connection: "close"
@@ -346,17 +220,6 @@ model_list:
#=======================豆包汇总思考等级medium
- model_name: doubao-seed-medium
litellm_params:
<<: *token_pricing
model: volcengine/doubao-seed-2-0-code-preview-260215
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
drop_params: true
extra_headers:
Connection: "close"
extra_body:
reasoning_effort: "medium"
- model_name: doubao-seed-medium
litellm_params:
@@ -364,6 +227,7 @@ model_list:
model: volcengine/doubao-seed-2-1-pro-260628
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
allowed_openai_params: ["reasoning_effort"]
drop_params: true
extra_headers:
Connection: "close"
@@ -376,6 +240,7 @@ model_list:
model: volcengine/doubao-seed-2-1-turbo-260628
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
allowed_openai_params: ["reasoning_effort"]
drop_params: true
extra_headers:
Connection: "close"
@@ -385,17 +250,7 @@ model_list:
#=======================豆包汇总思考等级high
- model_name: doubao-seed-high
litellm_params:
<<: *token_pricing
model: volcengine/doubao-seed-2-0-code-preview-260215
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
drop_params: true
extra_headers:
Connection: "close"
extra_body:
reasoning_effort: "high"
- model_name: doubao-seed-high
litellm_params:
@@ -403,6 +258,7 @@ model_list:
model: volcengine/doubao-seed-2-1-pro-260628
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
allowed_openai_params: ["reasoning_effort"]
drop_params: true
extra_headers:
Connection: "close"
@@ -415,6 +271,7 @@ model_list:
model: volcengine/doubao-seed-2-1-turbo-260628
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
allowed_openai_params: ["reasoning_effort"]
drop_params: true
extra_headers:
Connection: "close"
@@ -427,30 +284,29 @@ model_list:
- model_name: fanyi
litellm_params:
<<: *token_pricing
model: volcengine/doubao-seed-1-8-251228
model: volcengine/doubao-seed-2-1-pro-260628
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
timeout: 120
stream: false
api_key: os.environ/HUOSHAN_API_KEY
allowed_openai_params: ["reasoning_effort"]
drop_params: true
extra_headers:
Connection: "close"
Connection: "close"
extra_body:
reasoning_effort: "minimal"
reasoning_effort: "minimal"
- model_name: fanyi
litellm_params:
<<: *token_pricing
model: volcengine/doubao-seed-2-1-turbo-260628
api_base: os.environ/HUOSHAN_BASE_URL
api_key: os.environ/HUOSHAN_API_KEY
allowed_openai_params: ["reasoning_effort"]
drop_params: true
extra_headers:
Connection: "close"
extra_body:
reasoning_effort: "minimal"
# - model_name: fanyi
# litellm_params:
# <<: *token_pricing
# model: volcengine/doubao-seed-2-0-lite-260215
# api_base: os.environ/HUOSHAN_BASE_URL
# api_key: os.environ/HUOSHAN_API_KEY
# timeout: 120
# stream: false
# drop_params: true
# extra_headers:
# Connection: "close"
# extra_body:
# reasoning_effort: "minimal"
@@ -462,7 +318,7 @@ litellm_settings:
#drop_params: true #启用drop_params,这样LiteLLM会忽略不支持的参数,但参数仍然会传递给后端API
num_retries: 3 # retry call 3 times on each model_name (e.g. zephyr-beta)
fallbacks: [
{"doubao-seed-high": ["deepseek-v4-pro"]}
#{"doubao-seed-high": ["deepseek-v4-pro"]}
]
#default_fallbacks: ["deepseek-v4-flash"]
request_timeout: 120 # raise Timeout error if call takes longer than 10s. Sets litellm.request_timeout
+3 -3
View File
@@ -20,7 +20,7 @@ SECRET_KEY = os.getenv(
)
HOST = "open.volcengineapi.com"
REGION = "cn-beijing"
BUFFER = 300000
SMALL_BUFFER = 50000
# Suffixes to clean from Volcengine response
SUFFIXES_TO_REMOVE = [
@@ -203,10 +203,10 @@ if __name__ == "__main__":
print("\n--- Final Model Balances ---")
for model, balance in model_balances.items():
remaining = balance - BUFFER
remaining = balance - SMALL_BUFFER
print(f"Model: {model}")
print(f" Balance : {balance:,}")
print(f" Buffer : {BUFFER:,}")
print(f" SmallBuffer: {SMALL_BUFFER:,}")
print(f" Remaining: {remaining:,}")
# Simulate Redis Keys
+32 -5
View File
@@ -9,7 +9,7 @@ services:
- ./config.yaml:/app/config.yaml
# 【关键】挂载自定义脚本到容器根目录
- ./volc_limit_sync.py:/app/volc_limit_sync.py
# 自动消耗任务(每日 23:00 由 volc_limit_sync 调度触发)
# 自动消耗任务(每日 23:20 由 volc_limit_sync 调度触发)
- ./volc_consume.py:/app/volc_consume.py
# 消耗任务语料:宿主真实系统日志(只读)
- /var/log:/host-var-log:ro
@@ -21,8 +21,28 @@ services:
TZ: Asia/Shanghai
# ⚠️ 必须设置这个环境变量,否则 Python 找不到你的脚本
PYTHONPATH: "/app"
DATABASE_URL: "postgresql://litellm_app:shriek-sixfold-ethanol@192.168.1.34:5432/litellm"
# ⚠️ 必须保留 client_encoding=UTF8。
# 本库 litellm 是 SQL_ASCII 编码(server/client_encoding 均为 SQL_ASCII,
# datcollate=C)。psycopg 对 SQL_ASCII 连接的 TextLoader 直接返回 bytes
# 而非 str(psycopg/types/string.py:110-120),于是 litellm_proxy_extras
# 里 _MIGRATION_TS_RE.match(migration_name)、
# _TRANSIENT_INDEX_SUFFIX_RE.search(index.name) 等对 bytes 用字符串正则,
# 抛 TypeError: cannot use a string pattern on a bytes-like object。
# 该参数经 libpq 作为启动参数下发(fe-protocol3.c:2503),使连接按 UTF8
# 协商;psycopg 读服务端确认值决定解码(pq_ctypes.py:229-232),
# 从而正常返回 str。Prisma 侧无影响:quaint 丢弃未知参数
# (prisma-engines quaint/src/connector/postgres/url.rs:459),
# 且其驱动 tokio-postgres 本就固定发送 client_encoding=UTF8。
# 已全库校验:450 个文本列 / 43548 行 / 14.5MB 全部是合法 UTF-8,
# 不存在解码失败风险。
DATABASE_URL: "postgresql://litellm_app:shriek-sixfold-ethanol@192.168.1.34:5432/litellm?client_encoding=UTF8"
STORE_MODEL_IN_DB: "True"
# v2 migration resolver 的额外兜底(可去掉以启用 v2):
# main-latest 自 2026-09-20 起默认启用 v2(上游 commit fac518dbf7),
# v2 会在启动时调用 _warn_if_db_ahead_of_head() —— 即上面那个 TypeError
# 的触发点。上面的 client_encoding 修复已从根因上解除该崩溃,
# 但 v2 还引入迁移协调锁等新机制;保守起见先沿用更新前的 v1 路径。
USE_V2_MIGRATION_RESOLVER: "false"
# --- Modified: 添加 Redis 配置 ---
REDIS_HOST: "redis" # 对应下方 redis 服务的名字
REDIS_PORT: "6379"
@@ -50,15 +70,19 @@ services:
# --maxmemory-policy allkeys-lru : 内存满了之后,自动删除最久未使用的缓存(无论是否过期)
# --save "" : 如果你不需要持久化缓存到硬盘(重启后清空),可以把 save 设为空,减少磁盘IO
# command: redis-server --save 60 1 --maxmemory 128mb --maxmemory-policy allkeys-lru --loglevel warning
command: redis-server --maxmemory 32mb --maxmemory-policy allkeys-lru --save 300 1 --loglevel warning
# --appendonly yes: 开启 AOF,防止 Redis 崩溃/重启丢失快照与用量 key →
# 避免"额度未同步"fallback 窗口(首次同步前)被大请求烧穿免费额度
command: redis-server --maxmemory 32mb --maxmemory-policy allkeys-lru --save 300 1 --appendonly yes --appendfsync everysec --loglevel warning
environment:
TZ: Asia/Shanghai
# 配置详解:
# --maxmemory 32mb: 严格限制内存使用 32MB
# --maxmemory-policy allkeys-lru: 内存满时挤掉旧数据(安全兜底)
# --save 300 1: 每300秒(5分钟)如果至少有1个key变化,就保存快照到磁盘 (降低IO)
volumes:
# 映射数据目录,确保重启容器后,今天的计数还在
volumes:
# 映射数据目录:不挂载则 down/up 重建容器时 RDB+AOF 随容器删除,
# 当日用量统计与快照全丢(此前每次重启都丢)。挂载后 down 不删卷,
# 数据跨重启保留。
- redis_data:/data
# ---------------------------
@@ -85,3 +109,6 @@ volumes:
name: litellm_postgres_data
redis_data: # 如果开启了 redis 持久化,这里也要打开
driver: local
# 固定卷名:卷名不再由 compose 项目名(目录名)派生,
# 目录改名/迁移后 up 不会新建空卷导致数据"丢失"
name: litellm_redis_data
+45 -30
View File
@@ -1,7 +1,7 @@
# 火山免费额度自动消耗:auto_consume 合入 volc_limit_sync 设计方案
> 日期:2026-08-11
> 背景:`/root/workspace/huoshan-api-auto_consume/`(自动消耗脚本)与 `/root/workspace/litellm-config/`(LiteLLM 限流回调 + 火山余额同步)合并,使每日 23:00 自动消耗免费额度,防止资源包过期蒸发,避免真实扣费。
> 背景:`/root/workspace/huoshan-api-auto_consume/`(自动消耗脚本)与 `/root/workspace/litellm-config/`(LiteLLM 限流回调 + 火山余额同步)合并,使每日 23:20 自动消耗免费额度,防止资源包过期蒸发,避免真实扣费。
> 前置文档:《merge-volc-limit-into-newapi.md》(2026-08-05,方向相反:把 volc_limit_sync 功能迁往 new-api,未实施)。
---
@@ -12,7 +12,7 @@
```
每天任意时刻 真实用户 + 消耗任务 的消耗 = 当日采集量(火山实时统计,每模型单独计算)
每天 23:00 CST 消耗任务补齐缺口(可选,见 §4)
每天 23:20 CST 消耗任务补齐缺口(可选,见 §4)
次日 00:00 起 火山按昨日采集量发放资源包(不定时,可能延迟到上午)
```
@@ -122,7 +122,7 @@ API 请求的 token 消耗不可精确控制(输入按字符估算、输出不
**为什么均分优于一次性洗光**(在新模型下):
1. **最小干扰真实用户**:只在额度即将死亡(进窗口)后才每天接管 1/D,其余留给真实用户 → 免费额度利用率最大化,空窗风险最小
2. **请求负载分摊**:每天 3–5 次请求(而非 25–40 次),速率伪装自然,风控友好
3. **余额下降平缓**:23:00 后余额仍保留大部分 → 扣费只发生在余额真正归零时,均分下脚本几乎不可能制造归零
3. **余额下降平缓**:23:20 后余额仍保留大部分 → 扣费只发生在余额真正归零时,均分下脚本几乎不可能制造归零
4. **FIFO 自动一致**:效期统一 30 天 → 获得早 = 过期早 → 队列头部 = 窗口内包,计划与执行不打架(分包明细仅用于定总量)
**与日返还循环的契合**:旧脚本本就是"每天跑一次、分 D 天烧完"的设计;自动化后每天重算(余额/天数变化 → div 递减 → 当日份额递增),节奏天然吻合。9 天窗口 + 1.1 系数 + 24h 冲刺:只要每天触发成功,任何进窗口的包都赶在过期前清空。
@@ -149,13 +149,18 @@ daily_usage ≥ 2M → 停(采集上限,再烧不返还
### 3.6 触发调度
- 北京时间 **23:00 ± 随机抖动**(防固定时间被风控识别),Redis `consume:last_run:{日期}` 去重,当天未跑过才触发
- 北京时间 **23:20 ± 随机抖动**(防固定时间被风控识别),Redis `consume:last_run:{日期}` 去重,当天未跑过才触发
- 守护线程(`active_monitor_loop` 已有主循环)检测时点 → spawn daemon 线程执行,不阻塞主循环
- 23:00 的时点语义:距 00:00 采集结算 1 小时,此时窗口内包状态已定(真实用户当天用量已发生),洗包目标最准
- 23:20 的时点语义:距 00:00 采集结算 40 分钟,此时窗口内包状态已定(真实用户当天用量已发生),洗包目标最准
---
## 四、限额设计(防扣费)——v2 简化逻辑(v4 修订见 §4.5,2026-08-12 已实施)
## 四、限额设计(防扣费)——v2 简化逻辑(v5 修订见 §4.5,2026-08-12 已实施)
> ⚠️ 现行版本为 v6 Snapshot-Offset,见 `volc-quota-notes.md` §7.6。2026-08-13 实测修正了
> 其「B0 常数」论证缺陷:`limit = balance_at_sync + usage_at_sync − SMALL_BUFFER = B0 + L − SMALL_BUFFER`,
> L = 同步时点 API 结算滞后窗口内未结算消耗(实测 Δ≈14 分钟、L=149,616 > SMALL_BUFFER 5 万),
> 今日由静态 2M 墙兜底(B0=2,242,178 > 2M),B0 < 2M 时无兜底、有超扣风险。本节 v2/v5 仅作沿革参考。
### 4.1 为什么废弃 Peak-Buffer 峰值状态机(存量场景产物)
@@ -175,8 +180,8 @@ daily_usage ≥ 2M → 停(采集上限,再烧不返还
### 4.2 新公式
> ⚠️ 2026-08-12 修订:本节 v2 公式「池恒定」假设(usage ↑ / balance ↓ 同步)失效,
> 已由 v4 取代(见 §4.5)。保留原文作历史论证。
> ⚠️ 2026-08-12 修订:本节 v2 公式经实测确认**数学正确**(B 滞后收敛后守恒成立),
> 无需取代;v4 冻结快照尝试因误拦已回退(见 §4.5)。保留原文作论证。
```
effective_limit = min(HARD_DAILY_LIMITS, max(0, usage + balance − BUFFER))
@@ -188,10 +193,10 @@ HARD_DAILY_LIMITS → 统一 2M(采集上限;doubao-seed-code-preview-251028
- 返还:balance 跳涨 → 池涨 → 限额次日自动解锁(无需 RECHARGE)✓
- 过期:balance 跳跌 → 池跌 → 限额收紧(无需 DROP)✓
- 余额滞后(实测 20 万内不动):池虚高 ≤ 20 万 → BUFFER 30 万覆盖 ✓
- 消耗任务 23:00 烧(usage 计入):池不变 → 滞后窗口内限额偏松 ≤ BUFFER ✓
- 消耗任务 23:20 烧(usage 计入):池不变 → 滞后窗口内限额偏松 ≤ BUFFER ✓
- 余额耗尽:池 − 30 万 ≤ 0 → 限额 0 → 全拦,扣费结构性杜绝 ✓
**fallback(stable_limit 缺失窗口)**:免费模型余额常态 < 2M(昨日采多少返多少),若 dynamic 缺失(首次同步前/新增模型/TTL 过期)时裸奔到静态 2M,余额不足的模型会被烧穿扣费。故 fallback 用保守值 `FALLBACK_LIMIT = min(static, 90万)`(env 可调),宁可短时收紧不可裸奔。
**fallback(stable_limit 缺失窗口)**:免费模型余额常态 < 2M(昨日采多少返多少),若 dynamic 缺失(首次同步前/新增模型/TTL 过期)时裸奔到静态 2M,余额不足的模型会被烧穿扣费。故 fallback 用保守值 `FALLBACK_LIMIT = min(static, 20万)`(env 可调;2M 免费池的 10%,0 余额模型最大泄露收窄到 20 万),宁可短时收紧不可裸奔。另:Redis 不可用/读取异常时请求闸门 fail-closed(直接 429),不再放行。
**与消耗任务同构**:限额放行条件 = `balance − BUFFER ≥ 0`;消耗任务停止条件 = `余额快照 − 已烧 ≤ 0`——同一把尺子。
@@ -206,33 +211,43 @@ HARD_DAILY_LIMITS → 统一 2M(采集上限;doubao-seed-code-preview-251028
### 4.4 空窗期防扣费闭环(消耗量计入 daily_usage 的理由)
23:00 消耗任务把额度洗掉后余额可能很低,此时真实用户请求必须被拦。拦截依据 = `daily_usage vs effective_limit`:
23:20 消耗任务把额度洗掉后余额可能很低,此时真实用户请求必须被拦。拦截依据 = `daily_usage vs effective_limit`:
- **若消耗量不计入 daily_usage**:限额看到"真实用户才用 100k,未超限" → 放行 → **扣费**
- **若计入**:daily_usage ≈ 真实 + 消耗任务 ≈ 2M → 超限拦截 → 空窗期安全
同步线程检测 balance 归零 → limit 收紧有 2–60min 滞后;消耗量计入让拦截在**滞后窗口内也有效**,双保险。
### 4.5 v4 修订(2026-08-12 已实施):冻结快照,余额红线
### 4.5 v5 修订(2026-08-12 已实施):恢复 v2 骨架,实测修正论证
**v2 失效点**:`usage + balance = 当日初始可用池,消耗中恒定` 依赖余额 API 随消耗**实时递减**。实测余额 API 滞后(2026-08-12:快照 1,558,307 在 usage 1.24M→1.70M 期间纹丝不动,滞后上限未知),balance 不随消耗递减 → 池随 usage 膨胀(2.80M→3.26M)→「余额耗尽 → 限额 0」防线失效 → 放行到扣费区(当日超余额 13.8 万已扣费)。
**v2 曾被误判失效**:2026-08-12 上午观察到「balance 快照 1,558,307 在 usage 1.24M→1.70M 期间
不动」,据此推断 v2 的 `usage + balance` 池膨胀失效,改为 v4 冻结快照 `balance − BUFFER`。
**扣费口径(用户确认,2026-08-12)**:是否扣费只取决于**当下免费额度(余额)**;超出免费额度的请求都会被扣费;采集量只决定明日发放,与今日是否扣费无关。→ 余额必须是闸门红线,且不能依赖 API 实时性。
**v4 错误实证(15:09:51 生产日志)**:Bal=596,869(真实剩余 59.7 万),stable=296,869,
usage=1,696,135 → `[PRE-BLOCK] (1,696,135+16/296,869)` **误拦**——把「剩余余额」当
「从 0 累计的上限」,U>0 时低估初始池,有真实免费额度却全拦。
**v4 公式**:
**守恒反推(14:59:51 同步)**:U=1,696,135 + B=596,869 = B0=2,293,004 ≈ 理论剩余 597,004
+ 已用 1,696,135 → **B 滞后收敛后满足 B = B0 − U,v2 公式数学正确**;
13:36 的 1,558,307 是滞后快照(高估真实剩余约 50 万),非余额不足。
今日消耗 1,696,135 < B0 2,293,004 → **当日无扣费**。
**v5 公式(= v2 原式)**:
```
stable_limit = max(0, balance − BUFFER) # 冻结快照,同步时算一次,不随 usage 膨胀
effective_limit = min(HARD_DAILY_LIMITS, stable_limit) # 2M 防白烧;余额红线防扣费
stable_limit = max(0, usage + balance − BUFFER) # 同步时算一次
effective_limit = min(HARD_DAILY_LIMITS, stable_limit)
```
- 以最近同步快照的余额为「当日免费池上限」,本地 usage 实时累计撞线即拦 → **等价于本地实时扣减余额**,不依赖 API 刷新
- 返还:balance 跳涨 → 下次同步红线上升 → 自动解锁 ✓;过期/异常:balance 跳跌 → 红线收紧 ✓
- 余额 < BUFFER → 限额 0 → 全拦,扣费结构性杜绝 ✓
- `usage + balance` = 当日初始免费池(守恒),消耗中恒定(B 实时时);usage 累计撞线
= 用到剩 BUFFER 时停 —— 防扣费语义正确
- 残留风险仅「同步时点 B 滞后」→ stable 虚高 ≤ 滞后窗口消耗 → BUFFER + 低余额加密
同步(600s/300s)+ burst 触发覆盖;滞后收敛后池回守恒值
- 与消耗任务「余额快照 − 已烧 ≤ 0 停」同一把尺子(不变量:放行条件 = `balance − BUFFER ≥ 0`)
- 与昨日采集量**无关**:昨日 10K、余额 2M → 今日可用 1.7M(存量余额守恒,v3「昨日锚」因把单日流量当存量已被否决,见 volc-quota-notes.md §7.2)
- 与昨日采集量**无关**(存量余额守恒;v3「昨日锚」因把单日流量当存量已被否决)
**BUFFER 语义变化**:v2 里防「余额统计滞后 ≤ 20 万」;v4 里防「快照滞后窗口内(30min 常规同步 + burst 触发)的消耗 + 返还/过期抖动」。默认 30 万(env 可调),滞后上限未知时建议调大(如 50 万)。
**BUFFER 语义**:防「同步时点余额滞后窗口内的消耗」。13:36 实测滞后高估 ~50 万 > 30 万,
滞后上限未实测 → 建议调大(如 50 万,env 可调)或实测后定。
### 4.3 限额与消耗共用数据、方向相反
@@ -251,7 +266,7 @@ effective_limit = min(HARD_DAILY_LIMITS, stable_limit) # 2M 防白烧;余额
```
litellm-config/
├── volc_limit_sync.py # 现状:签名/拉取/归一化/限额/守护线程
│ # 新增:23:00 调度钩子(检测时点 + spawn 消耗线程 + 去重)
│ # 新增:23:20 调度钩子(检测时点 + spawn 消耗线程 + 去重)
├── volc_consume.py # 新增:消耗任务(从 auto_consume 移植 + 改造)
│ ├── load_base_logs / generate_dynamic_payload / _sanitize_line # 原样
│ ├── calculate_plan # 保留 + 两个 clamp
@@ -276,7 +291,7 @@ litellm-config/
### 5.3 消耗任务执行流程
```
23:00 触发(守护线程)
23:20 触发(守护线程)
→ consume:last_run:{今天} 已存在? → 跳过
→ 遍历 MONITORED_MODELS(config.yaml 发现的 volcengine 模型)
→ fetch_all_packages(复用)→ 按模型过滤窗口内包
@@ -298,7 +313,7 @@ litellm-config/
| 语料生成/脱敏/合成 | auto_consume | 原样移植 |
| 速率伪装/退避/失败上限/6h 上限 | auto_consume | 原样移植 |
| `_record_usage` 的 key 计算(`get_usage_key`) | volc_limit_sync | 消耗任务复用做 INCR |
| 调度钩子 | 新增 | 守护线程 23:00 检测 + 去重 |
| 调度钩子 | 新增 | 守护线程 23:20 检测 + 去重 |
---
@@ -309,11 +324,11 @@ litellm-config/
| 空窗期(0:00–次日发放不定时) | **用户确认可接受**;均分策略使空窗概率最小化(余额平缓下降) |
| 烧满 2M 的精确性 | 放弃该策略,改为过期风险驱动,结构性规避 |
| 物理上限 | 窗口内快过期额度 > 2M 时每天最多洗 2M,极少数过期蒸发不可避(返还上限硬约束) |
| 触发连续性 | 依赖每天 23:00 成功;连续失败 ~8 天耗尽窗口缓冲(冲刺兜底),守护线程常驻风险低 |
| 风控 | 23:00 ± 抖动 + 15–40s 随机间隔 + 每天 3–5 次请求,接近真实行为 |
| 触发连续性 | 依赖每天 23:20 成功;连续失败 ~8 天耗尽窗口缓冲(冲刺兜底),守护线程常驻风险低 |
| 风控 | 23:20 ± 抖动 + 15–40s 随机间隔 + 每天 3–5 次请求,接近真实行为 |
| 容器语料 | LiteLLM 容器内无 `/var/log` 系统日志 → 合成语料兜底(5000 行够用) |
| 消耗任务自身扣费 | 结构性杜绝:plan ≤ 余额快照 − 缓冲,且每轮检查余额 |
| 余额 API 滞后(v2 池膨胀根因) | v4 冻结快照:balance − BUFFER 定上限,本地 usage 实时撞线,滞后窗口由 BUFFER 覆盖 |
| 消耗任务自身扣费 | 结构性杜绝:有效墙 min(2M, b0_est − SMALL_BUFFER)(b0_est = 拉包时刻 余额+usage,守恒口径与 v6 闸门同构,2026-08-13 实施);usage 每轮实时含真实用户 → 两方合计不可超烧 |
| 余额 API 滞后 | v5 恢复 v2(usage+balance−BUFFER):滞后收敛后守恒成立;滞后窗口由 BUFFER + 低余额加密同步覆盖;v4 冻结快照(balance−BUFFER)因误拦已回退 |
---
@@ -330,7 +345,7 @@ litellm-config/
1. 移植 `auto_consume.py` 的语料/执行逻辑到 `volc_consume.py`,请求层改为直连 Ark(env key)
2. `calculate_plan` 加两个 clamp + 停止条件;执行循环加 `daily_usage` INCR 与余额检查
3. `volc_limit_sync.py` 守护线程加 23:00 调度钩子 + `consume:last_run` 去重
3. `volc_limit_sync.py` 守护线程加 23:20 调度钩子 + `consume:last_run` 去重
4. `HARD_DAILY_LIMITS` 统一为 2M;`MONITOR_TARGETS`/`config.py` 退役
5. 用 `test_connection.py` 等价方式验证 Ark 连通 → 手动触发一次消耗任务(dry-run 模式验证 plan 计算与 clamp)→ 观察 Redis key 变化
6. 全量回归:守护线程限额逻辑不受影响(消耗线程独立 daemon)
+362 -97
View File
@@ -1,24 +1,26 @@
# 火山免费额度机制速查(VolcEngine Quota Notes)
> 日期:2026-08-12
> 日期:2026-08-12 ~ 2026-08-13
> 用途:后续限额/防扣费设计、排障、消耗任务的事实基础,避免重复推导
> 来源:
> - `merge-auto-consume.md` §1(2026-08-11 用户确认的业务细则)
> - 2026-08-12 对话澄清(扣费口径、余额 API 滞后、限额方案演进)
> - 2026-08-12 生产日志观察(deepseek-v4-flash-ga-260731)
> - 2026-08-12 ~ 13 对话澄清 + 生产日志观察(deepseek-v4-flash-ga-260731)
> - 2026-08-13 网页口径校准(真实初始余额 B0=2,242,178;API 结算滞后 Δ≈14 分钟)
>
> 代码现状:v4 已实施(2026-08-12,见 §7.3);工作区在 HEAD `c6c1224` 之上仅含 v4 改动。
> v2/v3 内容保留为历史论证。
> 代码现状:v6 = Snapshot-Offset(`limit = balance_at_sync + usage_at_sync − SMALL_BUFFER`),2026-08-12 实施。
> v2/v5 (`usage + balance − BUFFER`) 已废弃,见 §7.4/§7.5。
> v6 reserve 改进(`token_counter + max_tokens`)2026-08-13 实施,见 §7.6。
> ⚠️ v6 的「B0 常数/永远不扣费」论证有前提缺陷,见 §4.2 与 §7.5 修正。
---
## 1. 业务模型(细则,用户确认)
## 1. 业务模型(用户确认)
### 1.1 日返还循环
```
每天任意时刻 真实用户 + 消耗任务 的消耗 = 当日采集量(火山实时统计,每模型单独计算)
每天 23:00 CST 消耗任务补齐缺口(可选)
每天 23:20 CST 消耗任务补齐缺口(可选)
次日 00:00 起 火山按昨日采集量发放资源包(不定时,可能延迟到上午)
```
@@ -26,13 +28,13 @@
| 规则 | 值 | 影响 |
|---|---|---|
| 采集上限 | 2M token/模型/天 | 消耗超过 2M 的部分**不返还**(白烧);所有模型统一 |
| 发放额度 | = 昨日采集量(≤2M) | 消耗 X → 次日返还 X,用多少补多少,延迟一天到账 |
| 采集上限 | 2M token/模型/天 | 消耗超过 2M 的部分**不返还**(白烧);所有模型统一 |
| 发放额度 | = 昨日采集量(≤2M) | 消耗 X → 次日返还 X,用多少补多少,延迟一天到账 |
| 发放时间 | 不定时 | 次日 0:00 至发放时点存在余额空窗 |
| 资源包有效期 | 30 天 | 过期额度实打实消失,失去"启动资金" |
| 消耗顺序 | FIFO(先消耗获得时间早的包) | 队列头部 = 最早获得 = 最早过期 |
| 消耗顺序 | FIFO(先消耗获得时间早的包) | 队列头部 = 最早获得 = 最早过期 |
| 余额耗尽 | 请求不失败,按真实价格扣费 | 全部防护的终极目标:避免扣费 |
| 首次开通 | 每模型赠送 2M | 启动资金的种子(无对应前日采集的免费午餐) |
| 首次开通 | 每模型赠送 2M | 启动资金的种子(无对应前日采集的免费午餐) |
### 1.3 账目守恒
@@ -42,28 +44,27 @@
= 余额_N (消耗即采集,≤2M 内全额返还)
```
推论:
- **使用不损失额度**(用多少补多少,仅延迟一天)
- **过期才损失额度**(额度蒸发 = 启动资金永久减少)
推论:
- **使用不损失额度**(用多少补多少,仅延迟一天)
- **过期才损失额度**(额度蒸发 = 启动资金永久减少)
- 消耗任务的价值 = 防止"启动资金"被 30 天过期侵蚀
- 真实用量 ≥ 66.7k/天(= 2M/30 天)时,赠送包被自然消耗;低于此值必须靠消耗任务在最后 9 天窗口洗掉
- 真实用量 ≥ 66.7k/天(= 2M/30 天)时,赠送包被自然消耗;低于此值必须靠消耗任务在最后 9 天窗口洗掉
---
## 2. 扣费判定(2026-08-12 用户确认口径,最重要)
## 2. 扣费判定(用户确认口径,最重要)
> 用户原话:**是否扣费只取决于当下有多少免费额度。超出免费额度的请求,都会被扣费。采集量只决定明日发放,与今日是否扣费无关。**
```
扣费判定:消耗 ≤ 当下余额(免费额度)→ 免费,余额扣减
消耗 > 当下余额 → 超出部分按真实价格扣费(无返还、不退)
今日采集量(≤2M)→ 只决定明日返还的包大小,与今日是否扣费无关
扣费判定:消耗 ≤ 当下余额(免费额度) → 免费,余额扣减
消耗 > 当下余额 → 超出部分按真实价格扣费(无返还、不退)
今日采集量(≤2M) → 只决定明日返还的包大小,与今日是否扣费无关
```
推论(2026-08-12 案例):
- 当日余额 1,558,307,今日已采 1,696,135 → 超出 **137,828 tokens 已扣费**,不可挽回
- 明日返还 = 今日采集量 1,696,135 的包(免费额度回正),但今天扣的钱不退
- 因此「先采后返」**不构成透支许可**:消耗超余额的部分当场计费
> 注:2026-08-12 §5 曾基于滞后快照推断"当日超余额 137,828 tokens 已扣费",
> 后经 §4.2 网页口径校准推翻(滞后快照高估真实剩余约 50 万,实际无扣费)。
> 判扣费必须用**网页真实初始余额 B0** 或滞后收敛时点的 U+B 反推,不能用任意时点快照。
---
@@ -71,140 +72,404 @@
| 概念 | 含义 | 量级特征 | 与扣费的关系 |
|---|---|---|---|
| 采集量(今日) | 今日实时消耗统计(火山网页"今日采集量") | 单日流量,0~2M | 只决定明日返还 |
| 余额(免费额度) | 资源包 AvailableAmount 存量之和 | 累计存量,守恒 ≈2M(种子) | **扣费唯一依据** |
| 采集配额 2M | 今日最大采集量 | 每模型固定 | 超了白烧(不返还),未必当场扣费 |
| 返还(T+1) | 昨日采集量 → 新资源包 | 单日流量延迟一天 | 补回余额,不抵今日扣费 |
| 采集量(今日) | 今日实时消耗统计(火山网页"今日采集量") | 单日流量,0~2M | 只决定明日返还 |
| 余额(免费额度) | 资源包 AvailableAmount 存量之和 | 累计存量,守恒 ≈2M(种子) | **扣费唯一依据** |
| 采集配额 2M | 今日最大采集量 | 每模型固定 | 超了白烧(不返还),未必当场扣费 |
| 返还(T+1) | 昨日采集量 → 新资源包 | 单日流量延迟一天 | 补回余额,不抵今日扣费 |
**核心:余额是累计存量(守恒),采集量是单日流量。限额设计必须用存量(余额),
绝不能用单日流量(昨日采集量)当上限——那会把可用性锁死在水位以下(见 §7.2 v3)。**
**核心:余额是累计存量(守恒),采集量是单日流量。限额设计必须用存量(余额),
绝不能用单日流量(昨日采集量)当上限——那会把可用性锁死在水位以下(见 §7.2 v3)。**
---
## 4. 余额 API 的滞后问题(2026-08-12)
## 4. 余额 API 的滞后问题
- **非实时,延迟未知**(用户明确)。实测:2026-08-12 13:36:01 同步快照 Bal=1,558,307,
此后 usage 从 1,241,821 涨到 1,696,135(13:36→13:38)期间 Bal 纹丝不动。
- 观察结论:AvailableAmount 不随今日消耗实时递减(至少滞后 20 万级,上限未知)。
- **后果**:一切「余额随消耗实时递减」的公式(v2 的 `usage + balance` 池恒定假设)在
滞后下失效——usage 涨而 balance 不动 → 池膨胀 → 限额虚高 → 放行到扣费区。
- 对策方向:以**最近同步快照**的余额为当日免费池上限(本地 usage 实时累计撞线),
快照频率由同步间隔(30min 常规 + 低余额 5/10min + 突发阈值触发)保障;
滞后窗口内的消耗用 BUFFER 覆盖(见 §7.3)。
### 4.1 2026-08-12 观测
- **非实时,延迟未知**(用户明确)。实测:2026-08-12 13:36:01 同步快照 Bal=1,558,307,
此后 usage 从 1,241,821 涨到 1,696,135(13:36→13:38)期间 Bal 纹丝不动。
- 观察结论:AvailableAmount 不随今日消耗实时递减(至少滞后 20 万级)。
### 4.2 2026-08-13 网页口径校准(Δ≈14 分钟)
- 网页确认今日真实初始余额 **B0 = 2,242,178**(采集量 0 时点读数,可信;次日 0:00 前无新包入账)
- 11:08 同步:Bal = 2,242,063 ≈ B0(API 尚未扣减当日用量,差 115 为残差)
- 11:22 同步:Bal = 1,726,526 = **B0 − 515,652**(= 11:08 时点累计用量,**精确吻合**)
- 结论:**结算滞后 Δ ≈ 14 分钟**,即 `Bal(t) ≈ B0 − Usage(t−Δ)`;滞后是**周期收敛**的
(2026-08-12 14:59:51 曾收敛:B=596,869 = 2,293,004 − 1,696,135 分毫不差)
- 滞后量(未结算消耗)随消费速度增长:实测 149,616(14 分钟突发消费),全天平均口径仅 ~1 万
#### 核心修正:`balance_at_sync + usage_at_sync ≠ B0 常数`
```
设 Δ_settled = 同步时点 API 已结算的今日消耗(= B0 − Bal_sync)
L = 滞后窗口内未结算消耗 = Usage_at_sync − Δ_settled ≥ 0
limit(v6) = Bal_sync + Usage_at_sync − SMALL_BUFFER
= B0 + L − SMALL_BUFFER
```
- 滞后收敛时(L=0):反推值 = B0 ✓(08-12 14:59:51 场景)
- 滞后未收敛时(L>0):反推值 = B0 + L,**虚高随消费增长**(08-13 11:22:2,391,794 vs
真实 2,242,178,虚高 149,616)
- 「守恒池」只在滞后收敛时成立;日常撞墙/恢复判定用的全是未收敛快照,故 v6 限额常态虚高 L
---
## 5. 症状对照:Today > Rem(2026-08-12 案例)
## 5. 症状对照:Today > BalAtSync
日志示例(13:36-13:38,deepseek-v4-flash-ga-260731):
> 以下日志为 v2/v5 时代历史示例(字段 Rem/StableLimit 已不存在于 v6 代码),
> 保留用于说明「Today > 余额」这一常见误判。
历史日志示例(13:36-13:38,deepseek-v4-flash-ga-260731):
```
DEBUG: Checking deepseek-v4-flash-ga-260731 (Bal: 1,558,307, Rem: 1,258,307) (余额/剩余)
DEBUG: deepseek-v4-flash-ga-260731 Pool=2,800,128 | Usage=1,241,821 | StaticLimit=2,000,000 | StableLimit=2,500,128 | EffectiveLimit=2,000,000
Set Redis limit:dynamic:volcengine:remaining:deepseek-v4-flash-ga-260731 = 1,258,307
📊 [Usage] deepseek-v4-flash-ga-260731:ba0383d3: +38083 (Today: 1,279,904 | Rem: 1,558,307 | Limit: 2,000,000)
```
字段口径:
v6 当前日志格式:
```
📊 [Usage] deepseek-v4-flash-ga-260731:ba0383d3: +98 (Today: 1,696,233 | BalAtSync: 596,869 | Limit: 2,000,000)
```
字段口径(v6):
| 字段 | 含义 | 计算 |
|---|---|---|
| Bal | 火山 API 余额(ListResourcePackages AvailableAmount 之和) | 同步快照 |
| Rem(Checking 行) | 入 Redis 的剩余 | Bal − BUFFER |
| Rem(Usage 行) | 展示用余额 | Redis remaining + BUFFER = Bal(展示与 API 一致) |
| Bal | 火山 API 余额(ListResourcePackages AvailableAmount 之和) | 同步快照 |
| BalAtSync | 同步时写入 Redis 的余额快照 | = Bal |
| UsageAtSync | 同步时写入 Redis 的累计消耗 | Redis `daily_usage` 求和 |
| Today | 本地今日采集量 | Redis `daily_usage` 逐请求累加 |
| Limit | 生效限额 | min(2M, stable_limit) |
| StableLimit(v2) | 动态限额 | usage + balance − BUFFER(随 usage 膨胀,见 §7.1) |
| Limit | 生效限额 | min(2M, balance_at_sync + usage_at_sync − SMALL_BUFFER) |
**Today > Rem 的原因**:Today 是单日流量,Rem 是存量余额快照,两套口径本就不该比较。
今日消耗可以暂时大于余额显示值(先采后返),但按 §2 口径,**超出部分实际已扣费**。
**Today > BalAtSync 的原因**: Today 是单日流量,BalAtSync 是存量余额快照,两套口径本就不该比较。
火山网页「今日采集量 1,696,135/2,000,000」对应 Today 与 2M 配额,与余额无关。
**扣费结论(2026-08-13 修正)**: 判扣费必须用**网页真实初始余额 B0** 或滞后收敛时点的 U+B 反推。
13:36 滞后快照(B=1,558,307)高估真实剩余约 50 万,不能用于判扣费。
14:59:51 恰好收敛(B0=2,293,004 可信),今日消耗 1,696,135 < B0 → 当日无扣费。
---
## 6. Redis key 与日志口径(速查)
## 6. Redis key 与日志口径(速查)
### 6.1 Redis key
| key | 读写方 | 说明 |
|---|---|---|
| `daily_usage:{北京日期}:{model}:{hash}` | LiteLLM 成功回调 INCR + 消耗任务 INCR | 当日采集量,TTL 48h,北京 00:00 滚动 |
| `limit:dynamic:volcengine:remaining:{model}` | 守护线程写 | 余额 − BUFFER(同步频率驱动、展示) |
| `limit:dynamic:volcengine:stable_limit:{model}` | 守护线程写 | 稳定限额(v2:usage+balance−BUFFER) |
| `consume:last_run:{日期}` | 消耗任务 | 23:00 当日去重,TTL 48h |
| `limit:dynamic:volcengine:balance_at_sync:{model}` | 守护线程写 | 同步时点 API AvailableAmount 快照 |
| `limit:dynamic:volcengine:usage_at_sync:{model}` | 守护线程写 | 同步时点 Redis 累计消耗快照 |
| `consume:last_run:{日期}` | 消耗任务 | 23:20 当日去重,TTL 48h |
### 6.2 关键日志
- `🚫 [PRE-BLOCK] {model} daily limit reached ({usage}+{reserve}/{limit})`:请求前闸门 429
- `📊 [Usage] {model}:{hash}: +{tokens} (Today: X | Rem: Bal | Limit: Y)`:成功记账
- `📅 [Quota] ...`:每日额度汇总(v4 设计,未实施)
- `📊 [Usage] {model}:{hash}: +{tokens} (Today: X | BalAtSync: Y | Limit: Z)`:成功记账
- `🚫 [BLOCK] {model} Limit Hit: {current}/{limit}`:后置闸门触发,soft_evict
---
## 7. 限额方案演进
### 7.1 v2(现状,HEAD c6c1224):`stable_limit = usage + balance − BUFFER`
### 7.1 v2(原式,已被 v5/v6 继承和替代):`stable_limit = usage + balance − BUFFER`
- 意图:`usage + balance` = 当日初始可用池,消耗中恒定 → 限额恒定
- **失效点**:该式假设 balance 随消耗实时递减;实测滞后 → usage 涨 balance 不动 →
池膨胀(2026-08-12:2.80M → 3.26M)→ 「余额耗尽 → 限额 0」防线失效 → 放行到扣费区
- 案例:余额 1,558,307 时 EffectiveLimit 仍取 2M,今日采到 1,696,135(超余额 13.8 万)未被拦
- 意图:`usage + balance` = 当日初始免费池(守恒 B0),消耗中恒定 → 限额恒定
- **数学正确性**:B 滞后收敛后确实满足 B = B0 − U
(14:59:51:U=1,696,135 + B=596,869 = B0=2,293,004,与理论剩余 597,004 几乎一致)。
同步时 stable = B0 − BUFFER,usage 累计撞线 = 用到剩 BUFFER 停止 —— 语义正确
- **残留风险(非公式错误,是快照时点问题)**:同步时 B 若滞后(13:36 高估真实剩余
约 50 万)→ stable 虚高 → 多放行滞后窗口内消耗。由 BUFFER + 低余额加密同步
(600s/300s) + burst 触发覆盖;收敛后池回到守恒值
- **已废弃**:BUFFER 盲猜无法解决高强度击穿/低强度浪费(§7.5)
### 7.2 v3(尝试后否决,已回滚):昨日锚 `effective = min(2M, 昨日采集量 × 系数)`
### 7.2 v3(尝试后否决,已回滚):昨日锚 `effective = min(2M, 昨日采集量 × 系数)`
- 思路:把「返还 = 昨日采集量」当作「今日可采上限」
- **否决理由(用户反例)**:余额 2M、昨日用量 10K → 今日最多 10K?荒谬。
错误在于把**单日流量(昨日采集量)当存量(余额)** 用;昨日少用不损余额(守恒),
- **否决理由(用户反例)**:余额 2M、昨日用量 10K → 今日最多 10K?荒谬。
错误在于把**单日流量(昨日采集量)当存量(余额)** 用;昨日少用不损余额(守恒),
今日可用 = min(2M, 余额) ≈ 2M,与昨日无关
- 附:系数 <1.0 还会引发「今日少采 → 明日返还更少」的日复一日递减陷阱(0.9 系数 5 日降至 66%)
- 附:系数 <1.0 还会引发「今日少采 → 明日返还更少」的日复一日递减陷阱(0.9 系数 5 日降至 66%)
### 7.3 v4(2026-08-12 已实施):冻结快照
### 7.3 v4(2026-08-12 实施后当天发现错误,已回退):冻结快照
```
stable_limit = max(0, balance − BUFFER) # 冻结快照,同步时算一次
effective_limit = min(2M 采集配额, stable_limit) # 2M 防白烧;余额红线防扣费
stable_limit = max(0, balance − BUFFER) # ❌ 错误:把「剩余余额」当「从 0 累计的上限」
```
- 唯一改动点:同步循环 stable_limit 从 `usage + balance − BUFFER` 改为 `balance − BUFFER`
(`volc_limit_sync.py` active_monitor_loop 同步段;debug 行 Pool 字段移除,改 Bal 口径)
- 数学等价于「本地实时扣减余额」:以最近快照余额为当日免费池上限,
usage 本地逐请求实时累计,撞线即拦,不再等 API
- 行为测试(2026-08-12,fake Redis 全过):冻结不膨胀 / 今日 1.696M 撞 1.258M 即拦 /
余额 2M 可用 1.7M(与昨日无关)/ stable 缺失兜底 900K / 次日返还解锁 / 余额 < BUFFER 全拦
- 效果对照(与 v2 现状):
- 设计初衷:余额 API 滞后 → 冻结快照防池膨胀
- **错误实证(15:09:51 生产日志)**:Bal=596,869(真实剩余 59.7 万),
stable = 296,869,usage=1,696,135 ≥ 296,869 → `[PRE-BLOCK] … (1,696,135+16/296,869)` 误拦。
账上有真实免费额度却全拦 —— 因为 stable 应是「今日累计上限」(= B0 − BUFFER),
而 balance 是「剩余」(= B0 − U);U>0 时两者相差 U,直接替换导致低估、误拦
- 仅在同步时点 U=0(日初/刚返还)巧合正确
- 教训:**「剩余额度」不能当「累计限额」用**;两者通过 `usage + balance` 桥接
| 场景 | v2 现状 | v4 |
### 7.4 v5(2026-08-12,已废弃):恢复 v2 骨架
```
stable_limit = max(0, usage + balance − BUFFER)
```
- 与 v2(§7.1)公式相同,区别在**论证与认识**:v2 数学正确(B 收敛后 = B0 − U),
不需要冻结快照;v4 的「剩余当上限」误拦由「usage + balance 桥接」天然避免
- 实测验证(14:59:51 同步 + 15:09:51 生产日志):U=1,696,135、B=596,869 →
stable=1,993,004 → 今日剩余可用 296,869(账上 59.7 万用到剩 30 万 BUFFER) ✓
- 防扣费语义不变:usage 撞 stable = 剩余恰为 BUFFER 时停,扣费结构性杜绝;
balance < BUFFER(U=0)→ stable=0 → 全拦
- **已废弃原因**:BUFFER 盲猜滞后窗口消耗,高强度击穿低强度浪费。被 §7.5 v6 Snapshot-Offset 替代。
### 7.5 v6(Snapshot-Offset,当前方案)
```
limit = balance_at_sync + usage_at_sync − SMALL_BUFFER(5 万)
= B0 + L − SMALL_BUFFER # L = 同步时点滞后窗口未结算消耗(§4.2)
```
> ⚠️ 2026-08-13 修正:原论证称 `= B0 − SMALL_BUFFER`(B0 常数、精确抵消),前提「同步时点
> balance + usage = B0」在滞后未收敛时**不成立**——实际 = B0 + L(§4.2 实测 L=149,616)。
#### 核心思路
用同步时点快照 `balance_at_sync + usage_at_sync` 计算累计限额:**滞后收敛时(L=0)恰为
当日初始免费池 B0**;滞后未收敛时虚高 L。两次同步间快照不变,实时 `current_usage` 撞线
只抵消同步**之后**窗口内的增量消耗,**不抵消同步时点已存在的滞后 L**。
**limit 是累计限额(从 0 到上限),不是剩余余额**。`_check_request_block` 用 `current_usage + reserve >= limit` 撞线。
#### 与旧方案的核心区别
```
# 旧 v5: 盲猜滞后窗口消耗
limit = usage + balance - BUFFER(30 万)
# 新 v6: 快照 + 实时 usage 撞固定线
limit = balance_at_sync + usage_at_sync - SMALL_BUFFER(5 万) = B0 + L - SMALL_BUFFER
```
- v5 的 BUFFER 猜的是「两次同步之间 + 滞后窗口内的消耗」——猜不准,高强度击穿低强度浪费
- v6 的 SMALL_BUFFER(5 万)只覆盖**非消耗性余额跳降**(资源包过期) + 少量未结算残差,
不覆盖滞后窗口消耗 L。**L > 5 万时限额虚高进入扣费区**(08-13 实测 L=149,616 > 5 万)。
#### 不变量(带 L 的精确形式)
撞线 `usage + reserve >= B0 + L - SMALL_BUFFER` 时:usage = `B0 + L − SMALL_BUFFER − reserve`,
真实剩余 = `B0 − usage` = **`SMALL_BUFFER + reserve − L`**。
- L=0(滞后收敛):剩余 = SMALL_BUFFER + reserve > 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 即可能超扣。**
#### 执行流程
**同步线程**(每 5~30min / 突发触发):
1. 拉 API → 取到 balance
2. 读 Redis 取 current_usage
3. 写入 `balance_at_sync` = balance, `usage_at_sync` = current_usage (TTL 24h)
4. 不再写入 `remaining`/`stable_limit` 旧键
**请求闸门**:
1. 读 `balance_at_sync` + `usage_at_sync`
2. `limit = balance_at_sync + usage_at_sync - SMALL_BUFFER`
3. `usage + reserve >= limit` → 429
#### 与旧方案对比
| 场景 | 旧 v5(BUFFER 30 万) | 新 v6(Snapshot-Offset) |
|---|---|---|
| 余额 2M,昨日 10K | 可用 ≈2M | min(2M, 1.7M)=1.7M,不锁死 ✓ |
| 余额 1.558M,今日已采 1.696M | 不拦(膨胀到 3.26M) | 立即拦(1.696 ≥ 1.258)→ 止损 |
| 明日余额跳涨(返还 1.696M) | — | 红线升 1.4M,自动解锁 |
| 高强度消耗 | 滞后窗口内消耗 > 30 万 → 击穿扣费 | 撞 B0+L 线:L ≤ 5 万安全,L > 5 万虚高 |
| 低强度消耗 | 浪费 30 万额度(实际只用了 5 万) | 只留 5 万 SMALL_BUFFER |
| 新包到账 | 下次同步 stable limit 跳升 | 下次同步 B0 跳升,一致 |
| 包过期 | 余额跳降,limit 偏高至下次同步 | 同左,但 SMALL_BUFFER=5 万风险窗口小 |
- 与消耗任务防线「余额快照 − 已烧 ≤ 0 停」同一把尺子(merge-auto-consume.md §4.4)
- 连带改动:`get_effective_limit` 兜底链不变(stable → FALLBACK_LIMIT 90 万);
文档同步(merge-auto-consume.md §4.2、AGENTS.md 限额描述)
#### 遗留风险
### 7.4 实施后待观察项(2026-08-12)
1. **同步时点滞后 L 无法抵消(当前首要风险)**:API 结算滞后 Δ≈14 分钟,
L = 窗口内消耗,随消费速度增长(实测 149,616 > SMALL_BUFFER 5 万)。v6 限额常态虚高 L,
仅被静态 2M 墙 + `min()` 兜住(今日 B0=2,242,178 > 2M)。若 B0 < 2M 或 L 放大,有
放行进扣费区风险。缓解方向:SMALL_BUFFER 与最近消费联动 / 缩短低余额同步 + 突发检测 /
直接以网页口径 B0 校验限额。
2. **多包同时过期 > SMALL_BUFFER**:概率低,无法绝对防御。可调大 SMALL_BUFFER 或监控包过期时间。
- **BUFFER(现 30 万,未调大)**:防「余额 API 滞后窗口内的消耗」。实测 20 万内不动 + 10 万冗余;
滞后上限未知,若观察同步间隔内(burst 阈值 50 万)烧穿,调大(如 50 万,env 可调)
- **部署**:代码已改(工作区),需重启 litellm 容器加载(挂载文件);重启在生产主机由用户执行
### 7.6 2M 采集上限击穿事件与 reserve 改进
#### 事件回顾(2026-08-12 20:53)
```
20:38:26 Today: 1,696,233 (delta +98)
20:53:13 Today: 1,872,804 (delta +176,571) ← 单请求消耗 176k
20:53:18 突发触发 (+176,669 > 100,000)
20:53:35 同步完成: Usage=2,050,826 ← 已超 2M!
23:18:59 下次同步: Bal=242,178 (API 滞后修正,B0 回归 2,293,004)
火山网页: 今日采集量 2,000,000/2,000,000 ← 2M 撑满
```
**2M 采集上限确实撞了**,usage 超出 50,826 token。但 B0=2,293,004 > usage,未扣费。
#### 根因:reserve 估算不足 + 并发击穿
旧 reserve 逻辑:
```python
reserve = data.get("max_tokens") or PREBLOCK_RESERVE_TOKENS(50000)
# 闸门: usage + reserve >= effective_limit → 429
```
deepseek-v4-flash 单请求实际消耗 176,571 token(远超 reserve 50k):
```
请求放行时: usage=1,800,000, reserve=50,000 → 1,850,000 < 2M → PASS
请求完成后: usage=1,976,571 (单请求吃了 176k)
下一个请求: usage=1,976,571, reserve=50,000 → 2,026,571 >= 2M → BLOCK
# 但此时已经超了 2M
```
**并发请求放大问题**:多个请求同时通过闸门(都读到 usage < 2M),各自实际消耗叠加,
放行时 usage 相加不超限,完成后集体超限。
#### B0 守恒假设的再次验证
```
20:33 B0 = 596,869 + 1,696,135 = 2,293,004
20:53 B0 = 596,771 + 2,050,826 = 2,647,597 ← 虚高(API 未结算 17.6 万消耗)
23:18 B0 = 242,178 + 2,050,826 = 2,293,004 ← API 修正后回归初值
```
B0 守恒 **在 API 滞后收敛后成立**,但同步快照期间虚高(§4.2 的 L 量)。
#### reserve 改进:token_counter 精确估算输入
```python
# 旧: 盲猜
reserve = data.get("max_tokens") or 50000
# 新: 精确输入 + 声明输出
input_tokens = litellm.token_counter(model=data["model"], messages=data["messages"])
# 未知模型 fallback cl100k_base,对中文偏高(更安全)
max_out = data.get("max_tokens") or PREBLOCK_RESERVE_TOKENS
reserve = input_tokens + max_out
```
**为什么中文偏高更安全**:DeepSeek 自有 tokenizer 对中文 ~1.5 token/字,
cl100k_base ~2-3 token/字。估算偏高 = 预留更多 = 提前撞线 = 更保守。
**效果**:输入 200k 的请求,旧 reserve 只有 50k,新 reserve 250k+,在 usage=1.8M 时拦住
(而非放行后超 2M)。⚠️ 该阈值仅对 `input ≥ 150k` 成立(由 `1.8M + input + 50k ≥ 2M` 反推);
小输入请求(如 126k)仍会在 usage<2M 时放行,封口实际依赖后置 `soft_evict`(见下节)。
#### 后置闸门兜底
```python
# _record_usage 里,写完 usage 后:
if current_total >= effective_limit:
soft_evict_from_router(raw_model, api_base) # 改名踢出,后续全 429
```
前置闸门挡不住已放行的请求,但 `_record_usage` 完成记账后检查超限,
立即 `soft_evict`,后续请求全部 429。
#### 双重防线总结
| 防线 | 机制 | 挡什么 | 挡不住什么 |
|---|---|---|---|
| **前置** | `reserve = token_counter(messages) + max_tokens` | 预估超限的请求 | 实际消耗 > reserve 的请求 |
| **后置** | `current_total >= effective_limit → 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/字高估),方向安全。
#### 软踢预警线(2026-08-13)
后置 soft_evict 触发点从"撞有效墙"提前为两个预警线(`volc_limit_sync.py` `should_soft_evict`,
env `SOFT_EVICT_USAGE_LIMIT=1950000` / `SOFT_EVICT_BALANCE_LIMIT=100000` 可调):
| 预警线 | 触发条件 | 目的 |
|---|---|---|
| 用量墙 | usage ≥ min(effective_limit, 1.95M) | 避开贴 2M 边的灰色地带(大请求 429/小请求放行 + reserve 低估/并发击穿余量) |
| 余额墙 | balance_at_sync < 10万 且 usage > 0 | 免费额度见底提前摘除;usage>0 防小额初始池(B0<10万)模型误杀永久死锁 |
**恢复对称(`should_restore_model`)**:usage < min(effective_limit, 1.95M) 且
(余额 ≥ 10万 或 usage == 0) 才恢复——否则踢了立即被下一轮同步恢复成踢/恢复抖动。
恢复函数读 balance_at_sync 与 usage 同源(快照口径)。`restore_blocked_models` /
`restore_model_if_healthy` / `startup_audit` 同口径。
**前置联动**:`_check_request_block` 对已软踢条目(`*-limit-reached`)恒 429,防踢后
1.95M~2M 区间小请求放行 → 路由原名缺失 → 400 路由错误。
**短路优化**:`async_pre_call_hook` 在 `token_counter` 之前先查 `_soft_evicted_block`——
已软踢模型直接 429,跳过 token 预估(含多模态图片 URL 下载等昂贵路径),与
`_check_request_block` 的 limit-reached 恒 429 双保险。
**代价**:用量墙让 1.95M~2M 的 5 万采集放弃(明日返还少 5 万 ≈2.5%),换取贴边期容错;
余额墙对正常大池模型无影响(余额 ≥10万 不触发)。数字用例验证 2026-08-13:9/9 场景
(踢/不踢/死锁防护/防抖动/动态墙取 min)全部符合预期。
---
## 8. 排障速查(今日结论汇总)
## 8. 排障速查
1. 看到 Today > Rem 不是异常,先分清口径(§5);但**按 §2 口径,超余额部分已在扣费**,应立即收闸
2. 余额显示不动 ≠ 余额充足,是 API 滞后;以最近快照 + BUFFER 为准
3. 判断「还能免费采多少」:`min(2M, 最近余额快照 − BUFFER) − 今日已采`(v4 已落地)
4. 消耗任务 23:00 直连 Ark 绕过 LiteLLM 闸门(防自杀),其自身 clamp:`plan ≤ 2M − usage`、
`plan ≤ 余额快照 − 已烧 − BUFFER`,停止条件三闭环
5. 扣费既成事实不可挽回(如 08-12 的 137,828);能做的只有止损(收闸)与次日恢复(返还)
1. 看到 Today > BalAtSync 不是异常,先分清口径(§5);判扣费优先看**网页真实初始余额 B0**(或滞后
收敛时点的 U+B 反推,§4.2),不要用任意时点快照(13:36 滞后快照曾误判「超余额扣费」,实际无扣费)
2. 余额显示不动 ≠ 余额充足,是 API 滞后(Δ≈14 分钟,§4.2);v6 的 `balance_at_sync + usage_at_sync`
在滞后未收敛时 = B0 + L(虚高),「实时抵消」仅覆盖同步后窗口,L 不抵消(§7.5)
3. 判断「还能免费采多少」:保守用 `网页 B0 − 今日已采`;v6 公式在滞后收敛时等价,未收敛时偏高 L
4. 消耗任务 23:20 直连 Ark 绕过 LiteLLM 闸门(防自杀),其自身 clamp:`plan ≤ 2M − usage`、
`plan ≤ b0_est − SMALL_BUFFER − usage`(b0_est = 拉包时刻 余额+usage,守恒口径与 v6 闸门同构,
2026-08-13 已实施),停止条件三闭环
5. 余额滞后不改变 B0 守恒;30 天过期蒸发是余额下跌的另一来源,
与消耗扣减区分(如 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。判扣费用网页真实初始余额或收敛时点反推
---
## 9. 待确认/风险清单
- [ ] 余额 API 滞后上限实测(决定 BUFFER 是否调大,现 30 万)
- [x] v4 方案批准与实施(2026-08-12,§7.3;待生产主机重启生效)
- [ ] 消耗任务口径是否同步收紧(可选:其停止条件与闸门同一把尺子已满足)
- [ ] 空窗期(0:00–返还到账)行为观察:v4 下余额红线自动收紧,确认无异常误杀
- [~] 余额 API 滞后实测(2026-08-13:Δ≈14 分钟,滞后量 = 窗口内消费,实测 L=149,616 > SMALL_BUFFER
5 万;上限随消费速度变化,建议 SMALL_BUFFER 与消费联动或改用网页 B0 校验,§7.5 遗留风险 1)
- [x] v4 误拦发现与回退(2026-08-12 15:09 生产日志实证,§7.3)
- [x] v5 废弃(2026-08-12,§7.4;被 v6 Snapshot-Offset 替代)
- [x] v6 实施(2026-08-12,§7.5;2026-08-13 生产验证)
- [x] 2M 采集上限击穿修复(2026-08-12 20:53 实测 §7.6:reserve 改用 token_counter + max_tokens,
后置 soft_evict 兜底;23:18 重启后 PRE-BLOCK 生效)
- [x] 消耗任务口径同步收紧(2026-08-13 代码确认:clamp2 用 `vls.SMALL_BUFFER`,与闸门同源一致)
- [ ] consume clamp2 余额快照口径修复待观测(2026-08-13 已实施守恒口径,待 1-2 个结算周期观测
🔴 深夜 L 实际是否 < SMALL_BUFFER;若触发"守恒墙已耗尽"且当日有扣费疑点 → 叠加 A' 收敛重取)
- [ ] 视觉图片请求 reserve 估算口径未校准(2026-08-13 调查):LiteLLM `token_counter` 对 OpenAI 格式
image_url 按 OpenAI 口径计(detail low/auto = 85 token/图,high = 下载图按 tile 85+170×tiles,
失败 fallback ~255),**与火山 Ark doubao 视觉模型实际计费公式不一致**;官方仅提供 Token 计算器
工具(未公开公式),FAQ 只说"图片计入输入 token"。闸门调用未传 use_default_image_token_count →
high-detail 图会在 hook 内同步下载(阻塞)。偏差量级 = 每图几十~几百 token,相对
THINKING_RESERVE_FLOOR(4096) 小;方向待实测(Token 计算器对照或发图看 usage),有图流量时关注
- [x] 软踢预警线(2026-08-13 实施,§7.6):后置踢出提前为 用量墙 1.95M / 余额墙 10万(usage>0),
恢复对称 + 前置 limit-reached 恒 429;9/9 数字用例验证
- [ ] 空窗期(0:00~返还到账)行为观察:v6 下 B0 自动收紧,确认无异常误杀
- [ ] 数据库泄漏: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)
- [x] reserve 精度验证(2026-08-13 短文本实证:估算 34 vs 实际 167 的根因 =
思考模型 reasoning tokens 不计入 reserve,已用 THINKING_RESERVE_FLOOR 兜底;
token_counter cl100k_base 对中文偏高对输入侧仍是安全方向)
- [x] consume clamp2 余额快照口径不同步(2026-08-13 已实施方向 C 守恒口径):原 `plan ≤ balance −
SMALL_BUFFER` 用拉包时刻余额快照 (volc_consume.py:455),**不含 23:20→00:00 真实用户消耗**;
clamp1 用共享 daily_usage 实时精准,但余额维度两方不同步。§4.2 已实测结算滞后 Δ≈14 分钟 →
快照相对真实剩余虚高 L(随消费速度增长,实测 149,616 > SMALL_BUFFER 5 万),深夜真实消耗大时
两方合计可超烧余额。
实施内容(2026-08-13):`_record_consumption` 已将自身 INCR 进共享 daily_usage,每轮
`_current_daily_usage` 即"真实用户+自身"总消耗 → `b0_est = balance + usage_now`(= B0+L,与
v6 闸门同构),clamp2 改 `plan ≤ b0_est − SMALL_BUFFER − usage_now`、停止条件改
`usage ≥ b0_est − SMALL_BUFFER`(与 clamp1 同构)。零 API 成本,两方不同步结构性消除。
数字用例验证:L=0 时撞墙剩 SMALL_BUFFER(旧方案执行中用户消耗 100k → 超扣 100k);
L>0 残余风险 = §7.5 遗留风险 1 同款(L > SMALL_BUFFER 时 b0_est 虚高,与闸门共享,非消耗任务独有)。
- 🅰 A' 可选叠加:执行前睡 ~15 分钟再拉一次取收敛 balance 算 b0_est(防 L 大,成本 8 次 API)
- ❌ B 扣减 L 预估:v5 BUFFER 盲猜教训(L 实测浮动 15 倍),否决
+28 -18
View File
@@ -2,17 +2,17 @@
# 🚀 VolcConsume — 火山免费额度自动消耗任务
# ==========================================
# 与 volc_limit_sync.py 合并方案的一部分(见 docs/merge-auto-consume.md):
# - 每日北京时间 23:00 由 volc_limit_sync 守护线程触发(可手动 --force)
# - 每日北京时间 23:20 由 volc_limit_sync 守护线程触发(可手动 --force)
# - 洗掉 CONSUME_MONITOR_DAYS 天内过期的资源包额度(FIFO 下即队列头部),
# 防止 30 天过期蒸发导致"启动资金"缩水(免费额度规则:消耗 X → 次日返还 X)
# - 直连 Ark API(Bearer token,绕过 LiteLLM 限额,避免消耗任务自杀)
# - 消耗量手动 INCR 进 daily_usage(与 LiteLLM 回调同 key):
# ① 算准采集上限缺口,② 空窗期限额能正确拦截真实用户(防扣费)
# - 三条停止条件:daily_usage ≥ 2M / 余额快照 − 已烧 ≤ 0 / 已烧 ≥ plan
# - 三条停止条件:daily_usage ≥ 2M / 总用量 ≥ 守恒墙(b0_est−SMALL_BUFFER,与 v6 闸门同构) / 已烧 ≥ plan
#
# 手动运行:
# python volc_consume.py --dry-run # 只算计划不消耗
# python volc_consume.py --force # 忽略 23:00 时间限制立即执行
# python volc_consume.py --force # 忽略 23:20 时间限制立即执行
import os
import sys
import time
@@ -429,8 +429,9 @@ def run_consume_model(model, source_groups, order, packages=None, dry_run=False)
对单个监控模型执行"洗过期包"消耗。
目标 = Σ窗口内包计划(均分×1.1/冲刺),clamp:
- 采集上限:plan ≤ CONSUME_DAILY_CAP − 当日已消耗
- 防扣费:plan ≤ 余额快照 − BUFFER
停止条件(每轮检查):daily_usage ≥ 2M / 余额快照−已烧 ≤ 0 / 已烧 ≥ plan
- 防扣费:plan ≤ b0_est − SMALL_BUFFER − 当日已消耗(b0_est = 拉包时刻 余额+usage,
守恒口径与 v6 闸门同构,见 docs/volc-quota-notes.md §9)
停止条件(每轮检查):daily_usage ≥ 2M / 总用量 ≥ b0_est − SMALL_BUFFER / 已烧 ≥ plan
:param packages: 复用 run_consume_all 的一次拉包结果;None 时自行拉取
"""
if packages is None:
@@ -451,8 +452,11 @@ def run_consume_model(model, source_groups, order, packages=None, dry_run=False)
usage_now = _current_daily_usage(model)
# clamp 1: 采集上限(超出部分不返还,白烧)
plan = min(plan, CONSUME_DAILY_CAP - usage_now)
# clamp 2: 余额快照 − BUFFER(脚本自身不烧穿余额,防扣费)
plan = min(plan, max(0, balance - vls.BUFFER))
# clamp 2: 守恒墙(与 v6 闸门同构,见 docs/volc-quota-notes.md §7.5/§9)
# b0_est = 拉包时刻 余额快照 + usage = B0 + L(L = 滞后窗口未结算消耗);
# 有效墙 min(2M, b0_est − SMALL_BUFFER),usage_now 实时含真实用户 → 两方合计结构性消除
b0_est = balance + usage_now
plan = min(plan, max(0, b0_est - vls.SMALL_BUFFER - usage_now))
plan = int(max(0, plan))
if plan < CONSUME_MIN_PLAN:
@@ -460,18 +464,19 @@ def run_consume_model(model, source_groups, order, packages=None, dry_run=False)
return
vls.log(
f"▶️ [{model}] 目标消耗 {plan:,} | 余额 {balance:,} | 当日已用 {usage_now:.0f}"
f"▶️ [{model}] 目标消耗 {plan:,} | 守恒墙 {int(b0_est - vls.SMALL_BUFFER):,} | 当日已用 {usage_now:.0f}"
)
if dry_run:
vls.log(f"🔍 [dry-run] 仅计算,不发送请求")
return
_run_requests(model, plan, balance, source_groups, order)
_run_requests(model, plan, b0_est, source_groups, order)
def _run_requests(model, plan, balance_snapshot, source_groups, order):
def _run_requests(model, plan, b0_est, source_groups, order):
"""
循环发送消耗请求。速率:目标 1 小时内完成(23:00 触发,00:00 结算)。
循环发送消耗请求。速率:目标 1 小时内完成(23:20 触发,00:00 结算)。
b0_est: 拉包时刻守恒池 = balance + usage(= B0 + L),有效墙 = min(2M, b0_est − SMALL_BUFFER)。
"""
burned = 0
fail_count = 0
@@ -481,7 +486,7 @@ def _run_requests(model, plan, balance_snapshot, source_groups, order):
# 任务轻松(单次间隔 > MAX_SLEEP_BASE)→ 正常模式 15-40s 伪装,不赶时间
# 任务极重(间隔 < 2s)→ 极速模式 1-3s(本场景 plan≤2M 实际不会触发)
# 其余 → 动态 ±5s
# 目标:23:00 触发、00:00 结算,1 小时内完成(plan=2M ≈ 47 次 × 27s ≈ 21 分钟)
# 目标:23:20 触发、00:00 结算,1 小时内完成(plan=2M ≈ 47 次 × 27s ≈ 21 分钟)
est_per_req = (MIN_INPUT_CHARS + MAX_INPUT_CHARS) / 2 + 1000
est_requests = max(1, plan / est_per_req)
interval = int(3600 / est_requests)
@@ -509,9 +514,12 @@ def _run_requests(model, plan, balance_snapshot, source_groups, order):
if _current_daily_usage(model) >= CONSUME_DAILY_CAP:
vls.log(f"🛑 [{model}] 当日采集已达上限 {CONSUME_DAILY_CAP:,},停止", level="WARNING")
break
# 停止条件 2: 余额快照耗尽(防扣费)
if (balance_snapshot - burned) <= 0:
vls.log(f"🛑 [{model}] 余额快照已耗尽,停止(防扣费)", level="WARNING")
# 停止条件 2: 守恒墙耗尽(防扣费;usage 实时含真实用户+自身,两方合计结构性杜绝)
if _current_daily_usage(model) >= b0_est - vls.SMALL_BUFFER:
vls.log(
f"🛑 [{model}] 守恒墙已耗尽(b0_est−SMALL_BUFFER),停止(防扣费)",
level="WARNING",
)
break
# 停止条件 3: 计划完成
if burned >= plan:
@@ -594,10 +602,12 @@ def main():
force = "--force" in sys.argv
if not force and not dry_run:
beijing_hour = (time.gmtime(time.time()).tm_hour + 8) % 24
if beijing_hour != 23:
beijing_struct = time.gmtime(time.time() + 8 * 3600)
beijing_hour = beijing_struct.tm_hour
beijing_min = beijing_struct.tm_min
if beijing_hour != 23 or beijing_min < 20:
vls.log(
f"⏰ 当前北京时间 {beijing_hour} 时,非 23:00 触发窗口。加 --force 强制执行",
f"⏰ 当前北京时间 {beijing_hour}:{beijing_min:02d},非 23:20 触发窗口。加 --force 强制执行",
level="WARNING",
)
return
+537 -160
View File
@@ -93,7 +93,7 @@ HOST = "open.volcengineapi.com"
REGION = "cn-beijing"
# Daemon Config
BUFFER = 300000 # User requested 30w buffer
SMALL_BUFFER = 50000 # 覆盖资源包过期/API 未结算等非消耗性余额跳降
CHECK_INTERVAL = 10
# 前置闸门预留量:请求开始判定 usage + reserve >= limit 即 429。
@@ -101,10 +101,74 @@ CHECK_INTERVAL = 10
# 未设置时 fallback 本常量。堵住"并发请求穿过闸门后集体撞线"的窗口。
PREBLOCK_RESERVE_TOKENS = int(os.getenv("PREBLOCK_RESERVE_TOKENS", "50000"))
# stable_limit 缺失时(首次同步前/新增模型/TTL 过期)的保守兜底限额。
# 免费模型余额常态 < 2M(昨日采多少返多少),若 fallback 裸奔到采集上限 2M,
# 余额不足的模型会被烧穿扣费。宁可短时收紧,不可裸奔。
FALLBACK_LIMIT = int(os.getenv("FALLBACK_LIMIT", "900000"))
# 思考模型输出侧兜底下限: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"))
# 在途预留(inflight)TTL:pre-hook 放行时预扣的 reserve 记入独立
# daily_usage_inflight key,正常请求在结算回调中配对释放;若请求
# 失败/进程崩溃/回调丢失,预留量由本 TTL 自动归零,防止额度被永久
# 卡死。1 小时远大于正常结算延迟(秒级),兜底释放足够及时。
PREBLOCK_INFLIGHT_TTL = int(os.getenv("PREBLOCK_INFLIGHT_TTL", "3600"))
# pre-hook 写入请求 metadata 的预留量键名,结算回调据此配对释放 inflight。
# 以 "_" 前缀避免与用户/代理自定义 metadata 冲突。
PREBLOCK_RESERVE_META_KEY = "_volc_preblock_reserve"
# Lua 原子闸门:把"读 usage + 读 inflight + 判 limit + 预扣"合并为
# 一个 Redis 原子操作,堵住并发请求在同一 usage 快照上集体撞线的
# TOCTOU 窗口(见 docs/volc-quota-notes.md 并发击穿分析)。
# KEYS[1]=usage_key, KEYS[2]=inflight_key
# ARGV[1]=effective_limit, ARGV[2]=reserve, ARGV[3]=inflight TTL
# 返回: 1=放行(已预扣), 0=拦截(未预扣)
GATE_SCRIPT = """
local usage = tonumber(redis.call('GET', KEYS[1]) or '0')
local inflight = tonumber(redis.call('GET', KEYS[2]) or '0')
local limit = tonumber(ARGV[1])
local reserve = tonumber(ARGV[2])
if usage + inflight + reserve >= limit then
return 0
end
redis.call('INCRBY', KEYS[2], reserve)
redis.call('EXPIRE', KEYS[2], tonumber(ARGV[3]))
return 1
"""
# Lua 原子释放:结算时配对扣减 inflight,floor 0 防止迟到结算的幽灵释放
# 误删别的请求的预留(TTL 过期后迟到 DECRBY 导致负数 → DEL 全部)。
# SET 后补 EXPIRE,保留剩余预留的 TTL(不因 SET 重置为永久)。
# KEYS[1]=inflight_key, ARGV[1]=reserve, ARGV[2]=inflight TTL
# 返回: 扣减后剩余 inflight 值(>=0)
RELEASE_SCRIPT = """
local cur = tonumber(redis.call('GET', KEYS[1]) or '0')
local reserve = tonumber(ARGV[1])
local new_val = cur - reserve
if new_val <= 0 then
redis.call('DEL', KEYS[1])
return 0
end
redis.call('SET', KEYS[1], new_val)
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
return new_val
"""
# stable_limit 缺失时(首次同步前/新增模型/TTL 过期/Redis 数据丢失)的保守兜底限额。
# 免费池常态 ~2M(首开赠送/昨日采多少返多少),若 fallback 裸奔到采集上限 2M,
# 余额不足或已耗尽的模型会被烧穿扣费。900k 对 0 余额模型仍是 9 倍优惠,
# 收紧到 20 万(2M 的 10%,单次消耗语料 5~11 万不受影响):宁可短时错杀大请求,
# 不可漏放大烧;快照由守护线程恢复后立即回到真实限额。
FALLBACK_LIMIT = int(os.getenv("FALLBACK_LIMIT", "200000"))
# 手动软踢自动解除增量:当前 balance_at_sync ≥ 踢除时快照 + 本值,才视为"免费额度已返还"
# 并自动 DEL manual_evict。默认 50 万(DeepSeek 主用模型返还需量 ≥ 100 万):
# 阈值取"返还量级的一半"级别,既能捕获次日返还,又不会因小额余额波动误放。
AUTO_RELEASE_BALANCE_MARGIN = int(
os.getenv("AUTO_RELEASE_BALANCE_MARGIN", "500000")
)
# Redis Config
REDIS_HOST = os.getenv(
@@ -140,9 +204,11 @@ MANUAL_MONITORED_MODEL_ALIASES = {
"doubao-seed-code": "doubao-seed-code-preview-251028",
"doubao-seed-translation": "doubao-seed-translation-250915",
"doubao-seed-2-0-code": "doubao-seed-2-0-code-preview-260215",
# 火山资源包名用 MMDD(deepseek-v4-flash-0731),上游模型名用 -ga-YYMMDD(-ga-260731),
# 火山资源包名用 MMDD(deepseek-v4-flash-0731 / deepseek-v4-pro-0813),
# 上游模型名用 -ga-YYMMDD(-ga-260731 / -ga-260813),
# 两者不一致,必须手工映射,否则资源包会被 "Not in monitored models" 跳过。
"deepseek-v4-flash-0731": "deepseek-v4-flash-ga-260731",
"deepseek-v4-pro-0813": "deepseek-v4-pro-ga-260813",
}
# Auto-discovered monitored upstream models and endpoint pairs from config.yaml
@@ -161,10 +227,23 @@ MONITORED_MODEL_ALIASES = {}
HARD_DAILY_LIMITS = {
# 火山免费额度规则(2026-08):每模型每日采集上限 2M,次日按采集量发放资源包。
# 请求前闸门(async_pre_call_hook)已不滞后 → 无需打折(旧 1.8M/480 万为 post-hoc 时代遗产)。
# 统一 2M,余额相关的动态限额由 stable_limit(usage+balance−BUFFER)表达。
# 统一 2M,余额相关的动态限额由 balance_at_sync - (usage_now - usage_at_sync) 实时。
"__DEFAULT__": 2_000_000,
}
# ==========================================
# 🚨 Soft Evict Thresholds(软踢预警线)
# ==========================================
# 提前于硬限额/动态墙踢出模型的预警线,留出缓冲:
# 1. 用量墙:usage ≥ SOFT_EVICT_USAGE_LIMIT(默认 1.9M) 即踢——避免贴 2M 边时
# 大请求 429/小请求放行的灰色地带(reserve 低估 / 并发击穿余量)
# 2. 余额墙:balance_at_sync < SOFT_EVICT_BALANCE_LIMIT(10万) 且 usage > 0 即踢——
# usage>0 限定防误杀小额初始池模型(B0<10 万未使用时被永久踢出死锁)
# 恢复条件对称(should_restore_model):usage < min(effective_limit, 用量墙)
# 且 (余额 ≥ 10万 或 usage == 0),否则踢了立即被恢复成踢/恢复抖动
SOFT_EVICT_USAGE_LIMIT = float(os.getenv("SOFT_EVICT_USAGE_LIMIT", "1900000"))
SOFT_EVICT_BALANCE_LIMIT = float(os.getenv("SOFT_EVICT_BALANCE_LIMIT", "100000"))
# ==========================================
# 🔌 Redis Connection
# ==========================================
@@ -343,6 +422,16 @@ def get_usage_key(clean_model, api_base=None):
return f"daily_usage:{date_str}:{clean_model}{suffix}"
def get_inflight_key(clean_model, api_base=None):
"""在途预留 key:与 usage key 同日期轮换,独立账户,结算配对释放。
pre-hook 放行时 INCRBY reserve(Lua 原子闸门内完成),请求成功结算时
按该请求的 reserve DECRBY 释放,usage 只计实际消耗;失败/丢失的预留
由 PREBLOCK_INFLIGHT_TTL 自动归零。
"""
return get_usage_key(clean_model, api_base) + ":inflight"
def get_static_limit_for_hash(clean_model, current_hash=None):
"""Check static configuration in HARD_DAILY_LIMITS using an optional endpoint hash."""
if current_hash:
@@ -407,24 +496,27 @@ def calculate_effective_limit(static_limit, dynamic_limit):
return float(static_limit) if static_limit is not None else None
def format_limit_debug_fields(static_limit=None, stable_limit=None, effective_limit=None):
"""Render explicit limit labels for debug logs to avoid confusing stable and effective caps."""
def format_limit_debug_fields(static_limit=None, balance_at_sync=None, usage_at_sync=None, effective_limit=None):
"""Render explicit limit labels for debug logs."""
fields = []
if static_limit is not None:
fields.append(f"StaticLimit={int(float(static_limit)):,}")
if stable_limit is not None:
fields.append(f"StableLimit={int(float(stable_limit)):,}")
if balance_at_sync is not None:
fields.append(f"BalAtSync={int(float(balance_at_sync)):,}")
if usage_at_sync is not None:
fields.append(f"UsageAtSync={int(float(usage_at_sync)):,}")
if effective_limit is not None:
fields.append(f"EffectiveLimit={int(float(effective_limit)):,}")
return " | ".join(fields) if fields else "EffectiveLimit=unknown"
def get_effective_limit(clean_model, api_base, current_usage=None):
"""
Calculate effective limit using the Peak-Buffer strategy.
Target Limit = (Peak Daily Remaining) - Buffer.
Calculate effective limit using the Snapshot-Offset strategy.
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 路径,非公式必需。
"""
if not redis_client:
return get_static_limit(clean_model, api_base)
@@ -432,39 +524,129 @@ def get_effective_limit(clean_model, api_base, current_usage=None):
# 1. Get Static Limit (Hard Config)
static_limit = get_static_limit(clean_model, api_base)
# 2. Get Dynamic Stable Limit (Calculated by Daemon)
# This limit is derived from: Peak_Daily_Balance - Buffer
# It is stable and immune to API latency spikes.
stable_limit_key = f"limit:dynamic:volcengine:stable_limit:{clean_model}"
stable_limit_val = redis_client.get(stable_limit_key)
# 2. Get Dynamic Limit from Snapshot
# 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}"
balance_val = redis_client.get(balance_key)
usage_val = redis_client.get(usage_key)
effective_limit = None
if stable_limit_val is not None:
if balance_val is not None and usage_val is not None:
try:
dynamic_limit = float(stable_limit_val)
effective_limit = calculate_effective_limit(static_limit, dynamic_limit)
balance_at_sync = float(balance_val)
usage_at_sync = float(usage_val)
# 累计限额 = B0 + L - SMALL_BUFFER(L=同步时点滞后未结算消耗),不依赖 current_usage
dynamic_limit = balance_at_sync + usage_at_sync - SMALL_BUFFER
dynamic_limit = max(0, dynamic_limit)
effective_limit = calculate_effective_limit(static_limit, dynamic_limit)
except (ValueError, TypeError):
effective_limit = calculate_effective_limit(static_limit, None)
else:
# Fallback: 如果 daemon 尚未计算 stable limit(首次同步前/新增模型/TTL 过期),
# 用保守兜底(FALLBACK_LIMIT),绝不裸奔到采集上限 —— 免费模型余额常态 < 2M,
# 裸奔 2M 会把余额不足的模型烧穿扣费。
# Fallback: 无同步数据时用保守兜底
fallback = float(static_limit) if static_limit is not None else float(FALLBACK_LIMIT)
fallback = min(fallback, float(FALLBACK_LIMIT))
effective_limit = calculate_effective_limit(fallback, None)
# Legacy fallback (Optional: Enable if you want immediate loose limits before first sync)
# dynamic_remaining = redis_client.get(f"limit:dynamic:volcengine:remaining:{clean_model}")
# if dynamic_remaining and current_usage:
# effective_limit = float(current_usage) + float(dynamic_remaining)
return effective_limit
# ==========================================
# 👻 Handler Logic (DailyLimitHandler)
# ==========================================
# 🚨 Soft Evict / Restore Decision(软踢与恢复对称判定)
# ==========================================
def soft_evict_wall(effective_limit):
"""软踢用量墙 = min(有效限额, SOFT_EVICT_USAGE_LIMIT);None 表示无限额。"""
if effective_limit is None:
return None
return min(float(effective_limit), SOFT_EVICT_USAGE_LIMIT)
def should_soft_evict(clean_model, api_base, current_usage, effective_limit):
"""
软踢判定:任一满足即触发
- 用量墙:current_usage >= min(effective_limit, SOFT_EVICT_USAGE_LIMIT)
- 余额墙:balance_at_sync < SOFT_EVICT_BALANCE_LIMIT 且 current_usage > 0
(balance_at_sync 缺失视为充足;usage>0 防小额初始池模型误杀死锁)
返回 (triggered, reason)。
"""
wall = soft_evict_wall(effective_limit)
if wall is not None and float(current_usage) >= wall:
return True, f"usage {int(float(current_usage)):,} >= soft wall {int(wall):,}"
try:
bal_key = f"limit:dynamic:volcengine:balance_at_sync:{clean_model}"
bal_val = redis_client.get(bal_key) if redis_client else None
if (
bal_val is not None
and float(bal_val) < SOFT_EVICT_BALANCE_LIMIT
and float(current_usage) > 0
):
return (
True,
f"balance {int(float(bal_val)):,} < {SOFT_EVICT_BALANCE_LIMIT:.0f} (usage>0)",
)
except Exception:
pass
return False, ""
def _soft_evicted_block(model_name):
"""
若模型已被软踢(router 中存在 {model_name}-limit-reached 条目),返回
(usage, effective_limit) 供直接 429;否则 None。用于 async_pre_call_hook 短路:
已踢模型注定被拒,跳过 litellm.token_counter 精确预估(含多模态图片下载等昂贵路径)。
"""
if not LITELLM_AVAILABLE or not redis_client:
return None
try:
from litellm.proxy.proxy_server import llm_router
if not llm_router or not llm_router.model_list:
return None
for entry in llm_router.model_list:
entry_name = entry.get("model_name") or ""
if entry_name == f"{model_name}-limit-reached" and "_daily_limit_backup" in entry:
params = entry.get("litellm_params", {})
clean = clean_model_name(params.get("model", ""))
api_base = params.get("api_base", "")
usage_key = get_usage_key(clean, api_base)
usage = float(redis_client.get(usage_key) or 0)
limit = get_effective_limit(clean, api_base)
return (usage, limit)
except Exception:
pass
return None
def should_restore_model(clean_model, api_base, current_usage, effective_limit):
"""
恢复判定(与 should_soft_evict 对称,防踢/恢复抖动):
usage < min(effective_limit, SOFT_EVICT_USAGE_LIMIT) 且 (余额 ≥ 10万 或 usage == 0)
effective_limit None 时仅按余额条件判定(原逻辑 None → 可恢复)。
"""
wall = soft_evict_wall(effective_limit)
if wall is not None and float(current_usage) >= wall:
return False
try:
bal_key = f"limit:dynamic:volcengine:balance_at_sync:{clean_model}"
bal_val = redis_client.get(bal_key) if redis_client else None
if (
bal_val is not None
and float(bal_val) < SOFT_EVICT_BALANCE_LIMIT
and float(current_usage) > 0
):
return False
except Exception:
pass
return True
# ==========================================
def soft_evict_from_router(target_model, target_api_base):
"""
@@ -584,14 +766,11 @@ def restore_blocked_models():
# get_effective_limit handles fetching usage if None passed.
effective_limit = get_effective_limit(clean_model, original_api_base)
should_restore = False
if effective_limit is None:
should_restore = True
else:
usage_key = get_usage_key(clean_model, original_api_base)
current_val = float(redis_client.get(usage_key) or 0)
if current_val < float(effective_limit):
should_restore = True
usage_key = get_usage_key(clean_model, original_api_base)
current_val = float(redis_client.get(usage_key) or 0)
should_restore = should_restore_model(
clean_model, original_api_base, current_val, effective_limit
)
if should_restore:
dprint(f"♻️ [RESTORE] Restoring {backup['model_name']} (Quota OK)")
@@ -643,14 +822,11 @@ def restore_model_if_healthy(clean_model, allowed_hashes=None):
continue
effective_limit = get_effective_limit(clean_model, m_base)
should_restore = False
if effective_limit is None:
should_restore = True
else:
usage_key = get_usage_key(clean_model, m_base)
current_val = float(redis_client.get(usage_key) or 0)
if current_val < float(effective_limit):
should_restore = True
usage_key = get_usage_key(clean_model, m_base)
current_val = float(redis_client.get(usage_key) or 0)
should_restore = should_restore_model(
clean_model, m_base, current_val, effective_limit
)
if should_restore:
dprint(f"♻️ [RESTORE] Restoring {backup['model_name']} (Quota OK)")
@@ -720,11 +896,14 @@ def startup_audit():
continue
usage_key = get_usage_key(clean_model, api_base)
current_val = redis_client.get(usage_key)
current_val = float(redis_client.get(usage_key) or 0)
if current_val and float(current_val) >= float(effective_limit):
evict, evict_reason = should_soft_evict(
clean_model, api_base, current_val, effective_limit
)
if evict:
dprint(
f"🚫 [STARTUP BLOCK] Found {raw_model} over limit ({int(float(current_val))}/{int(effective_limit)}). Evicting now."
f"🚫 [STARTUP BLOCK] {raw_model} soft-evict: {evict_reason}. Evicting now."
)
soft_evict_from_router(raw_model, api_base)
blocked_count += 1
@@ -740,15 +919,66 @@ def startup_audit():
log(f"❌ Startup audit failed: {e}", level="ERROR")
def _atomic_reserve(clean_model, api_base, effective_limit, reserve):
"""Lua 原子闸门:usage + inflight + reserve >= limit 则拦截,否则预扣并放行。
返回 (granted, usage_total)。granted=False 时 usage_total = usage + inflight
(供日志展示);granted=True 时预扣已入 inflight key。
Redis 不可用/脚本失败时抛异常由调用方 fail-closed 处理。
"""
usage_key = get_usage_key(clean_model, api_base)
inflight_key = get_inflight_key(clean_model, api_base)
granted = redis_client.eval(
GATE_SCRIPT,
2,
usage_key,
inflight_key,
int(float(effective_limit)),
int(reserve),
int(PREBLOCK_INFLIGHT_TTL),
)
usage = float(redis_client.get(usage_key) or 0)
inflight = float(redis_client.get(inflight_key) or 0)
return bool(granted), usage + inflight
def _release_inflight(clean_model, api_base, reserve):
"""结算配对释放:请求完成后把 pre-hook 预扣的 reserve 从 inflight 账户扣除。
用 Lua 原子脚本做 DECRBY + floor 0,防止 TTL 过期后迟到结算的幽灵释放
把 DECRBY 打到负数 → 误删别的请求的预留。reserve<=0 或脚本失败时无操作,
inflight 残留由 TTL 兜底归零。
"""
if not redis_client or not reserve:
return
try:
inflight_key = get_inflight_key(clean_model, api_base)
redis_client.eval(
RELEASE_SCRIPT, 1, inflight_key, int(reserve),
int(PREBLOCK_INFLIGHT_TTL),
)
except Exception as e:
log(f"❌ _release_inflight failed: {e}", level="ERROR")
def _check_request_block(model_name, reserve=0.0):
"""
请求前闸门:对外模型名 → 判定是否超限。
只拦截 MONITORED_MODEL_PAIRS 内的火山监控条目;newapi 等非监控渠道天然放行。
reserve: 本次请求的预估消耗(max_tokens 或常量兜底),usage + reserve >= limit 即拦截,
避免并发请求在闸门检查后集体撞线(限额是低频快照,用量是实时累计)。
返回 (clean_model, usage, limit) 表示超限,None 表示放行。任何异常 fail-open。
reserve: 本次请求的预估消耗(max_tokens 或常量兜底)。
判定用 Lua 原子闸门:usage + inflight(在途预留) + reserve >= limit 即拦截,
inflight 账户在 Redis 内原子预扣,并发请求串行看到彼此的在途量,堵死
"同一 usage 快照集体撞线"的 TOCTOU 击穿。放行时 reserve 已预扣入 inflight,
由结算回调配对释放(见 _release_inflight)。
返回 (clean_model, usage, limit) 表示超限,None 表示放行(已预扣)。
fail-closed:Redis 不可用 / 限额获取失败 / 读取异常时,对监控条目返回
(clean_model, -1, -1) 标记拒绝(宁可错杀,不可漏放);非监控条目不受影响。
手动软踢:Redis key manual_evict:{config model 字段全名} 存在时返回
(clean_model, -2, -2) 恒 429,由运维 docker exec 设置/删除(见函数内注释)。
自动解除:当前 balance_at_sync 比踢除时快照上升 ≥ AUTO_RELEASE_BALANCE_MARGIN
时删除 key 恢复放行(免费额度返还条件)。
"""
if not redis_client or not LITELLM_AVAILABLE:
if not LITELLM_AVAILABLE:
return None
try:
from litellm.proxy.proxy_server import llm_router
@@ -778,21 +1008,101 @@ def _check_request_block(model_name, reserve=0.0):
if MONITORED_MODEL_PAIRS and (clean, h) not in MONITORED_MODEL_PAIRS:
continue
# ── 监控条目 fail-closed:Redis 不可用直接拒绝 (usage=-1, limit=-1) ──
# 快照缺失但 Redis 可达时走 get_effective_limit 内部 fallback(收紧限额),
# 不在此拒绝——那是"尚未同步"的正常启动态,守护线程几分钟内补上。
if not redis_client:
log(
f"🚫 Redis unavailable, fail-closed 429 for monitored {clean}",
level="WARNING",
)
return (clean, -1, -1)
# ── 手动软踢:Redis manual_evict:{raw_model} 存在 → 恒 429 (usage=-2, limit=-2) ──
# 但自动解除:若当前 balance_at_sync ≥ 踢除时快照 + AUTO_RELEASE_BALANCE_MARGIN,
# 视为免费额度已返还,自动 DEL 恢复(余额大幅上升不会误判)。
# 惰性记录快照:首次发现 manual_evict key 无对应 snapshot 时,以当前余额为基准。
manual_key = f"manual_evict:{raw_model}"
manual_snap = f"manual_evict:snapshot:{raw_model}"
if redis_client.get(manual_key):
# 1) 确保 snapshot 存在(惰性记录)
snap_val = redis_client.get(manual_snap)
if snap_val is None:
# 首次命中:记录当前 balance_at_sync 作为踢除时刻快照
bal_key = f"limit:dynamic:volcengine:balance_at_sync:{clean}"
current_bal = redis_client.get(bal_key)
if current_bal is not None:
redis_client.set(manual_snap, current_bal)
snap_val = current_bal
else:
snap_val = "0"
# 2) 检查余额是否已显著恢复
auto_release = False
try:
snap_bal = float(snap_val)
bal_key = f"limit:dynamic:volcengine:balance_at_sync:{clean}"
current_bal = redis_client.get(bal_key)
if current_bal is not None:
current_bal_f = float(current_bal)
# 余额从踢除时水位上升了 ≥ AUTO_RELEASE_BALANCE_MARGIN = 返还已发生
if current_bal_f >= snap_bal + AUTO_RELEASE_BALANCE_MARGIN:
log(
f"♻️ [AUTO-RELEASE] {raw_model} manual_evict released (balance "
f"{int(snap_bal):,} → {int(current_bal_f):,}, "
f"delta {int(current_bal_f - snap_bal):,} ≥ margin {AUTO_RELEASE_BALANCE_MARGIN:,})",
level="WARNING",
)
redis_client.delete(manual_key)
redis_client.delete(manual_snap)
auto_release = True
except (TypeError, ValueError):
pass
if not auto_release:
log(
f"🚫 [MANUAL-EVICT] {raw_model} manually soft-evicted (429)",
level="WARNING",
)
return (clean, -2, -2)
# 自动解除后,继续走正常限额判定(不 return)
effective_limit = get_effective_limit(clean, api_base)
if effective_limit is None:
continue
# 监控条目取不到限额:拒绝而非放行(fail-closed)
log(
f"🚫 No effective limit for monitored {clean}, fail-closed 429",
level="WARNING",
)
return (clean, -1, -1)
usage_key = get_usage_key(clean, api_base)
val = redis_client.get(usage_key)
usage = float(val or 0)
if usage + float(reserve) >= float(effective_limit):
# 已被软踢的条目恒 429(无论 reserve),避免踢后灰色区间(如 1.95M~2M)
# 小请求被放行 → 路由原名不存在 → 400 路由错误
if entry_name == f"{model_name}-limit-reached" and "_daily_limit_backup" in entry:
usage = float(redis_client.get(usage_key) or 0)
return (clean, usage, effective_limit)
# Lua 原子闸门:usage + inflight + reserve >= limit 即拒绝(不预扣),
# 否则把 reserve 预扣进 inflight 账户并放行。判定与预扣在 Redis 内
# 原子完成,并发请求串行看到彼此的在途预留,堵死"同一 usage 快照上
# 集体撞线"的 TOCTOU 击穿(08-18 13:14:41-50 根因,见诊断)。
granted, _inflight_total = _atomic_reserve(
clean, api_base, effective_limit, reserve
)
if not granted:
return (clean, _inflight_total, effective_limit)
# 放行:预扣已入 inflight,结算时配对释放
return None
return None
except Exception as e:
log(f"❌ Pre-block check failed: {e}", level="ERROR")
return None
# fail-closed:无法确认额度状态即拒绝(调用方 429)。
# 到达此处时必已命中监控条目(非监控条目在循环内 continue 跳过,
# 不触发 Redis 调用),不会误伤 newapi 等非监控渠道。
log(f"❌ Pre-block check failed (fail-closed 429): {e}", level="ERROR")
return (model_name, -1, -1)
def quota_interceptor(kwargs, user_api_key_dict=None):
@@ -825,22 +1135,116 @@ class DailyLimitHandler(CustomLogger):
if not data or not data.get("model"):
return None
# 预留本次请求的预估消耗:优先真实 max_tokens(零采集损失),
# 未设置/非法时 fallback 常量,避免并发请求集体撞线超限。
# 已被软踢的模型直接 429,跳过 token_counter 精确预估(注定被拒,无需计数,
# 也避免多模态图片 URL 下载等昂贵路径)。注:与 _check_request_block 的
# limit-reached 恒 429 双保险——这里短路省 token_counter,那里兜底路由阶段。
evicted = _soft_evicted_block(data.get("model"))
if evicted is not None:
usage, ev_limit = evicted
limit_disp = int(ev_limit) if ev_limit is not None else 0
log(
f"🚫 [PRE-BLOCK] {data['model']} soft-evicted, 429 (usage {int(usage):,}/{limit_disp:,})",
level="WARNING",
)
raise RateLimitError(
message=f"Daily limit reached for model '{data['model']}' ({int(usage):,}/{limit_disp:,}) (soft-evicted)",
llm_provider="volcengine",
model=data["model"],
)
# 预留本次请求的预估消耗 = 输入 token(精确) + 输出上限(max_tokens)。
# 输入用 litellm.token_counter 精确计数(未知模型 fallback cl100k_base,
# 对中文偏高更安全)。
# 输出侧:思考模型 usage 含 reasoning tokens 且不受 max_tokens 限制,
# 故取 max(请求声明 max_tokens, THINKING_RESERVE_FLOOR) 兜底思考开销,
# 防短文本 + 小 max_tokens 请求 reserve 低估(实测 34 vs 实际 150+)。
# 幂等防重:litellm 对同一请求可能调用两次 async_pre_call_hook
# (proxy 级 + deployment 级),第二次预扣会重复占 inflight 且
# 结算只释放一次 → 残留累积。已有 reserve 标记时跳过预扣(含
# token_counter 精确计数等昂贵路径)直接放行。
# 假设:deployment-level hook 收到的是同一 data dict 引用(litellm
# pre_call_hook 循环体 data=result 赋值,就地修改会带过去)。若
# litellm 升级为深拷贝 data 传给第二次 hook,此检查失效,需改用
# 请求级唯一 ID(如 litellm_call_id)做去重。部署后留意日志确认
# 第二次 pre-hook 是否被跳过(Pre-reserve 只应打印一次)。
try:
reserve = float(
_existing_meta = data.get("metadata") or {}
if isinstance(_existing_meta, dict) and PREBLOCK_RESERVE_META_KEY in _existing_meta:
return data
except Exception:
pass
try:
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):
reserve = float(PREBLOCK_RESERVE_TOKENS)
max_out = max(
float(THINKING_RESERVE_FLOOR), float(PREBLOCK_RESERVE_TOKENS)
)
input_tokens = 0
try:
from litellm import token_counter as _tc
input_tokens = _tc(
model=data.get("model", ""),
messages=data.get("messages", []),
)
except Exception:
# token_counter 出错时保守:用 PREBLOCK_RESERVE_TOKENS 兜底输入
input_tokens = PREBLOCK_RESERVE_TOKENS
reserve = input_tokens + max_out
if DEBUG_MODE:
log(
f"🔍 Pre-reserve {data['model']}: input={int(input_tokens):,} + max_out={int(max_out):,} = reserve={int(reserve):,}",
level="DEBUG",
)
blocked = _check_request_block(data["model"], reserve=reserve)
if not blocked:
return None
# 放行:Lua 原子闸门已把 reserve 预扣进 inflight 账户。
# 把本次预留量记入请求 metadata,随 litellm 流转到 log_success_event
# 的 kwargs["litellm_params"]["metadata"],结算时配对释放(否则
# inflight 只会增不减,额度被已放行请求永久占死)。
try:
meta = data.get("metadata") or {}
if not isinstance(meta, dict):
meta = {}
meta[PREBLOCK_RESERVE_META_KEY] = int(reserve)
data["metadata"] = meta
except Exception:
pass # 写 metadata 失败仅影响结算释放,TTL 兜底仍会回收
return data
clean, usage, limit = blocked
if usage < 0 or limit < 0:
if usage == -2:
# manual_evict Redis key 触发的运维手动软踢
log(
f"🚫 [PRE-BLOCK] {data['model']} manually soft-evicted by operator (429)",
level="WARNING",
)
raise RateLimitError(
message=f"Model '{data['model']}' manually soft-evicted by operator, request denied",
llm_provider="volcengine",
model=data["model"],
)
# fail-closed 标记:Redis 不可用/限额状态未知,拒绝防漏放
log(
f"🚫 [PRE-BLOCK] {data['model']} denied: quota state unavailable (fail-closed)",
level="WARNING",
)
raise RateLimitError(
message=f"Quota state unavailable for model '{data['model']}', request denied to prevent overspend",
llm_provider="volcengine",
model=data["model"],
)
log(
f"🚫 [PRE-BLOCK] {data['model']} daily limit reached ({int(usage):,}+{int(reserve):,}/{int(limit):,})",
level="WARNING",
@@ -891,6 +1295,20 @@ class DailyLimitHandler(CustomLogger):
if clean_model and total_tokens > 0:
key = get_usage_key(clean_model, api_base)
# 结算配对释放:读回 pre-hook 预扣的 reserve(经 metadata 透传),
# 从 inflight 账户扣除。实际消耗 total_tokens 照常记入 usage;
# 预扣量 = 预估值,与实耗差额天然由 usage 侧的"只计实际"修正,
# inflight 只代表"已放行未结算"的在途量,结算即清零。
released = 0
try:
meta = litellm_params.get("metadata") or {}
if isinstance(meta, dict):
released = int(meta.get(PREBLOCK_RESERVE_META_KEY, 0) or 0)
except (TypeError, ValueError):
released = 0
_release_inflight(clean_model, api_base, released)
current_total = redis_client.incrby(key, int(total_tokens))
redis_client.expire(key, 172800)
@@ -902,16 +1320,14 @@ class DailyLimitHandler(CustomLogger):
f" | Limit: {int(effective_limit):,}" if effective_limit is not None else ""
)
# Get Dynamic Remaining for Log
dynamic_key = f"limit:dynamic:volcengine:remaining:{clean_model}"
rem_val = redis_client.get(dynamic_key)
rem_display = ""
if rem_val is not None:
# Get Balance At Sync for Log
balance_key = f"limit:dynamic:volcengine:balance_at_sync:{clean_model}"
bal_val = redis_client.get(balance_key)
bal_display = ""
if bal_val is not None:
try:
# Display Real Balance (Remaining + Buffer)
rem_int = int(float(rem_val))
bal_est = rem_int + BUFFER
rem_display = f" | Rem: {bal_est:,}"
bal_int = int(float(bal_val))
bal_display = f" | BalAtSync: {bal_int:,}"
except Exception as e:
print(
f"DEBUG: Error calculating balance for {clean_model}: {e}"
@@ -920,17 +1336,20 @@ class DailyLimitHandler(CustomLogger):
else:
# Debug why key is missing
if DEBUG_MODE:
print(f"DEBUG: Dynamic key not found: {dynamic_key}")
print(f"DEBUG: Balance key not found: {balance_key}")
key_display = key.split(":", 2)[-1]
log(
f"📊 [Usage] {key_display}: +{total_tokens} (Today: {current_total:,}{rem_display}{limit_str})",
f"📊 [Usage] {key_display}: +{total_tokens} (Today: {current_total:,}{bal_display}{limit_str})",
level="INFO",
)
if effective_limit is not None and float(current_total) >= float(effective_limit):
evict, evict_reason = should_soft_evict(
clean_model, api_base, current_total, effective_limit
)
if evict:
log(
f"🚫 [BLOCK] {clean_model} Limit Hit: {current_total}/{effective_limit}",
f"🚫 [BLOCK] {clean_model} soft-evict: {evict_reason} (limit: {effective_limit})",
level="WARNING",
)
soft_evict_from_router(raw_model, api_base)
@@ -1213,9 +1632,9 @@ def discover_monitored_models():
def _maybe_trigger_daily_consume():
"""
🕰️ 每日北京时间 23:00 触发免费额度消耗任务(洗过期资源包)。
🕰️ 每日北京时间 23:20 触发免费额度消耗任务(洗过期资源包)。
- consume:last_run:{北京日期} 去重(SETNX,当天只触发一次)
- daemon 线程执行,不阻塞主循环;线程内 0-10 分钟随机抖动,避免固定 23:00 被风控识别
- daemon 线程执行,不阻塞主循环;线程内 0-10 分钟随机抖动,避免固定 23:20 被风控识别
- 延迟 import volc_consume,避免模块级循环 import
"""
if not redis_client:
@@ -1224,8 +1643,9 @@ def _maybe_trigger_daily_consume():
now = time.time()
beijing_ts = now + (8 * 3600)
beijing_hour = time.gmtime(beijing_ts).tm_hour
if beijing_hour != 23:
return
beijing_min = time.gmtime(beijing_ts).tm_min
if beijing_hour != 23 or beijing_min < 20:
return # 23:20 起才触发(北京时间)
date_str = time.strftime("%Y-%m-%d", time.gmtime(beijing_ts))
lock_key = f"consume:last_run:{date_str}"
if redis_client.set(lock_key, "1", nx=True, ex=172800) is None:
@@ -1248,15 +1668,12 @@ def _maybe_trigger_daily_consume():
log(f"❌ 消耗任务调度失败: {e}", level="ERROR")
def compute_sync_interval(min_remaining, is_idle):
"""按最小正剩余余额 + 空闲状态计算同步间隔(秒)。"""
# 余额分档退化:余额驱动的"慢速档"已无意义(日返还模型下余额高 = 常态,
# 返还/过期/消耗随时发生,慢同步只制造检测延迟窗口)。
# 仅保留"低余额加密":<10万 5min、<30万 10min(返还解锁/扣费检测要紧);
# ≥30万 统一 30min,不再细分。真正的慢同步 = is_idle(base×4,上限 60min)。
if min_remaining < 100_000:
def compute_sync_interval(min_balance, is_idle):
"""按最小余额 + 空闲状态计算同步间隔(秒)。
min_balance 是 balance_at_sync 中的最小值(实际余额,非扣 SMALL_BUFFER 后)。"""
if min_balance < 100_000:
base_interval = 300 # 紧急 5 minutes
elif min_remaining < 300_000:
elif min_balance < 400_000:
base_interval = 600 # 低余额 10 minutes
else:
base_interval = 1800 # 常规 30 minutes
@@ -1304,11 +1721,17 @@ def active_monitor_loop():
"Starting Active Monitor Loop (Safety-Margin + Idle Awareness) / 正在启动主动监控循环 (安全余量 + 空闲感知)...",
flush=True,
)
print(f"Buffer / 缓冲: {BUFFER}", flush=True)
print(f"SmallBuffer / 缓冲: {SMALL_BUFFER}", flush=True)
first_iteration = True
while True:
try:
time.sleep(CHECK_INTERVAL)
if first_iteration:
# 首轮跳过 CHECK_INTERVAL,紧接 startup_audit(sleep 2s) 后立即同步,
# 把"额度未同步"空窗从 ~12-20s 缩到 ~2-5s(仅剩拉包 API 往返)
first_iteration = False
else:
time.sleep(CHECK_INTERVAL)
_maybe_trigger_daily_consume()
now = time.time()
@@ -1334,11 +1757,11 @@ def active_monitor_loop():
# Calculate delta for this specific model
m_delta = m_usage - last_usage_map.get(m, 0.0)
# Check if this model is considered "low quota"
rem_key = f"limit:dynamic:volcengine:remaining:{m}"
rem_val = redis_client.get(rem_key)
# 未知额度视为充足,避免首次启动时所有模型被误判为"低额度"
remaining = float(rem_val) if rem_val is not None else float(LOW_QUOTA_THRESHOLD)
# Check if this model is considered "low quota"
bal_key = f"limit:dynamic:volcengine:balance_at_sync:{m}"
bal_val = redis_client.get(bal_key)
# 未知额度视为充足,避免首次启动时所有模型被误判为"低额度"
remaining = float(bal_val) if bal_val is not None else float(LOW_QUOTA_THRESHOLD)
if m_delta > 0 and remaining < LOW_QUOTA_THRESHOLD:
any_low_quota_usage = True
@@ -1377,9 +1800,9 @@ def active_monitor_loop():
model_delta = curr_usage - last_usage
# 4. Get Current Balance for Threshold Calculation
rem_key = f"limit:dynamic:volcengine:remaining:{model}"
rem_val = redis_client.get(rem_key)
current_balance = float(rem_val) if rem_val else 0.0
bal_key = f"limit:dynamic:volcengine:balance_at_sync:{model}"
bal_val = redis_client.get(bal_key)
current_balance = float(bal_val) if bal_val else 0.0
# 5. Determine Dynamic Threshold based on Balance
# Rich models get loose limits, Poor models get strict limits.
@@ -1427,19 +1850,16 @@ def active_monitor_loop():
configured_models_found = False
for model_name, balance in model_balances.items():
remaining = balance - BUFFER
print(
f"DEBUG: Checking {model_name} (Bal: {int(balance):,}, Rem: {int(remaining):,}) (余额/剩余)",
f"DEBUG: Checking {model_name} (Bal: {int(balance):,})",
flush=True,
)
# Target Resolution (Already handled in process_packages_breakdown mostly)
# But we'll treat model_name as the target since process_packages_breakdown now handles the list logic
# Target Resolution
targets = [model_name]
for target in targets:
# STRICT FILTER: Only sync models explicitly configured with VolcEngine Base URL
# User Requirement: Do NOT sync or limit models that share names but use other providers (e.g. NewAPI)
if target not in MONITORED_MODELS:
if DEBUG_MODE:
print(
@@ -1448,16 +1868,14 @@ def active_monitor_loop():
)
continue
# Fetch Current Usage from Redis (to calculate Total Pool)
# We need to find the correct hash for this model from MONITORED_MODEL_PAIRS
# Fetch Current Usage from Redis
current_usage = 0
target_hashes = [
p[1] for p in MONITORED_MODEL_PAIRS if p[0] == target
]
# Sum usage across all configured endpoints for this model (usually just one)
# Sum usage across all configured endpoints for this model
for h in target_hashes:
# Replicate get_usage_key logic (Beijing Time UTC+8)
now_ts = time.time()
beijing_time_ts = now_ts + (8 * 3600)
usage_date = time.strftime(
@@ -1468,71 +1886,30 @@ def active_monitor_loop():
if val:
current_usage += float(val)
# Use normalized key for storage
# Format: limit:dynamic:volcengine:remaining:{model_name}
dynamic_key = f"limit:dynamic:volcengine:remaining:{target}"
balance_key = f"limit:dynamic:volcengine:balance_at_sync:{target}"
usage_key = f"limit:dynamic:volcengine:usage_at_sync:{target}"
# ==========================================
# 📏 v4 冻结快照:stable_limit = max(0, balance − BUFFER)
# ==========================================
# v2 的 usage + balance − BUFFER 假设「消耗中池恒定」(usage ↑ /
# balance ↓ 同步),但余额 API 实测滞后(2026-08-12 观察:快照
# 1,558,307 在 usage 1.24M→1.70M 期间不动,滞后上限未知),
# balance 不随消耗实时递减 → 池随 usage 膨胀、限额虚高,
# 「余额耗尽 → 限额 0」防线失效 → 放行到扣费区(当日超余额 13.8 万)。
# v4:以最近同步快照的余额为「当日免费池上限」,本地 usage 实时
# 累计撞线即拦(等价于本地实时扣减余额),不随 usage 膨胀;
# 扣费判定 = 消耗 > 当下免费额度即扣费(与采集量/返还无关)。
# BUFFER 覆盖快照滞后窗口内的消耗。见 docs/volc-quota-notes.md §7.3。
stable_limit = int(max(0, balance - BUFFER))
stable_limit_key = (
f"limit:dynamic:volcengine:stable_limit:{target}"
)
# Store stable limit
redis_client.setex(stable_limit_key, 86400, int(stable_limit))
# Store snapshot: balance_at_sync, usage_at_sync
redis_client.setex(balance_key, 86400, int(balance))
redis_client.setex(usage_key, 86400, int(current_usage))
if DEBUG_MODE:
static_limit = get_monitored_static_limit(
target, target_hashes
)
effective_limit = calculate_effective_limit(
static_limit, stable_limit
)
print(
f"DEBUG: {target} Bal={int(balance):,} | Usage={int(current_usage):,} | {format_limit_debug_fields(static_limit=static_limit, stable_limit=stable_limit, effective_limit=effective_limit)}",
f"DEBUG: {target} Bal={int(balance):,} | Usage={int(current_usage):,} | BalAtSync={int(balance):,} UsageAtSync={int(current_usage):,}",
flush=True,
)
if DEBUG_MODE:
print(
f"DEBUG: Checking {target} (Configured / 已配置) (Bal: {int(balance):,}, Rem: {int(remaining):,})",
flush=True,
)
# Set with expiry (24h)
redis_client.setex(dynamic_key, 86400, int(remaining))
if DEBUG_MODE:
print(
f"DEBUG: Set Redis {dynamic_key} = {int(remaining):,}",
flush=True,
)
# 即时恢复:同步发现限额已恢复(usage < effective limit)时立刻解除软踢,
# 不等 restore_blocked_models 的 30min/4h 闸门 —— 否则日返还额度到账后
# 模型仍被禁(有额度但 no healthy deployments),直到下一次恢复检查。
# 即时恢复:同步发现限额已恢复(usage < effective limit)时立刻解除软踢,
restore_model_if_healthy(target, target_hashes)
# Track min remaining for frequency control
# Track min balance for frequency control
configured_models_found = True
# Only track POSITIVE remaining for frequency control
# If a model is 0 (dead), we shouldn't panic-poll the healthy ones.
# We rely on the Restore Loop to check dead models.
if remaining > 0 and remaining < temp_min_remaining:
temp_min_remaining = remaining
if balance > 0 and balance < temp_min_remaining:
temp_min_remaining = balance
# If all models are dead (temp_min_remaining is still inf), set to 0 for logic but handle gracefully
# If we found models but all were <= 0, then temp_min_remaining is inf.