☰
内网离线(Air-gapped)私有化大模型部署实战:没有外网、没有 pip、没有 huggingface-cli 的世界
2026/10/17 2:39:40 网站建设 项目流程

内网离线(Air-gapped)私有化大模型部署实战:没有外网、没有 pip、没有 huggingface-cli 的世界

本文面向内网隔离环境(医院、银行、制造业、军工)里的 LLM 落地。这类环境的特点是:不能访问公网、pip 装不了包、模型权重不能在线下载、连 nvidia 驱动都得离线打。文章给出完整的离线交付方案——依赖闭包冻结与 wheel 打包、模型权重的分片校验与断点续传、镜像与配置的离线导入、离线环境下的许可证与合规留痕、以及一套能在完全断网条件下自证的验收脚本。附三份可运行脚本与 7 条踩坑。

关键词:离线部署、内网私有化、Air-gapped、wheel 打包、权重分片校验、vLLM 离线安装、医疗内网、合规留痕

一、写在前面:为什么现在必须聊这件事

我做过一次印象深刻的内网交付。模型在开发环境跑得好好的,到了医院内网,第一步就卡住:pip install vllm报 DNS 解析失败。运维给的说法是「这台机器永远不会有外网」,不是「暂时不通」。接下来两天的工作量分布是这样的——写业务代码 4 小时,解决依赖和权重搬运 20 小时。

这就是内网部署的真实比例。公网环境里一句pip install掩盖掉的工程量,在内网会全部暴露出来:依赖的依赖、编译期需要的系统库、运行时需要的 CUDA runtime、模型权重里隐含的 tokenizer 文件、甚至 Python 版本与 glibc 的匹配。任一环节缺失,你面对的报错往往是「某个 .so 找不到」这种信息量极低的提示。

更麻烦的是合规那条线。医疗、金融这类场景不只是「不能联网」,还要求可追溯:这个模型从哪来、什么版本、校验值是多少、谁在什么时候导入的、跑在什么硬件上。这些在公网环境里随手能查的元信息,在内网必须离线固化。

本文把内网 LLM 部署拆成五段:依赖闭包冻结 → 交付物打包 → 权重校验与搬运 → 离线安装与驱动 → 验收自证。每一段都给出可执行的命令与脚本,目标是让一次内网交付从「两天踩坑」压缩到「半天照做」。

二、技术背景与现状梳理

先把「离线」的几种程度区分开,因为不同隔离级别对应的方案完全不同。

完全隔离(Air-gapped):物理上不与任何外部网络连通,介质交换靠移动硬盘或刻录光盘。医院核心生产网、部分金融生产环境属于这一档。本文主要针对这一档。

单向导入(Data diode / 单向光闸):可以从外向内单向传数据,不能从内向外。医院与银行的边界区常见。这一档稍微好办一点——至少你可以在外侧准备好一切再推入,但依然不能在内侧跑pip install去装公网包。

受限代理(Proxy-whitelist):能通过代理访问白名单域名(比如只放行企业内部 PyPI 镜像)。这一档工程量最小,本文的方案对它属于过度设计,但其中的校验与留痕部分依然有用。

2026 年的一个新变化是模型体积。主流可用模型从 2024 年的 7B/13B 涨到现在的 30B~70B 区间,权重文件从 15 GB 涨到 140 GB 以上。这让「搬运」这件事从「一个 U 盘」变成「多分片 + 校验 + 断点续传」的工程问题——移动介质在拷贝 100 GB 数据时出错是常态,没有校验和你会得到一个「看起来下完了但加载报错」的权重目录。

另一个变化是推理框架对系统依赖的要求变重。vLLM 这类框架依赖特定版本的 CUDA、PyTorch、以及一批需要编译的 CUDA kernel。在公网环境用预编译 wheel 就能解决,在内网必须提前把匹配的 wheel 全部备齐,包括那些只在特定架构上才会被安装的条件依赖。

三、核心原理拆解

3.1 依赖闭包:离线交付的数学问题

pip install X在联网时是一个递归求解过程:pip 解析 X 的元数据,找到满足约束的版本组合,下载、构建、安装。离线场景下这个过程必须被「预先求解」并物化成一组文件。

