🟡 进阶

虚拟环境:让每个项目住进自己的房间

从 Python 到 Node.js,把「运行环境」拆成四层,一次看清 venv、conda、pyenv、nvm、uv、pnpm、Docker 各自管哪一层——以及别人克隆你的仓库时,怎么才能装出跟你一模一样的那套东西。

更新于 2026-09-16

本文校对于 Python 3.14.7 · Node.js 24.21 LTS(Current 为 26)· uv 0.12.14 · 2026-09 · 官方源:docs.python.org / nodejs.org

4
层隔离
20+
会碰到的工具名
7
组易混名词
1
张速查表

01先看一个现场

周一你接手同事的项目。README 第一行写着:

pip install -r requirements.txt

你敲下去,系统回你一句:

error: externally-managed-environment

你不知道这是什么。搜了一下,有人让你加 sudo。你加了,装上了,项目跑起来了。下午你去跑自己那个半年没动的老项目——起不来了。因为上午那一步,把两个项目共用的那个库换成了新版本。

这就是「环境」问题最典型的样子:你以为你只是在装一个包,其实你改的是整台机器共享的一层东西。

这个现场里藏着三个问题,整篇文章就是在回答它们:

⏱️ 30 秒速览

读完你能做什么:遇到一个陌生的环境工具(或一段 AI 给你的命令),能判断它是四层里的哪一层,知道该不该用、和手上已有的工具冲不冲突。

先记住三句:

① 虚拟环境不是虚拟机。它不复制操作系统,只是把「用哪个解释器(真正执行你代码的那个程序,比如 python 本身)、包装在哪个目录、能读到哪些变量」这三件事挪一挪。

② 所有这类工具都在四层里的一层:换语言版本 / 隔离依赖包 / 换整个系统 / 换环境变量。判断一个陌生工具属于哪层,看它「动了什么」就行。

③ 管版本的工具不管包,管包的工具不管版本。pyenv 和 nvm 只负责「用哪个 Python / Node」,它们不会帮你隔离 requests 或 node_modules。这是新手最常踩的第一课。

3.3Python 从 3.3 起把 venv 收进了标准库,不用另外装
10–100×uv 官方宣称比 pip 快这么多,因为它用 Rust 写
2 份大多数项目你要管的就是「解释器版本」和「依赖清单」两份东西
0 行Node.js 项目通常不需要建虚拟环境——node_modules 本来就在项目里

如果你只有 5 分钟:直接跳到 §03 四层楼 看那张总表,再看 §13 一页纸速查表。剩下的章节是给「我要真的动手配一套」准备的。

02「环境」这个词,实际指两个抽屉

「环境」听起来很玄,其实拆开只有两个抽屉。理解这两个抽屉,后面所有工具都能对上号。

抽屉一:敲一个命令时,系统去哪里找它(叫 PATH)

PATH 是一张目录清单,存着一个又一个文件夹的路径。(它就是一个环境变量,只是重要到几乎每个环境问题都要先看它一眼。)你在终端敲 python 的时候,系统从左到右把这张清单看一遍,找到第一个叫 python 的文件就执行它,后面的不再看。

这意味着:「当前用的是哪个 Python」并不是一个固定事实,而是「这一刻 PATH 上谁的排位靠前」。你在终端里看到的 python,在你的编辑器里、在定时任务里、在 AI 帮你跑的那条命令里,可能是完全不同的另一份——因为它们各自有自己的 PATH。

# 看看这张清单长什么样
echo $PATH
# 看看所有叫 python 的,按 PATH 顺序列出(第一个就是会跑起来的那个)
which -a python

抽屉二:装的包,落到哪个目录(Python 叫 site-packages,Node 叫 node_modules)

「装一个包」这个动作,本质就是下载一堆文件放进某个目录。Python 默认的落点是那一个 Python 安装自带的 site-packages 目录(site 是「站点」,指这份 Python 安装自己的地盘);Node 默认的落点是当前项目目录下的 node_modules。

于是问题来了:Python 的默认落点是「这一份 Python 的所有项目共用」的。你给 A 项目装的库,B 项目也看得见——这就是开头那个现场的成因。

两个抽屉要一起动,只动一个会出事

✗ 只换了 PATH 上的解释器

用版本管理器切到 Python 3.12,然后直接 pip install requests。装进的是这一份 3.12 安装自己的 site-packages——所有用 3.12 的项目都能看见它。

这不是隔离,只是换了个更大的公共抽屉。

✓ 两个抽屉都换

先切到 3.12,再在项目里建一个虚拟环境(python -m venv .venv),激活后再 pip install。装进的是项目目录里的 .venv。

PATH 和包目录一起变了,这才叫隔离。

⚠️ 还有第三个抽屉:环境变量

除了「命令去哪找」和「包装在哪」,进程启动时还会读到一批 键=值 形式的设置,比如数据库地址、密钥、NODE_ENV=production。这类叫环境变量:不落盘到项目里,只在进程运行时存在。它是第四层,见 §08。

03四层楼:所有工具都在这四层里

你会在各种教程里撞到几十个名字,它们看起来都在做「让环境干净」这一件事。实际上它们分属四层,每层解决的问题完全不同。(先说一个后面反复出现的词:依赖=你的项目运行时要用到的、别人写好的库。你写代码时写的那几行 import,对应的就是它们。)先记住这张表,后面每一节都在展开其中一层。

层它回答的问题这一层里的代表工具用了它之后,什么东西仍然不变
第 1 层
语言版本
我敲 python / node 时,跑起来的是哪个版本的哪一份? pyenv、nvm、fnm、volta、rustup、rbenv、SDKMAN、asdf、mise 包的安装位置不变。换完版本,包还是装进那一份解释器自己的目录。
第 2 层
依赖包
这个项目用哪些库、分别是哪个版本? venv、uv、poetry、conda、pipx、npm 的 node_modules、pnpm、Bundler 解释器本身的版本不变。你只是换了一批库给它用。
第 3 层
整个系统
连操作系统里的库、编译器、另一个语言的运行时一起换掉? Docker、Podman、dev container 你的电脑本身不受影响。所有变化都关在那一个容器里。
第 4 层
环境变量
这个项目启动时,能读到哪些 键=值? direnv、mise 的环境变量功能、.env 文件 硬盘上什么都没多装。进目录时有这些变量,出目录就没了。

