让 conda 负责 C/Fortran 扩展的编译产物,让 uv 负责项目级依赖隔离——各取所长,而不是互相替代。

问题的根源:科学计算栈的「编译地狱」

Python 科学计算的基石——NumPy、SciPy、scikit-learn、PyTorch——本质上不是 Python 包。它们是 C、C++、Fortran 编写的原生扩展,Python 只是薄薄一层胶水壳。你在 import numpy 时,真正加载的是 _multiarray_umath.cp312-win_amd64.pyd(Windows)、_multiarray_umath.cpython-312-x86_64-linux-gnu.so(Linux)这类编译后的二进制共享库。

这就带来一个根本性矛盾:

工具 设计哲学 对 C 扩展的态度
pip / uv 从源码构建或下载预编译 wheel 依赖系统工具链(GCC/GFortran/LAPACK/BLAS),失败率极高
conda 分发预编译二进制 所有依赖(包括 BLAS/OpenMP)都打包进环境,开箱即用

uv 再快,也快不过编译 NumPy 的 Fortran 源码——它做的事情和 pip 一样,只是更快地下载、更快地解析依赖图。面对 scipy 背后那个庞大的 Fortran 代码库(MINPACK、ODEPACK、FFTPACK、ARPACK……),你需要的不是一个更快的包管理器,而是一个根本不用编译的方案。这正是 conda 存在的意义。

架构设计:conda 做底座,uv 做上层

核心思路一句话:把 C 扩展密集的公共依赖下沉到 conda 环境作为底座,然后用 --system-site-packages 让每个项目的 uv 虚拟环境继承这个底座。

direction: down

scientific-base: {
  label: "conda 底座环境 \"scientific-base\""
  near: top-center

  content: |md
    Python 3.12
    numpy · scipy · scikit-learn · numba
    pandas · matplotlib · seaborn
    ── 全部预编译 C/Fortran 扩展 ──
    BLAS/OpenMP/编译器运行时 一并打包
  |
}

inheritance: {
  label: "--system-site-packages"
  shape: text
  style.font-style: italic
}

scientific-base -> inheritance -> project-a
scientific-base -> inheritance -> project-b
scientific-base -> inheritance -> project-c

project-a: {
  label: "项目 A (.venv, uv)"
  polars
  plotly
  opencv
}

project-b: {
  label: "项目 B (.venv, uv)"
  xgboost
  lightgbm
  prophet
}

project-c: {
  label: "项目 C (.venv, uv)"
  altair
  streamlit
  h3-py
}

layers: {
  platform: {
    label: "conda 底座"
  }
  computation: {
    label: "uv 项目层"
  }
}

底层原理:Python 的模块查找链

这里涉及一个被大多数人忽略的 Python 知识:虚拟环境不是"隔离"的,它是"层叠"的。

当你执行 python -m venv --system-site-packages .venv(或 uv venv --system-site-packages)时,uv 会在 .venv/pyvenv.cfg 中写入一行:

include-system-site-packages = true

Python 启动时读取这个标记,决定 sys.path 的构造方式。默认情况下(false),虚拟环境的 site-packages 完全替代系统路径;设为 true 后,系统路径追加到虚拟环境路径之后:

# --system-site-packages 后的 sys.path 结构:
[
    '.venv/Lib/site-packages',           # [0] uv 安装的包,优先级最高
    'C:/Users/xxx/miniforge3/envs/scientific-base/Lib/site-packages',  # [1] conda 底座
    'C:/Users/xxx/miniforge3/envs/scientific-base/Lib',               # [2] 标准库
    ...
]

这意味着一件事:conda 底座里装的包,uv 项目里不装也能直接 import 只有当你需要在某个项目中覆盖某个包的版本时,才用 uv pip install 把它装到 .venv 里,新版本会因路径顺序更高而「遮蔽」conda 的版本。

这是整个架构的地基。不是 hack,是 Python 虚拟机原生的标准机制。


实战流程:从零搭建三层结构

第一层:conda 科学计算底座

# 只做一次,所有项目共享
conda create -n scientific-base \
    python=3.12 \
    numpy scipy "scikit-learn>=1.5" \
    pandas matplotlib \
    "libblas=*=*mkl" \
    -c conda-forge

几个关键点:

  • 显式指定 BLAS 后端libblas=*=*mkl 确保用 Intel MKL 而不是默认的 OpenBLAS。NumPy 和 SciPy 的线性代数性能(np.dotnp.linalg、SVD、特征值分解)严重依赖 BLAS 实现。MKL 在 Intel CPU 上比 OpenBLAS 快 20%~50%,而且 conda 把 MKL 运行时一并打包,不需要系统层面安装任何东西。
  • conda-forge 优先:default channel 的包通常更旧,conda-forge 维护者更活跃且 ABI 兼容性测试更充分。
  • 底座保持干净:只放「多个项目都需要且涉及 C 扩展」的包。纯 Python 包(比如 tqdm、rich、pyyaml)不要放底座——让 uv 按需安装。

第二层:uv 项目虚拟环境

# 首先激活 conda 底座
conda activate scientific-base

# 进入项目目录
cd D:\projects\biomech-modeling

# 创建继承底座的 uv 虚拟环境
uv venv --system-site-packages --python 3.12

# 激活 uv 虚拟环境
.venv\Scripts\activate

