第 11 章 虚拟环境与依赖管理

这一章解决一个你迟早会踩的坑:项目 A 要 requests 2.28,项目 B 要 requests 2.31,装完一个另一个就炸。Go 用 module 天然隔离,Python 得靠「虚拟环境 + 依赖清单」这套组合拳,否则你机器上只有一个全局 Python,迟早变成依赖坟场。

墨叔:老哥,你先在脑子里过一遍——你那台工作机上,现在跑着几个 Python 项目?它们用的是同一个全局 python 吗?

老哥:……好像是。我 pip install 都是直接装的,也没管过环境。

墨叔:那就对了,这是每个 Python 新手都会走的弯路。你 Go 那边多清爽,go mod init 一下,每个项目自带 go.mod,依赖锁在自己目录里,互相不打架。Python 默认没有这套「项目级隔离」,你 pip install 的东西全进同一个全局 site-packages。今天给 A 项目升了个包,明天 B 项目跑挂了,你查半天发现是 A 把某个依赖顶上去了——这种事,我管它叫「依赖连环炸」。

老哥:那 Python 怎么解?总不能每项目都装一套解释器吧?

墨叔:聪明,思路对了一半。Python 的解法是「虚拟环境(virtual environment)」——它不复制解释器本体,而是在项目里建一个轻量的隔离目录,把 pip install 装的东西锁在这个目录里,跟全局和其他项目断开。下面一层层拆。

一、venv 到底是什么:给每个项目一个「独立厨房」

先破一个误解:venv 不是另一个 Python,它只是个指向你系统 Python 的「影子」。激活后,python 和 pip 命令被重定向到这个影子目录,你装的任何包都只落在这个目录的 lib/site-packages 里,动不到全局。

生活化类比:你租房子,整栋楼共用一套水电管网(系统 Python),但每户自己有独立厨房(venv)。你在自己厨房炒菜放多少盐(装什么版本的包),隔壁完全不受影响。搬家(删掉 venv 目录)也只是清掉你这户的锅碗瓢盆,楼没事。

# 在项目根目录执行(这是 shell 命令,不是 Python 代码)
# 创建虚拟环境,目录名习惯叫 .venv
python -m venv .venv
 
# 激活(Linux / macOS)
source .venv/bin/activate
 
# 激活(Windows PowerShell)
.venv\Scripts\Activate.ps1
 
# 激活后,命令行前缀会出现 (.venv),此时 pip 装的包只进这个环境
pip install requests==2.31.0
 
# 退出环境
deactivate

几个你要记牢的点:

  • .venv 目录不要提交进 git(放进 .gitignore),它能在任何机器上用同一条 python -m venv 重建,提交它只会制造跨平台噪音。
  • 创建 venv 用的那个 Python 版本,就是这环境的「基准解释器」。想换版本?删掉重建,没法原地升级。
  • 激活的本质,是修改了你的 PATH,让当前 shell 优先找到 .venv/bin/python。所以每个新开的终端窗口都要重新激活,这是新手最常忘的一步。
  • 想知道当前到底用的是哪个 Python、包装去哪了,跑一句 which python(Linux/macOS)或 where python(Windows),再看 python -m site 打出的 site-packages 路径是否落在 .venv 里——这是排查「我装的包为啥 import 不到」的第一板斧。如果路径指向系统 Python,说明你忘了激活或激活失败了,装包等于又污染了全局。

和 Go 对照:Go 没有「激活」这个概念,因为 go build 只认当前目录的 go.mod,天然项目隔离。Python 把隔离做成了「需要主动创建并激活」的显式动作,麻烦一点,但给了你「同一个解释器,多个互不干扰的项目」的灵活度,而且 venv 是标准库自带的,零额外依赖。

二、依赖怎么声明:requirements.txt 与 pyproject.toml 两条路

光有 venv 还不够。你同事 clone 你的项目,怎么知道要装哪些包、什么版本?得有份「采购清单」。

老办法:requirements.txt

最朴素的清单,就是一行一个包名加版本:

# requirements.txt
requests==2.31.0
sqlalchemy==2.0.23
fastapi==0.104.1

装的时候一把梭:

pip install -r requirements.txt

