开发基于ComfyUI图片生成类应用Step by step (四)前后端分离架构:独立ComfyUI服务于应用技术栈选型

前面我们已经介绍了ComfyUI的工作流原理,并搭建了运行环境。在我们创建的环境中,ComfyUI运行在后端,应用程序通过http和web socker协议与ComfyUI服务进行数据交换。本文重点介绍该架构涉及的协议和技术栈选型。

一、架构说明

下图说明了应用程序与ComfyUI服务之间的关系:

与在应用程序中启动和管理ComfyUI服务相比,这种架构有如下好处:

1.1 多应用共享一份模型与显存

Qwen-Image 2.1 三件权重共约 14.6GB。如果每个应用各自拉起 ComfyUI 子进程,模型会被重复加载 N 份------显存翻倍、启动变慢、还互相争抢 GPU。独立常驻后:

  • 模型只在首次生成时加载一次,之后常驻(ComfyUI 自带权重缓存管理)
  • 多个应用同时提交任务,由 ComfyUI 内部队列串行执行,天然避免显存冲突
  • 应用端进程可以随意重启,不影响正在进行的推理

1.2 应用端变得极薄

推理(torch、CUDA、GGUF 反量化、显存调度)全部留在 ComfyUI 侧,应用端不装 torch 也能工作。这意味着:

  • 应用端依赖从 ~3GB(torch + CUDA 运行时)降到几十 MB
  • 部署、升级应用不需要碰 GPU 环境;换显卡只重装 comfy-venv 一处
  • 应用端崩溃不会带走推理进程;反之亦然

1.3 生命周期解耦

可以编写独立的脚本统一管理ComfyUI的生命周期:系统开机启动一次,所有应用整日可用。应用端只需要"连接"而不需要"管理"推理进程,取消任务也不再调用 /interrupt 全局中断------避免影响共用同一服务的其它应用。

1.4 技术栈自由

接口只是 HTTP + WebSocket,应用端用什么语言实现都可以------Python、Node.js + TypeScript、Go、Rust 均无障碍(详见第四节)。推理生态(PyTorch、diffusers、GGUF 加载器)只存在于 ComfyUI 一侧,不再绑架应用端的语言选择。

模型管理集中化

extra_model_paths.yaml 把模型目录映射给 ComfyUI 后,"有哪些模型可用"只有一个权威答案(ComfyUI 自己)。应用端通过 /object_info 查询即可,不必各自扫描磁盘、猜测路径。

二、ComfyUI 接口详细说明

应用端与 ComfyUI 的全部交互归结为:构建工作流 JSON → 提交 → 监听进度 → 取图。本节详细说明应用端与ComfyUI的接口。

2.1 提交任务:POST /prompt

请求体:

json 复制代码
{
  "client_id": "myapp-3f9c2a",
  "prompt": {
    "1": {"class_type": "UnetLoaderGGUF", "inputs": {"unet_name": "qwen-image-2.1-UC-Q4_K_M.gguf"}},
    "2": {"class_type": "CLIPLoader", "inputs": {"clip_name": "qwen3vl_8b_int8_convrot.safetensors", "type": "qwen_image", "device": "default"}},
    "3": {"class_type": "VAELoader", "inputs": {"vae_name": "qwen_image_2.1_vae_bf16.safetensors"}},
    "9": {"class_type": "KSampler", "inputs": {"seed": 42, "steps": 20, "cfg": 1.0, "...": "..."}},
    "11": {"class_type": "SaveImage", "inputs": {"images": ["10", 0], "filename_prefix": "myapp/api"}}
  }
}

要点:

  • prompt 就是 ComfyUI 的 API 格式工作流 :节点字典,key 是节点 id,inputs 里引用其它节点写作 ["节点id", 输出槽位]
  • 模型按文件名引用 (unet_name / clip_name / vae_name),ComfyUI 按 extra_model_paths.yaml 注册的搜索路径解析------应用端不需要知道模型 的磁盘绝对路径
  • 响应 {"prompt_id": "...", "number": 1, "node_errors": {}};node_errors 非空表示工作流有结构性错误(如引用了不存在的模型文件名),任务不会入队

2.2 监听进度:WebSocket ws://<host>/ws?clientId=<client_id>

