Compare commits

..

23 Commits

Author SHA1 Message Date
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
vibe-code d9c7b1406d refactor: 目录整理配套引用同步
README/AGENTS 文件清单与命令更新为新结构(generate_keys -> scripts/、
daily_limit -> archive/、文档 -> docs/);config.yaml 与 volc_consume.py
注释中废弃脚本/文档路径同步;README 过期限额示例更新为 2M 配置。
2026-08-12 06:58:16 +00:00
vibe-code dd349b64cf fix: 限额改 v4 冻结快照,余额红线防扣费
v2 的 stable_limit = usage + balance - BUFFER 依赖余额 API 随消耗实时递减,
实测滞后(08-12 快照 1,558,307 在 usage 涨 45 万期间不动)导致池膨胀、限额
虚高,放行到扣费区(当日超余额 13.8 万已扣费)。
扣费口径(用户确认):是否扣费只取决于当下免费额度,超出即扣费,采集量只
决定明日发放。v4:stable_limit = balance - BUFFER 冻结快照,本地 usage 实时
撞线即拦,等价于本地实时扣减余额;effective = min(2M 配额, stable_limit)。
新增 docs/volc-quota-notes.md 额度机制速查;merge-auto-consume.md 补 §4.5。
2026-08-12 06:58:03 +00:00
vibe-code c6c122426a fix: 同步间隔打印重算 + 恢复/审计口径对齐,清理死代码
- 同步后按新 Min Rem 重算'下一次同步'间隔(compute_sync_interval 唯一实现),
  修复'旧 interval + 新 Min Rem'混打误导;删除过时的五档注释
- restore_blocked_models / startup_audit 补 MONITORED hash 过滤,
  与 soft_evict/请求闸门/即时恢复口径一致,防误恢复/误踢同名非监控条目
- 删除 THRESHOLD 死常量
2026-08-12 03:53:02 +00:00
vibe-code 505a55987f fix: 限额恢复即时解锁 + 前置预留闸门,修复有额度却被禁
- 同步守护写健康限额后立即恢复被软踢模型(restore_model_if_healthy),
  不等 restore_blocked_models 的 30min/4h 闸门,修复日返还额度到账后
  模型带额度空转被禁(no healthy deployments)
- 前置闸门改为 usage + reserve >= limit 即拦截,预留优先取请求
  max_tokens(零采集损失),堵住并发请求集体撞线的超限窗口
2026-08-12 03:40:44 +00:00
vibe-code ee1245621d feat: 消耗语料优化:宿主真实日志挂载 + 合成语料扩容 + 游标去重
- docker-compose 只读挂载 /var/log:/host-var-log,LOG_SOURCES 追加宿主路径(真实日志优先)
- 合成语料 5000→30000 行、模板 8→28 种、主机 6→12、覆盖 24h(行间隔 ≈86400/count)
- generate_dynamic_payload 随机起点改游标连续窗口:单次运行内零重复(时间轴随请求推进),
  耗尽后环绕(跨请求重复可接受,跨天语料重建)
- 验证:模板特征 20/20 命中、12 主机、6 窗口零重叠、payload 长度合规
2026-08-11 15:17:52 +00:00
vibe-code 56b46652f2 feat: 日志统一加北京时间戳(_tagged_print 一处生效,实测 1.33us/次) 2026-08-11 15:10:24 +00:00
vibe-code b82fe3aa7e refactor: 余额分档退化为低余额加密两档,常规统一 30 分钟
按上一轮分析彻底落地:余额驱动的慢速档无意义(日返还下余额高=常态,
返还/过期/消耗随时发生)。仅保留 <10万 5min / <30万 10min(返还解锁、
扣费检测要紧),≥30万 统一 30min;慢同步完全交给 is_idle(×4 上限 60min)。
模式打印随分档简化并补充空闲状态。
2026-08-11 15:07:55 +00:00
vibe-code a93f806b33 fix: 同步模式打印图标改为余额风险语义(极速→中风险等,与档位自洽) 2026-08-11 15:06:26 +00:00
vibe-code 068f2ee658 fix: 取消高水位 60 分钟同步档(日返还模型下余额≥80万为常态)
- 高水位 base_interval 3600 → 1800:返还解锁/余额扣减检测延迟从 ≤60min 降到 ≤30min,
  消除每日返还后的额度空转窗口与余额虚高扣费窗口;真正的慢同步条件 = is_idle(已独立实现)
- All Dead 非晨间 1000000 → 300000:返还可能下午到账,不能睡 60 分钟
- 修复模式打印数值与 base_interval 全部错位的遗留 bug,改为输出实际同步间隔
2026-08-11 14:16:27 +00:00
vibe-code 5e40a311d1 fix: 消耗任务四类遗漏修复 + 清理死变量
volc_consume.py:
- payload 模型参数路由:doubao 系 max_completion_tokens+reasoning_effort minimal,
  其他模型 max_tokens(旧 MONITOR_TARGETS 实测配置,否则 doubao 消耗会 400 失效)
- _send_ark URL 单源:直接拼 HUOSHAN_BASE_URL(与 config.yaml 同 env),删 ARK_HOST
- 速率三档对齐旧 auto_consume(正常 15-40s / 极速 / 动态),避免 plan 大时 60s/次拖过 00:00
- run_consume_all 单次拉包复用,消除 6 次重复分页

volc_limit_sync.py:
- 修复 DEBUG 打印残留 current_peak 引用(v2 简化后已删变量,DEBUG 模式会 NameError)
- 清理旧遗留死变量 SYNC_INTERVAL / mode_str / display_limit,去重 limit_str
2026-08-11 13:58:58 +00:00
vibe-code b7df36bb4e fix: dynamic 限额缺失时 fallback 保守兜底(FALLBACK_LIMIT 90万)
免费模型余额常态 <2M(昨日采多少返多少),stable_limit 缺失窗口
(首次同步前/新增模型/TTL 过期)此前 fallback 裸奔到静态 2M,
余额不足的模型会被烧穿扣费。改为 min(static, FALLBACK_LIMIT=90万)。
2026-08-11 13:38:32 +00:00
vibe-code 96b81d52c4 feat: 合入火山免费额度自动消耗任务(每日23点洗过期包防扣费)
- 新增 volc_consume.py:消耗任务模块,直连 Ark 洗 9 天内过期资源包
  (旧 calculate_plan 均分×1.1/24h冲刺保留 + 双 clamp:2M 采集上限、余额−BUFFER 防扣费)
- volc_limit_sync.py:限额 v2 简化(stable_limit = usage+balance−BUFFER,删除峰值状态机 ~90 行),
  HARD_DAILY_LIMITS 统一 2M(pre-block 不滞后,1.8M 折扣作废),
  新增 _maybe_trigger_daily_consume(北京时间 23:00 ± 抖动,SETNX 去重,延迟导入)