问题在于:这份清单通常只写「你直接 import 的顶层包」,它们的依赖(传递依赖)没锁版本。今天装和一个月后装,传递依赖可能拉到不同的新版本,于是「在我机器上能跑」的经典事故就来了。所以后来有了冻结式清单:

# 把当前环境里所有包(含传递依赖)及其精确版本 dump 出来
pip freeze > requirements.txt

pip freeze 出来的清单是「全量精确版」,适合部署时保证一致。但它有硬伤:可读性差(几百行传递依赖混在一起)、分不清「我直接依赖的」和「别人带进来的」、没法描述「这是个库还是个应用」「这个项目叫什么」。

新标准:pyproject.toml

PEP 518 / 621 之后,Python 社区把「项目元数据 + 依赖声明」统一收进一个 pyproject.toml,这是现在的官方推荐。

# pyproject.toml
[project]
name = "order-service"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
    "requests>=2.31,<3.0",
    "sqlalchemy>=2.0,<3.0",
    "fastapi>=0.104,<0.105",
]
 
[project.optional-dependencies]
dev = ["pytest>=8.0", "ruff>=0.5"]

你看,它不光列依赖,还顺手把项目名、版本、支持的 Python 版本、可选依赖组(dev / test)都写清楚了。对照 Go:这有点像 go.mod 里的 module、go 1.21、以及 require 块,但 pyproject.toml 承载的信息更多(它还是构建系统的配置入口)。

老哥:那到底用哪个?我看见有些老项目还是 requirements.txt。

墨叔:新项目直接上 pyproject.toml,别犹豫。requirements.txt 不是不能用,它简单直接,适合「我就一个脚本、装几个包」的轻量场景,或者纯部署用的冻结清单。但你要写的是「真实项目」,要被别人 clone、要分 dev/prod、要能被 pip install -e . 装成可编辑包,那 pyproject.toml 是主场。后面讲 poetry/uv 时会看到,现代工具其实都是读写这个文件。

三、lockfile 与可复现构建:Python 的 go.sum 在哪

这是 Go 工程师最该盯紧的一点。你的 go.sum 干的事,是把每一个直接和间接依赖的精确版本 + 哈希值锁死,保证「任何人任何时间 build,拿到的依赖树完全一致」,而且哈希还能防篡改。Python 这边,requirements.txt 和 pyproject.toml 的 dependencies 默认都只是「范围约束」(比如 >=2.31,<3.0),不锁传递依赖的精确版本,所以原生不保证可复现。

那锁文件谁来补?两种路径:

第一种,纯 pip 流派:用 pip freeze 生成一份精确版 requirements.lock(或者干脆把 freeze 输出当部署清单),人工维护。能用,但传递依赖一多,这份清单就成了一坨没人愿意看的天书,而且它把「直接依赖」和「间接依赖」混为一谈,升级顶层包时得手动 re-freeze。

第二种,也是正道:用带锁文件能力的现代工具(poetry、uv、pip-tools),它们会生成独立的 poetry.lock / uv.lock,里面不仅锁了所有传递依赖的精确版本,还存了每个包的哈希,作用和 go.sum 几乎一模一样。

# 用 pip-tools 从 pyproject.toml 编译出可复现的锁文件
pip-compile pyproject.toml -o requirements.lock
pip install -r requirements.lock

老哥:所以 Python 的「可复现构建」不是开箱即用的,得靠工具补一层?

墨叔:一针见血。这是 Python 和 Go 在依赖管理上最大的哲学差异:Go 把「锁文件」做成了语言工具链的内建强制项(go mod tidy 自动维护 go.sum),Python 把「怎么锁、要不要锁」留给了生态工具去选。好处是灵活(小项目不用被锁文件绑架),代价是新手容易踩「环境不一致」的坑——而踩过的人,最后都会乖乖用上带 lock 的工具。

四、poetry 与 uv:现代依赖管理双雄

既然原生 pip 在「锁文件 + 项目元数据」上偏弱,社区就造了两个主力工具。

Poetry:一站式项目管家

Poetry 把「建项目、管依赖、锁版本、打包装、发 PyPI」全包了,一个 pyproject.toml 打天下,poetry.lock 自动维护。

# 新建项目骨架
poetry new order-service
cd order-service
 