遇到陌生名词时,怎么判断它属于哪一层

只有一个动作:看它「动了什么」。

三层容易误解的地方

这四层是必须一层层叠着用吗?
不是。它们是职责,不是套餐。很多项目只用第 1 层 + 第 2 层;容器项目往往第 3 层就把前两层包进去了(容器里那台「小机器」自己有自己的 Node 和 Python);mise 一个工具同时干第 1 层和第 4 层。
那我是不是直接上第 3 层(容器)最省事,前面都别学了?
容器确实能一次性解决前三层,代价是:启动慢、文件同步要额外配置、调试时多一层「我到底在容器里还是容器外」的心智负担。日常写代码大多在第 1、2 层里解决;当你开始需要「系统级的依赖」(某个 C 库、某个编译器、某个只在 Linux 上有的东西)时,才值得上第 3 层。
同一个工具会不会跨层?
会。conda 既能装 Python 本体(第 1 层的意思),又能装包(第 2 层);uv 既能装 Python 版本(uv python install),又能建虚拟环境(uv venv)。跨层的工具不是异类,只是把两层的活一起干了——真正要小心的是两个跨层工具撞车(比如 pyenv 和 conda 都想管 Python 版本),详见 §12。

04第一层:换语言版本

这一层要解决的问题很朴素:一台机器上,Python 3.9 和 3.14 我都得留着。老项目锁在旧版本上,新项目想用新语法。

它们用的是同一套把戏:把 PATH 里排第一的那份换掉

回忆 §02 的抽屉一。既然「用哪个版本」等于「PATH 上谁排第一」,那么版本管理器做的事就一句话:切换时,把某个版本目录塞到 PATH 最前面。具体实现有三种风格:

实现风格怎么工作典型工具什么时候用 · 别当什么用
垫片(shim) PATH 前面放一个叫 python 的小文件(垫片),它自己再转手去调真正的解释器 pyenv、rbenv、asdf、volta 用在对「切换要快」不那么敏感、但要在多个版本间频繁跳的场景。别当包管理器用——垫片只转发命令。
改 PATH 顺序 不放假垫片,直接把真解释器所在目录提到最前面 nvm、SDKMAN 切换只影响当前这个终端窗口。别当「全局切换」用——新开一个终端可能是另一个版本。
声明式自动切换 进入目录时读一个配置文件,自动切到里面写的版本 volta、fnm(开启 --use-on-cd 后)、mise、asdf 用在「一个团队保证一致」。别当版本隔离用——它只保证版本,不保证包装在哪。

逐个认识:你实际会敲的命令

pyenv
Python 版本管理器
只管版本
它的官方 README 标题就是「Simple Python version management」。关键一点:它的「不做什么」清单里明确写着不做 Manage virtualenv——作者自己承认这不是它的职责。
怎么选版本

找版本的顺序是:环境变量 PYENV_VERSION(用 pyenv shell 设)→ 当前目录的 .python-version 文件(用 pyenv local 设)→ 往上层目录找 .python-version → 全局设置(用 pyenv global 设)。

常用命令
pyenv install 3.13.15        # 装一个版本(pyenv install -l 看全部可装)
pyenv local 3.13.15         # 在当前目录写 .python-version,这一棵树都用它
pyenv global 3.12.14         # 设默认版本
pyenv versions              # 看装了哪些
pyenv which python3         # 看现在这个命令最终指向哪个可执行文件
边界

官方不支持 Windows(社区另有一个 pyenv-win 分支)。虚拟环境要靠单独的插件 pyenv-virtualenv,或者干脆用下面 §05 里的 venv / uv。安装可以是 curl -fsSL https://pyenv.run | bash、git clone,或 macOS 上的 Homebrew。

nvm
Node.js 版本管理器
只管版本
官方描述是「一个 Node.js 版本管理器,按用户安装、按终端窗口生效」。注意:它不是一个可执行文件,而是一段被 source 进 shell 的函数——所以 which nvm 会失败,要用 command -v nvm 才能确认它存在。
常用命令
# 把版本写进项目:
echo "24" > .nvmrc
nvm install                # 读 .nvmrc,没装就装上
nvm use                    # 读 .nvmrc 切的版本,切到当前窗口
nvm install --lts
nvm ls                     # 看装了哪些
两个只有真读过文档才知道的细节

① nvm use 会从当前目录往上逐层找 .nvmrc;如果一路都找不到、命令行里也没给版本,它会以退出码 127 结束——如果你在脚本里用 nvm,这个退出码是要处理的。

② 每个 Node 版本有各自的一套全局包。npm install -g 装到哪,取决于当前激活的是哪个版本。换版本后原来的全局命令就没了,它为此提供了一个 --reinstall-packages-from 选项来搬运。

fnm

「Fast Node Manager」,用 Rust 写的单文件程序,主打启动快、跨平台(含 Windows)。它直接认 .node-version 和 .nvmrc,所以从 nvm 迁过去不用改项目文件。想让「进目录自动切」生效,要在 shell 配置里加 fnm env --use-on-cd。

volta

也是 Rust 写的静态单文件。它的杀手锏是把版本写进 package.json:volta pin node@20.16 会加一段 "volta": {"node": "20.16.0"},然后进目录自动切,团队里谁 clone 下来都拿同一份。它宣称顺带解决了「全局包」的污染问题——项目脚本不会意外依赖到你机器上的全局状态。

rustup(Rust)

一个「toolchain」(工具链)= 一套 Rust 编译器安装。channel 是「发布通道」,指你要稳定版(stable)、抢先版(beta)还是每日构建版(nightly)。项目里放 rust-toolchain.toml 就能把这一整套钉住:

[toolchain]
channel = "nightly-2020-07-10"
components = [ "rustfmt" ]
targets = [ "wasm32-unknown-unknown" ]

选哪个工具链的顺序是:命令行的 +名字 → RUSTUP_TOOLCHAIN 环境变量 → 目录 override(给某个文件夹单独指定用哪套,用 rustup override set 设置)→ toolchain 文件 → 默认。目录查找是从当前目录往上走,越近的优先。rustup show 可以看现在到底生效的是哪个。

rbenv(Ruby)与 SDKMAN(Java)

