修复: 熔断耗尽渠道并统一额度阀

This commit is contained in:
zizi 2026-07-20 20:59:35 +08:00
parent f805b00dd1
commit efb2b3e781
2 changed files with 174 additions and 17 deletions

View File

@ -29,6 +29,12 @@ MINIMAX_MODELS = {"MiniMax-M3", "MiniMax-M2.7"} # 计入每窗预算的
BUDGET_CHAIN = ["MiniMax-M3", "MiniMax-M2.7", "glm-5.2", "deepseek-v4-flash"] # 全局统一降级链 BUDGET_CHAIN = ["MiniMax-M3", "MiniMax-M2.7", "glm-5.2", "deepseek-v4-flash"] # 全局统一降级链
WINDOW_BUDGET_USD = 24.0 # 每窗 MiniMax 花费上限(创始人 2026-07-18 提额 $10→$24),超则切 glm-5.2→deepseek WINDOW_BUDGET_USD = 24.0 # 每窗 MiniMax 花费上限(创始人 2026-07-18 提额 $10→$24),超则切 glm-5.2→deepseek
WINDOW_CALL_CAP = 6000 # 每窗全模型调用上限(创始人 2026-07-18 提额 4000→6000),达则自动睡到下一窗续跑 WINDOW_CALL_CAP = 6000 # 每窗全模型调用上限(创始人 2026-07-18 提额 4000→6000),达则自动睡到下一窗续跑
# 上游实测上限:请求前主动裁剪,避免依赖不同渠道含混甚至错误的 HTTP 400 文案再猜测重发。
# M3 / deepseek 未观察到该限制,故不在表内、不主动裁剪。
MODEL_MAX_TOKENS = {
"MiniMax-M2.7": 196608,
"glm-5.2": 12000,
}
# 费率兜底(model_ratio, completion_ratio, cache_ratio),与 New-API /api/pricing 一致(2026-07-16 快照) # 费率兜底(model_ratio, completion_ratio, cache_ratio),与 New-API /api/pricing 一致(2026-07-16 快照)
PRICING_FALLBACK = { PRICING_FALLBACK = {
"MiniMax-M3": (0.15, 4.0, 0.2), "MiniMax-M3": (0.15, 4.0, 0.2),
@ -45,14 +51,21 @@ class SensitiveError(Exception):
立即抛给上层走模型降级链(创始人 2026-07-14:M3→MiniMax-M2.7→deepseek-v4-flash)。""" 立即抛给上层走模型降级链(创始人 2026-07-14:M3→MiniMax-M2.7→deepseek-v4-flash)。"""
class PlanQuotaExhausted(Exception):
"""上游模型渠道的 Token Plan 已耗尽。
该错误在同一额度窗内重试不会恢复,必须立即交给治理层熔断当前模型;它与普通限流 429
不同,普通 429 仍保留指数退避重试。"""
def chat(prompt, model=DEFAULT_MODEL, max_tokens=512000, temperature=0.2, def chat(prompt, model=DEFAULT_MODEL, max_tokens=512000, temperature=0.2,
retries=2, timeout=900, system=None, top_p=None): retries=2, timeout=900, system=None, top_p=None):
"""单轮对话,返回 (content, usage)。网络错/5xx/429 指数退避重试。 """单轮对话,返回 (content, usage)。网络错/5xx/普通 429 指数退避重试。
content 已剥离 <think>…</think>(推理模型可能把思考混进正文)。 content 已剥离 <think>…</think>(推理模型可能把思考混进正文)。
system:身份段与任务材料分离(角色遵从更稳、身份段利于上游缓存)。 system:身份段与任务材料分离(角色遵从更稳、身份段利于上游缓存)。
top_p:随 temperature 分化实验用(M 家族官方推荐 1.0/0.95,eval A/B 后定版)。 top_p:随 temperature 分化实验用(M 家族官方推荐 1.0/0.95,eval A/B 后定版)。
max_tokens 默认 512000(创始人 2026-07-15 拍板:不设人为输出限制)。 max_tokens 默认 512000;仅对有实测硬上限的 M2.7/GLM 请求前主动裁剪。
预扣费机制备忘:New-API 按 max_tokens 预扣(512k 预扣 $0.15375/次,网关已验证接受该值; 预扣费机制备忘:New-API 按 max_tokens 预扣(512k 预扣 $0.15375/次,网关已验证接受该值;
结算按实际用量,余额充足时预扣不产生额外成本)——**余额须 ≥ 并发路数 × $0.154**, 结算按实际用量,余额充足时预扣不产生额外成本)——**余额须 ≥ 并发路数 × $0.154**,
否则触发 403「预扣费额度失败」(2026-07-15 余额见底实测坐实此机制)。 否则触发 403「预扣费额度失败」(2026-07-15 余额见底实测坐实此机制)。
@ -61,10 +74,15 @@ def chat(prompt, model=DEFAULT_MODEL, max_tokens=512000, temperature=0.2,
s.trust_env = False # 本机代理 env 会劫持内网直连 s.trust_env = False # 本机代理 env 会劫持内网直连
messages = ([{"role": "system", "content": system}] if system else []) \ messages = ([{"role": "system", "content": system}] if system else []) \
+ [{"role": "user", "content": prompt}] + [{"role": "user", "content": prompt}]
model_cap = MODEL_MAX_TOKENS.get(model)
effective_max_tokens = min(max_tokens, model_cap) if model_cap is not None else max_tokens
if effective_max_tokens != max_tokens:
print(f"[llm] {model} max_tokens={max_tokens} 主动裁为模型上限 {effective_max_tokens}",
file=sys.stderr)
payload = { payload = {
"model": model, "model": model,
"messages": messages, "messages": messages,
"max_tokens": max_tokens, "max_tokens": effective_max_tokens,
"temperature": temperature, "temperature": temperature,
} }
if top_p is not None: if top_p is not None:
@ -76,17 +94,9 @@ def chat(prompt, model=DEFAULT_MODEL, max_tokens=512000, temperature=0.2,
r = s.post(f"{BASE}/v1/chat/completions", r = s.post(f"{BASE}/v1/chat/completions",
headers={"Authorization": f"Bearer {TOKEN}"}, headers={"Authorization": f"Bearer {TOKEN}"},
json=payload, timeout=timeout) json=payload, timeout=timeout)
# 各模型 max_tokens 上限不一(实测 M3 收 512k、M2.7 上限 196608 报 400): # Token Plan 耗尽不是瞬时限流:同模型退避只会白等 8/16 秒,立即交治理层按窗熔断。
# 撞上限自适应降档重发,保证降级链换模型时不被参数掀翻。 if r.status_code == 429 and "Token Plan 用量上限" in r.text:
# 必须在敏感检查之前处理——重发后的响应仍要完整走敏感/429/5xx 判定链 raise PlanQuotaExhausted(f"Token Plan 已耗尽 HTTP 429: {r.text[:200]}")
if r.status_code == 400 and "max tokens" in r.text.lower():
cur = payload.get("max_tokens", 0)
payload["max_tokens"] = 196608 if cur > 196608 else 12000
print(f"[llm] {model} max_tokens={cur} 超模型上限,降为 {payload['max_tokens']} 重发",
file=sys.stderr)
r = s.post(f"{BASE}/v1/chat/completions",
headers={"Authorization": f"Bearer {TOKEN}"},
json=payload, timeout=timeout)
# 内容安全拦截:同模型退避重试必再敏感,立即抛 SensitiveError 交上层 # 内容安全拦截:同模型退避重试必再敏感,立即抛 SensitiveError 交上层
# 降级换模型,不在此浪费退避(否则一敏感章空烧 3 次,实测占放量请求 23%) # 降级换模型,不在此浪费退避(否则一敏感章空烧 3 次,实测占放量请求 23%)
if r.status_code >= 500 and "sensitive" in r.text.lower(): if r.status_code >= 500 and "sensitive" in r.text.lower():
@ -148,6 +158,16 @@ def extract_json(text):
# 全窗调用达 6000 次自动睡到下一窗续跑——降级策略只此一处,调用方只管拿结果。 # 全窗调用达 6000 次自动睡到下一窗续跑——降级策略只此一处,调用方只管拿结果。
_PRICING_CACHE = None _PRICING_CACHE = None
# 仅保存当前额度窗内已确认 Token Plan 耗尽的模型。进程重启会自然重探;跨窗也会清空重探。
_PLAN_QUOTA_OPEN = {}
def _plan_quota_open_models(wk):
"""返回当前窗已熔断模型集合,并清除其他窗口的陈旧状态。"""
stale = [key for key in _PLAN_QUOTA_OPEN if key != wk]
for key in stale:
del _PLAN_QUOTA_OPEN[key]
return _PLAN_QUOTA_OPEN.setdefault(wk, set())
def get_pricing(): def get_pricing():
@ -267,7 +287,7 @@ def chat_governed(prompt, model=DEFAULT_MODEL, system=None, max_tokens=512000,
策略(每次调用前): 策略(每次调用前):
1) 读本窗账本;本窗 total_calls ≥ WINDOW_CALL_CAP → 打日志、睡到下一窗边界(不持DB连接)、重读续跑; 1) 读本窗账本;本窗 total_calls ≥ WINDOW_CALL_CAP → 打日志、睡到下一窗边界(不持DB连接)、重读续跑;
2) 本窗 minimax_usd ≥ WINDOW_BUDGET_USD → 降级链去掉 MiniMax 前缀(只剩 glm-5.2→deepseek),否则用全链; 2) 本窗 minimax_usd ≥ WINDOW_BUDGET_USD → 降级链去掉 MiniMax 前缀(只剩 glm-5.2→deepseek),否则用全链;
3) 沿链逐个模型调 chat():撞 SensitiveError 或 RuntimeError(重试耗尽/不可用) → 换下一个;成功即止; 3) 跳过本窗已确认 Token Plan 耗尽的模型;其余模型沿链调用,敏感/不可用时换下一个;成功即止;
4) 成功后 _bump_window(本窗, MiniMax模型才计成本否则0),返回三元组;全链失败返回 (None,None,None)。""" 4) 成功后 _bump_window(本窗, MiniMax模型才计成本否则0),返回三元组;全链失败返回 (None,None,None)。"""
from datetime import timedelta from datetime import timedelta
while True: while True:
@ -291,11 +311,21 @@ def chat_governed(prompt, model=DEFAULT_MODEL, system=None, max_tokens=512000,
f"本窗改用非 MiniMax 链 {chain}", file=sys.stderr) f"本窗改用非 MiniMax 链 {chain}", file=sys.stderr)
else: else:
chain = list(BUDGET_CHAIN) chain = list(BUDGET_CHAIN)
plan_quota_open = _plan_quota_open_models(wk)
skipped = [m for m in chain if m in plan_quota_open]
if skipped:
print(f"[llm] 本窗 {wk} 跳过 Token Plan 已耗尽模型 {skipped}", file=sys.stderr)
chain = [m for m in chain if m not in plan_quota_open]
# 3) 沿链逐个模型调用;撞敏感/不可用换下一个 # 3) 沿链逐个模型调用;撞敏感/不可用换下一个
for m in chain: for m in chain:
try: try:
content, usage = chat(prompt, model=m, system=system, max_tokens=max_tokens, content, usage = chat(prompt, model=m, system=system, max_tokens=max_tokens,
temperature=temperature, top_p=top_p) temperature=temperature, top_p=top_p)
except PlanQuotaExhausted as e:
plan_quota_open.add(m)
print(f"[llm] 治理链 {m} Token Plan 本窗耗尽,立即熔断并降级下一个:{str(e)[:80]}",
file=sys.stderr)
continue
except (SensitiveError, RuntimeError) as e: except (SensitiveError, RuntimeError) as e:
print(f"[llm] 治理链 {m} 失败({type(e).__name__}: {str(e)[:80]}),降级下一个", print(f"[llm] 治理链 {m} 失败({type(e).__name__}: {str(e)[:80]}),降级下一个",
file=sys.stderr) file=sys.stderr)

