monorepo 里 AI 老是改错包,怎么把边界和路径说清楚

2026-07-28

内容截至 2026-07。文中命令基于 Git 与常见包管理器的通用行为,其中 git stash push 属于较新的写法(更老的 Git 用 git stash save),跑之前先 git --version 看一眼;各包管理器的过滤器语法、退出码行为与工具的区域可用性,以官方最新说明为准。

多数人把这件事归给”模型不够聪明”,其实多数情况是你的仓库对 AI 来说根本没有”包”这个概念——它看到的只是一堆同名文件。 在一个有 40 个子包的仓库里,src/index.tssrc/utils/format.tspackage.jsontsconfig.json 这些名字各出现几十次。你说”改一下 format 里的日期处理”,AI 面对的是几十个候选,它挑了一个看起来最像的,然后在回复里自信地写下相对路径。你以为它改的是 packages/date-utils/src/format.ts,它改的是 apps/admin/src/utils/format.ts。测试没跑到那儿,评审也扫过去了,问题在两周后另一个人排查线上 bug 时才浮出来。

这篇只谈大仓里的”改错包”这一类故障:怎么分因、怎么验证、怎么改造仓库让它不再发生。站内另有两篇相邻的文章分工不同——CLAUDE.md 怎么写讲的是项目说明文件本身的写法和结构,大仓库上下文不足讲的是代码量超过模型可视范围时怎么切分和检索;本篇不重复它们,只聚焦”多包共存导致的定位歧义”这一个具体病灶,也就是上下文够用、说明文件也写了,AI 依然改错地方的那种情况。

一、先分清三种”改错包”,它们的处置完全不同

不要一上手就去改说明文件。这三种成因的修法互斥,搞错了等于白干。

第一种:路径歧义。 现象是 AI 改的文件名对、内容方向也对,但包不对。它在回复里说”已修改 src/format.ts”,没写包前缀。你的仓库里这个相对路径有多个实体,它选错了。判别方法很直接:把 AI 提到的路径拿去仓库里全局搜同名文件,如果命中超过一个,基本可以定案。搜的时候用受版本控制的文件清单比用文件系统遍历干净,不会把产物目录和依赖目录一起翻出来:

git ls-files '*/format.ts'

Git 的 pathspec 里 * 是可以跨目录分隔符匹配的,所以上面这条会把仓库中所有层级下叫 format.ts 的文件都列出来。输出多于一行,就说明”只写文件名”这种指代方式在你的仓库里本身就是不成立的。

第二种:包边界不清。 现象是 AI 改的包”看起来合理但职责错位”——你要在共享 UI 包里加一个组件,它加到了业务应用里;你要改后端的校验规则,它改了前端的表单校验。这种时候它的路径是唯一的、没有歧义,错的是它对”这段逻辑该住在哪个包”的理解。判别方法是问它一句”这个改动为什么放在这个包”,如果它的理由是”这里已经有类似代码”而不是”这个包的职责是 X”,说明它是靠相似度而不是靠边界在判断。

第三种:过滤器与工作区解析不一致。 现象是改动的位置其实是对的,但验证环节骗了你——构建/测试命令的作用范围没覆盖到被改的包,于是”全绿”,实际根本没编译到。或者反过来,AI 按你给的命令跑,命令匹配不到任何包却返回成功退出码,它据此判断”改完了、验证过了”,继续往下堆错误。这一种最阴,因为它不是改错包,而是让你无法发现改错了包。

三者可以同时存在。排查顺序建议按上面的次序,因为第三种会掩盖前两种——先把验证链路修好,再回头看定位准不准。

二、判别表:从现象倒推成因

