跳到主要内容

数据截至 (上游 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}} 的总入口,分发给五个 Resolverapps/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)按容器类型和哨兵类型四选一,下表行号同属该文件:

容器哨兵干什么位置
loopstartevaluateInitialCondition,不成立就直接吐 loop_exit:140-151
loopendevaluateLoopContinuation,返回继续或带聚合结果退出:153-176
parallelstart空集合直接退出;否则 prepareCurrentBatch 备好本批分支节点:190-210
parallelendaggregateParallelResults;还有下一批就吐 parallel_continue:212-228

还有一个容易忽略的判定:isFinalSentinelOutput(:107)。它保证"还要继续循环"的哨兵输出 绝不会被当成整条工作流的最终输出,而 loop_exit 时若这条出口没有后继边,该输出才算最终结果。


4. 核心机制之二:一副骨架服务四种循环

4.1 四种循环各自设置了什么

LoopOrchestrator.initializeLoopScope(apps/sim/executor/orchestrators/loop.ts:78)是唯一的建 scope 入口。 下表行号同属该文件:

循环类型关键字段条件字符串 scope.condition位置
formaxIterations = iterations(默认 1000)buildLoopIndexCondition(N):114-120
forEachitems(异步解析集合)、item = items[0]buildLoopIndexCondition(items.length):122-169
while无上限用户写的 whileCondition 原文:171-174
doWhile有条件用条件,没条件退化成计数用户条件 或 buildLoopIndexCondition(N):176-186

巧处在这里: forforEach 并没有单独的"计数器"代码路径,它们被翻译成一个字符串条件 <loop.index> < N(buildLoopIndexCondition,apps/sim/executor/constants.ts:371)。于是判继续的代码 只有一份——evaluateWhileCondition——同时服务四种循环。

4.2 条件是怎么算的

evaluateWhileCondition(apps/sim/executor/orchestrators/loop.ts:739)分两步:

  1. 先把条件里的引用换成字面量。resolveSingleReference 逐个解析 <...>,布尔/数字原样、 字符串加引号、对象走 JSON.stringify(:720-740)。
  2. 再丢进隔离 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:

判定时机适用位置
evaluateInitialConditionstart 哨兵,第一轮之前while 真求值;for/forEach 只查空;doWhile 恒为真:650
evaluateLoopContinuationend 哨兵,每轮之后全部: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. 核心机制之三:每轮迭代结束必须重置的四件事

这是整章最容易踩的坑,单独一节讲。

5.1 症状:第二轮卡死

如果只把 scope.iteration++ 然后放回队列,第二轮会一步都跑不动。因为上一轮的执行在四个地方 留下了"已经完成"的痕迹,而这四个痕迹分别属于四个不同的所有者。

5.2 四件事、四个所有者

要重置什么不重置会怎样谁干位置
executedBlocks 集合executeNode 开头就命中"跑过了",直接返上一轮缓存clearLoopExecutionStateunmarkExecutedorchestrators/loop.ts:403-412 / orchestrators/node.ts:68-75
嵌套子流程的 scope内层循环还停在上轮末的 iteration,一进来就判退出resetNestedLoopScopes / resetNestedParallelScopesorchestrators/loop.ts:418 / :412
节点的 incomingEdges调度时被 delete 掉了,依赖数永远不再变化restoreLoopEdgesorchestrators/loop.ts:611 / 删除动作在 execution/edge-manager.ts:64
EdgeManager.deactivatedEdges上轮被剪掉的分支边仍算作"永远不会来的活依赖",节点永远 not readyclearDeactivatedEdgesForNodesexecution/edge-manager.ts:179

上表路径均以 apps/sim/executor/ 为根,完整路径见 §13。

触发点在 handleRegularNodeCompletion:end 哨兵输出 selectedRoute === 'loop_continue' 时, 连着调 clearLoopExecutionStaterestoreLoopEdges(apps/sim/executor/orchestrators/node.ts:349-362)。

5.3 第四件事为什么最隐蔽

clearDeactivatedEdgesForNodes 的 TSDoc(apps/sim/executor/execution/edge-manager.ts:168-178)自己写清楚了 取舍:它只清除源节点在集合内的失活边。指向循环体、但源在循环外的失活边必须保留——否则 countActiveIncomingEdges 会把一个"这辈子都不会再触发"的外部源算成活依赖,循环就在第二轮停住。

