这是我在开发 My-Notion 项目时踩的一个真实坑——本地开发一切正常,推到 GitHub 后 CI/CD 构建直接失败。排查后发现是幽灵依赖(Phantom Dependencies)问题,而罪魁祸首竟然是 AI Agent 用错了包管理器。
问题复现
某天我 push 代码后,GitHub Actions 的 Build 流水线报红了:
Error: Cannot find module '@qdrant/js-client-rest'
奇怪,我本地跑得好好的啊。
看了一下代码,packages/ai/rag/qdrantVectorStore.ts 里确实用了 @qdrant/js-client-rest:
import { QdrantClient } from "@qdrant/js-client-rest";
再看 packages/ai/package.json,依赖声明是这样的:
{
"dependencies": {
"@langchain/qdrant": "^1.0.1",
// ... 其他依赖
}
}
注意——@qdrant/js-client-rest 并没有在 package.json 中声明,但代码里直接 import 了它。
本地能跑是因为 @langchain/qdrant 依赖了 @qdrant/js-client-rest,而 npm 在安装时会把它提升(hoist)到 node_modules 根目录,所以代码能找到这个包。但线上用 pnpm 构建时,pnpm 严格的依赖结构不允许访问未声明的依赖,直接报错。
什么是幽灵依赖
幽灵依赖(Phantom Dependencies)是指代码中实际使用了某个包,但该包没有在 package.json 中显式声明,而是通过其他包的依赖间接引入的。
用一张图来解释:
你的代码
└─ import { QdrantClient } from "@qdrant/js-client-rest" ← 直接使用
↑
│ (没有在 package.json 中声明)
│
@langchain/qdrant (package.json 中声明了)
└─ @qdrant/js-client-rest ← 间接依赖
你的代码能访问 @qdrant/js-client-rest,完全是因为 @langchain/qdrant 装了它。但这个关系是隐式的、脆弱的——一旦 @langchain/qdrant 升级版本不再依赖 @qdrant/js-client-rest,或者换了一个替代包,你的代码就会莫名其妙地挂掉。
为什么 npm 会有幽灵依赖,pnpm 不会
核心区别在于 node_modules 的目录结构。
npm 的扁平结构(Flat)
npm v3+ 采用扁平化安装,所有依赖(包括间接依赖)都会被提升到 node_modules 根目录:
node_modules/
├── @qdrant/js-client-rest/ ← 被提升上来了,你的代码能直接访问
├── @langchain/qdrant/
│ └── node_modules/
│ └── (空的,因为被提升了)
├── langchain/
├── openai/
└── ...
这种设计的好处是安装快、兼容性好,但代价就是幽灵依赖——你可以 import 任何被提升到根目录的包,不管你有没有声明它。
pnpm 的严格结构(Strict)
pnpm 采用软链接 + 硬链接的方式,每个包只能访问自己声明的依赖:
node_modules/
├── .pnpm/ ← 真实存储位置
│ ├── @qdrant+js-client-rest@1.17.0/
│ │ └── node_modules/
│ │ └── @qdrant/js-client-rest/
│ └── @langchain+qdrant@1.0.1/
│ └── node_modules/
│ ├── @langchain/qdrant/
│ └── @qdrant/js-client-rest/ ← 软链接,只有 @langchain/qdrant 能访问
├── @langchain/qdrant/ ← 软链接到 .pnpm
├── langchain/ ← 软链接到 .pnpm
└── (没有 @qdrant/js-client-rest!) ← 你的代码找不到它
在 pnpm 的结构下,@qdrant/js-client-rest 只存在于 @langchain/qdrant 的依赖树中,你的代码如果不显式声明,根本访问不到。
这正是 pnpm 的设计初衷——通过严格的依赖隔离,在开发阶段就暴露幽灵依赖问题,而不是等到线上部署才炸。
这个坑是怎么产生的
在我的场景中,问题出在 AI Agent 用了 npm install 而不是 pnpm add 来安装包。
我:帮我安装 @langchain/qdrant
Agent:npm install @langchain/qdrant ← 用了 npm!
npm 安装后,@qdrant/js-client-rest 被提升到了 node_modules 根目录。Agent 在写代码时,直接 import 了 @qdrant/js-client-rest,本地运行完全没问题——因为 npm 的扁平结构让它"看得见"这个包。
但 CI/CD 环境用的是 pnpm,严格的依赖结构直接暴露了这个幽灵依赖。
怎么解决
1. 显式声明依赖
把代码中实际使用的间接依赖,显式添加到 package.json 中:
pnpm add @qdrant/js-client-rest
{
"dependencies": {
"@langchain/qdrant": "^1.0.1",
+ "@qdrant/js-client-rest": "^1.17.0"
}
}
这是最根本的解决方案——你用了什么就声明什么,不依赖其他包的间接引入。
2. 清理并重装依赖
如果项目之前用 npm 装过包,node_modules 里可能残留着扁平结构下的幽灵依赖。需要彻底清理后用 pnpm 重装:
# 删除所有 node_modules
find . -name "node_modules" -type d -prune -exec rm -rf {} +
# 删除 lock 文件(如果有 package-lock.json)
find . -name "package-lock.json" -delete
# 用 pnpm 重新安装
pnpm install
重装后,pnpm 的严格结构会立刻暴露所有幽灵依赖——import 不到的包就是没声明的,一个个补上就行。
3. 让 AI Agent 统一使用 pnpm
问题的根源是 Agent 用了 npm。为了防止再犯,我让 Agent 写了一个全局 Skill,后续所有安装包的操作都强制使用 pnpm:
## 包管理器规则
- 本项目使用 pnpm 作为包管理器
- 安装依赖:pnpm add <package>
- 安装开发依赖:pnpm add -D <package>
- 全局禁止使用 npm install / yarn add
- Monorepo 中安装到指定包:pnpm --filter <package-name> add <dep>
这样 Agent 每次对话都会读取这条规则,不会再出现用错包管理器的问题。
如何检测幽灵依赖
除了等 pnpm 报错,还有更主动的检测方式:
pnpm 的 --strict-peer-dependencies
pnpm install --strict-peer-dependencies
安装时严格检查 peer dependencies,有冲突直接报错而不是静默跳过。
dpdm 工具
dpdm 可以扫描代码中的依赖引用,找出未声明的依赖:
npx dpdm src/index.ts
knip 工具
knip 可以检测未使用的依赖、未声明的依赖、以及各种死代码:
npx knip
总结
| npm | pnpm | |
|---|---|---|
| 依赖结构 | 扁平化,间接依赖提升到根目录 | 严格隔离,只能访问声明的依赖 |
| 幽灵依赖 | 本地不会报错,线上可能炸 | 开发阶段直接暴露 |
| 安装速度 | 较慢 | 快(硬链接 + 内容寻址) |
| 磁盘占用 | 每个项目独立存储 | 全局存储,多项目共享 |
幽灵依赖的本质是依赖声明和实际使用不一致。npm 的扁平结构掩盖了这个问题,让它在本地"看起来没问题",但线上部署时就会暴露。pnpm 的严格结构在开发阶段就强制你声明所有使用的依赖,虽然前期多写几行 package.json,但换来的是部署时的安心。
如果你也在用 pnpm + Monorepo,建议:
- 永远不要混用 npm 和 pnpm——一旦用 npm 装过包,
node_modules结构就被污染了 - 代码中 import 了什么,
package.json就声明什么——不要依赖间接依赖 - 让 AI Agent 也遵守包管理器规则——写好项目规则文件,防止 Agent 用错工具
- CI/CD 用 pnpm 构建——线上构建和本地开发保持一致,问题在本地就能发现
本文基于 My-Notion 项目的真实踩坑经历撰写,项目是一个 AI 原生的个人版 Notion,欢迎 Star ⭐