提交时带的 client_id 决定你在 WS 上收到哪些事件。消息分两类:

  • JSON 帧 (data 为对象):状态与进度事件
  • 二进制帧:预览图(本套应用忽略)

关键事件(type 字段):

事件 含义 应用端用途
execution_start 任务开始执行 更新任务状态 running
progress 采样步进 {value, max} 进度条:value/max * 权重
executing 节点切换 {node, display_node} 阶段提示(加载模型/编码/采样)
executed 节点完成,output.images 含产物文件名 收集结果文件名
execution_error 执行失败,含异常与节点栈 任务标记 failed,展示错误
execution_success 整个任务成功 结束监听
status 队列长度变化 {status: {remaining...}} 队列展示

实践提示:不要只监听 WS 就断定完成------偶发丢帧时任务会永远"进行中"。可靠做法是收尾时再 GET /history/<prompt_id> 校验一次,WS 负责"快",history 负责"准"。

2.3 查询结果:GET /history/{prompt_id} 与 GET /view

json 复制代码
// GET /history/<prompt_id>
{
  "<prompt_id>": {
    "status": {"status_str": "success", "completed": true},
    "outputs": {
      "11": {"images": [{"filename": "myapp/api_00042_.png", "subfolder": "", "type": "output"}]}
    }
  }
}

拿到文件名后取图:

复制代码
GET /view?filename=myapp%2Fapi_00042_.png&subfolder=&type=output

返回图片二进制。type=output 表示正式产物(区别于临时预览)。

2.4 系统与模型信息

端点 用途
GET /system_stats ComfyUI 版本、设备名、显存总量/余量------连接健康检查与设备展示
GET /object_info/{node} 节点定义;其 input.required.<字段>[0] 就是可选值列表。GET /object_info/UnetLoaderGGUF 返回的列表 = ComfyUI 实际可见的全部 GGUF 扩散模型 ,CLIPLoader / VAELoader 同理------模型选择功能的权威数据源
POST /interrupt 中断当前任务。共享模式下慎用:它中断的是整个 ComfyUI 的当前执行,会波及其它应用

2.5 上传参考图:POST /upload/image

multipart/form-data,字段 image(可选 overwrite=true)。返回:

{"name": "xxx.png", "subfolder": "", "type": "input"},随后在工作流中用LoadImage 节点按 name 引用。上传前应用端应自行预处理。

三、应用端架构与关键技术

一个编排型应用端包含以下模块(与语言无关):

复制代码
前端(HTML/JS 单页)
   │  REST + SSE
▼
应用后端
 ├── API 层          # generate / jobs / gallery / system 路由
 ├── 任务队列与调度    # 串行消费、队列上限、取消
 ├── ComfyUI 客户端   # 工作流构建 + HTTP 提交 + WS 监听 + 取图
 ├── 进度推送(SSE)    # 后端把 WS 事件转发给浏览器
 ├── 数据库(SQLite)   # 任务 / 图片元数据 / 图库
 ├── 图像处理         # 上传预处理、产物落盘、缩略图
 └── 辅助功能         # 耗时自校准、内容策略、配额清理

本节逐一介绍这些关键技术。

3.1 工作流构建(核心)

应用端的"业务逻辑"很大程度上就是拼工作流 JSON:

  • 文生图 :EmptySD3LatentImage(定尺寸)→ KSampler → VAEDecode → SaveImage
  • 传统重绘 :LoadImage → VAEEncode(参考图回 latent)→ KSampler(denoise=重绘强度)
  • 多参考图智能编辑 (Qwen-Image 2.1 特有):TextEncodeQwenImage21 同时接收文本与 N 张参考图(视觉塔 + VAE latent 双通道),输出三件套(positive, negative, latent) 直连 KSampler,官方约定 CFG=1、denoise=1, 输出尺寸跟随 image_1。提示词中用 <image1>...<image9> 按序号引用各图。

踩坑记录:该节点的 Autogrow 输入在 API 格式里必须用点号键 images.image_1、images.image_2...;写成嵌套字典 {"images": {...}} 会被静默忽略,参考图完全接不上,编辑退化成纯文生图。

模型切换 = 换工作流里的文件名字段,立即生效,ComfyUI 自动加载新文件并管理旧权重缓存------应用端无需(也不应)自行"加载/卸载模型"。