关键难点在于平台标记(environment markers)。一个包的依赖里常见这样的写法:nvidia-cublas-cu12; platform_system == "Linux" and platform_machine == "x86_64"。如果你在外侧用 macOS 或 Windows 打包,这些依赖根本不会被解析出来,到了内侧 Linux 机器上就缺件。

正确做法是在与目标环境同构的机器上冻结依赖近似切。最可靠的途径是准备一台与内网同操作系统、同架构、同 Python 版本的「打包机」,在其上完成安装并导出。这里指出的核心公式是:

闭包(S) = 传递闭包({resolve(d) | d ∈ 直接依赖(S)})

其中resolve必须携带目标环境的 markers 求解,否则闭包不成立。实务里最省事的验证方式是:把打包机也断网,用你导出的 wheel 目录重装一遍,装得上才算闭包完整。

3.2 权重搬运:分片、校验、断点续传

模型权重目录的结构大致是:若干*.safetensors分片 +config.json+tokenizer.json/tokenizer_config.json/special_tokens_map.json+generation_config.json。很多人只搬了 safetensors,到内侧加载时报「tokenizer 文件缺失」,或者能加载但分词行为与预期不符——因为 tokenizer 的版本没跟着。

搬运协议要解决三个问题。完整性:用 SHA256 逐文件校验,并在内侧用同一份清单核对。原子性:不能出现「部分文件已就位」的状态被误认为可用,做法是先在临时目录凑齐再原子 rename。可恢复:100 GB 传输中断后要能续,不能从头再来。

分片校验的数学很朴素,但要防一个坑:清单文件本身也必须被校验。如果只校验分片、清单是明文复制过去的,攻击面或损坏面就没被覆盖。做法是对整份清单算一个 sha256,把那个值写进人工核对的交付单(可以打印在纸上)。

3.3 离线安装的层级依赖

内网安装的依赖树是四层:系统层(glibc、libnuma、libibverbs)→ 驱动层(NVIDIA driver + CUDA runtime)→ Python 层(wheel)→ 应用层(模型与配置)。任何一层缺失都会以「上一层报错」的形式呈现。

最容易被忽略的是系统层。比如某个推理框架编译时链接了libnuma,本地有、内网没有,运行时报libnuma.so.1: cannot open shared object file。这类问题的排查工具是ldd,而ldd本身可能也需要你确认它在内网可用。

驱动层的一个特殊点是驱动与 CUDA runtime 的版本约束。宿主机驱动有向下兼容的最低 CUDA runtime 版本要求,如果你带进去的 PyTorch 编译时针对的 CUDA 版本高于驱动支持的版本,会得到CUDA driver version is insufficient for CUDA runtime version。内网调驱动成本极高(可能要走审批),所以打包前必须确认驱动版本,并据此选 PyTorch wheel 的 CUDA 变体。

3.4 合规留痕:把元信息做成离线清单

离线环境没有在线校验的能力,所以留痕必须是自包含的。一份合格的交付清单至少要包含:模型标识与来源说明、权重文件的 SHA256 清单及其清单哈希、依赖 wheel 的完整列表与版本、目标环境的驱动/CUDA/Python 版本、导入时间与操作人、以及验收脚本的输出快照。

这份清单的价值不只是合规。当三个月后模型出问题需要回溯,或者要复现同一环境,这份清单是唯一能回答「当时到底装了什么」的东西。

四、环境与依赖准备

打包机(外侧)与目标机(内侧)需要满足同构条件。

项目打包机要求目标机要求不一致后果
操作系统与目标机同发行版同大版本内网生产系统glibc 不匹配导致 so 加载失败
CPU 架构x86_64 / aarch64 一致同上wheel 平台标记不匹配
Python与目标机完全同小版本内网预装ABI 不兼容,C 扩展无法导入
GPU 驱动参考目标机实际版本内网实际驱动CUDA runtime 版本报错
pip 版本≥ 24.0任意元数据解析差异

外侧打包机准备:

# 外层打包机(需要外网)python3.11-mvenv /opt/pack-venv /opt/pack-venv/bin/pipinstall--upgrade"pip>=24.0"wheelmkdir-p/opt/deliver/wheels

内侧目标机准备(管理员执行,通常已有):

  • 确认 Python 已安装且版本一致:python3.11 --version
  • 确认驱动:nvidia-smi(记录驱动版本与 CUDA 版本)
  • 确认系统库:ldconfig -p | grep -E "libnuma|libibverbs"