现象大概率成因怎么验证处置动作
AI 回复里的路径不带包前缀,文件名在仓库里重复路径歧义git status --short 看实际改动落在哪个目录,与它口述的对照要求它一切路径必须从仓库根写起;在项目说明里给出包清单与一句话职责
改动落在功能相近但职责不同的包包边界不清反问改动归属理由;检查该包的依赖方向是否被反转在说明文件里写明每个包的职责与允许的依赖方向,并给两三条”这类改动放哪儿”的示例
命令输出”成功”但没有任何编译日志过滤器匹配不到包手动进入被改包目录直接跑本地脚本,对比是否有输出换用基于路径的过滤形式;给命令加上”匹配为空就失败”的校验
测试全绿但线上行为没变改的是同名文件的另一份副本git diff --stat 确认改动路径;再看构建产物时间戳同上,先修路径歧义,再重跑
AI 反复在两个包之间来回改同一处存在重复实现,边界本身就是坏的搜索关键函数名,看是否有两份近乎相同的实现停止让 AI 修,先人工合并重复代码,再交回去
类型报错指向另一个包的旧类型工作区软链与构建缓存不一致删掉本地缓存与产物目录后重装依赖,再复现清理后重跑;若仍复现,检查包的入口字段与产物路径是否对得上
改动被下一次操作覆盖多个会话或代理并行动同一批文件未提交的看文件修改时间(Linux 上 ls -l --time-style=full-iso <文件>,macOS 上 stat -f %Sm <文件>)与各会话操作时间线对照;已提交的用 git log --oneline -- <路径> 看提交是否交错串行化共享文件的改动,参考多会话并发冲突的做法

这张表的用法是:先看现象列找到最贴近的那一行,只做那一行的验证。不要一次性把所有处置动作都执行掉,否则你不知道是哪一条起了作用,下次照样犯。

三、动作一:把”包”变成 AI 能看见的东西

AI 不会自己推断出你的包结构语义。你得把它写下来,而且写在它一定会读到的地方。

第一件事是在仓库根的项目说明文件里放一张包清单,格式尽量机械:

