数据截至 (上 游 commit e741923f72c3)
子流程编排与变量解析:循环、并行、块间引用
30 秒导读: 上一章(02-scheduler-and-edges.md)讲的调度器只会把一个无环图跑一遍。 本章讲的是:同一套调度器怎么被"骗"成能跑循环和并行,以及循环体里的
<Agent 1.content>怎么在第 3 轮拿到第 3 轮的值、在并行第 2 个分支拿到第 2 个分支的值。
1. 这是什么(零基础也能懂)
一句话定义: 这是 Sim 执行引擎里管"子流程"(循环块、并行块)运行期语义的那一层,外加一套把
<块名.字段> 这种引用翻译成真实值的解析器。
它要解决的两个问题,是咬合在一起的:
- 问题 A(编排): DAG 是无环的,循环怎么转第二圈?并行的"同一个块跑 5 份"在图上怎么表示?
- 问题 B(取值): 循环体里的块 B 引用块 A 的输出。第 3 轮的 B 必须拿到第 3 轮的 A, 而不是第 1 轮那份;并行第 2 分支的 B 必须拿到第 2 分支的 A。
为什么 B 不是自动成立的: 执行状态是一张扁平的 Map<blockId, output>。同一个画布块在一次运行里
会产生 N 份输出(N 轮 / N 个分支),它们必须挤在同一张表里,还要能被正确地区分开。
三个一句话直觉:
| 概念 | 直觉 |
|---|---|
| 哨兵节点(sentinel) | 循环的那对括号。start 判"要不要进",end 判"要不要再来一轮" |
| 并行展开(expansion) | 复印机。把子图按分支数复印 N 份节点,节点 id 带下标 ₍N₎ |
| 节点 id 编码 | 快递面单。运行期身份(第几轮、第几分支、第几层克隆)全写在 id 字符串上 |
一个最小的使用场景: 用户在画布上放一个 forEach 循环,集合是 <Start.files>,循环体里放一个
Agent 块和一个 Function 块。Function 块的代码里写 const t = <Agent 1.content>。
用户期望:文件有几个就跑几轮,每轮的 Function 拿到本轮 Agent 的输出。本章讲的就是这句期望背后的机器。
2. 顶层全景(它大概怎么转)
2.1 一次节点执行的路径
所有节点——普通块也好、哨兵也好——都从 NodeExecutionOrchestrator.executeNode
(apps/sim/executor/orchestrators/node.ts:51)过一遍,哨兵在这里被特判分流:
调度器 readyQueue
│
▼
executeNode()
│
├─ 跑过了且不脏? ──是──> 直接返回缓存输出(这是"每轮重置"要打掉的东西)
│
├─ 属于某个 loop/parallel 且 scope 还没建? ──> 懒初始化 scope
│
├─ 是哨兵? ──是──> handleSentinel() ──> LoopOrchestrator / ParallelOrchestrator
│ (判继续/退出、聚合结果、备下一批)
│
└─ 否 ────────────> BlockExecutor.execute()
└──> VariableResolver.resolveInputs()
└──> 五个 Resolver 组成的责任链
2.2 运行期的四张表
编排和取值共用同一批运行期状态,都挂在 ExecutionContext 上:
| 表 | 键 | 存什么 | 谁写 |
|---|---|---|---|
ctx.loopExecutions | 循环 id(可能是克隆 id) | LoopScope:当前轮次、集合、条件、本轮各块输出 | LoopOrchestrator |
ctx.parallelExecutions | 并行 id(可能是克隆 id) | ParallelScope:分支总数、批次游标、各分支输出 | ParallelOrchestrator |
ctx.subflowParentMap | 子流程 id | 它的父容器是谁、在父容器的第几个分支 | registerClonedSubflows |
ctx.parallelBlockMapping | 分支节点 id | 它属于哪个并行、第几个分支、原始块 id 是什么 | registerBranchMappings |
LoopScope / ParallelScope 的字段定义在 apps/sim/executor/execution/state.ts:22 和 :36。
2.3 部件一句话职责
| 部件 | 干什么 | 文件 |
|---|---|---|
NodeExecutionOrchestrator | 所有节点执行的统一入口;哨兵特判;完成后按类型分流记账 | apps/sim/executor/orchestrators/node.ts |
LoopOrchestrator | 建/推进/清理循环 scope;判继续退出;每轮重置 | apps/sim/executor/orchestrators/loop.ts |
ParallelOrchestrator | 算分支数、分批、备批、聚合 | apps/sim/executor/orchestrators/parallel.ts |
ParallelExpander | 真正把子图复印/克隆成新 DAG 节点并接线 | apps/sim/executor/utils/parallel-expansion.ts |
SubflowNodeIdCodec | 节点 id 的唯一编解码实现(所有正则住在这) | apps/sim/executor/utils/subflow-node-id-codec.ts |
VariableResolver | <...> 与 {{ENV}} 的总入口,分发给五个 Resolver | apps/sim/executor/variables/resolver.ts |
| 五个 Resolver | 各认领一种前缀:loop / parallel / variable / env / 块名 | apps/sim/executor/variables/resolvers/ |
3. 核心机制之一:哨兵把环折成直线
3.1 它要解决的小问题
图编译阶段(01-canvas-to-dag.md)产出的是无环 DAG,调度器只认"入边都满足就可以跑"。 循环需要"跑完再回去",这在图上是一条回边——正好是 DAG 不允许的东西。
3.2 思路
给每个循环容器生成一对不是用户块的节点,夹住循环体。怎么读这张图:实线箭头是控制流方向, 右侧那条回到 start 的线是回边,它不参与入边计数。
┌──── loop_continue(回边)───────────┐
▼ │
[前驱] ──> (start 哨兵) ──> A ──> B ──> (end 哨兵) ──┘
│ │
│ loop_exit(空循环旁路) │ loop_exit
└─────────────┬───────────── ┘
▼
[后继]
回边不作为普通依赖参与"入边满足"的判定——它由 end 哨兵主动重置状态来模拟,
CONTROL_BACK_EDGE_HANDLES 就是这几个控制句柄的集合(apps/sim/executor/constants.ts:92)。
3.3 原理演示
// 示意,非源码:end 哨兵一次执行做的事
function onLoopEndSentinel(scope) {
scope.allIterationOutputs.push([...scope.currentIterationOutputs.values()])
scope.currentIterationOutputs.clear()
if (!conditionHolds(scope, scope.iteration + 1)) {
return { selectedRoute: 'loop_exit', results: scope.allIterationOutputs } // 收工
}
scope.iteration++
return { selectedRoute: 'loop_continue' } // 由调用方去"擦掉执行痕迹",让体重新可跑
}
重点看:哨兵不重跑循环体,它只改 scope 和返回一个路由标记;真正让体能再跑一遍的是路由标记 触发的那组重置(见 §5)。
3.4 真实实现
哨兵在 executeNode 里被特判:node.metadata.isSentinel 为真就走 handleSentinel
(apps/sim/executor/orchestrators/node.ts:88-96),不走 BlockExecutor。
handleSentinel(:122)按容器类型和哨兵类型四选一,下表行号同属该文件:
| 容器 | 哨兵 | 干什么 | 位置 |
|---|---|---|---|
| loop | start | evaluateInitialCondition,不成立就直接吐 loop_exit | :140-151 |
| loop | end | evaluateLoopContinuation,返回继续或带聚合结果退出 | :153-176 |
| parallel | start | 空集合直接退出;否则 prepareCurrentBatch 备好本批分支节点 | :190-210 |
| parallel | end | aggregateParallelResults;还有下一批就吐 parallel_continue | :212-228 |
还有一个容易忽略的判定:isFinalSentinelOutput(:107)。它保证"还要继续循环"的哨兵输出
绝不会被当成整条工作流的最终输出,而 loop_exit 时若这条出口没有后继边,该输出才算最终结果。
4. 核心机制之二:一副骨架服务四种循环
4.1 四种循环各自设置了什么
LoopOrchestrator.initializeLoopScope(apps/sim/executor/orchestrators/loop.ts:78)是唯一的建 scope 入口。
下表行号同属该文件:
| 循环类型 | 关键字段 | 条件字符串 scope.condition | 位置 |
|---|---|---|---|
for | maxIterations = iterations(默认 1000) | buildLoopIndexCondition(N) | :114-120 |
forEach | items(异步解析集合)、item = items[0] | buildLoopIndexCondition(items.length) | :122-169 |
while | 无上限 | 用户写的 whileCondition 原文 | :171-174 |
doWhile | 有条件用条件,没条件退化成计数 | 用户条件 或 buildLoopIndexCondition(N) | :176-186 |
巧处在这里: for 和 forEach 并没有单独的"计数器"代码路径,它们被翻译成一个字符串条件
<loop.index> < N(buildLoopIndexCondition,apps/sim/executor/constants.ts:371)。于是判继续的代码
只有一份——evaluateWhileCondition——同时服务四种循环。
4.2 条件是怎么算的
evaluateWhileCondition(apps/sim/executor/orchestrators/loop.ts:739)分两步:
- 先把条件里的引用换成字面量。 用
resolveSingleReference逐个解析<...>,布尔/数字原样、 字符串加引号、对象走JSON.stringify(:720-740)。 - 再丢进隔离 VM 求值。 拼成
return Boolean(<已替换的条件>),executeInIsolatedVM跑, 5 秒超时(:742-754,LOOP_CONDITION_TIMEOUT_MS在:39)。
一个很容易看漏的细节: evaluateCondition(:344)在求值前把 scope.iteration 临时改成
下一轮的下标,算完再改回来(:354-365)。因为条件里的 <loop.index> 问的是
"下一轮还该不该跑",不是"这一轮是第几轮"。
4.3 谁在什么时候判
下表行号同属 apps/sim/executor/orchestrators/loop.ts:
| 判定 | 时机 | 适用 | 位置 |
|---|---|---|---|
evaluateInitialCondition | start 哨兵,第一轮之前 | while 真求值;for/forEach 只查空;doWhile 恒为真 | :650 |
evaluateLoopContinuation | end 哨兵,每轮之后 | 全部 | :232 |
hasReachedConfiguredIterationLimit | 每轮之后,先于条件 | 只对 doWhile 生效 | :305 |
hasReachedConfiguredIterationLimit 的实现第一行就 if (scope.loopType !== 'doWhile') return false
——for/forEach 的上限不靠它,靠 §4.1 那个条件字符串。
被 start 哨兵拦下的循环会置 scope.skippedAtStart,end 哨兵读到它就直接走退出分支
(:258-261),避免把"从没跑过的一轮"记进结果。
4.4 结果是怎么攒起来的
- 循环体里每个块跑完 →
storeLoopNodeOutput按剥掉分支下标的基础块 id写进currentIterationOutputs(apps/sim/executor/orchestrators/loop.ts:241-259)。 - 每轮末 → 本轮所有块的输出打成一个数组,经
compactSubflowResults压缩(大值溢出到存储)后 push 进allIterationOutputs,然后清空本轮桶(:263-282)。 - 退出时 →
createExitResult把二维数组再压一次,写成循环块自己的输出{ results }(:312-342),并补发一条容器级 BlockLog(emitSubflowSuccessEvents)。
所以 <Loop 1.results> 拿到的是 results[轮次][块序] 这样的二维结构。
5. 核心机制之三:每轮迭代结束必须重置的四件事
这是整章最容易踩的坑,单独一节讲。