PyInstaller 打包注意事项:为什么你的包会意外变大

PyInstaller 打包注意事项:为什么你的包会意外变大

类型:通用经验 + 匿名案例

适用:Python 桌面应用、带原生依赖(OpenCV / ONNX / 音视频)的项目

案例来源:某多媒体桌面工具的实战排障(已脱敏)

姊妹文档(项目内维护):docs/packaging-notes.md


1. 写在前面

用 PyInstaller 打出来的目录,有时比「心里预期」大一个数量级------例如从「几百兆」变成 1 GB 以上 。这往往不全是「依赖本来就大」,而是 打包策略把同一份东西装了两次,再叠上一些本不该进发行包的文件。

本文重点讲 体积问题 的来龙去脉,并给出一套可复用的检查方法。文中的真实案例已脱敏:产品名、仓库、路径、API 提供商均用泛称代替。


2. 匿名案例:从 1.3 GB 到约 800 MB 发生了什么

2.1 背景(脱敏)

Python 桌面多媒体工具(下称「本项目」)具备:

  • 本地 Web UI(pywebview)
  • 视频下载、抽帧、硬字幕 OCR、语音转写
  • 调用云端大模型 API 做内容生成(Key 由用户自行配置)

发行形态为 onedir :一个 exe + _internal 目录,整文件夹拷贝分发。

2.2 现象

阶段 发行目录总体积 用户感受
初版打包 约 1.25--1.35 GB 「怎么这么大?」
排障精简后 约 750--800 MB 仍偏大,但可解释

精简后仍无法压到百兆级------这是 业务能力决定的下限,后文会说明。

2.3 体积解剖(精简前)

以下为构建机上的 量级参考,非精确值:

组成部分 约占用 里面是什么
外置 libs/site-packages/ ~640 MB OpenCV、视频解码、ONNX Runtime、推理引擎、下载器等
外置 libs/models/ ~140 MB 离线语音模型(base 档)
外置 libs/bin/ ~84 MB 额外复制的 FFmpeg
PyInstaller _internal/ ~450--550 MB 与上面高度重叠的同一批库
UI、配置、业务规则等 <5 MB 可忽略

关键发现: 真正「多出来」的约 500 MB ,不是新功能,而是 重复打包


3. PyInstaller 体积从哪来:先建立正确心智模型

3.1 onedir 结构(PyInstaller 6.x 常见)

text 复制代码
<发行目录>/
├── YourApp.exe
├── _internal/          ← PyInstaller 收集的运行时(字节码、扩展模块、DLL)
├── (可选)外置资源目录
└── ...

很多人只盯着 exe 几十 MB,但 体积几乎都在 _internal 和外置资源里

3.2 分析阶段决定了「会带上谁」

