批处理跑到一半断了:怎么续跑又不重复扣 USDC
批处理中断后续跑的核心是两件事:把每次调用的状态以幂等方式落到本地(自己生成请求 ID,先记录后调用),以及把计费核对做成独立步骤——用幂等键去重、用账单快照对账,而不是靠重跑整个批次来猜。本文给出可落地的断点记录结构、续跑判定逻辑和 USDC 扣费核对流程。
批处理最怕的不是崩溃,而是崩溃之后你不知道哪些调用已经花过钱。重跑整个批次可能重复计费,跳过整段又可能漏掉数据。下面是一套可操作的断点记录与幂等处理做法。
先想清楚:续跑的目标状态是什么
在设计记录之前,先明确三件事:
- 每条任务有唯一业务键。比如
item_id,来自你自己的数据,不依赖 API 返回。 - 每条任务有明确的状态机。建议至少四个状态:
pending、inflight、done、failed_permanent。 - 一次调用可能发生但没记上。崩溃点最可能落在「请求已发出、响应已收费、本地还没写」这个窗口里,这是设计重点。
断点记录:先写后调,而不是先调后写
常见错误是调用成功后再写结果表。崩溃在两者之间,就会留下「已扣费但状态仍是 pending」的记录,续跑时重放导致重复扣费。
反过来做:调用前先把状态改成 inflight 并落盘(或提交事务),拿到响应后再改成 done。这样崩溃后至少有两种可判定的状态,而不是一律当成未处理。
一个够用的记录结构字段示例:
item_id:业务主键,唯一索引attempt:第几次尝试,从 1 递增idempotencykey:由itemid+attempt稳定生成,不要用随机数state:状态机取值request_hash:请求体规范化后的哈希,防止同 key 但换了输入startedat/finishedat:用于判断 inflight 是否已成僵尸usage与raw_response:模型返回的用量字段与原始响应,供对账用charged:本地估算的扣费标记,最终以账单为准
注意 idempotency_key 一定要能从业务数据确定性重算。如果它是随机 UUID 且只存在内存里,崩溃后就没法复用同一个键,幂等就失效了。
续跑判定:三种状态三种处理
续跑脚本启动时,只扫非终态记录,按状态分流:
pending:还没发出过请求,直接跑。done:已完成,跳过,不产生任何请求。inflight:最需要小心的一类。用started_at加一个你自己设定的超时阈值判断:超过阈值仍未完成,视为僵尸记录。
对僵尸记录,不要直接重发,也不要直接标成功。推荐两种策略,按业务容错度选:
- 带同一幂等键重试。如果上游按幂等键去重,重发是安全的,响应会返回同一结果,不会重复计费。前提是你要确认聚合层对幂等键的行为,并把它当作契约写进代码注释。
- 先查后补。如果拿不到幂等保证,就把这条记录单独挑出来,人工或用账单侧信息判断是否已扣费,再决定补发还是标记完成。
无论哪种,都要把 attempt 加一,并把旧的 inflight 记录保留为审计行,不要覆盖。
幂等的两个层次
真正干净的续跑需要两层幂等:
- 请求层:同一
idempotency_key重发不产生第二次实际调用。这依赖上游支持,也依赖你稳定生成键。 - 落库层:写结果的操作用
item_id做唯一约束或 upsert,重复写入不产生重复行、不重复累加统计。
两层缺一不可。只有落库层幂等,仍可能白花一次钱;只有请求层幂等,统计数字仍可能被重复累加。
核对哪些调用已被计费
续跑的正确性不能只靠自己记的状态,最终要拿账单对。做法是把「对账」做成独立于执行流程的一步:
- 按时间段拉取用量/账单明细,取每个时间窗的调用记录与用量字段。
- 用你本地记录的
idempotency_key或你自己的请求标识做关联。如果你在请求头或元数据里带上了业务 ID,这一步会容易得多;做不到就退化为按时间、模型、用量三元组近似匹配。 - 比对三类差异:账单有、本地无(说明崩溃窗口里扣了费);本地有 done、账单无(可能记录写早了或调用未真正成功);两边都有但用量不一致。
- 把对账结果写回记录,为每条补上「已计费确认」字段。续跑脚本可以只信任这个字段而不是本地状态。
对账要按时间窗滚动做,不要等整批跑完再对。批处理量大时,全量对账的成本和延迟都会失控。
USDC 充值与余额侧的注意点
站点按 USDC 充值、无 KYC、一个 key 调多个模型,用户按官方价 ×1.3 扣费。这一点对续跑设计有两个实际影响:
- 扣费是按调用实际发生的,重发一次就是真实的一次成本。所以幂等键的价值直接等于金额,不是纯粹的技术洁癖。
- 余额可能在中途不足。续跑脚本要能识别「余额不足」这类失败,把它归为
pending而不是failed_permanent,等充值后继续,而不是把整批标成永久失败。
余额不足和模型报错要分开处理:前者可恢复,后者按你的重试策略决定是否升级为永久失败。
一个最小可用的续跑流程
把上面拼起来,落到脚本里大致是:
- 加载所有非终态记录,按状态分组。
- 对
inflight且超时的记录,标记为待复核,不进入自动重发队列(除非你确认了幂等契约)。 - 对
pending与可重试的inflight,用确定性幂等键提交请求。 - 每次响应先写
done与用量,再更新统计。 - 收尾时跑一次对账,把账单窗口内的调用与本地记录关联,输出差异清单。
- 把差异清单里「账单有、本地无」的条目单独处理,而不是整体重跑。
这套流程的关键不是把失败率降到零,而是让每次失败都可判定、可对账、可续跑。批处理本身会断,设计目标是断了之后花的每一分 USDC 都能说清楚去向。