这个项目一年多来的大部分代码,是和 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 挂,还污染了别人的功能。
所以每次提交前:
git status --short看清单里有没有不是你改的文件;git add之后git diff --cached逐块扫一遍,宁可git add -p逐块挑;- 别用
git stash挪别人的改动——stash 栈是所有 worktree 共享的,会串。
分支被别人切走了
写这个系列文章的同一个会话里就发生了一次:我一直在 main 上提交一份进度文档,某次提交时才发现——仓库已经被另一个会话切到了它的特性分支,工作区里有它十几个文件的未提交改动。
我那次提交落在了它的分支上。好在只有我自己的一个文件、没有推送、和它的改动零交集。我想把提交挪回 main,两种方式(强制移动分支指针、推送特定提交到远端 main)都被权限规则拦下了,判定为破坏性操作。
我没有去绕,停下来把情况原样告诉了人。
在共享工作区里,「现在在哪个分支」不是一个你可以记住的事实,是一个每次提交前都要重新查的事实。
什么时候用 worktree
规矩是:只有「同一时间真有多个会话并行改代码」时才用。 单人单会话用它是纯开销——多一份依赖、多一个构建产物目录、每次都要想「我现在在哪个目录」。
还有一个只在扩展开发里才有的坑:浏览器加载的是固定路径下的构建产物。在 worktree 里构建,浏览器看到的还是主目录那份——改完代码去浏览器里看还是旧的,连踩了两次。所以默认工作流是「主目录 + 特性分支 + PR」,构建产物永远在同一个地方。
而且 worktree 必须开工前就建,不能事后补。事后建意味着要从已经混在一起的提交里,靠肉眼逐块判断哪些是你的——判断错就推错代码。
push 是「发布」
commit 可以很频繁(只存本地,当存档点);push 是发布——CI 会跑、别的会话会拉到。
所以:运行时改动,先告诉人「需要真机验证什么、怎么验」,人确认可用之后才 push。纯文档可以直接推。会话结束前不留悬空改动——要么推完,要么明确说「改动在本地未提交」。
四、上下文压缩之后的幻觉
长会话会触发上下文压缩:早先的对话被摘要掉,只留下摘要和最近的内容。
有一次压缩之后,模型一上来就跑去重新设计一个早已废弃的动效设计稿。
查下来的原因:那个会话最早是用一个「主题设计」技能启动的,技能的参数是「重新设计某某 HTML」。后来话题早就转走了。压缩之后,那段技能调用连同参数被塞进了摘要,而摘要开头写着「本次会话从……开始」——于是它被当成了当前待办。
压缩机制本身其实标注了「仅供上下文,不要重新执行」,但和摘要开场白互相拉扯,后者赢了。
两个应对:
- 话题彻底切换之后开新会话,别让无关的旧任务混进压缩;
- 在那个设计稿所在目录的 README 里明确写上:这些是历史存档,不是待办,别主动重新设计。
留在仓库里的东西会被当成「还在进行中」。 如果它不是,就写出来——给模型看,也给三个月后的自己看。
五、模型会被「实测出来的结论」骗,人也会
这是这个系列写到一半时发生的,值得单独记。
项目里传了很久一条「铁律」:Windows 批处理文件里,rem 注释不保护元字符,注释里写了管道符会被当成命令执行。它有真实故障做背书——一条从 URL 中间劈开的报错。它被写进了文档、写进了 AI 的记忆、写进了我发布的文章。
写安装器模板时,自查脚本抓到注释里有 < >,我顺手去复现——复现不出来。四种元字符、顶层和括号块内都试了,全部被正常保护。
回头看那条原始报错:断点在 URL 的字母中间,不落在任何元字符上。那更像是另一条规则(非 ASCII 字符在 GBK 下把行劈开)的特征。
一条被当成铁律的结论,其实只是对一个真实现象的未经验证的解释。 已发布的文章当场改了,改成如实陈述「排除了什么 + 更合理的解释是什么 + 我哪一步没法验证」。
这件事对「和 AI 协作」的意义在于:AI 的记忆和文档是同一种东西——它们都会把一个解释固化成事实,然后在之后每一次会话里被原样复述。模型不会主动去怀疑一条写在记忆里的「实测结论」,人通常也不会。
能打破它的只有一件事:在一个需要依赖它的时刻,真的去再跑一次。
六、几条最后留下来的习惯
- 让模型把验证步骤写成「跑这条命令看返回」,而不是「看日志里有没有 X」。 日志可能打在另一个执行上下文里,看不到会被合理地理解成「没生效」。
- 错误信息必须对应真实分支。 一个函数四个失败分支共用一句错误信息,排查方向能错四轮——模型会非常认真地沿着那句错误信息去查。
- 给模型的 tool 分级,写操作不给它自主调用。这不是不信任模型,是不信任「推理链里看起来合理的下一步」。
- 记忆要能被删。发现一条记忆是错的,删掉或改掉,而不是再加一条「更正」叠在上面——叠上去的那条不一定会被先读到。
一年下来的感受是:和 AI 协作的质量,上限由模型决定,下限由项目的「可读性」决定。
一个文档精瘦、清单机械、闸门齐全、分支纪律严格的项目,普通的模型也能在里面干好活。一个到处是矛盾描述、隐式约定和口头规矩的项目,再强的模型也会在第二十次会话里迷路——而人其实也一样,只是迷路得慢一点。