rbenv 用 .ruby-version 决定这个目录用哪个 Ruby,rbenv install 来自单独的 ruby-build 插件。注意:gem(Ruby 的包)是跟着 Ruby 版本走的,不是跟着项目走的——项目级的隔离要靠 Bundler,见 §07。

SDKMAN 管的是 JDK(写 Java 程序用的那套工具包)以及 Scala、Groovy、Gradle 等一整套围着 Java 虚拟机(JVM,Java 程序实际运行的那层「虚拟机器」)转的工具:sdk install java、sdk use(只在当前窗口)、sdk default(以后都算数)、sdk env 读项目里的 .sdkmanrc。

跨语言的两把「总开关」:asdf 和 mise

如果你同时写 Python、Node、Terraform、kubectl……给每个语言装一个版本管理器太累。这两个工具让一份配置文件管所有语言。

工具它的配置长什么样为什么用它 / 什么时候别用
asdf 项目里放 .tool-versions;插件机制,每个语言一个插件(asdf plugin add nodejs …) 用在你想要「一个文件管全栈版本」。它会从当前目录往上找到 $HOME 逐层找配置文件;没有配置会直接报错,不像有些工具会退回系统版本。legacy_version_file = yes 可以让它顺便认 .nvmrc。
mise 项目里放 mise.toml:[tools] 段写版本,还能写环境变量和任务 不只是版本管理器——它同时管环境变量(进目录设置、出目录还原,像 direnv)、任务脚本、dotfiles。它直接兼容 asdf 的 .tool-versions,从 asdf 迁过去不用重写配置。仓库里号称有 1000+ 个工具。
# mise.toml —— 一份文件,同时钉住三个语言的版本 + 一个变量
[tools]
node = "24"
python = "3.13"
terraform = "1.13"

[env]
APP_ENV = "api"
mise use node@24          # 装并写进 mise.toml
mise install              # 按配置文件把缺的都装上
mise current              # 看当前目录实际生效的版本
⚠️ 这一层最大的误解:以为换了版本就等于隔离了包

用 pyenv 切到 3.12,再 pip install requests,你以为「3.12 环境里装了 requests」——实际上你装进了这一份 3.12 安装的公共 site-packages,所有用 3.12 的项目共享它。要真正隔离,还得往下走一层,见下一节。

05第二层 · Python 篇:把包关进项目里

第二层要解决的问题是这样的:A 项目要 requests 2.28,B 项目要 2.32。你为了跑 A 升级了它,B 挂了。原因是它们用的是同一个包目录。解决办法就是给每个项目一个自己的包目录——这就是「虚拟环境」这个词最狭义的意思。

标准做法:venv(Python 自带,不用装任何东西)

Python 从 3.3 起把 venv 收进了标准库(正式说法见 PEP 405——PEP 是 Python 社区用来记录「正式提案」的编号文档,一个编号对一份决议),所以只要你有 Python,就有这个命令。

# 在项目根目录建一个虚拟环境,惯例放在 .venv 里
python3 -m venv .venv

# 激活(macOS / Linux)
source .venv/bin/activate

# 激活(Windows PowerShell)
# .venv\Scripts\Activate.ps1

# 以后 pip 和 python 就都是这个环境里的了
python -m pip install requests
python -m pip freeze > requirements.txt   # 导出当前装的版本清单

# 退出
deactivate

它到底做了什么

在 .venv 目录里放三样东西:一个叫 pyvenv.cfg 的说明文件(写着「我是基于哪个 Python 建的」)、一个 bin/(Windows 上是 Scripts/)目录,里面有一个指向真解释器的 python、以及一个空的 lib/python3.x/site-packages/。

「激活」这个动作本身没有魔法:它只是把 .venv/bin 放到 PATH 最前面(回到 §02 的抽屉一)。所以激活并不是必须的——你完全可以直接写 .venv/bin/python -m pip install requests,效果一样。

三个容易忽略的规矩

「能装出一样的」:从 requirements.txt 到锁文件

光有虚拟环境还不够。虚拟环境解决的是「装到哪」,没解决「装哪个版本」。别人拿到你的 requirements.txt,如果是手写的、只写了 requests,他今天装到的版本可能和你三个月前装的不一样。

于是有了锁文件(lock file):一份记录了「所有包及其精确版本」的清单,目的是让别人装出跟你字节级一致的一套。

做法文件什么时候用 · 别当什么用
手写依赖 + 冻结 requirements.txt 用在简单项目,或者只打算支持 pip 的场合。pip freeze 会把当前环境里所有包(含间接依赖)都写进去——清单会变长,但它如实反映了现场。
Poetry pyproject.toml + poetry.lock 用在你想要「声明依赖 + 自动解析 + 锁死版本」一条龙。默认会把虚拟环境建在 Poetry 自己的缓存目录里(不是项目内),想改到项目内要设 virtualenvs.in-project。
uv pyproject.toml + uv.lock + .venv 用在你同时想要「快」和「少装几个工具」。uv.lock 是跨平台的通用锁文件,官方明确说应该提交到版本库。

Poetry 的最小工作流

poetry new myproject          # 或者在一个已有目录里 poetry init
poetry add pendulum           # 找合适的版本约束,装好,并写进 pyproject.toml
poetry install               # 按 poetry.lock 装;没有锁文件就先解析再生成
poetry run python app.py     # 在这个环境里跑命令,不用手动激活

# 看环境在哪、用哪个解释器
poetry env info --path
poetry env use 3.13          # 指定用哪个 Python 建环境
💡 一个正在变的细节

如果你以前用过 poetry shell,会发现它在较新版本里被移到了插件里,官方现在推荐 poetry env activate(它会打印出激活命令,你可以 eval 一下让它生效)。照着老教程敲命令报错时,先想想是不是这类「搬家」。

uv 的最小工作流

uv 是 Astral 公司(也是 Ruff 的作者)用 Rust 做的,2026 年 9 月的最新版本是 0.12.14。官方对它的定位是一句很狂的话:「一个工具取代 pip、pip-tools、pipx、poetry、pyenv、twine、virtualenv」——注意这几个名字横跨了本文的第 1 层和第 2 层。

# 装 Python 本身(第一层的活)
uv python install 3.13 3.12

# 新项目
uv init example
cd example
uv add requests              # 装依赖 + 更新 uv.lock + 更新 pyproject.toml
uv run python -c "import requests; print(requests.__version__)"
uv sync                      # 别人 clone 之后跑这个,按 uv.lock 装出一模一样的