packages/date-utils   —— 纯函数日期工具,无框架依赖,禁止引入业务概念
packages/ui           —— 通用组件,只依赖 date-utils,禁止依赖任何 apps/*
apps/admin            —— 后台应用,可依赖 packages/*,禁止被任何包依赖
services/api          —— 后端服务,禁止依赖 apps/*,共享类型走 packages/contracts

关键不是清单本身,而是那些”禁止”。职责描述给的是正向倾向,禁止条款给的是硬边界,后者在判断歧义时更有用。这一步的写法细节和整体结构,CLAUDE.md 怎么写那篇讲得更全,这里只强调 monorepo 特有的部分:一定要写依赖方向,不写方向的清单等于没写。

第二件事是要求路径一律从根写起。这条规则简单但收益极高。你在说明文件里加一句”引用任何文件时必须写仓库根相对路径,例如 packages/ui/src/Button.tsx,不得只写 src/Button.tsx”,之后你在回复里看到不带前缀的路径,就直接知道它没定位清楚,可以当场打断而不是等到改完。

第三件事是显式给出”我要改的是哪个包”。别指望 AI 自己找。你说”改 packages/date-utils 里的时区处理”,比说”改一下日期工具的时区问题”可靠得多。这不是迁就工具,这是消除歧义的最小成本做法——你打十几个字符,换掉一次全量返工。

四、动作二:让验证环节没法骗你

第三种成因的核心风险是”静默成功”。包管理器的过滤器语法在匹配不到目标时,未必返回非零退出码,于是脚本继续往下走,CI 也报绿。这不是某个工具的 bug,而是过滤语义的普遍陷阱:过滤器表达的是”符合条件的包都跑”,空集合也符合这个描述。

两个办法。一是尽量用基于路径的过滤形式而不是基于包名的形式——路径写错了你自己肉眼能看出来,包名写错了只有工具知道。二是在脚本里自己加一道校验,思路是在跑构建之前先确认目标真的存在——目标目录里有 package.json,才认为这是一个包;不存在就立刻以非零退出码失败,不给”静默成功”留机会:

#!/usr/bin/env bash
set -euo pipefail

TARGET="${1:?usage: verify.sh <package-dir>}"

if [ ! -f "$TARGET/package.json" ]; then
  echo "错误:$TARGET 下没有 package.json,过滤目标不存在" >&2
  exit 1
fi

echo "验证目标:$TARGET"
# 后续在此目录内执行该包自己的构建与测试脚本

set -euo pipefail 这行有必要拆开说清楚,别当口号抄。-e 让中间步骤一旦失败就中止脚本——不加它,失败的那步只是打条错误信息,脚本继续往下走,整个脚本的退出码取决于最后一条命令,于是”前面炸了、最后一句 echo 成功”就变成了对外宣称的成功。-u 让引用未定义变量直接报错,避免包路径变量拼空后跑到仓库根上去操作。-o pipefail 让管道的退出码取自失败的那一环,不加它,build | tee log 这种写法里 build 挂了也会被 tee 的成功掩盖。三者都是为了同一件事:让失败可见。

TARGET="${1:?usage: verify.sh <package-dir>}" 这个写法也顺带解决了参数漏传的问题——没传第一个参数时,脚本会打出那句提示并以非零退出码结束,不会带着空目标往下跑。

还有一个廉价但极有效的习惯:每次让 AI 改完,先看 git status --shortgit diff --stat,用眼睛确认改动落点,再看它说了什么。

git status --short
git diff --stat

两条命令三秒钟,能挡掉绝大部分”改错包”。顺序很重要——先看客观事实,再读它的自述,反过来你会被它的叙述带跑。AI 改动范围失控的一般性防线在改动范围失控那篇有更系统的讲法,大仓场景下最有价值的就是这两条命令的前置化。

五、动作三:按包切会话,而不是按任务切

很多人在大仓里的做法是一个会话干一整条需求,需求横跨五个包,AI 在包之间来回跳。这种模式下改错包几乎是必然的:它每次跳转都要重新判断当前语境属于哪个包,判断次数越多,出错概率越高。

更稳的做法是按包分段。一个会话只动一个包,把跨包的接口先定下来当作契约,各包分别对着契约改。这么做有三个直接收益:改动落点的候选集合从”整个仓库”缩到”一个目录”;每段的验证范围明确,跑单个包的测试就够;出问题时回滚粒度小,git revert 一个提交就干净了。

代价是你要多花心思在契约上。如果跨包接口本身没想清楚就分段,你会得到五个各自自洽但拼不起来的实现。判断标准很简单:如果你自己都说不清 A 包该给 B 包暴露什么,那先别开工,这是设计问题不是工具问题。

跨包的类型定义放哪儿也是个反复出问题的点。经验是宁可单独立一个只放类型和契约的包,也不要让两个业务包互相 import 类型——后者会让依赖图出现双向边,AI 一旦看到双向依赖,对”该改哪边”的判断就彻底失去锚点,你在说明文件里写的依赖方向也自相矛盾了。

六、什么情况下别再折腾

排查要有止损点,否则你会在一个坏结构上无限投入。以下四种情况,停手。

同一处逻辑被 AI 来回改了三次以上,每次都在两个包之间摇摆。 这是仓库在告诉你有重复实现或职责重叠。继续追问、继续补说明文件都没用,因为它面对的确实是两个都说得通的位置。止损动作:人工把重复实现合并掉,或者明确废弃一份,然后再让 AI 接手。合并这件事本身不适合交给 AI,因为它没有历史上下文判断哪份是”活的”。

你为了让 AI 找对包,已经在说明文件里写了超过一屏的规则,还是不准。 说明文件太长本身会稀释注意力,写到某个体量之后收益转负。止损动作:把长规则拆到各个包自己的目录下,让局部规则跟着代码走;根目录只留包清单和依赖方向。

改动已经污染了多个包,git diff 看不清全貌。 不要在乱掉的工作区上继续修。止损动作:git stash 或直接丢弃当前改动,回到干净基线重来。人的直觉是”改了这么多不能白费”,但在多包污染的场景下,从干净基线重做一遍通常比逐个甄别改动更快,而且结果可信。

git stash push -m "monorepo 定位混乱,回基线重做"
git status --short   # 确认工作区干净

你发现真正的问题是包划分本身不合理。 比如一个包同时承担了配置、工具函数和业务逻辑三种角色,任何”这该放哪儿”的问题都没有唯一答案。这时候换条路:先做一次小范围的包拆分(人工),再谈自动化。你在坏边界上叠多少规则,都换不来确定性。

判断”该回滚还是该继续”的一条实用依据:如果你能用一句话说清”正确的改动应该落在哪个文件的哪一段”,那就继续,把这句话直接给 AI;如果你自己也说不清,回滚,先把它想清楚。这条依据同样适用于其他 AI 改坏代码的场景,改坏代码怎么回滚那篇讲的回滚纪律可以配合着用。

七、避坑清单

坑一:只在根目录放说明文件,包内什么都不写。 为什么会踩:根说明文件是全局的,写细了会膨胀,写粗了不够用,两难之下多数人选择写粗,于是包级约定无处安放。怎么避:根目录只放包清单与依赖方向,每个包目录下放自己的局部说明,写清这个包的对外接口、不该出现什么、测试怎么跑。局部规则跟着代码走,改包的时候顺手就能更新。

坑二:让 AI 自己”探索一下仓库结构”。 为什么会踩:探索会消耗大量上下文去读无关文件,读完之后它对结构的理解仍然是概率性的,而且这份理解下一个会话就没了。怎么避:把结构结论写成静态文本,让它读结论而不是重新推导。你写一次,用无数次。

坑三:用包名做过滤器,且不校验命中数。 为什么会踩:包名和目录名经常不一致(比如带作用域前缀),写错一个字符就匹配为空,而空匹配往往不报错。怎么避:优先用路径形式过滤,并在脚本里显式检查目标目录存在、命中集合非空,命中为空就退出非零。

坑四:跨包改动一次提交。 为什么会踩:一次改五个包看起来省事,出问题时你既不知道是哪个包的改动引起的,也没法只回滚坏的那部分。怎么避:按包分提交,提交信息里带包路径前缀。回滚粒度等于你的提交粒度,这是硬约束。

坑五:默认 AI 说的路径就是它改的路径。 为什么会踩:它的自述是生成出来的文本,和实际文件操作之间没有强绑定,尤其在多轮修改后容易前后不一致。怎么避:只信 git statusgit diff,把看 diff 变成肌肉记忆,别把自述当验收依据。

坑六:清缓存当万能解药。 为什么会踩:工作区软链和构建产物不一致确实会导致奇怪的跨包报错,清一遍就好了,于是清缓存成了条件反射。但如果根因是包入口字段和产物路径对不上,清完下次还会犯。怎么避:清缓存只作为一次性验证手段——清完好了,就去查为什么会不一致,而不是把它写进日常流程。

坑七:把海外工具的可用性当默认前提。 为什么会踩:很多大仓工程实践的资料默认你能直连某些海外 AI 工具或模型服务。实际情况是多数海外 AI 服务的官方条款把中国大陆列在支持区域之外、不支持直连,具体以各家最新条款为准;流传的各种绕行做法,可靠性与合规性都要你自己承担,本文不涉及也不推荐任何具体做法。怎么避:在做流程设计时,把”某个具体工具可能随时不可用”当成常态,方案的核心约定(包清单、依赖方向、路径规范、验证脚本)要写成与工具无关的仓库资产,换工具不用重做。

收束:一份可以贴在评审模板里的自检清单

大仓里的”改错包”不是智力问题,是信息问题。仓库把包边界写清楚了,歧义就消失了大半;验证链路不会静默成功了,剩下的错误就藏不住了。这两件事都是一次性投入。

改完一轮,对着这几条过一遍:

  • AI 提到的每个路径都带仓库根前缀了吗?不带就打断。
  • git status --short 的输出和它的自述一致吗?先看前者。
  • 这次改动只动了一个包吗?动了多个,能拆提交吗?
  • 验证命令真的编译到被改的包了吗?有没有实际的编译日志?
  • 新加的跨包依赖,方向符合说明文件里写的规则吗?
  • 如果现在要回滚,一个 git revert 够吗?

六条里有两条答不上来,就别急着合并——在大仓里,一次错误合并要花多少人力去定位,取决于它藏了多久、牵连了多少包,通常远超当场多花五分钟看两条 git 输出的代价。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。