ComfyUI API 批量出图:提交成功却拿不到图的五个坑

这套接口能用,配脚本一个下午就能把批量出图接进自己的流程,前提是先认下它五个不报错的失败。下文所有数字来自 ComfyUI 0.43.0 在本机 Windows 桌面版的实测,显卡是 RTX 2080 Ti 22GB,服务监听 8000 端口,命令都能直接复制。

先分清四个端点的分工

ComfyUI 没有 REST 那种资源路径,只有四个固定位置。记错分工是后面所有问题的起点。

端点 方法 作用 返回什么
/prompt POST 把一份 API 格式的工作流塞进队列 prompt_id 与队列序号 number
/history/{prompt_id} GET 查这次任务跑完的结果 没跑完时是空对象
/view GET 按文件名取图片字节 PNG 原始字节
/queue GET 看正在跑和排队的任务 两个数组,元素里带完整工作流

另有两个辅助端点偶尔用得上。/object_info 返回全部节点定义,实测 1476 个节点类型、2.88 MB、耗时 0.49 秒,别把它放进循环。/free 用来卸载模型释放显存,它返回 200 但响应体是空的,用 json.loads 去解析会直接抛异常,需要 try 掉。

如果你真正想要的是让 AI 助手自己去调这套接口,官方另有一条 MCP 通道,走的是完全不同的调用方式,可以对照 ComfyUI 正式支持 MCP 那篇看差别。

第一步,导出 API 格式,别用保存

前端界面右上角那个保存,导出的是画布结构,顶层有 nodes 和 links 两个数组,每个节点带 pos 和 widgets_values。这份文件给浏览器加载是对的,给接口用是错的。我把它原样 POST 上去,拿到 HTTP 500,响应体只有一行 500 Internal Server Error 加一句 Server got itself in trouble,既没有 node_errors,也没提示哪个字段不对。

正确做法是在菜单里选 Export (API)。新版界面叫 Export,旧版叫 Save (API Format),导出的文件顶层是节点 id 到 class_type 的映射。判断标准很简单,打开文件看第一层键,是数字字符串就是 API 格式,看到 nodes 这个键就是画布格式,回去重新导一次。

最小可运行脚本

下面这段把提交、等结果、下载三步串起来,改模型文件名就能跑。节点 id 是字符串,连线写成 [上游节点 id, 输出槽位号] 这样的二元组,这是 API 格式和画布格式最容易写错的地方。

import json, time, uuid, urllib.request, urllib.parse

BASE = 'http://127.0.0.1:8000'

def post(path, obj):
    req = urllib.request.Request(BASE + path,
        data=json.dumps(obj).encode(),
        headers={'Content-Type': 'application/json'})
    with urllib.request.urlopen(req, timeout=60) as r:
        return json.loads(r.read())

def get(path, timeout=30):
    with urllib.request.urlopen(BASE + path, timeout=timeout) as r:
        return r.read()

GRAPH = {
  "1": {"class_type": "UNETLoader", "inputs": {
        "unet_name": "flux3_image_klein_int8_convrot.safetensors",
        "weight_dtype": "default"}},
  "2": {"class_type": "CLIPLoader", "inputs": {
        "clip_name": "cc4d5e9614a92cd534f08583204f87632c0072dfd8e89457eaf511207feb19fe.safetensors",
        "type": "flux3"}},
  "3": {"class_type": "CLIPTextEncode", "inputs": {
        "clip": ["2", 0], "text": "a red cube on a white table"}},
  "4": {"class_type": "CLIPTextEncode", "inputs": {
        "clip": ["2", 0], "text": ""}},
  "5": {"class_type": "EmptyLatentImage", "inputs": {
        "width": 512, "height": 512, "batch_size": 1}},
  "6": {"class_type": "KSampler", "inputs": {
        "model": ["1", 0], "positive": ["3", 0], "negative": ["4", 0],
        "latent_image": ["5", 0], "seed": 7, "steps": 20, "cfg": 1.0,
        "sampler_name": "euler", "scheduler": "simple", "denoise": 1.0}},
  "7": {"class_type": "VAELoader", "inputs": {"vae_name": "flux2-vae.safetensors"}},
  "8": {"class_type": "VAEDecode", "inputs": {"samples": ["6", 0], "vae": ["7", 0]}},
  "9": {"class_type": "SaveImage", "inputs": {
        "images": ["8", 0], "filename_prefix": "batch"}},
}

r = post('/prompt', {'prompt': GRAPH, 'client_id': str(uuid.uuid4())})
pid = r['prompt_id']
print('queued', pid, 'number', r['number'])