# 只想建个虚拟环境、继续用熟悉的 pip 命令也行
uv venv --python 3.12.0

# 跑一个一次性的命令行工具,不装进任何环境
uvx pycowsay "hello"          # uvx = uv tool run

项目里的 .venv 默认就放在 pyproject.toml 旁边——这一点对编辑器很友好,因为编辑器沿目录就能找到它,自动补全才不会认错解释器。

当你要装的不只是 Python 包:conda

纯 Python 的库,pip 就够了。但有些包本身带着 C 代码,需要系统里有对应的库才能编译。conda 的解法是把「非 Python 的东西」也当成依赖一起装。

官方文档给出的对照很直白:venv 是「依赖系统里已有的那份 Python」,而 conda 的「Python 独立于系统」;venv 用 pip 扩展,conda 能装 R、Perl、任意可执行文件这类非 Python 依赖。

conda create -n myenv python=3.13 # 建一个带指定 Python 版本的环境
conda activate myenv
conda env list                # 看有哪些环境(conda info --envs 等价)
conda deactivate

# 导出给别人
conda env export > environment.yml
conda env create --file environment.yml
⚠️ conda 的环境默认不在项目里

它的环境默认建在一个全局的 envs 目录下(安装根目录里的 envs/),不在你的项目目录里。想让环境跟着项目走,建的时候用 --prefix:

conda create --prefix ./envs python=3.13 requests
conda activate ./envs           # 注意:用了 prefix 就只能用路径激活,不能用 --name 找它

当你要装的是一条命令,不是一个库:pipx

有些 Python 包是给你敲的(比如一个格式化工具),不是给你 import 的。这类东西用 pip install 装进某个环境,会带来两个问题:它的依赖可能和你项目的依赖打架;想删的时候,你得记得它当初拖来了哪些东西。

pipx 的做法是每个命令行工具给一个独立的虚拟环境,然后把可执行文件挂到 PATH 上让你直接敲。官方把它类比成 macOS 的 Homebrew、JavaScript 的 npx。

pipx install ruff            # 每个工具一个独立环境,互不干扰
pipx run cowsay "一次性的,不留下"
pipx list
pipx upgrade
✅ 还有几个名字你会撞见

virtualenv 是 venv 的第三方老前辈,支持更老的 Python、有时更快——你现在新起项目用标准库的 venv 就行。PDM、Hatch 是 Poetry 的同层替代品,做的是同一件事:声明依赖 + 锁版本 + 管虚拟环境。在一层里挑一个用熟,比每个都试一遍更划算——它们大多会互相打架(都改 PATH、都管虚拟环境)。

06第二层 · Node.js 篇:默认就隔离,但隔离得不一样

如果你从 Python 过来,第一件会惊讶的事是:Node 项目通常根本不需要「建虚拟环境」。因为 npm 默认就把包装进项目目录:

官方文档写得很清楚——本地安装(默认行为)把东西放进当前项目根目录的 ./node_modules 里;只有加了 -g 才装到全局,全局的位置是「Node 安装所在的那个目录」,Unix 上通常是 {prefix}/lib/node_modules,而可执行文件挂在 {prefix}/bin。

所以 Node 的项目级隔离,是用「一个目录」实现的,而不是用一个环境。当你 import 或 require 一个包时,Node 会从当前文件所在目录开始,一层层往上找 node_modules。

Python 的默认行为Node 的默认行为
包装到哪那一份解释器自带的全局 site-packages当前项目目录下的 node_modules
要不要先「建环境」要,否则就是全局污染不用,npm install 就地建
多版本库共存靠每个项目一个 .venv靠「就近查找」:不同项目各自的 node_modules
坏处忘记建环境就污染全局目录树会非常大、非常深

那为什么 Node 还需要「版本管理器」?

因为 node_modules 只隔离了包,Node 这个程序本身还是全机一份。你机器上装的是 Node 24,而某个老项目需要 Node 18——这就是 §04 里 nvm / fnm / volta 的用武之地。它们和 npm 管的是两件完全不同的事,这一点在第 09 节还会再点一次。

锁文件:package-lock.json 与 pnpm-lock.yaml

和 Python 一样,光有目录隔离不够,还得锁版本。npm install 会顺手生成 package-lock.json,里面记着整棵依赖树的精确版本。CI 或服务器上应该用 npm ci——它严格按锁文件装,不会顺手升级任何东西。

pnpm:把「省磁盘」和「更严格」一起做了

npm 和 Yarn 一个项目一份完整拷贝,100 个项目用到同一个包就是 100 份。pnpm 的官方动机页讲了它的两个不同:

📌 一个值得记录的分歧

「npm 的 node_modules 到底算不算隔离」,两份官方材料给出的印象不一样:npm 的文档把本地安装当作默认的隔离手段;而 pnpm 的动机页指出,提升机制让代码可以拿到没声明过的依赖。两者都没说错——它们说的不是同一个强度的隔离。npm 保证了「不同项目之间不串」,pnpm 额外保证了「同一个项目里,代码只能碰它声明过的」。想从严,选 pnpm。

另外还有一个走得更远的选择:Yarn PnP——它干脆不生成 node_modules,让 Node 按一份清单去缓存里加载包。好处是安装快、没有那个巨大的目录;代价是有些工具还认死 node_modules 的存在。

Corepack:锁住「包管理器自己的版本」

还有一个更细的层次:npm 自己也有版本,Yarn 和 pnpm 更是有整套大版本之间的不兼容。Corepack 解决的就是这个——让一个项目声明「我要用哪个包管理器、哪个版本」,别人 clone 下来自动用对。

# package.json
{
  "packageManager": "pnpm@10.4.1+sha224.953c8..."
}
corepack enable             # 把 yarn / pnpm 的转发命令挂到 PATH 上
corepack use pnpm@10.4.1    # 拉取指定版本并写进 package.json
corepack install -g        # 装成全局默认
⚠️ 一条会过期的常识

很多人以为 Corepack 是 Node 内置的、一直都在。按官方说明:Corepack 随 Node.js 分发从 14.19.0 起、到 25.0.0 为止——也就是说 Node 25 及以后的版本不再自带它。2026 年 9 月的 Current 版本是 Node 26。如果你的 corepack 命令找不到,这就是原因,需要单独 npm install -g corepack。

