在 Monorepo 里使用 AI 编程,要先确认包边界
Monorepo 里 AI 编程最常见的问题:跨包误改
在 Monorepo 里让 AI Agent 写代码,Agent 写错逻辑的情况有,但更常见的是改错了地方。
一个典型场景:你只需要改某个应用页面的加载动画,Agent 却去改了 packages/ui 里的共享 Spinner 组件——结果所有依赖这个组件的应用都受影响。或者你让它调一个按钮的 hover 样式,它改了共享 Button 的 CSS,管理后台的深色主题被一并冲掉。
Agent 的代码能力没有问题,它分不清「该改哪里」。Monorepo 的核心约束是包边界——哪些代码是共享的、哪些是应用专属的、改了一个包会影响哪些下游消费者。Agent 不会自动理解这些边界,没有明确告诉它的话,它就按「看起来最像目标文件」的思路去改,然后把只属于一个应用的需求扩散到整个仓库。
Monorepo 的核心挑战:边界比代码多
Monorepo 让多个应用和包共享同一个仓库,带来了统一版本管理、原子化变更和简化的依赖关系等好处。但 monorepo.tools 的分析指出,统一代码库也引入了「复杂性代价」——模型可能在大量目录中「浪费 token」,跨应用的变更容易影响范围超出预期。
对 AI Agent 来说,Monorepo 的复杂性集中在包与包之间的关系上。这些关系包括:
- 依赖方向:哪些包可以被哪些包导入?
apps/能依赖packages/,但packages/不应该反向依赖apps/。 - 所有权分层:共享组件归谁管?应用层组件和基础 UI 组件的修改影响范围完全不同。
- 构建隔离:改了一个包,哪些应用需要重新验证?
- 环境差异:Server-only 的代码不能出现在 Client 组件里,但不是所有 Agent 都能自动识别这一点。
Ben Houston 在 Agentic Coding Best Practices 中建议「使用尽可能少的包,保持清晰的边界」。这个原则对人类开发者同样适用,但 AI Agent 对此更敏感——它的上下文窗口有限,边界越模糊,它越容易做出「看起来对但实际上越界」的修改。
先搞清楚你的 Workspace 结构
在让 Agent 动手之前,我通常会确保自己和 Agent 能回答这几个问题:
| 问题 | 需要确认的信息 | 常见误区 |
|---|---|---|
apps/ 下有哪些应用? | 应用名称、职责、部署目标 | 把所有应用当成同构的 |
packages/ 下有哪些包? | 包名称、导出内容、消费者列表 | 认为 packages/ 里的包都是「公共的」 |
| 依赖方向是什么? | 哪些包依赖哪些包 | 允许反向依赖导致循环 |
| 哪些代码只能在 server 端运行? | API 路由、数据库操作、服务端工具函数 | Agent 在 Client 组件里导入 server-only 模块 |
| UI 组件分几层? | 基础组件(Button/Spinner)vs 业务组件(ArticleCard/UserAvatar) | 把所有 UI 组件都放在同一个目录 |
| 根命令和包级命令的区别? | pnpm build vs pnpm --filter @app/web build | Agent 只跑了根命令就声称「已完成」 |
这些信息应该写在项目的 AGENTS.md 或 CLAUDE.md 中。Propel Code 的 2025 指南 建议「在代码库中嵌入指令,通过详细的文本文件提供上下文」,让 Agent 在动手前就能理解项目结构。
好的做法是在 AGENTS.md 中明确写出 workspace 结构:
## 项目结构
- apps/web - 主应用(Next.js App Router)
- apps/admin - 管理后台(深色主题,独立组件体系)
- packages/ui - 基础 UI 组件(Button, Spinner 等)
- packages/utils - 纯函数工具库
## 依赖规则
- apps/* 可以依赖 packages/*
- packages/* 不能依赖 apps/*
- packages/ui 不能包含业务逻辑
- apps/src/api/ 只能在 server 端使用什么都不写的话,Agent 只能看到目录结构,无法推断包之间的关系、所有权和约束。
让改动贴近所有权
Monorepo 里的每个目录都有自己的「所有权」。我后来想明白了一件事:所有权属于工程概念——谁的代码被改动了,谁就要承担回归风险。
Domain-First Nx Monorepos 一文中提到,用 packages/ 来让「所有权和边界变得显而易见」是 Monorepo 设计的核心原则。Agent 需要知道「这个改动应该落在哪个包里」,而不是觉得「在哪个包里改都一样」。
案例一:共享组件 vs 应用覆盖
场景:产品经理要求「把文章详情页的标题字号改大」。
坏的做法是直接改 packages/ui/src/Heading.tsx,把默认字号从 text-3xl 改成 text-4xl。Heading 被 apps/web、apps/admin 和 apps/docs 共同使用,三个应用的标题都会被改大。
正确的做法是在应用层覆盖样式:
// apps/web/src/app/articles/[slug]/page.tsx
import { Heading } from '@packages/ui'
export default function ArticlePage() {
return (
<article>
<Heading level={1} className="text-4xl">
{article.title}
</Heading>
</article>
)
}| 维度 | 改共享包 | 应用层覆盖 |
|---|---|---|
| 影响范围 | 所有使用 Heading 的应用 | 仅文章详情页 |
| 回归风险 | 高(3 个应用都受影响) | 低(只影响 1 个页面) |
| 验证命令 | 需要跑所有应用的验证 | 只需跑 apps/web |
案例二:复用已有组件 vs 复制实现
场景:给仪表盘页面加一个加载状态。
坏的做法是 Agent 不知道 packages/ui 已经有 Spinner,在应用目录新建了一个 LoadingOverlay 组件,手搓了一个 spinner 动画。代码能跑,但维护成本高——两套加载动画需要分别维护,不同应用可能出现不同样式的加载动画。
正确的做法是先查已有的共享组件,再决定是否需要新建:
// apps/admin/src/app/dashboard/page.tsx
import { Spinner, Overlay } from '@packages/ui'
export default function DashboardPage() {
if (isLoading) {
return (
<Overlay>
<Spinner size="lg" />
<p>加载仪表盘数据中...</p>
</Overlay>
)
}
}| 维度 | 新建组件 | 复用已有组件 |
|---|---|---|
| 新增文件 | 1 个 | 0 个 |
| 维护成本 | 高(两套实现分别维护) | 低(统一使用 packages/ui) |
| 一致性 | 差 | 好 |
我见过这种情况在小项目里影响不大,但一旦规模扩展到 5 个应用、20 个共享包,复制导致的碎片化会在几个月后变成难以清理的技术债。
案例三:跨包重构的连锁反应
场景:把 User 类型从 apps/web 移到 packages/types,方便其他应用复用。
任务本身合理。但我发现 Agent 在做这件事时,经常忽略两件事:原始类型被多个文件导入,以及实现代码里可能混着 server-only 的逻辑。
坏的做法:Agent 直接把整个 user.ts 移到 packages/types/,只更新了部分导入。更糟的是,原始文件里 getUserFromServerSession 引用了 process.env.DATABASE_URL,这段 server-only 代码被一起移到了共享包,任何应用导入时都会触发服务端变量泄露。
正确的做法是拆分类型定义和实现,只移动纯类型:
// packages/types/src/user.ts - 只放类型
export interface User {
id: string; name: string; email: string
}
// apps/web/src/lib/user-service.ts - 保留服务端实现
import type { User } from '@packages/types'
export async function getUserFromServerSession(): Promise<User | null> {
const db = await connectDB(process.env.DATABASE_URL)
return db.user.findFirst()
}| 维度 | 整体移动 | 只移动类型 |
|---|---|---|
| 导入更新 | 遗漏 8/12 个文件 | 只更新实际引用类型的位置 |
| server-only 泄露 | 是 | 否 |
| 验证范围 | 需要完整 CI 才能发现 | typecheck 就能发现遗漏 |
验证命令必须精确到包
Dortort 的分析 指出,AI Agent 在 Monorepo 中最擅长「跨层变更」——修改共享接口并更新所有消费者。但这把双刃剑的另一面是:验证不够精确的话,Agent 永远不知道自己改坏了什么。
Agent 最常见的验证偷懒行为是:只跑了目标包的 typecheck,就声称「已完成」。但共享包的改动可能影响下游消费者。
| 改动类型 | 最小验证命令 | 完整验证命令 |
|---|---|---|
| 应用层 UI 改动 | pnpm --filter @app/web typecheck | pnpm --filter @app/web build |
| 共享包改动 | pnpm --filter @packages/ui typecheck | 依赖它的每个应用的 typecheck |
| 类型定义改动 | 依赖它的所有包的 typecheck | 完整 pnpm typecheck |
| 根配置改动 | pnpm typecheck | pnpm build + pnpm test |
好的做法是在任务描述中写明验证命令:
## 任务:修改 Button 的 hover 样式
### 改动范围
- packages/ui/src/Button.tsx
### 验证步骤(必须全部通过才能声称完成)
1. pnpm --filter @packages/ui typecheck
2. pnpm --filter @app/web typecheck
3. pnpm --filter @app/admin typecheck
4. pnpm --filter @app/web build坏的做法是只说「改完跑一下 typecheck」——Agent 跑了共享包的 typecheck 通过了就声称完成,但 apps/admin 的深色主题被破坏了。
这种差异可以固化到 AGENTS.md 中:
## 验证规则 [CRITICAL]
- 共享包改动:必须跑所有依赖它的应用的 typecheck
- 应用层改动:跑目标应用的 typecheck + build
- 声称完成前必须附上验证命令的输出给 Agent 的上下文工程
Monorepo 场景下,Agent 需要的不只是「要改什么」,还有「不能改什么」。根据 monorepo.tools 的建议,让 Agent 高效工作需要三件事:暴露架构关系图、使用可预测的代码脚手架、保持紧密的反馈循环。
| 上下文类型 | 实现方式 | 示例 |
|---|---|---|
| 项目结构 | AGENTS.md / CLAUDE.md | workspace 结构、包所有权、依赖规则 |
| 代码规范 | 目录约定 + lint 规则 | apps/src/components/ 只放业务组件 |
| 依赖关系图 | turbo.json / nx.json | 声明包之间的依赖拓扑 |
| 验证命令 | 任务描述 + 项目规则 | 精确到包的 typecheck 和 build |
| 禁止操作 | AGENTS.md 约束列表 | 「packages/ui 不能包含业务逻辑」 |
一个实用的做法是在每个包的 package.json 中维护清晰的依赖声明——这既是给构建工具看的,也是给 Agent 看的:
// packages/ui/package.json
{
"name": "@packages/ui",
"dependencies": { "@packages/utils": "workspace:*" },
"peerDependencies": { "react": "^19.0.0" }
}
// Agent 可以推断:ui 依赖 utils,不依赖任何 apps/*,需要 React 19完整检查清单
任务开始前
- 明确 workspace 结构:
apps/和packages/下各有哪些包 - 明确包所有权:哪些是共享包,哪些是应用专属
- 明确依赖方向:
apps/*能依赖packages/*,反之不行 - 检查
AGENTS.md是否包含项目结构说明 - 确认 UI 组件分层:基础组件 vs 业务组件是否分开
任务描述中
- 改动范围精确到包和文件
- 只影响一个应用的改动,指定改应用目录
- 复用组件时,明确指出已有组件的位置
- 跨包移动代码时,区分类型定义和运行逻辑
验证阶段
- 验证命令精确到包(
pnpm --filter) - 共享包改动验证所有依赖它的应用
- 要求 Agent 附上验证命令的输出
- 根配置改动跑完整验证(typecheck + build + test)
结论
Monorepo 里使用 AI 编程,核心挑战是让 Agent 理解包边界。包边界决定了改动应该落在哪个目录、会影响哪些消费者、需要跑哪些验证命令。
三个原则:
- 先说结构——在
AGENTS.md中写清楚 workspace 结构和依赖规则,让 Agent 在动手前就能判断边界。 - 贴近所有权——只影响一个应用就改应用目录,不要碰共享包。需要共享时才下沉到
packages/。 - 精确验证——验证命令按包选择,共享包改动要验证所有消费者,要求 Agent 用输出证明完成。
这三条原则说起来简单,但需要在每次任务中刻意执行。Agent 不会自动学会你的 Monorepo 边界——它需要你告诉它。
参考资料
- Monorepo Explained — monorepo.tools — Monorepo 概念、工具对比和 AI 集成分析
- Monorepo vs Multi-Repo: Why AI Agents Tip the Scale — Dortort — AI Agent 如何改变了 Monorepo vs Multi-Repo 的权衡
- Agentic Coding Best Practices — Ben Houston — AI 辅助编程的实践原则,包含包边界和验证建议
- Structuring Your Codebase for AI Tools: 2025 Developer Guide — Propel Code — 面向 AI 工具的代码库结构设计指南
- Domain-First Nx Monorepos — 用 packages/ 目录让所有权和边界变得显而易见
- Structuring a Repository — Turborepo — Turborepo 官方仓库结构指南
- Will AI turn 2026 into the year of the monorepo? — Spectro Cloud — AI 时代 Monorepo 的趋势分析
- Claude Code vs Cursor vs Copilot: Monorepo Navigation — SNEOS — 主流 AI Agent 在 Monorepo 导航能力上的对比