开发基于ComfyUI图片生成类应用Step by step(三)理解ComfyUI工作流的执行方式
在本合集第二部分,我们编写了一个极简的图像生成程序,通过这个程序了解了ComfyUI的工作过程。在这个程序中使用代码创建了工作流,我们也可以使用ComfyUI的可视化工作流编制和运行工具创建工作流,然后导出工作流的json文件,应用到程序中。
使用ComfyUI可视化编辑器创建工作流
启动ComfyUI后,在浏览器访问 http://localhost:8188 ,就可以进入工作流编辑界面:

在这个编辑器里通过节点拖拽就可以完成工作流的编制,也可以运行工作流。有关ComfyUI可视化编辑器的使用的教程很多,这里不再赘述,我们主要关注创建的工作流如何在应用中使用。工作流编辑完成后,在文件操作中执行“导出(API)”,会得到下面的json文件:
{
"9": {
"inputs": {
"filename_prefix": "ComfyUI",
"images": [
"65",
0
]
},
"class_type": "SaveImage",
"_meta": {
"title": "保存图像"
}
},
"62": {
"inputs": {
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
"type": "lumina2",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "加载CLIP"
}
},
"63": {
"inputs": {
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "加载VAE"
}
},
"65": {
"inputs": {
"samples": [
"70",
0
],
"vae": [
"63",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE解码"
}
},
"67": {
"inputs": {
"text": "秋日湖边银杏树",
"clip": [
"62",
0
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP文本编码"
}
},
"68": {
"inputs": {
"width": 1024,
"height": 1024,
"batch_size": 1
},
"class_type": "EmptySD3LatentImage",
"_meta": {
"title": "空Latent图像(SD3)"
}
},
"69": {
"inputs": {
"shift": 3,
"sampling": "flow",
"model": [
"72",
0
]
},
"class_type": "ModelSamplingAuraFlow",
"_meta": {
"title": "采样算法(AuraFlow)"
}
},
"70": {
"inputs": {
"seed": 42,
"steps": 23,
"cfg": 1,
"sampler_name": "res_multistep",
"scheduler": "simple",
"denoise": 1,
"model": [
"69",
0
],
"positive": [
"67",
0
],
"negative": [
"71",
0
],
"latent_image": [
"68",
0
]
},
"class_type": "KSampler",
"_meta": {
"title": "K采样器"
}
},
"71": {
"inputs": {
"text": "low quality, bad anatomy, extra digits, missing digits, extra limbs, missing limbs",
"clip": [
"62",
0
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP文本编码"
}
},
"72": {
"inputs": {
"unet_name": "qwen-image-2.1-UC-Q4_K_M.gguf"
},
"class_type": "UnetLoaderGGUF",
"_meta": {
"title": "Unet Loader (GGUF)"
}
}
}
这个工作流中的参数是写死的,例如提示词、步数、种子、宽度、高度等等,我们需要将其改造为模板文件,才能在应用中使用。
工作流模板文件
我们将工作流中的参数改为可替换变量的形式,在应用程序中替换这些变量。下面是改造后的模板文件:
{
"1": { "class_type": "UnetLoaderGGUF", "inputs": { "unet_name": "{{transformer}}" } },
"2": { "class_type": "CLIPLoader", "inputs": {
"clip_name": "{{text_encoder}}",
"type": "{{clip_type}}",
"device": "default" } },
"3": { "class_type": "VAELoader", "inputs": { "vae_name": "{{vae}}" } },
"4": { "class_type": "ModelSamplingAuraFlow", "inputs": { "model": ["1", 0], "shift": "{{shift}}" } },
"5": { "class_type": "CLIPTextEncode", "inputs": { "clip": ["2", 0], "text": "{{prompt}}" } },
"6": { "class_type": "CLIPTextEncode", "inputs": { "clip": ["2", 0], "text": "{{negative}}" } },
"7": { "class_type": "EmptySD3LatentImage", "inputs": { "width": "{{width}}", "height": "{{height}}", "batch_size": 1 } },
"9": { "class_type": "KSampler", "inputs": {
"model": ["4", 0],
"positive": ["5", 0],
"negative": ["6", 0],
"latent_image": ["7", 0],
"seed": "{{seed}}",
"steps": "{{steps}}",
"cfg": "{{guidance}}",
"sampler_name": "{{sampler}}",
"scheduler": "simple",
"denoise": 1.0 } },
"10": { "class_type": "VAEDecode", "inputs": { "samples": ["9", 0], "vae": ["3", 0] } },
"11": { "class_type": "SaveImage", "inputs": { "images": ["10", 0], "filename_prefix": "template-demo/api" } }
}
在模板中使用{{变量名}}的格式,在程序中替换为实际值,下面是改造后的demo:
/**
* 使用 ComfyUI 常驻服务的最小示例 · JSON 模板方式(零依赖,Node ≥ 20.6)。
*
* 与 comfy-service-client-demo/demo.ts(代码生成方式)功能完全相同:
* 提交文生图工作流 → 轮询 history → 下载生成结果。
* 区别只在第一步:工作流不是代码里拼的,而是从
* workflows/txt2img.template.json 加载模板并注入参数。
*
* 用法:
* node demo.ts # 默认提示词
* node demo.ts "一只戴帽子的橘猫" # 自定义提示词
* COMFY_URL=http://127.0.0.1:8188 node demo.ts
*
* 前提:常驻服务已启动(comfy-service 根目录 ./start.sh)。
* 两种方案的对比见 comfy-service/docs/工作流管理-模板与代码生成.md。
*/
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { readFileSync } from "node:fs";
import path from "node:path";
const BASE = process.env.COMFY_URL ?? "http://127.0.0.1:8188";
const CLIENT_ID = `demo-tpl-${randomUUID().slice(0, 8)}`;
const TEMPLATE = path.join(import.meta.dirname, "workflows", "txt2img.template.json");
const OUTPUT = path.join(import.meta.dirname, "outputs", "demo.png");
// ---------------- 模板注入 ----------------
/**
* 递归遍历模板,把 "{{key}}" 占位符替换为参数值:
* - 模板统一用带引号的占位符(保证 JSON 文件本身合法)
* - 整值占位符且参数为数字/布尔 → 还原为原始类型(seed/steps 等)
* - 嵌入字符串中的占位符 → 文本替换
* 参数缺失时立即报错(fail fast)。
*/
function inject(node: unknown, vars: Record<string, unknown>): unknown {
if (typeof node === "string") {
// 整值占位符:"{{seed}}" 且值为数字/布尔 → 直接还原为原始类型
const whole = /^\{\{(\w+)\}\}$/.exec(node);
if (whole) {
const key = whole[1]!;
if (!(key in vars)) throw new Error(`模板变量缺失:${key}`);
const v = vars[key];
return typeof v === "number" || typeof v === "boolean" ? v : String(v);
}
// 嵌入式占位符:替换为字符串
return node.replace(/\{\{(\w+)\}\}/g, (m, key: string) => {
if (!(key in vars)) throw new Error(`模板变量缺失:${key}`);
return String(vars[key]);
});
}
if (Array.isArray(node)) return node.map((x) => inject(x, vars));
if (typeof node === "object" && node !== null) {
return Object.fromEntries(
Object.entries(node as Record<string, unknown>).map(([k, v]) => [k, inject(v, vars)]),
);
}
return node;
}
function loadWorkflow(vars: Record<string, unknown>): Record<string, unknown> {
const template = JSON.parse(readFileSync(TEMPLATE, "utf8"));
return inject(template, vars) as Record<string, unknown>;
}
// ---------------- HTTP ----------------
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function ping(): Promise<boolean> {
try {
await fetch(`${BASE}/system_stats`, { signal: AbortSignal.timeout(3000) });
return true;
} catch {
return false;
}
}
// ---------------- 主流程 ----------------
async function main(): Promise<void> {
const prompt = process.argv.slice(2).join(" ") || "一只戴帽子的橘猫,水彩画风格";
if (!(await ping())) {
console.error(`[错误] ComfyUI 服务不可达(${BASE}),请先启动 comfy-service`);
process.exit(1);
}
console.log(`[连接] ${BASE} client_id=${CLIENT_ID}`);
// 1. 加载模板并注入参数(对照 demo.ts:这里没有一行节点拼装代码)
const vars = {
transformer: "qwen-image-2.1-UC-Q4_K_M.gguf",
text_encoder: "qwen3vl_8b_int8_convrot.safetensors",
clip_type: "qwen_image", // Qwen-Image 的正确类型(勿用 lumina2)
vae: "qwen_image_2.1_vae_bf16.safetensors",
shift: 3.1,
prompt,
negative: "low quality, bad anatomy", // cfg>1 时才生效
width: 768, height: 768,
seed: Date.now() % 2 ** 31,
steps: 8,
guidance: 4.0,
sampler: "euler",
};
const workflow = loadWorkflow(vars);
// 2. 提交(node_errors 时 ComfyUI 返回 400 详情,原样带出)
const submitRes = await fetch(`${BASE}/prompt`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client_id: CLIENT_ID, prompt: workflow }),
signal: AbortSignal.timeout(30_000),
});
if (!submitRes.ok) {
console.error(`[失败] ComfyUI 拒绝了工作流(HTTP ${submitRes.status}):\n` +
(await submitRes.text()).slice(0, 600));
process.exit(1);
}
const { prompt_id: promptId, number } = await submitRes.json() as {
prompt_id: string; number: number;
};
console.log(`[提交] prompt_id=${promptId}(队列位置 ${number}:` +
`${number === 0 ? "正在执行" : `前面还有 ${number} 个任务`})`);
console.log(`[提示词] ${prompt}`);
// 3. 轮询 history 直到完成
const started = Date.now();
interface OutputImage { filename: string; subfolder?: string; type?: string }
let outputs: Record<string, { images?: OutputImage[] }> = {};
for (;;) {
await sleep(3000);
const res = await fetch(`${BASE}/history/${promptId}`, { signal: AbortSignal.timeout(30_000) });
const history = (await res.json()) as Record<string, {
status?: { status_str?: string };
outputs?: Record<string, { images?: OutputImage[] }>;
}>;
const entry = history[promptId];
if (!entry) {
process.stdout.write(`\r[等待] ${Math.floor((Date.now() - started) / 1000)}s …`);
continue;
}
if (entry.status?.status_str === "error") {
console.error(`\n[失败] ${JSON.stringify(entry.status)}`);
process.exit(1);
}
outputs = entry.outputs ?? {};
break;
}
// 4. 取回图片
for (const nodeOut of Object.values(outputs)) {
for (const img of nodeOut.images ?? []) {
const q = new URLSearchParams({
filename: img.filename, subfolder: img.subfolder ?? "", type: img.type ?? "output",
});
const res = await fetch(`${BASE}/view?${q}`, { signal: AbortSignal.timeout(60_000) });
const raw = Buffer.from(await res.arrayBuffer());
await mkdir(path.dirname(OUTPUT), { recursive: true });
await writeFile(OUTPUT, raw);
console.log(`\n[完成] 耗时 ${Math.floor((Date.now() - started) / 1000)}s → ${OUTPUT}(${(raw.length / 1024).toFixed(0)} KB)`);
return;
}
}
console.error("\n[失败] 输出中没有找到图片");
process.exit(1);
}
void main();
使用效果和代码拼接工作流一样。
代码拼接和工作流JSON模板的比较
现在我们比较一下代码拼接生成工作流(A)和使用工作流JSON模板(B)的好处与代价:
| 维度 | A 代码生成 | B JSON 模板 |
|---|---|---|
| 工作流变更成本 | 改代码 → 测试 → 发版(但 tsx watch 下也就是重启) | 画布上改 → 导出 → 换文件,可热加载,零发版 |
| 上手门槛 | 要读懂节点接线与类型 | 编辑器所见即所得,非程序员也能改 |
| 动态拓扑 | ✅ 强项:if/for 自由表达——通道切换、参考图 2~9 张的 images.image_N 循环接线、denoise/CFG 随通道联动 |
❌ 软肋:静态 JSON 表达不了条件与循环。要么按组合准备 N 个模板文件,要么模板引擎长出条件/循环语法——复杂度逼近写代码 |
| 类型与校验 | ✅ TypeScript 编译期防护 + 单元测试逐节点断言(image-studio 135 个测试) | ❌ 模板改坏(占位符拼错、删了被引用的节点)运行时才炸,报错来自 ComfyUI 的 node_errors,排查链变长 |
| 参数契约 | schema(zod)与构建器同仓演进 | 占位符与调用代码分处两处,需要额外机制保持同步 |
| 版本管理 | 代码评审、diff、回滚全套现成 | JSON 同样可进 git,但 diff 可读性差(编辑器导出常重排) |
| 复用与组合 | 函数组合(三通道共用 loaderNodes) | 模板片段复制粘贴,改一处忘另一处 |
方案A把工作流当逻辑(换来安全与表达力),B 把工作流当数据(换来变更灵活与低门槛)。矛盾的核心是:工作流既像配置(模型名、shift、采样器偏好)又像逻辑(通道、循环、约定)。因此,两种方案各有适用场景:
- 方案B 更合适:形状稳定、只有参数变的场景——固定链路的文生图/图生图、单点增删节点(如插入 LoRA)、由非开发人员维护工作流、A/B 测试不同工作流版本。
- 方案A 更合适:拓扑随参数变形(参考图数量不定、多通道路由)、工作流正确性是核心资产(商业产品)、团队全栈都是工程师。
进一步理解ComfyUI工作流的执行方式
在前面的例子中,方案A和方案B产生的工作流对ComfyUI是等效的,这验证了Comfy的实质是一台"无状态的图执行引擎"。也就是说comfyUI 服务端不保存任何工作流定义, 工作流("怎么组合模型和节点")始终由调用方持有,服务端只是一个执行器。从编辑器"保存"的工作流是浏览器下载到本地的文件,ComfyUI安装目录里并没有一个"工作流库"。这与很多工作流引擎不同,也是我们需要注意的地方。
在调用ComfyUI服务时,客户端都需要推送一张完整的工作流图(JSON),服务器解析校验工作流,将节点进行拓扑排序,然后逐节点执行,完成后通知客户端取图。这个设计的直接好处:多个应用(文生图、试衣、漫画……)可以共用同一个 ComfyUI 常驻服务,各推各的图,互不干扰;应用升级工作流(改提示词模板、换采样器)零部署成本——改客户端代码即生效。

本文来自博客园,作者:寻找无名的特质,转载请注明原文链接:https://www.cnblogs.com/zhenl/p/23583463

浙公网安备 33010602011771号