☰
从ComfyUI到TensorRT:图像生成系统的推理优化与服务化实战
2026/10/17 3:03:02 网站建设 项目流程

这两年聊图像生成系统,绕不开一个话题:ComfyUI 在前端怎么编排,后端怎么扛住真实请求。很多人把它当出图工具用,点几下鼠标、拖几个节点,生成几张图就结束了。但如果你的目标不是"偶尔出一张图",而是搭一套能对业务暴露接口、能稳定出图、能把单张耗时和成本压下来的图像生成系统,ComfyUI 只是起点,后面还得走一条从节点工作流到推理引擎优化的路。

这篇文章就记录一下我在某图像生成系统上的完整演进思路——从先在 ComfyUI 里把工作流跑稳,到用 TensorRT 把真正的推理环节提速,再到服务化改造中遇到的取舍。整个过程里既有可以直接抄作业的操作步骤,也有我踩过、后来填掉的坑。适合正在搭文生图、图生图服务,又不想一步迈入大规模分布式的人参考。

1. ComfyUI 不是"玩具",它是整个系统的第一版原型

1.1 为什么从 ComfyUI 起步,而不是直接写推理脚本

我第一次搭图像生成服务的时候,其实也纠结过要不要直接用 Python 写推理脚本,把模型加载、采样循环、后处理全部自己控制。后来发现这条路在项目早期根本走不通,原因很简单:图像生成系统最大的不确定性不在性能,而在效果。

你今天用这个采样器能出好图,换个 LoRA、调大 CFG 之后构图就崩了;这个模型对人物细节处理得好,但对建筑透视的刻画一塌糊涂。这种探索阶段的需求是"高频、低成本的试错",而 ComfyUI 这类节点式工作流天生就是为这种场景设计的。每个模型、采样器、VAE 都是一个独立节点,改参数不需要重启进程,结果马上能看到;想把 A 模型的 UNet 配上 B 模型的 VAE,拖两根线就完成了。用代码写这一套,少说要小半天,但在 ComfyUI 里只需要几分钟。

所以我的第一个经验是:别在项目第一天就追求"生产级部署",先把 ComfyUI 用起来,把所有模型组合、参数范围、后处理链路都在里面跑一遍。它不只是出图工具,更是整个系统的可视化原型环境。

1.2 先定目标:接口长什么样,比选哪张显卡更重要

很多人在搭系统之前先纠结"我该上什么卡""要不要多卡并行",我建议先想清楚另一件事:这套系统对外提供的接口契约到底是什么。输入是纯文本,还是文本加参考图?输出是一张图,还是一个批次的多张候选图?允不允许用户自定义采样步数、CFG、分辨率?这些需求直接决定了后面的模型栈选型和工作流结构。

我习惯在做任何优化之前,先列一张接口契约表,把输入输出字段、取值范围、默认值全部定下来。比如我手头这套系统最初的接口就三件事:

字段类型说明
promptstring正向提示词,必填
ref_imageimage图生图参考图,可空
sizestring512x512 / 768x768 / 1024x1024
stepsint采样步数,默认 20,范围 8~40
seedint随机种子,可空;空则随机

把接口先定下来,你就知道要准备几个模型、几个分辨率档位、缓存 key 怎么设计。如果接口定义模糊,后面做 TensorRT 引擎的时候,连动态 shape 该设哪几档都不知道,优化无从谈起。

1.3 把 ComfyUI 当"工作流模板库":从画布到 API 的一小步

ComfyUI 里有两套 JSON 格式,很多人会搞混:一套是 UI 用的 workflow 格式,保存了节点坐标、连线这些界面信息;另一套是 API format,是真正提交给后端执行的结构化 prompt。从界面右上角选"导出 API 格式",就能拿到后者。

把这个 API prompt 保存下来之后,下一步就是用 HTTP 接口把工作流真正跑起来。ComfyUI 自带一个 HTTP 服务,默认监听 8188 端口,提交任务和查结果都不需要装额外依赖。核心流程是两步:先向/prompt接口提交任务,拿到prompt_id,再轮询/history/{prompt_id}获取执行状态和输出图片文件名。一个最简的提交逻辑大概是这样的:

import json import urllib.request def queue_prompt(api_prompt: dict): data = json.dumps({"prompt": api_prompt}).encode("utf-8") req = urllib.request.Request( "http://127.0.0.1:8188/prompt", data=data, headers={"Content-Type": "application/json"} ) return json.loads(urllib.request.urlopen(req).read()) def get_history(prompt_id: str): req = urllib.request.Request(f"http://127.0.0.1:8188/history/{prompt_id}") return json.loads(urllib.request.urlopen(req).read()) api_prompt = json.load(open("workflow_api.json")) result = queue_prompt(api_prompt) prompt_id = result["prompt_id"] # 然后轮询 get_history,直到任务状态变为 success