一张命令对照表

你想做的事npmpnpm备注
按锁文件精确安装(CI 用)npm cipnpm install --frozen-lockfile别在 CI(服务器上自动跑构建与测试的流程)里用「可能顺手升级」的安装命令
装一个依赖npm i axiospnpm add axios默认都写进 dependencies
跑一次性的工具npx cowsay hipnpm dlx cowsay hi不装进项目,跑完就走
看装了什么npm ls --depth=0pnpm list --depth=0只看直接依赖,别看整棵树

07第二层 · 其他语言:三种不同的解题思路

Python 和 Node 是两条最主流的路,但「怎么隔离」这件事并没有唯一答案。看另外几种语言的解法,能帮你以后遇到新生态时快速归类。

思路一:把包装进项目目录(Ruby / Bundler)

Ruby 这边的分工是本文里最清楚的:rbenv 管 Ruby 版本,Bundler 管项目依赖,两者不重叠。而且 gem(Ruby 的包)装在哪是跟着 Ruby 版本走的——gem env home 会告诉你它在 ~/.rbenv/versions/<版本>/lib/ruby/gems/ 下面。也就是说,rbenv 本身不提供项目级隔离。

项目级隔离由 Bundler 负责。它的官方说法是「确保 Ruby 能找到 Gemfile 里所有的 gem 以及它们的依赖」——做法是把这些 gem 放到加载路径上。Gemfile 声明依赖,bundle install 会下载并评估全部 gem,形成「唯一一份权威的清单」,写进 Gemfile.lock。

# Gemfile
source "https://rubygems.org"
ruby "3.3.0"
gem "rails", "~> 7.1"

# 应用入口文件里要显式启用,Bundler 才会真正接管加载路径
require 'bundler/setup'
bundle install
bundle exec rails server   # 在 Bundler 选定的这套 gem 里跑命令

注意那个 require 'bundler/setup':Bundler 不是自动生效的,它要你主动在程序入口声明一下。这是 Ruby 生态一个容易忘的点——Gemfile 写得很漂亮但代码里没 require,运行时用的还是系统里那些 gem。

思路二:不装进项目,装进一个共享缓存(Go)

Go 的答案很不一样:依赖不放进你的项目目录。它们下载到本机一个模块缓存里,多个项目共享;项目里只留两个文件:

更特别的是,Go 把「用哪个版本的工具链」也写进了项目文件。这是一个很少见的思路:语言自己解决自己的版本问题。

go.mod 里的一行含义
go 1.24.0「这个模块至少需要这么新的 Go」——是最低要求,不是精确版本
toolchain go1.25.3「建议用这个工具链」——它的优先级高于 go 行

默认情况下 GOTOOLCHAIN=auto。这意味着:如果你机器上的 Go 比模块要求的旧,go 命令会自己去下载一个合适的版本接着跑,并且打印一行说明它换了——像这样:

go: module example.com/widget@v1.2.3 requires go >= 1.24rc1; switching to go 1.27.9

而且它很守规矩:换完之后会把这次实际用的工具链版本写回 go.mod,所以下次再跑就不会重复切换、也不会重复打印。

思路三:一个声明文件说清一切(Rust)

Rust 的 rustup 把「工具链」定义为「一套 Rust 编译器安装」。项目根放一个 rust-toolchain.toml(见 §04 的示例),就能把频道、组件、目标平台一起钉住。这个文件适合提交到仓库,官方还建议同时把 Cargo.lock 也提交。

它的选择顺序值得记一下,因为它很典型:命令行 +名字 > RUSTUP_TOOLCHAIN 环境变量 > 目录 override > toolchain 文件 > 全局默认。越「临时、越局部」的设置优先级越高。其他生态的版本管理器基本也是这个规律,记一条能省很多猜。

Java:版本归版本,依赖归依赖

JDK 的版本用 SDKMAN 这类工具管(sdk install java、sdk env 读项目里的 .sdkmanrc)。而项目依赖交给 Maven / Gradle,它们的本地仓库是一份整机共享的缓存——所以 Java 生态里,「每个项目一套隔离的依赖」这件事,传统上是在构建工具层面解决的,不是靠一个虚拟环境目录。

生态换版本用什么包装到哪一句话记住它
Rubyrbenv + .ruby-version跟着 Ruby 版本走;项目隔离靠 Bundler版本与依赖分工最清楚
Gogo.mod 的 go / toolchain 行本机共享的模块缓存语言自己把自己版本管了
Rustrustup + rust-toolchain.tomlCargo 的缓存一个声明文件钉死整条工具链
JavaSDKMAN + .sdkmanrcMaven / Gradle 的共享本地仓库版本归 SDKMAN,依赖归构建工具

08第三、四层:整个系统,与环境变量

前两层管的是「项目里的东西」。剩下两层管的是项目之外:操作系统本身,以及进程启动时读到的变量。

第三层:容器——连系统一起换

当你的项目需要的不只是一个库,而是「系统里得有个特定的 C 库」「得有个 Ubuntu 才能装的编译工具」时,第 1、2 层就管不了了。这时候要把整个「小机器」换掉。

Docker 官方文档对容器的定义是:「容器是给你的应用的每个组成部分使用的、被隔离的进程」;每个容器把自己需要的东西都带全了,「不依赖任何预先安装好的东西」。你要的东西打包成一个镜像(image,可以理解成一份只读的安装盘模板),运行起来就是容器。

docker run -d -p 8080:80 docker/welcome-to-docker   # 下载镜像并后台运行
docker ps                                        # 看正在跑的容器
docker stop <容器ID前几位>
⚠️ 精确一点:容器不是「另一台机器」

官方文档里的对照是:虚拟机是「一整个操作系统,带着自己的内核、硬件驱动、程序和应用」;而容器共享同一个内核(内核=操作系统最底层、负责跟硬件打交道的那个部分),所以能跑得更多、更省资源。所以那句「不依赖任何预先安装好的东西」,准确地说是指应用层面的依赖——内核仍然是和宿主机共用的。这就是为什么容器里的程序还必须是 Linux 程序,而不能跑一个 macOS 程序。

那容器里的项目还需要 venv 吗

