修复: 熔断耗尽渠道并统一额度阀
This commit is contained in:
parent
f805b00dd1
commit
efb2b3e781
@ -29,6 +29,12 @@ MINIMAX_MODELS = {"MiniMax-M3", "MiniMax-M2.7"} # 计入每窗预算的
|
||||
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_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 快照)
|
||||
PRICING_FALLBACK = {
|
||||
"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)。"""
|
||||
|
||||
|
||||
class PlanQuotaExhausted(Exception):
|
||||
"""上游模型渠道的 Token Plan 已耗尽。
|
||||
|
||||
该错误在同一额度窗内重试不会恢复,必须立即交给治理层熔断当前模型;它与普通限流 429
|
||||
不同,普通 429 仍保留指数退避重试。"""
|
||||
|
||||
|
||||
def chat(prompt, model=DEFAULT_MODEL, max_tokens=512000, temperature=0.2,
|
||||
retries=2, timeout=900, system=None, top_p=None):
|
||||
"""单轮对话,返回 (content, usage)。网络错/5xx/429 指数退避重试。
|
||||
"""单轮对话,返回 (content, usage)。网络错/5xx/普通 429 指数退避重试。
|
||||
|
||||
content 已剥离 <think>…</think>(推理模型可能把思考混进正文)。
|
||||
system:身份段与任务材料分离(角色遵从更稳、身份段利于上游缓存)。
|
||||
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/次,网关已验证接受该值;
|
||||
结算按实际用量,余额充足时预扣不产生额外成本)——**余额须 ≥ 并发路数 × $0.154**,
|
||||
否则触发 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 会劫持内网直连
|
||||
messages = ([{"role": "system", "content": system}] if system else []) \
|
||||
+ [{"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 = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"max_tokens": effective_max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
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",
|
||||
headers={"Authorization": f"Bearer {TOKEN}"},
|
||||
json=payload, timeout=timeout)
|
||||
# 各模型 max_tokens 上限不一(实测 M3 收 512k、M2.7 上限 196608 报 400):
|
||||
# 撞上限自适应降档重发,保证降级链换模型时不被参数掀翻。
|
||||
# 必须在敏感检查之前处理——重发后的响应仍要完整走敏感/429/5xx 判定链
|
||||
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)
|
||||
# Token Plan 耗尽不是瞬时限流:同模型退避只会白等 8/16 秒,立即交治理层按窗熔断。
|
||||
if r.status_code == 429 and "Token Plan 用量上限" in r.text:
|
||||
raise PlanQuotaExhausted(f"Token Plan 已耗尽 HTTP 429: {r.text[:200]}")
|
||||
# 内容安全拦截:同模型退避重试必再敏感,立即抛 SensitiveError 交上层
|
||||
# 降级换模型,不在此浪费退避(否则一敏感章空烧 3 次,实测占放量请求 23%)
|
||||
if r.status_code >= 500 and "sensitive" in r.text.lower():
|
||||
@ -148,6 +158,16 @@ def extract_json(text):
|
||||
# 全窗调用达 6000 次自动睡到下一窗续跑——降级策略只此一处,调用方只管拿结果。
|
||||
|
||||
_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():
|
||||
@ -267,7 +287,7 @@ def chat_governed(prompt, model=DEFAULT_MODEL, system=None, max_tokens=512000,
|
||||
策略(每次调用前):
|
||||
1) 读本窗账本;本窗 total_calls ≥ WINDOW_CALL_CAP → 打日志、睡到下一窗边界(不持DB连接)、重读续跑;
|
||||
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)。"""
|
||||
from datetime import timedelta
|
||||
while True:
|
||||
@ -291,11 +311,21 @@ def chat_governed(prompt, model=DEFAULT_MODEL, system=None, max_tokens=512000,
|
||||
f"本窗改用非 MiniMax 链 {chain}", file=sys.stderr)
|
||||
else:
|
||||
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) 沿链逐个模型调用;撞敏感/不可用换下一个
|
||||
for m in chain:
|
||||
try:
|
||||
content, usage = chat(prompt, model=m, system=system, max_tokens=max_tokens,
|
||||
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:
|
||||
print(f"[llm] 治理链 {m} 失败({type(e).__name__}: {str(e)[:80]}),降级下一个",
|
||||
file=sys.stderr)
|
||||
|
||||
@ -3,7 +3,8 @@
|
||||
不碰真 DB、不发真请求、不做任何真实 LLM 调用。直接 `.venv/bin/python test_quota.py` 跑(无需 pytest)。
|
||||
|
||||
覆盖:window_key 归窗 / seconds_to_next_window 边界 / cost_usd 计价(含缓存折扣) /
|
||||
chat_governed 六条路由(首选成功、$24 预算耗尽切链、敏感换模型、不可用换模型、全链失败、6000 次达上限睡窗)。"""
|
||||
模型 max_tokens 主动上限 / Plan 429 不退避与按窗熔断 / 普通 429 重试 /
|
||||
chat_governed 六条既有路由(首选成功、预算降级、敏感换模型、不可用换模型、全链失败、调用上限睡窗)。"""
|
||||
import pathlib
|
||||
import sys
|
||||
import types
|
||||
@ -34,8 +35,9 @@ def _install(now, reads, chat, sleep=None):
|
||||
return (add, 1) # 返回值 chat_governed 不消费,占位即可
|
||||
llm._bump_window = fake_bump
|
||||
llm.chat = chat
|
||||
llm._PLAN_QUOTA_OPEN.clear()
|
||||
# 只替换 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
|
||||
|
||||
|
||||
@ -84,6 +86,129 @@ def test_current_quota_policy():
|
||||
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():
|
||||
"""①usd=0/calls=0:链首 M3 成功 → 返回 MiniMax-M3、记账 usd>0、bump 一次到本窗。"""
|
||||
calls = []
|
||||
@ -186,6 +311,8 @@ def test_route_call_cap_sleep():
|
||||
def main():
|
||||
tests = [test_window_key, test_seconds_to_next_window, test_cost_usd,
|
||||
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_sensitive_then_next, test_route_runtime_then_next,
|
||||
test_route_all_fail, test_route_call_cap_sleep]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user