这一步做完,ComfyUI 就从"画布工具"变成了"后端执行引擎"。但这里有几个关键细节要提醒:节点 id 必须保持稳定,改完工作流之后要重新导出并回归测试,否则接口里引用的节点编号一旦变掉,任务会直接报错;另外 ComfyUI 的任务队列默认是单队列,所有请求排队执行,这在原型阶段完全没问题,但你的系统一旦面向多用户,这就成了第一个要改造的点。正确的心态是:ComfyUI 只是你的工作流模板库和效果基线,不是最终服务的全部。

2. 工作流稳定之后,真正的优化瓶颈出现在哪

2.1 一次出图的时间都花在哪了

工作流稳定、接口能通之后,问题就转到性能上了。这时候不要急着换框架,先搞清楚一次完整出图的时间到底消耗在哪些环节。以常见的文生图模型为例,整体耗时结构大致是这样的:

阶段主要工作耗时占比
文本编码把提示词编码成语义向量,一次推理即可很小,毫秒到几十毫秒级
UNet 去噪按步数反复执行噪声预测,是核心计算热点通常占 80% 以上
VAE 解码把潜在表示解码回像素图较小,几十毫秒级
后处理节点放大、修复、水印等,取决于工作流复杂度可忽略或显著

理解这个结构的意义在于:优化要找到大头。UNet 去噪部分的开销和采样步数成正比,和分辨率的关系则接近非线性——分辨率翻倍,中间 latent 的宽高都翻倍,实际计算量增长远超两倍。这也是为什么很多加速方案都会在步数和分辨率计算精度上做文章,而不是去优化 VAE 解码。

2.2 动手换框架之前,先把低成本的几件事做完

我一度想着直接上 TensorRT 一步到位,后来发现很多"性能问题"其实用更便宜的手段就能解决一大半。首先是采样器配置。同样的模型,从默认的 20 步降到 10 步左右,配合好一点的采样器(我自己常用 DPM++ 2M Karras 这一档),画质差距很多时候是可以接受的,但耗时直接减半。这是整个系统中性价比最高的优化。

其次是分辨率管理。把常用尺寸锁到固定档位,比如 512x512、768x768,这比让用户随便填一个宽高比要聪明得多。理由后面讲 TensorRT 时会提到:动态 shape 的引擎会有额外开销,固定档位可以把优化空间全部让给画质和并发。最后是显存管理。如果你的系统因为显存不够开始出现模型卸载、反复换入换出的情况,那才是真正的性能杀手——一次换入可能比一次推理还慢。所以第一步优化不是换更快的卡,而是把权重切成半精度、控制 batch 大小,让模型能稳稳地驻留在显存里。

2.3 TensorRT 不是万灵药,但它解决的是对的问题

把低成本的优化做完之后,如果单张耗时还是达不到业务要求,或者你希望同样的显卡能扛住更多并发,这时候就该认真考虑 TensorRT 了。它是当前单卡场景里用得最多的推理优化引擎之一,做的事情大体可以概括为三件:层融合、kernel 自动调优、精度压缩。

层融合是把多个相邻的小算子合并成一个 Kernel,减少显存读写和 kernel 启动的开销;kernel 自动调优是提前在大量候选实现里挑出最快的那个;精度压缩则是用 FP16、INT8 这些低精度格式换取成倍的计算和带宽收益。对比直接用 PyTorch 的常规推理模式,TensorRT 在相同硬件上能明显拉开差距,而且它发布的是一个可序列化的 engine 文件,运行时加载快、算子调度路径短,天然适合生产环境。

但我也得说句公道话:TensorRT 不是一把万能钥匙。它的收益模型是"单卡、单模型、高重复度"的场景,如果你的系统会频繁切换不同架构的模型、每次请求的尺寸天差地别,收益会被明显稀释。所以在决定投入之前,先确认你已经把前两节提到的低成本优化都做完了。另外一个提前布局的理由是:TensorRT 可以和 ComfyUI 渐进式结合,不是非黑即白,你可以先把 UNet 一个模型换成 TensorRT 引擎,其他环节保持原样,风险可控地推进。

3. 从 ComfyUI 到 TensorRT 的关键操作:导出、构建、接入

3.1 接入方式:保留工作流,替换推理后端

