无头 ComfyUI:把它当作服务运行,随处可用
- 发布日期
- 2026年7月11日
- 更新日期
- 2026年7月11日
- 作者
- Jacob Lloyd —— 项目完成后,在 AI 协助下撰写
- 阅读时长
- 约 11 分钟阅读
简单来说: 我那台用来生成图片的电脑一直在后台运行 ComfyUI,没有显示器,也没有人登录桌面。我可以用一行命令启动它、查看它的状态,或者向它要一张图片,还能通过一条私密加密连接,从笔记本或手机上打开它的完整界面。我写的一个小插件,能确保我从任意设备保存的每一个工作流都落到那一台电脑上,不会散得到处都是。
在上一篇文章里,我在一台 AMD 迷你主机上让 ComfyUI + Z-Image Turbo 用大约 27 秒生成了一张 1024px 的图片。这是它的续篇:把那套安装变成一台常驻运行的设备——不需要桌面会话,不需要守着终端——家里任何设备都能用,而它生成的一切,以及保存的每一个工作流,都留在那一台主机上。
tl;dr
最终会得到什么
日常使用中,"用这台图像生成主机"无非是下面三种情形之一,没有一种需要坐在它面前:
comfyctl status # server health + systemd state + newest output file
comfyctl generate "a foggy harbor at dawn, cinematic"
comfyctl logs 100
……或者是另一台机器上的脚本向 HTTP API 提交一个提示词,又或者是我在家里另一头的笔记本上,浏览器标签页里打开完整的节点图 UI——走 HTTPS,通过 VPN,保存内容落在服务器的磁盘上。
为什么要无头运行
网页 UI 是用来设计工作流的。但要把同一个工作流运行到第 200 次,它就是个糟糕的选择。一旦工作流调好了,你真正想要的其实是一台设备:一台始终开着、随时待命、对任何请求都有求必应的主机——不管请求来自一条 shell 命令、一个 cron 任务,还是凌晨 3 点需要新头像的聊天机器人。
这里说的"无头"不是"永远没有界面"的意思。服务器本身没有桌面会话,也没有自己的浏览器;但 UI 依然存在,通过 HTTP 提供给任何想要它的设备。干活的是服务器,其他一切都只是一块屏幕。
systemd 用户服务
这套服务模式和搭建那篇文章一样,所以这里就长话短说。一个位于 ~/.config/systemd/user/comfyui.service 的 systemd用户单元:
[Unit]
Description=ComfyUI server
After=network-online.target
[Service]
ExecStart=%h/comfy/start-comfyui.sh
WorkingDirectory=%h/comfy/ComfyUI
EnvironmentFile=-%h/comfy/comfy.env
Restart=on-failure
RestartSec=5
TimeoutStartSec=120
[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user enable --now comfyui.service
loginctl enable-linger $USER # 让用户服务在无人登录时也能保持运行
最后这一行是无头场景下人们最容易漏掉的一点:如果不启用 lingering,用户单元会在你最后一个会话结束时停止——而在一台无头主机上,这意味着"立刻"。EnvironmentFile 把端口和启动参数集中放在一个可编辑的地方(comfy.env),这样其他工具就能读同一个值,而不用把 8188 写死。
comfyctl 风格的控制脚本
直接用 systemctl 也能用,但套一层薄薄的封装,能让这台主机感觉像个"产品"。我的是一个约 100 行的 bash 脚本,叫 comfyctl:
comfyctl start # systemctl start + 轮询 /system_stats 直到健康
comfyctl stop
comfyctl restart
comfyctl status # 健康状态 + 单元状态 + 最新的输出 PNG
comfyctl generate "<prompt>" [--seed N --steps 8 --width 1024 --out PATH]
comfyctl logs [N] # journalctl --user -u comfyui.service -n N
让它不只是"短",而是"用起来舒服"的几个细节:
- "健康"指的是 API 有响应,而不是进程存在。
curl -sf http://127.0.0.1:$PORT/system_stats在一个 60 秒预算内循环检测。systemd 说"active"只代表 Python 启动了;一个模型服务器要等到能响应 HTTP 请求才算真正"起来了"。 - 它从服务本身用的同一个环境文件里读取端口,所以在一个地方改端口不会让工具跟丢。
generate会自动启动服务器。 如果健康检查失败,它会启动单元、等待变健康,然后再提交请求。调用者完全不需要知道这台主机之前是否处于唤醒状态。status会打印output/里最新的文件——不用打开任何东西,就能回答"我上一次生成到底成没成功"。
用脚本驱动 HTTP API
UI 做的所有事情都走同一套 HTTP API,核心流程只涉及三个端点。工作流本身就是 JSON(在 UI 里通过 Save (API format) 导出);脚本加载它,换上提示词和一个新的种子,然后:
import json, time, urllib.request
SERVER = "http://127.0.0.1:8188"
graph = json.load(open("workflow_api.json"))
graph["6"]["inputs"]["text"] = "a foggy harbor at dawn, cinematic" # 你的提示词节点 id
# 1. 提交到队列
req = urllib.request.Request(f"{SERVER}/prompt",
data=json.dumps({"prompt": graph}).encode(),
headers={"Content-Type": "application/json"})
pid = json.load(urllib.request.urlopen(req))["prompt_id"]
# 2. 轮询历史记录直到完成
while True:
hist = json.load(urllib.request.urlopen(f"{SERVER}/history/{pid}"))
if pid in hist: break
time.sleep(1)
# 3. 获取图片
img = hist[pid]["outputs"]["9"]["images"][0] # 你的 SaveImage 节点 id
url = f"{SERVER}/view?filename={img['filename']}&subfolder={img['subfolder']}&type={img['type']}"
open("result.png", "wb").write(urllib.request.urlopen(url).read())
只用标准库——不需要 ComfyUI 客户端包,也不需要 API key。这就是 comfyctl generate 背后的原理,也是让这台主机变成基础设施的关键一环:任何能发起 HTTP 请求的东西,现在都能生成图片。
Workbench 插件:你的工作流库就住在这台主机上
这是远程使用第一周就冒出来的问题。ComfyUI 自带的导出功能,是通过浏览器的下载机制来保存工作流 JSON 的——存到你正在用来浏览的那台设备上。从三台设备用这个 UI,你的工作流库就会散落在三个不同的下载文件夹里,而这三个文件夹没有一个是真正运行这些工作流的那台机器。
所以我做了一个小小的自定义节点("Workbench"),把重力的方向掰正过来。它是一个普通的 custom_nodes 插件:Python 那一侧在 ComfyUI 自己的 web 服务器上注册几条路由,JS 那一侧加一个侧栏标签页。它干两件事:
1. 工作文件夹的保存/打开
侧栏的保存按钮会把当前的图表 POST 给插件,插件把它写进 ComfyUI 标准的 user/default/workflows 文件夹,就在服务器上——文件名会被清理,限定在那个文件夹内,原子写入(临时文件加重命名),并且每次覆盖时都会滚动保留一份 .bak.json。"打开"则列出同一个文件夹,按最新排序。
关键点在于:UI 运行在远程浏览器里,但保存动作落在服务器的本地磁盘上。从笔记本保存,从手机打开,从脚本运行——库只有一个,文件夹只有一个,都在那台装着 GPU 的机器上。(在我的主机上,这个文件夹被软链接到一个普通的 ~/comfy/workflows 目录,所以备份起来也毫不费力。)因为前端只会对加载它的那台服务器发起相对于源地址的调用,所以不管是在 localhost 上还是经过代理,它的行为都完全一致——不需要针对每台设备单独配置。
2. 缺失模型扫描 + 白名单下载
远程使用的另一个痛点:你打开一个工作流,它要的模型文件这台主机上没有。把 12GB 下载到手机上再重新上传,显然是不对的做法。这个插件会把加载的工作流和每一个已注册的模型文件夹做对比,列出缺了什么,然后在服务器端直接下载到正确的 models/<文件夹>——流式写入一个 .part 文件,支持通过 HTTP Range 断点续传,可选 sha256 校验,进度通过 websocket 推送到 UI。
"浏览器能让服务器把任意 URL 下载到磁盘上"这句话本身听起来就挺吓人的,所以它被严格圈了起来:
- 只允许 HTTPS,而且主机名必须匹配
config.json里的白名单(比如huggingface.co、civitai.com,或者*.hf.co这样的通配符)——而且每一跳重定向都会针对同一份名单重新校验,因为"重定向到任意地址"正是白名单绕过的经典手法。 - 文件名会被清理成一个干净的基础名,并且必须以白名单里的扩展名结尾(比如
.safetensors);目标必须是一个已注册的模型文件夹——不允许路径穿越。 - 已存在的文件绝不会被覆盖,单次下载有大小上限(默认 30GB),而且下载是逐个排队进行的。
安全的远程访问:网状 VPN + HTTPS
这台主机上的 ComfyUI 只监听 127.0.0.1——这是写死的,不是个开关选项。它没有登录页面,所以绝对不能暴露到公网上,老实说,直接开放给原始局域网也不建议。干净的解法是像 Tailscale 这样的网状 VPN:每台设备都拿到一个私密加密的地址,你账号之外的任何人都连不进来,而且它的 serve 功能能充当本地 HTTPS 反向代理:
tailscale serve --bg --https=8443 http://127.0.0.1:8188
这样一来,https://<你的机器名>.<你的 tailnet>.ts.net:8443 就会带着一张真实(自动签发)的 TLS 证书,把完整的 UI 提供给你的设备,且仅限于你的设备。ComfyUI 本身依然只听得到 localhost 的声音——代理是唯一的入口。
这里的 HTTPS 不是可有可无的礼貌之举。浏览器对来自非 localhost 源的纯 HTTP 限制得越来越多(剪贴板、部分 worker/websocket 行为),而你传输的可是自己的提示词和图片。网状 VPN 让你不用拥有域名、也不用开放端口就能拿到一张真实证书。一个实用的提醒:连接时要用机器的 DNS 名称,而不是它裸的 VPN IP——TLS 证书是签给那个名字的,用 IP 会导致证书校验失败。
踩过的坑
- 只在远程才出现的 403,是来源校验,不是身份验证。 ComfyUI 的服务器中间件会在会改变状态的请求上校验
Origin/Host之类的头;有些代理配置转发的请求头和它预期的对不上(我在接另一个工具时正好踩到过这个——一个基于Sec-Fetch-Site的 403)。要么修好代理转发的请求头,要么把 ComfyUI 的 CORS 设置(--enable-cors-header)限定到你信任的那一个来源——绝不要用*。 - 用 VPN 主机名,不要用 IP。 网状 VPN 的 HTTPS 是按名字签发证书的;直接访问裸地址会得到看起来像服务器坏掉了的 TLS 错误。
- 自定义节点的前端必须使用相对于来源的地址。 插件 JS 里任何写死的
http://127.0.0.1:8188/...,在主机本地能用,但经过代理就会悄无声息地坏掉。用 ComfyUI 的api.fetchApi()和相对路径。 - 经过代理的 websocket 可能会断线。 进度事件是靠 websocket 传的;在代理后面,它偶尔会悄悄断掉。Workbench 之所以还留了轮询作为兜底,正是为了这个——设计时就该考虑到这一点。
- 没有
loginctl enable-linger,你的"服务"就会在登出时死掉。 - "单元处于 active"≠"服务器已经启动"。
- 把下载白名单管严一点。
- 绝对不要把这玩意端口转发到公网上。 没有身份验证 + 可执行任意工作流 +(配上下载器的话)还能写磁盘。要么走网状 VPN,要么就别开放。
如果说第一篇文章是以一台能生成图片的电脑收尾,这一篇则收在一个更好的地方:一台安安静静待在架子上的主机,我拥有的每一台设备和每一个脚本都能用它,而且它做出来的一切都统一保存在一个地方。