第 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.txtpip 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 才是真相源,逻辑一致。
七、你现在该带走的四句话
- 永远别往全局 Python 直接
pip install,每个项目一个 venv,这是纪律不是选项。 - 新项目用
pyproject.toml声明依赖(含 Python 版本约束),它比 requirements.txt 信息更全、是官方现行标准。 - 可复现构建靠 lock 文件,它等价于你的
go.sum——必须进 git,必须包含所有传递依赖的精确版本与哈希。 - 工具选 poetry 或 uv 都行,但「锁文件进 git、.venv 不进 git」这条红线别碰。
八、把这一章变成肌肉记忆
- 把你机器上某个现存的 Python 项目,先用
python -m venv .venv建环境、激活,再把原来散装的pip install整理成一份pyproject.toml的 dependencies,用uv或poetry重新装一遍,感受「可复现」和「散装」的区别。 - 故意制造一次「环境不一致」:在一台机器
pip freeze出清单,另一台装,看传递依赖版本是否漂移;再换成带 lock 的工具装一次,确认哈希锁死后两次结果一致。把这种「确定性」的甜头刻进习惯。
【思考题】
- 你在 Go 里习惯
go.mod写范围、go.sum锁精确版本与哈希。Python 这边如果用纯requirements.txt而不用 lock 工具,会出现什么具体风险?举一个「开发能跑、CI 挂了」的真实场景。 pyproject.toml里requires-python = ">=3.11"和你在代码里写print(f"{x=}")这种 3.8+ 语法,是什么关系?如果有人在 3.9 环境装了你声明>=3.11的包,会发生什么?Python 能在安装期拦住吗?- 你团队要同时维护一个内部库(给别人 import)和一个线上服务(自己跑)。这两类项目的依赖声明策略应该有什么不同?哪些该进
dependencies,哪些该进 optional 的 dev/prod 组?
【参考答案】
- 纯
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连同哈希写死,任何人装都拿同一版本,彻底消除这类漂移。 - 两者是正交的两层:
requires-python是「包的元声明,告诉安装器『我需要至少 3.11』」;而f"{x=}"是「代码里实际用到的语法特性」。关系是:声明了>=3.11,安装器会在安装期检查当前解释器版本,若低于 3.11 直接拒绝安装(pip 会报Requires-Python >=3.11不满足)。但反过来——你声明>=3.11却写了只在 3.12 才有的语法——安装器不会扫描你的源码去校验语法兼容性,它只信你写的requires-python。所以「声明」和「实际语法」得靠你自己对齐,这也正是为什么很多库会配 CI 矩阵测试多个 Python 版本,光靠声明不够保险。 - 内部库:它的
dependencies应该只写「真正必要的运行时依赖」,而且版本范围放得宽一些(比如>=2.0,<3.0),方便使用方整合;测试/lint 工具(pytest、ruff)绝不进dependencies,要进 optional 的dev组,否则使用方的环境会被强行塞一堆测试包。线上服务:反过来,dev组(pytest、httpx)只在开发/CI 用,prod组(gunicorn、sentry-sdk)只在生产装,顶层dependencies是服务运行必带的。原则一句话:库尽量「轻依赖、宽范围」,应用尽量「环境分组、按需安装」,两者策略相反是因为前者要被别人吸收、后者只自己跑。