我见过两种走入 TensorRT 的姿势:一种是彻底抛开 ComfyUI,直接用 TensorRT API 写一套完整的推理代码;另一种是在 ComfyUI 内部通过自定义节点接入 TensorRT 引擎,工作流骨架完全不动。我的建议是选后者,至少前期要选后者。

原因很直接:你已经有了一套验证过的 ComfyUI 工作流,里面可能有 LoRA、ControlNet、各类后处理节点。如果全部重写,等于把效果基线也扔了,出了问题你不知道是加速的锅还是代码的锅。保留工作流、替换关键模型节点之后,同一个工作流可以随时在两套后端之间切换:普通模型节点作为 baseline,TensorRT 引擎节点作为加速版。每一次优化前后,用同一种子、同参数跑出来的图做逐像素对比,效果差异一目了然。

社区里已经有现成的 ComfyUI TensorRT 节点包,基本流程是先加载普通模型、转换成 ONNX、再构建 engine,构建好的 engine 会成为工作流里的一个节点。如果你不想依赖别人的封装,也可以自己写一个 custom node 来调用 TensorRT 的 Python API,本质不外乎把 engine 加载进来、给输入 tensor 取名字、做一次推理。我的建议是先用现成节点跑通全链路,再根据业务需求决定要不要自研封装。

3.2 ONNX 导出与常见的坑

不管走哪条接入路线,只要模型不是原生 TensorRT 支持的格式,都会经过 ONNX 这一跳。这一步是踩坑重灾区,常见的翻车点有三个。

第一是 opset 版本。导出时指定的算子集版本如果太低,部分算子可能缺失;版本太高,TensorRT 的解析器可能还没跟上,构建时会报 "Unsupported operator"。我的做法是先用 ONNX Runtime 把导出的模型跑一遍,确认数值正常、输出 shape 正确,再交给 TensorRT。这一步能过滤掉一大半问题。第二是动态维度声明。UNet 的输入不止一个,以 SD1.5 为例,至少包含 sample、timestep、encoder_hidden_states 三个输入,导出时如果不显式声明动态轴,后面 TensorRT 就只能按固定 shape 构建,灵活性全没了。第三是自定义算子。某些社区模型会带一些特殊 attention 实现,标准算子解析不了,这时候要么在导出时强行替换成原生等价形式,要么给 TensorRT 写插件。对绝大多数人来说,优先选择"换成等价标准算子"的思路,因为写插件维护成本很高。

我踩过最典型的坑是:辛辛苦苦把 ONNX 导出成功,以为万事大吉,结果 TensorRT 构建时在某个 reshape 节点上报维度推导失败。原因是导出时有些动态分支没有被正确标记,输入 shape 一变,中间某个张量的维度就推不出来了。所以一定要把导出工具的日志打印出来,确认每个输入的 dynamic axes 都被标记到。

3.3 构建 engine:静态、动态 shape 和精度选择

ONNX 拿到手之后,就到了构建 engine 的环节。这一步的核心决策有三个:静态还是动态 shape、用哪档精度、opt 档位怎么选。

静态 shape 很好理解,固定 batch 和分辨率,性能最激进,但代价是换一个尺寸就要重新构建一个引擎。如果你的业务只有 512x512 和 1024x1024 两个档位,那就分别建两个静态引擎,简单粗暴。动态 shape 则需要在构建时提供minShapes、optShapes、maxShapes三档范围,TensorRT 会在这个范围内做 kernel 优化,运行时可以根据实际输入形状灵活匹配。动态引擎灵活性高,但优化空间会被摊薄,所以optShapes这一档一定要贴近真实生产负载,比如你的系统 80% 的请求都是 512x512、batch 为 2,那 opt 档就按这个填。

精度方面,FP16 是绝大多数文生图工作流可以无脑开的选项,收益大、画质损失在多数场景下可以接受。INT8 就需要谨慎了,因为量化会对权重和激活值做更低比特的压缩,必须准备一批有代表性的校准数据来统计激活值范围。校准集如果选得太窄,比如只拿几张测试图,上线后一旦遇到风格迥异的提示词,画质就可能崩。我自己的规则是:固定风格、内容模式可控的场景才上 INT8,完全开放式的文生图服务,先用 FP16 跑,除非延迟指标实在顶不住。

一个 SD1.5 UNet 的 trtexec 构建命令大概是这样的,实际输入名以你导出 ONNX 的节点名为准:

trtexec --onnx=unet.onnx \ --fp16 \ --minShapes=sample:1x4x64x64,timestep:1,encoder_hidden_states:1x77x768 \ --optShapes=sample:4x4x64x64,timestep:1,encoder_hidden_states:4x77x768 \ --maxShapes=sample:8x4x64x64,timestep:1,encoder_hidden_states:8x77x768 \ --saveEngine=unet_fp16.engine

初次构建可能耗时几分钟到几十分钟,这很正常。引擎构建完成后会落盘成.engine文件,之后每次加载只需要几百毫秒到几秒。这里有个好习惯:把每个引擎按模型版本、精度、shape 档位、batch 这些维度命名,比如unet_sd15_fp16_512_b4.engine,方便后续对比和回滚。

3.4 构建慢的工程化处理

engine 构建时间看起来只是开发期的烦恼,但一旦你需要经常更新模型、调整精度档位,这个"烦恼"会变成团队配合上的瓶颈。我的做法是把它彻底工程化:引擎构建任务从服务启动流程中拆出去,单独跑在一台构建机上,构建产物按命名规则缓存起来,CI 流程里按"模型 hash + 精度 + shape + batch"作为缓存 key,命中就直接复用。

另一个容易踩的坑是工具链版本。TensorRT、CUDA、ONNX 导出工具之间是有版本绑定关系的,同一份 ONNX 在不同版本工具链下构建出的引擎行为可能有差异。所以每次成功构建之后,建议把工具链版本、模型 hash、构建参数都写进一个 manifest 文件,和 engine 文件放在一起。这样生产环境一旦出问题,拿到 manifest 就能精确复现构建过程,而不是靠记忆去猜当时用的哪个版本。

4. 引擎只是开始,服务化设计才是拉开差距的地方

4.1 服务骨架:入口、队列、worker

TensorRT 引擎接入之后,单次推理确实快了,但如果你只是把引擎塞进一个同步接口里,并发一上来照样会卡死。服务化改造的核心不是把接口写得多花哨,而是把任务提交和算力执行解耦开。

我最后稳定下来的骨架是三层:入口层负责接收请求、鉴权、限流,把任务信息丢进队列;队列层按任务类型分流;worker 层是真正跑 ComfyUI + TensorRT 的执行者,从队列拉任务、提交给 ComfyUI、轮询结果、再把图片回传存储。这套结构和常见的后台任务系统没有本质区别,但有一件事我想特别强调:不要在第一版就引入分布式微服务。你需要的只是一个进程内够用的队列,或者单体服务里的后台任务池,顶多再加一个独立的消息队列。把网关、工作流、推理引擎拆成三个微服务,前期只会增加联调成本和部署复杂度。

接口设计也不用复杂。我用的就是两个接口:POST /v1/tasks提交任务,立刻返回task_id;GET /v1/tasks/{task_id}查询状态和结果。用户不需要同步等一张图生成完,前端轮询就行。这个设计对前后端都是最省事的。

4.2 显存、并发、排队:三个现实问题

引擎接入后,很多人第一反应是"我要开高并发",但真实约束是显存。一张图在推理过程中需要给中间激活值留出空间,一个 UNet 模型本身也要占几个 GB 显存。你手里一块 24GB 显存左右的卡,扣除文本编码器和 VAE 的占用,真正能同时跑的并发任务数可能只有两三个。

而且并发数上去之后,单个任务并不会变快,反而会因为算力和显存带宽争抢而集体变慢。这跟高速公路上车多了大家都堵是一个道理。所以我的观点是:在吞吐需求还没到临界之前,串行执行加一个有限队列,未必是坏设计。它带来的是确定性的延迟和最简的异常处理逻辑。真正的高并发优化,顺序应该是先做结果缓存、再做多副本水平扩展,最后才考虑在单卡内部堆并发。

排队策略上有一个特别划算的做法:完全相同的请求直接合并。如果两个用户提交了同样的提示词、同样的种子、同样的参数,后一个请求根本不需要重新推理,直接复用前一个的结果。这类缓存命中率比你想象中高得多,尤其在批量出图的业务里。

4.3 从单卡到多副本:什么时候扩,怎么扩

当单卡确实扛不住的时候,优先选多副本横向扩展,而不是去拆分模型服务。所谓多副本,就是部署多个"ComfyUI worker + TensorRT engine"的独立单元,前面挂一个负载均衡,按队列长度把任务分配给空闲的副本。这个方式的优点是没有跨机器的通信开销,任何副本挂了,流量切走就行,整个系统的复杂度和单机没有本质区别。

