pnpm 负责依赖,Turbo 负责任务:一次 Monorepo 治理的边界取舍

48 阅读16分钟

本文基于 AI Mind 项目的真实实现整理。
GitHub:github.com/HWYD/ai-min…
对应代码版本:v0.4.8-v0.4.9
线上体验:ai.hwyblog.cloud/instant-min…

AI Mind 是一个持续迭代中的 Next.js AI Chat 项目。从最基础的本地聊天开始,逐步加入流式协议、工具调用、MCP、Skill 和 Agent 能力。

如果你对这个项目感兴趣,或者这篇文章对你有一点帮助,也欢迎顺手到 GitHub 帮 AI Mind 点个 Star⭐,这会是对我继续更新很大的鼓励。

设计 Monorepo 时,最先要想清楚的往往不是“用哪个工具”,而是“哪些东西应该放在一起、边界怎么划、验证入口由谁负责”。

但在 AI Mind v0.4.8 这一版里,我真正想解决的不是“接入 pnpm 和 Turborepo”,而是一个更基础的问题:当项目已经有多个 app 和 package 时,依赖应该由谁负责,任务应该由谁负责,哪些步骤绝对不能被普通 cache 伪装成成功。

AI Mind 当前不是一个大型组织级 Monorepo。它仍然是一个单产品项目,只是已经长出了 Webapp(主聊天应用)、Project Assistant Service(项目助手服务)、database package(数据库访问与 Prisma 基础设施)和 stream-core package(流式协议核心库)。这个规模有点尴尬:继续手写根脚本会越来越乱,直接引入大型治理体系又明显过重。

所以这篇文章不会讲“如何从零配置 pnpm + Turbo”。我更想复盘的是:在一个还不算大的真实项目里,怎样把 dependency、task、cache 和数据库副作用分到正确的位置。

这一版的核心判断是:

pnpm 负责 dependency(依赖事实),Turbo 负责 task execution(任务执行);副作用步骤保持显式,不塞进普通 task graph 的 cache。

mian.png

Monorepo 的问题不是命令不够统一

在 v0.4.8 之前,AI Mind 已经不是单目录项目了。

这里的 Monorepo 可以先简单理解为:一个仓库里放多个相互协作的 workspace。workspace 就是仓库里的一个独立工作单元,可以是 app,也可以是内部 package。它不一定要独立发布,也可能只是为了让职责边界更清楚、验证更稳定。

AI Mind 这一阶段主要有四个 workspace:Webapp 负责主要聊天体验和 AI Runtime;Project Assistant Service 负责独立的辅助服务;packages/database(数据库 package,承载 Prisma schema、Prisma Client 生成和数据库相关命令)负责数据层基础设施;packages/stream-core(流式协议 package,承载结构化 stream protocol 和前后端共享的流式数据类型)负责跨端流式协议。

这些模块放在一个仓库里是合理的,因为它们围绕同一个产品演进,也需要共享协议、类型和数据库契约。但只要项目开始变成多个 workspace,新的问题就会出现:

  • 根目录命令和 package-level commands(包级命令)容易分裂。
  • CI、Docker、本地开发可能维护三套执行顺序。
  • 共享 package 改动后,依赖它的 app 是否一定重新验证,不应该靠人记。
  • Prisma generation、migration、checkpoint setup 这类 side-effectful 步骤,不能被普通 build cache 吞掉。
  • 内部依赖如果写错,不能静默回退到 registry 或变成隐形耦合。

所以这一版不是为了把命令写得更“高级”,而是为了建立一个能解释、能验证、能继续演进的工程基线:每个 workspace 依赖谁、先验证谁、哪些结果可以进入 cache、哪些状态必须重新准备,都要有明确答案。

pnpm 和 Turbo 不解决同一个问题

这次治理里最重要的取舍,是不把 pnpm 和 Turbo 混成一句“Monorepo 工具”。

如果只用一句话区分:pnpm 管 package / dependency,Turbo 管 command / task order。

