Tiana随笔

uv 用了半年:Python 依赖管理的新默认

· 工程与效率

标签:#Python #工具

uv 用了半年:Python 依赖管理的新默认

从 pip + venv 切到 uv 半年之后,安装速度、锁文件、多版本 Python 管理三件事都回不去了。讲讲迁移成本几乎为零的切换方式。

半年前我把团队里所有 Python 项目的依赖管理方案统一切到了 uv。起因很平淡:一台新入职同事的机器上,pip 同步一个中等规模后端项目的环境花了将近四分钟,而隔壁用 Node 的同事 npm ci 早就跑完了。那种"别人家的工具更快"的落差,最后逼着我认真评估了 uv。半年用下来,结论很明确,对于 2026 年的 Python 开发,uv 已经可以作为新项目的默认,旧项目迁移的边际成本也比想象中低。

不过我并不打算把它写成"uv 天下第一"的安利。半年里我踩过不少坑:lock 文件在 CI 里的诡异行为、PEP 723 脚本在老系统上的兼容问题、workspace 里某个子包版本被悄悄覆盖。这篇文章想做的,是把 uv 真正解决的老问题、我日常的工作流、以及那些文档里不会提前告诉你的坑,原原本本讲清楚,供你判断是否值得迁移。我自己的主栈是 FastAPI 后端加 Vue3 前端,后端这套依赖工具链换血,对我这种每天和虚拟环境打交道的人来说影响是直接的。如果你只想要一句话结论:新项目直接上 uv,老项目用 uv pip install 平替验证后再切,基本不会后悔。

pip 时代真正痛的不是慢,而是割裂

很多人把 uv 的卖点概括为"快",这没错,但只说对了一半。pip 时代真正折磨人的,是工具链的割裂。一个稍微规范点的项目,你至少要同时维护这几样东西:virtualenv 负责建隔离环境,pip 负责安装,pip-tools 的 pip-compile 负责把松散的 requirements.in 编译成锁死的 requirements.txt,pip-sync 负责让环境对齐锁文件,再加上 setuptools 或 poetry 的打包逻辑。每个工具各管一段,配置文件散落各处,新人光是把"怎么装出一个能跑的环境"这件事搞明白就要半天。

更麻烦的是可重现性。requirements.txt 里写着 requests>=2.28,你今天装和一个月后装拿到的版本可能不一样;pin 死所有版本号的 requirements.txt 又无法表达"为什么是这个版本"的来源关系。pip-tools 勉强补上了这块,但它是个外部拼图,不是 pip 内建的。我见过太多项目把 pip freeze > requirements.txt 的结果直接当锁文件提交,结果把间接依赖、平台相关的轮子全写死,换台机器就装不上,还顺带把开发依赖也带进了生产镜像。

回看历史,pip 自身的依赖解析器在 2020 年才真正修好,在那之前 pip install 会用一种"先到先得"的贪心策略,经常装到一半才发现版本冲突,然后留下一个半残环境让你手动收拾。2020 年的新解析器解决了正确性,却没解决速度,大依赖树解析依然慢得让人想喝茶。这种"工具能跑对,但跑得很不爽"的长期状态,正是 uv 出现的土壤。

uv 把这套东西收敛成了一个二进制。建环境、装包、锁版本、构建发布包,全在一个 uv 命令下。它底层用 Rust 重写,解析依赖图和下载解包都是并行加缓存,所以速度快是结果,不是目的。真正让我决定留下来的,是它让"可重现安装"从一种需要纪律约束的善行,变成了工具默认就给你的能力。慢是表象,割裂才是病根,uv 治的是后者。

核心工作流:从 uv init 到 uv sync

现在一个新后端项目,我的起手式就是 uv init。它会生成 pyproject.toml、.python-version 和一个 .venv 目录,同时探测好 Python 版本。和 poetry 不同,uv 默认不会把所有依赖都塞进 pyproject 里那种自成体系的私有字段,而是尽量贴合标准的 PEP 621 元数据,这样后续真要迁走也不痛苦。

# 初始化项目并指定 Python 版本
uv init my-api --python 3.12
cd my-api

