小许那天接手了一个离职同事的机器学习项目。README里只有一句话:“先 conda install 环境,缺什么再 pip install。”
他照着做,conda install 了主框架,又pip install了一个 PyPI 上才有的预处理库。一切正常,模型训练跑通,指标也复现了。他觉得自己运气不错。
两周后,他想给项目加一个新依赖。随手跑了一次 conda install,终端开始疯狂输出:
The following packages will be SUPERSEDED by a higher-priority channel:
numpy conda-forge/noarch → pkgs/main/linux-64
...
装完之后,import numpy 直接报 ImportError,提示二进制接口不兼容。他回滚了版本,又尝试 pip install,结果另一个之前正常的库也开始报错。
小许对着屏幕发愣:明明只是装了一个新包,为什么整个环境塌了?
同事留下的那句“先conda,再pip”,听起来像一个可以随意拼接的工作流。但pip和conda不是“两个可以互相替代的安装命令”,它们管理环境的方式从根上就不同。混着用,就是在两个互不知情的系统之间来回改同一套文件。
先看一个最直观的对比
同样装一个包,两者的行为完全不同。
pip install numpy
pip 从 PyPI 下载 numpy 的 wheel 包或源码包,装进当前激活的 Python 环境。如果当前没有激活任何虚拟环境,它就装进系统 Python。它不关心 numpy 底层依赖的 BLAS、LAPACK 这些 C/Fortran 数学库从哪来——那些必须由你的操作系统、或者某个预编译的 wheel 自己解决。
conda install numpy
conda 从配置的 channel(通常是 conda-forge)解析出一组二进制包,不仅包括 numpy 本身,还包括它依赖的 C 库、数学库、甚至 Python 解释器本身。所有组件被当作一个整体来求解,一起安装,一起记录。
用一个生活场景来理解。
pip 像一个只负责装家具的工人。你告诉他“把这张桌子搬进来”,他就搬桌子。桌子腿需要螺丝,螺丝需要扳手,这些工具从哪来,他不管——你自己得有,或者桌子出厂时恰好自带了一套适配你这个房间的螺丝。
conda 像一个整屋交付的装修队。你说“我要一个能工作的书房”,他不仅搬桌子,还把地板、电路、墙面、甚至房间本身的尺寸都一起考虑了。所有东西在进场之前就已经确认过彼此能兼容。
区别一目了然:一个只管上层组件,一个连底座一起管。
坑一:pip 不管非 Python 依赖,conda 管
这是两者最根本的分工差异。
pip 安装的是Python 包。如果这个包有 C 扩展,pip 会优先找预编译好的 wheel;找不到 wheel,它就回退到从源码编译,这时候你本机必须装有 C 编译器、对应的头文件、以及底层库。
pip install psycopg[c] # 从源码编译,需要 PostgreSQL 客户端库和编译工具
conda 安装的是二进制包,包里已经包含了编译好的 C 扩展和它依赖的非 Python 库。你不需要本机有编译器,也不需要提前装好底层 C 库。
conda install -c conda-forge psycopg # 二进制包,直接可用
这个差异在数据科学和机器学习场景下是决定性的。PyTorch 依赖 CUDA 工具链,NumPy/SciPy 依赖 MKL 或 OpenBLAS,GDAL 依赖一堆地理空间 C 库。用 pip 装这些包,你面对的可能是漫长的源码编译、缺失的头文件、以及和系统已有库的版本冲突。用 conda,这些底层依赖被打包在二进制里,conda 的求解器会把它们和 Python 包放在同一个解空间里一起匹配。
小许的项目里,conda install 维护的是一套包含底层数学库的 numpy 二进制;pip install 后来引入的包可能悄悄替换了 site-packages 里的某些文件。两者对“numpy 应该依赖哪个 BLAS”的假设不一致,ImportError 只是最后暴露出来的症状。
坑二:依赖解析的“视野”完全不同
pip 的依赖解析器(20.3 引入的回溯解析器)只看Python 包层面的版本约束。它会把 peach==1.0 和 apple==2.0 的依赖放在一起尝试,如果发现 peach 要求 banana==3.0、apple 要求 banana==2.0,它会报 ResolutionImpossible。这个机制本身是有效的,但它能看到的“世界”仅限于 PyPI 上的 Python 包。
conda 使用基于 libsolv 的 SAT 求解器(现代 conda 和 mamba 的默认方案)。它求解的变量不仅是 Python 包,还包括 Python 解释器版本、C 库、编译器运行时、CUDA 驱动等非 Python 组件。所有约束被翻译成逻辑子句,求解器寻找一组同时满足所有组件的赋值方案。
这个差异带来的后果是:pip 在它能看到的世界里可能是“一致”的,但在整个环境的真实状态下并不一致。 pip 不知道 conda 已经安装的 numpy 二进制链接的是哪个 BLAS,也不知道 conda 记录的 Python 版本和某个 C 扩展的 ABI 是否匹配。它在自己的视野里做最优解,但这个解可能和 conda 维护的底层状态冲突。
conda 的求解器虽然更“重”、更慢,但它的视野覆盖了环境里真正需要共存的所有组件。对于纯 Python 项目,这个优势不明显;对于有二进制依赖的栈,它是环境能站住的前提。
坑三:混用会制造“环境认知分裂”
这是小许遇到的核心问题,也是 conda 官方反复警告的“environmental damage”。
conda 在安装包时,会把每一个已安装包的信息写进环境里的 conda-meta 目录。这是 conda 对“这个环境里到底有什么”的唯一记录。下次 conda install 或 conda update 时,求解器读取的是这份记录,而不是去扫描 site-packages 里的实际文件。
pip 安装包时,**不会更新 conda-meta**。它只把文件写进 site-packages,然后留下自己的 *.dist-info 元数据。对 conda 来说,这些 pip 装进来的包是“不存在的”。
于是环境出现了两个互相矛盾的“事实”:
- conda 的认知:环境里只有我记录的那些包,版本是 X。
- 磁盘上的现实:
site-packages里的文件已经被 pip 改过,某些包的版本变成了 Y。
当 conda 下一次尝试修改环境时,它基于一份过时的认知做决策。它可能认为某个依赖关系仍然成立,但实际上 pip 已经把它破坏了。错误可能延迟几天甚至几个月,在你安装、更新或删除一个看起来完全无关的包时突然爆发。
小许的 conda install 触发了重新求解,而 conda 的求解依据是一份没有反映 pip 改动的记录。结果就是“SUPERSEDED”——conda 按自己的认知覆盖了 pip 留下的文件,底层二进制被换成了一个和现有 Python ABI 不匹配的版本。
坑四:环境隔离的“重量”不同,但隔离本身不是免费的
pip 本身不创建环境。它依赖 venv 或 virtualenv 来提供隔离。python -m venv .venv 创建的环境很轻,本质上是独立的 site-packages 目录加上指向系统 Python 解释器的链接。磁盘占用小,启动快,但隔离的边界仅限于 Python 包层面。系统层面的 C 库、CUDA 驱动、Python 解释器版本,仍然共享。
conda 的环境是内置的、更重的隔离。每个 conda 环境可以拥有自己的 Python 解释器、自己的 C 库、自己的二进制工具链。conda 通过包缓存和硬链接来降低磁盘开销:同一个包在多个环境之间共享时,文件只有一份,环境目录里只是硬链接。但这种共享依赖文件系统支持硬链接,跨设备或跨文件系统时会退化为复制,环境体积会显著增加。
隔离的“重”本身不是缺点。对于需要固定 CUDA 版本、固定 Python ABI、固定 BLAS 实现的项目,这种重量级隔离是必要的。问题在于:pip 在一个 conda 环境里安装时,它安装的是“Python 包层面的隔离”,并不尊重 conda 环境在二进制层面的边界。 你以为你在同一个隔离环境里操作,实际上两个工具对“隔离环境”的定义并不一致。
那到底该怎么选?
判断标准不是“哪个更好”,而是“你的依赖里有没有非 Python 的东西”。
纯 Python 项目,依赖都能在 PyPI 上以 wheel 形式拿到,不涉及 C 扩展、CUDA、系统级库:pip + venv 是标准选择。轻量,和 Python 官方工具链一致,Docker 镜像小,部署简单。
数据科学、机器学习、科学计算,依赖链里有 BLAS、CUDA、GDAL、R 互操作、或需要特定编译器运行时:conda(或者更快的 mamba)是更省心的选择。它把底层二进制和 Python 包放在同一个求解空间里,避免了 pip 层面无法解决的版本漂移。
混合场景,conda 环境里确实需要某个只在 PyPI 上有的纯 Python 包:可以混用,但必须遵守顺序和边界。先 conda 装完所有 conda 能提供的包,最后再用 pip 补纯 Python 的缺口。安装 pip 包时用 python -m pip,确保装进当前激活的 conda 环境,而不是别的 Python 解释器。同一个包绝对不要既用 conda 装又用 pip 装——那是冲突最高发的区域。
需要诚实地说:即使遵守了“先 conda 后 pip”,混用仍然是一种脆弱的、手工维护的平衡。conda 社区正在推进 conda-pypi 项目(conda 26.5.0 起可选),让 conda 的求解器原生理解 PyPI 上的纯 Python wheel,从而把 pip 这一步纳入同一个求解过程,不再制造“conda-meta 不知道的包”。在这个方案成熟之前,混用的最佳实践本质上是尽量缩小 pip 的使用范围,并且接受环境可能需要在某个时刻重建。
回到小许的项目
小许最终的解决方案不是继续修补那个环境,而是重建。
他把项目依赖分成两层:conda 能提供的(numpy、pytorch、以及它们的底层二进制依赖)全部写进 environment.yml;只在 PyPI 上有的纯 Python 包,作为 pip: 节放在 environment.yml 的最后,让 conda 先建完底座,再由 pip 补最上层的纯 Python 部分。
name: ml-project
dependencies:
- python=3.11
- numpy
- pytorch
- pip
- pip:
- some-pypi-only-package
他后来在文档里补了一条:“conda 管的是一整个房间,pip 管的是房间里的一件家具。你可以往conda装好的房间里再搬一件pip的家具,但不要让pip去动房间的地板和电路。 如果某个包conda有,就用conda;只有conda确实没有、而且确认是纯Python包时,才用pip,并且放在最后装。”
pip 和 conda 的区别,不在于谁“更强”。它们解决的是不同层级的问题。pip 是 Python 包安装器,conda 是跨语言的环境管理器。把环境管理器的工作交给包安装器,或者反过来,就会得到小许那种“能跑,但不知道什么时候会塌”的环境。