3. 依赖类型与依赖图

在科学计算环境中,“装了什么”往往不是最关键的问题,真正决定环境能否复现、能否迁移的是“谁依赖谁、依赖在什么阶段生效”。同一个包名,在不同语境下可能扮演完全不同的角色:它可能是你显式写进配置文件的直接依赖,也可能是被别的包悄悄拉进来的传递依赖;它可能只在编译扩展时需要,也可能在程序运行时必须存在。把这些角色区分清楚,并用依赖图把它们的关系固定下来,是环境迁移前最重要的一项建模工作。

五类依赖的判定标准

直接依赖是用户在环境声明文件中显式列出的包,例如 environment.yml 的 dependencies 列表或 requirements.txt 中的条目。它表达的是“我明确要求这个环境具备的能力”。传递依赖则是直接依赖的依赖,由求解器自动引入,用户通常不直接书写。二者的边界不是包本身的属性,而是“是否被显式声明”——numpy 在某个项目里是直接依赖,在另一个只装了 pandas 的项目里就只是传递依赖。

可选依赖(extra)是包作者声明为“非默认安装”的功能扩展,例如 pip install "scanpy[leiden]" 中的 leiden 组。它只在启用对应功能时才需要,缺失时主功能仍可运行,但相关代码路径会报错或降级。构建依赖指构建阶段(编译 C/Fortran 扩展、生成 Cython 代码、打包 wheel)所需的工具与库,典型如 setuptools、wheel、cython、numpy(作为编译期头文件来源)以及编译器本身。它们只在 build 阶段存在,安装完成后通常不再需要。运行时依赖则是程序执行期间必须可导入的库,包括解释器本身、动态链接库(如 libopenblas、libgomp)以及被 import 的 Python 包。

判断一个依赖属于哪一类,可以问三个问题:它是否被显式写入声明文件(直接/传递)?它是否只在构建阶段被调用(构建/运行时)?它缺失时是主功能失效还是仅扩展功能失效(必需/可选)?三个维度可以叠加,例如“可选的运行时依赖”“直接的构建依赖”。

依赖图:把关系变成可检查的结构

依赖图以包为节点、以“依赖”为有向边,边上可标注版本约束与依赖类型。对科学计算环境而言,一张有用的依赖图至少要能回答:某包的版本约束从哪些上游传来、哪些包是多个直接依赖的共同下游(即汇聚点)、哪些边是构建期边、哪些是可选边。下图以一个典型的数据分析环境为例,展示了五类依赖在同一张图中的位置与方向。

科学计算环境依赖图(示例:数据分析项目) 实线=必需依赖 虚线=可选依赖 橙色=构建期边 绿色=运行时系统库 pandas 2.2 直接依赖 scikit-learn 1.4 直接依赖 scanpy 1.10 直接依赖 numpy 1.26 传递依赖(汇聚点) scipy 1.12 传递依赖 python-dateutil 传递依赖 leidenalg 可选依赖 [leiden] cython / setuptools 构建依赖 numpy 头文件 构建依赖 libopenblas 运行时系统库 libgomp 运行时系统库 ≥1.23 ≥1.22 ≥1.21 extra build build runtime runtime 读图要点 ① numpy 是 pandas、scikit-learn、scanpy 三条直接依赖的汇聚点,版本约束需同时满足三者; ② 橙色虚线边只在构建阶段存在,迁移到无编译器的目标机时可剔除;③ 紫色虚线边缺失仅影响 leiden 聚类功能。

识别方法:从声明文件到实际环境

依赖类型不能只靠读声明文件推断,因为声明文件记录的是“意图”,实际环境记录的是“结果”。可靠的做法是双向核对。第一步,读取声明文件得到直接依赖集合;第二步,用 pip show 或 conda list 查看每个包的 Requires 字段,递归展开得到传递依赖闭包;第三步,用 pipdeptree 或 conda tree 直接输出树状依赖关系,与手工展开结果比对,发现“声明里没有、环境里却有”的包,这些通常是求解器为满足约束而引入的隐式传递依赖。

# 查看某包的依赖声明(含 extra 标记)
pip show scanpy | grep -i requires
# Requires: anndata, numpy, scipy, pandas, ...
# Requires-Python: >=3.9

# 输出完整依赖树,定位汇聚点与孤立包
pipdeptree --warn silence

# 查看构建依赖(PEP 517 构建系统声明)
python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['build-system'])"

构建依赖的识别要单独处理:它通常写在 pyproject.toml 的 [build-system] requires 中,或旧式 setup.py 的 setup_requires 里,不会出现在运行时依赖树中。若目标迁移机器没有 C 编译器,就必须确认所有包都提供预编译 wheel,否则构建依赖会变成迁移阻塞点。运行时系统库则要通过 ldd 检查扩展模块的动态链接情况,例如 ldd $(python -c "import numpy;print(numpy.__file__)") 可暴露对 libopenblas、libgfortran 的依赖,这类依赖不在任何 Python 包管理器视野内,却常是跨机器迁移失败的直接原因。

建模:把依赖图写成可执行的约束

识别完成后,应把依赖图固化为可版本控制的模型。推荐用“分层锁定”策略:顶层文件只写直接依赖并给出宽松约束,锁定文件记录完整传递闭包与精确版本,构建依赖单独成节,可选依赖按 extra 分组。这样迁移时既能复现完整环境,又能在需要精简时明确知道哪些边可以剪掉。

依赖类型建模位置迁移时的处理
直接依赖environment.yml / requirements.in保留,作为环境意图的权威来源
传递依赖lock 文件(精确版本 + hash)整体复现,不手工删改
可选依赖extra 分组或独立 requirements-extra.txt按目标功能决定是否安装
构建依赖pyproject.toml [build-system]目标机无编译器时改用 wheel 源
运行时系统库容器基础镜像 / 系统包清单随镜像或 apt/conda 系统包一并迁移

建模时还要注意版本约束的合并规则:当多条边指向同一节点时,有效约束是各边约束的交集。例如 pandas 要求 numpy>=1.23、scikit-learn 要求 numpy>=1.22、scanpy 要求 numpy>=1.21,则 numpy 的下界取最大值 1.23。若某条边给出上界(如 numpy<2.0),则必须与下界共同校验,出现空交集就说明依赖图本身不可满足,需要在直接依赖层面调整版本,而不是在传递依赖上强行覆盖。

常见误区:把 pip freeze 的输出直接当作依赖模型。它混合了直接依赖与传递依赖,丢失了“谁引入谁”的边信息,也丢失了构建依赖与可选依赖的区分。迁移后一旦需要升级某个直接依赖,就无法判断哪些传递依赖会随之变化。

最后,依赖图应随环境一起做一次“可满足性自检”:在干净目录中用锁定文件重建环境,记录求解耗时与最终版本,与源环境逐包比对。若出现版本漂移,说明锁定不完整;若求解失败,说明约束交集为空。把这次自检的结果连同依赖图一起归档,下一次迁移就有了可验证的基线,而不是重新猜测一遍依赖关系。