PyInstaller 在 Analysis 阶段会:

  1. 从入口脚本做静态依赖追踪
  2. 读取 hiddenimports 强制纳入的模块
  3. 读取 pathex 扩展搜索路径,从更多 site-packages 里「发现」模块
  4. 执行 hook,可能拖入整棵依赖树(如 hook-cv2hook-PyQt

结论: spec 里多写一行 hiddenimports,发行包就可能多出 数百 MB

3.3 「运行时能 import」≠「磁盘上只有一份」

Python 的 sys.path 决定 先从哪里加载;并不自动删除其他路径上的副本。

若你采用「exe 壳 + 外置 libs」架构,却在 PyInstaller 里又把 libs 里的重型库打进 _internal,则:

  • 运行时:可能只用到外置 libs 那一份
  • 磁盘上:两份都在 → 用户/compress 看到的就是双倍

#mermaid-svg-Z1eqqJUnXfFjZfcM{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Z1eqqJUnXfFjZfcM .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Z1eqqJUnXfFjZfcM .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Z1eqqJUnXfFjZfcM .error-icon{fill:#552222;}#mermaid-svg-Z1eqqJUnXfFjZfcM .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Z1eqqJUnXfFjZfcM .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Z1eqqJUnXfFjZfcM .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Z1eqqJUnXfFjZfcM .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Z1eqqJUnXfFjZfcM .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Z1eqqJUnXfFjZfcM .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Z1eqqJUnXfFjZfcM .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Z1eqqJUnXfFjZfcM .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Z1eqqJUnXfFjZfcM .marker.cross{stroke:#333333;}#mermaid-svg-Z1eqqJUnXfFjZfcM svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Z1eqqJUnXfFjZfcM p{margin:0;}#mermaid-svg-Z1eqqJUnXfFjZfcM .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Z1eqqJUnXfFjZfcM .cluster-label text{fill:#333;}#mermaid-svg-Z1eqqJUnXfFjZfcM .cluster-label span{color:#333;}#mermaid-svg-Z1eqqJUnXfFjZfcM .cluster-label span p{background-color:transparent;}#mermaid-svg-Z1eqqJUnXfFjZfcM .label text,#mermaid-svg-Z1eqqJUnXfFjZfcM span{fill:#333;color:#333;}#mermaid-svg-Z1eqqJUnXfFjZfcM .node rect,#mermaid-svg-Z1eqqJUnXfFjZfcM .node circle,#mermaid-svg-Z1eqqJUnXfFjZfcM .node ellipse,#mermaid-svg-Z1eqqJUnXfFjZfcM .node polygon,#mermaid-svg-Z1eqqJUnXfFjZfcM .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Z1eqqJUnXfFjZfcM .rough-node .label text,#mermaid-svg-Z1eqqJUnXfFjZfcM .node .label text,#mermaid-svg-Z1eqqJUnXfFjZfcM .image-shape .label,#mermaid-svg-Z1eqqJUnXfFjZfcM .icon-shape .label{text-anchor:middle;}#mermaid-svg-Z1eqqJUnXfFjZfcM .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Z1eqqJUnXfFjZfcM .rough-node .label,#mermaid-svg-Z1eqqJUnXfFjZfcM .node .label,#mermaid-svg-Z1eqqJUnXfFjZfcM .image-shape .label,#mermaid-svg-Z1eqqJUnXfFjZfcM .icon-shape .label{text-align:center;}#mermaid-svg-Z1eqqJUnXfFjZfcM .node.clickable{cursor:pointer;}#mermaid-svg-Z1eqqJUnXfFjZfcM .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Z1eqqJUnXfFjZfcM .arrowheadPath{fill:#333333;}#mermaid-svg-Z1eqqJUnXfFjZfcM .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Z1eqqJUnXfFjZfcM .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Z1eqqJUnXfFjZfcM .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Z1eqqJUnXfFjZfcM .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Z1eqqJUnXfFjZfcM .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Z1eqqJUnXfFjZfcM .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Z1eqqJUnXfFjZfcM .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Z1eqqJUnXfFjZfcM .cluster text{fill:#333;}#mermaid-svg-Z1eqqJUnXfFjZfcM .cluster span{color:#333;}#mermaid-svg-Z1eqqJUnXfFjZfcM div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Z1eqqJUnXfFjZfcM .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Z1eqqJUnXfFjZfcM rect.text{fill:none;stroke-width:0;}#mermaid-svg-Z1eqqJUnXfFjZfcM .icon-shape,#mermaid-svg-Z1eqqJUnXfFjZfcM .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Z1eqqJUnXfFjZfcM .icon-shape p,#mermaid-svg-Z1eqqJUnXfFjZfcM .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Z1eqqJUnXfFjZfcM .icon-shape .label rect,#mermaid-svg-Z1eqqJUnXfFjZfcM .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Z1eqqJUnXfFjZfcM .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Z1eqqJUnXfFjZfcM .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Z1eqqJUnXfFjZfcM :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 典型体积陷阱
入口脚本
PyInstaller Analysis
_internal 含 OpenCV/ONNX/...
构建脚本 copy 外置 libs
libs 含同样依赖
发行目录 ≈ 2x


4. 包太大的六大原因(按优先级)

4.1 重复打包(本案主因,最常见也最容易忽视)

典型组合:

错误做法 后果
hiddenimports 列出 cv2onnxruntime、自定义 C 扩展包 全部进 _internal
pathex 指向 libs/site-packages 分析阶段主动「捞」重型库
构建后又 shutil.copytree(整个 libs) 外置再来一份

修复思路(通用):

  • hiddenimports 只保留引导层:UI 桥、业务包、确实无法自动发现的轻量模块
  • 对确定由外置 libs 提供的包,使用 excludes 显式排除
  • pathex 不要包含外置 site-packages(只保留项目源码根)
  • 外置 libs 用 staging 脚本精简复制,而非无脑整目录拷贝

本案精简后 _internal~500 MB → ~50 MB ,总包体下降约 40%


4.2 把「开发依赖」打进发行包

pip install --target libs/site-packages 准备离线依赖时,常会带入:

  • pipsetuptoolswheel
  • pygments、测试框架等

它们 运行时不需要,但每个占数 MB 到十余 MB。

建议: staging 时维护 EXCLUDE_TOP_LEVEL 名单,复制 site-packages 时过滤。


4.3 同功能资源重复(如双份 FFmpeg)

本案中外置 libs/bin/ffmpeg(~84 MB)与 imageio_ffmpeg 内置二进制功能重叠;下载模块已有回退逻辑,不必再复制 bin 目录

通用检查: 在发行目录里对 ffmpeg*.dll 同名文件做搜索,看是否多份。


4.4 模型与缓存被打进包

离线 AI 工具常见坑:

内容 是否应随包分发
生产所需的 一个 模型权重 是(按需选一个档位)
开发机预热的 多个 模型(tiny + base + ...) 否(除非产品承诺可切换)
Hugging Face / 训练框架 缓存、日志
构建机 output/、用户任务结果 绝不可

本案默认只 staging 一个 base 档模型;改用小档模型可再省 约 70--100 MB


4.5 hiddenimports 滥用:「怕缺就全写上」

遇到 ModuleNotFoundError 时,新手容易把整份 requirements.txt 写进 hiddenimports

正确顺序:

  1. 确认模块是否 懒加载(函数内 import)------ Analysis 可能本就不会打进包
  2. 确认是否应由 外置 libs 提供------修 sys.path,不要修 hiddenimports
  3. 仅对 确认缺失且体量小 的纯 Python 模块添加 hiddenimports
  4. 重型库用 excludes + 外置部署,而不是 hiddenimports

4.6 业务本身依赖重(不是配置 bug)

即便做到「只装一份」,本案 libs 仍有 约 700 MB,因为能力栈决定:

能力 依赖类型 体积特点
视频抽帧 原生解码库 数十 MB 级 DLL
图像 / OCR OpenCV + ONNX Runtime 百 MB 级
本地语音转写 推理引擎 + 权重文件 引擎 + 模型
媒体下载转码 下载器 + FFmpeg 近百 MB

这不是 PyInstaller 配置能「优化没」的,而是 产品形态 问题。要再缩小,只能:

  • 首次启动在线下载模型(bootstrap)
  • 重计算放云端,本地只留 UI
  • 砍掉 OCR 或本地转写等能力

5. 推荐架构:「轻壳 + 外置 libs」(适合重型依赖)

若项目必须离线跑 OpenCV / ONNX / 音视频栈,建议明确分层:

text 复制代码
_internal/     →  exe 引导、UI 桥、业务 Python 源码(目标:尽量 < 100 MB)
libs/          →  site-packages + models(接受它是大头)
外置 ui/config →  可独立更新、便于脱敏

5.1 启动时注入 sys.path(通用模式)

python 复制代码
import sys
from pathlib import Path

def configure_runtime() -> None:
    if getattr(sys, "frozen", False):
        root = Path(sys.executable).resolve().parent
    else:
        root = Path(__file__).resolve().parent.parent

    site_packages = root / "libs" / "site-packages"
    if site_packages.is_dir():
        path = str(site_packages)
        if path not in sys.path:
            sys.path.insert(0, path)

要点:

  • 冻结模式下 PROJECT_ROOTexe 目录为准,不要写死开发机路径
  • 在导入重型库之前 调用 configure_runtime()(可在包 __init__ 最早执行)

5.2 spec 文件对照表

配置项 重型依赖项目建议
hiddenimports 仅 UI 桥 + 业务包
excludes 列出所有外置提供的重型包名
pathex 仅项目根,不含外置 site-packages
datas 只打必要静态资源,模型走外置 libs
onefile 重型项目 不推荐(解压慢、杀毒误报、难排障)

5.3 excludes 示例(按技术栈裁剪)

python 复制代码
EXCLUDED_HEAVY_MODULES = [
  "cv2",
  "onnxruntime",
  "torch",
  "tensorflow",
  "faster_whisper",
  # ... 由外置 libs 提供的包名
]

包名须与 import 时一致;排除后务必保证外置目录在运行时可被 sys.path 找到。


6. 打包体积自查清单(发布前必做)

6.1 构建后看目录,不要只看 exe

bash 复制代码
# 示例:统计发行目录各一级子目录(Linux/macOS)
du -sh dist/YourApp/* | sort -h

# Windows 可在资源管理器属性中查看 _internal 与 libs 分别占用

健康信号(本案量级):

目录 精简后约 警报线
_internal 50--80 MB > 300 MB 很可能又把重型库打进去了
libs 600--750 MB 视功能而定;突然暴涨先查是否 copy 了缓存/多模型

6.2 在发行目录内搜「不该重复」的文件

  • ffmpeg:是否出现两次以上
  • cv2onnxruntime:是否同时在 _internallibs
  • model.bin:是否有多套模型目录

6.3 干净环境冒烟测试

  1. 未安装 Python 的机器解压发行目录
  2. 勿使用开发机上的 config(可能含 API Key
  3. 跑一条端到端任务,确认外置 libs 加载正常

若只有开发机正常、干净机 ModuleNotFoundError,优先查 sys.path 与外置 libs 是否随包分发完整,不要先把重型库加回 hiddenimports。

6.4 脱敏检查(对外分发必做)

要求
API Key / Token 默认配置为空;文档示例用 sk-****
用户生成内容 output/、日志、任务队列勿打入包
构建机路径 日志与截图不出现真实用户名、公司目录
第三方服务 文档用「兼容 OpenAI 的 API 端点」等泛称

7. 常见误区(体积相关)

误区 事实
「PyInstaller 打成 onefile 就更小」 onefile 是压缩打包,首次运行解压;总占用往往 更大
「hiddenimports 越多越好」 每多一个重型库,_internal 可能 +几十到几百 MB
「pip list 里有的都要带上」 开发工具、测试库、缓存不必进发行包
「包太大一定是 PyInstaller 的锅」 先区分 重复打包 vs 业务确实需要
「体积优化 = 删功能」 第一阶段往往是 去重,不是砍能力

8. 本案精简手段小结(可复用到其他项目)

手段 作用 本案约节省
重型库移出 hiddenimports,加入 excludes 消除 _internal 重复 ~450 MB
pathex 不再指向外置 site-packages 减少 Analysis 误收 间接
staging 过滤 pip/setuptools 等 去掉开发垃圾 ~25 MB
不复制冗余 bin/ffmpeg 去掉重复二进制 ~84 MB
只打一个语音模型档位 避免多模型叠加 每个模型 40--140 MB
小档模型可选 精度换体积 ~70--100 MB

未解决部分: 约 700 MB 的 libs 是 单一副本 下的能力成本,需产品层决策是否在线化。


9. 延伸阅读与工具

资源 说明
PyInstaller 官方文档 --- What to bundle 分析 / 收集机制
pyinstaller --exclude-module 命令行排除(spec 中 excludes 等价)
build/<name>/warn-<name>.txt 构建后查看未解析 import
pyinstaller --debug=imports 运行时追踪导入来源(排障)

10. 与项目内文档的关系

  • 本文 :面向「PyInstaller + 体积排障」的 通用写法,案例已脱敏,可对外分享经验。
  • docs/packaging-notes.md :同一案例的 项目内操作手册(含具体脚本名、命令、目录约定)。

若你维护类似「桌面 + 本地多媒体 + 云端 API」的 Python 应用,建议:

  1. 先按本文 第 6 节清单 做体积解剖
  2. 再对照项目内手册落地 spec 与 staging 脚本

11. 一句话总结

包太大,先看是不是装了两遍;再看是不是装了不该装的;最后才承认是业务真的需要这么大。

PyInstaller 不会自动帮你去重------外置 libs 与 _internal 的职责边界,必须在 spec 和构建脚本里写清楚。