同样的清理在并行备批时也要做一次:prepareCurrentBatch 里调 edgeManager?.clearDeactivatedEdgesForNodes(apps/sim/executor/orchestrators/parallel.ts:191)。

5.4 重置的范围要覆盖克隆体

collectAllLoopNodeIds(apps/sim/executor/orchestrators/loop.ts:506)递归收集:本层的两个哨兵 + 直接子块 + 嵌套子流程的全部节点 + 所有克隆变体(collectClonedSubflowNodes,:553, 按 __obranch- / __clone 前缀扫 DAG 配置表)。漏掉克隆体,就会出现"外层循环第二轮时, 内层并行的克隆分支还记着第一轮的输出"。

restoreLoopEdges 复原入边时有两类边故意不复原(:599-604):

  • 回边(CONTROL_BACK_EDGE_HANDLES 里的句柄)。
  • start 哨兵直连 end 哨兵的"空循环旁路边"(isSubflowStartExitBypassEdge,:611)。

把这两类加回 incomingEdges 会造出永远等不到的依赖(inferred——代码只表达"跳过",没写原因)。


6. 核心机制之四:并行 = 复印子图 + 分批跑

6.1 分支数和批大小是两个数,只有后者有上限

这一小节要先破一个常见误读:MAX_PARALLEL_BRANCHES 这个名字听起来像"最多几个分支", 但代码里它只管批大小。

initializeParallelScope(apps/sim/executor/orchestrators/parallel.ts:55)开场算两个数, 它们的约束完全不同。

其一,分支数(totalBranches)——全程没有上限。 resolveBranchCount(:185-201)只有两条路:

parallelType分支数备注
countconfig.count ?? 1用户填几就是几
collectionitems.lengthresolveDistributionItems(:233)异步解析集合;空集合返回 0 并置 isEmpty

两条路都是算完直接 return,没有 clamp、没有 Math.min。一个 5000 元素的集合就是 5000 个分支。

其二,批大小(batchSize)——被夹在 1..20。 resolveBatchSize(:254-261)是全仓 唯一读这个常量的生产代码:

// apps/sim/executor/orchestrators/parallel.ts:260 —— 真实源码
return Math.max(1, Math.min(DEFAULTS.MAX_PARALLEL_BRANCHES, parsed))

解析不出数字就退回默认 20(DEFAULT_PARALLEL_BATCH_SIZE,:27),常量本身在 apps/sim/executor/constants.ts:179。行为规格见 apps/sim/executor/orchestrators/parallel.test.ts:261-278 ——那组用例特意把 count 设成 MAX_PARALLEL_BRANCHES + 10,只断言 batchSize 被归一化。

结论: 5000 个分支不会被截断成 20 个,而是被切成 250 批依次铺开。上限约束的是 "一批同时在图上展开几个分支节点",不是"总共能有几个分支"。

6.2 备批:真正的"复印"

一批的生命周期是"start 哨兵铺开 → 各分支跑 → end 哨兵聚合 → 要么再来一批,要么退出":

(start 哨兵) prepareCurrentBatch()
├─ 规则块 → 复印成 blockA₍0₎ … blockA₍k₎
├─ 嵌套子流程 → 整图克隆成 sub__obranch-N
├─ 注册 subflowParentMap / parallelBlockMapping
└─ 清失活边 + 清本批节点的"已执行"标记


本批各分支(0 … k)并发跑完


(end 哨兵) aggregateParallelResults()
├─ 本批 branchOutputs 并进 accumulatedOutputs
├─ 还有下一批 ─是→ parallel_continue
│ └─ prepareForBatchContinuation():两个哨兵 unmarkExecuted
│ → start 哨兵再跑一次,备下一批(回到顶部)
└─ 没有了 ─否→ parallel_exit,输出 results[分支][块] 二维数组

ParallelExpander.expandParallel(apps/sim/executor/utils/parallel-expansion.ts:36)是复印机本体, 分两种处理:

  • 规则块:只有分支 0 复用已有的模板节点(只改元数据,:85-92),分支 1..k 才 cloneTemplateNode 新建(:168)。省下一份节点。
  • 嵌套子流程:不能只复印一个节点(它有一对哨兵和整个子图),所以 cloneNestedSubflow(:402) 递归复制哨兵、配置、子块和重映射后的边(cloneSubflowGraph,:424)。

