简要版
- AI 编程智能体擅长新增代码,却不擅长发现代码已经存在。放任不管,它们会让代码库里每一个第一份辅助函数都配上一个第二份,而每个读者都必须核对两份才能信任其中任何一份。
- 解决办法是把两个习惯写下来,让智能体照着做。纹理(Grain):找到同类事物中最近的一个现有范例,照抄它的形态、位置和命名。马尾(Ponytail):从“这东西是否真的需要存在”一路爬梯子到“能起作用的最小实现”,在第一个站得住的横档上停下。
- 两者现在都是一项公开技能,MIT 许可:GitHub 上的 grain-skill 。里面有撰写规则、带固定标签的审查环节,以及那句让智能体说出自己照抄了什么的收尾语句的形态。
- 自八月以来在我们的主代码库里:386 个 issue 和 pull request 带有指名要照抄的范例的计划评论,覆盖提交进约 18,000 个文件的代码树中的 1,333 次提交。代码中有 35 处带有
ponytail:注释,指名一处有意为之的简化和它的升级路径。 - 那个让人不太舒服的发现:智能体一次都没有把这项技能当作技能来调用。规则之所以生效,是因为我同时也把它们放进了项目的 CLAUDE.md、worker 智能体定义和审查清单里。把规则放在智能体本来就会读的地方。
我以前写过一次这个话题,但我不喜欢那次的结果,因为它讲的是原则而不是机制。这一版讲的是机制。
这个技能要解决的问题
来自我们后端的三个例子,全都真实,全都出自通过了自己测试的、由智能体写的 pull request。
一条新的 REST 路由直接从工厂里取出应用服务。代码库里每一条同级路由都走路由到控制器到服务到仓储,每一跳都是注入的。这一条把链路压成了路由到服务。它能用。但它也意味着,这个功能原本该有的请求校验和响应塑形所在的唯一位置并不存在,而下一个碰这个功能的智能体有两种形态可选。
一个过大的服务被拆成 _payloads.py、_protocol.py 和 _tokens.py,每个都是由裸 async def 组成的模块,服务则通过 import 调用 protocol.discover(...)。文件长度检查通过了。而依赖注入本来要防的东西原封不动地回来了:一个在导入时选定的协作者,不打补丁就无法替换,也不出现在那个用来说明服务依赖什么的构造函数里。
第二个归一化函数。本来已经有一个了。智能体没有找到它,又写了八行几乎做同样事情的代码,于是现在有两个边界情况行为不同的函数都声称能把同一个字段规范化。
这些都不是测试能抓到的那种 bug。每一个都是智能体选择了它能生成的代码,而不是已经存在的代码。在几百个 pull request 上这么干,代码库就不再有形态了。
纹理
新代码应当与它周围的代码无从分辨。这项技能把这句话变成智能体在动笔前要走的五步。
先找范例。对于端点、服务、仓储、查询钩子、store slice 或组件,定位最近的一个现有实现,照抄它的形态、文件位置和命名。已有模式存在时,不要去发明结构。在我们的代码库里,branding slice 是后端指名要照抄的范例,因为它小、干净,而且贯穿每一层;技能里的表格则按制品类型列出该打开哪个文件、该照抄什么。
尊重分层。展示层调用应用层,应用层调用领域层,基础设施层实现领域层。跨这些界线的新 import 过不了架构检查。去读分层文档,不要重新推导一遍。
局部纹理与代码库纹理。要跟同级保持一致。但如果同级本身就偏离了权威链路,那就遵循权威链路并注明这处偏离,而不是照抄漂移。“它跟这个文件里另外五条路由一样”并不够,如果这六条都跳过了权威 slice 用的某一层的话。你比较得太局部了。
复用权威的辅助函数。如果某个辅助函数、类型或模式已经在代码库里,就用它。两个归一化函数会迫使每个读者去比对两者。
命名承载含义。复用代码里已有的词汇。给一个已有概念起新名字,就是一个等着读者来踩的 bug。
最后的检验就是我对审查者说的那句话:如果你仅凭形态就能看出哪些行是智能体写的,那它就是在逆着纹理干。
马尾
懒意味着高效,而不是粗心。最好的代码是从未写下的代码。先把问题彻底搞懂,弄清这次改动会碰到的每个文件、真实的端到端流程,然后爬这把梯子,在第一个站得住的横档上停下。
有两条规则随梯子同行。
修 bug 要修根因,而不是症状。工单描述的是症状。把你即将改动的那个函数的每个调用方都 grep 一遍。在共享函数里加一处防护,比在每个调用方各加一处防护改动更小;而只修补工单点名的那条路径,会让同级的其他调用方继续坏着。
保持改动最小。改动量就是审查要读的东西。不要顺手重构、不要改名、不要重新格式化、不要“既然来了就顺手改改”、不要碰任务没要求的文件。每一样都会把真正的改动埋起来。如果改动量比问题本身还大,就砍回去。你发现的改进应该是一个后续 issue,而不是这个分支上多出来的提交。
还有边界,因为“懒”是最容易被误读的词。绝不简化掉信任边界上的输入校验、防止数据丢失的错误处理、安全、无障碍基本要求,或用户明确要求的任何东西。在理解上绝不偷懒:梯子缩短的是解决方案,从不是阅读。放错位置的小改动是第二个 bug。非平凡的逻辑要留下一个可运行的检查,也就是逻辑一旦坏掉就会失败的最小东西。
当智能体是有意选了某个横档、并且知道它的天花板时,它要在代码里说明:
# ponytail: 全局锁,若吞吐量重要则按账户加锁
我们的代码库里现在有 35 处这样的注释。每一处都是一个否则未来读者得自行重建的决策,也是一个当天花板被撞到时该往哪看的标记。
让智能体真正照做
文档里的原则不是行为。三个机制把这两条原则变成了行为,我会按这个顺序给它们排名。
简报里带着范例。在架构师模式 中,主模型为 worker 写一份简报,简报的 Design 字段指名要照抄的最近现有模式,以及最小化的根因改动。worker 不必自己去找纹理,纹理是直接交给它的。在我们代码库里,386 个 issue 和 pull request 上的 491 条计划评论就是这个:一个在任何人动笔之前就指名要照抄什么的规划步骤。
智能体在收尾时说明自己照抄了什么。每个实现者的报告都以一行结尾:mirrored: <path>,或者指名新模式并说明为什么没有同级可套。这很便宜,而且是我加过的最好的审查辅助手段。一行指向错误文件类型的 mirrored: 会在你打开改动之前就告诉你这处改动是错的。缺了这一行,则说明智能体根本没看。
审查以固定顺序执行同一把标尺。我们的审查技能有两遍,使用固定标签,而这些标签正是让发现能在审查者之间、智能体之间相互比较的原因。
结构遍先跑。这处改动是否偏离了最近的范例、是否重复了已有的辅助函数、是否跨层、是否放错了位置、是否伸手去碰另一个模块的内部、是否用了不一致的命名、是否把本该注入的东西据为己有、是否破坏了权威调用链?然后才是删减遍。有什么可以删掉、用标准库替代、用原生功能替代、作为投机性代码砍掉,或者缩小?
这个顺序是规则,不是偏好。一致性优先于删减。一处让代码偏离其同级的删减并不算胜利,因为读者现在得学两种形态,只为了省下八行。先跑结构遍,删减遍就只会碰到已经对齐的代码。
代码库里实际发生了什么,包括没起作用的那部分
自 8 月 1 日以来,我们的主代码库收了 1,333 次提交,其中大多数是智能体在审查下写的。那段时间里,386 个 issue 和 pull request 带有指名要照抄的范例的计划评论,这类评论总共 491 条。权威链路是新端点的默认形态,而那两条跳过控制器的遗留路由在文档里被列为正在退役的偏离,而不是第二种风格。
35 处 ponytail: 注释比我预期的要少,而我认为这是对的。大多数简化不值得写注释。值得写注释的,是那些有天花板的。
现在说我最不想报告的部分。我把 grain 和 ponytail 作为一项 Claude Code 技能发布了:技能索引里的一段描述、按需加载的正文,以及 CLAUDE.md 里一行说写代码前要先调用它。在五周的会话记录里,智能体一次都没有按名字调用过它。mirrored: 收尾行出现在七次回复里。
规则还是落地了。它们之所以落地,是因为同样的文本还放在三个智能体无需选择就会读到的地方:项目的 CLAUDE.md,它从第一轮起就在上下文里;worker 智能体定义,其中把“照抄最近现有范例的形态、位置和命名”和“伸手去拿真正管用的最懒解法”写成规则,而不是一项供考虑的技能;以及审查清单,它的标签会让忽略它们的 pull request 直接不合格。技能正文变成了这三处所指向的权威参考。而作为触发器,它毫无作用。
所以,如果要给任何为智能体打包约定的人留一句经验,就是这个。技能适合放规则的长版本。它不适合放规则本身,因为智能体得自己决定去加载它,而它多半不会。把一段话的版本放进永远在上下文里的那个文件,把祈使句版本放进写代码的那个智能体的定义里,把检查放进卡住合并的审查里。然后让这三处都指向技能去取细节。
公开版本在 github.com/LivinTribunal/grain-skill ,就是按这个方式组织的:一份简短的 SKILL.md,装着智能体能记在脑子里的规则;一份完整的文档,装着梯子和反模式;审查标签;以及一份 README,说明哪一段该放进你的 CLAUDE.md,哪一段该放进你实现者的定义里。把里面的示例换成你自己的权威范例,就完事了。