五、完整实操

5.1 第一步:冻结依赖闭包并下载 wheel

#!/usr/bin/env bash# pack_deps.sh —— 在外侧打包机执行,产出完整依赖闭包set-euopipefailPY=/opt/pack-venv/bin/pythonOUT=/opt/deliver/wheelsREQ=requirements-lock.txt# 1) 还原锁定版本(推荐用 pip-tools / uv 生成 lock)$PY-mpipinstall-r"$REQ"# 2) 导出真实安装的完整闭包(含 markers 求解后的结果)$PY-mpip freeze--all>installed-frozen.txt# 3) 下载所有 wheel(含依赖),强制二进制$PY-mpip download\--dest"$OUT"\--only-binary=:all:\--require-hashes=false\-rinstalled-frozen.txt# 4) 对少数只能用 sdist 的包,单独下载源码包$PY-mpip download--dest"$OUT"--no-binary=:all:-rsdist-only.txt||trueecho"wheel 数量:$(ls-1"$OUT"|wc-l)"sha256sum"$OUT"/*>wheels.sha256

第 2 步的pip freeze --all是关键——它输出的是已解析的完整列表,而不是带约束的原始 requirements。第 3 步的--only-binary=:all:强制走二进制,避免内侧因为缺编译工具而失败。

5.2 第二步:生成权重清单并在内侧校验

#!/usr/bin/env python3"""verify_weights.py —— 生成/校验模型权重清单,支持断点续传校验。"""from__future__importannotationsimporthashlibimportjsonimportsysfrompathlibimportPath CHUNK=8*1024*1024defsha256_of(path:Path)->str:h=hashlib.sha256()withpath.open("rb")asf:whilechunk:=f.read(CHUNK):h.update(chunk)returnh.hexdigest()defbuild_manifest(model_dir:Path)->dict:files={}forpinsorted(model_dir.rglob("*")):ifp.is_file():files[str(p.relative_to(model_dir))]={"size":p.stat().st_size,"sha256":sha256_of(p),}payload=json.dumps(files,sort_keys=True,ensure_ascii=False)return{"root":model_dir.name,"file_count":len(files),"total_size":sum(v["size"]forvinfiles.values()),"manifest_sha256":hashlib.sha256(payload.encode()).hexdigest(),"files":files,}defverify(model_dir:Path,manifest:dict)->tuple[bool,list[str]]:problems:list[str]=[]forrel,metainmanifest["files"].items():target=model_dir/relifnottarget.exists():problems.append(f"MISSING{rel}")continueiftarget.stat().st_size!=meta["size"]:problems.append(f"SIZE{rel}期望{meta['size']}实际{target.stat().st_size}")continueifsha256_of(target)!=meta["sha256"]:problems.append(f"HASH{rel}")return(notproblems),problemsif__name__=="__main__":mode=sys.argv[1]# build | verifyroot=Path(sys.argv[2])# 模型目录mpath=Path(sys.argv[3])# 清单路径ifmode=="build":m=build_manifest(root)mpath.write_text(json.dumps(m,ensure_ascii=False,indent=2),encoding="utf-8")print(f"files={m['file_count']}total={m['total_size']}"f"manifest_sha256={m['manifest_sha256']}")elifmode=="verify":m=json.loads(mpath.read_text(encoding="utf-8"))ok,probs=verify(root,m)print(f"manifest_sha256={m['manifest_sha256']}")print("PASS"ifokelse"FAIL")forpinprobs:print(" ",p)sys.exit(0ifokelse1)else:raiseSystemExit("usage: verify_weights.py build|verify <model_dir> <manifest>")

外侧跑build,把manifest.json连同模型目录一起拷入;内侧跑verify。manifest_sha256单独打印出来记进交付单,防止清单本身被篡改。

5.3 第三步:内侧离线安装