6.3 分批推进

聚合发生在 end 哨兵:aggregateParallelResults(apps/sim/executor/orchestrators/parallel.ts:439) 先把本批 branchOutputs 并进 accumulatedOutputs,然后看游标:

  • 还有下一批 → 先对累积结果再压一次(:439-465 有一段注释解释为什么:单个输出都没超阈值, 但累积起来会超),advanceToNextBatch(:506)推游标,返回 allBranchesComplete: false。 node 侧据此调 prepareForBatchContinuation(apps/sim/executor/orchestrators/node.ts:374parallel.ts:522),把两个哨兵 unmarkExecuted,start 哨兵于是能再跑一次并备下一批。
  • 跑完了 → 按 branchIndex 从 0 到 totalBranches-1 排成二维数组,写成并行块的输出(:474-496)。 缺哪个分支只 logger.warn 并填空数组(:477-481),不报错。

6.4 分支结果怎么归到正确的分支

handleParallelBranchCompletion(apps/sim/executor/orchestrators/parallel.ts:405)找分支号的顺序是: 显式传入的覆盖值 → ctx.parallelBlockMapping → DAG 节点元数据 → 从节点 id 里 extractBranchIndex。 四级兜底,因为不同来源的节点(模板、克隆、嵌套子流程的哨兵)带的信息不一样。

嵌套子流程结束时归属父容器,走的是另一条路:handleParentSubflowCompletion (apps/sim/executor/orchestrators/node.ts:296)。它只在 end 哨兵真正退出(不是 continue)时触发, 查 subflowParentMap 找到父容器,再按父类型分别调并行的分支归集或循环的输出归集, 并且只把 { results } 转交上去(getSubflowResultOutput,:32)。


7. 核心机制之五:节点 id 就是运行期身份

7.1 编码协议

所有正则和模板只住在一个文件里:apps/sim/executor/utils/subflow-node-id-codec.ts

编码长相含义建/解
分支下标blockA₍3₎并行第 3 分支的 blockAbuildBranchNodeId / extractBranchIndex
循环哨兵loop-L1-sentinel-start循环 L1 的入口哨兵buildLoopSentinelStartId / extractLoopIdFromSentinel
并行哨兵parallel-P1-sentinel-end并行 P1 的出口哨兵buildParallelSentinelEndId / extractParallelIdFromSentinel
外层分支克隆L1__obranch-2L1 在外层并行第 2 分支的那一份buildOuterBranchScopedId / extractOuterBranchIndex
预克隆摘要blk__clone<24位hex>__obranch-2深层克隆,防撞名buildPreCloneIdForParent(apps/sim/executor/utils/parallel-expansion.ts:384)
循环轮次摘要..._loop3输出按轮次分桶只被 normalizeLookupId 剥离(codec :218)

分支下标用的是下标括号字符 (apps/sim/executor/constants.ts:123-126),不是普通括号 ——避开用户块名里可能出现的普通括号。

7.2 为什么要有 __clone<hex>

buildPreCloneIdForParent 的 TSDoc(apps/sim/executor/utils/parallel-expansion.ts:375-383)讲得很直白: 顶层克隆用朴素的 __obranch-N,是因为运行期的 findEffectiveContainerId 要认这个后缀; 但更深层的子块如果也用 __obranch-N,就会和"分支 0 那份原图后来在运行期自己展开时生成的 __obranch-N"撞名。所以深层用 sha256(父克隆id:原id:分支号) 前 24 位做摘要段——确定性的, 暂停/恢复重建时能算出同样的 id。

7.3 解码顺序

stripCloneSuffixes(apps/sim/executor/utils/subflow-node-id-codec.ts:134)的剥离顺序是固定的: 先去掉所有 __obranch-N,再去掉所有 __clone…,最后去掉尾部的 ₍N₎。剥完得到的是 画布上那个块的原始 id,也就是"静态身份"。

currentNodeId = "agent1__clone9f3e…__obranch-2"

├─ 剥掉全部后缀 ───────> "agent1" 静态身份:它在画布上是谁、在哪些容器里
└─ 留下的下标 2 ───────> obranch=2 运行期身份:该读哪一份 scope、哪一份输出

