把大模型 API 接进自己项目天天跑之后,我补了三块最缺的写法:失败重试、成本闸门、批量排队

2 阅读11分钟

上一篇讲了怎么把第一行请求跑通,结尾我留了个话头:跑通只是开始,等你真把模型接进自己的脚本、接进后端、开始定时跑批量,真正磨人的事才来。这篇就把这三件事一次讲透——请求失败怎么重试、成本怎么在调用侧先盯住、批量任务怎么排队。

先说个背景,免得你觉得这些是"大公司才需要"的东西。你在 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、三块都有了,已经能放心挂着过夜跑。 票数最高的那档,我下一篇往细里拆。

下一篇我打算写:同一批任务,怎么在不同档位的模型之间分工——哪些活值得用旗舰、哪些杂活换便宜模型,怎么把一次任务在强弱模型之间串起来,既不掉质量又能明显压成本,具体模型名以官方当期文档为准。

觉得有用,点个赞、收藏起来,等你自己接第一个要长期跑的脚本时,对着清单一条条勾;顺手关注一下,这个系列我一篇篇往下更。