相比之下,把文本编码器、UNet、VAE 拆成三个独立的推理服务,听起来很"先进",但只有到一个模型的耗时已经优化到极限、且不同模型的利用率差异极大时,才值得做。就我接触的大多数场景而言,UNet 是绝对大头,你只要把 UNet 的引擎优化做好,其他环节合在一起部署完全没问题。另外,模型版本的管理也应该和代码版本解耦。工作流定义本身很少变,但 checkpoint 可能会定期更新。我会把模型文件路径做成配置项,由部署系统控制,而不是把路径硬编码在代码里。这样可以做到:改模型不重新发布服务,引擎回滚也只需切换配置。

5. 跑通这套系统之后,我最想提醒的几个坑

5.1 缓存永远比优化算子便宜

在系统里加一层结果缓存,是我回头看作出的所有决定里回报率最高的一个。它的思路简单到不像技术方案:如果请求的 prompt、负面词、分辨率、采样步数、种子、模型版本完全一致,那就直接返回之前生成过的图。实现也不复杂,把这些字段拼成一个 key,查一下缓存,命中就返回。

需要注意的是缓存 key 的严谨性。很多人一开始只把 prompt 加进 key,结果换了种子发现返回的还是旧图,排查半天。我现在的习惯是:把正向词、负面词、采样步数、CFG、分辨率、模型 hash、引擎版本全部纳入 key,宁可损失一点命中率,也不能返回错误结果。另外,结果图的存储也不能马虎,至少做到按任务 id 归档,用户随时能重新获取自己生成过的图。

5.2 精度问题要按场景取舍,不能一刀切

FP16 在多数模型上表现很稳,但不是绝对无损。我遇到过某个风格化模型在 FP16 引擎下,暗部细节出现肉眼可辨的条纹噪声,而 FP32 原模型正常。这种差异在不同提示词之间表现得还不一样,所以每次换模型、换引擎之后,一定要做对比测试。我的做法是保存一组固定种子、固定提示词的对照图,通过 PSNR、SSIM 这类指标加人工抽检双重判断。

INT8 更需要留心。它引入的不只是轻微噪声,而是可能把某些激活值的分布压坏,导致画面出现色偏、结构畸变。如果一定要上 INT8,校准集要覆盖各种画风,至少几百张有代表性的图,不要只拿一两张测试图应付。量化后如果发现某个特定层的误差特别大,可以把那一层单独切回 FP16,做混合精度,而不是把整个引擎推倒重来。

5.3 版本锁与可复现性

这套系统里涉及到的组件版本非常多:ComfyUI 本身、自定义节点、TensorRT、CUDA、PyTorch,乃至 ONNX 导出工具。组件之间互相拉扯的兼容性问题,会在某次升级时集中爆发。我有一次只是升级了某个节点的版本,结果工作流在其他节点上报了一堆莫名其妙的不兼容错误,排查了整整一天。从那之后,我养成了把所有版本写进依赖锁定文件的习惯,并且严格按 staging 环境验证通过之后才上生产。

引擎文件同样需要可复现性。之前提到过 manifest 文件,这里再补充一点:引擎文件本身不应该被手动修改,它只是构建参数的产物。任何新引擎上线前,先跑一遍对照图,再用压测脚本看延迟和显存,最后逐步切流量,不要一把梭。

5.4 优化顺序的一点个人经验

最后想聊聊方法论层面的东西。我见过不少团队一上来就折腾推理引擎,结果系统复杂度上去了,收益却没想象中大。我的建议很简单:一切优化都要用数据说话。改造之前记录三个指标——单张生成耗时、显存峰值、从提交请求到拿到结果的端到端延迟;改造之后再记录同样三个指标,前后对比,收益小于 10% 的优化,不做也罢。

我自己会维护一个优化清单,按"预期收益除以改造风险"排序。最前面永远是改工作流参数、加缓存这类低成本高收益的事情;中间是精度压缩和 TensorRT 引擎;排在最后的是服务拆分和多机部署。按这个顺序做下来,每一步都有明确理由,而不是为了技术而技术。

最后再分享一个小技巧:我每次做这类优化,都会保留一套"原始 ComfyUI 工作流文件 + 原始权重"的对照环境。很多复杂问题,最后都会回归到"加速前后的结果到底一不一样"这个简单问题上。TensorRT 引擎、缓存、服务拆分都只是手段,真正重要的是一套可以自检、可回滚的稳定流程。这条从 ComfyUI 到 TensorRT 的路,本质上就是把灵感型工作流慢慢固化成服务型系统的过程。如果你正处在这个阶段的中间,别急着上分布式,先把单机链路的每个环节跑明白,收益会来得更实在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询