我已经用 Docker 了,是不是就不用管 venv 了?
看容器服务几个项目。如果这个容器就是为这一个项目造的,直接 pip install 装进容器的系统 Python 也没问题——容器的隔离已经够用了。但如果一个容器里要跑多个 Python 项目,venv 还是需要的,否则它们照样互相污染。层与层是可以叠加的,不是互斥的。

dev container:把容器配置写进仓库

容器的一个痛点是「配置在哪」——你在本地 docker run 了一堆参数,同事并不知道。开发容器(dev container)的解法是把配置写成一个提交到仓库的文件。

官方对自己的定义是:「一份开放规范,给容器补充开发专用的内容与设置」。配置文件名是 devcontainer.json,常用字段有:

字段写什么
image用哪个现成镜像(用镜像时这个是必填)
build或者自己用 Dockerfile 构建,这时 build.dockerfile 必填
features往容器里再加一块能力,比如 GitHub CLI;按 ID 引用,安装顺序默认由各自的 installsAfter 决定
postCreateCommand容器第一次分配给用户后跑一次,适合放 npm ci 这类初始化
forwardPorts要把容器里的哪些端口映射出来
customizations编辑器相关的设置,由具体支持的编辑器定义
{
  "features": { "ghcr.io/devcontainers/features/github-cli": {} },
  "forwardPorts": [3000],
  "postCreateCommand": "yarn install"
}

这份文件的价值在于:新同事打开项目,环境就已经是他要的那个了——不用在 README 里写十步安装说明,还写不准确。

第四层:环境变量

最轻的一层。direnv 是一个「shell 扩展」——它做的事是根据你当前所在目录,加载或卸载环境变量。进目录时加载,出目录时卸掉。

# .envrc(放在项目根目录)
export DATABASE_URL="postgres://localhost:5432/dev"
export NODE_ENV="development"
direnv allow .    # 第一次必须显式允许,见下方说明
💡 为什么第一次要 direnv allow

因为 .envrc 里是会被真的执行的代码——你 clone 一个别人的仓库时,如果那里的 .envrc 能自动跑,等于让陌生人的脚本在你机器上执行。所以 direnv 默认拦下来,要你确认一次(文件改了还要再确认)。

两个边界要清楚:① direnv 本身不装任何包,它只注入环境变量;② 它只管「export 出去的变量」,官方的说明提到别名和函数(alias / function)是不能跨回你的 shell 的。它和 asdf、mise 这类工具是互补的——mise 干脆把这两件事做进同一个工具里了。

09最容易混的七组名词

这一节是给你的「查号台」:听到一个名字拿不准,来这里对一下。第三列是判断法,比定义更有用。

容易混的一对各自到底管什么一句话判断法
虚拟环境 vs 虚拟机 / 容器 虚拟环境只换「解释器和包目录」,操作系统一层没动;虚拟机带自己的内核,容器共享宿主内核但把系统和依赖打包了 问一句:它下载了一个操作系统镜像吗?下载了就是第三层,没有就是第二层。
pyenv vs venv pyenv 决定「用哪一份 Python」,venv 决定「包装哪个目录」 问:它会不会在项目里多出一个文件夹?多出来的是 venv(.venv),改的是 PATH 的是 pyenv。pyenv 的官方 README 明确写着自己不管 virtualenv。
nvm vs npm nvm 管 Node 这个程序本身的版本;npm 管项目里装了哪些库 看名字里的字母:nvm 里的 v 是 version(版本)。nvm 换的是引擎,npm 换的是零件。
conda env vs venv conda 能装 Python 之外的东西(R、Perl、任意可执行文件、系统级的库),Python 独立于系统;venv 依赖系统里已有的那份 Python 问:我要装的东西是 Python 包吗?不是(或者需要编译 C 扩展、需要 CUDA 之类),才值得上 conda。
Node 的 node_modules vs Python 的 venv 两者都实现项目级隔离,但 Node 是「一个目录」而不是「一个环境」,而且默认就有、不用建 问:我要不要先「激活」什么?Python 要,Node 不用。
Docker 容器 vs venv 容器隔离到整个文件系统(含操作系统库),venv 只隔离 Python 包 问:我的问题能不能靠「换个更大的隔离」解决?需要系统级依赖才上容器,否则容器会给你带来更多要维护的东西。
asdf / mise vs 前面所有 它们不是新的一层,而是「多个第一层工具的遥控器」(mise 还兼管第四层) 问:它自己装包吗?不装——它只让不同目录用上不同版本的工具。

10一个具体场景:新同事的第一天

把前面所有东西放进一个你能照着走的场景里:你加入一个团队,仓库里有一个 Python 后端和一个 Node 前端,还配了一个命令行工具。你要在半小时内让它跑起来。

1
先看仓库里有什么——这些文件名就是路线图
ls -a
# pyproject.toml + uv.lock        → Python,用 uv 管(§05)
# requirements.txt                → Python,用 venv + pip 管(§05)
# package.json + pnpm-lock.yaml   → Node,用 pnpm(§06)
# .nvmrc / .python-version        → 这个项目要求的语言版本(§04)
# .envrc                          → 有环境变量要加载(§08)
# .devcontainer/devcontainer.json → 整包环境已备好,可以直接用(§08)
2
把第一层准备好:让工具自己读配置文件
# 跨语言方案:一份 mise.toml 管住所有版本
mise install
mise current

# 或者分开装(Python 用 uv,Node 用 fnm)
uv python install 3.13
fnm install
fnm use
3
后端:按锁文件装出一模一样的一套
# 有 uv.lock 就走这条
uv sync
uv run python -m backend

# 只有 requirements.txt 就走这条
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
4
前端:先确认包管理器版本,再照锁文件装
node -v && which node      # 先确认版本对不对(对比 .nvmrc)
corepack enable            # 按 package.json 的 packageManager 字段准备
pnpm install --frozen-lockfile
pnpm dev
5
那个命令行工具:别装进项目,单独隔离
uvx some-tool --help      # Python 工具,跑完不留痕
pipx install some-tool    # 想常驻就装成独立环境
pnpm dlx some-tool        # Node 工具的一次性跑法
6
要连数据库 / 要密钥:第四层
direnv allow .            # 加载项目里的 .envrc
env | grep DATABASE_URL  # 确认变量真的进来了
7
不想配任何东西:直接用仓库自带的开发容器
devcontainer up --workspace-folder .
# 或者在编辑器里选「Reopen in Container」
✅ 这个场景里真正重要的一件事

