Grok 错误码该不该重试
「加个重试就好了」是生产事故的常见起点。400 重试一百次还是 400,只是把一次失败拖成一百次;429 立刻重试,等于在一堵墙上反复撞;最危险的是超时后重试——第一次请求可能已经计费、已经生成,第二次又完整来一遍。
重试不是「加个循环」,是按错误类型分三类动作。
一、问题不是「重不重试」,而是「这次重试会改变什么」
判断一个错误该不该重试,只问两个问题:
- 重试能让结果变好吗? 参数错了、密钥无效,重试一万次也不会变。
- 重试会产生副作用吗? 对可能已计费的生成请求,重试可能造成重复计费与重复输出。
按这两个问题,状态码可以分成三类:
- 不重试:
400、401、403、404、413、415、422——请求本身有问题,重试无意义。 - 退避重试:
408、429、5xx——临时状态;429读Retry-After,5xx 用指数退避。 - 谨慎重试:超时、连接中断——可能已产生结果,重试前先确认是否计费。
二、最小可运行示例:重试决策与退避
下面的脚本不联网、不需要真实密钥。它按状态码给出动作与等待时间,429 优先用 Retry-After,5xx 用指数退避加抖动。
"""重试决策(mock):三类动作 + 指数退避 + 抖动。"""
import random
NO_RETRY = {400, 401, 403, 404, 413, 415, 422}
def decide(status: int, attempt: int, retry_after: float | None = None,
seed: int = 0, timed_out: bool = False) -> tuple[str, float]:
"""返回 (动作, 等待秒数)。attempt 从 0 开始。"""
if timed_out or status == 0:
return "cautious_retry", 0.0 # 先确认是否已计费,再决定重发
if status in NO_RETRY:
return "no_retry", 0.0
if status in (408, 429):
wait = retry_after if retry_after is not None else min(2 ** attempt, 30)
return "backoff", float(wait)
if 500 <= status < 600:
base = min(2 ** attempt, 30)
jitter = random.Random(seed).uniform(0, 0.5)
return "backoff", round(base + jitter, 2)
return "no_retry", 0.0
def offline_check() -> None:
cases = [
("400 参数错", 400, 0, None, False),
("401 密钥错", 401, 0, None, False),
("413 请求体过大", 413, 0, None, False),
("408 请求超时", 408, 0, None, False),
("429 带 Retry-After", 429, 0, 3.0, False),
("429 无 Retry-After", 429, 3, None, False),
("503 服务端错", 503, 2, None, False),
("网络超时", 0, 0, None, True),
]
for label, status, attempt, ra, to in cases:
action, wait = decide(status, attempt, ra, timed_out=to)
print(f"{label}:{action} {wait}")
if __name__ == "__main__":
offline_check()
真正上线时,attempt 从 0 开始计数,每次重试前检查「是否超过最大次数」;seed 在生产里换成随机源,测试里固定以便复现。
三、三类动作的触发与边界
| 类别 | 状态码 | 动作 | 注意 |
|---|---|---|---|
| 不重试 | 400 / 401 / 403 / 404 / 413 / 415 / 422 | 直接失败并记录 | 401 先查密钥读取时机 |
| 退避重试 | 408 | 退避后重试 | 与「超时」区分:408 是服务端明说超时 |
| 退避重试 | 429 | 读 Retry-After,无则指数退避 |
别用固定间隔猛撞 |
| 退避重试 | 5xx | 指数退避 + 抖动 | 设最大次数与总时长 |
| 谨慎重试 | 超时 / 连接断 | 先确认是否已计费 | 可能重复生成 |
关于 429,还有个容易被忽略的层次:它不是笼统的「你太快了」,而是两个独立维度各自触发——每秒请求数(RPS)与每分钟 token 数(TPM)。RPS 由每分钟配额推出来(RPM ÷ 48,且下限为 2),所以「把一分钟的额度在一秒内打完」必然被拦。TPM 那边有个反直觉的点:命中缓存的那部分 token 同样计入 TPM,只是单价更低——省的是钱,不是配额。
两个维度的上限都随团队档位放大,而档位看的是累计消费:$50 / $250 / $1,000 / $5,000 四道门槛,达到即升级,且只升不降。这解释了一个常见困惑:同一份代码在 A 团队跑得稳、在 B 团队频繁 429——往往不是代码问题,是档位问题。图像与视频(Imagine)的额度不在自助档位里,要单独找商务申请。
三件要强调的事:
- 重试要设总预算。不只看次数,还要看总耗时;一个退避 30 秒的循环跑五次,用户已经走了。
- 抖动不是装饰。多个客户端同时重试会形成新的尖峰;加随机抖动能把重试摊开。
- 超时是最难的一类。连接断了不代表请求没执行;对生成类请求,重试前先查「是否已有结果」,或者用幂等键去重(若平台提供,以官方页为准)。
四、预期输出与异常分支
运行决策器,预期输出:
400 参数错:no_retry 0.0
401 密钥错:no_retry 0.0
413 请求体过大:no_retry 0.0
408 请求超时:backoff 1.0
429 带 Retry-After:backoff 3.0
429 无 Retry-After:backoff 8.0
503 服务端错:backoff 4.42
网络超时:cautious_retry 0.0
换成真实流量时,若出现异常,按下面顺序定位:
| 现象 | 优先怀疑 | 第一步动作 |
|---|---|---|
| 重试风暴 | 没有抖动或上限 | 加抖动、设最大次数 |
| 401 一直重试 | 把认证错误当临时故障 | 归入不重试 |
| 账单出现成对请求 | 超时后直接重发 | 先确认是否已计费 |
| 等待时间过长 | 退避翻倍无上限 | 封顶并设总时长 |
| 429 频繁但并发不高 | TPM 触顶(含缓存 token) | 按 token 量算每分钟消耗,别只数请求数 |
最后一行尤其重要:429 的处理不是「等更久」,而是「按服务端给的节奏等」。忽略 Retry-After,重试越勤奋越糟。
五、判据卡
| 判断项 | 该这么做 | 不该这么做 |
|---|---|---|
| 400/401/403/404/422 | 不重试,直接记录 | 一律包进重试循环 |
| 429 | 读 Retry-After,并先分清 RPS 还是 TPM 触顶 |
一律当成「等久一点」 |
| 5xx | 指数退避 + 抖动 | 线性重试 |
| 超时 | 先确认是否已计费 | 直接重发 |
| 上限 | 次数 + 总时长双限 | 只限次数 |
这张卡的重点是先分类再重试。五条里有四条在说:不同错误需要不同动作,混成一类就等于没有策略。
六、来源与核验
- 官方 API 参考:
https://docs.x.ai/developers/rest-api-reference(核验日期 2026-11-04)。错误响应的整体结构以该页为准;具体状态码语义遵循 HTTP 通用约定。 - 官方 Streaming 页:
https://docs.x.ai/developers/model-capabilities/text/streaming(核验日期 2026-11-04)。推理模型流式输出建议覆盖超时的提示,是「超时不等于失败」的一个具体来源。 - 官方限流页:
https://docs.x.ai/developers/rate-limits(核验日期 2026-11-04)。RPS 由 RPM ÷ 48 推导(下限 2)、按档位的 RPS/TPM 上限、档位门槛与只升不降、缓存 token 计入 TPM、Imagine 额度需单独申请,均以该页为准(F58–F60)。 - 三类动作划分与退避实现为本书原创的工程框架;第三方重试文章中的相似提法仅用于确认这是共性问题,本节不转述其内容。平台是否提供幂等键,以官方页为准。
七、动手试
- 给你现有代码里每个重试点标注类别(不重试 / 退避 / 谨慎),找出「400 也在重试」的地方。
- 把
decide()改成带总预算的版本:超过总时长直接返回give_up,并说明为什么这比「再试一次」更稳。 - 设计一条超时请求的对账流程:先查什么、再决定重发还是放弃,写出三个检查点。