开发基于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 常驻服务,各推各的图,互不干扰;应用升级工作流(改提示词模板、换采样器)零部署成本------改客户端代码即生效。