diff --git a/docker-compose.yml b/docker-compose.yml index 3edd339..27c8be7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,7 +50,9 @@ 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 # 配置详解: diff --git a/docs/merge-auto-consume.md b/docs/merge-auto-consume.md index 1eb9bc8..3e40c01 100644 --- a/docs/merge-auto-consume.md +++ b/docs/merge-auto-consume.md @@ -196,7 +196,7 @@ HARD_DAILY_LIMITS → 统一 2M(采集上限;doubao-seed-code-preview-251028 - 消耗任务 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`——同一把尺子。 diff --git a/volc_limit_sync.py b/volc_limit_sync.py index f4219de..8cac368 100644 --- a/volc_limit_sync.py +++ b/volc_limit_sync.py @@ -109,10 +109,12 @@ PREBLOCK_RESERVE_TOKENS = int(os.getenv("PREBLOCK_RESERVE_TOKENS", "50000")) # 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")) +# stable_limit 缺失时(首次同步前/新增模型/TTL 过期/Redis 数据丢失)的保守兜底限额。 +# 免费池常态 ~2M(首开赠送/昨日采多少返多少),若 fallback 裸奔到采集上限 2M, +# 余额不足或已耗尽的模型会被烧穿扣费。900k 对 0 余额模型仍是 9 倍优惠, +# 收紧到 20 万(2M 的 10%,单次消耗语料 5~11 万不受影响):宁可短时错杀大请求, +# 不可漏放大烧;快照由守护线程恢复后立即回到真实限额。 +FALLBACK_LIMIT = int(os.getenv("FALLBACK_LIMIT", "200000")) # Redis Config REDIS_HOST = os.getenv( @@ -857,9 +859,11 @@ 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。 + 返回 (clean_model, usage, limit) 表示超限,None 表示放行。 + fail-closed:Redis 不可用 / 限额获取失败 / 读取异常时,对监控条目返回 + (clean_model, -1, -1) 标记拒绝(宁可错杀,不可漏放);非监控条目不受影响。 """ - if not redis_client or not LITELLM_AVAILABLE: + if not LITELLM_AVAILABLE: return None try: from litellm.proxy.proxy_server import llm_router @@ -889,9 +893,24 @@ 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) + 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) @@ -907,8 +926,11 @@ def _check_request_block(model_name, reserve=0.0): 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): @@ -1000,6 +1022,18 @@ class DailyLimitHandler(CustomLogger): return None clean, usage, limit = blocked + if usage < 0 or limit < 0: + # 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", @@ -1464,9 +1498,15 @@ def active_monitor_loop(): ) 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()