04 · Workflow、API Prompt 与动态图
ComfyUI 最常见的接口误解,是把“保存的 Workflow”与“提交给 /prompt 的 Prompt”当成同一种 JSON。它们表达同一张逻辑图,却服务于不同读者:前者服务编辑器,后者服务执行器。
两种图,两种职责
| 维度 | Workflow | API Prompt |
|---|---|---|
| 主要消费者 | Web 前端编辑器 | Python 后端执行器 |
| 节点形态 | 数组,含 position、size、flags、widget values | 以节点 ID 为键的对象 |
| 连线 | 独立 link 记录及 slot 索引 | 直接嵌入目标输入:[source_id, output_index] |
| UI 信息 | 分组、缩放、折叠、颜色等 | 通常不需要 |
| 执行类型 | 前端节点 type | class_type |
| 获取方式 | 保存工作流 | 前端 Export (API) / 客户端构造 |
官方示例脚本开头也明确说明它使用 API Prompt,并建议从前端 Export (API) 得到特定工作流的执行格式。
script_examples/basic_api_example.pyL1-12API Prompt 示例与 Export (API) 说明。最小 API Prompt
下面省略了部分采样链,只保留类型表达方式:
{
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": { "ckpt_name": "model.safetensors" }
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {
"text": "a glass city at sunrise",
"clip": ["4", 1]
}
}
}"model.safetensors" 是常量;["4", 1] 是链接,表示“取节点 4 的第 1 号输出”。这个表示法把边放进输入里,因此从目标节点可直接追到上游。
链接判定为什么不是“所有二元数组”
运行时的 is_link() 检查值形状与元素类型:第一个元素是字符串节点 ID,第二个是数字插槽。普通业务输入也可能是数组,所以协议要用更精确的判定,而不能见 list 就当边。
节点 ID 是字符串
前端常产生看似数字的 ID,如 "6",但运行时把它当字符串。运行时子图会生成带前缀的复合 ID,更说明不应在插件里强制转整数。
output index 是协议的一部分
连接不仅依赖上游节点,也依赖它的第几个输出。缓存签名会保留这个插槽位置;同一节点的 MODEL、CLIP、VAE 输出不可互换。
Prompt 的节点记录
基础字段包括:
node_id → {
class_type: 节点注册名,
inputs: 输入名 → 常量或 link,
_meta: 可选显示信息
}节点类的 INPUT_TYPES / V3 Schema 决定输入要求,RETURN_TYPES / outputs 决定链接类型。Prompt 本身不携带 Python 代码,也不序列化模型对象。
为什么从输出节点开始
画布中可能保留实验分支、预览节点和暂时断开的旧链路。执行器只需要满足用户声明的结果,因此验证器先找 OUTPUT_NODE / is_output_node,再沿链接反向递归。
上图只有到 SaveImage 的闭包必需。孤立节点即使 schema 完全合法,也不会因“出现在 JSON 中”自动运行。
这带来三个性质:
- 死分支天然消除:不需要独立优化 pass;
- 局部执行有自然语义:换一组输出根即可;
- 副作用必须显式成为输出:否则运行时没有理由求值。
partial execution targets
POST /prompt 可携带局部执行目标。验证器会把这些目标与输出节点规则结合,得到本次真正的 good_outputs。它适合只预览工作流的一部分,也使一个 Prompt 能被不同目标重复利用。
局部目标不是随便指定任意内部节点来绕过协议;目标仍需满足可执行根的约束,错误会在验证阶段返回。
node replacements
服务入口在验证前允许节点替换逻辑调整 Prompt。其目的通常是兼容或将一种高层节点映射到实际执行节点。因为替换先于验证,验证看到的是最终结构,而不是验证一张图、执行另一张图。
设计原则是:任何会改变依赖或类型的重写,都应在计算 good_outputs 和缓存签名前完成。
DynamicPrompt:原始图之外还有一层
执行器没有直接把原始 dict 当作唯一真相,而是创建 DynamicPrompt:
# 概念模型
class DynamicPrompt:
original_prompt
ephemeral_prompt
ephemeral_parents
ephemeral_display它提供 get_node_id_to_display()、get_real_node_id() 等映射,原因是运行时节点的内部唯一 ID 不一定适合直接显示给用户。错误消息需要指回生成它的父节点,而缓存又必须区分实际实例。
运行时子图怎样加入
允许 expand 的节点可返回:
{
"result": (...links_to_subgraph_outputs...),
"expand": {
"node_a": {"class_type": "...", "inputs": {...}},
"node_b": {"class_type": "...", "inputs": {...}}
}
}GraphBuilder 为节点加唯一前缀,把本地链接改写成全局 ephemeral IDs,并序列化成 Prompt 片段。执行器将这些节点合并进 DynamicPrompt,为它们建立独立缓存层,然后把当前节点退回 pending。
原始节点和临时节点的身份
| 属性 | 原始节点 | 临时节点 |
|---|---|---|
| 来源 | API Prompt | 某节点执行结果的 expand |
| ID | 客户端提供 | GraphBuilder 前缀化 |
| 生命周期 | 整个 Prompt | 生成它的执行分支 |
| 显示 | 自身 ID / _meta | 常映射到父节点显示 ID |
| 缓存 | 顶层缓存 | hierarchical cache 下的子缓存 |
临时节点不能污染下一次请求的原始 Prompt。它们属于这次动态展开,并通过父级缓存关系管理生命周期。
图中的三类输入
在真正调用节点前,get_input_data() 把 Prompt 输入归一为:
- 连接输入:从 cache / 上游输出提取指定 slot;
- 常量输入:放入单元素列表,以适配批/list mapping;
- 隐藏输入:由运行时注入,如
PROMPT、EXTRA_PNGINFO、UNIQUE_ID、DYNPROMPT。
隐藏输入是能力注入,不应由客户端伪造同名普通值来获得同等语义。节点 schema 明确声明后,运行时才提供。
图不只是 DAG
在静态视角,合法 Prompt 必须无强依赖环。但运行时还会出现:
- 懒链接暂时不计入强依赖;
- 外部异步任务让节点挂起;
- expand 使图增长;
- 缓存让某些节点无需进入执行;
ExecutionBlocker让结果沿边阻断。
因此更准确的模型是“可增长、有条件依赖、可挂起的数据流图”,而不是一次性排完序的 DAG。
一个改动如何传播
将正向提示从 A 改为 B:
CheckpointLoader signature unchanged → cache hit
CLIPTextEncode(+) constant changed → recompute
CLIPTextEncode(-) unchanged → cache hit
EmptyLatentImage unchanged → cache hit
KSampler upstream signature → recompute
VAEDecode upstream signature → recompute
SaveImage upstream signature → execute这不是 Prompt 图自己决定的,而是图结构被缓存签名递归编码后的结果。第 08 章会把签名算法完整拆开。
API 客户端的最小正确姿势
- 从 Export (API) 得到后端格式,不把 Workflow 原样 POST;
- 只修改已知节点的常量输入,保留链接二元组;
- 为每次提交生成/接收独立 prompt ID;
- 设置
client_id并监听 WebSocket,或轮询 history; - 先处理 HTTP validation error,再等待执行事件;
- 不假设节点 ID 是整数或执行顺序。
可迁移的设计方法
- 编辑模型与执行 IR 分离。 编辑器可以演进布局字段,执行器保持最小协议。
- 边内嵌在消费者输入。 反向需求分析和递归签名更直接。
- 原始 IR 不可变,动态扩展分层存储。 便于错误归因和缓存回收。
- 副作用由显式输出根驱动。 防止死分支意外产生外部效果。
- 链接位置进入语义身份。 上游对象相同但 output slot 不同,结果仍应不同。