跳过正文
  1. Posts/

为什么一个 Python Library 既要锁依赖,又不能锁依赖

·5959 字·12 分钟
目录

起因
#

最近在团队内部重新整理 Python 项目的打包、发布和依赖管理规范,逐渐把各个工程统一收敛到:

1pyproject.toml
2uv
3uv.lock
4wheel
5src layout
6internal package registry

在这个过程中,大家交流最多的往往是一些具体的工具用法:私有源怎么配、build backend 选哪一个、uv 的命令参数怎么写。但我越梳理越发现,这些繁琐的工程细节很容易遮蔽一个最根本的问题:

一个 Python library 到底需要管理什么?

进而还有两个随之而来的疑问:为什么随着代码库和调用关系变复杂,仅仅维护一个 requirements.txt 会越来越难以表达真正的依赖关系?而 uvPoetry 这一类现代 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 引用。这时候,我们显然不能要求所有下游环境里的 numpyrequests 必须精确等于我开发时解出来的版本。

否则,当下游应用同时引用了多个库:

1Library A
2Library B
3Library C
45Application

一旦每个 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 自身
23到底使用哪一套确定的 dependency graph?
45reproducibility
67lockfile

以及:

1Library 对外
23哪些 dependency version 是兼容的?
45compatibility
67package 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.4urllib3 == 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。

现代包管理工具(uvPoetryPDM 等)真正带来的改变,不是重写了一个更快的 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。

维度ApplicationLibrary
核心诉求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.4

A 在自己的 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
23Python Package
45Python Package

但在 ML Infra 中,依赖链会迅速向下穿透:

 1Python
 2 3NumPy / PyTorch / ONNX Runtime
 4 5C / C++ Native Extensions
 6 7CUDA Runtime / cuDNN / Triton
 8 9NVIDIA Driver
1011GPU 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 Test

editable 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、硬件驱动与复杂复用网络的场景下,严密的依赖模型,才是保障系统不至于滑向混沌的基石。

参考资料
#

🏛️ 111qqz 的技术博客 · 15 年博客历史 (2011 - 2026)
发布于 2026-09-11

💡 觉得有启发?欢迎互动交流!

如果你在阅读、编译运行或系统优化中有任何疑问、思考或更好的解法,欢迎在下方发表评论,或通过邮件直接探讨。

本文链接:https://111qqz.com/2026/09/python-library-dependency-model/ 知识共享署名-非商业性使用 4.0 国际许可 (CC BY-NC 4.0)

相关文章

爬虫学习笔记

·1219 字·3 分钟
再次迫于生计。。。 参考了面向新人的 Python 爬虫学习资料 大致的学习路线为: 一: 简单的定向脚本爬虫( request — bs4 — re )

使用python计算误差代码

·126 字·1 分钟
import os 1import math 2ave_err=0.0 3max_err=0.0 4max_err_rate=0.0 5length=0 6with open("cpu_result.txt","r") as fp1, open("cuda_ppl_result.txt","r") as fp2: 7 for l1 in fp1: 8 l2 = fp2.readline() 9 l1=l1[:-2] 10 l2=l2[:-2] 11 lst = l1.split(' ') 12 lst2 = l2.split(' ') 13 #print lst 14 lst = [float(x) for x in lst ] 15 length = length + len(lst) 16 lst2 = [float(x) for x in lst2] 17 #print (lst) 18 #print (lst2) 19 20 for index,x in enumerate(lst): 21 y = lst2[index] 22 ave_err = ave_err + abs(x-y) 23 max_err = max(max_err,abs(x-y)) 24 max_err_rate = max(max_err_rate,abs(x-y)/x) 25 26 print("len=",length) 27 print("max_err=",max_err) 28 print("max_err_rate=",max_err_rate*100,"%") 29 print("ave_err=",ave_err/length) 需要提供两个文件,并且两个文件的数据格式相同。