# 加依赖,自动写进 pyproject.toml 并更新 poetry.lock
poetry add requests
poetry add --group dev pytest
 
# 按 lock 文件精确装(等价于 go build 拿确定性依赖)
poetry install
 
# 跑命令时自动用项目环境
poetry run python main.py

它最爽的地方是 poetry add 会同时更新 pyproject.toml(给人看的约束)和 poetry.lock(给机器看的精确锁),两个文件职责分离又同步,刚好对应你 Go 里「go.mod 写意图、go.sum 写事实」。

uv:Rust 写的极速平替

2024 年冒头的 uv(来自 Ruff 那帮人),用 Rust 重写,速度是 pip 的 10~100 倍,而且兼容 pyproject.toml 和大部分 poetry 工作流,还能一条命令建环境:

# 建环境 + 装依赖,秒级
uv venv
uv pip install -r pyproject.toml   # 或 uv sync 读 pyproject + uv.lock
 
# 加依赖并锁
uv add requests

老哥:我该选哪个?

墨叔:团队已经在用 poetry 的,别折腾,继续用。新项目想快、想接近原生 pip 体验、又想要锁文件,uv 现在口碑极好,我更推荐新项目直接上 uv。但不管选谁,记住一点:锁文件(lock)必须进 git,环境目录(.venv)必须不进 git——这点和 Go 的 go.sum 进 git、vendor 可选,逻辑是一致的。顺带提醒,CI 里务必跑「按 lock 装依赖」这一步而非裸 pip install,否则你本地锁好的确定性在流水线里又被重新解析了一遍,lock 就成了摆设。

五、一个贴近项目的完整例子

给你看一个真实服务最常见的「三环境依赖」布局。假设你在写一个订单服务,本地开发、测试、生产用到的依赖不同:

# pyproject.toml(用 uv / poetry 都能读)
[project]
name = "order-service"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
    "fastapi>=0.104,<0.105",
    "sqlalchemy>=2.0,<3.0",
    "redis>=5.0,<6.0",
]
 
[project.optional-dependencies]
dev = ["pytest>=8.0", "ruff>=0.5", "httpx>=0.27"]      # 测试 + lint
prod = ["gunicorn>=21.0", "sentry-sdk>=2.0"]           # 生产 WSGI + 监控
# 开发环境:装直接依赖 + dev 组
uv sync --extra dev
# 生产环境:只装直接依赖 + prod 组,连 dev 的依赖都不带
uv sync --extra prod --no-dev

这样「测试框架 pytest 不会进生产镜像」、「sentry 监控只在 prod 组」就由依赖分组天然保证,比你在 Dockerfile 里手写两遍 pip install 干净得多。对照 Go:Go 没有「按环境分组依赖」的内建机制(你通常靠 build tag 或拆分 module 勉强做),Python 这边用 optional-dependencies 反而更顺手。

六、依赖冲突与「版本地狱」怎么破

墨叔:老哥,光会装还不够,你迟早会遇到「版本地狱」——A 包要求 requests<2.30,B 包要求 requests>=2.31,pip 直接摊手告诉你装不了。Go 这边靠 go mod 的「最小版本选择(MVS)」自动调和,同一个模块取满足条件的最高版本,多数时候悄无声息就和解了。Python 的解析器(pip 背后是 resolver)也是类似逻辑——取能满足所有约束的最高版本——但历史上有过一段「老 resolver 不回溯」的黑历史,如今新版 pip 已经默认带回溯式 resolver,大部分冲突能自动解。

真正解不开的情况,根因往往是某个传递依赖被两个顶层包钉了互斥的版本区间,这时候工具也帮不了你,得人工决策:

# 用 pip 的「为什么装了它」命令,顺藤摸瓜查冲突来源
pip show requests          # 看当前装了哪个版本
pipdeptree                 # 打印完整依赖树,一眼看出谁要求了冲突版本

查出来后三条出路:一是「抬升共同上界」——联系维护方或自己提 PR,让冲突的包都放宽版本约束;二是「找替代」——用功能相近、依赖更干净的包替换掉那个霸道的依赖;三是「拆服务」——两个冲突需求分到两个独立服务/环境里,各用各的。你 Go 项目里遇依赖冲突也是这几招,工程直觉直接平移。

