在 Monorepo 里使用 AI 编程,要先确认包边界

0阅读8分钟

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 buildAgent 只跑了根命令就声称「已完成」

这些信息应该写在项目的 AGENTS.mdCLAUDE.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-4xlHeadingapps/webapps/adminapps/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 typecheckpnpm --filter @app/web build
共享包改动pnpm --filter @packages/ui typecheck依赖它的每个应用的 typecheck
类型定义改动依赖它的所有包的 typecheck完整 pnpm typecheck
根配置改动pnpm typecheckpnpm 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.mdworkspace 结构、包所有权、依赖规则
代码规范目录约定 + 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 理解包边界。包边界决定了改动应该落在哪个目录、会影响哪些消费者、需要跑哪些验证命令。

三个原则:

  1. 先说结构——在 AGENTS.md 中写清楚 workspace 结构和依赖规则,让 Agent 在动手前就能判断边界。
  2. 贴近所有权——只影响一个应用就改应用目录,不要碰共享包。需要共享时才下沉到 packages/
  3. 精确验证——验证命令按包选择,共享包改动要验证所有消费者,要求 Agent 用输出证明完成。

这三条原则说起来简单,但需要在每次任务中刻意执行。Agent 不会自动学会你的 Monorepo 边界——它需要你告诉它。

参考资料

评论 0

0 / 1000