Mistral Large 4 调用返回 429——是并发太高还是配额见底了?

文章导读
Mistral Large 4 返回 429 时,HTTP 状态码本身只说明「你被限流了」,不区分是发得太快还是额度用完。可操作的判断顺序是:先把这次响应的头部和响应体完整留下来,再对照账户页面的用量与余额,最后才决定是降并发还是补额度。多数情况下两者会给出不同信号——速率限制通常在 Retry-After 或 X-RateLimit-* 里带具体数值,配额耗尽则更多体现在错误消息和账户的余额、b
📋 目录
  1. 壹 查看 429 响应头中的限流信息
  2. 贰 区分并发限制与配额限制
  3. 叁 在代码中实现指数退避重试
  4. 肆 用日志记录请求时间与频率
  5. 伍 调整批处理或降低并发数
A A

Mistral Large 4 返回 429 时,HTTP 状态码本身只说明「你被限流了」,不区分是发得太快还是额度用完。可操作的判断顺序是:先把这次响应的头部和响应体完整留下来,再对照账户页面的用量与余额,最后才决定是降并发还是补额度。多数情况下两者会给出不同信号——速率限制通常在 Retry-After 或 X-RateLimit-* 里带具体数值,配额耗尽则更多体现在错误消息和账户的余额、billing 状态上。

先做一次带 -i 的 curl,把 429 的响应头和响应体都留档。头部出现 Retry-After、X-RateLimit-Remaining 接近 0,偏向速率/并发限流;错误消息写 insufficient quota、余额不足,或账户页显示额度为 0,偏向配额耗尽。两者可能同时成立,所以重试逻辑要能读 Retry-After,批处理要能降并发;而配额问题是重试解决不了的,只能调整计划或充值。

查看 429 响应头中的限流信息

很多 SDK 会把异常包装掉,只抛出「429 Too Many Requests」,看不到头部。排查阶段建议先用 curl 直接打一次接口,把 -i 打开,把头部和响应体都打印出来。

# 放在能访问外网的机器上执行,替换 BASE_URL 和 API_KEY
curl -i -X POST "$BASE_URL/v1/chat/completions" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d @payload.json

响应头里常见的字段含义如下,字段名各服务商不完全一致,以你实际收到的为准:

  • Retry-After:服务端建议你等待多少秒再重试。这个值优先于你自己算的退避时间,有就按它睡。
  • X-RateLimit-Limit-*:当前窗口允许的上限,常见维度有 requests(RPM)和 tokens(TPM)。
  • X-RateLimit-Remaining-*:当前窗口还剩多少额度。这个值接近 0 却还在报 429,说明你踩的是速率上限。
  • X-RateLimit-Reset-*:窗口重置时间。用来判断还要等多久,不要用固定 sleep 硬扛。

一个示意的响应头(数值用占位表示):

HTTP/1.1 429 Too Many Requests
Retry-After: <秒数>
X-RateLimit-Limit-Requests: <上限>
X-RateLimit-Remaining-Requests: <剩余>
X-RateLimit-Reset-Requests: <重置时间戳>
Content-Type: application/json

验证方式:连续发几次相同请求,看 Remaining 是否单调递减到 0。如果是,那就是速率问题;如果 Remaining 一直很充足却仍被拒,或者头部根本没这些字段,就要转到下一节看错误消息和账户状态。

区分并发限制与配额限制

头部之外,响应体里的 error 字段是更直接的线索。结构示意如下,字段名和文案以实际返回为准:

Mistral Large 4 调用返回 429——是并发太高还是配额见底了?
// 偏向速率/并发超限
{
  "error": {
    "type": "rate_limit_exceeded",
    "message": "requests per minute limit exceeded"
  }
}

// 偏配额/余额问题
{
  "error": {
    "type": "insufficient_quota",
    "message": "you exceeded your current quota, please check your plan and billing details"
  }
}

判断路径可以这样走:

  1. 错误消息里出现 rate limit、too many requests、RPM、TPM 一类词,且 Retry-After 有值,按速率限制处理,走退避重试和降并发。
  2. 错误消息里出现 quota、billing、credit、balance 一类词,先去控制台的用量或计费页面确认当前额度与账单状态。
  3. 如果同时命中,先修配额。额度为 0 时,重试多少次都是 429,指数退避只会让你更慢地拿到同一个错误。

