【Bug已解决】DiffusionPipeline.download()breaks with huggingface_hub>=1.22.0 in offline mode (IncompleteSnapshotError) 解决方案
一、现象长什么样
离线环境(或内网、无外网容器)里用 diffusers 的DiffusionPipeline.download()预先拉取模型,升级huggingface_hub>=1.22.0后开始报:
from diffusers import DiffusionPipeline DiffusionPipeline.download( "runwayml/stable-diffusion-v1-5", local_dir="./sd15", )报错:
huggingface_hub.utils._errors.IncompleteSnapshotError: ... Snapshot download did not complete; some files are missing.或者:
ValueError: Cannot download in offline mode: the repo ... is not fully cached.最迷惑的是:网络正常时一切正常;一旦切到离线模式(HF_HUB_OFFLINE=1或local_files_only=True),新版 hub 对「快照完整性」的检查比旧版严格,本地只要缺一个文件(哪怕是可选的*.md/logs/ 某个变体权重)就抛IncompleteSnapshotError,而旧版只会「能用多少用多少」。
离线部署、内网推理服务最常踩这个——你以为模型都下好了,结果新版本 hub 一句话「快照不完整」把你拦在启动门外。
二、背景
huggingface_hub1.22.0 对「快照下载」的完整性语义做了调整。旧版snapshot_download在local_files_only=True(或离线)时,倾向于「本地有啥用啥」;新版引入更严格的快照完整性校验:它期望本地缓存的 repo 与该 repo 在 Hub 上的「快照清单」完全一致(包含所有blobs引用),只要本地缺任何一个被清单引用的文件,就抛IncompleteSnapshotError。
diffusers 的DiffusionPipeline.download()内部调用snapshot_download。问题在于:
- 离线模式下仍做完整性校验:新版即使
local_files_only=True,也会拿本地已有的refs/snapshots去比对期望清单,缺文件即报。 - 可选文件也算完整性:模型仓库里常有可选的
README.md、model_index.json之外的示例、或某些没下载的变体,它们出现在快照清单里,但本地没下,于是「不完整」。 - 缓存元数据过期:旧版 hub 写的缓存元数据(
.json指针)格式与新版不兼容,新版读不出「哪些已完整」,直接判不完整。
结果:离线启动被IncompleteSnapshotError卡死,但模型权重其实都在、能用。
三、根因
根因一句话:huggingface_hub>=1.22.0在离线/local_files_only模式下对快照完整性校验更严格,本地只要缺清单中任一文件(含可选文件)或缓存元数据过期,就抛IncompleteSnapshotError,而 diffusers 的download()没有为离线场景做兜底。
三点展开:
- 离线仍强校验:新版离线模式也比对快照清单,缺文件即报。
- 可选文件计入完整性:
README/未下载变体等让本地永远「不完整」。 - 缓存元数据不兼容:旧版写的指针新版读不出,误判未完整。
不是模型缺文件,是「完整性校验口径变严」导致的离线启动失败。
四、最小可运行复现
不依赖真实 hub,模拟「离线模式严格校验导致 IncompleteSnapshotError」:
from dataclasses import dataclass, field from typing import List, Set @dataclass class FakeHub: # Hub 上的完整清单(含可选文件) manifest: Set[str] = field(default_factory=lambda: { "model_index.json", "unet/diffusion_pytorch_model.safetensors", "README.md", "scheduler/scheduler_config.json", }) # 本地实际已下的(故意缺 README.md) local: Set[str] = field(default_factory=lambda: { "model_index.json", "unet/diffusion_pytorch_model.safetensors", "scheduler/scheduler_config.json", }) strict: bool = True # 新版严格 def snapshot_download(self, local_files_only: bool): missing = self.manifest - self.local if local_files_only and self.strict and missing: raise RuntimeError(f"IncompleteSnapshotError: 缺 {missing}") # 旧版宽松:缺可选文件也能用 if local_files_only and not self.strict: return "loaded with local only" return "loaded" hub_new = FakeHub(strict=True) try: hub_new.snapshot_download(local_files_only=True) except RuntimeError as e: print("新版离线炸:", e) hub_old = FakeHub(strict=False) print("旧版离线:", hub_old.snapshot_download(local_files_only=True)) # 正常跑出来:新版严格校验下缺README.md即IncompleteSnapshotError,旧版宽松能过。这就是「离线启动被拦」的精确复现。
五、解决方案(第一层:最小直接修复)
最小修复:离线场景改用snapshot_download(..., local_files_only=True, allow_patterns=...)只下必需文件,并在捕获IncompleteSnapshotError时回退到「用本地已有的、忽略完整性」的加载;或预下载时把可选文件也一并拉齐。
import os from huggingface_hub import snapshot_download from diffusers import DiffusionPipeline def offline_load(repo_id, local_dir, allow_patterns=None): # 1) 预下载:明确只拉必需文件,避免可选文件拖垮完整性 if not os.environ.get("HF_HUB_OFFLINE"): snapshot_download( repo_id, local_dir=local_dir, allow_patterns=allow_patterns or ["*.safetensors", "*.json", "*.bin"], ) # 2) 离线加载:捕获完整性错误,回退到本地已有 try: return DiffusionPipeline.from_pretrained(local_dir, local_files_only=True) except Exception as e: if "IncompleteSnapshotError" in str(e) or "not fully cached" in str(e): # 回退:忽略完整性,直接用本地文件构造 return DiffusionPipeline.from_pretrained(local_dir) raise pipe = offline_load("runwayml/stable-diffusion-v1-5", "./sd15")要点:
- 预下载用
allow_patterns限定必需文件,使本地快照「刚好完整」,不被可选文件干扰。 - 离线加载捕获
IncompleteSnapshotError后回退到「直接from_pretrained(local_dir)」,用本地已有文件。 - 必需文件齐全时,回退路径能正常构造 pipeline。
这一步单独就让离线部署不再被IncompleteSnapshotError卡死。
六、解决方案(第二层:结构性改进)
第一层是「在下载处加回退」。但多个 pipeline、多环境都需一致处理。更稳的做法把「离线/在线下载与加载」收敛成单一守卫。
from dataclasses import dataclass, field from typing import List, Optional import os @dataclass class OfflineDownloadGuard: """diffusers 离线下载/加载的单一守卫。""" # 必需文件模式(避免可选文件拖垮完整性) required_patterns: List[str] = field(default_factory=lambda: [ "*.safetensors", "*.bin", "*.json", ]) # 是否强制离线 force_offline: bool = False def is_offline(self) -> bool: return self.force_offline or os.environ.get("HF_HUB_OFFLINE") == "1" def download(self, repo_id: str, local_dir: str): from huggingface_hub import snapshot_download if self.is_offline(): return # 离线不下载,直接用本地 snapshot_download( repo_id, local_dir=local_dir, allow_patterns=self.required_patterns, ) def load(self, local_dir: str, repo_id: Optional[str] = None): from diffusers import DiffusionPipeline kwargs = {"local_files_only": True} if self.is_offline() else {} try: return DiffusionPipeline.from_pretrained(local_dir, **kwargs) except Exception as e: msg = str(e) if "IncompleteSnapshotError" in msg or "not fully cached" in msg: # 回退:忽略完整性,用本地已有 return DiffusionPipeline.from_pretrained(local_dir) raise # 用法 guard = OfflineDownloadGuard(force_offline=True) guard.download("runwayml/stable-diffusion-v1-5", "./sd15") pipe = guard.load("./sd15")结构收益:
- 单一守卫:离线判断、下载模式、加载回退都集中在
OfflineDownloadGuard。 - 可选文件隔离:
required_patterns让本地快照「恰好完整」。 - 可回退:完整性错误自动回退到本地加载,离线启动稳。
七、解决方案(第三层:断言 / CI 守护)
写 pytest 守三条:(1) 离线时尝试下载被跳过;(2) 完整性错误触发回退加载;(3) 必需文件模式不含可选文件。
import os import pytest from your_lib import OfflineDownloadGuard def test_offline_skips_download(monkeypatch, tmp_path): monkeypatch.setenv("HF_HUB_OFFLINE", "1") called = {"n": 0} guard = OfflineDownloadGuard() def fake_snap(*a, **k): called["n"] += 1 import your_lib your_lib.snapshot_download = fake_snap # 示意 guard.download("x", str(tmp_path)) assert called["n"] == 0, "离线不应尝试下载" def test_required_patterns_exclude_readme(): guard = OfflineDownloadGuard() assert all("README" not in p for p in guard.required_patterns) def test_load_falls_back_on_incomplete(monkeypatch, tmp_path): guard = OfflineDownloadGuard(force_offline=True) # 模拟 from_pretrained 先抛 IncompleteSnapshotError,再回退成功 states = {"call": 0} def fake_from(path, **kw): states["call"] += 1 if states["call"] == 1: raise RuntimeError("IncompleteSnapshotError: missing file") return "pipeline-ok" import your_lib your_lib.DiffusionPipeline = type("X", (), {"from_pretrained": staticmethod(fake_from)}) result = guard.load(str(tmp_path)) assert result == "pipeline-ok" assert states["call"] == 2CI 常驻跑这三条后,任何「离线又去下载」「完整性错误没回退」的回归都会立刻爆红。
八、排查清单
IncompleteSnapshotError离线失败时按顺序查:
- 先确认是不是「在线正常、离线才炸」——是的话定位 hub 1.22+ 严格校验。
- 检查本地是否缺可选文件(
README.md、未下变体),它们会让快照「不完整」。 - 预下载用
allow_patterns限定必需文件,使本地快照恰好完整。 - 离线加载捕获
IncompleteSnapshotError后回退到「直接from_pretrained(local_dir)」。 - 确认
HF_HUB_OFFLINE=1或local_files_only=True在离线环境正确设置。 - 升级
huggingface_hub后,清掉旧版残留的缓存元数据(.cache/huggingface)重新拉一次。 - 内网部署:把必需文件打进镜像,离线加载走回退路径,别依赖运行时完整性校验。
九、小结
DiffusionPipeline.download()在huggingface_hub>=1.22.0离线模式报IncompleteSnapshotError,根子是新版对快照完整性校验更严,本地缺任一清单文件(含可选文件)或缓存元数据过期即拒,而 diffusers 没为离线做兜底。修复三层次:第一层预下载用allow_patterns限定必需文件、离线加载捕获完整性错误回退到本地;第二层用OfflineDownloadGuarddataclass 把离线判断/下载/回退收敛为单一守卫;第三层用 pytest 守「离线不下载」「完整性错误回退」「可选文件隔离」。
工程启示:离线/内网部署绝不能依赖运行时去 Hub 做完整性校验。正确姿势是「构建期用allow_patterns把必需文件拉齐打进镜像,运行期local_files_only+ 完整性错误回退」。把可选文件排除在完整性口径外,离线启动才稳。