一个跑 4 小时的批量任务,在第 3 小时因为网络抖动挂掉。这时候真正值钱的问题不是"怎么让它不挂",而是"怎么知道它跑到哪了,以及剩下的 1 小时能不能只跑剩下的"。
这篇文章讲的是长任务(批量生成、批量爬取、数据集构建、批量转码/上传)的工程化:清单、账本、幂等键、状态指纹、对账。全文是踩坑后总结的,不是教科书。
短任务(<60s)的失败处理方式很简单:整段重跑。代价可接受。
长任务不行。一个 200 项、每项 90 秒的批量任务:
于是长任务失败会发生两件事,而且第二件更贵:
第 2 点是长任务的第一性问题。工程上有一句很实用的话:没有账本的长任务,失败一次就从零开始;有账本的长任务,失败一次只损失"正在跑的那一项"。
| 维度 | 短任务 | 长任务 |
|---|---|---|
| 恢复策略 | 整段重跑 | 按项续跑 |
| 状态存放 | 内存 / 局部变量 | 外部账本(落盘或数据库) |
| 失败处理 | 抛异常 → 退出码 | 记失败项 → 继续 → 最后统一重试 |
| 幂等要求 | 弱 | 强(写操作必须可重复执行) |
| 可观测性 | 结束时打日志 | 每一秒都能回答"现在跑到第几项" |
| 关键指标 | 是否成功 | 单位时间完成项数 + 失败项占比 |
判据:如果你的任务满足"单项耗时 × 项数 > 10 分钟",就应该按长任务设计;如果"重跑一次的成本 > 10 分钟",就必须按长任务设计。没有第三种。
不管任务形态是"批量下载 500 个页面"还是"逐场景生成 44 段视频",能恢复的长任务都要有这四样东西:
① 清单 manifest —— 这次要做哪些项(不可变,带版本号)
② 账本 ledger —— 每项做到了什么状态(只追加,不改写历史)
③ 幂等键 idem_key —— 重复执行同一项时,能识别出"这就是同一件事"
④ 恢复入口 resume —— 一个命令:读清单 + 读账本 → 得到待办集合 → 开跑
这四样缺任何一样,"断点续跑"就退化成一个口号。
清单变了但账本没变,就会出现"账本说 A 项完成,但新清单里 A 项的定义已经换了"——这是最隐蔽的一类脏数据。
做法:清单本身带一个内容哈希,写进账本的每一行。
import hashlib, json
def manifest_hash(items: list[dict]) -> str:
"""清单内容哈希:项顺序、内容任一变化都会变"""
blob = json.dumps(items, sort_keys=True, ensure_ascii=False).encode("utf-8")
return hashlib.sha256(blob).hexdigest()[:12]
跑之前先比一下:账本里记录的 mh 和当前清单的 mh 不一致 → 要么换新账本,要么显式确认复用。不允许静默复用。
"断点续跑"不是一个粒度,是四个。粒度越细,恢复成本越低,但记账开销越高。
| 粒度 | 恢复成本 | 记账开销 | 适用场景 |
|---|---|---|---|
| 整任务重跑 | 100% | 0 | 总时长 < 10 分钟 |
| 分段(每 N 项一个检查点) | 1/N | 极低 | 项之间强耦合(有共享上下文) |
| 逐项(每项一个检查点) | 1 项 | 低(每项一行 JSON) | 默认推荐:项之间独立 |
| 增量(项内可续传) | < 1 项 | 高(要处理部分产物) | 单项耗时很长(>10 分钟,如大文件下载、长视频渲染) |
选择规则:
ffmpeg 分段、curl -C -、数据集分片训练);不支持就承认它是"不可恢复原子项",靠缩短单项来降风险(把 1 个 30 分钟的任务切成 6 个 5 分钟的子任务)。真实教训:一批 44 段的视频生成任务,如果按"整批提交、等全部完成"来做,任何一段挂掉都要重新排队整批。改成"逐段提交 + 逐段记状态"之后,挂掉的重新提交成本是 4 分钟,不是 3 小时。
账本的正确形态是 JSONL(一行一条 JSON),只追加,不修改历史行。
{"ts":"2026-09-21T09:14:02+08:00","mh":"a3f9c1b2e004","item":"sc_017","attempt":1,"status":"ok","artifact":"out/sc_017.mp4","bytes":4821093,"cost_usd":0.031,"dur_s":87.4}
{"ts":"2026-09-21T09:15:31+08:00","mh":"a3f9c1b2e004","item":"sc_018","attempt":1,"status":"fail","err":"timeout: read 30s","dur_s":30.2}
{"ts":"2026-09-21T09:16:44+08:00","mh":"a3f9c1b2e004","item":"sc_018","attempt":2,"status":"ok","artifact":"out/sc_018.mp4","bytes":5011777,"cost_usd":0.029,"dur_s":73.1}
| 字段 | 必填 | 说明 |
|---|---|---|
ts |
✅ | 带时区的 ISO 时间戳。不带时区的本地时间会在跨天/跨时区复盘时坑死你 |
mh |
✅ | 清单哈希,防"清单换了账本没换" |
item |
✅ | 项 ID。稳定、可排序、不含路径分隔符 |
attempt |
✅ | 第几次尝试(从 1 开始)。重试也是历史,不能覆盖 |
status |
✅ | ok / fail / skip。只允许这三种,不要发明 partial(见第七节) |
artifact |
ok 时必填 | 产物路径或 URL。账本里没有 artifact 的 ok 是假成功 |
err |
fail 时必填 | 错误摘要(截断到 200 字符)+ 错误类别前缀(timeout: / http403: / oom:) |
dur_s |
建议 | 单项耗时,用来估算剩余时间 |
cost_usd |
建议 | 单项成本,用来做预算熔断 |
bytes |
建议 | 产物大小,用来抓"成功但产出为 0 字节" |
可以,但要注意两点:
status 被反复覆盖后,"这项重试了几次、失败原因是什么"就永久消失了。长任务的失败模式诊断恰恰依赖这些。要么双写事件表,要么直接用 JSONL。database is locked。JSONL 用"一行一次 write() + 文件锁"或"每项一个文件+qps 合并"更省心。回放能力是 JSONL 的隐藏价值:任何时刻可以用几行代码把账本重放成"当前状态视图",而数据库里你只能看到最终快照。
def current_state(ledger_path: str) -> dict[str, dict]:
"""把 append-only 账本压成当前状态(最后一次 attempt 说了算)"""
state: dict[str, dict] = {}
with open(ledger_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
r = json.loads(line)
state[r["item"]] = r # 后写的覆盖记录,历史仍在文件里
return state
def todo(items: list[dict], state: dict[str, dict], max_attempt: int = 3) -> list[dict]:
out = []
for it in items:
s = state.get(it["id"])
if s is None or (s["status"] == "fail" and s["attempt"] < max_attempt):
out.append(it)
return out
断点续跑的前提是"同一项跑两遍,结果一样、副作用只发生一次"。三种写法,按可靠性排序:
让下游接受一个调用方生成的幂等键,同一个键重复提交只生效一次。
def idem_key(mh: str, item: str) -> str:
return hashlib.sha256(f"{mh}:{item}".encode()).hexdigest()[:32]
def ensure_artifact(item_id: str) -> str:
path = f"out/{item_id}.json"
if os.path.exists(path) and os.path.getsize(path) > 0:
return path # 已存在且非空 → 视为完成,不重复生成
data = generate(item_id)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
os.replace(tmp, path) # 原子替换,防"半截文件被当成完成"
return path
注意 os.replace + .tmp 这个组合:直接写目标文件的话,进程在第 50% 处被杀,会留下一个"存在但残缺"的文件,而"存在"就是幂等判据——于是残缺文件被永久当成成功产物。先写临时文件、再原子改名,是长任务里最便宜的防呆。
产物自带唯一标识(URL、内容哈希、文件名含业务键)时用它去重。适合"结果集合"型任务。
seen = {json.loads(l)["cid"] for l in open("out/items.jsonl", encoding="utf-8") if l.strip()}
if cand["cid"] in seen:
continue
别用写法 4:"我记得刚才好像做过"(内存标记 / 只靠日志)。进程一重启就失灵。
断点续跑有一个反直觉的风险:它会把旧版本的产物当成当前版本的产物复用。
场景:你改了提示词模板或生成参数,想重跑全部 200 项。但账本说 200 项都是 ok,于是 resume 逻辑把 200 项全部跳过——产物还是旧版本的。
解法:把"决定产出内容的东西"做成指纹,进账本的每一行。
import hashlib, json, os
def config_fingerprint(config: dict) -> str:
"""影响产出的配置指纹:模型版本、提示词模板哈希、参数、下游 schema 版本"""
keys = ["model", "prompt_template_hash", "temperature", "max_tokens", "output_schema_ver"]
sub = {k: config.get(k) for k in keys}
return hashlib.sha256(json.dumps(sub, sort_keys=True).encode()).hexdigest()[:12]
账本行里多两个字段:cf(配置指纹)、v(脚本版本)。resume 的判据从"这项 ok 过"变成:
这项 ok 过 且
mh相同 且cf相同 且 产物文件存在且非空
任何一条不满足 → 重新执行。指纹变了的项要显式记账为"因指纹变化重跑",否则复盘时你会很奇怪"为什么这项跑了两次"。
指纹粒度别做太细。把 temperature 甚至随机种子都算进去,会导致"每次跑都全量重跑",续跑能力归零。经验值:指纹只包含会导致产物语义变化的东西——模型 ID、提示词模板、关键参数、输出结构版本。纯粹的日志级别、并发数、超时秒数不进指纹。
把上面所有东西合成一个小执行器。直接照抄可用。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""可恢复批量执行器:清单 + 账本 + 幂等 + 对账。"""
import hashlib, json, os, time, traceback
from datetime import datetime, timezone, timedelta
CST = timezone(timedelta(hours=8))
def now() -> str:
return datetime.now(CST).isoformat(timespec="seconds")
class Ledger:
def __init__(self, path: str):
self.path = path
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
def append(self, rec: dict) -> None:
line = json.dumps(rec, ensure_ascii=False) + "\n"
with open(self.path, "a", encoding="utf-8") as f:
f.write(line)
f.flush()
os.fsync(f.fileno()) # 崩溃前必须落盘
def replay(self) -> dict[str, dict]:
if not os.path.exists(self.path):
return {}
state = {}
with open(self.path, encoding="utf-8") as f:
for line in f:
if line.strip():
r = json.loads(line)
state[r["item"]] = r
return state
def artifact_ok(path: str | None) -> bool:
return bool(path) and os.path.exists(path) and os.path.getsize(path) > 0
class ResumableRunner:
def __init__(self, name: str, items: list[dict], cfg: dict,
run_one, out_dir: str = "out", max_attempt: int = 3):
self.name, self.items, self.run_one = name, items, run_one
self.out_dir, self.max_attempt = out_dir, max_attempt
self.mh = hashlib.sha256(
json.dumps(items, sort_keys=True, ensure_ascii=False).encode()).hexdigest()[:12]
self.cf = hashlib.sha256(
json.dumps(cfg, sort_keys=True, ensure_ascii=False).encode()).hexdigest()[:12]
self.ledger = Ledger(f"state/{name}.jsonl")
def pending(self) -> list[dict]:
state, out = self.ledger.replay(), []
for it in self.items:
r = state.get(str(it["id"]))
fresh = r and r.get("mh") == self.mh and r.get("cf") == self.cf
if fresh and r["status"] == "ok" and artifact_ok(r.get("artifact")):
continue # 真完成,跳过
if not fresh and r and r["status"] == "ok":
out.append({**it, "_reason": "fingerprint_changed"})
continue # 陈旧状态,重跑
if r and r["status"] == "fail" and r["attempt"] >= self.max_attempt:
continue # 超次数,交给人工
out.append(it)
return out
def run(self, limit: int | None = None) -> dict:
todo = self.pending()[:limit] if limit else self.pending()
state0 = self.ledger.replay()
print(f"[{self.name}] mh={self.mh} cf={self.cf} total={len(self.items)} "
f"done={len(self.items) - len(todo)} todo={len(todo)}")
stats = {"ok": 0, "fail": 0}
for i, it in enumerate(todo, 1):
iid = str(it["id"])
attempt = (state0.get(iid, {}).get("attempt") or 0) + 1
t0 = time.time()
try:
artifact = self.run_one(it)
rec = {"ts": now(), "mh": self.mh, "cf": self.cf, "item": iid,
"attempt": attempt, "status": "ok", "artifact": artifact,
"bytes": os.path.getsize(artifact) if artifact and os.path.exists(artifact) else 0,
"dur_s": round(time.time() - t0, 1)}
stats["ok"] += 1
except Exception as e:
rec = {"ts": now(), "mh": self.mh, "cf": self.cf, "item": iid,
"attempt": attempt, "status": "fail",
"err": f"{type(e).__name__}: {str(e)[:180]}",
"trace": traceback.format_exc()[-400:],
"dur_s": round(time.time() - t0, 1)}
stats["fail"] += 1
self.ledger.append(rec) # 无论成败都记账
print(f" [{i}/{len(todo)}] {iid} {rec['status']} ({rec['dur_s']}s)")
return stats
注意 run() 里的两个刻意的设计:
attempt 计数做熔断。配合一个 --retry-failed 入口,长任务就具备了完整的自我恢复能力:
python run_batch.py # 首跑,断哪算哪
python run_batch.py # 直接再跑一遍(自动续跑,只补缺口)
python run_batch.py --retry-failed # 只重试失败项(读取账本,max_attempt 内)
python run_batch.py --reconcile # 只做对账,不执行
逐项执行是串行的,5 小时太长。加并发,但并发会引入三个新问题:
不要用 time.sleep(1) 这种手写节流。用令牌桶 + per-provider 独立桶:
import threading, time
class TokenBucket:
"""线程安全的令牌桶:capacity 个突发配额,每秒补充 rate 个"""
def __init__(self, rate: float, capacity: int):
self.rate, self.cap, self.tokens = rate, capacity, float(capacity)
self.lock, self.last = threading.Lock(), time.monotonic()
def take(self, n: int = 1) -> None:
while True:
with self.lock:
now = time.monotonic()
self.tokens = min(self.cap, self.tokens + (now - self.last) * self.rate)
self.last = now
if self.tokens >= n:
self.tokens -= n
return
need = (n - self.tokens) / self.rate
time.sleep(min(need, 1.0))
不同供应商(模型 API / 图床 / 视频接口)各一个桶——它们的限额互不相干。
对 IO 密集任务,并发度取 限额 / 单项平均耗时 的倒数关系。经验值:
三个保护:
timeout=90),超时按 fail 记账,继续下一项consecutive_fail = 0
for it in todo:
...
consecutive_fail = consecutive_fail + 1 if rec["status"] == "fail" else 0
if consecutive_fail >= 8:
print("连续 8 项失败,疑似系统性故障,停机")
break
跑完不等于跑对。长任务结束(以及每天定时)都要做一次三方对账:
清单(应该有哪些) vs 账本(记录了哪些) vs 实际产物(磁盘上真的有哪些)
三方对账要能回答这四个问题:
| 问题 | 判据 | 严重性 |
|---|---|---|
| 有项漏跑 | 清单项在账本里没有任何记录 | 🔴 高 |
| 有项假成功 | 账本 ok 但产物不存在 / 0 字节 |
🔴 高 |
| 有孤儿产物 | 磁盘上有产物但账本没记录(手工生成的、或账本写失败) | 🟡 中 |
| 有项反复失败 | 同一项 fail 次数 ≥ 3 |
🟡 中 |
def reconcile(items: list[dict], ledger_path: str, out_dir: str) -> dict:
state = Ledger(ledger_path).replay()
ids = {str(i["id"]) for i in items}
missing = sorted(i for i in ids if i not in state)
fake_ok = sorted(i for i, r in state.items()
if r["status"] == "ok" and not artifact_ok(r.get("artifact")))
stuck = sorted(i for i, r in state.items()
if r["status"] == "fail" and r["attempt"] >= 3)
produced = {f.rsplit(".", 1)[0] for f in os.listdir(out_dir)} if os.path.isdir(out_dir) else set()
orphan = sorted(produced - ids)
return {"missing": missing, "fake_ok": fake_ok, "stuck": stuck, "orphan": orphan}
这是对账最有价值的一条延伸,也是定时长任务的关键:
"这次任务没产出"不是一个事件,"在预期时间窗口内该出现的产物不存在"才是一个事件。
做法:给每个长任务声明期望窗口(如"每天 09:00 前必须有当天的账本文件,且 ok 数 ≥ 阈值"),让一个独立的检查器(不是长任务自己)来验:
def absence_check(ledger_path: str, expect_ok: int, window_hours: int = 26) -> str | None:
"""返回 None=正常,否则返回告警文本"""
if not os.path.exists(ledger_path):
return f"账本文件不存在:{ledger_path}"
rows = [json.loads(l) for l in open(ledger_path, encoding="utf-8") if l.strip()]
if not rows:
return "账本为空"
latest = max(r["ts"] for r in rows)
if (datetime.now(CST) - datetime.fromisoformat(latest)).total_seconds() > window_hours * 3600:
return f"最近一次记账在 {latest},已超过 {window_hours}h 无新记录"
today_ok = sum(1 for r in rows if r["ts"][:10] == datetime.now(CST).date().isoformat()
and r["status"] == "ok")
if today_ok < expect_ok:
return f"今日 ok 数 {today_ok} < 期望 {expect_ok}"
return None
检查器必须独立于被检查的任务。让任务自己汇报"我正常",等于让被告当法官——任务挂掉的时候,恰恰是它没法汇报的时候。
| 现象 | 根因 | 修法 |
|---|---|---|
| 重跑后产物翻倍(同样的东西生成两遍) | 幂等判据是"内存里有没有跑过" | 幂等判据改成"产物存在且非空";写临时文件 + os.replace 原子落盘 |
| resume 之后产出还是旧版本内容 | 账本没有配置指纹,改了提示词/参数后全部被跳过 | 账本行加 cf(配置指纹)+ mh(清单哈希),resume 判据包含两者 |
| 账本说 173 项 ok,实际磁盘只有 171 个文件 | 先写账本后写产物(顺序反了),中间被杀 | 顺序必须是:写产物(原子) → 校验产物 → 才写 ok 行。账本是产物的从属,不能领先于产物 |
| 3 小时的批量任务,一次挂了以后重跑 3 小时 | 逐项状态没落盘,只有最后汇总 | 逐项 append + fsync;哪怕每项只记 4 个字段 |
| 定时长任务"看起来每天在跑",但一周没产出 | 只看退出码/last_status=ok,没人验产物 |
独立缺勤检测器:窗口内应有的产物不存在即告警;对账三方比对 |
另外两个容易忽略的:
+08:00 或 UTC。os.path.exists() 对 0 字节文件返回 True。判据必须带 getsize() > 0(必要时还要带"文件可解析"的校验,比如 JSON 能 load、视频能 ffprobe)。清单与身份
账本
flush + fsync(或等价的持久化保证)attempt 计数、status 三态、artifact、错误类别前缀--reconcile 只用账本不执行的模式幂等
指纹
并发与隔离
sleep对账与缺勤
长任务的工程本质是一句话:把不可恢复的过程,拆成可恢复的项,并为每一项留下可被外部核对的痕迹。
模型能力决定单项做得好不好;清单、账本、幂等、指纹、对账这五样东西,决定的是"跑到一半崩了以后,你还剩多少"。前者靠换模型提升,后者只能靠设计。
先把账本写出来,其他都会跟着清晰。
本文是「AI Agent 工程实战」系列之一。相关系列:
- AI Agent工具调用实战指南
- AI Agent自动化可靠性实战指南
- AI Agent定时任务实战指南
- AI Agent成本优化实战指南
📚 更多创作资源: https://afdian.com/a/felix007