Python
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
案例 10 · TLF 生成流水线(任务规划与并行调度)
==============================================
**目标**:把"生成一套 TLF"从**一串 if 和 shell 脚本**,
变成一张**可校验、可并行、可重试、可断点续跑**的任务图。
**覆盖章节**:第 19 章(任务规划与调度)
为什么"流水线"值得单独做一张图
------------------------------
传统做法是一个 `run_all.sas` / `run_all.sh` 顺序调 11 个程序。
它有三个说不出口的问题:
==================== ============================== ============================
问题 顺序脚本的表现 任务图的表现
==================== ============================== ============================
跑得慢 11 个程序一个个跑 **没依赖的一起跑**
失败后怎么办 停在那里,人肉判断从哪接着跑 失败传播明确:下游 skipped
重跑一遍 全部重跑,浪费时间 只跑未完成的(断点续跑)
"为什么表 3 要等表 2" 没人知道,靠口头传承 `depends_on` 写在代码里
出错原因 日志里翻半天 每个任务带 error / error_type
==================== ============================== ============================
任务图的核心不是"并行"(那是收益),而是**显式依赖**(那是正确性)。
五个场景
--------
a) **需求 → DAG** 拆解、静态校验、拓扑排序、dry-run
b) **并行调度** 分层执行 + 真实耗时对比(含 I/O 型任务的加速比)
c) **失败与重试** transient 重试、可选任务不阻塞、失败传播
d) **部分失败汇总** "结论仅覆盖已完成部分" —— 这句话必须由框架生成
e) **HITL 与断点续跑** 写操作暂停 → 计划存盘 → 确认后只跑未完成的
运行
----
python cases/case10_TLF生成流水线.py
python cases/case10_TLF生成流水线.py --only b
python cases/case10_TLF生成流水线.py --serial # 强制单线程对比
离线可跑:工具真实执行,产物写到 outputs/tlf/。
"""
from __future__ import annotations
import argparse
import sys
import time
from pathlib import Path
BASE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(BASE))
import clinic.agent_tools as T # noqa: E402
from clinic import agent_core as core # noqa: E402
from clinic import agent_planner as planmod # noqa: E402
from clinic import report as rpt # noqa: E402
OUT = BASE / "outputs"
TLF_DIR = OUT / "tlf"
ALLOWED = frozenset(T.ALLOWED_DATASETS)
# ==========================================================================
# 工具层:TLF 生成流水线实际要调的动作
# ==========================================================================
_IO_WAIT = 0.25 # 模拟"从外部数据源取快照"的等待时间(见下方说明)
_fail_counter: dict[str, int] = {}
def build_registry() -> core.ToolRegistry:
reg = core.ToolRegistry()
@reg.tool("fetch_snapshot",
"从数据源取某个域的快照元信息。这是**模拟外部接口调用**的工具,"
"用于演示 I/O 等待型任务的并行收益。",
{"type": "object",
"properties": {
"dataset": {"type": "string", "enum": sorted(ALLOWED),
"description": "要取快照的数据集"},
"source": {"type": "string",
"description": "数据源标识,如 edc / cdisc-ct"}},
"required": ["dataset"], "additionalProperties": False})
def _snapshot(args: dict) -> dict:
# ★ 生产里这里是 HTTP 请求 / 数据库查询 —— 时间花在"等"上,不花在 CPU 上。
# 这正是线程池能带来加速的原因(第 19.5 节)。
time.sleep(_IO_WAIT)
d = T.describe_dataset(args["dataset"])
return {"数据集": args["dataset"],
"数据源": args.get("source") or "edc",
"行数": d["行数"], "列数": d["列数"],
"快照时间": time.strftime("%Y-%m-%dT%H:%M:%S")}
@reg.tool("describe_dataset",
"查看数据集结构(变量、缺失率、示例值)。",
{"type": "object",
"properties": {
"dataset": {"type": "string", "enum": sorted(ALLOWED)}},
"required": ["dataset"], "additionalProperties": False})
def _describe(args: dict) -> dict:
return T.describe_dataset(args["dataset"])
@reg.tool("demo_table",
"生成人口学与基线特征表(Table 1)的数据:按治疗组的年龄描述统计"
"与性别、种族频数。返回可直接渲染成报表的行数据。",
{"type": "object",
"properties": {
"dataset": {"type": "string", "enum": sorted(ALLOWED),
"description": "分析人群数据集,通常是 adsl"}},
"required": ["dataset"], "additionalProperties": False})
def _demo(args: dict) -> dict:
ds = args["dataset"]
age = T.summarize_by_group(ds, "TRT01P", "AGE")
sex = T.frequency(ds, "SEX")
race = T.frequency(ds, "RACE")
return {"表": "Table 1 · 人口学与基线特征",
"人群": ds,
"年龄描述统计": age["结果"],
"性别分布": sex.get("分布"),
"种族分布": race.get("分布"),
"注": "分母为各治疗组分析人群人数;SD 为样本标准差(ddof=1)"}
@reg.tool("ae_table",
"生成不良事件汇总表(Table 2)的数据:按 SOC 或 PT 的治疗中出现"
"不良事件受试者数与百分比。",
{"type": "object",
"properties": {
"level": {"type": "string", "enum": ["soc", "pt"],
"description": "汇总层级"},
"treatment_only": {"type": "boolean",
"description": "是否只统计 TEAE,默认 true"},
"top_n": {"type": "integer", "minimum": 1, "maximum": 50,
"description": "返回前多少个 SOC/PT,默认 10"}},
"required": ["level"], "additionalProperties": False})
def _ae(args: dict) -> dict:
return T.count_events(level=args["level"],
treatment_only=args.get("treatment_only", True),
top_n=int(args.get("top_n") or 10))
@reg.tool("shift_table",
"生成实验室指标移位表(Table 3)的数据:基线正常性分类 × "
"基线后正常性分类的交叉表,单元格为 n (%)。",
{"type": "object",
"properties": {
"param": {"type": "string",
"description": "实验室参数代码,如 ALT / CREAT / HGB"},
"pct": {"type": "string", "enum": ["row", "col", "total"],
"description": "百分比分母:行/列/总计,默认 row"}},
"required": ["param"], "additionalProperties": False})
def _shift(args: dict) -> dict:
df = T._load("adlbc_shift")
param = str(args["param"]).strip().upper()
sub = df[df["PARAMCD"].astype(str).str.strip() == param].copy()
if sub.empty:
raise ValueError(
f"参数 {param!r} 不存在。可选值:"
f"{sorted(df['PARAMCD'].astype(str).str.strip().unique())[:20]}")
# 基线:ABLFL='Y' 优先,否则用 AVISIT='Baseline'
sub["AVISIT"] = sub["AVISIT"].astype(str).str.strip()
bl = sub[(sub["ABLFL"].astype(str).str.strip() == "Y")
| (sub["AVISIT"] == "Baseline")]
bl = bl.sort_values("USUBJID").drop_duplicates("USUBJID")
bl_ind = "BNRIND" if bl["BNRIND"].notna().any() else "ANRIND"
base = bl.set_index("USUBJID")[bl_ind].astype(str).str.strip().str[:1].str.upper()
# 基线后:优先末次访视(End of Treatment),否则按 AVISITN 最大
sub["AVISITN"] = _num(sub["AVISITN"])
post = sub[sub["AVISIT"] != "Baseline"]
eot = post[post["AVISIT"] == "End of Treatment"]
post = eot if not eot.empty else \
post[post["AVISITN"] == post.groupby("USUBJID")["AVISITN"].transform("max")]
post = post.sort_values("AVISITN").drop_duplicates("USUBJID", keep="last")
post_s = post.set_index("USUBJID")["ANRIND"].astype(str).str.strip().str[:1].str.upper()
common = base.index.intersection(post_s.index)
if len(common) == 0:
raise ValueError(f"{param}: 基线与基线后记录无法按受试者对齐")
tbl = rpt.crosstab_shift(base.loc[common], post_s.loc[common],
["L", "N", "H"], pct=args.get("pct") or "row")
tbl = tbl.reset_index()
tbl.columns = [str(c) for c in tbl.columns]
return {"表": f"Table 3 · 移位表({param})",
"参数": param,
"受试者数": int(len(common)),
"基线分类来源": bl_ind,
"百分比分母": args.get("pct") or "row",
"行": tbl.to_dict(orient="records"),
"注": "分级 L/N/H = 低/正常/高;基线取 ABLFL='Y'"}
@reg.tool("qc_domain",
"对数据集跑标准质量检查。TLF 生成前必须先过这一关 —— "
"**发现高优先级问题时应中止出表**。",
{"type": "object",
"properties": {
"dataset": {"type": "string", "enum": sorted(ALLOWED)},
"max_issues": {"type": "integer", "minimum": 1, "maximum": 50,
"description": "最多返回多少条明细,默认 10"}},
"required": ["dataset"], "additionalProperties": False})
def _qc(args: dict) -> dict:
res = dict(T.run_qc_checks(args["dataset"]))
n = int(args.get("max_issues") or 10)
res["问题"] = res["问题"][:n]
return res
# ---------------------------------------------------- 故障注入用(场景 c)
@reg.tool("flaky_tool",
"诊断用工具:前 N 次抛 transient 错误(模拟对方服务不稳定),"
"之后成功。用于验证调度器的重试逻辑。",
{"type": "object",
"properties": {
"key": {"type": "string", "description": "故障计数器的标识"},
"fail_times": {"type": "integer", "minimum": 0, "maximum": 10,
"description": "前几次失败,默认 2"}},
"required": ["key"], "additionalProperties": False})
def _flaky(args: dict) -> dict:
key = str(args["key"])
n = int(args.get("fail_times") or 2)
seen = _fail_counter.get(key, 0)
if seen < n:
_fail_counter[key] = seen + 1
_raise_transient(seen)
return {"成功": True, "第几次尝试": seen + 1, "key": key}
@reg.tool("always_timeout",
"诊断用工具:永远返回 transient 错误。用于验证“重试用尽后失败”"
"如何传播到下游。",
{"type": "object",
"properties": {"reason": {"type": "string",
"description": "错误信息"}},
"required": [], "additionalProperties": False})
def _timeout(args: dict) -> core.ToolResult:
return core.ToolResult.fail(
args.get("reason") or "对方服务 503,重试已用尽", "transient",
hint="稍后重跑该任务;上游数据源可能正在维护")
# ------------------------------------------------------------- 写操作
@reg.tool("write_tlf",
"把本批 TLF 的交付清单与关键指标写入文件。这是**写操作**,"
"执行前需要人工确认。",
{"type": "object",
"properties": {
"name": {"type": "string",
"description": "输出文件名(不含路径),如 tlf_manifest"},
"content": {"type": "string", "description": "文件内容"}},
"required": ["name", "content"], "additionalProperties": False},
side_effect="write", idempotent=False)
def _write(args: dict) -> dict:
name = Path(str(args["name"])).name
if not name.endswith(".md"):
name += ".md"
TLF_DIR.mkdir(parents=True, exist_ok=True)
p = TLF_DIR / name
p.write_text(args["content"], encoding="utf-8")
return {"已写入": str(p.relative_to(BASE)), "字节数": p.stat().st_size}
return reg
def _num(s):
import pandas as pd
return pd.to_numeric(s, errors="coerce")
def _raise_transient(seen: int):
raise TimeoutError(f"读取失败(第 {seen + 1} 次尝试):对方服务无响应")
# ==========================================================================
# 计划:一张 11 个任务的 DAG
# ==========================================================================
def build_plan(flaky_ae: bool = False) -> planmod.Plan:
"""把"出一套 TLF"拆成任务图。
拆解依据(MECE:互斥且穷尽):
取数(每域一个)→ 出表(每张表一个)→ 质检(每个源域一个)→ 汇总 → 写交付物
★ 注意:**每个任务只依赖它真正需要的东西**。
表 2(AE)不需要等表 1(人口学)—— 这一点如果写错,
并行度就从 5 掉到 1,而没人会立刻发现。
"""
p = planmod.Plan(goal="为 CDISCPILOT01 生成一批 TLF 并输出交付清单",
meta={"study": "CDISCPILOT01", "batch": "2026-09-Q3"})
# ---- L0:取数(4 个互不依赖)----
for ds, src in (("adsl", "edc"), ("adae", "edc"),
("adlbc_shift", "central-lab")):
p.add(planmod.Task(
id=f"fetch_{ds}", title=f"取 {ds} 快照",
tool="fetch_snapshot", args={"dataset": ds, "source": src},
depends_on=()))
p.add(planmod.Task(
id="fetch_ct", title="取 CDISC 受控术语版本",
tool="fetch_snapshot", args={"dataset": "dm", "source": "cdisc-ct"},
depends_on=(), optional=True)) # ★ 可选:取不到也不该卡住出表
# ---- L1:出表与质检(5 个并行)----
p.add(planmod.Task(id="t1_demo", title="Table 1 人口学",
tool="demo_table", args={"dataset": "adsl"},
depends_on=("fetch_adsl",)))
p.add(planmod.Task(id="t2_ae", title="Table 2 TEAE 汇总",
tool="ae_table", args={"level": "soc", "top_n": 10},
depends_on=("fetch_adae",)))
p.add(planmod.Task(id="t3_shift", title="Table 3 移位表",
tool="shift_table", args={"param": "ALT", "pct": "row"},
depends_on=("fetch_adlbc_shift",)))
p.add(planmod.Task(id="qc_adsl", title="ADSL 质量检查",
tool="qc_domain", args={"dataset": "adsl", "max_issues": 5},
depends_on=("fetch_adsl",)))
p.add(planmod.Task(id="qc_adae", title="ADAE 质量检查",
tool="qc_domain", args={"dataset": "adae", "max_issues": 5},
depends_on=("fetch_adae",)))
# ---- L2:汇总(纯编排节点,无工具调用)----
p.add(planmod.Task(
id="assemble", title="汇总交付清单",
tool="", depends_on=("t1_demo", "t2_ae", "t3_shift", "qc_adsl", "qc_adae")))
# ---- L3:写交付物(写操作,需人工确认)----
p.add(planmod.Task(id="deliver", title="写入交付清单",
tool="write_tlf",
args={"name": "tlf_manifest",
"content": _manifest_text(p)},
depends_on=("assemble",)))
return p
def _manifest_text(p: planmod.Plan) -> str:
"""按"计划定义"渲染交付清单。
⚠️ 注意这里**取的是计划定义,不是任务的执行结果** ——
因为 ``Task.args`` 是静态的(计划要能序列化)。真实项目里
应该由上游任务把结果落盘,下游按路径读后再渲染(见场景 e 末尾说明)。
"""
lines = [
"# TLF 交付清单",
"",
f"- 研究:{p.meta.get('study')}",
f"- 批次:{p.meta.get('batch')}",
f"- 任务总数:{len(p.tasks) + 1}", # +1 = 本清单任务本身
"",
"| 任务 | 工具 | 依赖 | 产物 |",
"|---|---|---|---|",
]
art = {"t1_demo": "Table 1 人口学与基线特征表",
"t2_ae": "Table 2 TEAE 按 SOC 汇总表",
"t3_shift": "Table 3 实验室移位表(ALT)",
"qc_adsl": "ADSL 质量检查记录",
"qc_adae": "ADAE 质量检查记录"}
for t in p.tasks:
lines.append(f"| {t.id} | {t.tool or '(编排节点)'} | "
f"{', '.join(t.depends_on) or '—'} | "
f"{art.get(t.id, '—')} |")
lines += ["", "> 本清单由任务图自动生成;未完成项必须显式列出。", ""]
return "\n".join(lines)
def _h(title: str) -> None:
print("\n" + "=" * 74)
print(title)
print("=" * 74)
def _table(plan: planmod.Plan) -> None:
print(f"{'任务':16s} {'工具':16s} {'状态':9s} {'尝试':>4s} {'耗时':>7s} 说明")
for t in plan.tasks:
note = t.title
if t.error:
note = f"✗ {(t.error_type or '')}: {str(t.error)[:44]}"
print(f"{t.id:16s} {t.tool or '(编排)':16s} {t.status:9s} "
f"{t.attempts:>4d} {t.elapsed:>6.2f}s {note}")
def make_progress_printer() -> "callable":
"""任务完成回调 —— 生产里这里就是"实时进度"的来源。
注意它是**在任务完成的当下**被调用的(可能来自不同线程),
所以回调里只做打印/落日志这类轻量且线程安全的事,
不要在回调里再去调工具。
"""
marks = {"done": "✓", "failed": "✗", "skipped": "-", "waiting": "⏸"}
def _on_task(t: planmod.Task) -> None:
mark = marks.get(t.status, "?")
extra = f" {t.error[:50]}" if t.error and t.status != "done" else ""
print(f" [{mark}] {t.id:16s} {t.elapsed:>5.2f}s "
f"(第 {t.attempts} 次){extra}")
return _on_task
# ==========================================================================
# 场景 a · 需求 → DAG
# ==========================================================================
def scenario_a() -> None:
_h("场景 a · 需求 → DAG:先校验,再执行")
p = build_plan()
print(f"目标:{p.goal}")
print(f"任务数:{len(p.tasks)}\n")
print("【分层结果】(同层可并行,层间有依赖)")
print(planmod.render_plan(p.tasks))
print("\n【拓扑序】(Kahn 算法;这是“该按什么顺序跑”的权威答案)")
print(" " + " → ".join(t.id for t in planmod.topological_order(p.tasks)))
print(f"\n【静态校验】{'✓ 通过' if not p.validate() else p.validate()}")
# ---- 故意造三种坏图,看校验能不能在**执行前**抓住 ----
print("\n--- 故意造坏图,验证“执行前就报错” ---")
bad1 = planmod.Plan(goal="依赖不存在的任务")
bad1.add(planmod.Task(id="a", title="A", tool="qc_domain",
args={"dataset": "adsl"}, depends_on=("ghost",)))
print(f"① 依赖不存在的任务 → {bad1.validate()}")
bad2 = planmod.Plan(goal="循环依赖")
bad2.add(planmod.Task(id="t3", title="表3", tool="ae_table",
args={"level": "soc"}, depends_on=("t2",)))
bad2.add(planmod.Task(id="t2", title="表2", tool="ae_table",
args={"level": "pt"}, depends_on=("t1",)))
bad2.add(planmod.Task(id="t1", title="表1", tool="demo_table",
args={"dataset": "adsl"}, depends_on=("t3",)))
print(f"② 循环依赖 → {bad2.validate()}")
bad3 = planmod.Plan(goal="id 重复")
for _ in range(2):
bad3.add(planmod.Task(id="dup", title="重复", tool="qc_domain",
args={"dataset": "adsl"}))
print(f"③ 重复 id → {bad3.validate()}")
print("""
为什么必须在执行前校验?
循环依赖在 TLF 场景里**藏在业务逻辑里**,写代码时看不出来:
· 表 3 需要表 2 算出的一个分母;
· 表 2 的分母又需要表 3 里的某个汇总数。
顺序脚本跑到一半才发现"要往回改上一张表",只能整批重跑。
拓扑排序会在**第一秒**就把它抛出来,并告诉你涉及哪几个任务。
⚠️ 循环依赖的错误信息要写清"涉及哪些节点 + 依赖关系",
否则你会盯着 11 个任务发懵。看上面 ② 的输出 —— 它把三个任务
和它们的依赖全列出来了,这就是"可修复的错误信息"。""")
print("\n【dry-run:只校验不执行】(上线前先跑一遍,确认图没问题)")
planmod.execute_plan(p, build_registry(),
ppolicy=planmod.PlanPolicy(dry_run=True))
print(f" {p.summary()}")
print(" → dry-run 让每个任务返回 skipped 而不真的执行,"
"用来在**不产生任何副作用**的前提下验证任务图与参数。")
# ==========================================================================
# 场景 b · 并行调度
# ==========================================================================
def scenario_b(force_serial: bool) -> None:
_h("场景 b · 并行调度:同层并行、层间串行")
registry = build_registry()
# 只为观察调度耗时:临时关掉写确认闸门(写操作的闸门见场景 e)
policy = core.Policy(confirm_side_effects=False, allowed_datasets=ALLOWED)
results = {}
for label, workers in (("单线程 max_workers=1", 1),
("4 线程 max_workers=4", 4)):
p = build_plan()
print(f"\n【{label}】实时进度(任务完成回调):")
t0 = time.perf_counter()
planmod.execute_plan(p, registry, policy,
ppolicy=planmod.PlanPolicy(max_workers=workers,
max_retries=0),
on_task=make_progress_printer())
results[label] = time.perf_counter() - t0
print(f" 总耗时 {results[label]:.2f}s")
for i, layer in enumerate(planmod.levels(p.tasks)):
spent = sum(t.elapsed for t in layer)
print(f" L{i}: {len(layer)} 个任务,串行累计 {spent:.2f}s "
f"→ {', '.join(t.id for t in layer)}")
s, pr = results["单线程 max_workers=1"], results["4 线程 max_workers=4"]
print(f"\n加速比:{s / pr:.2f}×({s:.2f}s → {pr:.2f}s)")
print(f"""
为什么不是"越多线程越快"?看各层的性质:
· **L0 是 I/O 型**(fetch_snapshot 里有 {_IO_WAIT:.2f}s 的等待,
模拟读 EDC / 中心实验室接口)→ 4 个任务并行时,
墙上时间 ≈ max(单个) 而不是 sum(全部) → **加速比接近 4×**。
· **L1 是 CPU 型**(出表、跑 QC 都在本地算)→ 每个只有几十毫秒,
线程池的调度开销几乎抵消收益,甚至更慢。
所以第 19.5 节的结论是:
· 线程池适合 **I/O 等待**(读文件、调接口、等数据库)——
临床流水线里 90% 的时间花在这里;
· 真正吃 CPU 的任务(大数据的重算),要么用进程池,
要么**先解决算法问题**(用向量化把 10 分钟压到 10 秒,比加 8 个核更值);
· **别把 DataFrame 在线程间传来传去** —— 传路径/文件名,让每个任务自己读。
上面这个 `fetch_snapshot` 用 `time.sleep` 模拟等待,是**故意的**:
它让"并行收益来自 I/O 而不是 CPU"这件事变得可测量。""")
# ==========================================================================
# 场景 c · 失败与重试
# ==========================================================================
def scenario_c() -> None:
_h("场景 c · 失败、重试与传播:错误要按类型处理")
reg = build_registry()
_fail_counter.clear()
p = planmod.Plan(goal="演示重试、可选任务与失败传播")
# 1) transient 错误 → 重试后成功
p.add(planmod.Task(id="flaky", title="不稳定的接口(前 2 次超时)",
tool="flaky_tool",
args={"key": "case10", "fail_times": 2}))
# 2) 永远超时的**必选**任务 → 重试用尽后失败,阻塞下游
p.add(planmod.Task(id="broken_required", title="必选的失败任务",
tool="always_timeout",
args={"reason": "读不到中央实验室数据"}))
p.add(planmod.Task(id="child_of_required", title="它的下游(应被跳过)",
tool="qc_domain", args={"dataset": "adsl"},
depends_on=("broken_required",)))
# 3) 永远超时的**可选**任务 → 失败但**不阻塞下游**
# 真实例子:查 CDISC 受控术语版本失败,不该卡住出表
p.add(planmod.Task(id="broken_optional", title="可选的失败任务(外部 CT 服务)",
tool="always_timeout",
args={"reason": "CT 服务维护中,用本地版本兜底"},
optional=True)) # ★ optional
p.add(planmod.Task(id="child_of_optional", title="它的下游(照常执行)",
tool="qc_domain", args={"dataset": "dm"},
depends_on=("broken_optional",)))
pp = planmod.PlanPolicy(max_retries=2, backoff_base=1.2)
t0 = time.perf_counter()
planmod.execute_plan(p, reg, ppolicy=pp)
print(f"(含退避等待,总耗时 {time.perf_counter() - t0:.2f}s)\n")
_table(p)
print("\n【逐条解读】")
flaky = p.by_id("flaky")
print(f"① flaky:status={flaky.status},attempts={flaky.attempts} "
f"→ transient 错误被**自动重试**,第 3 次成功")
br = p.by_id("broken_required")
print(f"② broken_required:status={br.status},attempts={br.attempts},"
f"error_type={br.error_type}")
print(" → 重试 2 次后仍失败:记 error + error_type,**不抛异常**"
"(异常会把整批任务的中间结果全丢掉)")
print(f"③ child_of_required:status={p.by_id('child_of_required').status},"
f"error={p.by_id('child_of_required').error}")
print(" → 依赖失败 → skipped。**注意是 skipped 不是 failed**:"
"它没被执行过,不该被算成“执行失败”,也不该占用重试次数")
bo = p.by_id("broken_optional")
co = p.by_id("child_of_optional")
print(f"④ broken_optional:status={bo.status}(optional=True)")
print(f" child_of_optional:status={co.status} "
f"→ **前置失败但下游照跑**,这就是 optional 的用途")
print("""
`optional=True` 什么时候用?(在 TLF 流水线里特别常见)
· 查外部 CT 版本 / 实验室参考范围 —— 取不到就用本地版本兜底,
**不该卡住整批出表**;
· 某个"锦上添花"的 QC(如额外的离群值扫描)—— 失败只记一笔,
不该让交付清单出不来;
· ⚠️ 反过来:**安全性与有效性人群的核对、主键唯一性检查、
关键派生变量的核对,绝不能标 optional**。
判断标准只有一条:**这个任务失败,结论还成立吗?**
成立 → optional;不成立 → 必选,让它把下游一起挡住。""")
print("""
错误分类决定了调度行为(与第 20 章的 error_type 同一套词表):
transient → 可重试,指数退避 (网络、5xx、429)
validation → 不可重试,**参数错了重试 100 次也没用**
permission → 不可重试,且要告警 (401 重试会锁账号)
internal → 不可重试,是程序缺陷
not_found → 不可重试,检查路径/版本
⚠️ 最容易被忽略的是 validation 与 permission:
如果调度器"一视同仁地重试",一个拼错的参数会白等 3 轮退避,
一个过期的 token 会被反复拿去撞墙(很多系统会因此锁账号)。
重试策略必须**按 error_type 分支**,不能写成"失败就再来一次"。""")
# ==========================================================================
# 场景 d · 部分失败汇总
# ==========================================================================
def scenario_d() -> None:
_h("场景 d · 部分失败汇总:让“未完成”变得显式")
reg = build_registry()
summaries = {}
for label, stop in (("stop_on_failure=False(默认:能跑的都跑完)", False),
("stop_on_failure=True(一失败就整体中止)", True)):
_fail_counter.clear()
p = planmod.Plan(goal=f"演示 {label}")
p.add(planmod.Task(id="pre", title="前置准备", tool="qc_domain",
args={"dataset": "dm", "max_issues": 3}))
# 与失败任务**同层但互不依赖**
p.add(planmod.Task(id="boom", title="失败任务",
tool="always_timeout",
args={"reason": "读不到中央实验室数据"},
depends_on=("pre",)))
p.add(planmod.Task(id="a1", title="正常任务 A1", tool="qc_domain",
args={"dataset": "adsl", "max_issues": 3},
depends_on=("pre",)))
# 在**更靠后的层**,且与 boom 无依赖关系 —— 这才是 stop_on_failure 的判别点
p.add(planmod.Task(id="a2", title="正常任务 A2(与失败无依赖)",
tool="qc_domain",
args={"dataset": "adae", "max_issues": 3},
depends_on=("a1",)))
planmod.execute_plan(p, reg, ppolicy=planmod.PlanPolicy(
max_retries=0, stop_on_failure=stop))
print(f"\n【{label}】")
_table(p)
summaries[label] = p.summary()
print(f" summary → {p.summary()}")
print("""
两种策略的差别只有一个:**要不要继续跑"其他还能跑的"任务**。
· stop_on_failure=False:a2 与失败任务没有依赖关系,所以照跑 → 完成 3/4;
· stop_on_failure=True :一旦出现失败就中止整批 → a2 被跳过 → 完成 2/4。
什么时候选哪个?
· 生成一份**交付物**(TLF、核查报告):用默认(False)。
能出的表先出,人可以先看已完成的部分。
· **中间数据被污染**时(比如上游衍生逻辑被证明是错的):
用 True。这时候继续跑只会产生一堆基于错误数据的表,
而且这些表看起来"正常",反而危险。
· 判断依据同样是那一句:**继续跑,结论还成立吗?**""")
print("""
关键在 summary() 结尾那一句:
"⚠️ 结论仅覆盖已完成的部分,未完成项需单独处理。"
为什么一句话这么重要?
在临床场景里,一份"看起来完整"的报告如果漏了一张表,
后果比"报告没出来"严重得多 —— 后者会被立刻发现,
前者可能一路走到递交。
所以进度汇总必须满足三点:
· **数字**:完成 x/y,而不是"基本完成"
· **点名**:哪几个任务失败/跳过,不是只说"有失败"
· **免责**:显式写出"结论仅覆盖已完成部分"
这三条不能靠人记得写,必须由框架在每次执行后自动生成 ——
因为人一定会忘。""")
# ==========================================================================
# 场景 e · HITL 与断点续跑
# ==========================================================================
def scenario_e() -> None:
_h("场景 e · 人工确认点与断点续跑")
plan_file = OUT / "tlf_plan.json"
reg = build_registry()
manifest = TLF_DIR / "tlf_manifest.md"
if manifest.exists():
manifest.unlink() # 从"文件不存在"开始,结果才可断言
print("\n第一步:跑完整张图,但**在写交付物前暂停**(hitl_before)\n")
p = build_plan()
pp = planmod.PlanPolicy(max_workers=4, max_retries=1,
hitl_before=("write_tlf",)) # ★ 人工确认点
print("实时进度:")
planmod.execute_plan(p, reg, ppolicy=pp, on_task=make_progress_printer())
print()
_table(p)
print(f"\n summary → {p.summary()}")
deliver = p.by_id("deliver")
print(f"\n 写操作任务状态:{deliver.status} ← 是 waiting,不是 failed")
print(f" 原因:{deliver.error}")
print(f" 文件是否被写出:{manifest.exists()}")
p.save(plan_file)
print(f"\n第二步:把计划(含每个任务的状态与结果)存盘 → {plan_file.name}")
print(f" 文件大小 {plan_file.stat().st_size} 字节")
# ---- 人工确认后:加载计划,只跑未完成的 ----
print("\n第三步:人工确认通过后,重新加载计划并**只跑未完成的**\n")
# ---- 人工确认后:加载计划,只跑未完成的 ----
print("\n第三步:人工确认通过后,重新加载计划并**只跑未完成的**\n")
p2 = planmod.Plan.load(plan_file)
todo = [t for t in p2.tasks if not t.settled]
done = [t for t in p2.tasks if t.settled]
print(f" 计划里的任务:{len(p2.tasks)} 个")
print(f" 已到终态(从 JSON 读回状态,**不重跑**):{len(done)} 个 "
f"→ {[t.id for t in done]}")
print(f" 仍待执行:{len(todo)} 个 → {[t.id for t in todo]} "
f"← 注意只有这一个,其余 10 个的结果都是从盘上读回来的")
# ★ 放行方式是"逐任务确认",不是"全局关掉闸门"
approver, when = "reviewer@example.com", time.strftime("%Y-%m-%d %H:%M:%S")
p2.meta["approvals"] = {t.id: {"by": approver, "at": when} for t in todo}
print(f"\n 人工确认记录(会随计划一起存盘,作为审计证据):")
print(f" {approver} 于 {when} 确认放行 {[t.id for t in todo]}")
pp2 = planmod.PlanPolicy(max_workers=4, max_retries=1,
hitl_before=(), # 撤掉计划层闸门
confirmed=tuple(t.id for t in todo)) # 逐任务放行
t0 = time.perf_counter()
planmod.execute_plan(p2, reg, ppolicy=pp2)
print(f"\n 续跑耗时 {time.perf_counter() - t0:.2f}s(重跑整张图要 "
f"{_IO_WAIT * 3 + 0.2:.1f}s 以上)")
_table(p2)
print(f"\n summary → {p2.summary()}")
if manifest.exists():
print(f"\n 交付物:{manifest.relative_to(BASE)}"
f"({manifest.stat().st_size} 字节)")
print(" " + "─" * 60)
for line in manifest.read_text(encoding="utf-8").splitlines()[:16]:
print(" " + line)
print(" " + "─" * 60)
else:
print("\n 交付物未生成")
print("""
这里有两个**独立的**闸门,容易搞混,说清一下:
① 计划层:``PlanPolicy.hitl_before=("write_tlf",)``
"跑到这一步就停下来等人。" —— 它管的是**流程**。
② 工具层:``ToolSpec.side_effect="write"`` + ``Policy.confirm_side_effects``
"这个工具会改外部世界,默认不许执行。" —— 它管的是**副作用**。
两道都要过。放行时必须用 ``PlanPolicy.confirmed=("deliver",)``
**逐任务放行**,而不是把 ``confirm_side_effects`` 全局关掉 ——
全局关掉等于"确认过一次之后,之后所有写操作都不再需要确认",
这正是审批流最容易被绕过的地方。
⚠️ 还有一条铁律:**确认必须留痕**。上面把"谁、什么时候、确认了哪个任务"
写进了 ``plan.meta`` 并随计划落盘。没有这条记录,
事后没人能证明"这份交付物是经过人工确认的"。""")
# ==========================================================================
def main() -> None:
print("""
断点续跑为什么能成立?因为**计划本身就是可序列化的数据**:
Plan(tasks=[Task(id, tool, args, depends_on, status, result, attempts), ...])
· status / result / attempts 都在任务对象上,跟着 JSON 一起落盘;
· 恢复时"只跑未完成的",已完成的**不重跑**(省时间,也避免重复副作用);
· 但要注意一个坑:**恢复后重跑的任务必须幂等**。
如果 write_tlf 已经写成功、只是状态没存下来,重跑会覆盖一遍 ——
对"整份覆盖写"的文件没问题,对"追加"或"POST 创建"就危险了。
这就是第 23 章里幂等键要解决的问题。
生产里更常见的形态是:计划存进数据库 + 定时任务扫"未完成"的计划。
这里用 JSON 文件,是为了让你看清**最核心的那部分是什么**。
【顺带说清一个限制:任务之间怎么传数据?】
你可能已经注意到:``assemble`` 是"编排节点",它**不会把上游结果传给**
``deliver`` —— 因为 ``Task.args`` 是**静态的**(这是刻意的:
计划要是纯数据,才能存盘、才能发给同事审阅、才能断点续跑)。
真实项目的两种做法:
① **中间产物落盘 + 下游按路径读**(临床里最推荐)
每个任务把结果写成 ``outputs/tlf/<task_id>.json``,
下游任务的参数里写 ``{"source": "outputs/tlf/t1_demo.json"}``。
· 好处:每一步都能单独重跑、单独核对;中间产物本身就是审计证据;
失败重试不必重算上游(不用在内存里传 DataFrame)。
· 代价:多一次落盘 I/O —— 相比"重算一遍",这点成本可以忽略。
② **参数模板**(框架层注入)
允许 ``args={"dataset": "$t1_demo.result.人群"}``,执行前解析成实际值。
· 好处:写起来短。
· 代价:引入**隐式依赖** —— 依赖关系藏在字符串里,
拓扑排序看不见它,计划也不再是纯数据。要谨慎。
本案例用"编排节点 + 静态参数"是为了把注意力留在调度本身;
你自己项目里请走 ①。""")
# ==========================================================================
def main() -> None:
ap = argparse.ArgumentParser(
description="案例 10 · TLF 生成流水线(任务规划与并行调度)",
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--only", default="all",
choices=["all", "a", "b", "c", "d", "e"])
ap.add_argument("--serial", action="store_true",
help="场景 b 额外打印单线程结果(默认已包含)")
args = ap.parse_args()
if args.only in ("all", "a"):
scenario_a()
if args.only in ("all", "b"):
scenario_b(args.serial)
if args.only in ("all", "c"):
scenario_c()
if args.only in ("all", "d"):
scenario_d()
if args.only in ("all", "e"):
scenario_e()
print("\n" + "=" * 74)
print("案例 10 结束。下一步:")
print(" · 案例 11 —— 双编程:两个 Agent 独立实现同一张表,再机器比对差异")
print(" · 案例 12 —— 把这张图做成服务:接口、指标、日志、评估集")
print("=" * 74)
if __name__ == "__main__":
main()