pnpm 负责的是依赖层事实:

  • 哪些目录是 workspace;
  • 内部依赖是否通过 workspace: 解析;
  • 依赖版本是否来自统一 Catalog;
  • dependency build scripts(依赖安装脚本)是否经过显式允许;
  • lockfile 是否可复现。

Turbo 负责的是任务层事实:

  • 哪个 package 的 build 应该先跑;
  • 哪些任务可以并行;
  • 哪些任务会产出可复用的 outputs;
  • 哪些任务可以 cache;
  • 哪些任务是 long-running 或 side-effectful,不能 cache。

这两个问题如果混在一起,后面会很难讲清楚。比如 @ai-mind/stream-core 被 Webapp 依赖,这是 pnpm 要维护的依赖事实;当 stream-core 改了,Webapp 的 typecheck / build 应该在它之后执行,这是 Turbo 要维护的任务事实。

这也是为什么我没有用一堆根目录脚本去手写顺序。脚本能跑,但脚本本身不会告诉我们“这个顺序为什么正确”。task graph 的价值,是让顺序来自依赖关系,而不是来自某个人刚好记得要先跑哪个命令。

先把安装变成可复现的事实

v0.4.8 第一层治理是 pnpm 基线。原因很直接:如果安装结果本身不稳定,后面的 lint、test、build 再漂亮也没有意义。

根目录、CI 和 Docker 都统一到 Node.js 22 和 pnpm@10.34.0,并继续使用同一份 pnpm-lock.yaml 做 frozen install(冻结安装)。冻结安装的意思是:安装时只接受 lockfile 里已经记录好的 dependency resolution,不允许顺手重新计算一份新依赖。

pnpm-workspace.yaml(workspace 发现、Catalog 和安装策略配置)里保留了明确的 workspace 范围:

packages:
  - "packages/*"
  - "apps/*"

这段配置看起来很普通,但它解决的是“哪些目录属于这个 Monorepo”的事实来源问题。后续所有 workspace package 都只能在这个范围内被发现和治理。换句话说,项目先声明“我管哪些目录”,再谈这些目录之间怎么依赖、怎么构建。

接着是 Catalog。v0.4.8 没有把所有依赖都集中起来,而是只集中真正跨 workspace 共享、且兼容性明确的依赖:

catalog:
  '@types/node': 22.20.1
  '@modelcontextprotocol/sdk': ^1.29.0
  dotenv: 17.2.3
  typescript: 5.9.3
  vitest: ^4.1.4
  zod: ^4.3.6

这里的取舍很重要。

Catalog 可以理解为“全仓库共享版本表”,但它不是“看起来整齐”的工具。如果一个依赖只属于 Webapp,比如 Next.js、React、UI/editor 相关库,就没有必要为了形式统一把它提升成全仓库策略。否则 Catalog 会从治理工具变成另一个隐式耦合点。

内部依赖则使用 workspace:*。它的意义是:如果我声明依赖的是本地 workspace,就必须解析到本地 workspace,不能在本地包缺失时静默去 registry 找一个同名包。

这类错误一旦混进 CI 或 Docker,会非常难查。因为命令看起来成功了,但运行的可能不是我们以为的内部包。

dependency build scripts 也要显式治理

v0.4.8 还处理了一个容易被忽略的问题:dependency build scripts,也就是依赖安装阶段自动执行的脚本。

有些 npm 依赖在安装时会自动执行脚本,用来下载二进制文件、生成本地构建产物,或者做一些安装前检查。pnpm 10 对这类脚本有更明确的治理能力。AI Mind 没有采用“全量允许”,也没有保留占位配置,而是把当前发现到的 dependency build scripts 逐项标成允许或拒绝。

比如 Prisma engines、esbuild、sharp、unrs-resolver 这类当前构建或运行确实需要的脚本被允许;一些和当前链路无关或不希望安装阶段自动执行的脚本保持拒绝。

这件事的价值不在配置本身,而在默认策略变成了 fail closed(默认拒绝):以后如果新依赖带来了新的 install script,它需要被看见、被解释、再被允许,而不是悄悄进入安装链路。

