案例分析:把token不足的拒绝误判为网络瞬时错误导致无限重试,实际是重试间隔不足触发了更严格的限流。
凌晨2点把我吵醒的故障,不是由糟糕的模型或过载的服务器引起的。它是由一个重试循环引起的——这个循环把 token 限制拒绝当作了瞬时网络错误。同一条流水线通过了我们运行的所有评估,这反而让故障更难诊断,而不是更容易。我们信任那些绿色的对勾,不再质疑它们。这里的确切数字被隐去了,但这种模式我在好几个项目中都见过重复。
生产环境在凌晨 1:47 左右开始出现故障,失败模式看起来是随机的。大约六分之一的总结任务请求返回了 HTTP 429,更少一部分在 90 秒后超时。这个任务很小,是一个把支持对话压缩成每日摘要的后台 worker,它已经运行了两周而没有一次记录在案的错误。当我打开日志时,重试计数器已经把同一个失败的 payload 推送到队列中十一次了。故障不是一次事件;它是一个循环。
我们的第一个假设是常见嫌疑:免费层达到了速率限制。我们在请求之间加了 sleep,等待模式消失,但失败仍然以相同的频率持续。这排除了简单的每分钟配额,所以第二个假设归咎于网络,我们切换了提供商却复现了相同的行为。转折点出现在我们把确切请求体与状态码一起记录时。响应体包含了一个我们从未解析过的细节:服务器是根据它计算的 token 数量而不是我们估计的数量来拒绝请求的。
# Inspect the rejection instead of guessing from the status code
curl -i "$ENDPOINT" \
-H "Authorization: Bearer $TOKEN" \
-d @payload.json | head -40
这里有个认知差距:我们的客户端分词器估计摘要 prompt 为 8,400 个 token。服务器计数为 10,300,因为它包含了我们在单独步骤中附加的完整对话历史。请求超过了限额阈值,服务器返回了 429,错误信息看起来像速率限制,而我们的重试逻辑忠实地重新发送了同一个过大的 payload。这一单个决定把一次合同违规变成了十一次相同的违规。同步退避随后导致每个 worker 在同一时刻都去冲击这个端点。
# The original retry: same payload, synchronized backoff, no jitter
for attempt in range(10):
response = call_model(payload)
if response.status_code == 429:
time.sleep(2 ** attempt) # every worker sleeps the same schedule
continue
break
修复有三个部分,都不涉及更换模型。第一,我们在每个非 200 状态码时解析响应体并记录服务器声明的 token 计数。合同拒绝变成了一个可以与我们自己估计值对比的数字。第二,我们添加了客户端硬性上限,使用与服务器相同的规则计算完整对话历史。我们在 job 到达网络之前就拒绝它。第三,我们用有界重试替换了同步退避,添加了随机抖动并将总尝试次数限制为三次。
# The fixed version: parse the reason, cap the payload, jitter the retry
def call_with_budget(payload, max_tokens):
if estimate_full_payload(payload) > max_tokens:
raise BudgetExceeded(payload) # fail before the network
for attempt in range(3):
response = call_model(payload)
if response.status_code == 429:
reason = response.json().get("error", {}).get("message", "")
log("429", reason, estimate_full_payload(payload))
time.sleep((2 ** attempt) + random.uniform(0, 1.5))
continue
return response
raise RetryBudgetExhausted(payload)
为了在不消耗付费额度的情况下验证修复,我们在免费服务器上重建了相同的 job,并指向 MonkeyCode 的开源项目——该项目目前提供一千万 token 额度的免费模型访问和一个免费服务器选项。披露:本文是 MonkeyCode 产品推广的一部分。这样做的目的不是对提供商进行基准测试,而是在受控环境中重现确切的失败模式。免费额度使得在单个下午运行数十次重现成为可能。免费配额会变化,所以在依赖这些数字之前请检查当前条款,并把任何免费层视为沙箱而非保证。
可复用的经验是把每个错误码当作假设,而不是结论。429 可以意味着速率限制、配额、payload 违规或服务器拒绝——如果愿意读一下的话,响应体通常会告诉你究竟是哪一个。记录那个响应体是你能添加的最便宜的监控手段。第二个经验是重试逻辑是系统行为的一部分,所以重复十一次的失败是设计缺陷,而不是运气不好。抖动存在是有原因的,有界重试存在的原因更充分。
这种方法并非适合所有人,如果你的工作负载每天只发送少量请求且可以手动检查每个失败,你应该跳过它。客户端 token 上限不会从突然改变输出长度的模型中拯救你,所以实时面向用户的特性仍然需要输出验证。如果你在评估生产用模型,免费层是一个有用的沙箱,而不是对你实际付费的提供商的契约测试的替代品。
下次后台任务在凌晨 2 点失败时,在动重试循环之前先读一下响应体,因为模型很少是问题所在。修复只是几行日志、一个硬性上限和一个抖动重试,你可以在免费服务器上测试所有这些,不花一分钱。这是一种你在任何地方都能做的调试,而免费层恰好让它变得更便宜。