你会发现「先看仓库里有什么文件」永远排在第一条。因为每个文件名本身就宣告了它想让你用什么工具:看到 uv.lock 就别用 pip,看到 pnpm-lock.yaml 就别用 npm,看到 .tool-versions 就说明团队在用 asdf 系。跟着仓库的既有选择走,比挑一个你喜欢的工具更重要——混用两个锁文件是团队协作里最常见的坏味道。

11场景索引:你现在是什么状态

这张表是按「你的处境」查的。左边找到最像你的一句,右边直接给你该做的事和该看的节。

在 AI 编程里,你撞见这些名词的场景其实很集中,无非四种:① 你让 AI 帮你跑起来一个项目,它给的安装命令在你机器上报错(多半是层不对,或者仓库里有锁文件而 AI 用了另一个工具);② 你让 AI 装一个库,它装成功了,但你另一个项目崩了(它装进了全局);③ AI 说「找不到这个包」,而你的终端里明明是好的(它跑在一个没激活虚拟环境的进程里);④ 你想让 AI 用某个特定版本来干活(得先有 .nvmrc / .python-version 这类文件,它才有依据)。下面四行就是按这四种情况写的。

你现在的状态用哪个去哪节
只是想让两个 Python 项目别互相污染,别的都不想管python -m venv .venv§05
写 Node,想知道要不要建虚拟环境不用建,npm install 就地隔离了;.gitignore 记得忽略 node_modules§06
一台机器上要同时留三个 Python 版本pyenv(或 uv python install)§04
要保证同事装出来跟我一模一样提交锁文件:uv.lock / poetry.lock / package-lock.json / pnpm-lock.yaml§05、§06
有一个包怎么都装不上(要编译、要系统库)先试 conda;还不行就上容器§05、§08
我要装的是一条命令,不是一个库pipx(Python)/ uvx / npx / pnpm dlx§05
团队要求「clone 下来打开就能跑」dev container(把环境写进仓库)§08
要把密钥、数据库地址放进项目direnv + .envrc§08
AI 给你的命令在你机器上跑不通先确认「现在用的是哪一份解释器」,见排错一节§12
让 AI 帮你干活,它却说「找不到包」/ 读不到环境它多半运行在一个没激活虚拟环境的 shell 里,用绝对路径或在它那边显式激活§12

12常见坑与排错

坑 1:error: externally-managed-environment

这是 01 节那个现场的真身。它的来历有正式文档:有一份编号 PEP 668 的提案,专门处理「操作系统的包管理器和 pip 打架」的问题——因为 pip 默认往全局装东西,而系统自己也用同一批目录。

它的机制是一个标记文件:如果 Python 的标准库目录里存在一个叫 EXTERNALLY-MANAGED 的文件,pip 就会在「你不在虚拟环境里」的时候拒绝安装。Debian、Fedora 以及 Homebrew、MacPorts 这些「发行版」(Linux 或软件包体系的不同发行版本,各自维护自己的软件清单)都会放这个文件。

✗ 加 --break-system-packages 硬装

能用,但它是「我知道我在干什么」的逃生门。官方说明里明确讲它不应该被默认开启。用它装进去的包,可能被下一次系统更新覆盖掉,或者反过来把系统工具弄坏。

✓ 建个环境,或者用 pipx

项目用 python3 -m venv .venv;命令行工具用 pipx。提案给出的建议正是这两条路:错误信息本身也应该提示你去建虚拟环境。

坑 2:建了虚拟环境,pip 却还装到全局

九成是因为没激活,或者激活之后换了终端窗口。激活只影响当前这个 shell——新开一个窗口,PATH 就回到原样了。

# 到底现在用的是哪一个?三个命令一起看
which -a python
python -c "import sys; print(sys.prefix != sys.base_prefix)"   # True = 在虚拟环境里
python -m pip --version

顺带一个更隐蔽的原因:你的编辑器 / AI 助手跑的进程,未必继承你终端里激活的那个环境。编辑器的 Python 解释器通常是单独配置的(去找 .venv 目录),而命令行工具可能根本没走你的 shell 配置。判断法永远是同一句:在出问题的那个进程里,打印它自己的解释器路径。

坑 3:把 .venv 或 node_modules 提交进了仓库

# .gitignore
.venv/
node_modules/
__pycache__/

已经提交过的,需要 git rm -r --cached .venv 把它从版本库里摘掉(文件本身保留在本地)。虚拟环境是「可丢弃、不可移植」的,别人应该用锁文件重建,而不是拿到你打包的那一份。

坑 4:pyenv 和 conda 互相打架

两个工具都想决定「哪个 Python 排第一」。它们的解法都是改 PATH,所以谁后动谁就赢——表现为「我明明 pyenv local 了 3.13,怎么还是 3.11」。排查顺序:

pyenv version          # pyenv 认为现在该用哪个
conda info --envs      # conda 当前激活了哪个
which -a python        # 真相在这:按 PATH 顺序列出

建议的做法是别让它们管同一个项目:用 conda 的项目就在 conda 环境里干活,用 pyenv 的项目就别激活 conda。

坑 5:nvm 在编辑器 / 脚本里不生效

因为 nvm 是一段 shell 函数,不是程序。非交互式 shell(脚本、CI、编辑器调起的任务)通常不会加载你的 .zshrc,所以它根本不知道 nvm 存在。两个应对:脚本里用绝对路径指向要用的 node;或者换用 fnm / volta / mise 这类「真二进制 + 按目录自动切换」的工具。

坑 6:corepack 命令找不到

见 §06:它从 Node 25 起不再随 Node 分发。装一个就好:npm install -g corepack。

通用排错法:一次问三个问题

🔧 环境问题的万能三步

① 我跑的是哪一份? which -a python / which -a node——永远先看这个,九成问题在这一步就露馅了。

② 它从哪读的配置? Python 看 python -c "import sys; print(sys.prefix)";Node 看 node -p "process.execPath";环境变量看 env | grep 你的前缀。

③ 别人凭什么能跑? 看他提交了哪个锁文件。如果仓库里有 uv.lock 而你用的是 pip,那就是问题本身。

13一页纸速查表

这张表是给你以后回来查的。左边找到你的技术栈,第二列是「换版本」,第三列是「隔离依赖」。