3.2 异步任务模型:队列 + SSE

生成一张图从几十秒到几分钟,必须异步化:

  1. 前端 POST /jobs 提交,立即返回 {job_id, position},可连续排队多个
  2. 后端调度器串行消费 队列(GPU 任务并行没有意义),每任务执行时:
    构建 payload → 上传参考图 → 提交工作流 → 监听 WS → 进度回写数据库
  3. 前端通过 SSE (GET /jobs/{id}/events)订阅进度------浏览器原生EventSource,自动重连;后端把引擎线程收到的 WS 事件转成 SSE 帧
  4. 同步等待场景(界面上"开始生成"按钮)用 POST /generate 阻塞到完成, 注意设置略小于引擎侧的超时,保证取消逻辑有机会执行

取消语义 :应用端"删除任务"要先判断------共享模式下不能调 /interrupt(会打断其它应用的任务),只能放弃监听、在应用侧标记取消。

3.3 数据库(SQLite)

图片生成类应用有两类基础数据需要保存:任务数据和生成产物数据。建议选择轻量级的SQLite数据库(单文件、零运维、读写对本场景绰绰有余),图片本体存文件系统(按日期分目录),库里只存相对路径。

两张核心表(任务和产物):

  • jobs:任务参数快照(JSON)、状态机(queued → running → succeeded/failed/canceled)、 进度、时间戳。大字段(参考图 base64)外置成 blob 文件,绝不进数据库
  • images:产物元数据------尺寸、步数、种子、提示词、elapsed_ms、收藏/标记。 它同时是耗时自校准的数据源(见 3.5)与图库/参数复现的依据

3.4 图像处理

  • 上传预处理:参考图长边压到 1536、对齐 32 倍数(防 12MP 手机原图撑爆视觉序列与 latent,且超出模型训练范围会明显劣化)
  • 产物落盘 :从 /view 取回二进制 → 按日期归档 → 相对路径入库
  • 前端直出 :/view 的 URL 可直接交给浏览器 <img>(ComfyUI 静态服务),应用端不必中转大文件

3.5 耗时自校准(实践经验)

跨设备预估生成耗时没有意义(RTX 2060 与 RTX 5090 差 40 倍)。可靠做法:从 images 表取最近 30 条 elapsed / (steps × 百万像素) 的中位数拟合本机系数 k 与固定开销,缓存 30 秒;无历史时回退保守参考值并明确提示"未校准"。

实测:预估 128s vs 实际 116s(误差 ~10%),随样本增多更准。

3.6 状态与模型管理

  • 状态徽章 = GET /system_stats 可达性(不要用引擎内部标志,引擎惰性加载要到首次生成才置位,会永远显示"启动中")
  • 模型列表 = GET /object_info/{UnetLoaderGGUF,CLIPLoader,VAELoader} 的可选值并集;选择后写回应用配置并即时生效

四、两种技术栈的实现对照

接口既然只有 HTTP + WebSocket,应用端语言就是自由选择。以下对照说明关键模块在 Python 与 Node.js + TypeScript 中的等价实现。

4.1 总体对照表

模块 Python Node.js + TypeScript
Web 框架 FastAPI + uvicorn Fastify / NestJS / Express + tsx
HTTP 客户端 urllib / httpx 内置 fetch(Node 18+)
WebSocket 客户端 websocket-client ws
SSE 推送 FastAPI StreamingResponse reply.raw / sse 库
数据库 sqlite3(标准库) better-sqlite3(同步 API,质量很高)
图像处理 Pillow sharp
配置 pydantic-settings + .env zod + dotenv(类型安全更彻底)
校验 pydantic 模型 zod schema,可与前端共享类型

4.2 ComfyUI 客户端(Node.js + TypeScript 示例)

typescript 复制代码
// comfy.ts ------ 提交工作流 + 监听进度,与 Python 版等价
import WebSocket from "ws";

const COMFY = "http://127.0.0.1:8188";
const clientId = `myapp-${crypto.randomUUID().slice(0, 8)}`;

export interface Workflow { [nodeId: string]: NodeDef }