此时你的 shell 里,python 指向 .venv/Scripts/python.exesys.path 包含 conda 底座路径。验证:

import sys
for p in sys.path:
    print(p)
# .venv/Lib/site-packages
# .../miniforge3/envs/scientific-base/Lib/site-packages

第三层:项目专用依赖

# 只装这个项目需要的轻量包
uv pip install polars plotly pyarrow pydantic

# 如果需要覆盖 conda 底座的某个包版本
uv pip install "scipy==1.14.0"  # 装到 .venv,遮蔽 conda 的版本

到这里,biomech-modeling 项目拥有:

  • conda 底座的全部科学计算能力(numpy, scikit-learn, matplotlib…)
  • 自己独立的 polars、plotly 等纯 Python 依赖
  • 完全不影响其他项目

依赖锁定:让底座可复现

conda 底座本身也需要锁定。不要靠记忆重建环境,而是维护一个 environment.lock.yml

# scientific-base/environment.lock.yml
name: scientific-base
channels:
  - conda-forge
dependencies:
  - python=3.12.7=h2628f8c_0_cpython
  - numpy=2.1.3=py312hbb9b9f5_0
  - scipy=1.14.1=py312hbb9b9f5_0
  - scikit-learn=1.5.2=py312hbb9b9f5_0
  - pandas=2.2.3=py312hbb9b9f5_0
  - matplotlib=3.9.2=py312hbb9b9f5_0
  - libblas=3.9.0=25_win64_mkl
  - libcblas=3.9.0=25_win64_mkl
  - liblapack=3.9.0=25_win64_mkl
  # ... 完整依赖树

生成锁文件:

conda list --explicit > environment.explicit.txt   # 精确到 build string
conda env export --from-history > environment.lock.yml  # 人类可读

这样,换机器或重装时一键还原:

conda env create -f environment.lock.yml

版本遮蔽的风险与纪律

前面提到,你可以用 uv pip install numpy==2.0 覆盖 conda 底座的版本。但这可能炸。

原因在于 C ABI 兼容性。conda 底座里的 scikit-learn 是针对底座里的 numpy 版本编译的,它们的 C 结构体布局、函数签名、内存对齐都是一致的。如果你在 .venv 里覆盖了一个 ABI 不兼容的 numpy 版本:

# conda 底座: numpy 1.26.4 (C API version 0x00000010)
# uv .venv:    numpy 2.1.0  (C API version 0x00000012)
# scikit-learn 在底座里,链接了 numpy 1.26.4 的 C API
# → segfault 或静默数据损坏

铁律:你想覆盖底座里的某个包 → 要么新建一个 conda 底座,要么把所有依赖它的包也一并用 uv 覆盖(这样做就失去了 conda 底座的意义)。

所以我推荐的规则是:

包类型 谁负责 理由
C/Fortran 扩展包 conda 底座 避免编译,保证 ABI 一致
大型 ML 框架(PyTorch) conda 底座 CUDA/cuDNN 依赖链极复杂
纯 Python 包 uv 项目层 解析快、版本灵活
轻量 C 扩展但版本敏感 uv 项目层 + --system-site-packages 遮蔽 conda 版本

进阶:针对不同项目类型的底座策略

传统数值计算(NumPy/SciPy 为主)

一个通用底座就够了。所有项目共享同一个 scientific-base

深度学习项目

PyTorch 和 TensorFlow 的 CUDA 依赖极其复杂,建议每个 CUDA 版本独立一个底座

conda create -n torch-cu121-base python=3.12 pytorch torchvision torchaudio pytorch-cuda=12.1 -c pytorch -c nvidia
conda create -n torch-cu124-base python=3.12 pytorch torchvision torchaudio pytorch-cuda=12.4 -c pytorch -c nvidia

uv 项目再选择继承对应的底座。

地理空间计算

GDAL/GEOS/PROJ 等底层 C 库有严格的版本耦合:

conda create -n geo-base python=3.12 \
    geopandas rasterio shapely fiona pyproj \
    gdal libgdal \
    -c conda-forge

这些库如果交给 pip/uv 从源码编译,依赖的系统库差异会导致难以排查的运行时错误——坐标转换偏移、投影信息丢失。conda 的二进制分发是唯一可靠方案。


这套架构的本质

这是 Python 虚拟环境设计之初就预留的扩展点。--system-site-packages 的本意是让虚拟环境继承系统 Python 自带的包(比如 Linux 发行版通过 apt 安装的 python3-numpy),我们只是把「系统 Python」换成了「conda 预编译的科学计算环境」,把「虚拟环境工具」换成了更快、更现代的 uv。

apt → conda(都是系统级/环境级二进制包管理器)
venv → uv venv(都是虚拟环境管理器,后者更快)

总结

Conda 解决的是编译问题(二进制分发、ABI 兼容性、BLAS/CUDA 运行时打包),uv 解决的是速度问题(依赖解析、下载、虚拟环境创建)。把它们对立起来是没意义的——它们在 Python 包管理的技术栈里分别占据不同的层次:

层次 工具 关注点
运行时与系统库 conda C/Fortran ABI、BLAS 后端、CUDA、编译器运行时
项目级依赖隔离 uv 快速解析、虚拟环境、版本锁定

二者不是「选哪个」,而是「怎么组合」。