起因#
最近在团队内部重新整理 Python 项目的打包、发布和依赖管理规范,逐渐把各个工程统一收敛到:
1pyproject.toml
2uv
3uv.lock
4wheel
5src layout
6internal package registry在这个过程中,大家交流最多的往往是一些具体的工具用法:私有源怎么配、build backend 选哪一个、uv 的命令参数怎么写。但我越梳理越发现,这些繁琐的工程细节很容易遮蔽一个最根本的问题:
一个 Python library 到底需要管理什么?
进而还有两个随之而来的疑问:为什么随着代码库和调用关系变复杂,仅仅维护一个 requirements.txt 会越来越难以表达真正的依赖关系?而 uv、Poetry 这一类现代 project manager,真正解决的核心问题到底是什么?
比起具体的命令用法,我更想记录的是在这个过程中,重新想清楚的关于 Python 依赖管理的几个核心抽象。
一个矛盾的直觉:要不要锁依赖?#
先来看一个看似矛盾的工程问题:
为什么一个 Python library 既需要“锁死依赖”,又绝对不能“锁死依赖”?
假设我们维护一个内部的基础 library,比如 feature_engine,它的依赖包括:
1numpy
2requests
3onnxruntime从这个 library 自己的 repository 视角出发,我们对确定性有着极高的要求:
- 本地开发环境;
- CI 测试环境;
- 半年之后为了排查线上问题 checkout 某个老 commit 的环境。
我们希望这几个环境解出来的依赖版本完全一致。说白了,我必须确切知道当时 resolver 到底解出了什么版本。如果今天 CI 跑通了,下周别人往第三方库发了个有 bug 的 patch release,我们这边的 CI 就莫名其妙挂掉,这在工程上是无法接受的。
这就是 reproducibility(可复现性)。为了可复现,代码仓库理所当然需要一个 lockfile(例如 uv.lock),把整张依赖图完全固定下来。
但换到下游消费者的视角,事情立刻反过来了。
如果 feature_engine 被公司内部十几个不同的业务使用——有的用于训练离线生成样本,有的嵌在在线模型服务里,有的被另一个算法 SDK 引用。这时候,我们显然不能要求所有下游环境里的 numpy 和 requests 必须精确等于我开发时解出来的版本。
否则,当下游应用同时引用了多个库:
1Library A
2Library B
3Library C
4 ↓
5Application一旦每个 library 都在自己的发布信息里把依赖锁死(比如 A 要求 numpy == 1.26.4,B 要求 numpy == 2.0.0),下游在构建应用环境时就会立刻触发死锁级别的 dependency conflict。
对下游来说,library 真正应该表达的是 compatibility contract(兼容性合约):
1我需要 numpy >= 1.26
2我需要 onnxruntime >= 1.16于是这里出现了两个诉求完全相反的视角:
1Repository 自身
2 ↓
3到底使用哪一套确定的 dependency graph?
4 ↓
5reproducibility
6 ↓
7lockfile以及:
1Library 对外
2 ↓
3哪些 dependency version 是兼容的?
4 ↓
5compatibility
6 ↓
7package metadata同一个 library,在仓库内部需要极致的确定性,在对外发布时又需要给出合理的弹性空间。
Declaration 不是 Resolution#
要解开上面的矛盾,最核心的一点在于厘清两件事的区别:
“我允许什么依赖”,和“resolver 最后解出了什么依赖”,是两个完全不同的问题。
也就是说:
Dependency declaration is not dependency resolution。
在现代 Python project 的模型中,这两个概念分别对应了不同的实体:
pyproject.toml承载的是 Dependency Declaration。这是人类维护的意图(human-maintained intent),表达的是兼容性承诺。比如声明numpy >= 1.26,意思是「经过测试,我认为任何大于等于 1.26 的版本都能满足我的接口需求」。uv.lock承载的是 Dependency Resolution。这是机器维护的状态(machine-maintained state),记录了 resolver 在某一特定时刻,基于声明的约束条件和平台环境,求解出的一套精确、具体的依赖图(例如numpy == 1.26.4、urllib3 == 2.2.1以及所有 transitive dependencies)。
flowchart TD
subgraph Human Intent
A["pyproject.toml
(dependency declaration)"]
end
A -->|uv lock / resolver| B
subgraph Machine State
B["uv.lock
(concrete dependency graph)"]
end
B -->|uv sync| C["virtualenv
(runtime environment)"]
A -->|uv build| D["wheel metadata
(compatibility contract)"]
格式本身(比如是不是 TOML)并不重要,真正重要的是 Intent 与 Resolution 的分离。
这其实是一个非常通用的软件工程范式:人类给出声明式的约束(declaration),求解器(solver)在解空间中计算出一个可行的确定性解(resolved state)。这两个对象承担着不同的职责,绝不能混淆在一起。
重新看 requirements.txt:工具没有错,只是缺少 Project Model#
既然 Intent 和 Resolution 是两个不同的东西,为什么过去大家用一个 requirements.txt 也能走过很多年?
在讨论这个问题时,必须保持技术上的客观和严谨。不能简单粗暴地断言「requirements.txt 把直接依赖和间接依赖混在一起」或者「requirements.txt 解决不了依赖管理」。
一个人工维护的 requirements.txt:
1numpy>=1.26
2requests>=2.31完全可以只包含 direct dependencies,它表达的显然是 dependency declaration。
而在部署脚本里执行:
1pip freeze > requirements.txt导出来的:
1certifi==2024.2.2
2charset-normalizer==3.3.2
3idna==3.6
4numpy==1.26.4
5requests==2.31.0
6urllib3==2.2.1表达的则是一个当前环境的 environment snapshot。
不仅如此,传统 Python 工具链完全可以通过一整套组合拳搭建出非常规范的工作流:
1requirements.in ──pip-compile──▶ requirements.txt
2(直接依赖声明) (锁定版本快照)
3
4setup.cfg / pyproject.toml ──▶ package metadata
5virtualenv ──▶ isolated environment
6build ──▶ distribution artifact (wheel)这套方案完全可以工作。但只要在规模稍大的团队里实践过,就会发现一个很吃力的问题:
同一个基础 abstraction(requirements file),在不同的场景下被用来承担不同层次的职责。哪些是声明、哪些是快照、该怎么同步,全靠开发者的口头约定和外部脚本来维系。
这时候暴露出来的问题,不是 requirements.txt 语法有什么硬伤,而是:随着项目复杂度提升,我们真正需要的已经不是一个单纯的 requirements 文件,而是一个一等公民的 Project Model。
现代包管理工具(uv、Poetry、PDM 等)真正带来的改变,不是重写了一个更快的 pip,而是把 project metadata、dependency declaration、resolution、environment sync 和 build 流程收敛到了同一个一致的工程模型中。
Library 与 Application:两种截然不同的依赖哲学#
在梳理依赖关系时,还有一个常见的误区:把 Library 和 Application 的管理策略一概而论。
社区里有一句很经典的概括:
Applications optimize for reproducibility; libraries optimize for compatibility.
这个断言方向是对的,但容易让人产生一种误解,以为 library 就不需要关心 reproducibility 了。
更严密的描述是:A library needs reproducibility internally, but compatibility externally。
| 维度 | Application | Library |
|---|---|---|
| 核心诉求 | Reproducibility(端到端完全确定) | 内部 Reproducibility,外部 Compatibility |
| 依赖声明 | 可以非常固定,甚至直接全锁 | 声明真实的兼容范围,避免过度限制 |
| Lockfile 角色 | 决定最终运行环境的全局真理 | 仅供本仓库开发、测试和 CI 使用 |
| 下游依赖图 | 通常没有下游,自身即终点 | 必须无缝嵌入下游已有的依赖图中 |
| 核心交付产物 | Docker 镜像 / 部署包 / 运行环境 | Wheel 及包内的元数据(Metadata) |
Application 是整张依赖图的终点消费者,运行在受控的孤岛环境里,它关心的就是「线上跑的和我测试的到底是不是同一串字节」。
而 Library 的宿命是进入别人的依赖图。它必须给下游的 resolver 留出足够的余量,让下游去协调不同组件之间的版本约束。
Lockfile 属于 Repository,不属于下游消费者#
基于前面的模型,可以自然推导出一个极其关键的工程原则:
Lock file belongs to the repository, not to a library’s consumers。
假设我们有一个内部库 Library A,它的依赖声明是:
1[project]
2name = "library-a"
3dependencies = [
4 "numpy>=1.26",
5]在 A 的仓库内部,uv.lock 可能当前解析出的具体状态是:
1numpy == 1.26.4A 在自己的 CI 和开发机上,跑的都是这套固定的 resolution。
但是,当 A 被构建成 wheel 时,打包工具写入 wheel METADATA 文件的是:
1Requires-Dist: numpy>=1.26而不是 numpy==1.26.4。A 仓库里的 uv.lock 压根不会被打包进 wheel,也不会随着 wheel 分发出去。
flowchart LR
subgraph Library Repo
P1["pyproject.toml
(numpy >= 1.26)"] --> R1["uv lock"] --> L1["uv.lock
(numpy == 1.26.4)"]
P1 --> B1["uv build"] --> W1["Wheel
(Requires-Dist: numpy >= 1.26)"]
end
subgraph Downstream Application
W1 -.->|pip install / uv add| P2["pyproject.toml"]
P2 --> R2["Downstream Resolver"]
R2 --> L2["App uv.lock
(numpy == 2.1.0)"]
end
style L1 fill:#fff3cd,stroke:#666,stroke-dasharray: 5 5
style W1 fill:#d1e7dd,stroke:#0f5132,stroke-width:2px
style L2 fill:#cff4fc,stroke:#055160
当下游 Application 安装 Library A 时,下游的 resolver 结合自身所有的直接依赖和其他间接依赖,重新计算出属于下游应用自己的解(比如下游完全可能解出 numpy == 2.1.0)。
A 的 lockfile 不会、也不应该成为下游的 runtime contract。
明白了这个原则,很多长期争论不休的问题就有了清晰的答案:
- 为什么 library 也应该提交 lockfile? 因为 library 仓库自身也需要稳定可复现的开发和 CI 环境,提交 lockfile 完全不会“影响下游”。
- 为什么 library 不应该在 metadata 里无脑写
==? 因为 wheel metadata 才是真正对外暴露的 compatibility contract,锁死了下游就无法共存。 - 为什么上游用了现代工具,下游完全不需要强绑定? 上游用
uv还是Poetry只是仓库内部的工程选型,只要产出的 wheel 符合 PEP 517 / PEP 621 标准,下游哪怕用最原始的pip消费,拿到的也是标准 metadata。
说白了,在 Python 的分发模型里,真正能够跨越代码仓库边界的,是一个标准化的 distribution artifact(也就是 wheel)以及它携带的 metadata,而不是仓库内部的 lockfile 或开发者的本地环境。
为什么在 ML Infra 中,依赖管理往往是 Runtime Engineering#
上面讨论的原则,适用于所有编写和分发 Python library 的软件工程场景。无论是 Web 框架中间件、数据库驱动还是网络工具库,本质都一样。
这并不是 ML Infra 独有的问题。
但 ML Infra 是一个极具代表性的放大镜(amplification case)。在很多纯 Python 或业务应用中,依赖管理的轻微失误可能只会引发小范围的异常或被运行时拦截;而在 ML Infra 体系中,隐式依赖一旦失控,很容易直接演变成系统级的灾难。
主要有三个放大效应。
1. 依赖图向下穿透 Python 边界#
普通的 pure Python library,依赖链往往停留在 Python 字节码这一层:
1Python Module
2 ↓
3Python Package
4 ↓
5Python Package但在 ML Infra 中,依赖链会迅速向下穿透:
1Python
2 ↓
3NumPy / PyTorch / ONNX Runtime
4 ↓
5C / C++ Native Extensions
6 ↓
7CUDA Runtime / cuDNN / Triton
8 ↓
9NVIDIA Driver
10 ↓
11GPU Hardware在纯 Python 世界里,升级一个 minor 版本,通常只是函数调用的增删。但在 ML 场景下,一个看似普通的包版本变动,背后牵扯的可能是:
- C++ runtime 的 ABI 兼容性;
- 预编译 wheel 中的 CUDA 版本与物理机器上的显卡驱动是否匹配;
- 算子底层实现是调用了特定版本的 cuDNN kernel 还是 Triton 生成的代码;
- 甚至不同 native extension 之间链接的静态库版本是否存在符号污染。
因此在 ML Infra 中,依赖图更深,而且极其容易直接穿透到 native runtime 和硬件体系。
2. 对 Reproducibility 的定义更为严苛#
在通用业务系统中,判断一次依赖升级是否成功,标准相对简单:服务能启动、单元测试全绿、核心 API 能正常返回数据,通常就证明兼容性没有大问题。
但在 ML Infra 场景中,“程序还能跑”往往只是及格线的最底端。
当底层的数值计算库或者 runtime 版本发生细微变化时,可能会出现:
- 某些算子的舍入逻辑变化,导致模型输出产生微小的 numerical drift;
- 训练过程虽然跑通,但 loss 收敛曲线无法严格对齐历史结果;
- 某些高效 kernel 没有命中,回退到通用实现,导致推理延迟从 15ms 恶化到 30ms;
- 内存或显存分配器的内部策略微调,导致特定 batch size 下触发 OOM。
在这些场景下,“还能跑”根本不等于“可复现”。 依赖管理在这里直接关乎计算的确定性和性能的稳定性,它已经成为了 runtime engineering 的一部分。
3. 内部 Reusable Library 密集且复用拓扑复杂#
在典型的 ML Infra 架构中,存在大量需要跨团队、跨系统复用的内部库:
- 特征计算与抽取库;
- 模型序列化与 export 工具;
- 推理运行时与 C++ binding SDK;
- 训练通用的 metrics、checkpoint 与 distributed 辅助组件。
这些 library 通常由不同的同学维护,但又同时被离线样本生成任务、分布式训练任务、以及生产环境的在线推理服务共同消费。
在这么密集的复用网络中,如果任何一个上游库把自己的依赖模型搞混了(比如直接把自己的开发快照发布成了依赖约束),下游组合时就会迅速爆发连锁冲突。
从 Dependency Model 推导出的工程实践#
把底层概念梳理清楚之后,很多具体的工程做法其实不需要死记硬背,它们都可以从上面的模型中自然推导出来。
1. 用 pyproject.toml 声明一等公民的 Project Metadata#
使用 pyproject.toml,不是因为 TOML 格式比 txt 新颖,而是因为它在标准层面把 project metadata、direct dependencies、Python 版本约束和 build 配置放在了同一个统一模型里。依赖声明不再是随便写在一个 txt 里的零散字符串,而是项目本身不可分割的元属性。
2. Library 仓库提交 uv.lock,但不向下游传播#
无论应用还是库,代码仓库自身都应该提交 uv.lock。
提交 lockfile 的目的不是为了“永远用最新的包”,而是为了固定已经经过团队验证的 resolution。任何人拉下代码、任何一台 CI runner 检出历史分支,跑的都是完全相同的确定性依赖图。
同时牢记:这个 lockfile 只属于仓库自己,发布出去的 wheel 只暴露兼容范围。
3. 不要无脑 pin,也不要假装自己能预测未来#
在 library 的依赖声明中:
- 不要机械地写
numpy==1.26.4,这会剥夺下游的组合空间; - 同样,不要在没有事实依据的情况下机械地加
<2.0。upper bound 应该表达真实的兼容性认知(例如已知某个大版本有破坏性重构),而不是在什么都没测过的时候,预先假设下一个版本一定会坏。
4. 依赖范围本身是 API Contract,需要被验证#
这是一个经常被忽略的盲点。
假设我们在 pyproject.toml 中声明:
1dependencies = [
2 "numpy>=1.26",
3]而仓库的 uv.lock 锁定的版本是:
1numpy == 2.1.0那么日常的 locked CI 测试,充其量只能证明「当前代码在 numpy 2.1.0 下可以正常工作」,完全没有证明「当前代码在 numpy 1.26 下也能正常工作」。
既然声明的范围是对外承诺的 compatibility contract,那么这个范围的下界(最低兼容版本)本身也是 contract 的一部分。对于关键的基础库,CI 除了验证当前的 lockfile 之外,值得定期跑一次下限依赖验证(比如利用 uv 的 --resolution lowest-direct 特性)。
声明的 contract 本身必须经过测试,不能只凭想象。
5. CI 的职责是验证,而不是隐式重新求解#
CI 运行的核心目标,是验证当前代码和当前经过审查的 resolution 是否能够正常工作,而不是在每次执行时偷偷升级依赖。
在 CI 流程中,应当显式声明使用锁定环境运行:
1uv lock --check
2uv sync --locked
3uv run --locked pytest如果有人改动了依赖声明但没有同步更新 lockfile,CI 应该在第一步就明确报错,而不是假装无事发生地重新 resolve 出一套未经验证的新环境。
6. 以 Wheel 作为跨仓库集成的真实边界#
在本地迭代代码时,editable install(pip install -e .)非常方便。
但在跨仓库集成测试、或者在 CI 中验证依赖是否完备时,必须通过构建 wheel、安装 wheel 来进行:
1Source Tree ──uv build──▶ Wheel ──uv pip install──▶ Downstream Testeditable install 是一种开发期的路径劫持技巧,并不是真正的分发产物。只有实际构建出的 wheel,才会暴露出真正的打包遗漏、缺失的 metadata 以及真实的依赖声明。Wheel 才是组件之间真正的物理边界。
结论#
一个 Python library 到底需要管理什么?
回到最开始的问题,其实可以总结为三句话:
Dependency declaration is not dependency resolution。 人类负责维护声明意图,求解器负责计算具体状态,两者不应混为一谈。
Lock file belongs to the repository, not to a library’s consumers。 代码仓库需要 lockfile 获得确定性,但发布出去的包只能通过 metadata 表达兼容范围。
A library needs reproducibility internally, but compatibility externally。 对内追求极致的可复现,对外留出合理的协同空间。
requirements.txt 没有做错什么,它在很长一段时间里很好地履行了一个文本清单的职责。只是随着软件工程规模的扩大,我们终于意识到,依赖管理从来不是摆弄几个 txt 文件的文本技巧,而是一个需要被明确对待的 Project Model。
在很多领域,这个模型的缺失或许还能靠工程师的个人经验勉强维系;但在深入 native runtime、硬件驱动与复杂复用网络的场景下,严密的依赖模型,才是保障系统不至于滑向混沌的基石。
参考资料#
- PEP 517 – A build-frontend / build-backend interface for Python packages
- PEP 518 – Specifying Minimum Build System Requirements for Python Projects
- PEP 621 – Storing project metadata in pyproject.toml
- Astral uv Documentation: Concepts & Philosophy
- Python Packaging User Guide: Requirements Files vs. Libraries