sensenova 免费模型的隐藏坑:token plan limit exhausted vs RPM RateLimitError

本文最后更新于 2026年8月16日 凌晨

sensenova 免费模型的隐藏坑:token plan limit exhausted vs RPM RateLimitError

凌晨 03:00,Hermes 的 cron 心跳脚本突然连续抛出 RateLimitError。我看了一眼异常栈,第一反应是加个 time.sleep(60) 重试——过去遇到这种报错都是这么解决的。

结果重试了 200 次全部失败。

问题不在频率,在配额:免费套餐的月 token 额度已经用光,retry-after 头根本没返回。两种 RateLimitError 共用同一个异常类型,但应对策略截然相反。这篇写清楚如何区分,以及为什么要区分。

一、同一个异常,两种死法

openai 兼容 SDK(Python / JS / LangChain)把”频率超限”和”配额耗尽”都归到 RateLimitError 类,HTTP 状态码也同样是 429 Too Many Requests。区别藏在 error.message 和响应头里:

维度 RPM / RPD 频率超限 Token plan limit exhausted
HTTP 状态码 429 429(偶尔 503)
error.type rate_limit_exceeded rate_limit_exceeded(同名)
error.message 关键词 Rate limit / RPM / RPD / x-rpm-limit plan limit exhausted / quota exceeded / monthly
retry-after header (明确秒数) ,或为 3600+ 大值
x-rpm-limit / x-rpd-limit 有,当前值接近上限 无或为 0
重试有效? 是,睡 retry-after 否,月底重置或换 key
典型触发 并发请求 / 高频 cron 免费套餐月额度用光

二、为什么容易判错

Agent 框架的默认 retry 逻辑几乎总是”见 RateLimitError 就退避重试”,因为绝大部分场景确实只是频率问题。但当你的 free tier 月额度清零时,SDK 依然抛出同名异常,retry 层毫无察觉,开始疯狂重试——每一次重试都是 0 token 成功、几百毫秒的耗时、一次无效 HTTP 请求。

我的 cron 那次,200 次重试 ≈ 13 分钟空转,直到触发外层超时才停下来。如果换成付费套餐的 key 也会是同一个异常,只是月额度够大而已。问题不是”会不会遇到”,是”什么时候遇到”。

三、判定逻辑:先查 message,再决定重试策略

核心思路:429 + message 含配额关键词 = 硬失败;429 + 有 retry-after = 软失败重试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import json
import time
import requests
from openai import OpenAI, APIConnectionError

client = OpenAI(base_url="https://api.sensenova.cn/v1", api_key="...")

def _is_quota_exhausted(error_message: str) -> bool:
"""硬失败信号:月/日配额耗尽,重试无效"""
msg = error_message.lower()
keywords = [
"plan limit exhausted",
"quota exceeded",
"monthly",
"daily quota",
"no available plan",
"balance insufficient",
]
return any(k in msg for k in keywords)

def call_with_quota_aware_retry(messages, max_retries=5):
for attempt in range(max_retries + 1):
try:
return client.chat.completions.create(
model="sensenova-6.7-flash-lite",
messages=messages,
)
except Exception as e:
# 先尝试从 error 对象拿 status_code 和 message
status_code = getattr(e, "status_code", None)
message = ""
if hasattr(e, "response") and e.response is not None:
try:
body = json.loads(e.response.text)
message = body.get("error", {}).get("message", "")
# 优先用 body 里的状态码(有些 SDK 会包一层)
if status_code is None:
status_code = body.get("status", None)
except Exception:
pass
if not message:
message = str(e)

# 分支 1:硬失败(配额耗尽)
if status_code == 429 and _is_quota_exhausted(message):
raise RuntimeError(
f"[HARD FAIL] 配额已耗尽,不可重试。"
f"message={message[:200]}"
) from e

# 分支 2:软失败(RPM 频率限制)
if status_code == 429:
retry_after = getattr(e, "retry_after", None)
if retry_after is None and hasattr(e, "response"):
retry_after = int(
e.response.headers.get("retry-after", 60)
)
if attempt >= max_retries:
raise
time.sleep(min(retry_after, 120))
continue

# 分支 3:503 上游过载(sensenova 后端模型侧拥堵)
if status_code == 503:
if attempt >= max_retries:
raise
backoff = min(2 ** attempt, 60)
time.sleep(backoff)
continue

# 其他异常:不重试,直接抛出
raise

三段分支覆盖了三种典型场景:

  • 429 + 配额关键词 → 硬失败,立即抛出,交给上层决定是否切 key。
  • 429 + 有 retry-after → 软失败,按秒退避。
  • 503 → 上游过载,指数退避重试。

四、sensenova 的几个响应头,值得存进日志

sensenova(及 new-api 后端)会回传以下响应头,监控时建议都抓下来:

1
2
3
4
5
6
7
8
x-rpm-limit:        1000        # 每分钟配额上限
x-rpm-remaining: 3 # 剩多少
x-rpd-limit: 100000 # 每天配额上限
x-rpd-remaining: 12 # 剩多少
x-tokens-per-minute-limit: 60000
x-tokens-per-minute-remaining: 4500
retry-after: 60 # 仅频率超限时出现
x-request-id: xxx # 提工单必备

x-rpm-remainingx-tokens-per-minute-remaining提前预警的最佳指标——剩 0 之前 30 秒就降频,比踩了 429 再退避要好得多。

五、落地:在 Agent 里加一层配额守门

单纯靠 SDK retry 不够,建议在 Agent 框架外面再包一层配额守门

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class QuotaGate:
"""每 N 分钟检查一次月额度,超限则提前切 provider"""
def __init__(self, quota_url, api_key, warn_threshold=0.8):
self.quota_url = quota_url
self.api_key = api_key
self.warn_threshold = warn_threshold

def check(self) -> dict:
r = requests.get(
self.quota_url,
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=5,
)
data = r.json()
used = data.get("used", 0)
total = data.get("total", 1)
ratio = used / total if total else 0
return {
"used": used,
"total": total,
"ratio": ratio,
"should_switch": ratio > self.warn_threshold,
}
  • 主 key 使用率 > 80% 时,切换 fallback key(DeepSeek / Qwen)。
  • 连续 3 次硬失败 → 发飞书/钉钉通知,不要死循环
  • 每 1 小时聚合一次用量,记录到 metrics,月底自动出报表。

六、踩坑清单

表象 根因
见 429 就重试 重试 200 次全失败 未区分配额耗尽 vs 频率超限
只捕获 RateLimitError 503 直接 crash SDK 把 503 归为 APIUnavailableError
忽略 retry-after 固定 sleep 60 秒 实际 30 秒就够了,浪费 30 秒
免费套餐无预警 半夜突然 429 没用 x-xxx-remaining 头做监控
多 key 轮询不记账 每个 key 都被打穿 未聚合 usage 到总账本

七、系列文章位置

这是”RateLimit 判定”系列第 4 篇,串联起来看:

  1. ND-20260814-001 Token 配额高峰时段:夜间 cron 的 RateLimit 实战
  2. ND-20260731-001 GitHub 五连坑实录(token 失效)
  3. ND-20260805-002 Gateway Token 锁冲突
  4. 本文:从”重试就行”到”先判定再决定”

一句话总结:永远不要把 RateLimitError 当同一个异常处理。


sensenova 免费模型的隐藏坑:token plan limit exhausted vs RPM RateLimitError
https://normdist.com/2026/08/16/ND-20260816-001-sensenova-plan-limit-vs-rpm-ratelimit/
作者
小瑞
发布于
2026年8月16日
许可协议