这两条信息合起来,就是下一节"取值"的全部输入。


8. 核心机制之六:<block.field> 怎么解析

8.1 语法与责任链

引用语法极简:< 开头 > 结尾,内部按 . 分段(REFERENCE 常量在 apps/sim/executor/constants.ts:141-145,切分函数 parseReferencePath 在同文件 :431); 环境变量走另一条通道 {{NAME}}

VariableResolver 构造时按固定顺序装配五个解析器(apps/sim/executor/variables/resolver.ts:164-170), resolveReference(:1363)从头问到尾,第一个 canResolve 为真的接手:

顺序解析器认领什么文件(以 apps/sim/executor/variables/ 为根)
1LoopResolver<loop.*> 或"名字是某个循环块的"resolvers/loop.ts
2ParallelResolver<parallel.*> 或"名字是某个并行块的"resolvers/parallel.ts
3WorkflowResolver<variable.名字>resolvers/workflow.ts
4EnvResolver{{NAME}}resolvers/env.ts
5BlockResolver其余一切(兜底)resolvers/block.ts

兜底的实现方式值得记一笔:BlockResolver.canResolve 不是"匹配某个模式",而是 "只要第一段不属于 loop/parallel/variable 这三个保留前缀,就归我" (apps/sim/executor/variables/resolvers/block.ts:82)。这三个词因此是保留块名 (RESERVED_BLOCK_NAMES,apps/sim/executor/constants.ts:165,现在委托给 @sim/workflow-typesRESERVED_WORKFLOW_BLOCK_NAMES,保证客户端与执行器一致)。

8.2 三种输入路径

下表行号同属 apps/sim/executor/variables/resolver.ts:

入口用在哪特点
resolveInputs(:229)普通块递归遍历数组/对象/字符串;条件块的 conditions 走特殊的转义分支
resolveInputsForFunctionBlock(:154)代码块见 §9,值不进代码文本
resolveSingleReference(:305)循环条件、集合输入整个字符串就是一个引用时,返回原值而非字符串化

resolveValue(:334)负责递归,resolveTemplate(:1244)负责一个字符串里的替换, 最后由 BlockResolver.formatValueForBlock(apps/sim/executor/variables/resolvers/block.ts:389) 按目标块类型决定怎么落成文本:条件块要转义加引号、代码块要合法字面量 (formatLiteralForCode 会把 null 变成 Python 的 None)、其它块对象走 JSON.stringify

8.3 两级定位:先找对 scope,再找对输出

第一级——找对容器 scope。 findEffectiveContainerId (apps/sim/executor/utils/subflow-node-id-codec.ts:163)拿"目标容器的原始 id"和"当前节点 id", 在 scope 表里找那份属于当前迭代上下文的 scope:

  1. 若调用方给了 mappedBranchIndex > 0,先试 原id__obranch-N,再扫全表找后缀匹配的。
  2. 否则从 currentNodeId 里抠出 __obranch-N;若当前节点带 __clone 标记,优先匹配同样带 __clone 的 scope id(:187-197)——这是深层克隆和顶层克隆的区分点。
  3. 都不中,退回原始 id。

LoopResolverParallelResolver 都在解析的第一步做这件事 (apps/sim/executor/variables/resolvers/loop.ts:105-117parallel.ts:103-115)。

第二级——找对块输出。 ExecutionState.getScopedBlockOutput (apps/sim/executor/execution/state.ts:122,getScopedBlockState)在扁平的 blockStates 表里,按三元组 (obranch 号, ₍N₎ 下标, _loopN 轮次) 逐项相等地筛存储 id:

想读 blockA,当前节点是 blockB₍2₎_loop3

├─ 候选 "blockA₍2₎_loop3" 三段全等 ──> 命中
├─ 候选 "blockA₍0₎_loop3" 分支不等 ──> 跳过
└─ 候选 "blockA₍2₎_loop1" 轮次不等 ──> 跳过

匹配不到、且当前节点确实带 __obranch- 时,函数明确返回 undefined而不是回退到全局那份 (apps/sim/executor/execution/state.ts:85-88)——宁可空,也不给一个别的分支的值。

8.4 <loop.index> 怎么知道自己在哪个循环

