Record / Entry
代码写清了做什么,没写为什么
代码是决策的结果,不是决策本身。而当代码由 agent 写出时,被丢掉的那部分不在任何人脑子里。
一行看起来不需要解释的代码
先看这个函数。它就在这个博客里,负责把文章日期渲染成页面上的那串数字:
export function formatDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
一行。没有分支,没有边界处理,没有任何一处看起来需要解释。
正因为如此,它非常危险。
假设你想让日期显示得好看一点——2026 年 8 月 9 日,或者至少带上本地化。你会顺手改成 date.toLocaleDateString("zh-CN"),测一下,本机显示完全正常,提交。
然后某天,一篇标注 2026-08-09 的文章,在线上显示成了 8 月 8 日。
原因是 frontmatter 里的 2026-08-09 是个 date-only 值,按规范会被解析成 UTC 零点。站点又是预渲染的,日期在构建时就固定进了 HTML。只要构建机器的时区在 UTC 以西,UTC 零点换算到本地就落回前一天。本机是东八区,所以你永远测不出来;CI 换一台机器,日期就集体错位一天。
这就是那行 toISOString() 存在的全部理由。而代码本身,一个字都没说。
但真正的问题不是「作者会忘」
上面这个故事,是「为什么要写注释」的经典版本:作者当时想清楚了,只是过了半年记不住了。
传统的应对办法是「靠谱的作者会记得」或者「翻 git 历史」。而现在,这两条同时失效了——因为那行代码根本不是我逐字写出来的。
我现在写代码的方式,多数时候是描述意图,让 agent 生成实现,然后我 review。它交出来一段能跑、也确实正确的代码。上面那个 toISOString().slice(0, 10) 就是这么来的。
问题在于:在这个流程里,「为什么不用 toLocaleDateString」这个理由,从来没有存在于任何人的脑子里。
agent 大概率是知道的——时区陷阱是它训练数据里被反复讨论过的东西,它在生成时做出了正确选择。但那个判断发生在一次前向推理的内部,输出的只有最终代码。它不会在下一轮对话里记得,而我作为 review 的人,看到的是一段简洁、正确、毫无异常的代码,于是我点了通过。
没有人遗忘,因为没有人曾经拥有。
这是 vibe coding 带来的一个新问题,和传统的「注释会过时」「作者会离职」都不是一回事。过去代码库里的知识流失是衰减——理由曾经在某个人脑子里,随时间变淡。现在是断供:代码一诞生就是没有理由的,从第一天起就是一段「不知道为什么这么写」的代码。
而它的表现形式非常隐蔽:你的代码库看起来质量很高。每个函数都简洁、正确、风格统一。只有当你想改动它时,才会发现自己在一片没有路标的地方行走——不知道哪些写法是精心选择的,哪些只是随手为之。
agent 会重新犯下同一个错误
更麻烦的是下一轮。
半年后你让 agent 去改那个日期函数:「把日期显示改成中文格式」。它看到的上下文里,只有一行没有任何解释的 toISOString().slice(0, 10)。
它不会知道这行代码是为了避开构建时区问题——那个理由既不在代码里,也不在它的上下文窗口里。于是它做出一个非常合理的修改:换成 toLocaleDateString("zh-CN")。
它甚至可能觉得自己在改进代码。
这里有个反直觉的地方:agent 知道时区陷阱,却依然会踩进去。因为它在写新代码时会调用这个知识,但在改现有代码时,它默认现有代码没有特殊意图。它无法区分「这行代码是深思熟虑的结果」和「这行代码只是随手写的」——除非你在代码里明确告诉它。
而这恰恰是注释最擅长的事。当那行代码带着理由时:
// Rationale: 内容日期是 date-only 值,使用 UTC ISO 日期可避免构建时区改变前端展示的日历日期。
export function formatDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
agent 读到这一行,就知道这里有一个必须保住的约束。它会告诉你「本地化格式会重新引入时区问题,建议改成手动拼接 UTC 年月日」,而不是闷头把防护删掉。
注释在这里的角色变了。它不再只是给人看的说明,而是跨会话传递给 agent 的上下文。你的代码库有多少约束能挺过下一次 AI 重构,取决于有多少约束被写进了代码本身。agent 的上下文窗口每次都会清空,代码文件不会。
git 也接不住这件事
到这里通常会有一个反驳:这不是有 git 吗?
我认真试过。git blame 到那一行,翻出对应的 commit,message 是 Add SEO, sitemap, social sharing, and post update-date support。
这条 message 没什么问题——它准确描述了那次改动加了什么。但它回答不了我此刻的问题。日期格式化只是那次改动里顺带落地的一个小函数,时区这件事甚至不值得在 message 里占半句话。
而且这不是「message 写得不够好」能解决的。commit 记录的是变更,我想知道的却是当时的否决:为什么不用 toLocaleDateString?考虑过在构建时锁定 TZ 吗?为什么是截断字符串而不是取 getUTCFullYear() 拼接?
这些从来没有进入过版本历史,因为它们从来不是变更。它们是变更之前那一步——权衡。代码提交的是权衡的结果,权衡本身散场了。
有人会说,那把理由写进 commit message 不就行了。可以,但在 AI 协作的场景下它有个硬伤:agent 读的是代码,不是 git 历史。你可以让它去查,但那需要你先意识到「这里可能有隐情」——而这正是你不知道的事。写在代码里的注释是零成本抵达的,写在 git 里的需要有人主动去挖。
让 agent 自己交代理由
想通这一点之后,我的做法很直接:要求 agent 在生成代码时,把非平凡的决策理由一并写进注释。
我在项目里定了一条约定,让 agent 在每个非平凡的逻辑块上写一条以 Rationale: 开头的注释,一两句话说明设计意图或取舍原因。
这件事之所以成立,是因为那个理由在生成的那一刻是存在的——agent 确实在两种写法之间做了选择。只是默认情况下,这个选择的依据不会进入输出。你要求它写出来,它就写得出来;等到下一轮对话再问,就已经晚了,那时它只能对着代码重新猜测,和你一样。
顺带一提,这也让 review 变得可行。review 一段 AI 生成的代码,最难的从来不是看懂它做了什么,而是判断它为什么这么做、有没有想过别的。带 rationale 的代码把这一层摊开了:我不再是在验证「这段代码对不对」,而是在验证「这个理由成不成立」——后者快得多,也准得多。有几次我正是从注释里看出 agent 的前提搞错了,而代码本身完全正常。
我判断一条 rationale 合不合格的标准只有一个:把它删掉之后,下一个改这段代码的人(或 agent)会不会做出一个错误的修改?如果不会,那它多半只是在复述代码,删了也罢。
一条好的 rationale 长什么样
写多了之后我发现,真正有用的那些,基本都在回答三件事之一。
一是说明否决了什么。新建文章的脚本里有这么一段,负责把标题填进 frontmatter 模板:
# Rationale: 用 bash 参数替换而非 sed,避免 title 中的 & 或正则特殊字符被 sed 误解析。
content=${content//__TITLE__/$title_escaped}
「用 sed 做占位符替换」是这里最自然的写法,几乎是肌肉记忆——对人和对 agent 都是。这条注释的价值就在于,它把这条路提前堵死了,并且给出了堵死的理由:& 在 sed 的替换串里表示「整个匹配内容」,一个标题里带 & 的文章就能让模板炸开。没有这句话,下一次「清理一下这段奇怪的 bash」,几乎必然会踩回去。
二是说明代价。取舍是双向的,只写好处的注释是半条注释:
// Rationale: 首页提供快速入口即可,完整标签列表由归档页承载,避免侧栏随标签数量无限增长。
const featuredTagEntries = tagEntries.slice(0, 5);
一个 slice(0, 5) 孤零零地摆在那里,看起来就像个待办:为什么是 5?是不是忘了做分页?注释把它标记成主动选择,同时点明了被接受的代价(首页看不全所有标签)和它换来的东西(侧栏长度有界)。少了它,你让 agent「完善一下首页」,它有很大概率会热心地把这个限制去掉。
三是说明假设失效时会怎样。这类里最典型的是各种规则抑制:
// biome-ignore lint/correctness/noUnusedImports: Biome 未识别 Astro 模板中的 Layout 组件引用。
import Layout from "../layouts/Layout.astro";
一句不带理由的 biome-ignore 是永久的——没人知道它当初为什么加,于是也没人敢删,它会一直躺到项目结束。写清前提之后,这条抑制就有了明确的失效条件:哪天 Biome 支持了 Astro 模板解析,它就该被删掉。注释让一个临时妥协保持着可回收的状态。
不要给所有东西都写
这套习惯最容易走歪的地方,是变成给每一行都配一句话。让 agent 写注释时尤其容易失控——它非常乐意给 const posts = await getCollection("blog") 也配上一句「获取博客集合」。
那比不写还糟。噪音会稀释真正重要的那几条,让人开始整段跳过注释;对 agent 也一样,关键约束被埋在一堆废话里,等于没写。
我的界线是:只在存在过分歧的地方写。
不写的:变量赋值、显而易见的一行代码、样板代码、名字已经自解释的测试、纯配置文件、二十行以内的小脚本。这些地方代码自己说得比注释清楚。
写的:任何一个当时存在两个以上合理选项的位置。这个标准对人和 agent 都适用——如果你在两种写法之间犹豫过,或者需要停下来想一想,那就值得留一句。犹豫本身就是信号:既然你会犹豫,读的人也会。
它真正省下的是什么
写 rationale 的成本不在打字,而在必须把理由想清楚才写得出来。这既是成本也是收益:好几次我在写注释的过程中发现,自己其实说不出这么做的理由——那通常意味着这个设计本身有问题。让 agent 写的时候同理,一条含糊其辞、只是把代码翻译成中文的 rationale,往往正说明它也没什么真实依据,那段代码值得多看两眼。
注释写不出来,是一个相当灵敏的坏味道探测器。
至于收益,最直接的一条是:它保护那些看起来多余的代码。
还是那个建文章的脚本,里面有两行纯粹的转义:
# Rationale: frontmatter 模板用双引号包裹字符串字段,title 若含 " 或 \ 会破坏 YAML
# 解析(曾在联调中触发 dev server 崩溃),故先做 YAML 双引号转义再替换。
title_escaped=${title//\\/\\\\}
title_escaped=${title_escaped//\"/\\\"}
脱离上下文看,这就是两行可以删掉的偏执——毕竟谁会在文章标题里写引号呢?但括号里那半句话说明了一切:这不是假想的风险,是真的把 dev server 搞崩过一次。
防护性代码都有这个共同特征——它们生效时悄无声息,看起来纯属冗余。它们最常见的死法不是被推翻,而是被顺手删掉,删的人还觉得自己在做清理。而当「删的人」是一个每次都从零开始读代码、上下文窗口里没有任何历史的 agent 时,这件事发生的频率会高得多。
一条注释拦住一次这样的删除,成本就回来了。
收尾
代码是决策的结果,不是决策本身。编译器只需要结果,人和 agent 都需要决策。
过去我们说写注释是为了对抗遗忘。现在它对抗的是一件更彻底的事:理由从一开始就没被任何人拥有过。代码生成的速度越快,这种断供就越普遍——你的代码库会以前所未有的速度积累起「能跑,但没人知道为什么这么写」的代码。
如果只挑一条开始,我会挑这个:下次 agent 交给你一段你看不出为什么这么写的代码,别直接点通过——问它一句「为什么不是另一种写法」,然后把答案留在代码里。
那个理由此刻还在。等到下一轮对话,它就不在了。