这个项目一年多来的大部分代码,是和 AI 编码助手一起写的。

这篇不讨论「AI 能不能写代码」——它能。讨论的是另一件事:一个项目要长成什么样,才能让它在第五十次会话时仍然知道自己在干什么。

一、CLAUDE.md 要精瘦

AI 编码助手每次会话开始都会读一份项目说明(这里叫 CLAUDE.md)。它是常驻上下文,每一行都在每一次会话里付费。

早期它是一份什么都往里塞的大文档:架构、每个功能的实现细节、历史踩坑、待办、想法。几千行。

问题不在长,在于它开始自相矛盾:某个功能改了三次,文档里三个版本的描述都还在,只是分散在不同段落。模型读到哪段,就按哪段的理解干活。

后来拆成了两层:

层放什么何时读
CLAUDE.md(精瘦核心)跨功能的工程约束、架构、启动顺序、配置 schema、全局踩坑每次会话
docs/<域>.md某个功能域的详细实现与踩坑改那个功能之前
docs/plan.md路线图、待办、已踩坑教训的权威历史规划时

CLAUDE.md 里有一张「功能域文档索引」表:哪个域、对应哪份文档、代码在哪、对外有哪些能力。规矩是:

改某个功能前,先读它对应的 docs/<域>.md;改完后如果行为或踩坑有变,同步更新那份文档。 CLAUDE.md 只在跨域约束变化时才动。

代码按功能域分目录,文档按同样的域拆——这个一一对应本身就是一种导航。

二、写「机械检查表」,而不是写「注意事项」

最有用的文档形式,是那种可以逐项打勾的清单,而且每一项都写了漏掉会出现什么现象。

举一个:加一个新的配置顶层 slice,要改六处显式列举的地方。文档里是这样写的:

#位置漏了会怎样
4合并对象设置页那个 tab 整个渲染崩(空白、切不动)
5持久化保存时没写进去 → 后台读不到 → 功能报「未启用」
6变更监听内存副本不随保存更新(刷新一下就好,于是被当成偶发)

「注意同步修改相关位置」这种话,人和模型都会读过就忘。「漏了第 5 处,你会看到后台说功能未启用」——这种话在排查时是能被搜到的。

而且这份清单本身也漏过:它长期只记了五处。直到加第十四个 slice 时才发现漏了第六处,补上。

清单会过时。每次按清单做完一件事,顺手检查清单本身是否完整。

三、git 纪律:多会话并行时最容易出事的地方

这个项目经常同时开好几个 AI 会话,各改各的功能。git 在这里的风险比单人开发大得多。

核心坑:git add <file> 暂存的是整个文件

某次一个会话在做 A 功能,git add 了三个它改过的文件。另一个会话正在做 B 功能,也改了其中一个文件。

于是 B 的一半改动被 A 的提交一起带走了。

最危险的是:在工作区里 typecheck 全绿——因为 B 的改动在工作区里是完整的(字段定义和使用处都在)。直到把提交摘到干净的基点上,才炸出一串类型错误。如果当时直接推了,推上去的是半截的 B 功能,CI 挂,还污染了别人的功能。

所以每次提交前:

  1. git status --short 看清单里有没有不是你改的文件;
  2. git add 之后 git diff --cached 逐块扫一遍,宁可 git add -p 逐块挑;
  3. 别用 git stash 挪别人的改动——stash 栈是所有 worktree 共享的,会串。

分支被别人切走了

写这个系列文章的同一个会话里就发生了一次:我一直在 main 上提交一份进度文档,某次提交时才发现——仓库已经被另一个会话切到了它的特性分支,工作区里有它十几个文件的未提交改动。

我那次提交落在了它的分支上。好在只有我自己的一个文件、没有推送、和它的改动零交集。我想把提交挪回 main,两种方式(强制移动分支指针、推送特定提交到远端 main)都被权限规则拦下了,判定为破坏性操作。

我没有去绕,停下来把情况原样告诉了人。

在共享工作区里,「现在在哪个分支」不是一个你可以记住的事实,是一个每次提交前都要重新查的事实。

什么时候用 worktree

规矩是:只有「同一时间真有多个会话并行改代码」时才用。 单人单会话用它是纯开销——多一份依赖、多一个构建产物目录、每次都要想「我现在在哪个目录」。