# 添加运行时依赖,自动写入 pyproject 并生成锁文件
uv add fastapi "pydantic>=2.0" uvicorn

# 添加开发依赖,放进独立的 group,避免污染生产依赖
uv add --dev pytest ruff mypy

# 根据锁文件把环境对齐到精确版本(本地和 CI 都该用这条)
uv sync

uv add 的体验比手写 pyproject 舒服太多:它会解析现有依赖、选择兼容版本、更新 uv.lock,然后立即同步环境,一步到位。uv.lock 是个跨平台的全量锁定文件,记录了每个包在 Linux/macOS/Windows 上各自应该装什么轮子,这是 pip-tools 单文件锁很难优雅表达的东西。它本质上是一份"真相清单":任何一次安装都以此为准,而不是临时去 PyPI 现拉。

关于 uv.lock 是否进版本库,我的答案是:必须进,而且建议保留默认的 requires-python 和依赖来源约束。它是可重现构建的基石,CI 里跑 uv sync --frozen 时如果 lock 和系统实际不一致会直接报错,等于把"依赖漂移"这件事在 CI 阶段就卡死。这里的经验是,永远用 --frozen 跑 CI,这样本地忘记重新锁版本的情况会立刻暴露,而不是在凌晨的生产环境才炸。

# CI 里必须 frozen,否则锁不一致也不报错,等于没锁
uv sync --frozen --no-dev   # 生产环境只装运行时依赖

# 本地想强制重新解析(比如上游发了新版本想升级)
uv lock --upgrade-package fastapi

# 想整体升级所有能升的包,再跑测试验证
uv lock --upgrade

和 requirements.txt 互转也很现实,毕竟很多老系统还认 txt。导出兼容 pip 的锁定文件只需要:

# 导出一份 pip 可用的冻结清单,给老部署流水线兜底
uv pip compile pyproject.toml -o requirements.lock.txt

# 反过来,如果你有个现成的 requirements.txt 想迁移到 uv
uv add -r requirements.txt

我保留了导出能力,是因为有些部署流水线(比如某些容器基础镜像里只预装了 pip)暂时还不能换。互转的存在让 uv 可以渐进式落地,而不是非黑即白地重写一切。这也是我推荐"先平替、后切换"的底层理由:你随时有退路。

平替验证:把 uv 当更快的 pip 使

很多团队不敢动依赖工具,是怕一步到位切过去出事。uv 的聪明之处在于它兼容 pip 的绝大多数命令语义,所以你完全可以先不碰 pyproject,只把安装命令换了:

# 以前:pip install -r requirements.txt
# 现在等价写法:
uv pip install -r requirements.txt

# 装进系统 Python(谨慎使用,等同于 pip 的 --system)
uv pip install --system some-package

# 在已有 venv 里装
uv pip install -r requirements-dev.txt

注意 uv pip install 和 uv sync 是两套心智模型:前者和你过去十年用的 pip 一模一样,面向"我已经有一份清单,照着装";后者面向"我有 pyproject 和 lock,请帮我维护整个环境"。我们用这套分级策略平滑迁移:第一阶段所有 CI 把 pip 换成 uv pip,零改动验证速度收益;第二阶段挑边缘服务切到 uv sync 完整工作流;第三阶段主站跟进。整个过程任何一步出问题都能退回上一步。

PEP 723:单文件脚本的依赖内联

这是半年里改变我工作习惯最大的一块,甚至超过依赖管理本身。PEP 723 允许在脚本文件头部用注释声明依赖,uv 能直接读懂并临时准备一个带这些依赖的环境运行它,不用你手动建 venv。对做数据对账、接口巡检这类"写一次、跑几次就丢"的活儿来说,这是质变。

# /// script
# requires-python = ">=3.11"
# dependencies = [
#     "httpx",
#     "rich",
# ]
# ///
"""一次性查询多个接口并打印耗时,依赖随脚本走,不污染全局环境。"""
import time
import httpx
from rich.table import Table
from rich.console import Console