#!/usr/bin/env bash# install_offline.sh —— 在内侧目标机执行,全程不触网set-euopipefailWHEELS=/mnt/delivery/wheelsVENV=/opt/llm/venvMODEL=/mnt/delivery/modelMANIFEST=/mnt/delivery/model_manifest.json# 0) 环境自检python3.11--versionnvidia-smi --query-gpu=name,driver_version,memory.total--format=csv ldconfig-p|grep-E"libnuma|libibverbs"||echo"WARN: 缺系统库"# 1) 校验 wheel 完整性cd"$WHEELS"&&sha256sum-cwheels.sha256# 2) 建 venv 并离线安装python3.11-mvenv"$VENV""$VENV/bin/python"-mpipinstall--upgrade\--no-index --find-links="$WHEELS"pip wheel"$VENV/bin/python"-mpipinstall\--no-index --find-links="$WHEELS"-rinstalled-frozen.txt# 3) 校验模型权重"$VENV/bin/python"/opt/llm/verify_weights.py verify"$MODEL""$MANIFEST"# 4) 冒烟测试(离线加载 + 一次生成)"$VENV/bin/python"-<<'PY' import os os.environ["HF_HUB_OFFLINE"] = "1" os.environ["TRANSFORMERS_OFFLINE"] = "1" from vllm import LLM, SamplingParams llm = LLM(model="/mnt/delivery/model", tensor_parallel_size=int(os.environ.get("TP", "1"))) out = llm.generate(["你好,请用一句话介绍你自己。"], SamplingParams(max_tokens=32)) print(out[0].outputs[0].text) PY

--no-index是离线安装的核心开关,它让 pip 完全不访问网络,只在--find-links指定的目录里找包。同时把HF_HUB_OFFLINE=1、TRANSFORMERS_OFFLINE=1设上,避免框架在启动时尝试联网校验。

5.4 第四步:交付清单生成器

#!/usr/bin/env python3"""delivery_manifest.py —— 生成自包含的离线交付清单(合规留痕用)。"""from__future__importannotationsimporthashlibimportjsonimportplatformimportsubprocessimporttimefrompathlibimportPathdefsh(cmd:str)->str:try:returnsubprocess.run(cmd,shell=True,capture_output=True,text=True,timeout=30).stdout.strip()exceptExceptionase:# noqa: BLE001returnf"<error:{e}>"deffile_sha(path:Path)->str:h=hashlib.sha256()withpath.open("rb")asf:forchunkiniter(lambda:f.read(8<<20),b""):h.update(chunk)returnh.hexdigest()defmain(pkg_dir:Path,out:Path)->None:wheels=sorted(pkg_dir.glob("*.whl"))manifest={"generated_at":time.strftime("%Y-%m-%dT%H:%M:%S%z"),"host":{"system":platform.system(),"release":platform.release(),"machine":platform.machine(),"python":platform.python_version(),},"gpu":sh("nvidia-smi --query-gpu=name,driver_version,memory.total ""--format=csv,noheader"),"wheel_count":len(wheels),"wheels_sha256":{w.name:file_sha(w)forwinwheels},}payload=json.dumps(manifest,sort_keys=True,ensure_ascii=False)manifest["package_sha256"]=hashlib.sha256(payload.encode()).hexdigest()out.write_text(json.dumps(manifest,ensure_ascii=False,indent=2),encoding="utf-8")print(f"交付清单已生成:{out}")print(f"package_sha256 ={manifest['package_sha256']}<= 请抄进交付单")if__name__=="__main__":importsys main(Path(sys.argv[1]),Path(sys.argv[2]))

5.5 第五步:验收清单

交付现场逐条走:

序号检查项通过标准命令
1Python 版本与打包机完全一致python3.11 --version
2驱动版本≥ 打包时记录值nvidia-smi
3wheel 完整性全部 OKsha256sum -c wheels.sha256
4依赖安装无 Missingpip check
5权重校验PASSverify_weights.py verify
6离线加载能加载并生成冒烟脚本
7断网确认无外联请求拔网线重跑第 6 步

第 7 项是很多人漏掉的:拔掉网线重跑冒烟测试。因为有些框架在联不通外网时会静默降级到某个缓存路径,你不拔网线就测不出真实断网行为。

六、结果对比与数据分析

下面是同一套模型(30B 级,INT4 量化权重约 18 GB)在两种交付方式下的对比。方案 A 是「想到什么搬什么」的手工方式,方案 B 是本文的清单化方式。