还有一个只在扩展开发里才有的坑:浏览器加载的是固定路径下的构建产物。在 worktree 里构建,浏览器看到的还是主目录那份——改完代码去浏览器里看还是旧的,连踩了两次。所以默认工作流是「主目录 + 特性分支 + PR」,构建产物永远在同一个地方。

而且 worktree 必须开工前就建,不能事后补。事后建意味着要从已经混在一起的提交里,靠肉眼逐块判断哪些是你的——判断错就推错代码。

push 是「发布」

commit 可以很频繁(只存本地,当存档点);push 是发布——CI 会跑、别的会话会拉到。

所以:运行时改动,先告诉人「需要真机验证什么、怎么验」,人确认可用之后才 push。纯文档可以直接推。会话结束前不留悬空改动——要么推完,要么明确说「改动在本地未提交」。

四、上下文压缩之后的幻觉

长会话会触发上下文压缩:早先的对话被摘要掉,只留下摘要和最近的内容。

有一次压缩之后,模型一上来就跑去重新设计一个早已废弃的动效设计稿。

查下来的原因:那个会话最早是用一个「主题设计」技能启动的,技能的参数是「重新设计某某 HTML」。后来话题早就转走了。压缩之后,那段技能调用连同参数被塞进了摘要,而摘要开头写着「本次会话从……开始」——于是它被当成了当前待办。

压缩机制本身其实标注了「仅供上下文,不要重新执行」,但和摘要开场白互相拉扯,后者赢了。

两个应对:

  1. 话题彻底切换之后开新会话,别让无关的旧任务混进压缩;
  2. 在那个设计稿所在目录的 README 里明确写上:这些是历史存档,不是待办,别主动重新设计。

留在仓库里的东西会被当成「还在进行中」。 如果它不是,就写出来——给模型看,也给三个月后的自己看。

五、模型会被「实测出来的结论」骗,人也会

这是这个系列写到一半时发生的,值得单独记。

项目里传了很久一条「铁律」:Windows 批处理文件里,rem 注释不保护元字符,注释里写了管道符会被当成命令执行。它有真实故障做背书——一条从 URL 中间劈开的报错。它被写进了文档、写进了 AI 的记忆、写进了我发布的文章。

写安装器模板时,自查脚本抓到注释里有 < >,我顺手去复现——复现不出来。四种元字符、顶层和括号块内都试了,全部被正常保护。

回头看那条原始报错:断点在 URL 的字母中间,不落在任何元字符上。那更像是另一条规则(非 ASCII 字符在 GBK 下把行劈开)的特征。

一条被当成铁律的结论,其实只是对一个真实现象的未经验证的解释。 已发布的文章当场改了,改成如实陈述「排除了什么 + 更合理的解释是什么 + 我哪一步没法验证」。

这件事对「和 AI 协作」的意义在于:AI 的记忆和文档是同一种东西——它们都会把一个解释固化成事实,然后在之后每一次会话里被原样复述。模型不会主动去怀疑一条写在记忆里的「实测结论」,人通常也不会。

能打破它的只有一件事:在一个需要依赖它的时刻,真的去再跑一次。

六、几条最后留下来的习惯

  • 让模型把验证步骤写成「跑这条命令看返回」,而不是「看日志里有没有 X」。 日志可能打在另一个执行上下文里,看不到会被合理地理解成「没生效」。
  • 错误信息必须对应真实分支。 一个函数四个失败分支共用一句错误信息,排查方向能错四轮——模型会非常认真地沿着那句错误信息去查。
  • 给模型的 tool 分级,写操作不给它自主调用。这不是不信任模型,是不信任「推理链里看起来合理的下一步」。
  • 记忆要能被删。发现一条记忆是错的,删掉或改掉,而不是再加一条「更正」叠在上面——叠上去的那条不一定会被先读到。

一年下来的感受是:和 AI 协作的质量,上限由模型决定,下限由项目的「可读性」决定。

一个文档精瘦、清单机械、闸门齐全、分支纪律严格的项目,普通的模型也能在里面干好活。一个到处是矛盾描述、隐式约定和口头规矩的项目,再强的模型也会在第二十次会话里迷路——而人其实也一样,只是迷路得慢一点。