urls = ["https://example.com", "https://httpbin.org/get"]
console = Console()
table = Table("url", "ms")

for u in urls:
    t0 = time.perf_counter()
    httpx.get(u, timeout=5)
    table.add_row(u, f"{(time.perf_counter() - t0) * 1000:.1f}")

console.print(table)

跑它只需要 uv run script.py。uv 会解析头部的 dependencies,在缓存里准备一个临时环境,装好就执行,脚本之间互不影响。以前我要写一个随手用的小工具,得先 python -m venv /tmp/x && source ... && pip install ...,用完还得记得清理;现在这些一次性脚本散落在 ~/scratch 目录,每个自带依赖声明,半年过去也没把全局 Python 搞脏。

更有用的是它的可分享性。一个带 PEP 723 头的脚本,可以直接丢给同事,uv run 就能在对方机器上复现完全一致的依赖,不需要对方先读懂你的环境。我们团队现在约定:所有一次性运维脚本一律用 PEP 723 头,杜绝"在我机器上能跑"。

# /// script
# dependencies = ["pandas", "openpyxl"]
# ///
"""把一个杂乱的 xlsx 清洗成标准 csv,纯脚本级任务。"""
import pandas as pd

df = pd.read_excel("raw.xlsx")
df = df.dropna(subset=["order_id"]).rename(columns={"金额": "amount"})
df.to_csv("clean.csv", index=False)

uv run 还有个配套能力叫 uvx,用来临时运行一个命令行工具而不污染环境,等价于过去的 pipx。比如 uvx ruff check . 会拉起一个临时的 ruff 环境跑完就走。我们甚至把本地开发用的各种小工具全挪到了 uvx,全局 site-packages 空空如也,干净得反常。uvx 第一次运行某工具会下载,之后走缓存同样很快,所以它既解决了"装了一堆一次性 CLI 污染全局"的老问题,又没牺牲体验。

这里有个明确的取舍判断:PEP 723 适合"脚本级"的一次性或半一次性工具。真正长期维护的库和应用,还是应该走完整的 pyproject + lock 流程,因为脚本头不适合表达复杂的版本约束、可选依赖、构建钩子和测试配置。别因为内联方便就把整个项目拍扁成一个大脚本,那是另一种极端。

workspace:多包项目的统一节奏

我的主站是 FastAPI 后端加一个共享的 schema 包再加若干内部小工具,以前用 poetry 的 workspace 或者干脆 git submodule 凑合,体验都不算好。uv 的 workspace 用根目录 pyproject.toml 里的 [tool.uv.workspace] 声明成员,子包之间可以互相引用而无需发布到索引。

# 根 pyproject.toml
[tool.uv.workspace]
members = ["packages/*", "services/api"]

[tool.uv.sources]
shared-schema = { workspace = true }

这样在 services/api 里 uv add shared-schema,uv 知道这是同一个 workspace 内的包,用可编辑方式链接,改了源码立刻生效,无需重新发布。一次性 uv sync 就能把整个 workspace 的所有环境拉齐,CI 里也是一次搞定。对多包单体仓库来说,这一点比逐包管理清爽太多,也避免了"本地改了共享包却忘了发布,导致同事拉不到新代码"的经典事故。

构建和发布也统一了。过去要发一个包到私有索引,得切到对应目录、打 wheel、传仓库,步骤散。现在 uv build 在根目录就能把 workspace 内所有成员各自打成 wheel 和 sdist,uv publish 直接推到配置的索引:

# 在根目录构建所有成员的发布产物
uv build

# 推送到私有 PyPI(凭据走环境变量或配置)
uv publish

但要小心一个坑:workspace 内多个成员如果各自 pin 了同一个第三方包的互斥版本,uv 在解析根 lock 时会尝试找公共解,找不到就直接报错,而不是静默让你某个成员用旧版。这其实是好事,只是第一次遇到时容易被吓到,错误信息其实写得很清楚,顺着看是哪个成员引入的冲突即可。我的处理习惯是:共享依赖统一在根 pyproject 里约束一个宽松范围,子包只声明自己真正需要的额外项。

缓存机制为什么快,以及它带来的副作用