技术栈换语言版本(第 1 层)隔离依赖(第 2 层)最小命令
Python pyenv / uv python install venv + requirements.txt / uv / poetry / conda python3 -m venv .venv && source .venv/bin/activate
Node.js nvm / fnm / volta 无需额外工具;node_modules 天然在项目里 npm ci(或 pnpm install --frozen-lockfile)
Ruby rbenv + .ruby-version Bundler(+ require 'bundler/setup') bundle install && bundle exec …
Go go.mod 的 go / toolchain 行,GOTOOLCHAIN=auto 模块缓存(整机共享)+ go.sum go mod tidy && go build
Rust rustup + rust-toolchain.toml Cargo 缓存 + Cargo.lock rustup show(先看生效的是哪个)
Java SDKMAN + .sdkmanrc Maven / Gradle 的共享本地仓库 sdk env(读项目配置)
跨语言 asdf(.tool-versions)/ mise(mise.toml) 它们不管包——包仍归各语言自己的工具 mise install
整机隔离 Docker / Podman;写进仓库用 dev container devcontainer up --workspace-folder .
命令行工具 给每条命令一个独立环境,不污染任何项目 pipx install / uvx / npx / pnpm dlx

最容易记反的一对

14参考来源与时效说明

本文的关键事实来自下面这些一手来源。官方文档的结构是为「已经懂的人」写的,本文只借它核对事实,行文顺序是按理解障碍重新排的。

主题来源用它核对了什么
Python venvdocs.python.org/3/library/venv.html3.3 起进标准库、创建与激活命令、pyvenv.cfg、不可移植、sys.prefix 判断法
PEP 668peps.python.org/pep-0668/EXTERNALLY-MANAGED 标记文件、哪些发行版会放、为什么不建议 --break-system-packages
Python 官方打包指南packaging.python.org推荐工作流、激活后 PATH 的变化、pip freeze 与 requirements.txt、加进 .gitignore
pyenvgithub.com/pyenv/pyenv「不做 Manage virtualenv」、垫片机制、版本选择顺序、不支持 Windows
Poetrypython-poetry.org · basic-usage默认环境在缓存目录、virtualenvs.in-project、poetry.lock 的作用、env activate 取代 shell
uvdocs.astral.sh/uv/ · 项目布局取代清单、10–100× 的说法、各条命令、.venv 位置、uv.lock 应提交
condaconda 环境概念 · 管理环境与 venv 的官方对照表、默认 envs 目录、创建/导出/导入命令、--prefix
pipxpipx.pypa.io每个应用一个隔离环境、与 pip 的差别、install / run / list / upgrade
Node 版本线nodejs.org/en/about/previous-releases2026-09 的 Current 是 26、LTS 是 24、发布节奏将从 27 起改为年度制
nvmgithub.com/nvm-sh/nvm是 shell 函数而非二进制、.nvmrc 向上查找、找不到时退出码 127、每个版本一套全局包
fnm / voltagithub.com/Schniz/fnm · docs.volta.sh单文件 Rust 程序、--use-on-cd、volta pin 写进 package.json
npm 目录结构docs.npmjs.com …/folders本地装进 ./node_modules、全局在 {prefix}/lib/node_modules、v3 起提升依赖
pnpmpnpm.io/motivation内容寻址仓库、硬链接、符号链接结构、「提升让代码能访问未声明的依赖」
Corepackgithub.com/nodejs/corepackpackageManager 字段、命令、随 Node 分发的范围止于 25.0.0
rbenv / Bundlergithub.com/rbenv/rbenv · guides.rubygems.org · Gemfile 参考gem 跟着 Ruby 版本走、require 'bundler/setup'、bundle install 形成唯一权威清单
Go 工具链go.dev/doc/toolchain · 管理依赖go / toolchain 两行的含义、GOTOOLCHAIN=auto、自动切换并打印、go.sum 要提交
rustuptoolchains · overridestoolchain 定义、rust-toolchain.toml 结构、优先级顺序、向上查找
SDKMANsdkman.io/usage管 JDK 与 JVM 生态工具、sdk use 与 sdk default 的区别、sdk env 读 .sdkmanrc
asdf / miseasdf-vm.com · mise.jdx.dev.tool-versions、垫片必须在 PATH 上、mise 同时管环境变量与任务、兼容 asdf 配置
Docker / dev containerDocker 容器概念 · containers.dev · devcontainer.json 参考容器定义与虚拟机对照、「共享同一内核」、各字段含义与示例
direnvdirenv.net.envrc 的加载机制、direnv allow 的安全含义、别名与函数不导出
⏳ 时效说明(这篇会过时的地方)

本文写于 2026-09-16,涉及版本号与产品行为的判断都按这个时间点。最容易过期的几处:

① 版本号:Python 3.14.7 / Node 24.21 LTS(Current 26)/ uv 0.12.14 都只是当时的最新值,具体数字会一直变。

② Corepack 的分发范围:它「到 Node 25 为止内置」是当前的事实,未来 Node 的发布节奏本身还要改成年度制,这条随时可能再变。

③ Poetry 的命令搬迁:poetry shell 被移进插件是较近的变化,老的教程和 AI 记忆里可能还是旧的写法。

④ Node 的版本命名规则正在切换:从 Node 27 起改成「每个大版本都会进入 LTS」,所以现在这套「奇数版短命、偶数版 LTS」的记法到 27 之后就失效了。

📐 证据边界

本文对「哪个工具更好」的表态都尽量限于官方文档能支持的范围。没有一手来源支撑的判断,我在这里明说:第 09 节里「pyenv 和 conda 互相打架」的具体表现(谁后动谁赢)来自 PATH 机制的直接推论和大量社区经验,官方文档没有专门描述两者冲突;「conda 适合装带 C 扩展的包」这一说法来自 conda 官方把它与 venv 对比时强调的「管理系统级库」,但官方文档并未逐条列举 C 库、CUDA 这些具体类别,措辞比我这里的转述更保守。

99一句话总结

🎯 核心要点

「虚拟环境」这个词被几十个工具共用,但它们其实分属四层——换语言版本、隔离依赖包、换整个系统、换环境变量;判断一个新工具属于哪层,只要看它「动了什么」;而真正让团队协作不崩的,不是某一层用了什么工具,是仓库里那份锁文件被所有人尊重。

⌂