<loop.index> 这类"泛化引用"没写循环名。findInnermostLoopForBlock (apps/sim/executor/variables/resolvers/loop.ts:292)用剥干净后的 baseId 去 workflow.loops 里 找所有包含它的循环,若有多个,取那个"不是任何其它候选的祖先"的——也就是最内层。 ParallelResolver 有对称的实现(apps/sim/executor/variables/resolvers/parallel.ts:279)。

<loop.*> 支持的字段是白名单:iteration/index/item/currentItem/items (apps/sim/executor/variables/resolvers/loop.ts:45),写错会抛 InvalidFieldError 并附上 "这个循环可用哪些字段"(forEach 才有 currentItem/items,:284-300)。

ParallelResolver.resolveBranchIndex(apps/sim/executor/variables/resolvers/parallel.ts:222) 找分支号也是多级兜底:parallelBlockMapping → 节点 id 的 ₍N₎__obranch-N → 沿 subflowParentMap 找父并行的分支号(resolveParentParallelBranchIndex,:229)。 最后一条专门服务"块在嵌套子流程里,自己不带分支下标"的情形。


9. 深入实现:代码块的两个巧处

代码块(Function block)是引用解析里唯一一条不做字符串拼接的路径,原因有两个:安全,和体积。

9.1 值不进代码文本,进运行时上下文变量

朴素做法的问题: 把解析出来的值 JSON.stringify 后拼进用户代码,等于把数据当代码执行—— 数据里的引号/反引号能直接改写程序结构。而且一个 10 MB 的数组会让代码字符串本身变成 10 MB。

Sim 的做法(resolveCodeWithContextVars,apps/sim/executor/variables/resolver.ts:425):

// 示意,非源码
// 用户写的: const t = <Agent 1.content>
// 实际执行的: const t = globalThis["__blockRef_0"]
// 另外带过去的: contextVariables = { __blockRef_0: <真实值> }

值被塞进 contextVarAccumulator,代码里换成对该变量的运行时访问表达式 (formatContextVariableReference,:919),值本身从此不再以源码文本的形式出现。

传递链路:BlockExecutor 把 map 挂在 _runtimeContextVars 键上 (FUNCTION_BLOCK_CONTEXT_VARS_KEY,apps/sim/executor/variables/resolver.ts:32;塞入点 apps/sim/executor/execution/block-executor.ts:189), apps/sim/executor/handlers/function/function-handler.ts:78 再取出来作为 contextVariables 传给 function_execute 工具。这个键在写日志前会被过滤掉 (apps/sim/executor/execution/block-executor.ts:878)。

9.2 引号上下文判断:同一个变量,四种写法

难点在于引用可能出现在代码的任何位置,而"访问变量"的正确写法取决于它落在哪里。 getCodeStringQuoteContext(apps/sim/executor/variables/resolver.ts:1060)从头扫到引用位置, 维护一个模式栈(普通代码 / 单引号 / 双引号 / Python 三引号 / 模板串 / 模板表达式 / 行注释 / 块注释), 给出该位置的引号上下文,然后按语言产出:

引用落在JavaScript 产出Python 产出
普通代码globalThis["__blockRef_0"]globals()["__blockRef_0"]
双引号串内" + JSON.stringify(globalThis[...]) + "" + json.dumps(globals()[...]) + "
单引号串内' + JSON.stringify(...) + '' + json.dumps(...) + '
模板串 `…`${JSON.stringify(...)}—(Python 无此上下文)

Shell 走单独一支(formatShellContextVariableReference,:1175):双引号里直接 ${var}, 单引号里要先闭合外层单引号再包一层双引号。

这一步同时买到两样东西: 语法正确(在字符串里必须变成拼接,不能裸放表达式), 以及注入防护(值永远不作为源码 token 出现)。

9.3 大值走懒引用,不进内存

三档阈值,各管一段:

机制阈值形态定义处
LargeValueRef单值 8 MB值被换成一个带 id/kind/size/preview 的小对象apps/sim/lib/execution/payloads/large-value-ref.ts:3
LargeArrayManifest大数组分块清单:chunks[] 每块一个 ref,外加 totalCount/previewapps/sim/lib/execution/payloads/large-array-manifest-metadata.ts:7
函数块内联预算6 MB(数据+展示各算一份)超预算的值临时上传成 refapps/sim/executor/variables/resolver.ts:48

