工作流 JSON 没变,为什么 ComfyUI 更新后仍可能跑出不同结果?
做视频工作流时,一份 JSON、同一个 模型 文件名、同一个 seed,常被当作“昨天那次任务”的完整凭据。环境更新后画面变了,人很容易先怀疑随机数。
这里应先问一个更靠前的问题:这次请求里的节点名和模型名,仍然指向昨天的实现与文件吗? JSON 记录了引用,不会自动把引用指向的所有内容一起封存。
本文核对 ComfyUI 官方固定提交 5c460d81 的执行与加载代码,给出一个可重跑的文件哈希反例。它解释同一份请求为什么不足以证明执行条件相同;没有运行 ComfyUI、下载模型或生成视频,也不声称下面任何变化必然改变画面。
先确认你保存的是哪份 JSON
官方文档区分浏览器保存格式与 API 格式:前者保留编辑用的布局等信息,后者用于提交执行。两者扩展名都可以是 .json,不能只凭 文件名 判断用途。官方 API 示例把节点写成 class_type 与 inputs,再包装进请求的 prompt 字段。1
这意味着“工作流文件没改”首先只能说明那份文件没改。假设调用脚本读入它之后替换了输入视频、seed 或 提示词 ,磁盘上的原文件仍可以逐字节相同,真正提交的请求却已不同。这是对脚本行为的构造例子,不是本文观察到的 ComfyUI 故障。
排查时应同时保留可编辑工作流和实际提交的 API prompt,并关联本次任务。比较请求内容时把字段顺序、空格差异与参数差异分开:文本哈希不同可能只是序列化变了;节点类型、输入值和连接关系的差异才需要逐项解释。API 导出是起点,不能代替“脚本最终发了什么”的证据。
如果实际请求也相同,仍有两个名称解析动作发生在请求之外。
从任务身份反查最终请求
一次任务可以关联三份对象:编辑工作流、脚本处理后的API prompt 和输出记录。前者方便继续编辑,中间一份解释实际执行,后一份说明结果来自哪次提交。不要用同一个“工作流文件”名字把三者混为一谈。保留各自摘要及关联任务标识,能判断变化发生在文件导出、参数替换还是运行之后。
比较请求时,应按节点阅读输入及连接。例如提示词文本、输入视频引用、采样参数或连接到哪个上游输出,一项改变就已破坏请求相同的前提。若只是空格与键顺序变化,可用明确的规范表示辅助比较,同时保留原始提交内容。规范化不能随手删除未知输入字段,否则可能为了得到相同哈希而抹掉真正变化。
固定官方 API 示例本身就给出具体线索:它先解析示例文本,再把正向提示词从 girl 改为 man,把 KSampler 的 seed 从 8566257 改为 5,最后才调用 queue_prompt。这是静态源码核查,不是本文向服务提交过请求;它说明必须观察发送前的对象,不能只对照导出的文本。1
排队脚本与请求生成器的版本也值得关联。脚本不仅搬运JSON,还可能根据任务条件替换文件名、节点输入或输出路径。审查时找到实际发送位置的内容,才可验证这次究竟用了哪份结果。本文不声称已捕获任何真实任务请求;这是读者留档时应执行的检查。
首次保存参照任务时,就记下接受目标。若目标是生成同样内容,不必要求编辑布局也完全一样;若目标是逐字节复现执行请求,则要限定请求表示。两种相同都不覆盖运行时引用。请求对齐之后,才进入节点与模型层,排查顺序因此不会被一个文件名提前结束。
节点类型相同,查到的 Python 类未必相同
固定提交的 execution.py 第 444–445 行先读取节点的 class_type,再执行:
class_def = nodes.NODE_CLASS_MAPPINGS[class_type]
这里取的是当前进程里的类映射,不是从 JSON 中还原一份 Python 源码。2
同提交 nodes.py 第 2303–2307 行展示旧式 custom node 的注册路径:模块导出的名称和类会进入 NODE_CLASS_MAPPINGS,同时登记模块位置。该片段有自己的过滤条件,也不是所有扩展注册方式的完整说明;它已足以说明,节点类型这个字符串与具体实现之间还隔着一次运行时查找。3
因此,一个社区节点保留原类型名却修改了预处理或默认行为,旧请求可以继续找到它,也可能正常执行。没有“缺失节点”报错,并不能证明调用的是旧实现。 例如裁剪规则或默认值的解释改变,名称兼容仍不等于行为兼容。这是由解析路径得到的可能性,不是对某个社区节点升级的质量指控。反过来,缺失节点表明图在当前映射无法完整解析,应先处理接口条件,不能归给模型随机性。
真正有用的对照是:这个请求里的节点类型,由哪个安装目录、哪个仓库提交提供,目录是否有未提交修改。只记一个仓库 commit 仍漏掉本地改动;只记包名则连版本都没限定。对参与任务的扩展记录版本和工作树差异,才能把“名字没变”推进到“提供这个名字的实现是否变了”。前端与扩展若参与请求生成,也应纳入这次记录。
名称兼容与行为兼容分开判断
参与节点清单可以从实际请求的class_type开始,逐项追提供者。内置节点对应核心代码版本,扩展对应实际安装目录与来源提交;有本地修改时,再记录差异。只保存插件管理器显示的名称,不能说明当前进程导入了哪份Python实现。源码目录与正在运行进程之间的关系,也需要在实际环境中核实。
并非所有已安装扩展都参与这一任务。首轮留档可以限定为请求使用及请求生成依赖的组件,减少无关清单;如果实际执行还动态调用其他模块,继续补入。该范围须由实现路径证实,不能只因JSON没写某包名,就断言它与执行无关。
对同名实现升级做对照时,也要检查兼容条件。旧节点可能依赖旧核心,新节点依赖新核心,强行混装得到错误,不一定说明单一改动造成原画面差异。先明确可运行的组合,再控制变更。本文没有执行这种对照,提出的是避免错误归因的准备步骤。
模型名相同,读到的文件字节也未必相同
固定提交的 CheckpointLoaderSimple 并没有把 checkpoint 嵌进 JSON。nodes.py 第 638–641 行先将 ckpt_name 交给路径解析,再把所得路径交给加载函数。3
folder_paths.py 第 449–454 行进一步说明:解析会遍历这个类别配置的目录,拼接文件名,遇到存在的文件就返回。因此,相同名称既可能因为文件被替换而指向不同字节,也可能因为搜索目录配置或顺序不同而解析到另一位置。4
这是该内置 checkpoint 加载器的证据。视频工作流中的独立 diffusion model、VAE、文本编码器或社区加载器,应逐个核对实际实现,不能把这几行代码当作所有加载器的统一行为承诺。
为了把“同名不等于同内容”落到可复核对象,随稿脚本创建了两个临时环境:请求都引用 model.bin,但分别写入 weights-A 与 weights-B 两段演示字节。没有真实模型。脚本实际执行得到:
| 比较对象 | 结果 | 可以推出什么 |
|---|---|---|
| 构造 API 形状 prompt 的规范序列化字节 | 完全相同 | 本例请求没有变化 |
| 两边引用的文件名 | 都是 model.bin | 名称没有变化 |
| 两文件的实际字节与 SHA-256 | 均不同 | 同一名称不足以锁定文件内容 |
| 视频输出 | 未生成 | 不能推出画面是否或如何变化 |
这不是 ComfyUI 端到端复现测试,也没有模拟模型加载。它只验证一个更小的命题:请求哈希不能替代被引用文件的内容校验。 源码说明为什么这个小命题与真实加载路径有关,演示不替源码承担更多结论。
可直接运行:复用同名文件反例,检查下一层变化
以下单文件只依赖 Python 标准库。它在 A、B 两个临时目录分别写入 weights-A 与 weights-B,用同一个构造 prompt 比较请求、路径和文件 摘要 ,并检查序列化、输入变更、目录顺序与文件缺失。DemonstrationLoader 是自造标签,model_name 也是样例输入,不是注册过的 ComfyUI 节点,更不是 CheckpointLoaderSimple 的 ckpt_name。这个 prompt 只借用 API 形状,不宣称能提交执行。
把代码保存为 reference-identity-demo.py 可直接用 python3 reference-identity-demo.py 运行。若使用本仓库附件,实际工作目录为仓库根目录,命令如下:
python3 08-publishing-pipeline/generated-packages/PKG-20261005-0002-comfy-workflow/examples/reference-identity-demo.py
完整可运行代码与两组输出集中在文末附录。两组分别是原始文件身份反例和带六类自检的扩展示例,都未调用 ComfyUI 或模型。
第一组仍证明请求和文件名相同而内容不同。第二组不是六次模型实验,而是六类本地自检:格式变化、输入值变化、未知输入保留、目录顺序、同路径替换、文件缺失。格式变化的断言同时检查原始字节确实不同、解析后规范表示相同;把 seed 改为 43 后,规范表示必须不同。新增未知字段也必须保留,不能删掉它来制造“请求相同”。本例只处理有限 JSON 对象,没有把数字类型、Unicode 或任意业务等价关系统一规范化,更不裁定不同图在算法上是否等价。
目录反例交换 A/B 顺序,命中位置随之不同。这里的 pick 是作者写的有限文件选择规则,只处理两个目录和一个普通文件;它没有调用官方 folder_paths,不验证其路径清理、类别配置、缓存、错误语义或社区加载器。真实加载器行为仍以固定源码和运行环境为依据。随后在同一解析路径写入新演示字节,摘要变化,再删除文件验证缺失;临时目录退出时自动清理,没有留下伪模型交付物。
这个结果也给出排查分岔:如果原始 JSON 哈希不同而规范表示相同,先保留序列化差异,继续核实现和文件;如果 inputs 已变,先查发送前参数替换;如果路径变了,查具体加载器的目录配置;如果路径没变、摘要变了,查替换时点。不要从任何一个 true 推出视频可复现;程序从未生成视频,后续输出验收仍为空白。
实际留档时,需要记录加载器最终解析的路径及模型文件的内容摘要。摘要是实用的内容身份检查手段,不是数学上的无碰撞证明;路径相同也不代表内容不变。如果工作流还有输入视频、 LoRA 等外部文件,同样不能只保存名称。
文件身份从解析结果开始
对内置加载器,留档要从最终解析路径开始,而不是猜常用models目录。若两个配置目录存在同名文件,搜索顺序决定本次命中哪个;若同一路径的内容被替换,路径完全相同也不能说明权重一致。名称、解析路径和内容摘要分别回答引用什么、找到哪里和内容是否匹配,三者不能互相替代。
摘要计算还要附采集时点。若在任务结束后才检查文件,而文件期间被改动,事后摘要可能无法解释任务加载时的对象。实际可采用稳定存放的任务文件或受控更新过程,并记录何时核对;本文不假定已有这种保护。无法证实加载时身份,就在报告中写未知,而不是用最新文件哈希替过去运行背书。
视频任务中的外部依赖往往不止checkpoint。输入视频、参考图、文本文件、VAE、LoRA等,是否实际参与和怎样加载,应沿具体节点查。路径换机器后不存在,是可运行性问题;文件存在但同名不同内容,是身份问题;文件相同而解码或预处理不同,则继续进入实现与环境。分开这几类失败,下一步会更明确。
不必为这份文章复制所有模型大文件。内容摘要与来源位置可帮助识别,恢复环境是否仍能取得同一对象则是后续交付条件。能识别一个缺失文件,不等于已恢复工作流;能重新下载一个同名文件,也不等于恢复相同内容。把留档、还原和执行通过分层报告,才不会把准备工作当成复现结果。
seed 固定,是继续比较的条件,不是复现保证
请求、节点实现和文件身份对齐以后,才有条件判断剩余差异是否来自运行环境或随机行为。 PyTorch 官方明确说明,不保证不同 release、commit 或平台之间的完全复现;即便 seed 相同,CPU 与 GPU 执行也可能无法复现彼此结果。5
所以,不宜把“seed 没变”当成排除环境影响的证据,也不能把所有画面差异都归给 seed。记录 Python、PyTorch、相关依赖、驱动、设备、启动参数及影响算法选择的设置,是为了让下一次对照知道究竟保持了哪些条件。环境文件或容器标识可以帮助还原部分条件,却不自动覆盖宿主驱动、外部挂载文件和远端服务行为。
这里无需为了留档立刻重建一套复杂部署。对一台工作站上的具体任务,先保住实际请求、参与执行的代码版本与本地改动、实际文件摘要,再补当次环境记录,就能排除很多“以为相同”的前提。若节点调用远端 API,服务器实现通常不由本地仓库锁定,应明确写出复现边界。
环境记录服务于具体对照
一份依赖锁文件说明计划安装什么,运行环境信息说明此次真正使用什么。二者相符能减少疑问,但外部挂载文件、宿主驱动和远端API仍可能在范围之外。对远端节点,可以记录请求及响应身份、调用时点和服务声明的版本,无法锁定内部实现时明确标注。本文不给某服务可复现保证,也不要求为了留档新购服务。
若工作流在CPU和GPU之间切换,即使请求与权重一致,也已经改变运行条件。先把这一变化列出来,再讨论结果差异。运行失败、执行成功但画面不同、执行成功且业务可接受是三种结论;不应只凭任务状态成功就宣布数值或视觉复现通过。
对同一环境的重复执行,也要事先决定想验证哪一层。检查逐字节文件相同可能受编码和元数据影响;看帧内容则需要选定比较范围;看业务质量又需要明确主体、运动或一致性标准。环境记录支撑解释,验收规则决定能否接受,两者各有作用。
本篇重点仍是先修正相同条件的假设。请求、实现和文件任一未核实,都不应直接把剩余差异称为随机性。条件已经对齐后,真实重复运行才有价值;本文没有这类GPU证据,所以不填写任何环境下的复现通过率。
更新前后的对照,先回答“什么变了”
对于准备交付的一条视频工作流,我会把一次已接受的任务留作参照,再在隔离环境里更新。更新后先核对图解析与依赖加载;前提缺失就报告兼容性失败,不用空输出评价画质。对照从实际请求开始:如果参数已不同,先解释请求生成过程;请求相同,再检查参与节点的实现和模型、输入文件的身份;这些也对齐,才进入环境、算法设置与真实输出的比较。
“发现差异”不等于“找到原因”。例如同时更新了社区节点与 PyTorch,只知道两者都变了,还不能把画面差异归因于其中之一。需要在兼容条件允许时控制变更,重新运行实际任务,才能进一步检验。若文件摘要与输出同时变化,也只能先记录关联;无法独立替换时报告组合变化。本文没有做这一步。
真正比较视频时,还应先约定验收目标:逐字节相同、帧级数值接近,还是运动与内容达到业务要求。这些标准不能相互替代;还没运行就不该宣布复现成功。文件身份记录负责让对照有依据,输出检查负责判断这次任务是否可接受。
一份不变的 JSON 很有价值,但它只固定了记录下来的那一层。先确认请求里的名称这次到底解析到了什么,再讨论随机性和画面变化,排查才不会从一个未经证明的“环境相同”开始。
把升级检查做成一次可解释的任务
真实视频比较应同时看输入身份和输出验收。若新节点改进了运动但改变画面,是否接受取决于预先选定的业务目标;若要求旧版本字节一致,则改进也不能代替这一要求。结论可以是新组合满足业务要求但未复现旧输出,两者并不冲突。
最后交付一张小范围变化清单即可:什么保持、什么改变、哪项未知、真实执行是否完成、按哪种标准检查输出。对每个未知给下一步证据需求,例如缺最终解析路径、缺本地改动或尚未执行。这样下一位维护者能接着验证,不必从“同一份JSON”重新猜全部环境。
来源与验证边界
原始核对日期:2026-10-05;本次 2026-10-13 重新取得同一固定源码与文档,摘要与原记录一致。ComfyUI 源码固定到 5c460d8172fe30761ff67c0df3d5643bb74e0d70;文档固定到 c063f7f9f46fe440098ca59438423d0ebb045218,不主张这些提交是安装推荐。包内扩展示例复用原脚本和结果,新增本地身份反例已实际运行并绑定 SHA;当前默认分支的部分代码与行号已变,本文仍限定固定提交,不将旧截图解释为最新版。既不推荐长期固定旧提交,也不宣称升级必然损害视频。未运行 ComfyUI/GPU/视频生成,未测性能。
附录:完整演示代码与输出
完整代码:
from pathlib import Path
import hashlib,json,tempfile
h=lambda b:hashlib.sha256(b).hexdigest()
prompt={"1":{"class_type":"DemonstrationLoader","inputs":{"model_name":"model.bin","seed":42}}}
request=json.dumps(prompt,sort_keys=True,separators=(",",":")).encode()
with tempfile.TemporaryDirectory() as d:
rows=[]
for label,data in [("A",b"weights-A"),("B",b"weights-B")]:
p=Path(d)/label/"model.bin";p.parent.mkdir();p.write_bytes(data)
rows.append(dict(environment=label,request_sha256=h(request),model_name=p.name,model_sha256=h(p.read_bytes())))
out=dict(scope="constructed file identity demonstration; no ComfyUI/model/GPU/video execution",rows=rows,request_bytes_equal=True,filenames_equal=rows[0]["model_name"]==rows[1]["model_name"],file_bytes_equal=(Path(d)/"A/model.bin").read_bytes()==(Path(d)/"B/model.bin").read_bytes(),model_hashes_equal=rows[0]["model_sha256"]==rows[1]["model_sha256"],video_generated=False)
print(json.dumps(out,ensure_ascii=False,indent=2))
# Reuse the original two fake files above; no ComfyUI imports or API calls.
canonical=lambda obj:json.dumps(obj,sort_keys=True,separators=(",",":")).encode()
pretty=json.dumps(prompt,indent=2).encode()
assert pretty != request and canonical(json.loads(pretty)) == request
changed=json.loads(request);changed["1"]["inputs"]["seed"]=43
assert canonical(changed) != request
unknown=json.loads(request);unknown["1"]["inputs"]["extra_input"]="do-not-delete"
assert canonical(unknown) != request
# Author's finite directory-selection fixture, not folder_paths execution.
dirs=[Path(d)/"A",Path(d)/"B"]
pick=lambda roots:next((root/"model.bin" for root in roots if (root/"model.bin").is_file()),None)
ab,ba=pick(dirs),pick(dirs[::-1])
assert ab.read_bytes()==b"weights-A" and ba.read_bytes()==b"weights-B"
assert ab.name==ba.name and ab != ba
path_before=ab.resolve();hash_before=h(ab.read_bytes())
ab.write_bytes(b"weights-replaced")
assert ab.resolve()==path_before and h(ab.read_bytes()) != hash_before
ab.unlink()
assert pick([dirs[0]]) is None
checks={
"serialized_bytes_differ_but_canonical_prompt_equal":canonical(json.loads(pretty))==request,
"changed_seed_changes_prompt_identity":canonical(changed)!=request,
"unknown_input_preserved_in_comparison":canonical(unknown)!=request,
"directory_order_AB":str(path_before.relative_to(Path(d).resolve())),
"directory_order_BA":str(ba.relative_to(d)),
"same_path_replacement_detected":h(b"weights-replaced")!=hash_before,
"missing_file_reported":pick([dirs[0]]) is None,
"self_checks":6,
"scope":"finite JSON/file identity only; no registry, official loader, weights, GPU or video execution"
}
print(json.dumps(checks,ensure_ascii=False,indent=2))
本机 Python 3.11.6 实际运行,退出码 0,stderr 为空。实际 stdout 如下:
{
"scope": "constructed file identity demonstration; no ComfyUI/model/GPU/video execution",
"rows": [
{
"environment": "A",
"request_sha256": "f8f905e8d0a74c9756c1615c15b09533b58e3f9f46e969f820883a55cbb9a1d3",
"model_name": "model.bin",
"model_sha256": "7a2cf92f089712051f6a1d5fc3493b9cc6dc3757cfac1a834fe553b19b6affaa"
},
{
"environment": "B",
"request_sha256": "f8f905e8d0a74c9756c1615c15b09533b58e3f9f46e969f820883a55cbb9a1d3",
"model_name": "model.bin",
"model_sha256": "a9e542d599be9357b404931da8db97a6a8441ccd038ea2d2943b49ffd6e6ce2b"
}
],
"request_bytes_equal": true,
"filenames_equal": true,
"file_bytes_equal": false,
"model_hashes_equal": false,
"video_generated": false
}
{
"serialized_bytes_differ_but_canonical_prompt_equal": true,
"changed_seed_changes_prompt_identity": true,
"unknown_input_preserved_in_comparison": true,
"directory_order_AB": "A/model.bin",
"directory_order_BA": "B/model.bin",
"same_path_replacement_detected": true,
"missing_file_reported": true,
"self_checks": 6,
"scope": "finite JSON/file identity only; no registry, official loader, weights, GPU or video execution"
}
-
custom node 注册,L2303–2307;CheckpointLoaderSimple,L638–641。 ↩︎ ↩︎
-
PyTorch:Reproducibility。原始记录的 stable 入口对应此版本;2026-10-13 直接复核此版本页面,仅引用复现边界,不据本次仅显示 Redirecting 的 stable 入口推断其当前目标。 ↩︎