上周三晚上十一点,我盯着 GitHub Actions 的日志发呆。一个只装了 fastapi、sqlalchemy、alembic、redis 和 pytest 的服务,CI 光 Install dependencies 那一步跑了 1 分 47 秒,这还没算上 poetry 自己安装花掉的 12 秒。我粗算了一下,团队六个后端服务,一天平均跑 40 次 CI,一年下来光等依赖装完就烧掉六十多个小时的 runner 时间。老板不会因为这个骂我,但我会因为这个骂我自己。
所以我把 uv 拿来试了。不是跑个 hello world 然后就发朋友圈那种试,是把三个已经在跑的服务真真切切迁过去,其中一个还挂着公司内部的私有 PyPI 源。下面没有官方 benchmark 的截图,只有我自己机器上跑出来的数字,和三个真实把我卡住的报错。
先说清楚我拿什么测的
本地是 M2 MacBook Air,16G 内存,macOS 14.5;CI 是 GitHub Actions 的 ubuntu-latest,2 核 7G 的标准 runner。对比对象是 pip 24.2 + venv、Poetry 1.8.3、uv 0.5.11,三个都先把各自的缓存目录删干净再跑,macOS 上分别是 ~/Library/Caches/pip、~/Library/Caches/pypoetry、~/Library/Caches/uv。
依赖集是同一份:fastapi 0.115、uvicorn[standard] 0.32、sqlalchemy 2.0.36、alembic 1.14、pydantic 2.10、pytest 8.3、redis 5.2、psycopg[binary] 3.2。直接依赖 8 个,算上传递依赖展开大概 70 多个包。每项跑三遍取中位数,不过三遍的波动其实很小,不像有些评测那样抖动剧烈。
冷缓存的结果是:pip 63 秒左右,Poetry 71 秒(它要先解析再装,光解析就吃掉 20 多秒),uv 8 秒出头。热缓存重装、也就是删 venv 但不删 cache 的情况:pip 24 秒,Poetry 33 秒,uv 不到 2 秒。CI 上因为机器更弱,差距反而拉得更大,uv 连下载 wheel 一起算 19 秒,pip 是 2 分 11 秒。第一次跑完我愣了一下,因为太快了,退出码是 0 但我下意识觉得肯定哪里没装上,这种怀疑大概过了一个星期才消失。
三个真把我卡住的坑
第一个:uv 建的虚拟环境默认不带 pip、setuptools、wheel。这个设计我认同,毕竟装了也基本不用,但我们有个老脚本里有一行 subprocess.check_call([sys.executable, "-m", "pip", "install", ...]),用来在运行时补装一些可选依赖,切过去直接报 No module named pip。临时解法是 uv venv --seed 把 pip 塞回去,最终解法是把那段代码删了,改成走正式的依赖声明。顺带说一句,那种运行时装包的操作本来就是个定时炸弹,这次算是顺手埋了。
第二个跟私有源有关,这个坑我觉得最值得单独说。Poetry 那边我们是这么配的:
[[tool.poetry.source]]
name = "internal"
url = "https://pypi.internal.example.com/simple/"
priority = "supplemental"
迁到 uv 的时候,我一开始直接翻译成 [[tool.uv.index]],然后把 default = true 加在了私有源上,想当然以为意思是优先用它。结果 uvicorn 之类的公共包全去私有源找,然后一片 404。uv 里 default = true 的真实含义是这个源直接取代 PyPI,不是优先。要同时保留 PyPI 和私有源,正确做法是私有源不加 default,再用 [tool.uv.sources] 给特定包指定走哪个源。这套语义跟 pip 的 --extra-index-url 和 Poetry 的 supplemental 都不一样,文档里确实写了,但我第一遍确实是看岔了。
顺便提一个相关的:uv 默认的 index 策略是 first-index,找到就不再往后找。这比 pip 的 extra-index-url 安全得多,pip 那种所有源一起挑最新版本的做法,是依赖混淆攻击的经典入口。代价就是你从 pip 迁过来的时候,某些包会突然装不上,因为 uv 不肯去第二个源里找它。我建议先别急着加 --index-strategy unsafe-best-match,那个参数名字里的 unsafe 不是吓唬人的。
第三个坑最小,只是烦。uv 的 lock 默认是跨平台的 universal resolution,有个依赖在 Windows 上带个可选的 pywin32,uv 会把它也写进 lock 并打上平台标记。这不是 bug,是预期行为,但我第一次 diff lock 文件,看到 pywin32 出现在一个纯 Linux 服务的 lock 里,心里还是咯噔了一下。用了两周之后就无所谓了。
迁移本身到底要做什么,大概 25 分钟
先说安装,macOS 和 Linux 上一行搞定:curl -LsSf https://astral.sh/uv/install.sh | sh,Windows 用对应的 PowerShell 脚本。装完 uv --version 确认一下,再把 ~/.local/bin 加进 PATH。
然后是项目改造。如果项目本来就已经是 PEP 621 格式,也就是依赖写在 [project.dependencies] 里,那基本不用动,只要确认 build backend 别写成 poetry 独有的东西就行。我们三个项目里两个是这种,只改了 [build-system] 那一段,requires 保留 poetry-core 也完全没问题,uv 不挑 backend。如果还是老式的 [tool.poetry.dependencies],有个社区工具 migrate-to-uv 可以直接 uvx migrate-to-uv 跑,它会自动转格式,但转完记得手工核对一遍,尤其是 extras 和 marker 那部分。
改完之后删掉 poetry.lock,跑 uv lock,再跑 uv sync。uv sync 会自动建 .venv、装依赖、写出 uv.lock,一条命令干三件事。加依赖用 uv add,加开发依赖用 uv add --dev,它会写进 [dependency-groups] 的 dev 组,这是 PEP 735,pip 25.1 之后也认这个格式,所以迁移成本比想象中小。想让某个包升版本就 uv lock --upgrade-package requests,别用 uv lock --upgrade,那个是全量升,我吃过一次亏,一次性升了十几个包,review 的时候根本看不出哪个改动是哪来的。
CI 那边改动更简单。原来 snok/install-poetry 那一套全删掉,换成:
- uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- run: uv sync --locked --no-dev
- run: uv run pytest -q
--locked 这个参数请一定加上。它的意思是断言 lock 文件和 pyproject.toml 一致,不一致就直接报错退出。不加的话,uv 默认会在 sync 时顺手更新 lock,于是某次你改了 pyproject 忘了提交 lock,CI 会自己解一遍然后绿着通过,本地和线上的依赖就悄悄分叉了。这种事通常要等到线上炸了才被发现。
Docker 里我用的官方那套分层写法:
FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /bin/uv
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-install-project --no-dev
COPY . .
RUN uv sync --locked --no-dev
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0"]
分两次 sync 是为了让依赖层能被 Docker 缓存住,改业务代码不用重装依赖,镜像构建从 3 分半降到 40 秒左右。UV_LINK_MODE=copy 别漏,Docker 的 layer 和宿主机文件系统之间做硬链接会出各种奇怪问题,官方文档的 Docker 章节里提了,但那句话埋得很深。
但是库项目我现在还是不用 uv
迁完应用之后我本来想把一个内部 SDK 也切过去,试了两小时放弃了。原因不是 uv 不行,是它解决的根本不是这个场景的问题。
写应用和写库是两回事。应用的依赖应该被锁死,uv.lock 提交进仓库,每次装出来的东西一模一样,这是对的。但库不是,库的 pyproject.toml 里写的是版本范围,比如 pydantic>=2.6,<3,它需要在 CI 里对着一堆版本组合去测:Python 3.9 到 3.13,pydantic 2.6、2.7、2.10 都跑一遍。这时候你要的恰恰是每次装的东西都不一样,才能撞出兼容性问题。这套东西的正确工具是 tox 或者 nox 加一个干净的 pip 环境,锁文件在这里是有害的。
我实测过在 tox 环境下把 pip 换成 uv pip install,安装时间大概能省 40%,但绝对值是从 18 秒变成 11 秒,省下来的这点时间还不够我调试那层多出来的配置。所以现在给团队的说法很明确:应用用 uv,库继续 pip + tox。这不是技术优劣,是需求不同,网上那种「uv 已经可以完全替代 pip 和 poetry 了」的说法,我觉得说得太满了。
至于 Poetry,它现在的位置确实有点尴尬。它比 pip 好用,比 uv 慢一个数量级,而它最核心的那几个能力——lock、依赖分组、打包——uv 现在都有,格式还是互相兼容的 PEP 621 加 PEP 751。但 Poetry 也不是没优势,它的报错信息比 uv 友好太多了,依赖冲突的时候会告诉你完整的推导链条,uv 那边经常就甩一句 resolution failed 然后让你自己猜。我刚迁完那几天特别想念 Poetry 的报错。
我的结论
这轮工具换代真正改变的东西,其实不是那几十秒。是 Python 项目总算有了「环境版本号」这个共识。以前你 clone 一个 Python 仓库,README 上写的是先装 Python 3.x,再 pip install -r requirements.txt,这句话在 2024 年之前其实是不完整的,因为你根本不知道这个 requirements 是哪天生成的。lock 文件提交进仓库这个习惯,才是真正解决问题的部分。uv 快,只是让这个习惯变得没那么难遵守而已。
我现在那个服务的 CI 跑完是 1 分 30 秒,其中依赖安装占 11 秒。省下来的时间我也没干什么正经事,主要是看着它跑完,然后按时下班。