代码里的替换形态(行号同属 apps/sim/executor/variables/resolver.ts):

  • 单值 → (await sim.values.read(globalThis["__blockRef_0"]))(formatLazyLargeValueReference,:709)
  • 数组清单 → (await sim.values.readArray(...))(formatLazyLargeArrayManifestReference,:725)

使用前提:canUseJavaScriptRuntimeHelpers(:771)要求 language === 'javascript', 且代码里没有静态 import / require(...)(hasJavaScriptModuleDependencySyntax,:778, 用同一套模式栈扫描,不会被注释和字符串里的 import 骗到)。不满足就只能内联,内联不下就抛 materialization 错误。

第三档的动机写在 maybeOffloadInlineFunctionContextValue 的 TSDoc 里(:647-657): 几个"单个都没超 8 MB"的中等值合在一个函数块里,照样能把内部路由的 ~10 MB 请求体撑爆。 所以按累计足迹记账,超预算的那些临时上传成 ref。

非代码路径的大值处理在两个 navigatePath 里:同步版遇到 ref 就 materialize-or-throw (apps/sim/executor/variables/resolvers/reference.ts:130),服务端异步版能真去存储里取, 还能按需 hydrate 文件的 base64(apps/sim/executor/variables/resolvers/reference-async.server.ts:139)。

展示层单独处理:formatDisplayValueForCodeContext (apps/sim/executor/variables/resolver.ts:1014)对 ref 渲染成 /* large object · 12.3 MB, fetched at runtime */ 这样的注释占位,日志里既不泄露内部 ref 结构, 也不把大值再存一遍。真跑的代码和给人看的代码从这里开始分家 (FUNCTION_BLOCK_DISPLAY_CODE_KEY,:34)。


10. 巧妙之处(可借鉴的技术)

  1. 把计数循环翻译成条件字符串。 for/forEach 不写计数器分支,统一编译成 <loop.index> < N, 四种循环共用一份判定代码(apps/sim/executor/constants.ts:371apps/sim/executor/orchestrators/loop.ts:115-211)。
  2. 求值前临时改 iteration。 条件问的是"下一轮该不该跑",所以求值窗口内 scope.iteration 被临时置为 iteration+1,算完还原(apps/sim/executor/orchestrators/loop.ts:389-400)。
  3. 分支 0 复用模板节点。 复印时第 0 份只改元数据不建新节点,N 个分支只新建 N-1 个 (apps/sim/executor/utils/parallel-expansion.ts:85-104)。
  4. 失活边只清"源在集合内"的。 一行取舍换来循环第二轮不卡死,理由写在 TSDoc 里 (apps/sim/executor/execution/edge-manager.ts:168-178)。
  5. 正则集中到一个 codec。 所有节点 id 的模式只在 apps/sim/executor/utils/subflow-node-id-codec.ts 出现一次,调用方一律走命名函数,不重建模式。
  6. 引号上下文感知的值注入。 一次代码扫描同时解决"语法正确"和"防注入"两个问题 (apps/sim/executor/variables/resolver.ts:961 + :1018)。
  7. 展示流与数据流分家。 同一次解析产出 resolvedCode(变量引用)和 displayCode(字面量/占位), 日志好读、执行安全、大值不重复落盘(apps/sim/executor/variables/resolver.ts:1315-1330 起,引用替换核心 replaceValidReferencesAsync:76)。