还有个老哥常忽略的点:别在 requirements 里把传递依赖写死版本。你只钉自己直接 import 的顶层包,传递依赖交给 lock 工具去锁,否则你手写一个版本,lock 又锁一个,两份真相打架。这跟 Go 里你只 require 直接依赖、间接依赖全自动管理,go.sum 才是真相源,逻辑一致。

七、你现在该带走的四句话

  1. 永远别往全局 Python 直接 pip install,每个项目一个 venv,这是纪律不是选项。
  2. 新项目用 pyproject.toml 声明依赖(含 Python 版本约束),它比 requirements.txt 信息更全、是官方现行标准。
  3. 可复现构建靠 lock 文件,它等价于你的 go.sum——必须进 git,必须包含所有传递依赖的精确版本与哈希。
  4. 工具选 poetry 或 uv 都行,但「锁文件进 git、.venv 不进 git」这条红线别碰。

八、把这一章变成肌肉记忆

  1. 把你机器上某个现存的 Python 项目,先用 python -m venv .venv 建环境、激活,再把原来散装的 pip install 整理成一份 pyproject.toml 的 dependencies,用 uv 或 poetry 重新装一遍,感受「可复现」和「散装」的区别。
  2. 故意制造一次「环境不一致」:在一台机器 pip freeze 出清单,另一台装,看传递依赖版本是否漂移;再换成带 lock 的工具装一次,确认哈希锁死后两次结果一致。把这种「确定性」的甜头刻进习惯。

【思考题】

  1. 你在 Go 里习惯 go.mod 写范围、go.sum 锁精确版本与哈希。Python 这边如果用纯 requirements.txt 而不用 lock 工具,会出现什么具体风险?举一个「开发能跑、CI 挂了」的真实场景。
  2. pyproject.toml 里 requires-python = ">=3.11" 和你在代码里写 print(f"{x=}") 这种 3.8+ 语法,是什么关系?如果有人在 3.9 环境装了你声明 >=3.11 的包,会发生什么?Python 能在安装期拦住吗?
  3. 你团队要同时维护一个内部库(给别人 import)和一个线上服务(自己跑)。这两类项目的依赖声明策略应该有什么不同?哪些该进 dependencies,哪些该进 optional 的 dev/prod 组?

【参考答案】

  1. 纯 requirements.txt 通常只锁顶层包或干脆不锁(写 requests 不写版本),传递依赖版本由解析器当场决定。场景:你在 1 月开发,requests 2.31 拉进来的 urllib3 是 2.1;同事 3 月 clone,requests 2.31 不变,但 urllib3 已经发到 2.2,新行为改了某个超时默认值,于是他本地能跑、CI 用全新环境装到 2.2 就挂。根因就是「传递依赖没锁精确版本」。带 lock 工具(poetry.lock / uv.lock)会把 urllib3==2.1.x 连同哈希写死,任何人装都拿同一版本,彻底消除这类漂移。
  2. 两者是正交的两层:requires-python 是「包的元声明,告诉安装器『我需要至少 3.11』」;而 f"{x=}" 是「代码里实际用到的语法特性」。关系是:声明了 >=3.11,安装器会在安装期检查当前解释器版本,若低于 3.11 直接拒绝安装(pip 会报 Requires-Python >=3.11 不满足)。但反过来——你声明 >=3.11 却写了只在 3.12 才有的语法——安装器不会扫描你的源码去校验语法兼容性,它只信你写的 requires-python。所以「声明」和「实际语法」得靠你自己对齐,这也正是为什么很多库会配 CI 矩阵测试多个 Python 版本,光靠声明不够保险。
  3. 内部库:它的 dependencies 应该只写「真正必要的运行时依赖」,而且版本范围放得宽一些(比如 >=2.0,<3.0),方便使用方整合;测试/lint 工具(pytest、ruff)绝不进 dependencies,要进 optional 的 dev 组,否则使用方的环境会被强行塞一堆测试包。线上服务:反过来,dev 组(pytest、httpx)只在开发/CI 用,prod 组(gunicorn、sentry-sdk)只在生产装,顶层 dependencies 是服务运行必带的。原则一句话:库尽量「轻依赖、宽范围」,应用尽量「环境分组、按需安装」,两者策略相反是因为前者要被别人吸收、后者只自己跑。