- docker-compose 挂载 volc_consume.py;AGENTS.md/文档同步
2026-08-11 13:15:24 +00:00
vibe-code 2cc7c7d08b docs: 火山免费额度自动消耗合并设计方案 2026-08-11 12:53:51 +00:00
14 changed files with 1749 additions and 283 deletions
+16 -5
View File
@@ -5,13 +5,16 @@
## 1. 项目上下文
此仓库包含用于 **LiteLLM** 的自定义回调脚本,核心功能是限制模型用量(特别是火山引擎/VolcEngine)。
- **核心逻辑**:通过 `quota_interceptor` 拦截请求,并通过 `DailyLimitHandler` 记录用量到 Redis。后台 `active_monitor_loop` 守护线程定期从火山引擎 API 同步配额到 Redis。
- **架构**:所有逻辑统一在 `volc_limit_sync.py` 单文件中。LiteLLM 通过 `config.yaml` 中 `callbacks: ["volc_limit_sync.proxy_handler_instance"]` 加载回调,模块加载时自动启动后台同步线程。
- **架构**:主逻辑在 `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`: **唯一核心文件**,包含同步守护线程(`active_monitor_loop`)、LiteLLM 回调(`DailyLimitHandler` + `quota_interceptor`)、限额计算、模型屏蔽/恢复等全部逻辑。
- `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:00 消耗任务调度(`_maybe_trigger_daily_consume`,SETNX 去重)。
- `volc_consume.py`: 消耗任务模块(每日 23:00 触发;手动 `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`。
- `debug_volc_sync.py` / `debug_volc_sync_manual.py`: 调试脚本,用于手动验证 API 和 Redis 状态。
- `daily_limit.py.deprecated`: 已废弃,功能已全部合并到 `volc_limit_sync.py`,仅保留作历史参考。
- `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` 未实施的迁移方案)。
- `debug/`: 调试脚本(`debug_volc_sync.py` / `debug_volc_sync_manual.py`),手动验证 API 和 Redis 状态。
- `scripts/`: 工具脚本(`generate_keys.py` 监控模型审计入口)。
- `archive/`: 已废弃(`daily_limit.py.deprecated` 历史参考、`recover.py` 一次性恢复工具)。
## 2. 环境与运行 (Build & Run)
@@ -38,6 +41,14 @@ pip install litellm redis requests
- **调试模式**:
设置环境变量 `DAILY_LIMIT_LOG_LEVEL=DEBUG` 可启用详细日志。
- **消耗任务(手动触发)**:
```bash
# 需要 HUOSHAN_API_KEY(Ark API Key)与 HUOSHAN_BASE_URL
python volc_consume.py --dry-run # 只计算每个模型的消耗计划,不发送请求
python volc_consume.py --force # 立即执行(绕过 23:00 时间限制)
```
每日 23:00 由 `volc_limit_sync.py` 守护线程自动触发(`consume:last_run:{日期}` 去重)。
## 3. 代码风格指南 (Code Style)
### 3.1 基础规范
+33 -21
View File
@@ -10,7 +10,7 @@ callbacks: ["volc_limit_sync.proxy_handler_instance"]
这意味着当前 Docker 容器里真正生效的是 `volc_limit_sync.py`。
`daily_limit.py` 目前 **未启用**,只保留作历史参考。除非你把 `callbacks` 改回 `daily_limit.py`,否则改它里面的 `MODEL_QUOTAS` 不会影响当前运行行为。
旧脚本 `archive/daily_limit.py.deprecated` 目前 **未启用**,只保留作历史参考。除非你把 `callbacks` 改回旧模块,否则改它里面的 `MODEL_QUOTAS` 不会影响当前运行行为。
---
@@ -62,7 +62,7 @@ glm-4-7-251222 -> glm-4-7
每次改完 `config.yaml` 后,执行:
```bash
python generate_keys.py
python scripts/generate_keys.py
```
这个脚本现在不是单纯“生成 key”,而是正式的 **监控模型审计入口**。它会输出:
@@ -106,7 +106,7 @@ python generate_keys.py
### 新增监控模型
1. 在 `config.yaml` 的 `model_list` 里加入新的火山模型条目。
2. 运行 `python generate_keys.py`。
2. 运行 `python scripts/generate_keys.py`。
3. 先确认脚本输出中:
- 该模型已经出现在“监控模型审计”里。
- “监控别名”正确。
@@ -119,7 +119,7 @@ python generate_keys.py
### 删除监控模型
1. 从 `config.yaml` 里删掉对应模型条目。
2. 运行 `python generate_keys.py`,确认它已经不在“监控模型审计”里。
2. 运行 `python scripts/generate_keys.py`,确认它已经不在“监控模型审计”里。
3. 如果该模型曾有单独的 `HARD_DAILY_LIMITS` 项,可以按需清理。
4. 如果该模型对应的是特殊手工别名,且已经没有任何上游模型再用它,可以再清理 `MANUAL_MONITORED_MODEL_ALIASES`。
5. 重启 LiteLLM 容器。
@@ -160,14 +160,14 @@ python generate_keys.py
3. 模糊规则
4. `__DEFAULT__`
例如:
例如(当前值,2026-08):
```python
HARD_DAILY_LIMITS = {
"doubao-seed-code-preview-251028": 4_800_000,
"doubao-seed-2-0-pro-260215:ba0383d3": 1_900_000,
"doubao-seed-2-0-lite-260215:ba0383d3": 1_900_000,
"__DEFAULT__": 1_800_000,
# 火山免费额度规则:每模型每日采集上限 2M,次日按采集量发放资源包。
# 采集上限封顶;真正决定今日可采的是 v5 动态层:
# effective = min(2M, stable_limit),stable_limit = 余额快照 − BUFFER(见 docs/volc-quota-notes.md)
"__DEFAULT__": 2_000_000,
}
```
@@ -175,13 +175,13 @@ HARD_DAILY_LIMITS = {
## 常见坑
### 1. 改了 `daily_limit.py` 但不生效
### 1. 改了旧脚本(`archive/daily_limit.py.deprecated`)但不生效
这是最容易踩的坑。
当前 `config.yaml` 没有启用 `daily_limit.py`,所以:
当前 `config.yaml` 没有启用旧模块 `daily_limit`,所以:
- 改 `daily_limit.py`
- 改 `archive/daily_limit.py.deprecated`
- 改 `MODEL_QUOTAS`
都不会影响当前 LiteLLM 容器的启动和监控行为。
@@ -204,7 +204,7 @@ xxx-251201 -> xxx
xxx-260215 -> xxx
```
所以普通版本升级时,通常只需要改 `config.yaml`,再跑一遍 `python generate_keys.py` 审计确认。
所以普通版本升级时,通常只需要改 `config.yaml`,再跑一遍 `python scripts/generate_keys.py` 审计确认。
---
@@ -213,7 +213,7 @@ xxx-260215 -> xxx
### 审计当前纳入监控模型
```bash
python generate_keys.py
python scripts/generate_keys.py
```
### 直接运行同步监控
@@ -235,18 +235,30 @@ DAILY_LIMIT_LOG_LEVEL=DEBUG python volc_limit_sync.py
```text
litellm-config/
├── config.yaml # 纳入监控模型的主入口
├── volc_limit_sync.py # 当前生效的监控、同步、限额脚本
├── generate_keys.py # 监控模型审计入口
├── daily_limit.py # 旧脚本,当前 config 未启用
├── debug_volc_sync.py # 资源包调试脚本
└── debug_volc_sync_manual.py # 手动调试脚本
├── docker-compose.yml # 容器编排(挂载 config.yaml / volc_limit_sync.py / volc_consume.py)
├── volc_limit_sync.py # 当前生效的监控、同步、限额脚本(v4 冻结快照限额)
├── volc_consume.py # 每日 23:00 自动消耗任务(防免费资源包过期蒸发)
├── AGENTS.md # 仓库 Agent 协议(代码风格/运行/提交规范)
├── README.md
├── docs/
│ ├── merge-auto-consume.md # 消耗任务 + 限额设计(v2 历史 / v4 修订)
│ ├── volc-quota-notes.md # 火山额度机制速查(扣费口径/方案演进/排障)
│ └── merge-volc-limit-into-newapi.md # 未实施的迁移方案(历史)
├── scripts/
│ └── generate_keys.py # 监控模型审计入口
├── debug/
│ ├── debug_volc_sync.py # 资源包调试脚本
│ └── debug_volc_sync_manual.py # 手动调试脚本
└── archive/
├── daily_limit.py.deprecated # 旧脚本,当前 config 未启用(历史参考)
└── recover.py # 一次性恢复工具(历史)
```
---
## 维护原则
- 先改 `config.yaml`,不要先改 `daily_limit.py`
- 先跑 `python generate_keys.py` 审计,再决定要不要补 `HARD_DAILY_LIMITS`
- 先改 `config.yaml`,不要先改旧脚本 `archive/daily_limit.py.deprecated`
- 先跑 `python scripts/generate_keys.py` 审计,再决定要不要补 `HARD_DAILY_LIMITS`
- 普通版本号模型优先依赖自动推导,不要手工补一堆重复映射
- 只有特殊别名或一对多场景,才改 `MANUAL_MONITORED_MODEL_ALIASES`
View File
+1 -1
View File
@@ -473,7 +473,7 @@ litellm_settings:
allowed_fails: 3 # cooldown model if it fails > 1 call in a minute.
# 当前生效入口:volc_limit_sync.py
# 监控模型发现、监控别名推导、资源包同步、HARD_DAILY_LIMITS 都在这个脚本里。
# daily_limit.py 当前未启用,仅保留作历史参考。
# 旧脚本 archive/daily_limit.py.deprecated 当前未启用,仅保留作历史参考。
callbacks: ["volc_limit_sync.proxy_handler_instance"]
router_settings: # router_settings are optional
@@ -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
+4
View File
@@ -9,6 +9,10 @@ services:
- ./config.yaml:/app/config.yaml
# 【关键】挂载自定义脚本到容器根目录
- ./volc_limit_sync.py:/app/volc_limit_sync.py
# 自动消耗任务(每日 23:00 由 volc_limit_sync 调度触发)
- ./volc_consume.py:/app/volc_consume.py
# 消耗任务语料:宿主真实系统日志(只读)
- /var/log:/host-var-log:ro
command:
- "--config=/app/config.yaml"
ports:
+351
View File
@@ -0,0 +1,351 @@
# 火山免费额度自动消耗:auto_consume 合入 volc_limit_sync 设计方案
> 日期:2026-08-11
> 背景:`/root/workspace/huoshan-api-auto_consume/`(自动消耗脚本)与 `/root/workspace/litellm-config/`(LiteLLM 限流回调 + 火山余额同步)合并,使每日 23:00 自动消耗免费额度,防止资源包过期蒸发,避免真实扣费。
> 前置文档:《merge-volc-limit-into-newapi.md》(2026-08-05,方向相反:把 volc_limit_sync 功能迁往 new-api,未实施)。
---
## 一、火山免费额度业务模型(一切设计的事实基础)
### 1.1 日返还循环
```
每天任意时刻 真实用户 + 消耗任务 的消耗 = 当日采集量(火山实时统计,每模型单独计算)
每天 23:00 CST 消耗任务补齐缺口(可选,见 §4)
次日 00:00 起 火山按昨日采集量发放资源包(不定时,可能延迟到上午)
```
### 1.2 五个硬规则(用户确认)
| 规则 | 值 | 影响 |
|---|---|---|
| 采集上限 | **2M token/模型/天** | 消耗超过 2M 的部分**不返还**(白烧);所有模型统一 |
| 发放额度 | = 昨日采集量(≤2M) | 消耗 X → 次日返还 X,用多少补多少,延迟一天到账 |
| 发放时间 | **不定时** | 次日 0:00 至发放时点存在余额空窗 |
| 资源包有效期 | **30 天** | 过期额度实打实消失,失去"启动资金" |
| 消耗顺序 | **FIFO(先消耗获得时间早的包)** | 队列头部 = 最早获得 = 最早过期(效期统一 30 天时两者等价) |
| 余额耗尽 | **请求不失败,按真实价格扣费** | 全部防护的终极目标:**避免扣费** |
| 首次开通 | **每模型赠送 2M** | 启动资金的种子(无对应前日采集的免费午餐) |
### 1.3 关键账目:启动资金守恒
```
余额_{N+1} = 余额_N − 消耗_N + 返还_{N+1}
= 余额_N − 消耗_N + 采集_N
= 余额_N (消耗即采集,≤2M 内全额返还)
```
**推论**:
- **使用不损失额度**(用多少补多少,仅延迟一天)
- **过期才损失额度**(额度蒸发 = 启动资金永久减少)
- 消耗任务的价值 = 防止"启动资金"被 30 天过期侵蚀,把额度滚成新包永续循环
- 真实用量 ≥ 66.7k/天(= 2M/30 天)时,赠送包被自然消耗,脚本自动闲置;低于此值必须靠脚本在最后 9 天窗口洗掉
### 1.4 扣费风险链(限流系统的真正职责)
```
免费额度耗尽(余额 = 0) → 继续请求 → 真实扣费
```
触发扣费的两种路径:
1. **真实用户单日用量 > 当日可用余额**(含空窗期 0:00–发放时点余额不足)
2. **消耗任务烧穿余额**(脚本自身引入的扣费,必须结构性杜绝)
---
## 二、两项目现状与合并可行性
### 2.1 当前项目 `volc_limit_sync.py`(1536 行,LiteLLM 回调 + 常驻守护)
| 能力 | 实现 |
|---|---|
| 火山余额同步 | SigV4 签名调 `ListResourcePackages`(`Status: Effective` 过滤 + `seen_tokens` 防分页死循环),`normalize_volc_name` 归一化,按监控模型聚合 |
| 动态限额 | Peak-Buffer:`PeakPool = 当日用量 + 余额`;`stable_limit = PeakPool − 15% margin(下限 25w)`;与静态 `HARD_DAILY_LIMITS` 取小得 `effective_limit` |
| 当日用量计数 | LiteLLM 成功回调 `INCRBY total_tokens` → Redis `daily_usage:{北京日期}:{model}:{api_base_hash}`,北京时间 00:00 滚动,TTL 2 天 |
| 超限停用/恢复 | `soft_evict_from_router` 重命名软踢;北京时间 7–13 点每 30 分钟、其余 4 小时检查恢复;`startup_audit` 启动预踢 |
| 模型发现 | 解析 `config.yaml` 中 `volcengine/` 前缀 + `HUOSHAN_BASE_URL` 条目 → `MONITORED_MODEL_PAIRS`;版本号自动推导别名 + `MANUAL_MONITORED_MODEL_ALIASES` 手工覆盖 |
### 2.2 目标项目 `huoshan-api-auto_consume/`(420 行 + 4 辅助)
| 能力 | 实现 |
|---|---|
| 语料 | `/var/log` 系统日志分组 + 脱敏(IP/MAC/邮箱/路径)+ 不足时合成日志 |
| 计划 | `calculate_plan`:9 天窗口内包 `remaining/ceil(days_left)×1.1`,最后 24h 冲刺全量,多包相加 |
| 执行 | 直连 Ark API(`ark.cn-beijing.volces.com/api/v3/chat/completions`),速率伪装(15–40s 随机),指数退避,6h 上限,连续失败 5 次跳过 |
| 配置 | `config.py`:`MONITOR_TARGETS`(id/keyword/alias/extra_params) |
### 2.3 可复用资产与冲突点
**重叠(合并即消除重复)**:
- SigV4 签名(`volc_signer.py` ≈ `get_signed_headers`)——**同一账户同一组 AK/SK**
- `fetch_all_packages`(目标版缺 `seen_tokens` 防循环保护,保留当前版)
- 资源包→模型映射(当前版 `normalize_volc_name` + 别名体系更健壮,目标版 `keyword` 模糊匹配退役)
**关键事实:模型配置天然单一化**。`config.yaml` 的 volcengine 上游模型名(剥掉 `volcengine/` 前缀)就是 Ark endpoint id,与 `MONITOR_TARGETS` 的 id 一一对应(`deepseek-v4-flash` 的 `-ga-260731` vs `-0731` 差异已由 `MANUAL_MONITORED_MODEL_ALIASES` 覆盖)→ **`MONITOR_TARGETS` 退役,消耗任务直接由 config.yaml 驱动**。
**冲突点与解法**:
1. 限额 vs 消耗:消耗请求**必须直连 Ark,绝不改走 LiteLLM**(否则被 `quota_interceptor` 拦下自杀)
2. 消耗量**必须计入 `daily_usage`**(手动 INCR 同 key)——空窗期防扣费闭环的关键,见 §5.3
3. 脚本自身不能烧穿余额(结构性防扣费,见 §4.4)
---
## 三、消耗策略设计
### 3.1 为什么不做"烧满 2M"
API 请求的 token 消耗不可精确控制(输入按字符估算、输出不确定)。若目标 = 补满 2M:
- 烧超采集上限的部分不返还 → 白烧
- 烧到余额 0 仍继续 → **直接扣费**
- 精确校准需要逐请求调整 → 复杂度不可接受
### 3.2 目标:洗掉"即将过期的额度"(过期风险驱动)
```
消耗量 = Σ(该模型 ExpiryTime − now ≤ MONITOR_DAYS 的资源包剩余额度)
```
- 消耗量 ≤ 快过期包剩余 ≤ 当前可用余额 → **结构上不可能烧穿余额**(估算偏差只决定多烧/少烧几笔请求,多烧部分来自非过期包,次日照常返还,无害)
- FIFO 先烧最早获得 = 最早过期的包 → 脚本烧的恰好是最该洗的包,顺序自动正确
- 不追求 2M 精确值 → 无校准问题
### 3.3 均分逻辑(保留旧 `calculate_plan`,经真实使用验证)
```
对每个 9 天窗口内的包:
days_left ≤ 1.0 → plan = remaining # 24h 冲刺,全量兜底
days_left > 1.0 → plan = remaining / ceil(days_left) × 1.1 # 均分 + 10% 提前量
总 plan = Σ(各包 plan)
```
**为什么均分优于一次性洗光**(在新模型下):
1. **最小干扰真实用户**:只在额度即将死亡(进窗口)后才每天接管 1/D,其余留给真实用户 → 免费额度利用率最大化,空窗风险最小
2. **请求负载分摊**:每天 3–5 次请求(而非 25–40 次),速率伪装自然,风控友好
3. **余额下降平缓**:23:00 后余额仍保留大部分 → 扣费只发生在余额真正归零时,均分下脚本几乎不可能制造归零
4. **FIFO 自动一致**:效期统一 30 天 → 获得早 = 过期早 → 队列头部 = 窗口内包,计划与执行不打架(分包明细仅用于定总量)
**与日返还循环的契合**:旧脚本本就是"每天跑一次、分 D 天烧完"的设计;自动化后每天重算(余额/天数变化 → div 递减 → 当日份额递增),节奏天然吻合。9 天窗口 + 1.1 系数 + 24h 冲刺:只要每天触发成功,任何进窗口的包都赶在过期前清空。
### 3.4 三个约束(旧逻辑缺失,必须补)
```
plan = min(plan, 2M − daily_usage) # 采集上限:超出不返还,白烧
plan = min(plan, 余额快照 − 本次会话已烧 − BUFFER) # 防扣费:脚本不烧穿余额
```
**执行停止条件(每轮请求后检查,3 条闭环)**:
```
daily_usage ≥ 2M → 停(采集上限,再烧不返还)
余额快照 − 本次已烧 ≤ 0 → 停(防扣费)
本次已烧 ≥ plan → 停(任务完成)
```
### 3.5 消耗量估算
- 单次请求:输入 3–5.5 万字符 ≈ 5–11 万 token(中文 1 字 ≈ 1–2 token)+ 输出 ~1000
- 极值 2M / 8 万 ≈ 25 次请求 → 15–40s 间隔 ≈ 10–17 分钟,1 小时窗口绰绰有余
- 均分模式每天 3–5 次请求,更宽松
### 3.6 触发调度
- 北京时间 **23:00 ± 随机抖动**(防固定时间被风控识别),Redis `consume:last_run:{日期}` 去重,当天未跑过才触发
- 守护线程(`active_monitor_loop` 已有主循环)检测时点 → spawn daemon 线程执行,不阻塞主循环
- 23:00 的时点语义:距 00:00 采集结算 1 小时,此时窗口内包状态已定(真实用户当天用量已发生),洗包目标最准
---
## 四、限额设计(防扣费)——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 峰值状态机(存量场景产物)
旧逻辑 `stable_limit = PeakPool × 0.85`(PeakPool = usage + balance,首锁/门控重锁/DROP 状态机维护)有两个问题:
1. **比例 margin 在 usage 接近 balance 时数学失效**(致命):
- `u=0.5b` 时限额允许再烧 `0.775b`,实际余额只剩 `0.5b`(超 55%)
- `u=0.8b` 时允许再烧 `0.73b`,实际只剩 `0.2b`(超 3.6 倍)→ **烧穿扣费**
- 存量场景没爆是因为 usage 远小于 balance;新模型"返还 = 消耗"使 usage 天天逼近 balance → 必炸
2. **峰值状态机防的是旧 bug**(存量包余额只减不增时,滞后虚高把 PeakPool 漂移、限额越算越多 399,836 → 599,511)。新模型 balance 每天真实跳涨(返还),锁峰值会把真实发放当异常压住——**跟随现值即可,状态机删除**
### 4.1b 1.7M/1.8M 保守值的来历:post-hoc 滞后时代的遗产
旧拦截机制是"请求成功后记账 + 踢模型"(`log_success_event`),**超限请求本身已放行**,存在"滞后一个请求"的漏网量。因此限额必须打折留安全垫:`HARD_DAILY_LIMITS` 默认 1.8M(2M 打 9 折)、`stable_limit` 打 8.5 折(≈1.7M 效应)。
**现状已改为请求前闸门**:`async_pre_call_hook` → `_check_request_block`(429 拒绝,不进 router,不触发 retries/fallback)——主拦截不再滞后,"滞后一个请求"的折扣失去存在必要。新公式精确到 2M;BUFFER 30 万保留,但它防的是**余额统计滞后**(实测 20 万内不动,固定量非比例),与请求滞后无关。
### 4.2 新公式
> ⚠️ 2026-08-12 修订:本节 v2 公式经实测确认**数学正确**(B 滞后收敛后守恒成立),
> 无需取代;v4 冻结快照尝试因误拦已回退(见 §4.5)。保留原文作论证。
```
effective_limit = min(HARD_DAILY_LIMITS, max(0, usage + balance − BUFFER))
HARD_DAILY_LIMITS → 统一 2M(采集上限;doubao-seed-code-preview-251028 480 万遗留一并改为 2M)
```
**`usage + balance` = 当日初始可用池,天然稳定**:
- 消耗中:usage ↑ / balance ↓ → 池恒定 → 限额恒定 ✓
- 返还:balance 跳涨 → 池涨 → 限额次日自动解锁(无需 RECHARGE)✓
- 过期:balance 跳跌 → 池跌 → 限额收紧(无需 DROP)✓
- 余额滞后(实测 20 万内不动):池虚高 ≤ 20 万 → BUFFER 30 万覆盖 ✓
- 消耗任务 23:00 烧(usage 计入):池不变 → 滞后窗口内限额偏松 ≤ BUFFER ✓
- 余额耗尽:池 − 30 万 ≤ 0 → 限额 0 → 全拦,扣费结构性杜绝 ✓
**fallback(stable_limit 缺失窗口)**:免费模型余额常态 < 2M(昨日采多少返多少),若 dynamic 缺失(首次同步前/新增模型/TTL 过期)时裸奔到静态 2M,余额不足的模型会被烧穿扣费。故 fallback 用保守值 `FALLBACK_LIMIT = min(static, 90万)`(env 可调),宁可短时收紧不可裸奔。
**与消耗任务同构**:限额放行条件 = `balance − BUFFER ≥ 0`;消耗任务停止条件 = `余额快照 − 已烧 ≤ 0`——同一把尺子。
### 4.3 简化后保留/删除清单
| 保留 | 删除(~100 行) |
|---|---|
| 同步频率控制(驱动改为 balance,余额越低同步越勤) | `volc:peak_pool:*` 状态机 |
| `remaining = balance − BUFFER` 写入 | RECHARGE / DROP / 首锁 / 门控重锁分支 |
| 软踢 / 恢复 / 审计 / pre-block / usage 记账 | `volc:last_seen_pool` / `volc:last_balance` |
| `get_usage_key` 北京时间滚动 | stable_limit 改直接写 `max(0, usage + balance − BUFFER)` |
### 4.4 空窗期防扣费闭环(消耗量计入 daily_usage 的理由)
23:00 消耗任务把额度洗掉后余额可能很低,此时真实用户请求必须被拦。拦截依据 = `daily_usage vs effective_limit`:
- **若消耗量不计入 daily_usage**:限额看到"真实用户才用 100k,未超限" → 放行 → **扣费**
- **若计入**:daily_usage ≈ 真实 + 消耗任务 ≈ 2M → 超限拦截 → 空窗期安全
同步线程检测 balance 归零 → limit 收紧有 2–60min 滞后;消耗量计入让拦截在**滞后窗口内也有效**,双保险。
### 4.5 v5 修订(2026-08-12 已实施):恢复 v2 骨架,实测修正论证
**v2 曾被误判失效**:2026-08-12 上午观察到「balance 快照 1,558,307 在 usage 1.24M→1.70M 期间
不动」,据此推断 v2 的 `usage + balance` 池膨胀失效,改为 v4 冻结快照 `balance − BUFFER`。
**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 时低估初始池,有真实免费额度却全拦。
**守恒反推(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, usage + balance − BUFFER) # 同步时算一次
effective_limit = min(HARD_DAILY_LIMITS, stable_limit)
```
- `usage + balance` = 当日初始免费池(守恒),消耗中恒定(B 实时时);usage 累计撞线
= 用到剩 BUFFER 时停 —— 防扣费语义正确
- 残留风险仅「同步时点 B 滞后」→ stable 虚高 ≤ 滞后窗口消耗 → BUFFER + 低余额加密
同步(600s/300s)+ burst 触发覆盖;滞后收敛后池回守恒值
- 与消耗任务「余额快照 − 已烧 ≤ 0 停」同一把尺子(不变量:放行条件 = `balance − BUFFER ≥ 0`)
- 与昨日采集量**无关**(存量余额守恒;v3「昨日锚」因把单日流量当存量已被否决)
**BUFFER 语义**:防「同步时点余额滞后窗口内的消耗」。13:36 实测滞后高估 ~50 万 > 30 万,
滞后上限未实测 → 建议调大(如 50 万,env 可调)或实测后定。
### 4.3 限额与消耗共用数据、方向相反
| 数据 | 限额(拦真实用户) | 消耗任务(洗过期额度) |
|---|---|---|
| `daily_usage` | 超限踢出 | 停止条件(≥2M 停) |
| `remaining`(余额−缓冲) | 限额收紧 | 防烧穿上限 |
| `stable_limit` | effective_limit 组成 | 不适用(直连旁路) |
---
## 五、架构与实现
### 5.1 代码形态
```
litellm-config/
├── volc_limit_sync.py # 现状:签名/拉取/归一化/限额/守护线程
│ # 新增:23:00 调度钩子(检测时点 + spawn 消耗线程 + 去重)
├── volc_consume.py # 新增:消耗任务(从 auto_consume 移植 + 改造)
│ ├── load_base_logs / generate_dynamic_payload / _sanitize_line # 原样
│ ├── calculate_plan # 保留 + 两个 clamp
│ ├── run_stealthy_consume # 保留(停止条件 + 手动 INCR)
│ └── consume_once(model) # 新封装:查缺口 → 执行 → 记账
├── config.yaml # 模型入口不变(消耗任务读同一份监控发现)
└── .env # 密钥统一走 env(HUOSHAN_API_KEY 即 Ark key)
```
请求直连 Ark(`HUOSHAN_API_KEY`),复用 `MONITORED_MODEL_PAIRS` / `MONITORED_MODEL_ALIASES` / `fetch_all_packages` / 签名。`MONITOR_TARGETS` 与 `config.py` 不再需要。
### 5.2 Redis key 一览
| key | 读写方 | 说明 |
|---|---|---|
| `daily_usage:{日期}:{model}:{hash}` | LiteLLM 回调 INCR + 消耗任务 INCR | 当日用量,TTL 2 天 |
| `limit:dynamic:volcengine:remaining:{model}` | 守护线程写 | 余额 − BUFFER |
| `limit:dynamic:volcengine:stable_limit:{model}` | 守护线程写 | v4 冻结快照:余额 − BUFFER |
| `volc:peak_pool:{日期}:{model}` | 守护线程 | 当日峰池 |
| `consume:last_run:{日期}` | 消耗任务 | 当日去重,新增 |
### 5.3 消耗任务执行流程
```
23:00 触发(守护线程)
→ consume:last_run:{今天} 已存在? → 跳过
→ 遍历 MONITORED_MODELS(config.yaml 发现的 volcengine 模型)
→ fetch_all_packages(复用)→ 按模型过滤窗口内包
→ plan = calculate_plan 结果,应用两个 clamp
→ plan > 阈值(如 5 万)?
→ 读 remaining 快照 → spawn 消耗线程
→ 循环:生成语料 payload → 直连 Ark(速率伪装/退避/6h 上限)
→ 成功 → INCR daily_usage;每轮检查 3 停止条件
→ 失败 > 5 次 → 跳过该模型
→ 写 consume:last_run:{今天}
```
### 5.4 代码资产迁移清单
| 资产 | 来源 | 处理 |
|---|---|---|
| 签名 / `fetch_all_packages` / 归一化 / 模型发现 | volc_limit_sync | 原样复用 |
| `calculate_plan`(9 天窗口/均分×1.1/冲刺/相加) | auto_consume | 保留,加 2 个 clamp |
| 语料生成/脱敏/合成 | auto_consume | 原样移植 |
| 速率伪装/退避/失败上限/6h 上限 | auto_consume | 原样移植 |
| `_record_usage` 的 key 计算(`get_usage_key`) | volc_limit_sync | 消耗任务复用做 INCR |
| 调度钩子 | 新增 | 守护线程 23:00 检测 + 去重 |
---
## 六、风险与边界(已确认/已知)
| 项 | 结论 |
|---|---|
| 空窗期(0:00–次日发放不定时) | **用户确认可接受**;均分策略使空窗概率最小化(余额平缓下降) |
| 烧满 2M 的精确性 | 放弃该策略,改为过期风险驱动,结构性规避 |
| 物理上限 | 窗口内快过期额度 > 2M 时每天最多洗 2M,极少数过期蒸发不可避(返还上限硬约束) |
| 触发连续性 | 依赖每天 23:00 成功;连续失败 ~8 天耗尽窗口缓冲(冲刺兜底),守护线程常驻风险低 |
| 风控 | 23:00 ± 抖动 + 15–40s 随机间隔 + 每天 3–5 次请求,接近真实行为 |
| 容器语料 | LiteLLM 容器内无 `/var/log` 系统日志 → 合成语料兜底(5000 行够用) |
| 消耗任务自身扣费 | 结构性杜绝:plan ≤ 余额快照 − 缓冲,且每轮检查余额 |
| 余额 API 滞后 | v5 恢复 v2(usage+balance−BUFFER):滞后收敛后守恒成立;滞后窗口由 BUFFER + 低余额加密同步覆盖;v4 冻结快照(balance−BUFFER)因误拦已回退 |
---
## 七、待确认事项
1. `MONITOR_DAYS` 沿用 9 天(推荐:每天触发 + 冲刺兜底下足够,且与旧逻辑一致)
2. 实现落点:拆 `volc_consume.py` 模块(推荐,单文件将超 2000 行)还是严格并入 `volc_limit_sync.py`
3. 消耗触发阈值(plan 小于多少跳过):建议 5 万 token,避免无谓请求
4. 消耗任务与真实用户的额度优先级:均分策略已隐含"真实用户优先",无需额外开关
---
## 八、实施步骤(确认后执行)
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` 去重
4. `HARD_DAILY_LIMITS` 统一为 2M;`MONITOR_TARGETS`/`config.py` 退役
5. 用 `test_connection.py` 等价方式验证 Ark 连通 → 手动触发一次消耗任务(dry-run 模式验证 plan 计算与 clamp)→ 观察 Redis key 变化
6. 全量回归:守护线程限额逻辑不受影响(消耗线程独立 daemon)
+429
View File
@@ -0,0 +1,429 @@
# 火山免费额度机制速查(VolcEngine Quota Notes)
> 日期:2026-08-12 ~ 2026-08-13
> 用途:后续限额/防扣费设计、排障、消耗任务的事实基础,避免重复推导
> 来源:
> - `merge-auto-consume.md` §1(2026-08-11 用户确认的业务细则)
> - 2026-08-12 ~ 13 对话澄清 + 生产日志观察(deepseek-v4-flash-ga-260731)
> - 2026-08-13 网页口径校准(真实初始余额 B0=2,242,178;API 结算滞后 Δ≈14 分钟)
>
> 代码现状: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 日返还循环
```
每天任意时刻 真实用户 + 消耗任务 的消耗 = 当日采集量(火山实时统计,每模型单独计算)
每天 23:00 CST 消耗任务补齐缺口(可选)
次日 00:00 起 火山按昨日采集量发放资源包(不定时,可能延迟到上午)
```
### 1.2 硬规则
| 规则 | 值 | 影响 |
|---|---|---|
| 采集上限 | 2M token/模型/天 | 消耗超过 2M 的部分**不返还**(白烧);所有模型统一 |
| 发放额度 | = 昨日采集量(≤2M) | 消耗 X → 次日返还 X,用多少补多少,延迟一天到账 |
| 发放时间 | 不定时 | 次日 0:00 至发放时点存在余额空窗 |
| 资源包有效期 | 30 天 | 过期额度实打实消失,失去"启动资金" |
| 消耗顺序 | FIFO(先消耗获得时间早的包) | 队列头部 = 最早获得 = 最早过期 |
| 余额耗尽 | 请求不失败,按真实价格扣费 | 全部防护的终极目标:避免扣费 |
| 首次开通 | 每模型赠送 2M | 启动资金的种子(无对应前日采集的免费午餐) |
### 1.3 账目守恒
```
余额_{N+1} = 余额_N − 消耗_N + 返还_{N+1}
= 余额_N − 消耗_N + 采集_N
= 余额_N (消耗即采集,≤2M 内全额返还)
```
推论:
- **使用不损失额度**(用多少补多少,仅延迟一天)
- **过期才损失额度**(额度蒸发 = 启动资金永久减少)
- 消耗任务的价值 = 防止"启动资金"被 30 天过期侵蚀
- 真实用量 ≥ 66.7k/天(= 2M/30 天)时,赠送包被自然消耗;低于此值必须靠消耗任务在最后 9 天窗口洗掉
---
## 2. 扣费判定(用户确认口径,最重要)
> 用户原话:**是否扣费只取决于当下有多少免费额度。超出免费额度的请求,都会被扣费。采集量只决定明日发放,与今日是否扣费无关。**
```
扣费判定:消耗 ≤ 当下余额(免费额度) → 免费,余额扣减
消耗 > 当下余额 → 超出部分按真实价格扣费(无返还、不退)
今日采集量(≤2M) → 只决定明日返还的包大小,与今日是否扣费无关
```
> 注:2026-08-12 §5 曾基于滞后快照推断"当日超余额 137,828 tokens 已扣费",
> 后经 §4.2 网页口径校准推翻(滞后快照高估真实剩余约 50 万,实际无扣费)。
> 判扣费必须用**网页真实初始余额 B0** 或滞后收敛时点的 U+B 反推,不能用任意时点快照。
---
## 3. 关键概念区分(反复踩坑处)
| 概念 | 含义 | 量级特征 | 与扣费的关系 |
|---|---|---|---|
| 采集量(今日) | 今日实时消耗统计(火山网页"今日采集量") | 单日流量,0~2M | 只决定明日返还 |
| 余额(免费额度) | 资源包 AvailableAmount 存量之和 | 累计存量,守恒 ≈2M(种子) | **扣费唯一依据** |
| 采集配额 2M | 今日最大采集量 | 每模型固定 | 超了白烧(不返还),未必当场扣费 |
| 返还(T+1) | 昨日采集量 → 新资源包 | 单日流量延迟一天 | 补回余额,不抵今日扣费 |
**核心:余额是累计存量(守恒),采集量是单日流量。限额设计必须用存量(余额),
绝不能用单日流量(昨日采集量)当上限——那会把可用性锁死在水位以下(见 §7.2 v3)。**
---
## 4. 余额 API 的滞后问题
### 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 > BalAtSync
> 以下日志为 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) (余额/剩余)
📊 [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 之和) | 同步快照 |
| BalAtSync | 同步时写入 Redis 的余额快照 | = Bal |
| UsageAtSync | 同步时写入 Redis 的累计消耗 | Redis `daily_usage` 求和 |
| Today | 本地今日采集量 | Redis `daily_usage` 逐请求累加 |
| Limit | 生效限额 | min(2M, balance_at_sync + usage_at_sync − SMALL_BUFFER) |
**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.1 Redis key
| key | 读写方 | 说明 |
|---|---|---|
| `daily_usage:{北京日期}:{model}:{hash}` | LiteLLM 成功回调 INCR + 消耗任务 INCR | 当日采集量,TTL 48h,北京 00:00 滚动 |
| `limit:dynamic:volcengine:balance_at_sync:{model}` | 守护线程写 | 同步时点 API AvailableAmount 快照 |
| `limit:dynamic:volcengine:usage_at_sync:{model}` | 守护线程写 | 同步时点 Redis 累计消耗快照 |
| `consume:last_run:{日期}` | 消耗任务 | 23:00 当日去重,TTL 48h |
### 6.2 关键日志
- `🚫 [PRE-BLOCK] {model} daily limit reached ({usage}+{reserve}/{limit})`:请求前闸门 429
- `📊 [Usage] {model}:{hash}: +{tokens} (Today: X | BalAtSync: Y | Limit: Z)`:成功记账
- `🚫 [BLOCK] {model} Limit Hit: {current}/{limit}`:后置闸门触发,soft_evict
---
## 7. 限额方案演进
### 7.1 v2(原式,已被 v5/v6 继承和替代):`stable_limit = usage + balance − BUFFER`
- 意图:`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, 昨日采集量 × 系数)`
- 思路:把「返还 = 昨日采集量」当作「今日可采上限」
- **否决理由(用户反例)**:余额 2M、昨日用量 10K → 今日最多 10K?荒谬。
错误在于把**单日流量(昨日采集量)当存量(余额)** 用;昨日少用不损余额(守恒),
今日可用 = min(2M, 余额) ≈ 2M,与昨日无关
- 附:系数 <1.0 还会引发「今日少采 → 明日返还更少」的日复一日递减陷阱(0.9 系数 5 日降至 66%)
### 7.3 v4(2026-08-12 实施后当天发现错误,已回退):冻结快照
```
stable_limit = max(0, balance − BUFFER) # ❌ 错误:把「剩余余额」当「从 0 累计的上限」
```
- 设计初衷:余额 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` 桥接
### 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) |
|---|---|---|
| 高强度消耗 | 滞后窗口内消耗 > 30 万 → 击穿扣费 | 撞 B0+L 线:L ≤ 5 万安全,L > 5 万虚高 |
| 低强度消耗 | 浪费 30 万额度(实际只用了 5 万) | 只留 5 万 SMALL_BUFFER |
| 新包到账 | 下次同步 stable limit 跳升 | 下次同步 B0 跳升,一致 |
| 包过期 | 余额跳降,limit 偏高至下次同步 | 同左,但 SMALL_BUFFER=5 万风险窗口小 |
#### 遗留风险
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 或监控包过期时间。
### 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/字高估),方向安全。
---
## 8. 排障速查
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:00 直连 Ark 绕过 LiteLLM 闸门(防自杀),其自身 clamp:`plan ≤ 2M − usage`、
`plan ≤ 余额快照 − SMALL_BUFFER`,停止条件三闭环
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 滞后实测(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 生效)
- [ ] 消耗任务口径同步收紧(已改为 vls.SMALL_BUFFER,确认与闸门一致)
- [ ] 空窗期(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 对中文偏高对输入侧仍是安全方向)
- [ ] consume clamp2 余额快照口径不同步:`plan ≤ balance − SMALL_BUFFER` 用拉包时刻余额快照
(volc_consume.py:455),**不含 23:00→00:00 真实用户消耗**;clamp1 用共享 daily_usage 实时精准,
但余额维度两方不同步。深夜真实消耗大时两方合计可超烧余额。建议运行时重取余额或扣减预估
+609
View File
@@ -0,0 +1,609 @@
# ==========================================
# 🚀 VolcConsume — 火山免费额度自动消耗任务
# ==========================================
# 与 volc_limit_sync.py 合并方案的一部分(见 docs/merge-auto-consume.md):
# - 每日北京时间 23:00 由 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
#
# 手动运行:
# python volc_consume.py --dry-run # 只算计划不消耗
# python volc_consume.py --force # 忽略 23:00 时间限制立即执行
import os
import sys
import time
import math
import random
import uuid
import re
import traceback
from datetime import datetime, timezone
import requests
# 复用 volc_limit_sync 的拉包/归一化/Redis/日志/用量 key
import volc_limit_sync as vls
# ==========================================
# 🛠️ Configuration
# ==========================================
# Ark endpoint 单源:URL 直接拼自 HUOSHAN_BASE_URL(与 config.yaml 同一 env)
HUOSHAN_BASE_URL = os.getenv(
"HUOSHAN_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"
)
ARK_API_KEY = os.getenv("HUOSHAN_API_KEY", "")
# 采集上限:火山免费额度规则,每模型每天最多返还 2M(2026-08 确认所有模型统一)
CONSUME_DAILY_CAP = int(os.getenv("CONSUME_DAILY_CAP", "2000000"))
# 计划低于该阈值不消耗(避免无谓请求)
CONSUME_MIN_PLAN = int(os.getenv("CONSUME_MIN_PLAN", "50000"))
# 洗包窗口:ExpiryTime - now <= N 天的包视为"快过期"
CONSUME_MONITOR_DAYS = int(os.getenv("CONSUME_MONITOR_DAYS", "9"))
# 计划超额系数(旧脚本逻辑:多烧 10% 防积压)
PLAN_OVERAGE = 1.1
# 请求伪装参数(移植自 auto_consume.py)
MIN_INPUT_CHARS = 30000
MAX_INPUT_CHARS = 55000
MIN_SLEEP_BASE = 15
MAX_SLEEP_BASE = 40
MAX_RUNTIME_SECONDS = 21600 # 单模型任务最长 6h
MAX_CONSEC_FAILS = 5
# 任务提示词池(移植自 auto_consume config.py)
TASK_PROMPTS = [
{"sys": "你是资深运维专家。", "user": "请分析以下日志数据,找出潜在的风险点。"},
{"sys": "你是数据结构化助手。", "user": "请忽略乱码,提取以下文本中的关键信息。"},
{"sys": "你是安全审计员。", "user": "扫描以下文本,查找是否存在敏感操作记录。"},
{
"sys": "你是系统可靠性工程师。",
"user": "请判断以下日志片段中是否出现服务降级或异常重启的迹象。",
},
{"sys": "你是日志分析工具。", "user": "汇总以下日志中的错误级别事件,并给出时间分布。"},
{"sys": "你是性能优化顾问。", "user": "根据以下日志评估系统负载特征,指出瓶颈迹象。"},
]
# 日志源候选:宿主挂载(/host-var-log,docker-compose 只读挂载 /var/log)优先,
# 容器内 /var/log 兜底;均不存在时由合成语料兜底
LOG_SOURCES = [
"/host-var-log/syslog",
"/host-var-log/messages",
"/host-var-log/kern.log",
"/host-var-log/auth.log",
"/host-var-log/journal.log",
"/var/log/syslog",
"/var/log/messages",
"/var/log/kern.log",
"/var/log/auth.log",
]
# 单次运行内的语料游标(来源 -> 下一窗口起点),避免连续请求取到重复片段
_CURSOR = {}
# 敏感信息脱敏规则:(正则, 替换值)
_SENSITIVE_PATTERNS = [
(re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), "x.x.x.x"),
(re.compile(r"([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}"), "xx:xx:xx:xx:xx:xx"),
(re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+"), "user@example.com"),
(re.compile(r"(/home/|/Users/)[^\s\"':]+"), r"\1user"),
]
# ==========================================
# 📄 语料生成(移植自 auto_consume.py,原样保留)
# ==========================================
def _sanitize_line(line):
"""过滤日志行中的敏感信息(IP、MAC、邮箱、用户目录等)"""
for pattern, repl in _SENSITIVE_PATTERNS:
line = pattern.sub(repl, line)
return line
def _synthetic_lines(count=30000):
"""
生成时间线连续、格式多样的合成日志(语料不足时的兜底)。
覆盖最近 24 小时(count 行均匀分布),多主机多风格,模拟真实系统日志流。
"""
hosts = [
"web-01",
"web-02",
"web-03",
"api-01",
"api-02",
"db-01",
"cache-01",
"gateway-01",
"worker-01",
"k8s-node-01",
"mq-01",
"log-01",
]
templates = [
# 内核 / 网络
"kernel: [TCP] Possible SYN flooding on port {port}. Sending cookies.",
"kernel: EXT4-fs (sda1): mounted filesystem with ordered data mode",
"kernel: nf_conntrack: table full, dropping packet.",
"kernel: br0: port 2(veth{hash}) entered blocking state",
"NetworkManager[{pid}]: <info> device (eth0): state change: activated -> connected",
# 系统 / systemd / 安全
"systemd[{pid}]: Started Daily apt download and clean activities.",
"systemd[{pid}]: Stopped User Manager for UID 1000.",
"systemd[{pid}]: Reloading nginx.service.",
'cron[{pid}]: (root) CMD (find /var/log -name "*.gz" -mtime +7 -delete)',
"sudo: pam_unix(sudo:session): session opened for user deploy by (uid=0)",
"sshd: Accepted publickey for deploy from x.x.x.x port {port} ssh2",
"sshd[{pid}]: Failed password for invalid user admin from {ip} port {port} ssh2",
# 应用
"nginx: worker process, pid={pid}, GET /api/v2/status HTTP/1.1 200 0.00{ms}",
'nginx: {ip} - - ["GET /v1/users HTTP/1.1"] 200 512',
"python3[{pid}]: heartbeat ok, workers={n}, queue_depth={q}",
"gunicorn[{pid}]: POST /api/chat 200 {ms}ms",
"celery[{pid}]: Task mail.send[abc{hash}] succeeded in 12.3s",
"node[{pid}]: memory usage 62%, heap {heap}MB, gc paused {gc}ms",
"app[{pid}]: slow query detected: SELECT * FROM metrics WHERE ts > now() - interval '1h'",
# 数据库 / 中间件
"postgres[{pid}]: LOG: checkpoint complete: wrote {n} buffers ({p}%)",
"postgres[{pid}]: LOG: autovacuum: processing database postgres",
"redis-server[{pid}]: Saving DB to disk complete",
"redis-server[{pid}]: 100 changes in 60 seconds. Saving...",
"mysql[{pid}]: [Note] InnoDB: Buffer pool(s) load completed",
"rabbitmq[{pid}]: connection from {ip}:{port} opened",
# 日志轮转 / 采集
"logrotate: rotating /var/log/nginx/access.log, log->access.log.1",
"rsyslogd: action 'action 18' resumed",
"fluent-bit[{pid}]: [info] tail input plugin started",
]
# 24 小时覆盖,行间隔 ≈ 86400/count 秒
step = 86400.0 / count
start_ts = datetime.now().timestamp() - 86400.0
lines = []
for i in range(count):
ts = datetime.fromtimestamp(start_ts + i * step).strftime("%b %d %H:%M:%S")
host = random.choice(hosts)
tpl = random.choice(templates)
msg = tpl.format(
port=random.randint(1024, 65535),
pid=random.randint(500, 9999),
n=random.randint(10, 500),
q=random.randint(0, 200),
ms=random.randint(1, 99),
hash="%04x" % random.randint(0, 0xFFFF),
a=random.randint(0, 255),
b=random.randint(0, 255),
heap=random.randint(200, 900),
gc=random.randint(1, 60),
p=random.randint(1, 100),
ip=f"10.0.{random.randint(0, 255)}.{random.randint(1, 254)}",
)
lines.append(f"{ts} {host} {msg}")
return lines
def load_base_logs():
"""
加载日志语料(按来源分组,保留行序,去除相邻重复、过滤敏感信息)。
:return: (来源列表, {来源: 日志行列表})
"""
source_groups = {}
order = []
for log_path in LOG_SOURCES:
if os.path.exists(log_path) and os.access(log_path, os.R_OK):
try:
with open(log_path, "r", errors="ignore") as f:
lines = [l.strip() for l in f.readlines()[-8000:] if l.strip()]
if lines:
key = os.path.basename(log_path)
source_groups[key] = [_sanitize_line(l) for l in lines]
order.append(key)
except Exception as e:
vls.log(f"⚠️ 读取 {log_path} 失败: {e}", level="WARNING")
continue
# 语料不足 1000 行时,用时间线连续的合成日志补充
total_lines = sum(len(v) for v in source_groups.values())
if total_lines < 1000:
vls.log("📄 本地日志不足,使用合成日志补齐语料")
source_groups["synthetic"] = _synthetic_lines(30000)
order.append("synthetic")
# 每次运行重置语料游标(新语料从头取窗口,跨请求时间轴推进)
_CURSOR.clear()
# 去除相邻重复行(日志轮转/重复打印的常见现象)
for key in source_groups:
deduped = []
for line in source_groups[key]:
if not deduped or line != deduped[-1]:
deduped.append(line)
source_groups[key] = deduped
return order, source_groups
def generate_dynamic_payload(source_groups, order):
"""
生成随机会话头 + 时间线连贯的日志片段。
从单个来源中随机起点取连续窗口(保持时间线连续性),混入少量轮转标记。
"""
target_chars = random.randint(MIN_INPUT_CHARS, MAX_INPUT_CHARS)
available = [k for k in order if len(source_groups[k]) >= 50]
if not available:
available = [k for k in order]
source_key = random.choice(available)
source_lines = source_groups[source_key]
# 游标取窗口:单次运行内连续前进,避免重复片段,且时间轴随请求推进
#(语料按时间排序;耗尽后环绕,跨请求重复可接受,跨天语料重建)
total = len(source_lines)
window_size = min(total, 5000)
start_idx = _CURSOR.get(source_key, 0)
if start_idx + window_size > total:
start_idx = 0
_CURSOR[source_key] = (start_idx + window_size) % total
sampled = source_lines[start_idx : start_idx + window_size]
content_lines = []
total_chars = 0
for line in sampled:
content_lines.append(line)
total_chars += len(line) + 1
if total_chars >= target_chars:
break
markers = [
"-- Reboot --",
"-- 日志轮转,新文件开始 --",
"-- 会话超时,连接已断开 --",
"# 服务健康检查快照",
"",
]
for _ in range(random.randint(1, 3)):
if len(content_lines) > 10:
pos = random.randint(0, len(content_lines) - 1)
content_lines.insert(pos, random.choice(markers))
content = "\n".join(content_lines)
session_id = uuid.uuid4()
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
header = f"[LOG SESSION {session_id} | 来源: {source_key} | 采集时间: {ts} UTC]"
return f"{header}\n{content}\n[END OF SESSION {session_id}]"
# ==========================================
# 📊 消耗计划(旧 calculate_plan 核心 + v2 clamp)
# ==========================================
def _pkg_models(pkg):
"""解析资源包归属的监控模型(与 process_packages_breakdown 同口径)。"""
raw_code = pkg.get("ConfigurationCode", "")
raw_name = pkg.get("ConfigurationName", "Unknown")
target_raw = raw_code if raw_code else raw_name
norm = vls.normalize_volc_name(target_raw)
targets = vls.MONITORED_MODEL_ALIASES.get(norm, [norm])
if isinstance(targets, str):
targets = [targets]
return targets
def calculate_plan_for_model(packages, model):
"""
计算某模型的当日消耗计划与余额快照。
:return: (plan, details, balance) balance = 该模型全部 Effective token 包剩余
逻辑(保留旧脚本):
- 窗口内包(ExpiryTime - now ≤ CONSUME_MONITOR_DAYS):
days_left ≤ 1 → 冲刺全量;否则 remaining/ceil(days)×1.1,多包相加
- 窗口外包不消耗(留给真实用户,FIFO 下被自然消耗)
"""
total_plan = 0
balance = 0
details = []
now = datetime.now(timezone.utc)
for pkg in packages:
if model not in _pkg_models(pkg):
continue
if pkg.get("Status") != "Effective":
continue
unit = pkg.get("Unit", "token")
if str(unit).lower() != "token":
continue
try:
remaining = int(float(pkg.get("AvailableAmount", 0)))
except Exception:
continue
if remaining < 1000:
continue
balance += remaining
expiry_str = pkg.get("ExpiryTime", "")
if not expiry_str:
continue
try:
if expiry_str.endswith("Z"):
expiry_str = expiry_str[:-1] + "+00:00"
expiry_date = datetime.fromisoformat(expiry_str)
except Exception:
continue
days_left = (expiry_date - now).total_seconds() / 86400.0
if days_left <= 0 or days_left > CONSUME_MONITOR_DAYS:
continue
if days_left <= 1.0:
plan = remaining
strategy = "🚨 冲刺(最后24h)"
else:
div = math.ceil(days_left)
plan = int((remaining / div) * PLAN_OVERAGE)
if plan > remaining:
plan = remaining
strategy = f"📉 分{div}天(加量10%)"
if plan > 0:
total_plan += plan
details.append(
f" - [{pkg.get('ConfigurationName')}] 剩:{remaining:,} | 计划:{plan:,} | {strategy}"
)
return total_plan, details, balance
# ==========================================
# 🔌 Ark 直连请求
# ==========================================
def _send_ark(payload, timeout=120):
"""直连 Ark Chat Completions(Bearer token,不经过 LiteLLM)。
单源配置:endpoint 直接取 HUOSHAN_BASE_URL(与 config.yaml 同一 env),防 region 漂移。"""
url = f"{HUOSHAN_BASE_URL.rstrip('/')}/chat/completions"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {ARK_API_KEY}",
}
return requests.post(url, headers=headers, json=payload, timeout=timeout)
def _build_payload(model, source_groups, order):
"""构造消耗请求:日志语料 + 任务提示词。
模型参数与旧 MONITOR_TARGETS 实测配置一致:
- doubao-seed 系列 → max_completion_tokens + reasoning_effort minimal(火山方舟规范)
- 其他(glm/deepseek)→ max_tokens
"""
dynamic_input = generate_dynamic_payload(source_groups, order)
tpl = random.choice(TASK_PROMPTS)
payload = {
"model": model,
"messages": [
{"role": "system", "content": tpl["sys"]},
{
"role": "user",
"content": f"{tpl['user']}\n\n[DATA START]\n{dynamic_input}\n[DATA END]",
},
],
"stream": False,
}
if model.startswith("doubao"):
payload["reasoning_effort"] = "minimal"
payload["max_completion_tokens"] = 1000
else:
payload["max_tokens"] = 1000
return payload
# ==========================================
# 📈 用量记账(与 LiteLLM 回调同 key)
# ==========================================
def _usage_key(model):
return vls.get_usage_key(model, HUOSHAN_BASE_URL)
def _current_daily_usage(model):
if not vls.redis_client:
return 0
val = vls.redis_client.get(_usage_key(model))
return float(val or 0)
def _record_consumption(model, tokens):
if not vls.redis_client:
return
try:
key = _usage_key(model)
vls.redis_client.incrby(key, int(tokens))
vls.redis_client.expire(key, 172800)
except Exception as e:
vls.log(f"❌ 消耗记账失败: {e}", level="ERROR")
# ==========================================
# 🎯 单模型消耗执行
# ==========================================
def run_consume_model(model, source_groups, order, packages=None, dry_run=False):
"""
对单个监控模型执行"洗过期包"消耗。
目标 = Σ窗口内包计划(均分×1.1/冲刺),clamp:
- 采集上限:plan ≤ CONSUME_DAILY_CAP − 当日已消耗
- 防扣费:plan ≤ 余额快照 − SMALL_BUFFER
停止条件(每轮检查):daily_usage ≥ 2M / 余额快照−已烧 ≤ 0 / 已烧 ≥ plan
:param packages: 复用 run_consume_all 的一次拉包结果;None 时自行拉取
"""
if packages is None:
try:
packages = vls.fetch_all_packages()
except Exception as e:
vls.log(f"❌ [{model}] 拉取资源包失败: {e}", level="ERROR")
return
plan, details, balance = calculate_plan_for_model(packages, model)
for d in details:
vls.log(d)
if plan <= 0:
vls.log(f"💤 [{model}] 无快过期包,跳过")
return
usage_now = _current_daily_usage(model)
# clamp 1: 采集上限(超出部分不返还,白烧)
plan = min(plan, CONSUME_DAILY_CAP - usage_now)
# clamp 2: 余额快照 − SMALL_BUFFER(脚本自身不烧穿余额,防扣费)
plan = min(plan, max(0, balance - vls.SMALL_BUFFER))
plan = int(max(0, plan))
if plan < CONSUME_MIN_PLAN:
vls.log(f"💤 [{model}] 计划 {plan:,} < 阈值 {CONSUME_MIN_PLAN:,},跳过")
return
vls.log(
f"▶️ [{model}] 目标消耗 {plan:,} | 余额 {balance:,} | 当日已用 {usage_now:.0f}"
)
if dry_run:
vls.log(f"🔍 [dry-run] 仅计算,不发送请求")
return
_run_requests(model, plan, balance, source_groups, order)
def _run_requests(model, plan, balance_snapshot, source_groups, order):
"""
循环发送消耗请求。速率:目标 1 小时内完成(23:00 触发,00:00 结算)。
"""
burned = 0
fail_count = 0
deadline = time.time() + MAX_RUNTIME_SECONDS
# 速率三档(对齐旧 auto_consume run_stealthy_consume):
# 任务轻松(单次间隔 > MAX_SLEEP_BASE)→ 正常模式 15-40s 伪装,不赶时间
# 任务极重(间隔 < 2s)→ 极速模式 1-3s(本场景 plan≤2M 实际不会触发)
# 其余 → 动态 ±5s
# 目标:23:00 触发、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)
if interval > MAX_SLEEP_BASE:
cur_min, cur_max = MIN_SLEEP_BASE, MAX_SLEEP_BASE
mode_str = "正常模式 🐢"
elif interval < 2:
cur_min, cur_max = 1, 3
mode_str = "极速模式 🐇"
else:
cur_min, cur_max = max(1, interval - 5), interval + 5
mode_str = f"动态调整 🚗 ({cur_min}~{cur_max}s)"
vls.log(
f"⚡ 速率控制: {mode_str} | 预计 {int(est_requests)} 次请求 (单次 ≈{int(est_per_req):,} token)"
)
while burned < plan:
if time.time() >= deadline:
vls.log(f"⏰ [{model}] 已达运行时长上限,停止本次任务", level="WARNING")
break
if fail_count > MAX_CONSEC_FAILS:
vls.log(f"❌ [{model}] 失败次数过多,跳过剩余任务", level="ERROR")
break
# 停止条件 1: 采集上限
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")
break
# 停止条件 3: 计划完成
if burned >= plan:
break
payload = _build_payload(model, source_groups, order)
try:
resp = _send_ark(payload)
if resp.status_code == 200:
fail_count = 0
total = resp.json().get("usage", {}).get("total_tokens", 0)
if total > 0:
_record_consumption(model, total)
burned += total
percent = min(100, burned / plan * 100)
vls.log(
f"✨ [{model}] 进度 {percent:.1f}% | 本次 {total:,} | 累计 {burned:,}/{plan:,}"
)
if burned < plan:
time.sleep(random.randint(cur_min, cur_max))
else:
if resp.status_code == 401:
vls.log(f"❌ [{model}] 认证失败 (401),API Key 可能已失效,中止", level="ERROR")
break
fail_count += 1
backoff = min(60, 2**fail_count) + random.uniform(0, 1)
vls.log(
f"⏳ [{model}] API 错误 {resp.status_code},{backoff:.1f}s 后重试",
level="ERROR",
)
time.sleep(backoff)
except Exception as e:
fail_count += 1
vls.log(f"❌ [{model}] 请求异常: {e}", level="ERROR")
time.sleep(10)
vls.log(f"✅ [{model}] 消耗结束。实际消耗: {burned:,}/{plan:,}")
# ==========================================
# 🚀 总入口
# ==========================================
def run_consume_all(dry_run=False):
"""遍历所有监控模型执行消耗。模型列表复用 volc_limit_sync 的 config.yaml 发现。"""
if not ARK_API_KEY:
vls.log("❌ 未配置 HUOSHAN_API_KEY,无法消耗", level="ERROR")
return
if not vls.MONITORED_MODEL_PAIRS:
vls.discover_monitored_models()
models = sorted({m for m, _h in vls.MONITORED_MODEL_PAIRS})
if not models:
vls.log("❌ 未发现监控模型(config.yaml 无 volcengine 条目)", level="ERROR")
return
vls.log(f"📚 加载语料库...")
order, source_groups = load_base_logs()
vls.log(
f"📄 语料来源: {', '.join(f'{k}({len(v)}行)' for k, v in source_groups.items())}"
)
# 只拉一次资源包,全部模型复用(每模型独立 fetch 会重复分页 6 次)
try:
packages = vls.fetch_all_packages()
except Exception as e:
vls.log(f"❌ 拉取资源包失败: {e}", level="ERROR")
packages = []
for model in models:
try:
run_consume_model(model, source_groups, order, packages=packages, dry_run=dry_run)
except Exception as e:
vls.log(f"❌ [{model}] 消耗任务异常: {e}\n{traceback.format_exc()}", level="ERROR")
vls.log("🎉 消耗任务完成")
def main():
dry_run = "--dry-run" in sys.argv
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:
vls.log(
f"⏰ 当前北京时间 {beijing_hour} 时,非 23:00 触发窗口。加 --force 强制执行",
level="WARNING",
)
return
run_consume_all(dry_run=dry_run)
if __name__ == "__main__":
main()
+298 -248
View File
@@ -3,6 +3,8 @@ import sys
import time
import json
import hmac
import math
import random
import hashlib
import datetime
import requests
@@ -54,9 +56,12 @@ _original_print = print
def _tagged_print(*args, **kwargs):
# 统一北京时间戳(显式 UTC+8,与业务口径一致,不依赖容器 TZ)。
# 性能:µs 级/次,相对打印本身(含 flush)可忽略。
ts = time.strftime("%m-%d %H:%M:%S", time.gmtime(time.time() + 8 * 3600))
if args and isinstance(args[0], str):
args = (args[0].lstrip("\n"), *args[1:])
_original_print(LOG_TAG, *args, **kwargs)
_original_print(f"[{ts}] {LOG_TAG}", *args, **kwargs)
print = _tagged_print
@@ -88,10 +93,26 @@ HOST = "open.volcengineapi.com"
REGION = "cn-beijing"
# Daemon Config
BUFFER = 300000 # User requested 30w buffer
SYNC_INTERVAL = 60
SMALL_BUFFER = 50000 # 覆盖资源包过期/API 未结算等非消耗性余额跳降
CHECK_INTERVAL = 10
THRESHOLD = 300000
# 前置闸门预留量:请求开始判定 usage + reserve >= limit 即 429。
# reserve 优先取本次请求自带 max_tokens(真实上限,零采集损失),
# 未设置时 fallback 本常量。堵住"并发请求穿过闸门后集体撞线"的窗口。
PREBLOCK_RESERVE_TOKENS = int(os.getenv("PREBLOCK_RESERVE_TOKENS", "50000"))
# 思考模型输出侧兜底下限:deepseek-v4-flash 等默认思考模式,
# usage.total_tokens 含 reasoning tokens,且不受 max_tokens 限制
# (max_tokens 只约束正文 completion,思考 token 另计照常计费)。
# 短文本请求若客户端声明 max_tokens=16,reserve 输出侧仅 16,
# 实际消耗 ~150+(思考占大头),闸门形同虚设。取
# max(声明值, 本下限) 兜住思考开销;2M 限额下 4096 偏差仅 0.2%。
THINKING_RESERVE_FLOOR = int(os.getenv("THINKING_RESERVE_FLOOR", "4096"))
# stable_limit 缺失时(首次同步前/新增模型/TTL 过期)的保守兜底限额。
# 免费模型余额常态 < 2M(昨日采多少返多少),若 fallback 裸奔到采集上限 2M,
# 余额不足的模型会被烧穿扣费。宁可短时收紧,不可裸奔。
FALLBACK_LIMIT = int(os.getenv("FALLBACK_LIMIT", "900000"))
# Redis Config
REDIS_HOST = os.getenv(
@@ -146,13 +167,10 @@ MONITORED_MODEL_ALIASES = {}
# Format: "clean-model-name": limit_in_tokens
# Or with hash: "clean-model-name:hash": limit_in_tokens
HARD_DAILY_LIMITS = {
# Specific Limit for Doubao Code
"doubao-seed-code-preview-251028": 4_800_000,
# Specific Limits for Doubao Seed 2.0 models on HUOSHAN_BASE_URL
"doubao-seed-2-0-pro-260215:ba0383d3": 1_900_000,
"doubao-seed-2-0-lite-260215:ba0383d3": 1_900_000,
# Default Limit for all other models (Universal Fallback)
"__DEFAULT__": 1_800_000,
# 火山免费额度规则(2026-08):每模型每日采集上限 2M,次日按采集量发放资源包。
# 请求前闸门(async_pre_call_hook)已不滞后 → 无需打折(旧 1.8M/480 万为 post-hoc 时代遗产)。
# 统一 2M,余额相关的动态限额由 balance_at_sync - (usage_now - usage_at_sync) 实时。
"__DEFAULT__": 2_000_000,
}
# ==========================================
@@ -397,24 +415,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)
@@ -422,30 +443,34 @@ 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: If daemon hasn't calculated stable limit yet, use old logic?
# Or just use static. Safer to wait for daemon.
effective_limit = calculate_effective_limit(static_limit, 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)
# 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)
return effective_limit
@@ -532,10 +557,8 @@ def restore_blocked_models():
# Morning Window: 07:00 - 13:00
if 7 <= beijing_hour < 13:
interval = 1800 # 30 minutes
mode_str = "Morning/Window (30m)"
else:
interval = 14400 # 4 hours
mode_str = "Off-Peak (4h)"
if now - last_restore_check < interval:
return
@@ -560,6 +583,14 @@ def restore_blocked_models():
clean_model = clean_model_name(upstream_model)
original_api_base = backup.get("api_base")
# 只恢复监控内的火山条目(clean + 端点 hash),与 soft_evict/请求闸门口径一致,
# 避免按 clean 名误恢复同名不同端点(如 newapi 渠道)被踢的条目
if MONITORED_MODEL_PAIRS and (
clean_model,
generate_api_base_hash(params.get("api_base", "")),
) not in MONITORED_MODEL_PAIRS:
continue
# Check Quota
# Need current usage to calculate dynamic limit correctly?
# get_effective_limit handles fetching usage if None passed.
@@ -590,6 +621,65 @@ def restore_blocked_models():
log(f"❌ Restore check failed: {e}", level="ERROR")
def restore_model_if_healthy(clean_model, allowed_hashes=None):
"""
若指定模型已被软踢(_daily_limit_backup)且当前限额已恢复(usage < effective limit),
立即恢复其路由条目。供同步守护在写健康限额后调用,不等 restore_blocked_models 的
30min/4h 闸门;否则额度到账后模型会带额度空转被禁(有额度但 no healthy deployments)。
allowed_hashes: 限定匹配的 api_base hash 集合(None = 不限,按 clean 名匹配)。
返回恢复的条目数;任何异常 fail-safe 返回 0。
"""
if not LITELLM_AVAILABLE or not redis_client:
return 0
try:
from litellm.proxy.proxy_server import llm_router
if not llm_router or not llm_router.model_list:
return 0
restored = 0
for i, model_entry in enumerate(llm_router.model_list):
backup = model_entry.get("_daily_limit_backup")
if not backup:
continue
params = model_entry.get("litellm_params", {})
m_model = params.get("model", "")
if clean_model_name(m_model) != clean_model:
continue
m_base = params.get("api_base", "")
if allowed_hashes is not None and generate_api_base_hash(
m_base
) not in allowed_hashes:
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
if should_restore:
dprint(f"♻️ [RESTORE] Restoring {backup['model_name']} (Quota OK)")
llm_router.model_list[i]["model_name"] = backup["model_name"]
llm_router.model_list[i]["litellm_params"]["api_base"] = m_base
del llm_router.model_list[i]["_daily_limit_backup"]
restored += 1
if restored > 0:
log(f"✅ Restored {restored} entry/entries of {clean_model}.", level="INFO")
return restored
except Exception as e:
log(f"❌ Restore failed for {clean_model}: {e}", level="ERROR")
return 0
def startup_audit():
"""
🚀 Startup Audit:
@@ -626,6 +716,14 @@ def startup_audit():
if not clean_model:
continue
# 只审计监控内的火山模型(clean + 端点 hash),与请求闸门口径一致,
# 避免误判同名非监控渠道(如 newapi 别名模型)超限而误踢
if MONITORED_MODEL_PAIRS and (
clean_model,
generate_api_base_hash(api_base),
) not in MONITORED_MODEL_PAIRS:
continue
# 2. Check Limit & Usage
# We use effective limit logic which checks both static and dynamic
effective_limit = get_effective_limit(clean_model, api_base)
@@ -654,10 +752,12 @@ def startup_audit():
log(f"❌ Startup audit failed: {e}", level="ERROR")
def _check_request_block(model_name):
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。
"""
if not redis_client or not LITELLM_AVAILABLE:
@@ -698,7 +798,7 @@ def _check_request_block(model_name):
val = redis_client.get(usage_key)
usage = float(val or 0)
if usage >= float(effective_limit):
if usage + float(reserve) >= float(effective_limit):
return (clean, usage, effective_limit)
return None
@@ -737,13 +837,50 @@ class DailyLimitHandler(CustomLogger):
if not data or not data.get("model"):
return None
blocked = _check_request_block(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+)。
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):
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
clean, usage, limit = blocked
log(
f"🚫 [PRE-BLOCK] {data['model']} daily limit reached ({int(usage):,}/{int(limit):,})",
f"🚫 [PRE-BLOCK] {data['model']} daily limit reached ({int(usage):,}+{int(reserve):,}/{int(limit):,})",
level="WARNING",
)
raise RateLimitError(
@@ -799,18 +936,18 @@ class DailyLimitHandler(CustomLogger):
effective_limit = get_effective_limit(
clean_model, api_base, current_usage=current_total
)
display_limit = f"/{int(effective_limit):,}" if effective_limit else ""
limit_str = (
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}"
@@ -819,15 +956,11 @@ 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]
# Display Limit clearly
limit_str = (
f" | Limit: {int(effective_limit):,}" if effective_limit is not None else ""
)
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",
)
@@ -973,8 +1106,12 @@ def process_packages_breakdown(packages):
return model_counts
def active_monitor_loop():
# Config Discovery
def discover_monitored_models():
"""
🔎 从 config.yaml 自动发现纳入监控的火山模型,填充 MONITORED_MODEL_PAIRS /
MONITORED_MODEL_ALIASES。返回监控模型名 set。
独立函数化:volc_consume.py 手动运行(无守护线程)时也可复用。
"""
global MONITORED_MODEL_PAIRS, MONITORED_MODEL_ALIASES
MONITORED_MODEL_PAIRS.clear()
MONITORED_MODEL_ALIASES.clear()
@@ -1107,6 +1244,69 @@ def active_monitor_loop():
except Exception as e:
print(f"Config Auto-discovery failed / 配置自动发现失败: {e}", flush=True)
return MONITORED_MODELS
def _maybe_trigger_daily_consume():
"""
🕰️ 每日北京时间 23:00 触发免费额度消耗任务(洗过期资源包)。
- consume:last_run:{北京日期} 去重(SETNX,当天只触发一次)
- daemon 线程执行,不阻塞主循环;线程内 0-10 分钟随机抖动,避免固定 23:00 被风控识别
- 延迟 import volc_consume,避免模块级循环 import
"""
if not redis_client:
return
try:
now = time.time()
beijing_ts = now + (8 * 3600)
beijing_hour = time.gmtime(beijing_ts).tm_hour
if beijing_hour != 23:
return
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:
return # 今天已触发过
log("🚀 触发每日消耗任务(洗过期资源包防退费)...", level="INFO")
def _run():
try:
time.sleep(random.randint(0, 600))
import volc_consume # 延迟导入,避免模块级循环 import
volc_consume.run_consume_all()
except Exception as e:
log(f"❌ 消耗任务异常: {e}\n{traceback.format_exc()}", level="ERROR")
t = threading.Thread(target=_run, daemon=True)
t.start()
except Exception as e:
log(f"❌ 消耗任务调度失败: {e}", level="ERROR")
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_balance < 400_000:
base_interval = 600 # 低余额 10 minutes
else:
base_interval = 1800 # 常规 30 minutes
if is_idle:
interval = min(base_interval * 4, 3600)
if interval < 300:
interval = 300
else:
interval = base_interval
return interval
def active_monitor_loop():
# Config Discovery
MONITORED_MODELS = discover_monitored_models()
if not redis_client:
print(
"❌ Redis not connected. Exiting monitor loop / Redis 未连接,退出监控循环.",
@@ -1137,11 +1337,12 @@ def active_monitor_loop():
"Starting Active Monitor Loop (Safety-Margin + Idle Awareness) / 正在启动主动监控循环 (安全余量 + 空闲感知)...",
flush=True,
)
print(f"Buffer / 缓冲: {BUFFER}, Threshold / 阈值: {THRESHOLD}", flush=True)
print(f"SmallBuffer / 缓冲: {SMALL_BUFFER}", flush=True)
while True:
try:
time.sleep(CHECK_INTERVAL)
_maybe_trigger_daily_consume()
now = time.time()
# Enforce Beijing Time for daily operations
@@ -1167,10 +1368,10 @@ def active_monitor_loop():
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)
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
@@ -1187,41 +1388,10 @@ def active_monitor_loop():
(now - idle_start_time) > IDLE_THRESHOLD
)
# Dynamic Interval
# Logic: Adjust intervals based on distance from BUFFER
# User specified thresholds (Distance < X):
# < 10w -> 2min (120s)
# < 30w -> 5min (300s)
# < 50w -> 10min (600s)
# < 80w -> 30min (1800s)
# < 100w -> 60min (3600s)
# >= 100w -> 60min (3600s)
# Determine Base Sync Interval based on global risk (Optimized for Hourly Latency)
# Since API updates are hourly, frequent polling provides no new info and wastes resources.
# We trust Redis Usage for real-time gating.
# < 100k -> 5 min (Emergency check for the update moment)
# < 300k -> 10 min
# < 500k -> 20 min
# < 800k -> 45 min
# >= 800k -> 60 min
if global_min_remaining < 100_000:
base_interval = 300 # 5 minutes
elif global_min_remaining < 300_000:
base_interval = 600 # 10 minutes
elif global_min_remaining < 500_000:
base_interval = 1200 # 20 minutes
elif global_min_remaining < 800_000:
base_interval = 2700 # 45 minutes
else:
base_interval = 3600 # 60 minutes
if is_idle:
current_sync_interval = min(base_interval * 4, 3600)
if current_sync_interval < 300:
current_sync_interval = 300
else:
current_sync_interval = base_interval
# Dynamic Interval: 按最小正剩余余额分档(见 compute_sync_interval)
current_sync_interval = compute_sync_interval(
global_min_remaining, is_idle
)
# =====================================================
# 🚀 Per-Model Burst Detection (精细化突发流量检测)
@@ -1240,9 +1410,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.
@@ -1290,19 +1460,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(
@@ -1311,16 +1478,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(
@@ -1331,133 +1496,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}"
# ==========================================
# 🏔️ Daily Peak Lock (First-Sync + Balance-Gated Re-Lock)
# ==========================================
# 背景:AvailableAmount 是各资源包实例的实时剩余,但火山统计有
# 更新滞后(高用量时段会短暂冻结,实测 20 万 token 消耗内余额不动)。
# 旧逻辑把每次用量增长误判为"余额恢复"(Jump)锁更高峰值,
# 导致 stable_limit 越算越多(当天 399,836 -> 599,511 一路抬升)。
# 修正策略(日内限额单调可控):
# 1. 首次同步锁定当日峰值(= usage + balance 的总配额估计);
# 2. 仅当 balance 自身净上涨(新资源包发放/对账回补,≥50k)
# 时,按新增额度抬高峰值 —— 幅度 = 历史峰值 + 发放增量,
# 绝不把滞后虚高的 usage 一起算进去;
# 3. balance 回落(额度扣减追上用量)时 Drop 收紧;
# 4. usage 增长驱动的 pool 上涨一律不得抬高限额。
# 实测:协作奖励计划包每自然日发放、偶发一天 2 个,故必须有第 2 条;
# 次日 00:00 (CST) 自动重建当日峰值。
total_pool = current_usage + balance
peak_key = f"volc:peak_pool:{today_str}:{target}"
# Short-term memory (Last seen value) - 仅用于日志对比/增量计算
last_pool_key = f"volc:last_seen_pool:{target}"
last_pool_val = redis_client.get(last_pool_key)
last_pool = float(last_pool_val) if last_pool_val else 0.0
last_balance_key = f"volc:last_balance:{target}"
last_balance_val = redis_client.get(last_balance_key)
last_balance = float(last_balance_val) if last_balance_val else 0.0
# Long-term memory (Day's High Water Mark)
old_peak_val = redis_client.get(peak_key)
historical_peak = float(old_peak_val) if old_peak_val else 0.0
current_peak = historical_peak
# 1. INITIALIZATION (First sync of the day): lock once
if historical_peak == 0:
if DEBUG_MODE and last_pool > 0:
print(
f"DEBUG: 📌 Locking Daily Peak ({int(last_pool):,} -> {int(total_pool):,}) - First Sync Today.",
flush=True,
)
current_peak = total_pool
redis_client.setex(peak_key, 172800, int(current_peak))
# 2. RECHARGE (Balance-driven upward re-lock)
# 新资源包发放 / API 对账回补 → balance 自身净上涨 ≥50k。
# 只把"发放增量"加进已锁定的峰值,不重算整个滞后 pool。
elif last_balance > 0 and (balance - last_balance) >= 50000:
grant_delta = int(balance - last_balance)
current_peak = historical_peak + grant_delta
if DEBUG_MODE:
print(
f"DEBUG: 🆕 New Quota Detected (balance {int(last_balance):,} -> {int(balance):,}, +{grant_delta:,}) - Re-locking Daily Peak to {int(current_peak):,}.",
flush=True,
)
redis_client.setex(peak_key, 172800, int(current_peak))
# 3. DROP DETECTION (Resource Expiry / latency catch-up)
# pool 回落时收紧限额;回落之后保持不变,绝不回升。
elif total_pool < historical_peak and historical_peak > 0:
drop_ratio = (
historical_peak - total_pool
) / historical_peak
if DEBUG_MODE:
print(
f"DEBUG: 📉 Pool Drop Detected ({int(historical_peak):,} -> {int(total_pool):,}) - Ratio: {drop_ratio:.1%} - Adjusting Limit Down.",
flush=True,
)
current_peak = total_pool
redis_client.setex(peak_key, 172800, int(current_peak))
# 4. NO USAGE-DRIVEN RE-LOCK: 用量增长(balance 不变)不得抬高限额。
# (旧逻辑的 Bug 就在这里:把滞后虚高当余额恢复。)
# Always update "Last Seen" for next comparison
redis_client.setex(last_pool_key, 86400, int(total_pool))
redis_client.setex(last_balance_key, 86400, int(balance))
# Calculate Stable Limit
safety_margin = max(current_peak * 0.15, 250000)
stable_limit = int(max(0, current_peak - safety_margin))
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} PeakPool={int(current_peak):,} | 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,
)
# 即时恢复:同步发现限额已恢复(usage < effective limit)时立刻解除软踢,
restore_model_if_healthy(target, target_hashes)
# 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,
)
# 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.
@@ -1480,9 +1542,10 @@ def active_monitor_loop():
200000 # Pretend we have some quota to get ~5min interval
)
else:
# Afternoon/Night: Unlikely to reset
# 返还"不定时"(可能下午到账):All Dead 不能睡 60 分钟,
# 否则额度到账后最多空转 60 分钟才解锁。收紧到 30 分钟。
global_min_remaining = (
1000000 # Pretend high quota to sleep 60m
300000 # 30-minute sync while waiting for reset
)
else:
global_min_remaining = temp_min_remaining
@@ -1492,29 +1555,16 @@ def active_monitor_loop():
last_usage_map[m] = u_val
last_sync_time = now
if global_min_remaining > 800000:
print(
f"🌊 High Tide Mode: Sleeping for 60m (Effective Min Rem: {int(global_min_remaining):,}) / 高水位模式:休眠 60 分钟",
flush=True,
)
elif global_min_remaining > 500000:
print(
f"🌊 Normal Mode: Sleeping for 30m (Min Rem: {int(global_min_remaining):,}) / 正常模式:休眠 30 分钟",
flush=True,
)
elif global_min_remaining > 300000:
print(
f"🚤 Speed Mode: Sleeping for 10m (Min Rem: {int(global_min_remaining):,}) / 极速模式:休眠 10 分钟",
flush=True,
)
elif global_min_remaining > 100000:
print(
f"⛵ Safe Sailing Mode: Sleeping for 5m (Min Rem: {int(global_min_remaining):,}) / 安全航行模式:休眠 5 分钟",
flush=True,
)
if global_min_remaining <= 100000:
mode_icon = "🆘 紧急模式"
elif global_min_remaining <= 300000:
mode_icon = "🚨 低余额模式"
else:
mode_icon = "ℹ️ 常规模式"
# 用本次同步后的新 Min Rem 重算,避免"旧 interval + 新 Min Rem"混打误导
next_interval = compute_sync_interval(global_min_remaining, is_idle)
print(
f"🆘 Emergency Mode: Sleeping for 2m (Min Rem: {int(global_min_remaining):,}) / 紧急模式:休眠 2 分钟",
f"{mode_icon}: 下一次同步 {next_interval}s (Min Rem: {int(global_min_remaining):,}, {'空闲' if is_idle else '活跃'}) / 同步间隔 {next_interval}s",
flush=True,
)