export async function submit(workflow: Workflow): Promise<string> {
  const res = await fetch(`${COMFY}/prompt`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ client_id: clientId, prompt: workflow }),
  });
  const data = await res.json();
  if (Object.keys(data.node_errors ?? {}).length)
    throw new Error(`工作流错误: ${JSON.stringify(data.node_errors)}`);
  return data.prompt_id as string;
}

export function listen(promptId: string, steps: number,
                       onProgress: (pct: number, msg: string) => void,
                       onDone: (ok: boolean, err?: string) => void) {
  const ws = new WebSocket(`ws://127.0.0.1:8188/ws?clientId=${clientId}`);
  ws.on("message", (raw) => {
    const msg = JSON.parse(raw.toString());
    switch (msg.type) {
      case "progress":
        onProgress(5 + 90 * msg.data.value / msg.data.max, "采样中");
        break;
      case "execution_error":
        onDone(false, msg.data.exception_message); ws.close(); break;
      case "execution_success":
        onDone(true); ws.close(); break;
    }
  });
}

export async function fetchImage(filename: string): Promise<Buffer> {
  const q = new URLSearchParams({ filename, subfolder: "", type: "output" });
  const res = await fetch(`${COMFY}/view?${q}`);
  return Buffer.from(await res.arrayBuffer());
}

export async function ping(): Promise<boolean> {
  try { await fetch(`${COMFY}/system_stats`, { signal: AbortSignal.timeout(3000) }); return true; }
  catch { return false; }
}

对比 Python 版:

python 复制代码
# 同样三步:requests/urllib 提交 → websocket-client 监听 → /view 取图
prompt_id = _post_prompt(workflow)                 # POST /prompt
history = _listen_progress(prompt_id, steps, cb)   # ws + /history 兜底
image = _fetch_output(history)                     # /view

两者结构一一对应,复杂度相当。

4.3 工作流构建:TS 的类型优势

工作流是"字段名拼错就静默失败"的 JSON(尤其 images.image_1 这类点号键),TS 可以定义类型获得编译期防护:

typescript 复制代码
type NodeRef = [string, number];  // ["9", 0]

interface QwenEditWorkflow {
  "1": { class_type: "UnetLoaderGGUF"; inputs: { unet_name: string } };
  "2": { class_type: "CLIPLoader"; inputs: { clip_name: string; type: "qwen_image"; device: string } };
  // ...
  "12": {
    class_type: "TextEncodeQwenImage21";
    inputs: {
      clip: NodeRef; prompt: string; negative_prompt: string; vae: NodeRef;
      resolution: number;
      // 点号键:Autogrow 输入的 API 格式,拼错会静默忽略参考图!
      [key: `images.image_${number}`]: NodeRef;
    };
  };
}

Python 侧靠 pydantic 校验请求、靠人工约定工作流结构;TS 侧类型可以直接覆盖到工作流内部,前端参数表单与后端工作流共享同一套类型定义。

4.4 SSE 转发

typescript 复制代码
// Fastify:把引擎事件转发给浏览器
app.get("/jobs/:id/events", async (req, reply) => {
  reply.raw.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
  });
  const send = (event: string, data: unknown) =>
    reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
  bus.on(String(req.params.id), send);      // 应用内事件总线
  req.raw.on("close", () => bus.off(String(req.params.id), send));
});

Node 的事件循环与"单线程串行队列"模型天然契合,这部分甚至比Python 的 asyncio + run_coroutine_threadsafe 桥接更简洁。

4.5 取舍建议

场景 建议
团队前端已是 TS,应用以编排/交互为主 Node.js + TS:前后端共享类型,栈统一
需要写 ComfyUI 自定义节点、做 CV 预处理、复用社区脚本 Python:与 ComfyUI 生态同语言
需要"绕过 ComfyUI 直连 diffusers"的备用通道 只有 Python 可行(torch/diffusers 生态)------但这与本架构方向相悖,不建议保留该路径
高并发 API 服务 两者皆可;瓶颈在 GPU 不在应用端

推理下沉到独立 ComfyUI 服务之后,应用端是"薄编排层",Python 与 Node.js + TypeScript 都能完整实现且没有明显劣势;选型依据应是团队技能与生态偏好,而非技术约束。

五、应用示例

Image Studio就是采用这种架构开发一款简单的图片生成和处理程序,下面是这个项目的github地址:

Github: zhenl/image-studio

后续会对这个项目做详细说明。