文章目录
多步脚本最怕跑到一半挂掉:前面已经生成了中间产物,却只能从头再来。本文讲一套可落地的做法——用 state.json 持久化进度,用步骤表编排执行,用 run_id / --from-step 在失败点接着跑。

问题与目标
典型痛点
一条流水线常拆成:读入参数 → 生成产物 → 后处理 → 校验。任一步超时、人工中断或外部依赖失败,都会让进程退出。若内存里的进度不落盘,重启只能重跑整链,浪费算力和时间。
设计目标
- 可续跑:同一
run_id再次启动时,读已有状态,从指定步骤继续 - 可单步:调试某一环节时只跑一步,不污染前后产物
- 可核对:状态文件能直接打开看
finished_steps、关键路径、业务字段
状态怎么建模
一次运行一个目录
每次执行分配一个 run_id(常用时间戳 YYYYMMDD-HHMMSS),产物与状态都落在同一工作目录,例如 build/pipeline/<run_id>/。续跑时只传 run_id,就能定位目录并加载 state.json。
最小字段集
用 dataclass 描述状态,序列化成 JSON。核心字段建议固定三类:
| 类别 | 字段示例 | 用途 |
|---|---|---|
| 身份 | run_id, work_dir | 定位本次运行 |
| 进度 | finished_steps | 已成功完成的步骤名列表 |
| 产物 | input_path, output_path, 业务字段 | 后续步骤读上游结果 |
示意:
from dataclasses import asdict, dataclass, field, fields
import json
from pathlib import Path
from typing import Any
@dataclass
class WfState:
run_id: str
prompt: str = ""
work_dir: str = ""
output_path: str = ""
finished_steps: list[str] = field(default_factory=list)
def to_json(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_json(cls, data: dict[str, Any]) -> "WfState":
known = {f.name for f in fields(cls)}
return cls(**{k: v for k, v in data.items() if k in known})
def save_state(state: WfState, work_dir: Path) -> None:
work_dir.mkdir(parents=True, exist_ok=True)
path = work_dir / "state.json"
path.write_text(
json.dumps(state.to_json(), ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
from_json 用 fields 过滤未知键,状态 schema 演进时旧文件仍能加载,少踩一次升级坑。

何时写入磁盘
落盘时机比字段设计更关键。至少覆盖:
- 步骤成功:
mark_done追加步骤名后立刻save_state - 步骤失败 / 抛异常:仍保存当前已写入的产物路径,方便排查
KeyboardInterrupt:用户 Ctrl+C 也要先写盘再退出,否则「差一点跑完」的进度会丢
def mark_done(state: WfState, step: str) -> None:
if step not in state.finished_steps:
state.finished_steps.append(step)
路径建议存相对仓库根的 POSIX 字符串(as_posix()),跨 Win / macOS 读回更稳。
编排器:选步、跳步、续跑
步骤表是唯一真相
把步骤名放进有序列表,别在业务里硬编码「下一步是谁」:
STEPS = (
"step-01-input",
"step-02-transform",
"step-03-validate",
"step-04-export",
)
def select_steps(step: str, from_step: str) -> list[str]:
if step and from_step:
raise ValueError("step 与 from_step 不能同时使用")
if step:
if step not in STEPS:
raise ValueError(f"未知步骤: {step}")
return [step]
if from_step:
if from_step not in STEPS:
raise ValueError(f"未知步骤: {from_step}")
return list(STEPS[STEPS.index(from_step) :])
return list(STEPS)
三种用法对应三种场景:
- 不传:全量新跑
--step step-02-transform:只重跑中间一步(调试)--from-step step-03-validate --run-id 20260302-143000:从失败点续到结束

主循环骨架
def run_workflow(run_id: str, step: str = "", from_step: str = "") -> int:
work = Path("build/pipeline") / run_id
prev = load_state(work) # 无文件则 None
state = prev or WfState(run_id=run_id)
state.work_dir = str(work)
steps = select_steps(step, from_step)
save_state(state, work)
for name in steps:
try:
code = load_step(name)(state) # 约定 run(state) -> int
except KeyboardInterrupt:
save_state(state, work)
return 130
except Exception:
save_state(state, work)
return 1
if code != 0:
save_state(state, work)
return code
return 0
每步单独文件、统一 run(state) -> int,编排器用动态加载即可。步骤内部负责:读上游字段 → 写产物 → mark_done → save_state。编排器只关心退出码与是否继续。
幂等:重跑同一步时怎么办
续跑常会再次进入已完成过的步骤名(例如 --from-step 从中间开始,但该步产物其实还在)。常见两种策略:
- 跳过:若
step已在finished_steps且产物文件存在,直接 return 0 - 覆盖:调试场景强制重算,并覆盖对应字段
把策略写进步骤开头,比在编排器里塞特例更清晰。
落地时的取舍
JSON 够用,别一上来上数据库
单机脚本、产物本就在磁盘上时,state.json 足够:可读、可 diff、可手工改字段救急。只有多机并发抢同一任务时,才值得换成 DB 行锁或队列 ack。
运行时标志不要硬持久化
像「退出时是否关浏览器」「本次是否 dry-run」这类 本次会话 开关,可以进内存字段,但序列化时剔除(to_json 里 pop),避免上次调试参数污染下次正式跑。
CLI 与函数拆开
业务入口做成 run_workflow(...),CLI 只做 argparse 再调用。长文本参数走函数,不要全塞进命令行——Windows 上尤其容易被截断。
中断也是正常路径
Ctrl+C 退出码用 130,日志打 [SKIP] 已中断 即可,不必当未捕获异常。重点是:中断前写盘、释放外部资源(子进程、文件句柄),别留半截占用。

实战里,把「步骤表 + 状态对象 + 每步落盘」三件事钉死,多步流水线就能从「能跑」变成「能断、能接、能查」。接下来你要改的,往往只是给 WfState 多加几个业务字段,而不是重写编排器。
53

被折叠的 条评论
为什么被折叠?