View File

@ -3,7 +3,8 @@
不碰真 DB、不发真请求、不做任何真实 LLM 调用。直接 `.venv/bin/python test_quota.py` 跑(无需 pytest)。 不碰真 DB、不发真请求、不做任何真实 LLM 调用。直接 `.venv/bin/python test_quota.py` 跑(无需 pytest)。
覆盖:window_key 归窗 / seconds_to_next_window 边界 / cost_usd 计价(含缓存折扣) / 覆盖:window_key 归窗 / seconds_to_next_window 边界 / cost_usd 计价(含缓存折扣) /
chat_governed 六条路由(首选成功、$24 预算耗尽切链、敏感换模型、不可用换模型、全链失败、6000 次达上限睡窗)。""" 模型 max_tokens 主动上限 / Plan 429 不退避与按窗熔断 / 普通 429 重试 /
chat_governed 六条既有路由(首选成功、预算降级、敏感换模型、不可用换模型、全链失败、调用上限睡窗)。"""
import pathlib import pathlib
import sys import sys
import types import types
@ -34,8 +35,9 @@ def _install(now, reads, chat, sleep=None):
return (add, 1) # 返回值 chat_governed 不消费,占位即可 return (add, 1) # 返回值 chat_governed 不消费,占位即可
llm._bump_window = fake_bump llm._bump_window = fake_bump
llm.chat = chat llm.chat = chat
llm._PLAN_QUOTA_OPEN.clear()
# 只替换 time.sleep(chat 被打桩后 chat_governed 路径不再用 time.time),不污染真 time 模块 # 只替换 time.sleep(chat 被打桩后 chat_governed 路径不再用 time.time),不污染真 time 模块
llm.time = types.SimpleNamespace(sleep=(sleep if sleep else (lambda s: None))) llm.time = types.SimpleNamespace(sleep=(sleep if sleep else (lambda s: None)), time=lambda: 0)
return bumps return bumps
@ -84,6 +86,129 @@ def test_current_quota_policy():
assert llm.WINDOW_CALL_CAP == 6000, "当前每窗总调用上限必须是 6000" assert llm.WINDOW_CALL_CAP == 6000, "当前每窗总调用上限必须是 6000"
class _FakeResponse:
"""只实现 chat() 所需的最小 requests.Response 接口。"""
def __init__(self, status_code, text="", data=None):
self.status_code = status_code
self.text = text
self._data = data
def raise_for_status(self):
if self.status_code >= 400:
raise llm.requests.HTTPError(f"HTTP {self.status_code}: {self.text}")
def json(self):
return self._data
def _success_response():
return _FakeResponse(200, data={
"choices": [{"message": {"content": "ok"}, "finish_reason": "stop"}],
"usage": dict(_USAGE),
})
def _run_chat_with_responses(model, responses, max_tokens=512000, retries=2):
"""替换 HTTP 会话并执行一次 chat,返回(结果、请求 payload、sleep 记录)。"""
posts = []
sleeps = []
queue = list(responses)
class FakeSession:
trust_env = True
def post(self, url, headers=None, json=None, timeout=None):
posts.append(dict(json))
return queue.pop(0)
old_session, old_time = llm.requests.Session, llm.time
try:
llm.requests.Session = FakeSession
llm.time = types.SimpleNamespace(time=lambda: 0, sleep=lambda secs: sleeps.append(secs))
result = llm.chat("p", model=model, max_tokens=max_tokens, retries=retries)
return result, posts, sleeps
finally:
llm.requests.Session, llm.time = old_session, old_time
def test_plan_429_does_not_retry():
"""Token Plan 429 必须首响应立即抛出,不进入 8/16 秒退避。"""
body = '{"error":{"message":"已达到 Token Plan 用量上限:请升级套餐"}}'
posts = []
sleeps = []
class FakeSession:
trust_env = True
def post(self, url, headers=None, json=None, timeout=None):
posts.append(dict(json))
return _FakeResponse(429, text=body)
old_session, old_time = llm.requests.Session, llm.time
try:
llm.requests.Session = FakeSession
llm.time = types.SimpleNamespace(time=lambda: 0, sleep=lambda secs: sleeps.append(secs))
try:
llm.chat("p", model="MiniMax-M3", retries=2)
raise AssertionError("Plan 429 应抛 PlanQuotaExhausted")
except llm.PlanQuotaExhausted:
pass
finally:
llm.requests.Session, llm.time = old_session, old_time
assert len(posts) == 1, f"Plan 429 不应重试,实发 {len(posts)} 次"
assert sleeps == [], f"Plan 429 不应退避,实睡 {sleeps}"
def test_regular_429_still_retries():
"""普通速率限制 429 仍按原策略退避重试,不能误触发 Plan 熔断。"""
result, posts, sleeps = _run_chat_with_responses(
"MiniMax-M3",
[_FakeResponse(429, text='{"error":{"message":"rate limit"}}'), _success_response()],
retries=2,
)
assert result[0] == "ok", "普通 429 后应重试成功"
assert len(posts) == 2, f"普通 429 应重试一次,实发 {len(posts)} 次"
assert sleeps == [8], f"第一次普通 429 应退避 8 秒,实得 {sleeps}"
def test_model_max_tokens_caps():
"""只主动裁 M2.7/GLM;M3/deepseek 保留调用方请求值。"""
cases = [
("MiniMax-M2.7", 196608),
("glm-5.2", 12000),
("MiniMax-M3", 512000),
("deepseek-v4-flash", 512000),
]
for model, expected in cases:
_, posts, _ = _run_chat_with_responses(model, [_success_response()])
assert posts[0]["max_tokens"] == expected, \
f"{model} max_tokens 应为 {expected},实得 {posts[0]['max_tokens']}"
def test_plan_quota_breaker_same_window_and_recovery():
"""M3/M2.7 同窗各探一次后跳过;跨窗清空熔断并从 M3 重探。"""
clock = {"t": datetime(2026, 7, 16, 12, 0)}
calls = []
def chat(prompt, model=None, **kw):
calls.append((llm.window_key(clock["t"]), model))
if llm.window_key(clock["t"]) == "2026-07-16T10" and model in llm.MINIMAX_MODELS:
raise llm.PlanQuotaExhausted("plan exhausted")
return "ok", dict(_USAGE)
_install(lambda: clock["t"], lambda wk: (0.0, 0), chat)
assert llm.chat_governed("first")[2] == "glm-5.2"
assert llm.chat_governed("second")[2] == "glm-5.2"
old_window_models = [m for wk, m in calls if wk == "2026-07-16T10"]
assert old_window_models == ["MiniMax-M3", "MiniMax-M2.7", "glm-5.2", "glm-5.2"], \
f"同窗 MiniMax 应各探一次后跳过,实得 {old_window_models}"
clock["t"] = datetime(2026, 7, 16, 15, 0)
assert llm.chat_governed("third")[2] == "MiniMax-M3"
assert calls[-1] == ("2026-07-16T15", "MiniMax-M3"), "跨窗后应清空熔断并重探 M3"
def test_route_first_success(): def test_route_first_success():
"""①usd=0/calls=0:链首 M3 成功 → 返回 MiniMax-M3、记账 usd>0、bump 一次到本窗。""" """①usd=0/calls=0:链首 M3 成功 → 返回 MiniMax-M3、记账 usd>0、bump 一次到本窗。"""
calls = [] calls = []
@ -186,6 +311,8 @@ def test_route_call_cap_sleep():
def main(): def main():
tests = [test_window_key, test_seconds_to_next_window, test_cost_usd, tests = [test_window_key, test_seconds_to_next_window, test_cost_usd,
test_current_quota_policy, test_current_quota_policy,
test_plan_429_does_not_retry, test_regular_429_still_retries,
test_model_max_tokens_caps, test_plan_quota_breaker_same_window_and_recovery,
test_route_first_success, test_route_budget_exhausted, test_route_first_success, test_route_budget_exhausted,
test_route_sensitive_then_next, test_route_runtime_then_next, test_route_sensitive_then_next, test_route_runtime_then_next,
test_route_all_fail, test_route_call_cap_sleep] test_route_all_fail, test_route_call_cap_sleep]