从"能装上"到"可复现":Python 团队如何正确使用 requirements.txt、锁定文件与依赖分组
团队 Python 项目最常见的混乱,往往不是代码写错,而是环境不一致:A 同事本地能跑,B 同事 pip install -r requirements.txt 后报错;CI 今天通过,明天突然失败;生产环境和开发环境看起来装的是"同一批依赖",行为却不一样。
问题的根源在于:"能装上"只说明依赖解析器找到了一个可用组合;"可复现"要求任何人在指定时间、指定平台、指定 Python 版本下,都能安装出同一个环境。 这两者之间隔着版本范围、传递依赖、平台差异、构建后端、包索引状态和锁定策略。
现代 Python 依赖管理里,可以把三类文件分清楚:
| 类型 | 作用 | 是否应该提交 | 典型使用场景 |
|---|---|---|---|
pyproject.toml / requirements.in |
声明"我需要什么" | 是 | 人类维护的直接依赖 |
requirements.txt |
pip 安装清单,也常被当作扁平锁定结果 | 通常是 | 部署、CI、传统 pip 工作流 |
pylock.toml / uv.lock / poetry.lock |
记录完整解析结果 | 是 | 可复现安装、生产部署 |
| 依赖分组 | 区分 prod/dev/test/docs/lint | 是 | 本地开发、测试、文档、CI |
pip 官方文档明确说,requirements 文件是给 pip install 使用的安装项列表,但完整语法与 pip 内部细节紧密绑定,并不是一个面向所有工具的通用标准。(pip.pypa.io) Python Packaging 规范则把依赖说明符定义为 PEP 508 风格:可以指定包名、版本范围、extras、URL、环境标记等。(Python 打包用户指南)
一、requirements.txt:不要把它误当成"万能真相"
很多团队的第一个习惯是:
bash
pip freeze > requirements.txt
pip install -r requirements.txt
这在小脚本里能用,但在团队项目中很容易埋雷。pip freeze 记录的是"当前环境已经装了什么",里面可能混入临时调试工具、全局污染包、过期依赖,甚至记录了并非项目真正需要的包。
更合理的思路是:
text
人类维护:我直接依赖什么
↓
工具解析:所有直接依赖 + 传递依赖
↓
锁定输出:给 CI / 生产 / 新同事安装
例如先写一个最小输入文件:
txt
# requirements.in
fastapi>=0.110,<1.0
uvicorn[standard]>=0.29,<1.0
pydantic>=2,<3
再生成锁定版:
bash
python -m pip install pip-tools
pip-compile --generate-hashes -o requirements.txt requirements.in
生成后的 requirements.txt 可能类似:
txt
fastapi==0.115.12 \
--hash=sha256:...
pydantic==2.11.4 \
--hash=sha256:...
starlette==0.46.2 \
--hash=sha256:...
uvicorn==0.34.2 \
--hash=sha256:...
pip-tools 文档说明,pip-compile 可以从 pyproject.toml、setup.py、setup.cfg 或 requirements.in 编译出锁定的 requirements.txt;它还支持 --generate-hashes 生成哈希校验。(pip-tools.readthedocs.io)(pip-tools.readthedocs.io)
一句话原则:
text
requirements.in / pyproject.toml:人写
requirements.txt:工具生成
如果团队成员手改生成后的 requirements.txt,后续升级、审计、回滚都会变得困难。
二、锁定文件:可复现环境的核心
"能装上"和"可复现"之间最大的差距,是依赖解析存在很多合法答案。
比如你写:
txt
requests>=2.30
今天解析到:
txt
requests==2.32.3
urllib3==2.2.2
certifi==2024.8.30
几个月后,可能解析到:
txt
requests==2.33.0
urllib3==2.3.0
certifi==2025.x.x
这两次都"能装上",但行为未必完全一致。
真正的锁定文件应该记录:
text
直接依赖
传递依赖
精确版本
环境标记
Python 版本约束
平台差异
包来源
文件哈希
Python Packaging 已经有 pylock.toml 规范,它的目标就是描述 Python 环境中可复现安装所需的依赖。(Python 打包用户指南) pip 也已有实验性的 pip lock 命令,但官方说明其生成结果只保证对当前 Python 版本和平台有效。(pip.pypa.io)
所以在实践中可以这样选:
| 项目类型 | 推荐方案 |
|---|---|
| 传统 pip 项目 | requirements.in + pip-compile + requirements.txt |
| 现代应用项目 | pyproject.toml + uv.lock / pylock.toml |
| Python 库 | pyproject.toml 声明宽松范围,测试矩阵覆盖多个版本 |
| 数据科学项目 | 锁 Python 版本、锁依赖、锁系统依赖,必要时用容器 |
| 生产服务 | 必须提交锁定文件,CI 和部署都从锁定文件安装 |
一个更稳的生产安装命令:
bash
pip install --require-hashes -r requirements.txt
如果你用 pip-tools,同步环境时不要只用 pip install,更推荐:
bash
pip-sync requirements.txt
因为 pip install -r requirements.txt 不会自动删除环境里多余的包;pip-sync 会让虚拟环境精确匹配锁定文件。pip-tools 文档也强调,pip-sync 会安装、升级或卸载必要内容,使环境反映 requirements 文件。(pip-tools.readthedocs.io)
三、依赖分组:别让开发工具污染生产环境
团队项目通常至少有这些依赖:
text
生产运行:fastapi、sqlalchemy、redis
测试:pytest、coverage
代码质量:ruff、mypy
文档:mkdocs、sphinx
本地调试:ipython、debugpy
如果全部塞进一个 requirements.txt,生产镜像会变大,安全扫描会变吵,升级风险也更高。
现代写法可以放进 pyproject.toml:
toml
[project]
name = "team-api"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.110,<1.0",
"uvicorn[standard]>=0.29,<1.0",
"pydantic>=2,<3",
]
[project.optional-dependencies]
postgres = [
"psycopg[binary]>=3.2,<4",
]
redis = [
"redis>=5,<6",
]
[dependency-groups]
dev = [
"pytest>=8,<9",
"coverage[toml]>=7,<8",
"ruff>=0.8,<1",
"mypy>=1.13,<2",
]
docs = [
"mkdocs>=1.6,<2",
]
这里有两个容易混淆的概念:
[project.optional-dependencies] 是面向用户的 extras。
例如你的库支持 PostgreSQL,用户可以这样安装:
bash
pip install "team-api[postgres]"
[dependency-groups] 是面向项目内部的开发分组。
例如测试、lint、文档构建、类型检查。Python Packaging 规范说明,依赖组适合 linting、testing 等内部开发用途,也适合不构建分发包的脚本集合;这些依赖组不会作为构建后包的元数据发布。(Python 打包用户指南)
判断标准很简单:
text
用户是否需要通过 pip install your-package[xxx] 使用它?
是:optional-dependencies
否:dependency-groups
四、一套可直接落地的团队工作流
推荐目录结构:
text
team-api/
├── pyproject.toml
├── requirements/
│ ├── prod.txt
│ ├── dev.txt
│ └── docs.txt
├── src/
├── tests/
└── .github/workflows/ci.yml
1. 固定 Python 版本
bash
python --version
# Python 3.12.x
可以在仓库里加:
text
# .python-version
3.12
或在 Dockerfile / CI 中明确指定:
yaml
strategy:
matrix:
python-version: ["3.12"]
2. 生成生产锁定文件
使用 pip-tools:
bash
pip-compile \
--generate-hashes \
-o requirements/prod.txt \
pyproject.toml
3. 生成开发锁定文件
如果使用分层 requirements,可以让 dev 受 prod 约束:
txt
# requirements/dev.in
-c prod.txt
pytest>=8,<9
coverage[toml]>=7,<8
ruff>=0.8,<1
mypy>=1.13,<2
然后:
bash
cd requirements
pip-compile --generate-hashes -o dev.txt dev.in
pip-tools 文档也给出了 layered requirements 的思路:开发依赖可以通过 -c requirements.txt 受生产依赖约束,从而保证 dev 和 prod 中共享依赖版本一致。(pip-tools.readthedocs.io)
4. 新同事本地初始化
bash
python -m venv .venv
source .venv/bin/activate
python -m pip install -U pip pip-tools
pip-sync requirements/prod.txt requirements/dev.txt
Windows PowerShell:
powershell
py -3.12 -m venv .venv
.venv\Scripts\Activate.ps1
python -m pip install -U pip pip-tools
pip-sync requirements/prod.txt requirements/dev.txt
5. CI 中禁止"漂移安装"
yaml
name: ci
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pip install -U pip pip-tools
- run: pip-sync requirements/prod.txt requirements/dev.txt
- run: ruff check .
- run: pytest -q
CI 的关键不是"装上最新依赖",而是"装出和团队约定一致的环境"。
五、为什么"可复现"这么难?
1. 传递依赖会变化
你只写了:
txt
fastapi
但实际会装:
text
fastapi
├── starlette
├── pydantic
├── typing-extensions
└── anyio
任何一个子依赖发布新版本,都可能影响最终环境。
2. 平台不同,wheel 不同
同一个包在 macOS、Linux、Windows 上可能安装不同 wheel。带 C 扩展的包,如 numpy、cryptography、psycopg,更容易受到平台、ABI、系统库影响。
3. Python 版本不同,依赖树不同
很多依赖会写环境标记:
txt
importlib-metadata; python_version < "3.10"
Python 3.9 和 3.12 解析出来的依赖树可能不同。
4. pip install 不等于同步
假设环境里已经有:
text
old-debug-tool==1.0
你运行:
bash
pip install -r requirements.txt
它不会主动删除这个多余包。于是测试可能偷偷依赖了一个锁定文件里没有的包。
5. 未锁哈希,供应链风险更高
只锁版本不锁文件哈希,仍然不如锁版本 + 锁哈希可靠。实际生产项目建议尽量开启 hash checking。
六、常见错误与修复方式
错误 1:直接手写巨大 requirements.txt
不推荐:
txt
fastapi==0.115.12
starlette==0.46.2
anyio==4.9.0
sniffio==1.3.1
...
更推荐:
txt
# requirements.in
fastapi>=0.110,<1.0
然后工具生成完整锁定结果。
错误 2:生产和开发混在一起
不推荐:
txt
fastapi
pytest
ruff
mkdocs
ipython
debugpy
推荐拆开:
text
requirements/prod.txt
requirements/dev.txt
requirements/docs.txt
错误 3:库项目过度锁死依赖
如果你写的是库,不应该轻易这样:
toml
dependencies = [
"requests==2.32.3",
]
库应该给兼容范围:
toml
dependencies = [
"requests>=2.30,<3",
]
因为库会被别人的应用集成。真正需要锁死的是"最终应用"的运行环境,而不是所有可复用库的公开依赖。
错误 4:升级依赖没有流程
推荐设定固定升级节奏:
bash
pip-compile --upgrade -o requirements/prod.txt pyproject.toml
pip-compile --upgrade -o requirements/dev.txt requirements/dev.in
pip-sync requirements/prod.txt requirements/dev.txt
pytest
如果只升级某个包:
bash
pip-compile --upgrade-package fastapi -o requirements/prod.txt pyproject.toml
pip-compile 默认会尽量保留现有锁定结果;需要升级全部依赖时使用 --upgrade,升级单个包时使用 --upgrade-package。(pip-tools.readthedocs.io)
七、给团队的一份依赖管理约定
可以直接放进团队 README:
md
## Dependency Policy
1. 不直接手改生成后的 requirements/*.txt。
2. 新增运行时依赖,修改 pyproject.toml 的 [project].dependencies。
3. 新增测试、lint、docs 工具,修改 [dependency-groups] 或 requirements/*.in。
4. 变更依赖后必须重新生成锁定文件。
5. 本地和 CI 必须使用 sync,而不是随意 pip install。
6. 生产环境只安装 prod 锁定文件。
7. 每次依赖升级必须跑完整测试。
8. Python 小版本、基础镜像、系统包也属于环境契约。
八、实践结论
requirements.txt 不是过时文件,但它不应该承担所有角色。它适合作为 pip 安装清单,也可以作为由工具生成的锁定结果;但项目的"意图"更适合写在 pyproject.toml 或 requirements.in 中。
锁定文件解决的是可复现问题。它把"当前解析器碰巧选了什么"固化下来,让新同事、CI、测试环境和生产环境尽可能一致。
依赖分组解决的是环境边界问题。生产只需要运行依赖,开发才需要测试、lint、类型检查、文档和调试工具。
最终建议可以浓缩成一句 Python 最佳实践:
text
声明依赖要清晰,锁定结果要提交,安装环境要同步,依赖升级要审查。
当团队从"我这里能跑"走向"任何人都能复现",Python 编程的体验会发生质变:问题更少,发布更稳,协作更轻,代码也更值得信任。