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 阶段会:
- 从入口脚本做静态依赖追踪
- 读取
hiddenimports强制纳入的模块 - 读取
pathex扩展搜索路径,从更多 site-packages 里「发现」模块 - 执行 hook,可能拖入整棵依赖树(如
hook-cv2、hook-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 列出 cv2、onnxruntime、自定义 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 准备离线依赖时,常会带入:
pip、setuptools、wheelpygments、测试框架等
它们 运行时不需要,但每个占数 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。
正确顺序:
- 确认模块是否 懒加载(函数内 import)------ Analysis 可能本就不会打进包
- 确认是否应由 外置 libs 提供------修
sys.path,不要修 hiddenimports - 仅对 确认缺失且体量小 的纯 Python 模块添加 hiddenimports
- 重型库用 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_ROOT以 exe 目录为准,不要写死开发机路径 - 在导入重型库之前 调用
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:是否出现两次以上 - 搜
cv2、onnxruntime:是否同时在_internal与libs - 搜
model.bin:是否有多套模型目录
6.3 干净环境冒烟测试
- 在 未安装 Python 的机器解压发行目录
- 勿使用开发机上的
config(可能含 API Key) - 跑一条端到端任务,确认外置 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 应用,建议:
- 先按本文 第 6 节清单 做体积解剖
- 再对照项目内手册落地 spec 与 staging 脚本
11. 一句话总结
包太大,先看是不是装了两遍;再看是不是装了不该装的;最后才承认是业务真的需要这么大。
PyInstaller 不会自动帮你去重------外置 libs 与 _internal 的职责边界,必须在 spec 和构建脚本里写清楚。