注意:部分服务对「免费额度用尽」也返回 429 而不是 402,所以不能只看状态码。账户页面的用量曲线是唯一能确认配额的手段,日志和头部只能给出倾向性判断。

在代码中实现指数退避重试

临时限流适合用重试消化,但必须满足两点:尊重 Retry-After,以及加抖动。下面是一个通用骨架,fn 换成你自己的调用函数即可,返回值需要有 status_code 和 headers。

import time
import random

def call_with_backoff(fn, max_retries=5, base=1.0, cap=30.0):
    resp = None
    for attempt in range(max_retries):
        resp = fn()
        if resp.status_code != 429:
            return resp

        retry_after = resp.headers.get('Retry-After')
        if retry_after:
            wait = float(retry_after)          # 服务端说了算
        else:
            wait = min(cap, base * (2 ** attempt))
            wait += random.uniform(0, wait * 0.3)  # 抖动,避免多个线程同时重试
        time.sleep(wait)

    return resp  # 重试耗尽,交给上层决定是否降级

替换项与验证:base 从 1 秒起步通常够用,cap 控制单次最长等待,避免线程被长时间挂住。max_retries 不要设太大,重试耗尽后应该有明确的失败返回,而不是无限循环。验证方式是在本地人为压低触发条件,观察日志里每次重试的间隔是否成倍增长、是否读到了 Retry-After。另外,重试只对幂等或可容忍重复的调用安全,带副作用的写操作需要先确认是否可以重复提交。

用日志记录请求时间与频率

要判断是不是「发得太快」,得先有一份能按分钟统计的日志。关键是把时间戳、请求 ID、状态码都打在同一行上。

import logging, time, uuid

log = logging.getLogger('mistral')
logging.basicConfig(
    format='%(asctime)s %(levelname)s %(message)s',
    level=logging.INFO,
)

def tracked_call(fn):
    req_id = str(uuid.uuid4())[:8]
    start = time.monotonic()
    resp = None
    try:
        resp = fn()
        return resp
    finally:
        log.info('req_id=%s status=%s cost=%.3fs',
                 req_id,
                 getattr(resp, 'status_code', 'n/a'),
                 time.monotonic() - start)

有了日志,就能按分钟聚合请求数(注意 substr 的长度要和你的时间格式对齐):

Mistral Large 4 调用返回 429——是并发太高还是配额见底了?
# 统计每分钟的总请求数
grep 'req_id=' app.log | awk '{print substr($1,1,16)}' | sort | uniq -c

# 只看 429 出现在哪些分钟
grep 'req_id=' app.log | grep 'status=429' | awk '{print substr($1,1,16)}' | sort | uniq -c

验证方式:如果 429 集中在某几个分钟、其余分钟很干净,基本是突发并发;如果 429 从某个时间点开始持续出现且请求量没变,更像是配额或账户状态变化。请求 ID 还能帮你把重试和原始请求对应起来,避免把重试也算成新请求而误判频率。

调整批处理或降低并发数

退避重试是止血,降低发送速率才是从源头减少 429。第一步是把并发数写成显式的小值,而不是依赖线程池或框架的默认行为。

from concurrent.futures import ThreadPoolExecutor

# max_workers 按你的 RPM/TPM 上限反推,先取一个小值再慢慢往上调
with ThreadPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(send_one, batch))

如果并发不好控制,或者上游限制是按分钟计的,用队列串行发送更稳:

import queue, threading, time

q = queue.Queue()
for item in batch:
    q.put(item)

def worker(interval):
    while True:
        try:
            item = q.get_nowait()
        except queue.Empty:
            return
        send_one(item)
        time.sleep(interval)   # 控制发送节奏,按实际限流值调整
        q.task_done()

threads = [threading.Thread(target=worker, args=(0.2,)) for _ in range(2)]
for t in threads:
    t.start()

取舍上:max_workers 和队列线程数决定了峰值 RPM,interval 决定了长期平均速率。两者都调小可以明显减少 429,代价是总耗时变长,批量任务需要重新排期。批处理还可以按大小拆分成多轮提交,轮与轮之间留出间隔。

验证方式:改成串行或小并发后,重跑同一批任务,对照日志里每分钟的请求数和 429 出现次数。如果 429 消失但吞吐明显下降,说明你之前确实踩在速率上限附近;如果降到单线程仍然 429,就该回到第二节,检查是不是配额问题。