上一篇讲了怎么把第一行请求跑通,结尾我留了个话头:跑通只是开始,等你真把模型接进自己的脚本、接进后端、开始定时跑批量,真正磨人的事才来。这篇就把这三件事一次讲透——请求失败怎么重试、成本怎么在调用侧先盯住、批量任务怎么排队。
先说个背景,免得你觉得这些是"大公司才需要"的东西。你在 Cursor、Claude Code 这类工具里点一下补全、让它跑一个 Agent,后台本质就是一次 API 调用。你点得很顺,是因为它在你看不见的地方替你把重试、限速、额度这些都处理了。等你自己在代码里裸调,这些保护一个都没有,全得自己补。我自己从"能跑通"到"敢挂着过夜跑批量",补的就是下面这三块。
一、先分清:哪些错值得重试,哪些重试到天荒地老也没用
新手最容易犯的错,是把所有异常都丢进一个重试循环,失败了就再来一遍。我早期也这么干,结果一次把一个参数错误的请求重试了几十遍,日志刷了一屏,问题一点没解决,还白白占着并发。
重试之前,先判断这次失败到底是"没赶到"还是"根本错了":
| 报错类型 | 典型状态 | 该不该重试 | 我的处理 |
|---|---|---|---|
| 限流、服务端临时繁忙 | 429 / 500 / 502 / 503 / 504 | 该,等一会儿往往就好 | 退避重试 |
| 请求超时 | 读超时、连接超时 | 多半该,网络抖动居多 | 退避重试,适当放宽超时 |
| 偶发冲突 | 409 | 看情况,幂等才重试 | 确认幂等后重试 |
| 认证失败 | 401 / 403 | 不该,key 或权限有问题 | 立刻停,去查 key |
| 请求本身写错 | 400 / 404 / 模型名找不到 | 不该,重试一万遍还是错 | 立刻停,改参数 |
| 内容超长 | context length 一类 | 不该,得先裁剪 | 立刻停,缩短输入 |
| 余额 / 额度耗尽 | 对应额度报错 | 不该,重试只会更糟 | 立刻停,去看账单 |
这张表的核心判断只有一句:这个错,是"换个时间再试可能成功",还是"我不改东西、它永远不会好"? 前者才重试,后者必须当场抛出、让你看见。状态码具体含义以官方当期文档为准,个别厂商会有自己的码,但这个判断逻辑通用。
二、重试的正确姿势:不是 for 循环,是退避 + 抖动 + 封顶
确定要重试,也不是失败了立刻再来。立刻猛打,限流只会更狠,等于在人家门口反复敲门。稳妥的做法是指数退避:第一次失败等 1 秒,第二次等 2 秒,第三次 4 秒,越等越久,给服务端缓过来的时间;同时加一点随机抖动,不然你一批任务同时失败、又同时整点重试,那一下的流量尖峰反而又把自己打到限流。
再加上最大重试次数和单次超时,避免一个请求无限挂着。下面这段是我自己在用的封装,标准库就能跑,直接抄:
import time
import random
# 只对"换个时间再试可能成功"的错误重试,具体状态码以官方 SDK 当期文档为准
RETRYABLE_STATUS = {408, 409, 429, 500, 502, 503, 504}
MAX_RETRIES = 5
def call_with_retry(call, *args, **kwargs):
attempt = 0
while True:
try:
return call(*args, **kwargs)
except Exception as e:
status = getattr(e, "status_code", None)
# 400/401/403 这类重试到天荒地老也不会好,直接抛出去
if status not in RETRYABLE_STATUS:
raise
attempt += 1
if attempt > MAX_RETRIES:
raise
# 指数退避 1s、2s、4s... 再叠加随机抖动,避免一批任务整点齐刷
backoff = 2 ** (attempt - 1)
wait = backoff + random.uniform(0, backoff * 0.5)
# 服务端若在 429 里明确告诉你"多久后再试",优先听它的,字段名以官方文档为准
retry_hint = getattr(e, "retry_after", None)
if retry_hint:
wait = float(retry_hint)
time.sleep(min(wait, 60))
还有一个比退避更要命的概念:幂等。意思是同一个请求发两遍,结果不能乱——查询类、生成类天然幂等,重发一遍顶多浪费一次;但如果你在重试"提交一个工单、扣一次库存、写一条记录"这种动作,重发可能就提交了两次。所以会改变状态的请求,要么给它一个你自己生成的唯一请求编号让服务端去重,要么干脆别自动重试、记下来人工确认。我自己的习惯是:只读和生成的请求放心重试,带副作用的请求先确认幂等再重。
三、成本别等月底看账单:在调用侧先埋账、再装闸门
007 那篇提过一句"每次返回的 usage 顺手记一笔"。很多人没当回事,觉得控制台后台能看。问题是后台账单是事后的、按天甚至按账号汇总的,等你看见一个吓人的数字,钱已经花完了,而且你还说不清是哪段代码、哪个任务烧的。
我的做法是在调用侧——也就是你自己的代码里——先把账记明白,再加一道闸门。每次请求成功,立刻把 usage 里的输入、输出 token 记下来,顺手标上这是哪个任务、用的哪个模型;再设一个预算上限,累计到阈值就熔断,后面的请求直接在本地拦下、根本不往服务端发。
import json
import time
import threading
class BudgetGuard:
def __init__(self, input_limit, output_limit):
self.in_limit = input_limit # 输入 token 预算,按自己能接受的量设
self.out_limit = output_limit # 输出通常更贵,单独给一道
self.used_in = 0
self.used_out = 0
self.lock = threading.Lock()
self.ledger = [] # 每笔都落账,事后能按任务、按模型对账
self.broken = False
def record(self, task, model, usage):
pin = getattr(usage, "prompt_tokens", 0)
coi = getattr(usage, "completion_tokens", 0)
with self.lock:
self.used_in += pin
self.used_out += coi
self.ledger.append({"task": task, "model": model,
"in": pin, "out": coi, "ts": time.time()})
# 任一维度触顶就熔断,后续请求在本地拦下,不再发往服务端
if self.used_in >= self.in_limit or self.used_out >= self.out_limit:
self.broken = True
def check(self):
if self.broken:
raise RuntimeError("预算已触顶并熔断,先看账本再决定要不要加额度")
def dump(self, path):
with open(path, "w", encoding="utf-8") as f:
json.dump(self.ledger, f, ensure_ascii=False, indent=2)
这么干的好处,等你跑过上百个任务就懂了:
- 哪类任务烧钱一目了然。 账本按 task、model 分类汇总,你会发现往往是那几个"历史越滚越长"或"动不动就换旗舰模型"的任务在吃预算,照着 007 那篇的省法(裁剪历史、杂活换便宜模型、长材料用缓存)去压,方向很明确。
- 失控循环兜得住。 死循环、prompt 写岔导致它疯狂输出的情况,闸门会在你设的额度处停下,而不是烧到你肉疼才发现。
- 输入和输出分开盯。 输出普遍更贵,有的任务是输入爆炸(历史没裁),有的是输出失控(max_tokens 没限),分开记账才能对症下药。
具体额度填多少、单价差多少,看官方当期的计费说明,按自己能接受的范围设,别照搬别人的数。
四、批量任务别裸循环:限并发、限速率、失败单列、断点续跑
单个请求稳了,真正的考验是批量——比如一次性处理几百个文件、给几千条数据做分类。新手常见写法是一个 for 循环从头跑到尾,结果要么一把并发太猛被限流,要么中间第 300 个报错,整个任务崩了,前面的也白跑。
我把批量固定成四件事:限并发、限速率、失败单独收集、进度落盘能续跑。
- 限并发:用线程池加一个信号量,同时在飞的请求控制在少数几个,比如 2~3 个,跑通了再慢慢往上加。
- 限速率:如果服务端有每分钟请求数的限制,就按它的节奏发,宁可稳一点;具体配额以官方文档为准。
- 失败单列:某条数据失败,记下来继续跑剩下的,别让一颗老鼠屎坏一锅汤,全部跑完再统一处理失败的那批。
- 断点续跑:每完成一条就把它的编号写进进度文件,任务中断后重跑,已完成的直接跳过,只补没做的。
import json
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
def load_done(path):
try:
with open(path, "r", encoding="utf-8") as f:
return set(json.load(f))
except FileNotFoundError:
return set()
def mark_done(uid, path):
done = load_done(path)
done.add(uid)
with open(path, "w", encoding="utf-8") as f:
json.dump(list(done), f, ensure_ascii=False)
def run_batch(items, uid_of, worker, guard, progress_path, max_workers=3):
done = load_done(progress_path) # 断点续跑:做完的直接跳过
todo = [x for x in items if uid_of(x) not in done]
results, failed = [], []
sem = threading.Semaphore(max_workers) # 限并发,别一把全压上去
def run_one(item):
with sem:
guard.check() # 每次发车前先过预算闸门
try:
r = worker(item, guard) # worker 内部成功后调 guard.record
mark_done(uid_of(item), progress_path)
return ("ok", item, r)
except Exception as e:
return ("fail", item, str(e)) # 失败不连累其它任务
with ThreadPoolExecutor(max_workers=max_workers) as pool:
futs = [pool.submit(run_one, it) for it in todo]
for f in as_completed(futs):
kind, item, payload = f.result()
(results if kind == "ok" else failed).append((item, payload))
return results, failed
这里 worker 就是你处理单条数据的函数,guard 是第三节那个 BudgetGuard。三个东西串起来的效果是:并发被压住不会触发限流,预算超了会自动停,失败的会单独列出来给你看,中途断电了重跑也不会从头再来。这套组合在 Cursor 里跑长任务时其实也有影子——它也是一条条改、改完落盘,而不是一口气把所有文件重写一遍,道理一样。
五、上线前我会过一遍的清单
把这三块拧起来以后,每次新写一个要长期跑、要跑批量的调用脚本,我都会照着下面勾一遍,少哪条补哪条:
| 检查项 | 没做的后果 |
|---|---|
| 非幂等请求有没有去重 / 人工确认 | 重试一次可能就重复提交、重复扣费 |
| 重试是不是只针对可重试错误 | 参数错误被狂刷几十遍,纯浪费 |
| 有没有退避 + 抖动 + 最大次数 | 整点齐刷把自己打到限流,或一个请求挂到死 |
| 单次请求有没有设超时 | 网络一抖,整个批量卡死在那一条 |
| 每次 usage 有没有记账 | 月底看账单才知道谁烧的钱,说不清 |
| 输入、输出有没有分别设预算闸门 | 失控循环烧到肉疼才发现 |
| 批量有没有限并发、限速率 | 一把压上去直接 429 |
| 失败有没有单独收集 | 第 300 条崩了,前面全白跑 |
| 进度有没有落盘、能否续跑 | 一中断就得从头再来 |
写在最后
007 解决的是"能不能跑通",这篇解决的是"敢不敢挂着它长期跑、放心跑批量"。听上去多了一堆代码,其实就三个朴素的念头:失败了别蛮干,给彼此留缓冲;钱要花在明处,先设好止损;批量要排队、要能恢复,别一条崩全崩。 这些正是 Cursor、Claude Code 这类成熟工具在后台默默替你做的事,你自己接 API 时把它们补上,才算真的把模型变成了自己程序里一个稳定、可控、算得清账的组件。
评论区留个数字,说说你现在把模型接进自己项目到哪一步了:1、还在跑通第一行的阶段,没到工程化;2、自己跑批量,但经常被限流或者中途崩;3、有重试和预算,但批量排队、断点续跑还没做;4、三块都有了,已经能放心挂着过夜跑。 票数最高的那档,我下一篇往细里拆。
下一篇我打算写:同一批任务,怎么在不同档位的模型之间分工——哪些活值得用旗舰、哪些杂活换便宜模型,怎么把一次任务在强弱模型之间串起来,既不掉质量又能明显压成本,具体模型名以官方当期文档为准。
觉得有用,点个赞、收藏起来,等你自己接第一个要长期跑的脚本时,对着清单一条条勾;顺手关注一下,这个系列我一篇篇往下更。