维度方案 A(手工)方案 B(清单化)改善
首次交付耗时约 2 人日约 0.5 人日约 4x
权重搬运返工次数2~3 次0 次—
因系统库缺失失败2 次0 次—
可复现性无法复现清单可完整复现质变
合规留痕无自包含清单质变

搬运耗时的量级参考(USB 3.2 移动硬盘、持续写):

数据量理论耗时实际耗时校验耗时
18 GB(30B INT4)约 60 s3~6 min约 40 s
70 GB(70B INT4)约 4 min12~25 min约 3 min
140 GB(70B FP16)约 8 min25~50 min约 6 min

实际耗时普遍是理论的 3~5 倍,原因是小文件(tokenizer 目录下十来个 json)与大分片混传时,文件系统的元数据开销会拖慢整体。优化手段是把整个模型目录打成一个 tar(不压缩,避免 CPU 成为瓶颈),单文件顺序写,速度快很多。

七、踩坑记录与排查手册

7.1 pip download 捞不全依赖,内侧缺件

现象:外侧pip download成功,内侧--no-index安装报No matching distribution found for nvidia-cudnn-cu12。
定位:检查外侧包的元数据,发现该依赖带platform_system == "Linux"的 marker,而外侧打包机是 Windows。
原因:pip 在解析依赖时会按当前平台求值 marker,跨平台打包必然漏件。
解决:打包机必须与目标机同平台。无法做到时,用pip download --platform manylinux2014_x86_64 --python-version 311 --only-binary=:all:显式指定目标平台,但这种方式对 sdist 包无效,且容易漏传递依赖,仅作兜底。

7.2 权重拷完却加载失败

现象:内侧模型目录文件数看起来对,from_pretrained报Error no file named pytorch_model.bin or model.safetensors。
定位:ls发现 safetensors 分片是model-00001-of-00004.safetensors到00003,第 4 片丢了;同时model.safetensors.index.json里的分片总数写的是 4。
原因:拷贝中断且没有校验,缺片但目录看起来「像完整的」。
解决:强制走verify_weights.py verify,缺任何文件直接 FAIL;搬运协议改为 tar 单文件 + 落地解包。

7.3 驱动版本不足导致 CUDA runtime 报错

现象:冒烟测试报CUDA driver version is insufficient for CUDA runtime version。
定位:外侧准备的 PyTorch 是 cu124 变体,内侧驱动只支持到 CUDA 12.1。
原因:驱动兼容性有下限约束,runtime 高于驱动支持上限会直接报错。
解决:打包前先在内侧确认驱动版本,据此选择 PyTorch 的 CUDA 变体;如果驱动确实过旧且无法升级,改用 CPU 推理或换用对旧驱动更友好的推理后端。

7.4 系统库缺失,报错指向框架而非根因

现象:ImportError: libnuma.so.1: cannot open shared object file: No such file or directory,看起来像 vLLM 的问题。
定位:ldd $(python -c "import vllm._C" 2>/dev/null; echo)逐层找未满足的 so。
原因:编译期链接的系统库在目标机缺失,Python 层无法感知。
解决:把系统层依赖(libnuma、libibverbs、libgomp 等)纳入交付清单,用 rpm/deb 包一并带进去;验收脚本第 3 步加ldconfig -p检查。

7.5 框架尝试联网导致启动卡死

现象:离线加载时进程卡住几分钟后超时,日志里没有明确错误。
定位:抓包或看框架日志,发现它在尝试访问 huggingface 校验 etag。
原因:没有设置离线环境变量,框架默认走在线校验路径,DNS 超时被放大。
解决:启动前设HF_HUB_OFFLINE=1、TRANSFORMERS_OFFLINE=1,并确保~/.cache/huggingface里没有半成品目录(半成品会触发重新下载逻辑)。

7.6 交付清单的哈希被人当成「可选」

现象:验收时运维只看文件数,不跑哈希校验,三个月后模型加载偶发失败。
定位:重新校验发现一个 safetensors 分片的 SHA256 与清单不符,字节数一致但内容被改动(可能是介质问题)。
原因:只看文件大小和数量无法发现位翻转级别的损坏。
解决:把哈希校验写进验收脚本并且作为强制门禁,交付单上手工抄录manifest_sha256供双人核对。

7.7 tokenizer 版本与模型不匹配