对一个 AI Native 项目来说,这种边界尤其重要。后续项目会继续引入模型、工具、MCP、Agent、数据库能力,依赖面只会变大。如果安装阶段没有明确策略,供应链行为会变成一块很难审计的暗区。

任务图不是把脚本搬到根目录

pnpm 基线解决的是“依赖是否可信”。接下来才轮到 Turbo 解决“任务怎么跑”。

这里容易踩的坑是:把每个 package 里原来的 scripts 收集到根目录,并不等于有了 task graph。真正的 task graph 要回答的是:哪些任务依赖上游 workspace,哪些任务可以并行,哪些任务失败时应该定位到哪个 workspace。

v0.4.8 新增 turbo.json(task graph、cache 和 outputs 配置),把根目录的 linttypechecktestbuild 接到同一套 task graph 上。这样日常开发和 CI 不再各自维护执行顺序。

核心配置大致是这样的:

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**", "build/**"]
    },
    "typecheck": {
      "dependsOn": ["^build", "^typecheck"],
      "outputs": []
    },
    "lint": {
      "outputs": []
    }
  }
}

这里的 ^build 表示当前 workspace 的 build 依赖上游 workspace 的 build。比如 Webapp 依赖 @ai-mind/stream-core,那么 Webapp build 之前,stream-core build 应该先完成。这个顺序不再写死在某个脚本里,而是从 workspace dependency graph 推出来。

根目录命令因此变成日常入口:

{
  "scripts": {
    "lint": "turbo run lint",
    "typecheck": "turbo run typecheck",
    "build": "pnpm validate:workspace-boundaries && turbo run build"
  }
}

package-level scripts 仍然保留,但它们的身份变了:它们是诊断入口,不是第二套 canonical orchestration。

也就是说,正常情况下从根目录跑;失败后,再用 pnpm --filterpnpm --dir 缩小范围。这样既保留了调试灵活性,又不会让项目长期维护两套“标准流程”。

cache 只属于可证明可复用的任务

Turbo 的 cache 能力很有用,但它也容易被用过头。

在 AI Mind 里,有些任务天然适合进入 cache,比如 packages/stream-core 的协议测试、Project Assistant Service 的稳定测试、纯类型检查和普通构建。它们的结果主要由源码、配置和 lockfile 决定。只要这些 inputs 没变,复用上一次结果是合理的。

但另一些任务不能这么处理。

packages/database 的 build 实际上会触发 Prisma Client 生成。数据库 migration、runtime checkpoint setup、UserMemory schema setup 这些步骤会改变外部状态。Webapp 的部分测试也会依赖数据库或可选外部服务。

这些任务如果被普通 cache 恢复,就会出现一个危险情况:命令显示通过,但它没有真的验证当前状态。对读者来说,这比失败更糟,因为失败至少会暴露问题,而“假成功”会把问题推迟到更远的地方。

所以 v0.4.8 里对数据库 build 做了明确处理:

{
  "@ai-mind/database#build": {
    "cache": false,
    "outputs": []
  }
}

这段配置表达的是一个边界:Prisma generation 可以作为构建前置被编排,但它不是一个应该从通用 cache 里恢复的普通产物任务。它和普通 TypeScript build 不一样,不能只用“源码没变”来判断是否安全复用。

这也是整篇文章最想强调的取舍之一。

Monorepo 治理不是把所有东西都塞进一个看起来漂亮的 task graph。恰恰相反,治理的价值在于知道哪些东西不应该进 cache,哪些状态变化必须显式发生,哪些失败必须暴露在正确的位置。

数据库 setup 保持显式,而不是追求“全自动”

AI Mind 的数据库链路包括 Prisma migration、Prisma Client generation,以及 LangGraph checkpoint / chat memory / UserMemory 相关 runtime schema setup。

这些步骤和普通 lint、typecheck、unit test 不一样。它们不是只读源码就能决定结果的任务,而是会依赖数据库连接、迁移状态和运行时表结构。

因此 v0.4.8 没有把它们隐藏在普通根命令里,也没有把生产部署路径改造成新的工具链。数据库 setup 继续通过显式命令表达:

{
  "db:setup:deploy": "pnpm --filter @ai-mind/database db:migrate:deploy && pnpm --dir apps/webapp db:runtime-checkpoints:setup"
}

这段命令的重点不是写法,而是位置。

它属于状态初始化,不属于可复用 task cache。它应该被清楚地放在部署或集成验证的显式步骤里,而不是让 Turbo 因为某个 cache hit 跳过它,也不是让本地开发者误以为普通 test 已经覆盖了数据库状态。

这个取舍让系统少了一点“全自动包装感”,但多了很多可解释性。

当数据库失败时,我们能知道失败发生在 migration、Prisma generation、checkpoint setup,还是后续 integration test。对于工程项目来说,这比“一个 root test 神秘失败”要有价值得多。可解释的失败,是工程治理的一部分。

CI 和 Docker 也要服从同一套边界

v0.4.8 另一个目标,是减少本地、CI、Docker 三套流程的漂移。

如果本地跑 pnpm build 是一套顺序,CI 又手写一套顺序,Dockerfile 里再维护第三套顺序,时间一长一定会分叉。某个 package 新增依赖、某个 build 前置变化,很可能只改了其中一处。

所以 v0.4.8 让 CI 的普通 lint、typecheck、test、build 进入同一套 Turbo graph;Docker builder 也使用根 pnpm build,不再手写每个 workspace 的 build 顺序。

但这里仍然保留前面说的边界:数据库 migration、checkpoint setup 这种 side-effectful 步骤仍然是显式有序步骤,不进入可复用 cache。

可以把这个设计理解成两层:

依赖与任务层:
pnpm install -> boundary validation -> turbo lint/typecheck/test/build

状态初始化层:
Prisma generation / migration / runtime setup -> integration validation / deployment path

第一层追求可复现、可并行、可以进入 cache。第二层追求显式、可定位、不可伪装。

这两层不混在一起,整个项目的验证语义才比较干净。

基线建立之后,还要继续收紧验证可信度

v0.4.8 建立的是 Monorepo 基线:依赖安装可复现,任务执行有 task graph,cache 边界开始变清楚。

但基线搭好以后,新的问题就会出现:task graph 能保证“按顺序跑”,但不能自动保证“workspace 边界没有被绕过”;测试命令能统一执行,但不能自动说明“哪些测试是稳定的,哪些依赖数据库,哪些依赖真实外部服务”。

换句话说,v0.4.8 解决的是“怎么统一跑”,v0.4.9 继续追问的是“统一跑出来的结果能不能信”。

所以 v0.4.9 沿着同一条线继续收紧。

第一,所有 workspace 身份统一成 @ai-mind/* namespace。根治理单元叫 @ai-mind/workspace,Webapp、Project Assistant Service、database、stream-core 都有唯一、私有、无歧义的身份。

第二,workspace boundary validator(边界验证脚本)从简单依赖检查升级为更完整的仓库契约。它会检查重复身份、非法依赖方向、循环依赖、未声明内部依赖、跨 workspace 相对路径 import,以及没有经过 package exports 暴露的深层实现 import。

也就是说,测试代码和生产代码一样,不能绕过另一个 workspace 的公开入口去读私有实现。

第三,测试被拆成 stable、integration、external 三条 lane:

stable:不依赖数据库和真实外部服务,可以进入 cache
integration:依赖 PostgreSQL / Prisma / runtime schema,不进入 cache
external:依赖真实云服务或模型,必须手动 opt-in,不进入普通 CI

这个拆分让 v0.4.8 的 cache 边界继续变得更严格:不是所有 test 都叫 test。一个测试能不能进入 cache,能不能进 PR CI,失败时应该归到哪个问题,必须由 lane 明确表达。

CI 也因此拆成两个 job:stable-validation 不启动 PostgreSQL;stateful-integration 依赖 stable 成功后才启动 PostgreSQL 并执行 integration lane。

这不是为了让 CI 配置更复杂,而是为了保证一个关键事实:如果静态检查或 stable test 已经失败,就不应该提前创建数据库状态,更不应该让状态初始化日志混进无状态失败里。CI 的结构本身,也是在表达工程边界。

为什么没有引入更大的 Monorepo 体系

这两版里,我刻意没有引入 Nx、remote cache、affected-only execution、Changesets、npm publishing 或大规模 package extraction。

原因很简单:AI Mind 当前还没有到那个阶段。

它是一个持续演进的 AI Native Runtime Skeleton,但仍然是单产品仓库。当前最容易出问题的不是“包太多导致调度效率低”,而是这些更基础的问题:

  • 依赖边界不够明确;
  • 根命令和 package 命令容易分裂;
  • CI、Docker、本地验证可能漂移;
  • 数据库和外部服务测试可能被普通 cache 或普通 test 语义误导;
  • 后续 Skill / MCP / Agent / 数据层继续增长时,基础工程契约不够硬。

所以 v0.4.8-v0.4.9 的取舍是小步治理。

先用 pnpm 和 Turbo 建立事实边界,再用 validator、test lane 和 CI job 边界继续收紧。等项目真的出现更多 package、独立发布需求、PR 验证耗时压力,再单独评估 affected-only、remote cache 或发布自动化。

工程治理最怕的不是慢一点,而是过早引入一套项目还解释不了的复杂体系。工具一旦超过了项目当下的复杂度,后续每次维护都会先向工具解释项目,而不是用工具解释项目。

当前结果与边界

到 v0.4.8,AI Mind 已经完成了 Monorepo pnpm / Turborepo 基线治理:

  • 本地、CI、Docker 使用统一 Node.js / pnpm 基线;
  • frozen lockfile 成为依赖复现入口;
  • 内部 package 依赖使用 workspace:*
  • Catalog 只集中共享且兼容的依赖;
  • dependency build scripts 使用显式 allow / deny 策略;
  • linttypechecktestbuild 进入统一 Turbo task graph;
  • package-level scripts 保留为诊断入口;
  • 数据库 setup 和生产部署契约保持显式,不被普通 cache 隐藏。

到 v0.4.9,这条线继续变成更强的 repository contract:

  • workspace 身份统一到 @ai-mind/*
  • source import 只能通过声明依赖和 public exports;
  • 测试分为 stable / integration / external;
  • integration 和 external 不进入 cache;
  • external smoke 只手动触发;
  • CI 先无状态验证,再状态集成验证。

这些改变都没有修改 AI Mind 的聊天 Runtime、Tool、Skill、MCP、Agent、stream protocol、公开 API、数据库业务 schema 或 UI 行为。

也就是说,这两版真正做的是工程地基,而不是产品能力扩展。

这次治理给我的一个判断

这次改造之后,我对小型 Monorepo 的判断更明确了:

Monorepo 治理的第一步,不是追求工具完整度,而是把每个工具负责的边界讲清楚。

pnpm 负责依赖,Turbo 负责任务。
Catalog 负责共享版本,不负责制造统一感。
cache 只属于可证明可复用的任务,不属于数据库状态和外部服务结果。
CI 不只是“跑命令”,它还要表达哪些验证可以先跑,哪些状态必须后置。

对 AI Mind 来说,这样的治理不会立刻带来一个新功能,但它让后续继续加 Skill、MCP、Agent、持久化和更多 runtime 能力时,仓库不会先从工程入口处散掉。

这也是我觉得 v0.4.8-v0.4.9 值得单独写一篇的原因:它不是一次工具迁移,而是一次边界收口。

项目地址

👉 GitHub:github.com/HWYD/ai-min…

👉 线上体验:ai.hwyblog.cloud/instant-min…

如果这篇文章或者 AI Mind 项目对你有所帮助,也欢迎顺手帮项目点个 Star⭐。这个支持对我来说很重要,也会让我更有动力继续整理后续版本的实现过程、设计取舍和踩坑复盘。