uv 的快,根源在三层设计。第一是依赖解析用 Rust 实现并全程并行,不像 pip 那种偏串行的解析;第二是下载的 wheel 和源码包按内容哈希缓存到全局 ~/.cache/uv,同一个包在任何项目里都只下载一次;第三是环境构建时优先复用已解压的缓存,而不是每次都从零解包。

# 看缓存占了多少空间,清理过期项
uv cache dir
uv cache clean        # 全清
uv cache prune        # 只删没有被引用的

实际体感:一台机器上第二个项目装相同依赖几乎是秒级,因为包都在缓存里了。CI 里配合缓存 ~/.cache/uv 目录,能省下大量重复下载,有时候能把一次构建从几分钟压到几十秒。需要说明,我没法给你一个"精确跑分":不同网络、不同包数量差异巨大,我只说体感:大型 Django 类项目从接近两分钟降到十几秒这种量级是真实发生过的,但你自己的项目请以实测为准。背后的关键是内容寻址:同一个 wheel 不管被哪个项目、哪个环境引用,磁盘上只存一份,环境之间靠硬链接或引用计数共享,而不是各复制一份。这也就解释了为什么你机器上项目越多,uv 的相对优势越明显。

副作用也有。缓存是全局共享的,意味着一个项目里误装了带问题的同名包,会影响其他项目。当然这是供应链安全的共性问题,不是 uv 独有,pip 的全局缓存同样有这问题。更现实的小问题是缓存会悄悄膨胀,我这台开发机半年攒了几个 G,定期 uv cache prune 即可。另外在 Docker 多阶段构建里,记得把缓存层放在前面的 RUN,否则每次构建都重新下载。

# 先把依赖锁和 pyproject 拷进来,利用层缓存
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project

COPY . .
RUN uv sync --frozen --no-dev

--no-install-project 这个参数的作用是只装依赖不装项目自身,这样依赖层不会因为源码改动而失效,是 Docker 构建提速的关键技巧。很多新人一开始漏了它,导致每次改一行业务代码都要重装所有依赖。

一个真实 FastAPI 项目的 pyproject 长什么样

光说命令太空泛,贴一份我主站后端实际在用的 pyproject 骨架(脱敏后),你就能看清日常结构:

[project]
name = "tiana-api"
version = "0.4.1"
requires-python = ">=3.12"
dependencies = [
    "fastapi>=0.115",
    "uvicorn[standard]>=0.30",
    "pydantic>=2.8",
    "sqlalchemy>=2.0",
    "aiosqlite>=0.20",
    "pyjwt>=2.9",
]

[dependency-groups]
dev = [
    "pytest>=8.0",
    "ruff>=0.6",
    "mypy>=1.11",
]

[tool.uv.sources]
# 内部包走私有索引或 workspace,不写死版本
shared-schema = { workspace = true }

[tool.ruff]
line-length = 100
target-version = "py312"

值得注意两点。第一,dependencies 里我故意只写大版本下限,把"具体锁到哪版"交给 uv.lock 去记,这样既能防漂移又保留升级弹性。第二,开发依赖用的是标准的 [dependency-groups](PEP 735),而不是塞进 [project.optional-dependencies],因为 dev 依赖本就不该出现在发布包里。这种区分,是过去混用 optional extras 时最容易搞脏元数据的坑。

升级与回滚:依赖不是装完就完事

依赖管理最考验工具的不是"装",而是"出事之后怎么办"。上游某包发了个有 bug 的版本,你的 CI 一升级就挂,这时你需要的是干净利落地回退,而不是手动改一摞文件。

# 只升级某一个包到最新兼容版,其余不动
uv lock --upgrade-package fastapi

# 锁定到某个已知良好的具体版本
uv add "fastapi==0.115.0"

# 升级整个锁(谨慎,建议配合测试套件一起)
uv lock --upgrade

# 回滚:lock 进了 git,直接 git revert 那次提交再重新 sync 即可
git revert <commit-of-bad-upgrade>
uv sync --frozen

