本文基于
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。

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 范围:
yaml
packages:
- "packages/*"
- "apps/*"
这段配置看起来很普通,但它解决的是"哪些目录属于这个 Monorepo"的事实来源问题。后续所有 workspace package 都只能在这个范围内被发现和治理。换句话说,项目先声明"我管哪些目录",再谈这些目录之间怎么依赖、怎么构建。
接着是 Catalog。v0.4.8 没有把所有依赖都集中起来,而是只集中真正跨 workspace 共享、且兼容性明确的依赖:
yaml
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 配置),把根目录的 lint、typecheck、test、build 接到同一套 task graph 上。这样日常开发和 CI 不再各自维护执行顺序。
核心配置大致是这样的:
json
{
"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 推出来。
根目录命令因此变成日常入口:
json
{
"scripts": {
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
"build": "pnpm validate:workspace-boundaries && turbo run build"
}
}
package-level scripts 仍然保留,但它们的身份变了:它们是诊断入口,不是第二套 canonical orchestration。
也就是说,正常情况下从根目录跑;失败后,再用 pnpm --filter 或 pnpm --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 做了明确处理:
json
{
"@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 继续通过显式命令表达:
json
{
"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。
可以把这个设计理解成两层:
text
依赖与任务层:
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:
text
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 策略;
- 根
lint、typecheck、test、build进入统一 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⭐。这个支持对我来说很重要,也会让我更有动力继续整理后续版本的实现过程、设计取舍和踩坑复盘。