现象:模型能加载,但生成的中文出现异常的字符切分,或者停止符不生效。
定位:对比内侧与外层的tokenizer_config.json,发现内侧那份是模型仓库的旧版本,缺少chat_template字段。
原因:搬运时只更新了 safetensors,tokenizer 相关 json 沿用了上一次交付的旧文件。
解决:权重目录整体替换而不是增量覆盖;把 tokenizer 相关文件纳入清单校验范围;验收时增加一个「tokenizer 往返一致性」测试(编码再解码应还原原文)。

八、优化方案与进阶打法

把交付做成可复制的产物。一次内网交付最贵的不是这次,是下次。建议把打包流程脚本化,产出物固定为一个目录:wheels/(含校验)、model/(含清单)、install.sh、verify.sh、delivery_manifest.json。下次交付只改版本,流程不动。

用 tar 代替目录拷贝。100 GB 级数据用 tar 单文件顺序写,比多文件拷贝快 2~3 倍,且天然具备「要么完整要么失败」的原子性。解包前先校验 tar 的 sha256,解包后再逐文件校验。

把离线环境变量写进启动脚本而不是靠人记。HF_HUB_OFFLINE、TRANSFORMERS_OFFLINE、VLLM_NO_USAGE_STATS这类变量应该在 systemd unit 或启动脚本里固化,避免不同人不同配法导致的「玄学差异」。

建内网自己的 wheel 源。如果内网机器数量超过 5 台,搭一个内网 PyPI 镜像(用 devpi 或 nginx 静态目录)是划算的。它把「每次拷贝 wheel 目录」变成「一次建源、后续 pip 直连内网源」,工程体验接近公网。

权重按需拆分。如果内网有多套模型需求,不必每套都全量搬运。把公共的 tokenizer、config 抽出来做共享,只搬差异化的权重分片。

验收脚本要能自证。最好的验收是让脚本输出一个带时间戳的结果文件,附在交付单后面。这样「验过了」有据可查,而不是口头说「我试过没问题」。

九、适用场景与选型建议

该用这套方案的情况:内网完全隔离或只有单向导入通道;部署机器数量在 3 台以上(摊薄打包成本);有合规留痕要求;模型体积在 10 GB 以上。

不该用这套方案的情况:内网有可用的企业 PyPI 镜像(直接pip install更简单);只部署一次且模型很小(几 GB,手工拷也行);开发测试环境(没必要上清单化流程);上层已经有成熟的离线交付平台(重复建设)。

一个需要权衡的点是量化与体积。内网搬运 140 GB 的 FP16 权重很痛苦,改用 INT4 量化能把体积压到 35 GB 左右,搬运时间和存储都大幅下降。代价是精度损失,需要在内网环境用业务数据做一次效果评估。对医疗这类对准确性敏感的场景,这个评估不能省。

十、总结与展望

内网离线部署的难度不在技术栈,而在把公网环境里被隐藏的依赖关系显式化。你需要的不是更聪明的方法,而是一套笨但可靠的清单:依赖闭包、文件哈希、环境快照、验收记录。这套东西不产生任何业务价值,但它是所有业务价值能被交付的前提。

三件事值得今天就固化:把打包流程脚本化成固定产物目录;把哈希校验做成强制门禁而不是可选项;把离线环境变量写进启动配置。这三条能挡掉本文列出的绝大多数故障。

往后看,内网交付的趋势是与供应链安全合流——模型的来源校验、权重是否被篡改、依赖是否含已知漏洞,这些在内网都必须离线完成。目前大量工具仍然假设有网,这个落差会持续一段时间,也是私有化交付工程师的价值所在。

参考素材与延伸阅读

  • pip — Installing Packages from local and offline sources — pip 官方离线安装文档,持续更新
  • Hugging Face — Offline mode and cache management — 官方缓存与离线模式说明,2026-09
  • vLLM — Deployment and installation requirements — vLLM 官方安装与依赖文档,2026-08
  • NVIDIA — CUDA compatibility for driver and runtime versions — 驱动与 runtime 兼容矩阵,2026 版
  • Kubernetes — Air-gapped clusters and offline image distribution — 离线集群实践参考,2026-06
  • SLSA — Supply chain levels for software artifacts — 供应链完整性框架,用于交付留痕设计

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

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

立即咨询