这套"lock 即事实、git 即历史"的模型,让回滚变成了纯粹的版本控制操作,不需要工具提供什么魔法。我吃过一次亏:某次没有先跑测试就 --upgrade 全量升级,导致一个间接依赖的破坏性更新溜进生产。从那以后,我们的规则是 uv lock --upgrade 必须走 PR,CI 跑全套测试通过才合并,绝不在本地随手升级就推。

另一个常被忽略的点是 Python 本身的版本升级。uv python install 3.13 可以在一台机器上并行装多个解释器,uv python pin 3.13 切换项目目标版本。配合 .python-version,整个团队的解释器版本也能像依赖一样被锁死,避免"他本地 3.12 我 3.11 跑出来不一样"的低级扯皮。

迁移成本、兼容陷阱与半年的坑

如果你要从 pip + requirements.txt 迁移,最省事的方式不是重写,而是渐进:uv pip install -r requirements.txt 在现有环境就能用,相当于把 uv 当更快的 pip 使,零成本验证。确认没问题后再逐步切到 pyproject 工作流。我们当时就是这样,先在一台边缘服务上验证,跑稳了再推主站。

半年里我记下的几个坑,按出现频率排序:

  • lock 文件冲突。多人同时改依赖时,uv.lock 是机器生成、不可手工 merge 的,必须一方重新 uv lock 解决。我们后来约定改依赖前先同步主干再操作,并把 uv lock 接到了 pre-commit,冲突率大幅下降。
  • Python 版本探测。CI 容器里如果没装 .python-version 指定的小版本,uv 会尝试下载,可能触发网络限制或超时。解决办法是在 CI 基础镜像里预装好对应版本,或用 uv python install 3.12 显式声明。
  • PEP 723 脚本在只有老 pip 的环境无法直接运行,需要和同事约定这类脚本一律用 uv run。我们把它写进了仓库的 CONTRIBUTING,并在脚本头部加注释提醒。
  • uv add 默认会把依赖写进 [project.dependencies],但某些旧式构建后端(比如很老的 setuptools 配置)可能读不到,需要确认 build backend 是标准兼容的。遇到过一次依赖装上了却 import 不到,追了半小时才发现是老 setuptools 忽略了 PEP 621 字段。
  • 全局缓存权限。在 CI 里如果以不同用户跑多个 uv 进程,缓存目录权限可能打架,统一用一个用户或临时目录最稳。

兼容性上 uv 对 pip 生态基本是超集,绝大多数 wheel 和源码包都能装。少数需要编译的包(如某些带 C 扩展的科学计算库)在 uv 下和 pip 下行为一致,没有额外惊喜。如果你依赖 Conda 那种自带非 Python 运行时(如 MKL、CUDA)的环境,uv 目前还替代不了,那是另一类需求,得继续用 Conda 或 pixi。

什么时候我不推荐 uv?一是团队完全没法接受任何新工具、培训成本被严格压死的老项目,平替验证足矣,不必强推完整工作流;二是需要精确的 Conda 级系统依赖的场景;三是极老的 Python 2 遗产,uv 只支持 Python 3。除此之外,我实在想不出不用的理由。

回过头看,迁移的边际成本很低,收益却贯穿每天的 install、sync、run。我的建议是:新项目直接 uv 起手;老项目先用 uv pip install 平替验证,再择机切到完整工作流,过程中用 requirements 互转保留退路。半年时间足够验证它不是一个会突然不维护的玩具,而是 Python 依赖管理一个值得托付的新默认。等哪天 pip 官方把 uv 的能力内建进去,我会很高兴,但在此之前,uv 就是我的答案。

最后给还在犹豫的人一句大实话:工具迁移最贵的从来不是学习命令,而是改变团队的肌肉记忆。你今天花一个下午把常用命令从 pip 换成 uv,真正难的是接下来三个月里每次手滑敲出 pip install 时把自己拽回来。把它写进脚手架模板、写进 CI、写进 CONTRIBUTING,让工具替你记住,比靠意志力可靠得多。等哪天你发现团队已经没人记得 pip 怎么用了,那次迁移才真正算完成。

相关文章