这套接口能用,配脚本一个下午就能把批量出图接进自己的流程,前提是先认下它五个不报错的失败。下文所有数字来自 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 够用,两秒一次的开销可以忽略,还省掉断线重连的代码。
延伸阅读
- DeepSeek Harness 接本地 Ollama:8192 上下文必崩的三个坑2 天前
- GPT-OSS开源权重模型发布:120B/20B双版本Apache 2.0可商用,128K上下文与本地部署全解析2 月前
- 华为云 openPangu-2.0 停售下线:11 月 30 日停用与迁移替代路径5 天前
- vLLM d3-ultra:26.09B 答完 255 题,SR-Text 87.2 分2 周前
继续读
同一批更新里这几篇值得一看:
- DeepSeek Harness 密钥放哪:四层解析与五个报错2026-11-08
- Qwen-Image 与 FLUX 显存对比:22GB 卡跑 1024 实测2026-11-07
- ComfyUI 装 FLUX 模型:safetensors 损坏与下拉框空白怎么修2026-11-07
