07 · 列表、懒执行、异步与运行时子图
ComfyUI 节点表面上只需定义一个函数,运行时却允许它一次处理列表、延迟索取某个输入、返回未完成 coroutine、生成新节点,或阻断整条分支。复杂性集中在 _async_map_node_over_list() 与 execute(),使普通节点保持简单。
能力矩阵
| 能力 | 节点声明 / 返回 | 调度器动作 |
|---|---|---|
| 列表映射 | 普通输入收到多个元素 | 逐 index 调函数并合并输出 |
| 整列表输入 | INPUT_IS_LIST = True | 只调一次,传完整列表 |
| 列表输出 | OUTPUT_IS_LIST | 合并时展平对应 slot |
| 懒输入 | check_lazy_status | 需要时把弱边变强,节点 PENDING |
| 异步 | async def execute | task 未完成则 external block + PENDING |
| 子图 | expand | 注册 ephemeral nodes 和依赖,父节点 PENDING |
| 阻断 | ExecutionBlocker / block_execution | 不调用下游计算,传播阻断语义 |
统一调用适配器
execution.pyL243-319列表切片、V1/V3 调用、CurrentNodeContext 与 coroutine task。_async_map_node_over_list() 接收对象、归一后的 input_data_all、要调用的方法名和 V3 元数据。验证、fingerprint、lazy status 与真正 execute 都复用它。
这保证一个节点的列表语义不会在四条代码路径中各自实现。
列表映射
所有输入在执行适配层里都是“值列表”。若长度分别为:
images = [A, B, C]
strength = [0.2]
mask = [M1, M2]普通节点会调用三次:
call 0: A, 0.2, M1
call 1: B, 0.2, M2
call 2: C, 0.2, M2短输入通过“重复最后一个元素”补齐,而不是循环或报错。这个规则允许标量自然广播,但也意味着长度不匹配可能被静默接受;节点若要求严格一一对应,应自己验证。
空输入
没有输入时函数仍调用一次,参数为空。否则一个无输入生成器节点将永远没有执行机会。
INPUT_IS_LIST
声明后,运行时不切片,只调用一次并把每个输入的完整 list 传入。它适合需要跨元素聚合、排序或批量决策的节点。
输出合并
每次调用返回一个 tuple。merge_result_data() 按 output slot 转置:
- 普通 output:收集为
[call0_value, call1_value, ...]; OUTPUT_IS_LIST=True的 slot:把每次返回的列表 extend 成一层。
输入列表与张量 batch 不是一回事。前者由执行器多次调用节点;后者可能是一次调用里的 IMAGE tensor 第一维。节点作者要明确自己需要哪一层批处理。
V3 调用为什么要 clone 和 lock
V3 节点通过 class method 描述 schema 和执行。适配器在每次调用前验证类、准备 class clone,并用 locked method 包装调用。动态嵌套输入还会从扁平形式重建为预期的嵌套 dict。
这样能:
- 避免把跨调用可变状态意外留在共享 class 上;
- 让每个 list index 拥有正确的调用上下文;
- 在兼容 V1 执行器时仍保持 V3 schema 语义;
- 将协议保留方法锁定,防止节点覆盖破坏执行契约。
CurrentNodeContext
同步与异步调用都包在 CurrentNodeContext(prompt_id, node_id, list_index) 中。底层服务或 API 节点可读取当前执行身份,而不用把三项参数塞进每个节点签名。
这是一种 context-local 注入。异步任务尤其需要它,否则多个挂起任务恢复时容易读到错误的“当前节点”。
懒输入:先看条件,再决定依赖
典型节点:若条件为真使用 if_true,否则使用 if_false。静态把两个分支都设为强依赖,会浪费一半计算。
节点定义 check_lazy_status(**kwargs),未求值的懒输入以 None(list 模式下是对应空结构)出现。函数返回当前真正需要的输入名。
为什么懒边起初不能计入环
如果两个条件分支里有一个形成环,但运行时永远不选它,静态把它作为强边会错误拒绝合法执行。TopologicalSort 区分连接存在与 strong dependency,等节点明确索取后才阻塞。
防止无限索取
执行器只接受字符串输入名,并过滤掉已存在且不缺失的输入。节点若一直请求同一个已提供输入,不会无限新增边;错误实现仍可能导致反复 PENDING,需要测试覆盖。
异步节点
当执行方法是 coroutine:
- 适配器用
asyncio.create_task()启动; await asyncio.sleep(0)给它一次立即完成机会;- 已完成则直接取 result;
- 未完成则把 Task 留在返回列表。
execute() 发现 pending tasks 后:
pending_async_nodes[node_id] = tasks
unblock = execution_list.add_external_block(node_id)
create background await_completion(tasks, then unblock)
return PENDING下一次节点被 stage,执行器不重复调用远端 API,而是从 pending_async_nodes 取 task.result(),再走统一输出解析。
async 的并发边界
- coroutine 在服务器 asyncio loop 体系内推进;
- 同一 Prompt 的调度循环可以去做其他 ready node;
- 同步 CPU 密集代码即使写在
async def里仍会阻塞 loop; - Task 异常在节点恢复时重新抛出,进入统一 execution error;
- async 优先 heuristic 只是尽早启动,不承诺固定时序。
运行时子图
节点可返回 V1 dict 或 V3 NodeOutput(expand=..., ...)。get_output_from_returns() 把两种形式归一为 (new_graph, result_links)。
执行器随后:
- 把新节点加入
DynamicPrompt,记录 parent / display ID; - 找出子图中的输出节点并加入 ExecutionList;
- 为所有 caches 创建父节点下的 subcache;
- 把父节点 result 中的 links 设为强依赖;
- 保存
pending_subgraph_results[parent]; - 父节点返回 PENDING。
子图完成后,父节点再次执行入口,读取 result link 对应的 cached outputs,按列表规则合并,最后把父节点自身结果写入 cache。
execution.pyL555-610ephemeral nodes、subcache、output links 与父节点挂起。GraphBuilder 的唯一前缀
列表映射可能对同一个节点调用多次,每次都创建本地名 node_1。执行前回调设置默认 prefix,结合父节点 ID 与 call index,避免不同调用的临时节点冲突。
子图缓存为何分层
临时节点只在父节点当前签名下有意义。若把它们扔进顶层平面缓存,父输入改变后旧子节点可能泄漏或被误复用。HierarchicalCache 以父节点 cache key 管理 subcache 生命周期。
ExecutionBlocker
有些情况不是异常,而是“这条数据流不应该继续”。ExecutionBlocker(message) 作为值沿输入传播:
- 无 message 的 blocker 可安静传播;
- 有 message 时执行器发送
ExecutionBlocked错误,并转为空消息 blocker 继续阻断下游; - list 输入里任一元素 blocker,可按节点 list 语义阻断该调用;
- V3
NodeOutput(block_execution=...)被转成每个输出 slot 的 blocker。
异常表示实现失败;Blocker 表示领域控制流。区分两者可以让条件节点停止一条分支,而不把整个运行时当作崩溃。
UI 输出与 expand 的边缘组合
源码注释明确指出一个边缘风险:同一次返回若同时含 expand 和自定义 UI,UI 可能在子图完成前就被缓存。当前没有已知广泛使用的节点依赖这个组合,但节点作者应避免把“依赖子图最终结果的 UI”与 expand 同时提前返回。
这是阅读源码时值得保留的证据:成熟系统也会在注释里标记尚未统一的状态机边界,文档不应把它抹平。
组合示例:异步节点返回动态后处理
同一个父节点可能经历两次 PENDING:一次等待 coroutine,一次等待 expand 子图。调度器不需要为具体能力写死阶段,只需要维护 pending state 和新依赖。
节点作者的选择指南
| 需求 | 使用 |
|---|---|
| 对每张图独立做相同操作 | 默认 list mapping |
| 比较整组图片并选最佳 | INPUT_IS_LIST |
| 根据条件只计算一个昂贵分支 | lazy input + check_lazy_status |
| 等待 HTTP / queue / subprocess | 真正非阻塞的 async def |
| 根据输入生成可视化执行步骤 | GraphBuilder + expand |
| 合法地停止下游而非崩溃 | ExecutionBlocker |
不要用 expand 代替普通函数分解;它适合需要让运行时看见、缓存和调度的新计算单元。不要用 async 包装阻塞 GPU 调用;那只会把阻塞移到 event loop。
可迁移的设计方法
- 把同步/异步、单值/列表的适配集中在调用边界,不污染业务函数。
- 懒依赖用“两阶段询问 + 动态强边”,保持调度器可见性。
- async task 以显式 pending registry 保存,恢复时绝不重复提交副作用。
- 运行时 IR 增量扩展时保留 parent/display 两种身份。
- 用领域 blocker 表达可预期的“不产出”,异常只表达失败。