环不是容器:工作流 IR 里的 SCC 图案与三级终止性判定
把「循环」还原为有向图上由静态分析识别的 SCC 图案:四条硬约束给出工程终止保证,把不可判定的 AI 推理风险收敛到一个带三重上限的孤岛节点里——一套可直接落地的混合编排终止性设计。
附件
本文提出一套工作流中间表示(Intermediate Representation,IR)的终止性设计:把「循环」从一种需要专门声明的节点类型,还原为有向图上由静态分析自动识别的环图案,用四条硬约束给出工程终止保证,并把唯一不可静态证明的风险收敛到一个被硬上限层层包围的节点类型里。同一份 IR 可以被 DolphinScheduler、Airflow、Temporal、LangGraph 等不同引擎解释执行。
你读完能得到什么:一套可以直接抄走的混合编排方案设计——当你的平台上既有确定性 DAG 任务、又有 AI Agent 推理循环时,如何仍然对整张图给出分级明确的终止性保证和规划期风险报告。
前置知识:有向图基础(一条边能走回去就成环);用过大体任意一个 DAG 调度器(DolphinScheduler / Airflow 均可);对 LangGraph、Temporal 这类支持循环的执行引擎有使用印象更好,没有也不影响主线。
两档读法:只想理解思路,读第 1、2、3、8 节;要在自己的引擎里落地,按第 4 节 → 第 5 节 → 第 6 节 的顺序读,第 7 节是完整示例。
一个术语的澄清:业界说「图灵完备的编排」通常指“能表达无限循环”。本文不做系统整体图灵完备的宣称——恰恰相反,它把“无界”压缩进一个节点类型里,其余一切都被静态约束框住。能力面上的“完备”与终止性上的“可控”,两条并行不悖,这正是文章标题想表达的事。
1 问题:一种编排器,两种执行观
设想你在做一个数据平台:一边是成熟的传统 DAG——抽取、清洗、加载,每一步执行次数在设计时就数得清;另一边,业务方想把一个 AI Agent 挂进流程里,让它自主判断“数据质量异常的根因是什么、尝试自动修复几次”。
两种需求的终止性保证等级完全不同。传统 DAG 的终止性由“图无环”这一结构性质保证,引擎可以在提交时就检查出来;Agent 循环的退出条件由模型在运行时自主判断,这一行为在原理上不可静态判定的(它等价于停机问题)。
本文结论先行:不要为此发明专门的“循环节点”或“容器语法”。工作流就是一张普通有向图,节点和边都允许构成环;判断风险等级只需要回答两个正交的问题——这个环的迭代次数是否有界,环内是否含有非确定性推理。四条约束加三类节点,就能覆盖从「数学上保证终止」到「工程兜底终止」的全谱系,同时把不可判定的风险收敛到唯一一处、可审计的位置。
下面先讲这条路上走过的弯路,因为它是理解这个设计“为什么长这样”的最好材料。
2 弯路:把循环装进容器
第一版设计里,我们造过一个叫 condition_loop 的容器节点:一个专门的“盒子”,次数未知但确定性可复现的循环被装进盒子里,对外只暴露入口和出口。直觉上它很合理——“循环”听起来就该是一个需要单独声明的东西。
它扛住了想象,扛不住落地,代价有三条:
- schema 冗长。每个循环都要手写容器节点,容器内部还要再定义一套半嵌套的子图语法,IR 复杂度成倍上涨。
- 与图论重复。容器边界内要判断“哪些节点能到达出口”,而这件事图论里的强连通分量(Strongly Connected Component,SCC)算法早已解决——我们是在自己重新发明环检测。
- 校验跟着容器走。可达性、终止性、成本上限,每一项校验都要先识别容器边界再做,一旦嵌套(容器里装容器),校验逻辑立刻分形复杂。
转念发生在意识到一件事:这跟识别出一段代码是尾递归是同一种问题——编译器不需要程序员专门写一个 TailRecursionBlock 语法结构,它从代码形状里自己认出来。环本来就是图论里自然存在的结构,容器只是给它包了一层不必要的壳。
一个常见的误判也要在此一并拆掉:「退出条件要运行时才知道 = 必须用 AI 处理」——错的。轮询外部系统状态、等待一个信号量、探测一个接口是否就绪,这些循环体完全是确定性操作,不需要任何推理能力,只是迭代次数不可提前枚举而已。用一个普通的环加一条带上限的回边就能表达,AI 完全不必入场。
图 A:容器方案(上)把循环包成黑盒,校验都得绕着边界做;图原生环(下)让环回到图的本来面目,回边带上 loop_control 上限即可
3 转念之后:环是 SCC 图案,不是节点类型
3.1 什么构成一个环
工作流是一张有向图。只要存在一条边 A → B,而 B 又存在一条路径能走回 A,{A, ..., B} 这些节点就构成一个 SCC——分量内任意两个节点互相可达。环,就是一个非平凡的 SCC(节点数大于 1,或存在自环)。
对一个图跑标准的 Tarjan SCC 算法(线性时间),图里所有环都会被自动识别出来,不需要任何语法标注。原有的 condition_loop 于是从 IR 里的一种 schema,降格为静态分析阶段产出报告里的一个条目。
3.2 环的解剖:环头、回边、出口边
「本文所指的环」最自然的产生方式,是条件分支节点的某条边指回自己的上游:
图 B(上图):check_status → decide 构成 SCC,decide → check_status 是回边,decide → proceed 是出口边
{check_status, decide} 两节点构成 SCC;decide → check_status 是回边(闭合成环的那条边);decide → proceed 是出口边;外部进入这个 SCC 的入口节点称为环头(loop header,下例里是 check_status)。
3.3 四条硬约束(其中一条是白送的)
约束① 必须存在至少一条出口边。SCC 内某节点要有一条指向分量外部的边。这是“环不是死循环”的基本前提,规划期静态拒绝没有出口的环。
约束② 可达性。只要约束①成立,SCC 定义本身就保证分量内所有节点都能到达那条出口边——分量内任意两点互相可达,出口边的起点也在分量内。这条不需要校验,是 SCC 的数学定义自带的,也正是它比容器方案里“逐节点检查可达性”省事的原因。
约束③ 必须存在至少一条带 loop_control 的回边。回边要标注迭代上限或时长上限(见 4.2 节),任一上限触发就强制走 on_exhausted 指定的兜底节点。没有 loop_control 的回边,规划期直接拒绝提交——这是保证“环一定有工程终止保证”的唯一强制点。
约束④ 环必须是单入口的(loop header 唯一)。所有从环外部进入这个 SCC 的边,必须全部指向 SCC 内同一个节点。这是我们对编译器理论里“自然循环”判据的简化加强版:标准的可归约(reducible)判据要求回边头节点支配尾节点,还牵涉支配树计算;“单一外部入口”是它更朴素的近似判据,比标准定义更严(会拒绝个别标准下可归约的图),但换来两条工程上关键的性质——loop_control 的“最多走 N 次”有唯一计数基准,SCC 检测之后一次 O(V+E) 遍历就能出结果。规划期发现多入口的环,直接拒绝,报错会指出具体冲突的边;实践里多入口环几乎总是设计失误,这个检查省下的多半是设计者自己的排查时间。
四条里,②是数学白送的,①③④是校验器要真正执行的。这个比例本身就说明:图原生方案下,我们只多做了很少的工程。
3.4 环内出现 Agent 怎么办:不禁止,但必须重新计价
SCC 内部(含递归嵌套)检测到 agent_island(孤岛节点,定义见 4.3 节)时:
- 孤岛自身的
constraints一条不少,环的存在不豁免任何校验。 - 环的风险分类自动上调——只要含至少一个孤岛,这个环就从「确定性环」归入「非确定性环」最高风险等级。
- 成本按复合方式计算:
回边上限 × Σ(环内每个孤岛的成本上限),必须过全局预算校验,超出直接拒绝提交。
设计取向是一句话:允许,但诚实计价。真正要防的不是“环里有 AI”,而是风险被悄悄低估。
4 最小数据模型
4.1 顶层:一张普通的属性图
{
"workflow_id": "string",
"version": "1.0",
"metadata": { "name": "string", "owner": "string", "description": "string" },
"parameters": [
{ "name": "string", "type": "string|int|float|bool|json", "default": "any", "required": "bool" }
],
"node_templates": { "template_id": { /* 可复用节点定义 */ } },
"nodes": [ /* Node[],见 4.3 */ ],
"edges": [ /* Edge[],见 4.2 */ ],
"policies": { /* 全局策略,见 4.5 */ }
}工作流本质上是一个有向属性图:nodes 是顶点,edges 是有向边。图本身允许出现环,环不需要任何包装结构——本模型与容器方案的最大区别就在这一句话里。
两个结构性补充:
- 递归嵌套。
agent_island内部如果本身又是一段复合流程,可以拥有自己的nodes/edges,语法与顶层完全一致。因此一切“检测某范围内是否含孤岛”的校验都必须深度优先递归,不能只查一层;多个孤岛同时存在时,成本上限取求和。 - 模板引用。有界循环的循环体按 ID 引用一个可复用节点定义,统一存放在顶层
node_templates;规划期校验模板 ID 必须可解析,悬空引用直接拒绝。
4.2 边:路由语义 + 回边上限
{
"from": "node_id", "to": "node_id",
"type": "success | failure | always | conditional",
"condition": "expression | null",
"loop_control": {
"max_iterations": "int",
"max_wall_clock_seconds": "int",
"poll_interval_seconds": "int",
"on_exhausted": "node_id"
}
}type | 路由语义 |
|---|---|
success | 上游成功才走(默认) |
failure | 上游失败才走(错误处理分支) |
conditional | 求值 condition 后按“值匹配”走——统一为一种语义,不区分二元/多路分支两套机制 |
always | 无论成败都走(清理/通知) |
两个关键约束:
loop_control只属于回边。max_iterations限制这条回边最多被走多少次;max_wall_clock_seconds限制整个环最久占用多久;poll_interval_seconds是可选的走在回边前的等待间隔(轮询场景防空转);on_exhausted指定上限触发时的强制跳转目标(通常是转人工/告警节点)。没有loop_control的回边,规划期直接拒绝——见约束③。conditional边要互斥且穷尽。同一个源节点发出的所有conditional边,条件集合应覆盖上游输出的全部可能取值,否则运行时会出现“求值结果找不到任何一条可走的边”的死角。
4.3 六种节点类型
节点是这套抽象的核心:类型是终止性保证等级的唯一来源。六种类型里,五种有界,一种孤岛。
① static_task——静态原子任务
{
"id": "extract_data", "type": "static_task",
"executor": "shell | sql | python | http | container",
"config": { "command": "extract.sh", "env": { "KEY": "value" } },
"timeout_seconds": 300,
"retry": { "max_attempts": 3, "backoff": "exponential", "base_delay_seconds": 10 }
}终止性:执行一次,成功/失败/超时三选一,天然有限步,静态可证明。
② parallel——可选的分组节点
多数情况下并行不需要显式节点:没有依赖关系的节点天然并行,汇合点自然等待全部前驱。要显式控制汇合策略时才用:
{
"id": "parallel_group_1", "type": "parallel",
"children": ["task_a", "task_b", "task_c"],
"join_policy": "all | any | n_of_m",
"join_n": 2
}终止性:子节点均有界时整组可证明终止,且时间上限是子节点上限的最大值而非总和(并行语义)。
③ bounded_foreach——有界循环(规划期展开)
{
"id": "process_partitions", "type": "bounded_foreach",
"iterate_over": "${detect_partitions.output.partition_list}",
"body": { "template_node": "clean_one_partition", "max_parallel": 8 },
"on_item_failure": "fail_fast | skip | collect_errors",
"hard_cap": 10000
}前提是 iterate_over 求值后必须是一个有限、物化的列表(不能是“按需持续产出”的生成器);hard_cap 是防御性上限,即便上游异常返回超大集合也不会无限展开。引擎在规划期就把它展开成一组普通节点,所以它本质是图的语法糖:可静态证明终止,也不在图上留下环。
④ bounded_retry_loop——有界重试
{
"id": "retry_flaky_call", "type": "bounded_retry_loop",
"body": "call_external_api",
"condition": "result.status != 'success'",
"max_iterations": 5,
"backoff": "exponential"
}比 static_task.retry 多出的能力是重试之间执行复杂调整逻辑(如按上次失败原因改参数)。只要 max_iterations 是构造期常量,与 bounded_foreach 一样是规划期展开的语法糖,不在图上留环。
⑤ conditional_branch——条件分支(环的主要来源)
{ "id": "quality_check", "type": "conditional_branch",
"condition": "${process_partitions.output.error_rate} > 0.05" }节点本身只求值、不路由:没有内嵌的 on_true/on_false 字段,路由完全由从它发出的 type=conditional 边承担。这个“去内嵌化”是有意为之——避免“节点字段”和“边条件”两套路由机制并存且可能互相矛盾。当它的某条边指回上游时,环由此产生(第 3 节的主题)。
⑥ agent_island——无界孤岛(唯一的逃生舱)
{
"id": "auto_repair", "type": "agent_island",
"runtime": "langgraph",
"goal": "分析这批数据质量异常,判断根因并尝试自动修复,修复不了则标记需要人工介入",
"input_schema": { "anomaly_report": "json", "affected_tables": "array<string>" },
"output_schema": {
"status": "enum[fixed, partially_fixed, needs_human, failed]",
"fixed_rows": "int", "summary": "string"
},
"tools_allowed": ["read_file", "run_sql", "web_search"],
"tools_denied": ["delete_table", "drop_database"],
"constraints": { "max_iterations": 30, "timeout_seconds": 900, "max_cost_usd": 5.0 },
"idempotency_key": "${workflow_run_id}-${node_id}",
"on_constraint_violation": "fail | best_effort_return",
"fallback_node": "escalate_to_human"
}| 字段 | 作用 |
|---|---|
runtime | 底层执行引擎(langgraph / 其他 Agent 运行时),同一抽象、不同实现的接入点 |
goal | 自然语言目标,替代确定性命令,交给底层 Agent 自主分解 |
tools_allowed / tools_denied | 权限边界,限制不可判定行为能触碰的操作面 |
constraints | 必填。把“不可判定”转化为“工程可控”:迭代数 / 时间 / 成本三重硬上限 |
idempotency_key | 执行路径不确定,失败重跑前必须先查重,这是调度层敢于重试它的前提 |
fallback_node | 触达任一上限时的降级路径,流程转入确定性的人工介入分支 |
这里有一个与通用规则的关系要说清:agent_island 是唯一不满足“环必须有 loop_control 回边”这条通用约束、而用专属 constraints 兜底的节点。孤岛的循环发生在引擎内部,外层根本看不到它的回边——loop_control 里“最多走 N 次这条边”的语义,对“内部想怎么循环就怎么循环”的推理过程根本不成立,需要用三重上限单独兜底。
终止性:不可静态证明,但 constraints 保证最坏情况下也会在有限时间/资源内被强制终止,并给出一个(哪怕是失败的)结果。
4.4 上下文与数据契约
三级作用域:
WorkflowContext(全局,贯穿整个运行)
└── NodeContext(每个节点私有,执行完毕写回全局)
└── IslandContext(孤岛私有,外部不可见,仅输出 schema 约定字段)静态节点之间通过 ${node_id.output.field} 引用上游输出。在一个环内,同一节点会被多轮执行,此时该引用默认取最近一次的结果;需要历史轮次时(例如“这是第几次进入循环”),带上迭代偏移:${node_id.output.field, iteration=-1}。
孤岛内部的推理过程、工具调用轨迹、对话历史完全不透传,只有 output_schema 定义的字段写回全局。孤岛执行完毕后,引擎必须对返回值做 schema 校验:不符合 output_schema 一律视为执行失败,走重试或 fallback_node。
4.5 全局策略
"policies": {
"default_timeout_seconds": 600,
"default_retry": { "max_attempts": 2 },
"max_workflow_wall_clock_seconds": 7200,
"max_total_agent_island_cost_usd": 50,
"on_agent_island_timeout": "fallback_node",
"audit_log": { "log_island_io": true, "log_island_internal_trace": false }
}max_workflow_wall_clock_seconds 是实例级终极兜底——即便每个环、每个孤岛各自守规矩,多个风险点级联仍可能把总耗时推高,需要一道顶层熔断。log_island_internal_trace 默认关闭孤岛内部轨迹落盘,审计只记契约层面的进出,兼顾可排查性与运行成本。
5 静态分析:三级终止性报告
引擎在规划期(工作流提交时)执行以下检查并产出一份报告。以下是说明性伪代码,用于表达校验逻辑而非可运行实现:
def classify_workflow(workflow):
graph = build_graph(workflow)
assert_syntax_sugar_expanded(workflow) # foreach / retry_loop 已在先前的步骤展开(见 4.3)
loops = []
for scc in tarjan_scc(graph): # 标准 O(V+E)
if not is_nontrivial(scc, graph): # 单节点且无自环 => 不是环
continue
entry = verify_single_entry(scc) # 约束④:外部入口唯一,违例即拒绝
verify_exit_edge_exists(scc) # 约束①:没有出口边 => 死循环
back_edge = verify_loop_control_cutoff(scc, entry) # 约束③:回边切断
# 约束②(可达性)由 SCC 定义自动满足,不校验
islands = find_agent_islands(scc) # 深度优先递归,见 4.1
if islands:
verify_island_constraints(islands) # constraints 必填,缺即拒绝
compound_cost = back_edge.cutoff.max_iterations * sum(islands.max_cost_usd)
verify_global_budget(compound_cost) # 复合成本过全局预算
loops.append(report(scc, entry, back_edge, islands))
return build_verdict(loops)判定结果分三级,风险递增:
| 判定 | 含义 | 发生条件 |
|---|---|---|
provably_terminating | 图中无任何环、也无独立孤岛,数学上保证终止,等价于 DolphinScheduler 等传统引擎的保证等级 | 无环且无孤岛 |
engineering_bounded_deterministic | 有环,但每个环内不含孤岛;结构上保证“满足条件必达出口”(约束②自可达),配合回边 loop_control 上限,能算出确定性的最坏耗时 | 有环且无孤岛 |
engineering_bounded_nondeterministic | 存在含孤岛的环或独立孤岛节点;报告分别列出 nondeterministic_loop_count 与 standalone_islands,让审查者能看出风险来自“环里藏了推理”还是“某个独立节点” | 有环且环内含孤岛,或存在独立孤岛 |
此外报告还会给出一份 worst_case_wall_clock(最坏耗时上界),聚合口径为:
Σ(非环静态节点超时上限) + Σ(确定性环.wall_clock上限)
+ Σ(非确定性环.max_iterations × (环内孤岛超时 + poll 间隔))
+ Σ(独立孤岛.timeout_seconds)这个数值必须小于 max_workflow_wall_clock_seconds 才允许提交,它是“各处风险上限级联会不会击穿总预算”的规划期答案。
6 引擎适配:容器从 IR 下放为适配器实现细节
摊平方案换来一个真代价:容器方案里,整个环被打包成一个黑盒,天然能被 DolphinScheduler 这类假设一张 DAG 每个节点只跑一次的传统引擎执行——把盒子编译成内部跑 while 循环的脚本即可。摊平之后这层掩护消失了:图里如果真有裸露的环,引擎必须支持“同一节点在一次运行里被重复调度”,做不到的引擎就得由适配器先做一轮预编译。所以适配器要按“引擎是否原生支持环”分两条路走。
6.1 适配器接口
interface WorkflowRuntimeAdapter:
def execute_static_task(node, context) -> TaskResult
def execute_bounded_foreach(node, context) -> TaskResult
def execute_bounded_retry_loop(node, context) -> TaskResult
def execute_agent_island(node, context) -> IslandResult
def supports_native_cycles() -> bool # 能力探测,决定要不要走 6.3 的预编译conditional_branch 与 parallel 是纯控制流,由编排引擎自身按图逻辑处理,无需单独的执行方法。
6.2 原生支持环的引擎(Temporal / LangGraph)
supports_native_cycles() 返回 true 的引擎,摊平后的图原样喂入——比如 LangGraph 的回边本来就是这个形状:
graph.add_conditional_edges(
"decide",
lambda state: "check_status" if state["status"] == "not_ready" else "proceed",
{"check_status": "check_status", "proceed": "proceed"},
)
# loop_control.max_iterations 直接映射为 recursion_limit
app = graph.compile()
result = app.invoke(initial_state, config={"recursion_limit": back_edge.max_iterations})6.3 传统 DAG 引擎的预编译(DolphinScheduler / Airflow)
class DolphinSchedulerAdapter(WorkflowRuntimeAdapter):
def supports_native_cycles(self):
return False
def compile(self, workflow):
analysis = classify_workflow(workflow)
compiled_graph = workflow.copy()
for loop in analysis["detected_loops"]:
# 把整个 SCC(含环内的 static_task / conditional_branch / agent_island)
# 渲染成一段自带 while 循环的 Shell 脚本,作为单个不透明任务节点
script = compile_scc_to_shell(loop, workflow)
opaque_task = {
"id": f"compiled_loop_{loop.entry}",
"type": "static_task", "executor": "shell",
"config": { "command": script },
"timeout_seconds": loop.back_edge.cutoff.max_wall_clock_seconds,
}
compiled_graph.replace_scc_with_node(loop.nodes, opaque_task)
return compiled_graph这一步的本质:“容器”这个概念没有死,它从 IR 的一部分降格成了这一类适配器的内部实现细节——IR 保持极简,应对传统引擎的复杂度由真正需要处理它的那一层承担。
6.4 孤岛层适配
孤岛节点最终都要落到某个 Agent 运行时上。以 LangGraph 为例,一个孤岛就是一个带能力过滤与硬上限的受限图执行:
def run_island(node: AgentIslandNode, input_data: dict) -> dict:
graph = build_react_graph(
tools=filter_tools(node.tools_allowed, node.tools_denied),
recursion_limit=node.constraints.max_iterations,
)
result = graph.invoke(
{"messages": [("user", render_prompt(node.goal, input_data))]},
config={"recursion_limit": node.constraints.max_iterations,
"configurable": {"timeout": node.constraints.timeout_seconds}},
)
return validate_and_extract(result, node.output_schema)孤岛层适配的关键在接口层的完全等价:无论底下是哪个运行时,都吃 goal + input_schema + constraints,都吐通过 output_schema 校验的结果。至于内部——即使同一个运行时,同一目标、同一约束跑两次,执行轨迹也对不上。“契约可移植,行为不可移植”在这里是设计出来的,别幻想成 bug。
6.5 错误处理与恢复语义
| 场景 | 静态节点 / 确定性环 | 孤岛节点 / 非确定性环 |
|---|---|---|
| 执行失败 | 按 retry 重试,超限走 failure 边 | 重试前必须校验 idempotency_key 未被用过,否则直接返回缓存结果,避免副作用重复 |
| 超时 | 直接失败 | 走 on_constraint_violation:fail 直接失败,或 best_effort_return 强制中断并尽量给出当前最佳结果 |
loop_control 上限耗尽 | 强制走 on_exhausted 指向的节点 | 同左,但要在 on_exhausted 节点里区分是外部环境没就绪、还是模型始终无法给出满足条件的结果 |
| 输出不合 schema | 不适用(静态节点输出由代码保证) | 视为执行失败,走 fallback_node(通常转人工) |
| 整体断点续跑 | 从失败节点续跑;环内失败则从该环的环头重新进入 | 不保证语义等价断点续跑,只能“重新触发一次孤岛的完整执行”,靠 idempotency_key 控制副作用 |
7 完整示例:数据质量自愈流水线
把前六节串起来。场景:数仓分区清洗后仍存在质量异常,先修自动修复,修不好的转人工。图中只有一个环(数据源就绪轮询)和一个独立孤岛(自动修复 Agent)。
7.1 例图(约束①③④落到实处的样子)
图 C:左段为就绪轮询环(回边带 loop_control 三项上限与 poll 间隔),深色节点为孤岛 auto_repair,右下深红节点是 on_exhausted 与 failure 共同指向的人工介入出口
7.2 定义(浓缩稿)
{
"workflow_id": "data_quality_self_heal_v1",
"version": "1.0",
"metadata": { "name": "数据质量自愈流水线", "owner": "drizzlepal" },
"node_templates": {
"clean_one_partition": {
"type": "static_task", "executor": "python",
"config": { "command": "clean_partition.py --partition=${item}" },
"timeout_seconds": 120
}
},
"nodes": [
{ "id": "check_source_lag", "type": "static_task", "executor": "sql",
"config": { "command": "check_replication_lag.sql" }, "timeout_seconds": 10 },
{ "id": "lag_decision", "type": "conditional_branch",
"condition": "${check_source_lag.output.lag_seconds} < 5" },
{ "id": "extract", "type": "static_task", "executor": "shell",
"config": { "command": "extract_from_tidb.sh" },
"timeout_seconds": 300, "retry": { "max_attempts": 3 } },
{ "id": "detect_partitions", "type": "static_task", "executor": "python",
"config": { "command": "list_dirty_partitions.py" }, "timeout_seconds": 60 },
{ "id": "process_partitions", "type": "bounded_foreach",
"iterate_over": "${detect_partitions.output.partition_list}",
"body": { "template_node": "clean_one_partition", "max_parallel": 8 },
"hard_cap": 500 },
{ "id": "quality_check", "type": "conditional_branch",
"condition": "${process_partitions.output.error_rate} > 0.05" },
{ "id": "auto_repair", "type": "agent_island", "runtime": "langgraph",
"goal": "分析清洗后仍异常的分区,定位根因(脏数据源/字段映射错误/编码问题等),能自动修复的直接修复,不能的标记原因",
"input_schema": { "partition_report": "json" },
"output_schema": { "status": "enum[fixed, needs_human, failed]",
"fixed_partitions": "array<string>", "root_cause": "string" },
"tools_allowed": ["read_file", "run_sql", "web_search"],
"tools_denied": ["drop_table", "truncate_table"],
"constraints": { "max_iterations": 25, "timeout_seconds": 600, "max_cost_usd": 3 },
"idempotency_key": "${workflow_run_id}-auto_repair",
"fallback_node": "escalate_to_human" },
{ "id": "escalate_to_human", "type": "static_task", "executor": "http",
"config": { "command": "notify_oncall_webhook" } },
{ "id": "load_to_warehouse", "type": "static_task", "executor": "sql",
"config": { "command": "load_final.sql" }, "timeout_seconds": 300 }
],
"edges": [
{ "from": "check_source_lag", "to": "lag_decision", "type": "success" },
{ "from": "lag_decision", "to": "check_source_lag", "type": "conditional", "condition": "false",
"loop_control": { "max_iterations": 40, "max_wall_clock_seconds": 600,
"poll_interval_seconds": 15, "on_exhausted": "escalate_to_human" } },
{ "from": "lag_decision", "to": "extract", "type": "conditional", "condition": "true" },
{ "from": "extract", "to": "detect_partitions", "type": "success" },
{ "from": "detect_partitions", "to": "process_partitions", "type": "success" },
{ "from": "process_partitions", "to": "quality_check", "type": "success" },
{ "from": "quality_check", "to": "auto_repair", "type": "conditional", "condition": "true" },
{ "from": "quality_check", "to": "load_to_warehouse", "type": "conditional", "condition": "false" },
{ "from": "auto_repair", "to": "load_to_warehouse", "type": "success" },
{ "from": "auto_repair", "to": "escalate_to_human", "type": "failure" }
],
"policies": {
"max_workflow_wall_clock_seconds": 3600,
"max_total_agent_island_cost_usd": 10,
"audit_log": { "log_island_io": true, "log_island_internal_trace": false }
}
}7.3 静态分析的读数
对这份定义跑第 5 节的分析,得到:
- 检测到 1 个环:
{check_source_lag, lag_decision},环头check_source_lag,出口边lag_decision → extract,回边带loop_control,环内无孤岛。四条约束全过,归入确定性环。 - 1 个独立孤岛:
auto_repair,不在任何环里,单列。 - 整体判定:
engineering_bounded_nondeterministic——即便那个环本身是干净可分析的,只要全图任意一处存在非确定性推理,就按最高风险等级审查;但nondeterministic_loop_count = 0、standalone_islands=["auto_repair"],一眼能看出风险不是“环里藏了推理”。 worst_case_wall_clock≈ 环上限 600 + 环外静态节点 660(extract300 +detect_partitions60 +load_to_warehouse300;process_partitions按并行语义计入最大值而非总和)+ 孤岛 600 ≈ 1860 秒 < 3600 秒全局熔断线,规划期校验通过。
8 取舍清单与方法论
| 维度 | 本抽象的选择 | 理由 |
|---|---|---|
| 环是否需要专门节点类型 | 不需要,环是 SCC 图案 | 环是图论自然结构,包容器是不必要的复杂化 |
| 环合法性怎么保证 | 四条约束:出口边 /(SCC 白送的)可达性 / loop_control 回边 / 单一入口 | 前三条给出工程终止保证,第四条保证计数基准唯一 |
| 次数已知的循环 | bounded_foreach / bounded_retry_loop,规划期展开,不留环 | 覆盖“看起来像循环其实有界”的场景,可数学证明终止 |
| 次数未知但确定性的循环 | 普通环 + 带 loop_control 的回边,不需 AI | 轮询、等待本来就该被表达为环,而非一种新概念 |
| 环内出现孤岛 | 不禁止;风险上调为非确定性环,成本按“回边上限 × 孤岛上限之和”复合计算 | 允许但必须诚实计价 |
| 真正的开放式推理决策 | 收敛到唯一节点类型 agent_island,constraints 三重上限 | 把不可复现的推理不确定性从“到处都可能”降为“只在标注处” |
| 传统引擎适配问题 | 原生环引擎直接执行;DAG 引擎由适配器跑 SCC 检测后编译回不透明单元 | 容器概念从 IR 下放为适配器实现细节,IR 保持最简 |
| 终止性分类谁来做 | 规划期静态分析器(Tarjan SCC + 规则校验),产出三级报告 | 把“风险在哪、多大”变成可审查的显式指标 |
收个尾。这套抽象值得推广的其实不是某一个字段,而是两条可以迁移到其他设计的判断:
- 让不可判定的风险收敛到一处,而不是想让所有东西都可判定。工程上,“处处略粗糙”永远好过“局部无懈可击、别处藏着雷”——给风险显式标注等级,比消除风险更现实。
- 契约保持最简,复杂度下放给真正需要处理它的那一层。环相关的全部复杂度(可达性判定、预编译、针对传统引擎的适配),没有一个住在 IR 里。
方法论上同样诚实的一点:这套设计并没有“解决”停机问题——它只是把停机问题的不可判定性,通过硬上限转化成了一个可以在规划期给出答案的核算问题。能力完备感与终止可控是可以同时拥有的,前提是你接受“风险要有价签”。
参考资料
- Tarjan R E. Depth-first search and linear graph algorithms. SIAM Journal on Computing, 1972——SCC 的线性时间算法。
- Aho A, Lam M, Sethi R, Ullman J. Compilers: Principles, Techniques, and Tools(第 2 版)——自然循环、可归约控制流与支配树的标准定义。
- Temporal 文档(docs.temporal.io)——Workflow 原生循环与
continue-as-new的长任务终局模式。 - LangGraph 文档(langchain-ai.github.io/langgraph)——
StateGraph/add_conditional_edges/recursion_limit语义。 - Apache DolphinScheduler 文档(dolphinscheduler.apache.org)——任务是 DAG 定义(无环)的引擎语义。