11. 边界与局限

  • 并行批大小上限 20,分支数不设上限。 DEFAULTS.MAX_PARALLEL_BRANCHES = 20 (apps/sim/executor/constants.ts:179)只在 resolveBatchSize (apps/sim/executor/orchestrators/parallel.ts:271-278)里 clamp 批大小,默认值也是 20(:27); 分支数由 resolveBranchCount(:185-201)算出后直接使用,没有任何截断。一个大集合会变成 很多批,不会变成 20 个分支。
  • 循环默认 1000 轮。 DEFAULT_LOOP_ITERATIONS = 1000(apps/sim/executor/constants.ts:178), while 循环没有硬性轮数上限——只受条件和外部取消控制。
  • 条件求值失败一律当 false。 VM 报错只记日志然后 return false (apps/sim/executor/orchestrators/loop.ts:791-799),表现为循环静默退出,而不是工作流报错。
  • 嵌套深度 10。 MAX_NESTING_DEPTH = 10(apps/sim/executor/constants.ts:180), 用于父迭代链遍历的防跑飞上限(apps/sim/executor/utils/iteration-context.ts:8)。
  • 聚合缺分支只告警。 某个分支没有输出时填空数组并 logger.warn,不中断 (apps/sim/executor/orchestrators/parallel.ts:498-502)。
  • 代码块用了 import/require 就没有懒大值。 只有纯 JavaScript 且无模块依赖语法时才能用 sim.values.read(apps/sim/executor/variables/resolver.ts:813),否则超阈值的值直接抛错。
  • 嵌套值里带 ref 不支持。 引用解析到"对象内部嵌着大值 ref"时直接抛错,提示用户 直接引用那个嵌套字段(apps/sim/executor/variables/resolver.ts:70-74:458)。
  • loop/parallel/variable 是保留块名(apps/sim/executor/constants.ts:165,委托 @sim/workflow-types), 否则会被前三个解析器截胡。

12. 横向对比

12.1 同一件事,四个引擎四种解法

本章的核心问题——在一张"谁的依赖满足了谁就跑"的图引擎上,怎么表达循环和分支——是所有可视化 工作流引擎的公共难题。总库对这一族的定位见 分支 E · 长成什么产品(「画布产 JSON → 引擎跑图」家族)。 下表把货架里三个兄弟子库的取舍和 Sim 并排放:

项目图里有环吗循环靠什么表达分支没选中的路怎么办深入读
sim(本章)有回边,但回边不登记入边,所以入边视角仍无环一对哨兵节点 + 每轮四件重置边失活并沿下游级联剪枝本章 §3、§5
langflow真有环networkx 求强连通分量标出"环上顶点",给它们单独的放行判据 + max_iterations 兜底两套并存的剪枝状态机langflow/04-cycles-and-branching.md
rivet无环,纯数据流特殊数据值 control-flow-excluded 沿边传染,loopController'loop-not-broken' 标签解毒同一个"排除值"顺着边往下毒rivet/03-control-flow-loops-races.md
flowise v2无环解释器把上游节点再推一次队列,loopCounts 记圈数防死循环引擎决定谁入队,没选中的干脆不入队flowise/03-agentflow-v2-engine.md

三点可带走的对比结论:

  • "环"可以不进图。 sim 和 flowise 都把回跳做成运行期动作(重置状态 / 重新入队), langflow 则选择让图真的带环、再给调度器加一套环上判据。前者调度器更简单,后者图更贴用户心智。
  • 分支剪枝必须是显式的。 四家都没有"不选中就不管":sim 剪边、rivet 传毒、langflow 开状态机。 原因一样——下游的汇合节点必须被告知"那条路死了",否则永远等不到齐(sim 侧的完整论证在 02-scheduler-and-edges.md §6)。
  • 扇出的"多份身份"往哪放,是最大的分野。 rivet 的 split run 是节点内扇出——Promise.all 跑完再把结果聚合回一个数组,图上不多出节点,而且扇出数封顶 10;sim 反过来,把分支号和轮次 写进节点 id 字符串(§7),图上真的多出 N 组节点、分支数不设上限(§6.1)。代价是多一套编解码, 收益是取值天然带作用域(§8.3)、每个分支的日志与状态天然隔离。

12.2 接着读:本组其它章

章节关系
index.md全局导读与阅读顺序
01-canvas-to-dag.md本章用的 dag.loopConfigs / dag.parallelConfigs / 哨兵节点都是那一章编译出来的
02-scheduler-and-edges.md本章的"每轮重置"打的正是那一章的就绪判定和失活边状态
04-agent-providers-tools.md本章解析出的 inputs 交给块处理器执行,Agent 块是其中最复杂的一种
05-pause-resume-and-triggers.md克隆 id 的确定性(__clone<hex>)正是为跨请求恢复服务的
06-copilot-mothership.mdAI 搭工作流时生成的正是本章要跑的循环/并行结构

13. 代码地图(导航索引)

