从“能装上”到“可复现”: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:不要把它误当成“万能真相”
很多团队的第一个习惯是:
pip freeze>requirements.txt pipinstall-rrequirements.txt这在小脚本里能用,但在团队项目中很容易埋雷。pip freeze记录的是“当前环境已经装了什么”,里面可能混入临时调试工具、全局污染包、过期依赖,甚至记录了并非项目真正需要的包。
更合理的思路是:
人类维护:我直接依赖什么 ↓ 工具解析:所有直接依赖 + 传递依赖 ↓ 锁定输出:给 CI / 生产 / 新同事安装例如先写一个最小输入文件:
# requirements.in fastapi>=0.110,<1.0 uvicorn[standard]>=0.29,<1.0 pydantic>=2,<3再生成锁定版:
python-mpipinstallpip-tools pip-compile --generate-hashes-orequirements.txt requirements.in生成后的requirements.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)
一句话原则:
requirements.in / pyproject.toml:人写 requirements.txt:工具生成如果团队成员手改生成后的requirements.txt,后续升级、审计、回滚都会变得困难。
二、锁定文件:可复现环境的核心
“能装上”和“可复现”之间最大的差距,是依赖解析存在很多合法答案。
比如你写:
requests>=2.30今天解析到:
requests==2.32.3 urllib3==2.2.2 certifi==2024.8.30几个月后,可能解析到:
requests==2.33.0 urllib3==2.3.0 certifi==2025.x.x这两次都“能装上”,但行为未必完全一致。
真正的锁定文件应该记录:
直接依赖 传递依赖 精确版本 环境标记 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 和部署都从锁定文件安装 |
一个更稳的生产安装命令:
pipinstall--require-hashes-rrequirements.txt如果你用pip-tools,同步环境时不要只用pip install,更推荐:
pip-sync requirements.txt因为pip install -r requirements.txt不会自动删除环境里多余的包;pip-sync会让虚拟环境精确匹配锁定文件。pip-tools文档也强调,pip-sync会安装、升级或卸载必要内容,使环境反映 requirements 文件。(pip-tools.readthedocs.io)
三、依赖分组:别让开发工具污染生产环境
团队项目通常至少有这些依赖:
生产运行:fastapi、sqlalchemy、redis 测试:pytest、coverage 代码质量:ruff、mypy 文档:mkdocs、sphinx 本地调试:ipython、debugpy如果全部塞进一个requirements.txt,生产镜像会变大,安全扫描会变吵,升级风险也更高。
现代写法可以放进pyproject.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,用户可以这样安装:
pipinstall"team-api[postgres]"[dependency-groups]是面向项目内部的开发分组。
例如测试、lint、文档构建、类型检查。Python Packaging 规范说明,依赖组适合 linting、testing 等内部开发用途,也适合不构建分发包的脚本集合;这些依赖组不会作为构建后包的元数据发布。(Python 打包用户指南)
判断标准很简单:
用户是否需要通过 pip install your-package[xxx] 使用它? 是:optional-dependencies 否:dependency-groups四、一套可直接落地的团队工作流
推荐目录结构:
team-api/ ├── pyproject.toml ├── requirements/ │ ├── prod.txt │ ├── dev.txt │ └── docs.txt ├── src/ ├── tests/ └── .github/workflows/ci.yml1. 固定 Python 版本
python--version# Python 3.12.x可以在仓库里加:
# .python-version 3.12或在 Dockerfile / CI 中明确指定:
strategy:matrix:python-version:["3.12"]2. 生成生产锁定文件
使用pip-tools:
pip-compile\--generate-hashes\-orequirements/prod.txt\pyproject.toml3. 生成开发锁定文件
如果使用分层 requirements,可以让 dev 受 prod 约束:
# requirements/dev.in -c prod.txt pytest>=8,<9 coverage[toml]>=7,<8 ruff>=0.8,<1 mypy>=1.13,<2然后:
cdrequirements pip-compile --generate-hashes-odev.txt dev.inpip-tools文档也给出了 layered requirements 的思路:开发依赖可以通过-c requirements.txt受生产依赖约束,从而保证 dev 和 prod 中共享依赖版本一致。(pip-tools.readthedocs.io)
4. 新同事本地初始化
python-mvenv .venvsource.venv/bin/activate python-mpipinstall-Upip pip-tools pip-sync requirements/prod.txt requirements/dev.txtWindows 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.txt5. CI 中禁止“漂移安装”
name:cion:pull_request:push:branches:[main]jobs:test:runs-on:ubuntu-lateststeps:-uses:actions/checkout@v4-uses:actions/setup-python@v5with: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-qCI 的关键不是“装上最新依赖”,而是“装出和团队约定一致的环境”。
五、为什么“可复现”这么难?
1. 传递依赖会变化
你只写了:
fastapi但实际会装:
fastapi ├── starlette ├── pydantic ├── typing-extensions └── anyio任何一个子依赖发布新版本,都可能影响最终环境。
2. 平台不同,wheel 不同
同一个包在 macOS、Linux、Windows 上可能安装不同 wheel。带 C 扩展的包,如numpy、cryptography、psycopg,更容易受到平台、ABI、系统库影响。
3. Python 版本不同,依赖树不同
很多依赖会写环境标记:
importlib-metadata; python_version < "3.10"Python 3.9 和 3.12 解析出来的依赖树可能不同。
4.pip install不等于同步
假设环境里已经有:
old-debug-tool==1.0你运行:
pipinstall-rrequirements.txt它不会主动删除这个多余包。于是测试可能偷偷依赖了一个锁定文件里没有的包。
5. 未锁哈希,供应链风险更高
只锁版本不锁文件哈希,仍然不如锁版本 + 锁哈希可靠。实际生产项目建议尽量开启 hash checking。
六、常见错误与修复方式
错误 1:直接手写巨大requirements.txt
不推荐:
fastapi==0.115.12 starlette==0.46.2 anyio==4.9.0 sniffio==1.3.1 ...更推荐:
# requirements.in fastapi>=0.110,<1.0然后工具生成完整锁定结果。
错误 2:生产和开发混在一起
不推荐:
fastapi pytest ruff mkdocs ipython debugpy推荐拆开:
requirements/prod.txt requirements/dev.txt requirements/docs.txt错误 3:库项目过度锁死依赖
如果你写的是库,不应该轻易这样:
dependencies = [ "requests==2.32.3", ]库应该给兼容范围:
dependencies = [ "requests>=2.30,<3", ]因为库会被别人的应用集成。真正需要锁死的是“最终应用”的运行环境,而不是所有可复用库的公开依赖。
错误 4:升级依赖没有流程
推荐设定固定升级节奏:
pip-compile--upgrade-orequirements/prod.txt pyproject.toml pip-compile--upgrade-orequirements/dev.txt requirements/dev.in pip-sync requirements/prod.txt requirements/dev.txt pytest如果只升级某个包:
pip-compile --upgrade-package fastapi-orequirements/prod.txt pyproject.tomlpip-compile默认会尽量保留现有锁定结果;需要升级全部依赖时使用--upgrade,升级单个包时使用--upgrade-package。(pip-tools.readthedocs.io)
七、给团队的一份依赖管理约定
可以直接放进团队 README:
## 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 最佳实践:
声明依赖要清晰,锁定结果要提交,安装环境要同步,依赖升级要审查。当团队从“我这里能跑”走向“任何人都能复现”,Python 编程的体验会发生质变:问题更少,发布更稳,协作更轻,代码也更值得信任。