while True:
    hist = json.loads(get('/history/' + pid))
    if pid in hist:
        break
    time.sleep(2)

item = hist[pid]['outputs']['9']['images'][0]
q = '?filename=%s&subfolder=%s&type=%s' % (
    urllib.parse.quote(item['filename']),
    urllib.parse.quote(item.get('subfolder', '')),
    item.get('type', 'output'))
open('out.png', 'wb').write(get('/view' + q))
print('saved')

client_id 可以不传。实测不带它提交同样返回 200 并正常受理,这个字段只影响 WebSocket 推送归到哪个连接。脚本用轮询取结果的话,省掉它没有影响。

提交成功之后,history 为什么是空的

这是最高频的一处误判。POST 返回 200 并且给了 prompt_id,很多人紧接着 GET /history/{prompt_id} 想取图,拿到的是空花括号。我记录了回读三次的响应体长度,t+0.0 秒、t+0.6 秒、t+1.3 秒三次数值都是 2,一个空对象的字节数。

原因在于 history 只登记已经结束的任务,任务没结束前这个 prompt_id 不在表里。所以循环条件要写成 pid in hist,光判断返回非空并不够,因为别的任务跑完也会让表变满。也别用固定 sleep 卡某一秒,出图耗时随分辨率、步数和是否冷启动浮动,本机 512 见方的两连拍,冷启动那次端到端 48.2 秒,模型常驻后 1.6 秒,差三十倍。

要区分在跑还是排队,看 /queue。排队的在 queue_pending,正在跑的在 queue_running,数组首元素是队列序号,第二个就是 prompt_id。我连提两个任务后抓的快照里,queue_running 的序号是 420,刚提交的 421 还在 pending 等,那 48 秒大部分花在排队上。

取图,filename 不等于 prompt_id

很自然会想用 prompt_id 拼一个 xxx.png 去 /view 取。实测这条路给 404,响应体 0 字节。真实文件名在 history 的 outputs 里,按节点 id 分桶,每个输出节点下面有 images 数组,每项含 filename、subfolder、type 三个字段。

我这一轮拿到的实际值是 filename 为 apiprobe_00001_.png,subfolder 是空串,type 是 output。三个字段要原样回传,subfolder 即使为空也得显式带上空值,少一个参数就是 404。

缓存,重提同一份 prompt 拿到的是旧图

这条最容易在批量脚本里埋雷。ComfyUI 按节点粒度做结果缓存,重提一份完全相同的 prompt 它不会重新生成,而是把上次的结果原样还回来,文件名也不变。我用同一个 seed 连提四次,记录如下。

测试 改动 耗时 execution_cached 的节点 产出文件名
R1 seed 改 999001 1.63 秒 1 2 3 4 5 7 apiprobe_00003_.png
R2 与 R1 完全一致 0.82 秒 1 2 3 4 5 6 7 8 9 apiprobe_00003_.png
R3 steps 从 2 改 3 1.63 秒 1 2 3 4 5 7 apiprobe_00004_.png
R4 分辨率改 640 1.63 秒 1 2 3 4 7 apiprobe_00005_.png

这张表能读出三条规则。第一,判据落在 execution_cached 的 nodes 数组里,只要里面出现输出节点 9 的编号,这次就没产生任何新文件,R2 的文件名和 R1 逐字节相同。第二,只改采样器参数时模型加载、文本编码、latent 全在复用,节点 6 才开始重算,省下的时间主要在读盘上,推理量一点没少。第三,改分辨率会让 EmptyLatentImage 掉出缓存列表,这是分辨率与缓存关系的直接证据。

落到脚本里要改两处。每次提交前给 seed 加一个随机偏移,或者把批次序号拼进 filename_prefix。判断这次到底有没有真出图,看文件名有没有推进比看耗时可靠。不同模型的单张耗时差异很大,具体数值可以对着 Qwen-Image 与 FLUX 显存对比 那组实测做预算。

文件到底落在哪个目录

SaveImage 的 filename_prefix 支持斜杠,它会被拆成子目录和文件名两段,但落盘后回读到的分隔符是反斜杠。我把前缀写成 fluxbbs/test/img,history 里返回的 filename 是 img_00001_.png,subfolder 是 fluxbbs 加反斜杠加 test。拿这个值去 /view 必须原样带上带反斜杠的 subfolder,手打成斜杠或者留空都返回 404。

还有一个常见误会,百分号包裹的日期模板在这个节点里不生效。我实测前缀写成 fluxbbs_dated_ 加 %date:yyyyMMdd% 加下划线,返回的文件名就是这段字符原样加 00001 加下划线,日期没有被替换。要按日期分目录,得在脚本里自己算好字符串再提交。

