遇到 Claude Haiku 5.5 被限流,先别急着在代码里改某一个数字。批量调用场景下限流的直接原因,通常是短时间内的并发请求量超过了账号或接口当前允许的额度,而这个量往往被两件事共同放大:调度侧的并发数,以及失败后立刻重试带来的二次请求。判断顺序一般是先看日志做归因,再决定动作。如果重试既没有上限也没有退避,先压并发只是把问题往后推;反过来,如果失败集中在少数几分钟、其余时间很平稳,压并发会白白牺牲吞吐,改重试策略更对症。下面按“看分布、分类型、补重试骨架、压并发对照、固化配置”的顺序给可执行的做法。
限流先归因再动手:按分钟统计成功与失败次数,看失败是均匀铺开还是集中在少数几分钟。集中在少数时段、之后能自行恢复的,多为瞬时峰值,优先给重试加退避、上限和抖动;整个观察窗口内持续失败、长时间不恢复的,更像配额耗尽,优先把并发降到稳定区间。两种情况都建议一次只改一个变量,改完用同一套日志指标再对比,避免把重试造成的请求放大误当成解决方案。
在日志里统计限流出现的时间分布
这一步的目的不是算总量,而是看清限流的时间形状。前提是每次调用都在日志里落一行记录,至少包含时间戳、状态码或错误类型、本次耗时。如果现在的日志只有错误行、没有成功行,先补上成功行再统计,否则算不出失败比例。
下面这段脚本按分钟聚合成功与失败次数,用标准库即可,替换日志路径和字段格式就能跑:
import re, collections
# 假设每行形如: 2025-01-01T10:00:03Z status=429 cost=812ms
pat = re.compile(r"^(?P<ts>\S+).*status=(?P<code>\d+)")
buckets = collections.Counter()
for line in open("call.log", encoding="utf-8"):
m = pat.search(line)
if not m:
continue
minute = m.group("ts")[:16] # 截到分钟
code = m.group("code")
if code == "429":
buckets[(minute, "fail")] += 1
else:
buckets[(minute, "ok")] += 1
for (minute, kind), n in sorted(buckets.items()):
print(minute, kind, n)跑完看两件事:失败是几乎每个分钟都有、数量接近,还是只在某几分钟堆起来;以及失败分钟之间是否夹着大量成功分钟。如果日志量很大,也可以先用 grep -c 'status=429' call.log 确认总量,再决定要不要上聚合脚本。这一步只做观察,不要顺手改并发参数,否则后面就分不清是哪个动作起了作用。
区分瞬时峰值与配额耗尽
同样是 429,峰值型和配额型的处理方向相反。峰值型说明额度在时间轴上有富余,只是被短时冲击打满,恢复后能继续跑;配额型说明在当前的观察窗口里,总量本身已经贴近上限,压住瞬时峰值也没用。判断依据是失败比例随时间的变化和恢复时间,可以按下面这张表对照:
| 观察项 | 峰值型特征 | 配额型特征 | 先动的动作 |
|---|---|---|---|
| 失败在时间轴上的分布 | 集中在少数几分钟,成簇出现 | 各分钟都有,比例大致稳定 | 峰值型改重试,配额型降并发 |
| 窗口内失败比例随并发变化 | 降并发后明显回落 | 降并发后改善有限 | 用同一窗口做对照 |
| 恢复时间 | 停一会儿再发,能自行恢复 | 停一会儿仍持续失败 | 持续失败时不要靠重试硬顶 |
| 重试效果 | 退避后重试成功率较高 | 退避后重试仍大量失败 | 重试无效就回到降并发或排期 |
表里的“明显”“有限”需要结合自己环境的窗口长度和调用量来判断,建议固定一个观察窗口(例如最近若干分钟或最近一批任务),每次只改一个变量再重跑同一批数据。
给重试加退避、上限和抖动
重试的作用是吸收偶发失败,不是提升额度。如果失败后立刻重发,重试本身就会把请求量叠加到峰值上,限流只会更严重。通用骨架要满足三点:指数退避、最大次数上限、随机抖动,并且只对可重试的错误类型生效。
import random, time
RETRYABLE = {429, 500, 502, 503, 504}
class ApiError(Exception):
def __init__(self, status):
self.status = status
def call_with_retry(fn, max_attempts=4, base=1.0, cap=15.0):
for attempt in range(max_attempts):
try:
return fn()
except ApiError as e:
if e.status not in RETRYABLE or attempt == max_attempts - 1:
raise
delay = min(cap, base * (2 ** attempt))
delay = delay * (0.5 + random.random()) # 抖动区间 0.5x ~ 1.5x
time.sleep(delay)几个容易踩的点:最大次数是上限而不是目标,不要为了“跑完”把它调得很高;抖动是让多个 worker 不要同一时刻一起重发,缺了抖动,退避在并发场景下效果会打折;重试的总耗时最好和单次调用的超时、整批任务的时间预算对齐,否则任务会在重试里悄悄拖长。不同平台对错误码的划分不同,哪些状态码算可重试,需要结合自己的接口返回确认后再填进集合。
压并发做对照测试
并发不要凭感觉调,做几组对照。选一组从小到大的取值,例如 1、2、4、8、16,每组跑固定请求数或固定时长,保持请求内容、超时和重试配置不变,只改并发这一个变量。记录方式建议用一张固定字段的表,跑完填进去:
| 并发 | 请求数 | 成功数 | 被限流数 | 成功率 | 记录到的恢复情况 |
|---|---|---|---|---|---|
| 1 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 2 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 4 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 8 | 待填 | 待填 | 待填 | 待填 | 待填 |
每组要看的核心指标是成功率,其次是被限流次数在整批任务中的位置分布:是从头到尾均匀出现,还是越靠后越多。同时记录该组是否出现明显的恢复等待,这能帮你判断这一档并发是不是已经顶到上限。取“成功率能接受、再往上加就明显恶化”的那一档往下留一点余量,作为候选配置,不要贴着临界值用。
把稳定参数写回调度配置
对照测试得到的并发值、超时和重试参数,要落到调度配置文件里,而不是散在代码常量中。下面是一份通用示例,字段名按自己项目替换,重点是结构:并发、超时、重试、排队策略放在一起,改的时候能一眼看到全貌。
client:
endpoint: "https://<your-endpoint>"
model: "<model-id>"
timeout_seconds: 30
max_concurrency: 4 # 来自对照测试的稳定档位
retry:
max_attempts: 4
base_delay_seconds: 1
max_delay_seconds: 15
jitter: true
retry_on: [429, 500, 502, 503, 504]
queue:
mode: "bounded"
max_pending: 200 # 队列满了先拒绝,不要无限堆积上线之后看这几类指标:每分钟成功数与限流数、限流在批次中的位置、重试次数的分布(是否少数请求重试很多次)、重试后成功与最终失败的比例、单次调用耗时的高位值,以及队列是否有持续堆积。如果限流比例仍偏高,先把 max_concurrency 降一档再观察,不要同时改并发和重试次数,否则无法判断是哪一项起作用。队列设为有界并配合拒绝策略,比让它无限堆积更安全,能避免任务在内存里越积越多却始终打不出去。