ComfyUI 平台化:能力契约、节点白名单和积分预扣怎么串起来
这个项目是我和朋友一起做的内容生产 SaaS 后端,底层接 ComfyUI,但目标不是再套一层节点图 UI。
ComfyUI 对工程师很好用:节点怎么连、模型怎么换、输出怎么取,都能摊开调。但换到多租户内容团队里,问题会变得很别扭。创作者不应该理解一整张 workflow JSON,管理员也不能把 /prompt 原样开放出去,然后祈祷每张图都安全、稳定、可计费。
我负责的部分,是把 ComfyUI 收到一个后端治理层里:工作流先封成"能力",能力再进入任务和生产流。项目刚开发完后端核心链路,还没有进入生产环境,所以这篇只写工程实现和边界,不写任何生产效果。
我一开始想错的地方
最顺手的方案,其实是薄皮 UI:前端保存 ComfyUI workflow JSON,后端拿到后直接调 /prompt。开发快,演示也容易。
但这个方案很快露出问题:它没有能力边界。一个 workflow 可以悄悄引入新节点,输出类型可能变,资源消耗也不好估。更麻烦的是,多租户平台里还有组织、角色、订阅计划、积分余额和预算限制。ComfyUI 的接口只知道"提交一张图",不知道"谁可以跑、花多少钱、产物该归到哪里"。
所以我把入口从 /prompt 往上抬了一层。用户提交的不是节点图,而是某个已经被管理员封装过的能力。
这张图里的关键点不是组件数量,而是 ComfyUI 被压到了 runtime 层:业务规则、权限、计费和对象存储都不让它直接承担。
能力契约:把自由节点图收窄成平台能力
能力层以 WorkflowTemplate 为核心。一个能力至少要说清几件事:用户可以填什么参数、会产出什么类型、资源画像是什么、依赖哪些节点类型。
下面是脱敏简化后的 schema,不对应真实字段名,只保留机制:
json
{
"parameter_schema": {
"parameters": [
{
"name": "prompt",
"type": "string",
"required": true,
"inject_to": "workflow.node.text"
},
{
"name": "reference_image",
"type": "image",
"required": false,
"inject_to": "workflow.node.image"
}
]
},
"output_schema": {
"outputs": [
{
"name": "result",
"type": "images"
}
]
},
"resource_profile": {
"recommended_concurrency": "positive_integer",
"timeout_seconds": "positive_integer"
},
"node_dependencies": [
"allowed_class_type_a",
"allowed_class_type_b"
]
}
这段 schema 解决的是"平台能不能理解这张图"的问题:参数必须是数组,输出不能为空,资源配置要是正整数,工作流里的 class_type 必须都出现在 node_dependencies 里。
参数注入支持 string / integer / number / boolean / image / object / array / json 8 种类型。这里我没有把所有业务都塞进 ComfyUI 图里,原因很简单:图越自由,平台越难知道自己在执行什么。
两层节点白名单:能力能用,不代表角色能跑
节点白名单我做了两层。
能力级白名单回答的是:这个能力声明自己依赖哪些节点。它防止 workflow JSON 在配置后混入未声明节点。
角色级白名单回答的是:当前角色是否允许使用这些节点。比如管理员可以维护一类高成本能力,但普通成员不一定能跑。
这个设计会增加维护成本。新增节点时要更新能力依赖,也要检查角色权限。可如果省掉这层,平台就很难解释"为什么这个用户能跑一个成本更高、风险更大的图"。在多租户后端里,可解释性比省几行配置更重要。
任务链路:先扣积分,再提交 ComfyUI
任务创建时,我采用先扣后跑。后扣费对用户感觉更自然,但平台会承担算力已经消耗、余额却不足的风险。这个项目更偏后端治理层,所以我选择在提交 ComfyUI 前把约束先检查完。
核心流程大概是这样:
python
def create_task(user, capability, params):
assert_user_can_access(capability)
assert_nodes_allowed_by_capability(capability.workflow)
assert_nodes_allowed_by_role(user.role, capability.node_dependencies)
assert_active_tasks_not_exceed(["created", "queued", "running"])
cost = max(1, round(
capability.default_cost_credits * user.plan.credits_multiplier
))
ledger.reserve_once(user.org_id, cost)
try:
workflow = inject_params(capability.workflow, params)
prompt_id = comfy_client.submit_prompt(workflow, timeout=30.0)
except ComfyClientError:
ledger.refund_once(user.org_id, cost)
raise
return Task(prompt_id=prompt_id, cost_credits=cost)
这段伪代码只保留任务创建主干:权限、节点白名单、活跃任务、积分预扣和提交失败退款;真实实现还要处理状态刷新和幂等。
余额没有直接存在用户表上,而是从积分事件账本里 sum(delta_credits) 算出来。发放、预扣、退款都走事件,这样后面追账会清楚很多。ComfyClient 也刻意做薄,只保留提交 prompt、查 history、抽取 images / gifs / videos 产物引用这些最小接口,默认 HTTP 超时是 30.0s。
我挺喜欢这个边界。ComfyUI 只负责生成,平台负责判断这次生成是否应该发生。
生产流 DAG:不要把业务流程塞回节点图
内容生产不是一次生成就结束。会有生成、文本变换、人工审核、再进入下一步的情况。
我没有把这些动作继续塞进 ComfyUI workflow,而是单独做了 Production Flow,graph schema 版本是 2。节点类型可以是 capability、transform、manual_review 等。节点之间通过 input_mapping 传数据,来源包括 static、run_input、preference 和 node_output。
这里有一个容易忽略的校验:node_output 只能引用上游节点。运行时用 Kahn 拓扑排序推进就绪节点。如果允许引用下游节点,流程图看起来能存,跑起来就会变成很难查的循环依赖。
我把 transform 做得很克制,只支持有限文本格式化;manual_review 也只是流程卡点,不伪装成完整工作流引擎。这个阶段更重要的是把生成能力、参数传递、权限和计费跑顺。
对象存储迁移:history 里的路径不能直接信
踩得最实的一处坑,是 ComfyUI history 返回的 filename 和 subfolder。如果平台直接拼路径去读文件,再上传 MinIO,就会留下路径逃逸风险。
测试里覆盖了几类脱敏输入:{"filename": "../secret.png"}、{"filename": "/tmp/secret.png"}、{"filename": "secret.png", "subfolder": "../"}。这些不需要真实生产事故也能判断出风险:多租户平台不能让 runtime 返回值决定后端读哪个文件。
python
def resolve_output_path(output_dir, filename, subfolder=""):
if Path(filename).is_absolute() or Path(subfolder).is_absolute():
raise OutputPathForbidden()
base = Path(output_dir).resolve()
target = (base / subfolder / filename).resolve()
if base != target and base not in target.parents:
raise OutputPathForbidden()
return target
这段伪代码解决的是对象迁移前的路径边界:先拒绝绝对路径,再确认 resolve() 后的真实路径仍在 ComfyUI 输出目录内,失败时在调用 MinIO 前就拦掉。
失败样例表
| 失败类型 | 脱敏简化样例 | 为什么错 | 处理策略 |
|---|---|---|---|
| history 文件名路径逃逸 | filename: "../secret.png" |
可能读到输出目录之外的文件 | _resolve_output_path 在上传对象存储前拒绝 |
| history 子目录路径逃逸 | subfolder: "../" |
子目录参与拼接后仍可能越界 | resolve() 后校验真实路径必须在输出目录内 |
| 参数 schema 形状不对 | parameter_schema.parameters 不是数组 |
平台无法稳定做参数注入 | 能力变成可用状态前直接拒绝 |
| 输出契约为空 | output_schema.outputs 为空 |
下游不知道该拿 images / gifs / videos 里的哪类产物 | 要求输出 schema 非空 |
| 节点依赖未声明 | workflow 里出现未列入 node_dependencies 的 class_type |
能力实际执行内容和声明不一致 | 能力级白名单拦截 |
| 角色节点权限不足 | 角色不允许某个依赖节点 | 能力可用不代表当前角色可运行 | 角色级白名单再次校验 |
| DAG 引用下游输出 | node_output 指向尚未执行的节点 |
运行顺序不可确定,可能形成循环 | 校验只能引用上游节点,再做拓扑排序 |
表里的样例都是模式级示例,不含真实客户、文件名或生产数据。
什么时候不建议这么做
如果只是个人使用 ComfyUI,或者团队里人人都能理解节点图,这套治理层会显得重。直接用 ComfyUI 原生界面,反而更省事。
但一旦要做多用户、权限、预算、积分、对象存储和可复用流程,后端就不能只当转发器。/prompt 很好,但它不应该成为 SaaS 用户的业务入口。
我现在对这类项目的判断是:先把 ComfyUI 当 runtime,再在外层定义能力契约。节点图仍然保留它的表达力,平台也能知道自己到底在允许谁、以什么成本、执行什么能力。
如果这篇对你有帮助,欢迎点赞收藏。