输出节点类型决定文件能活多久。SaveImage 的结果落在 output 目录,type 是 output。PreviewImage 的结果在临时目录,文件名形如 ComfyUI_temp_mpnuo_00001_.png,type 是 temp。两者都能被 /view 取到,但 type 传错就取不到,用 output 去取 temp 的文件同样是 404。批量任务一律用 SaveImage。

五个静默失败与真实报错

下面这些是我逐个构造出来再原样记录的,可以直接拿去对照日志。

触发方式 HTTP 错误类型 关键字段怎么读
传画布格式 JSON 500 无 只有一句 Server got itself in trouble,定位不到任何字段
连线指向不存在的节点 400 prompt_outputs_failed_validation node_errors 的键是下游节点 id,details 才是断掉的上游 id,exception_type 为 KeyError
模型文件名写错 400 value_not_in_list details 里直接列出全部可选值,等于一份模型清单
漏掉必填输入 400 required_input_missing details 就是缺失的字段名,dependent_outputs 给出受影响的输出节点
工作流里没有输出节点 400 prompt_no_outputs 提示 Prompt has no outputs,提交空对象也是这个错

第二行那个坑值得单独说。我把 KSampler 的 latent_image 指向一个不存在的节点 99,返回的 node_errors 键是 6,看着指向 KSampler 没错,但里面的 input_name 写的是 samples,exception_type 是 KeyError。samples 是下游 VAEDecode 的输入名,跟 KSampler 的 latent_image 对不上。真正给线索的只有 details 那个字符串 99,看到这个报错先去 details 里找 id,顺着 input_name 改参数只会白改。

第三行的细节最实用。我把 unet_name 写成 not_exist.safetensors,报错里把十个可用值全列了出来,从 ernie-image 系列到 z_image 系列一览无余,其中有 qwen_image_2.1 与 flux3_image_klein 两个量化版,参数规模见 Qwen-Image-2.1 开源 那篇。下次不确定文件夹里有什么,与其去磁盘翻,不如故意写个错名字看报错,省一次目录遍历。模型文件本身是不是损坏,判定方法写在 safetensors 损坏与下拉框空白怎么修 里。

踩坑,三个不报错但结果错的地方

第一坑,批量脚本出重复图。缓存会让完全相同的 prompt 秒回旧文件,脚本只检查有没有拿到 200 和 prompt_id,就会把同一张图存十遍。判断方法是读 history 里 status 下 messages 中 execution_cached 那一项的 nodes 数组,含输出节点编号就是没生成。修复是在循环里递增 seed,并校验每次产出的 filename 较上次有没有变化。

第二坑,subfolder 的反斜杠在跨平台时被吃掉。Windows 落盘返回带反斜杠的目录名,JSON 里这个反斜杠已经转义过,用 urllib.parse.quote 编码再拼进查询串才取得到。手动拼字符串会在部分 HTTP 客户端上把反斜杠当路径分隔符吞掉,表现是 404 而文件明明在磁盘上。判断方法是打印请求实际发出的 URL,逐字符对比 history 返回的 subfolder 原文。

第三坑,把 /object_info 放进循环。这个端点 2.88 MB、1476 个节点定义,单次 0.49 秒看着不慢,批量循环一百次就是白等五十秒加接近三百兆流量。节点定义拉到本地缓存一次即可,真需要校验某个输入时,让服务端用 value_not_in_list 报错回你更省事。

FAQ

接进脚本要装依赖吗。不需要。整套接口就是普通 HTTP,Python 用标准库 urllib 就能跑完提交、轮询、下载三步,上面那段脚本没有 import 任何第三方包。Node 端用 fetch 同理,只有走 WebSocket 实时看进度时才要额外处理 Upgrade 头。

任务卡住不动怎么排查。先 GET /queue 看它躺在 queue_running 还是 queue_pending。在 pending 里说明前面还有任务没结束,把并发压到 1 到 2。在 running 里但十几分钟没动静,多半是模型还在读盘,冷启动加载十几 GB 权重花掉几十秒很常见,观察 /system_stats 里 vram_free 有没有在下降,就能判断它是不是在真干活。

能不能直接用 WebSocket 取进度。可以,但要处理协商。/ws 必须带 clientId 参数并升级成 WebSocket,用普通 GET 去打会收到 400 和一句 No WebSocket UPGRADE hdr。对批量脚本来说轮询 /history 够用,两秒一次的开销可以忽略,还省掉断线重连的代码。