主题文件路径关键符号
所有节点执行入口、哨兵特判apps/sim/executor/orchestrators/node.tsNodeExecutionOrchestrator.executeNodehandleSentinelhandleParallelSentinel
节点完成后的记账分流apps/sim/executor/orchestrators/node.tshandleNodeCompletionhandleParentSubflowCompletionhandleRegularNodeCompletion
循环 scope 生命周期apps/sim/executor/orchestrators/loop.tsinitializeLoopScopeevaluateLoopContinuationcreateExitResult
循环条件求值apps/sim/executor/orchestrators/loop.tsevaluateInitialConditionevaluateWhileConditionhasReachedConfiguredIterationLimit
每轮重置apps/sim/executor/orchestrators/loop.tsclearLoopExecutionStateresetNestedLoopScopesresetNestedParallelScopescollectAllLoopNodeIdsrestoreLoopEdges
失活边清理apps/sim/executor/execution/edge-manager.tsclearDeactivatedEdgesForNodesprocessOutgoingEdges
并行 scope 与分批apps/sim/executor/orchestrators/parallel.tsinitializeParallelScoperesolveBranchCountresolveBatchSizeprepareCurrentBatchadvanceToNextBatch
并行聚合与归集apps/sim/executor/orchestrators/parallel.tsaggregateParallelResultshandleParallelBranchCompletionregisterClonedSubflowsregisterBranchMappings
并行分批的行为规格apps/sim/executor/orchestrators/parallel.test.tsnormalizes %s batch sizeadvances batch state at sentinel end
子图复印与克隆apps/sim/executor/utils/parallel-expansion.tsParallelExpander.expandParallelcloneNestedSubflowcloneSubflowGraphbuildPreCloneIdForParentClonedSubflowInfo
节点 id 编解码apps/sim/executor/utils/subflow-node-id-codec.tsSubflowNodeIdCodecstripCloneSuffixesfindEffectiveContainerIdnormalizeLookupId
编解码的对外门面apps/sim/executor/utils/subflow-utils.tsextractLoopIdFromSentinelstripOuterBranchSuffixsubflowContainsBlockemitSubflowSuccessEvents
迭代上下文(给日志/SSE)apps/sim/executor/utils/iteration-context.tsgetIterationContextbuildContainerIterationContextbuildUnifiedParentIterations
集合输入解析(服务端)apps/sim/executor/utils/subflow-utils.server.tsresolveArrayInputAsyncnormalizeCollectionValue
变量解析总入口apps/sim/executor/variables/resolver.tsVariableResolver.resolveInputsresolveValueresolveTemplateresolveSingleReferenceresolveReference
代码块专用解析apps/sim/executor/variables/resolver.tsresolveInputsForFunctionBlockresolveCodeWithContextVarsFUNCTION_BLOCK_CONTEXT_VARS_KEY
引号上下文与注入形态apps/sim/executor/variables/resolver.tsgetCodeStringQuoteContextformatContextVariableReferenceformatShellContextVariableReference
懒大值apps/sim/executor/variables/resolver.tsformatLazyLargeValueReferenceformatLazyLargeArrayManifestReferencemaybeOffloadInlineFunctionContextValue
大值载体定义apps/sim/lib/execution/payloads/large-value-ref.tslarge-array-manifest-metadata.tsLargeValueRefisLargeValueRefLargeArrayManifestisLargeArrayManifest
块引用解析apps/sim/executor/variables/resolvers/block.tsBlockResolver.canResolvegetBlockOutputformatValueForBlock
循环/并行上下文引用apps/sim/executor/variables/resolvers/loop.tsparallel.tsfindInnermostLoopForBlockresolveBranchIndexresolveParentParallelBranchIndex
变量与环境变量引用apps/sim/executor/variables/resolvers/workflow.tsenv.tsWorkflowResolver.resolveEnvResolver.resolve
路径导航apps/sim/executor/variables/resolvers/reference.tsreference-async.server.tsnavigatePathnavigatePathAsyncRESOLVED_EMPTYResolver
输出的迭代作用域查找apps/sim/executor/execution/state.tsExecutionState.getBlockOutputgetScopedBlockOutput
常量与引用语法apps/sim/executor/constants.tsEDGECONTROL_BACK_EDGE_HANDLESLOOP.SENTINELPARALLEL.BRANCHREFERENCERESERVED_BLOCK_NAMESparseReferencePathbuildLoopIndexConditionDEFAULTS