和 AI 协作写代码:先让它读懂系统,再让它动手

AI 编程助手最容易出问题的地方,不是不会写代码,而是太快开始写代码。

我用了大半年各种 coding agent 之后最大的体会就是这个。你给它一句需求,它三秒钟就开始噼里啪啦输出,看着很爽,但十次里有三四次写出来的东西要么不符合项目风格,要么解决了一个你根本没问的问题。后来我把工作方式整个调了一遍,核心就一句话:先让它读懂系统,再让它动手。

一个项目里真正重要的信息,往往不在需求描述里,而在已有代码里。

组件怎么拆、数据怎么流、错误怎么处理、测试怎么跑,这些都需要先看清楚。需求文档能写两段,代码里的隐性约定能有两百条,而且后者才是真正决定改动会不会翻车的东西。

先读代码

开始改动前,至少要弄清楚几件事:

  • 入口文件在哪里
  • 现有逻辑如何组织
  • 这次改动会影响哪些页面或模块
  • 项目里有没有统一错误处理
  • 测试、构建、生成脚本分别怎么跑
  • 哪些文件是生成产物,哪些文件是手写源码

如果跳过这一步,很容易写出能运行但不合群的代码。

项目里已经有的约定,通常比“通用模板”更重要。

我现在会先让 AI 做一次只读扫描,而不是马上改。

比如我会要求它先回答:

1请先不要改代码。
2先阅读相关文件,告诉我:
31. 这个功能的数据入口在哪里
42. 现有错误处理怎么做
53. 哪些文件可能需要改
64. 你认为风险点是什么

这个步骤看起来慢,但能过滤掉很多“凭空发挥”。AI 最擅长补全,如果上下文没给够,它会用通用经验补。读代码就是为了减少这种补全空间。

我自己踩过一个很典型的坑。当时让 AI 给博客加一个标签聚合页,我没让它先扫一遍,它直接照着脑子里的 Next.js 通用写法搞了一套:在页面里现场 fs.readdirposts 目录、自己解析 frontmatter、自己去重标签。问题是项目里早就有一个 lib/posts.ts 把这些活儿全干了,还带缓存。结果就是同一份逻辑出现了两份,标签去重规则还不一致,一个区分大小写一个不区分,归档页和聚合页统计出来的文章数对不上,排查了半天才发现是两套解析逻辑。

后来我养成了一个习惯,让它先把“相关文件清单”和“关键函数签名”列出来给我看,类似这样:

1请先只读不改,列出:
2- 处理这块逻辑的核心模块路径
3- 我应该复用的现有函数及其签名
4- 这些函数的输入输出和副作用

它要是能准确说出 getAllPosts(): Promise<Post[]> 这种签名,我基本就放心了,说明它真读进去了;要是它含糊其辞或者开始描述“一般来说会有一个函数负责……”,那就是没读,得让它回去重读。这个判断标准很实用,比直接看它写的代码省事多了。

“一般来说”这四个字我现在几乎当成一个警报词。只要它的回答里开始出现“通常项目会……”“一般这类逻辑放在……”,基本就说明它已经离开了你的代码、退回到训练数据里的通用印象。这时候不管后面说得多顺,都不该信,得把它拽回具体文件:“别说一般,就说这个项目里它在哪个文件、叫什么名字。”逼它落到具体路径和符号上,它要么老老实实去读,要么露馅承认没找到。

再拆任务

需求越简单,越容易低估影响面。

例如“把文章放到年份目录里”,表面看只是移动文件,实际会牵涉:

  • 内容扫描逻辑
  • slug 生成规则
  • 文章详情页静态路径
  • 归档页统计
  • sitemap 和侧栏数据

拆清楚以后,改动才不会漏。

我现在更喜欢把任务拆成“可验证的小步”,而不是让 AI 一次性做完整套。

例如:

1第一步:只调整内容扫描逻辑,不改页面。
2第二步:补静态路径生成。
3第三步:更新归档和侧栏数据。
4第四步:运行 build,检查文章数量和路由。

这样做有两个好处。第一,每一步的 diff 更容易 review。第二,一旦出错,可以定位是哪一步引入的,不会变成一大坨改动一起排查。

我吃过一次大 diff 的亏。早期贪图省事,让 AI 一口气把上面那个目录迁移全做完,它返回了一个改了二十多个文件、八百多行的 diff,看着挺完整。结果 build 起来文章少了三篇,slug 还撞了一个。问题是这八百行里源码、生成产物、路由配置全混在一起,我根本没法判断是扫描逻辑漏了文件,还是 slug 规则改错了,最后只能整个 revert 重来。从那以后我就坚持小步,每步做完先停下来让它自己验证再继续。

实际操作里我会让它每完成一步就跑一次最小验证再往下走,而不是攒到最后。比如改完扫描逻辑那步,立刻让它:

1node -e "const {getAllPosts}=require('./lib/posts'); getAllPosts().then(p=>console.log('count:',p.length))"

先确认文章总数没变,再动页面。这种“每步带一个体温计”的做法,比最后统一 build 报错再回头猜要稳得多。拆任务的颗粒度我现在的标准是:每一步的 diff 自己扫一眼五分钟内能看明白,超过这个量就还得再拆。

拆步还有个附带的好处,是能顺便管住 agent 能自己执行哪些命令。现在的 coding agent 大多能自己跑命令——装依赖、跑脚本、甚至 git 操作。只读扫描、build、跑测试这类无副作用的命令我放得比较开,让它自己跑;但会改动仓库状态的,尤其是 git reset --hardgit cleanrm -rf、批量改文件名这种一旦执行就难回退的,我一律要它先把命令列出来、说清楚为什么,等我点头再跑。有过一次它为了“清理干净”自作主张 git checkout .,把我还没提交的一部分手改一起冲掉了,从那以后这条线我卡得很死。小步拆分正好给了这种确认的天然节点:每步之间停一下,既 review 代码,也 review 它接下来要执行什么。

每次改动前说清楚

协作开发里,改动前说明计划不是形式。

它能让人提前发现风险,比如:

  • 是否会改变公开 URL
  • 是否会删除已有业务逻辑
  • 是否会引入新依赖
  • 是否需要补兼容逻辑

这些问题在动手前讨论,成本最低。

我现在也会要求 AI 在计划里写出“不会做什么”。

比如:

1本次只改 posts 解析和归档统计。
2不会修改文章 slug。
3不会引入新依赖。
4不会调整页面样式。

这个约束很有用。很多 AI 协作翻车,不是因为它没有完成需求,而是它顺手改了不该改的东西。把能改什么、不能改什么提前说清楚,比事后 review 一堆无关 diff 更省心。

“顺手改”这事我印象太深了。有次只是让它修一个归档页排序的小 bug,它顺带把 lib/posts.ts 里每个 Post 对象的 date 字段,从原来的 ISO 字符串改成了 Date 对象,理由是”这样语义更准确”。出发点没错,但它没注意到 next-sitemapadditionalPaths 回调里有一行把 post.date 直接当 lastmod 传了过去,Date 对象序列化时变成了 [object Object],生成的 sitemap 里所有文章的 lastmod 全变成无效值,而本地 next dev 里日期是字符串化展示完全看不出来,是部署后跑第三方 sitemap 校验工具才发现的。

所以现在我的“不会做什么”清单里,重构和改函数签名是必写项。如果它真觉得有必要重构,我会让它单独开一个任务、单独一个 commit,绝不允许塞进别的改动里搭车。我甚至会在 prompt 里写一句“如果你认为需要做计划外的改动,先停下来问我,不要自己决定”,这一句话拦下来的意外改动比我想象的多。

改完必须验证

AI 写代码也好,人写代码也好,最后都要回到验证。

最基础的是跑 lint 和 build。涉及核心逻辑时,还应该补测试或做更具体的用例检查。

如果改动影响内容系统,至少要确认:

  • 文章数量是否正确
  • slug 是否重复
  • 静态路径是否生成
  • 归档和标签是否仍然可用

这里有个细节,AI 经常会“嘴上说验证过了”,但其实根本没跑。它会很自信地告诉你“已确认文章数量正确、路由生成无误”,可你一问它具体跑了什么命令、输出是什么,它就答不上来。所以我现在不接受口头结论,要的是它把命令和真实输出贴出来。比如内容系统这类改动,我会让它跑一段脚本把关键不变量直接打出来:

1// scripts/check-posts.js
2const { getAllPosts } = require('../lib/posts')
3
4getAllPosts().then((posts) => {
5  const slugs = posts.map((p) => p.slug)
6  const dup = slugs.filter((s, i) => slugs.indexOf(s) !== i)
7  console.log('文章总数:', posts.length)
8  console.log('重复 slug:', dup.length ? dup : '无')
9  console.log('缺失日期:', posts.filter((p) => !p.date).map((p) => p.slug))
10})

有了这种脚本,验证就不再依赖谁的记忆或自信,输出摆在那里,对就是对,错就是错。我也会顺手把它沉淀进 package.jsonscripts 里,下次直接 npm run check:posts,AI 和我都能跑,省得每次重新解释要验证什么。

协作的重点不是让 AI 写得更快,而是让整个过程可检查、可回退、可维护。

Review AI 代码时,我会重点看三类问题

第一类是“看起来对,但不符合项目约定”。

比如项目里已经有统一请求封装,AI 又新写了一套 fetch;项目里错误对象已经标准化,AI 在页面里直接 alert(error.message)。这类代码可能能跑,但会把系统风格打散。

还有更隐蔽的,比如项目用的是 dayjs 处理时间,它给你引了个 date-fns;项目状态管理统一走 zustand,它在某个组件里又起了个 useReducer 自治。单看每一处都说得通,但攒多了整个代码库就变成大杂烩,新人接手时根本搞不清到底该用哪套。这类问题 lint 一般也查不出来,只能靠人盯。我 review 时会专门问一句:“这段代码用到的工具/封装,项目里是不是已经有现成的?”逼它先去找,再决定要不要新写。

第二类是“只覆盖成功路径”。

AI 很容易写出 happy path:请求成功、数据存在、权限正常、输入合法。但真实业务里,空数据、超时、权限不足、重复提交才是更容易出问题的地方。

举个具体的,让它写文章详情页,它默认 frontmatter 里所有字段都齐全,于是直接 post.tags.map(...)。可我历史文章里有一批早期的根本没写 tags 字段,线上一访问就 Cannot read properties of undefined。这种 case AI 不会主动想到,因为它看到的样例数据都是完整的。所以我 review 时会拿着边界清单一条条对:

1// 这些它通常默认不会发生,但恰恰最常炸
2post.tags?.map(...)        // 字段可能不存在
3posts.length === 0         // 列表可能为空
4new Date(post.date)        // 日期可能格式不对 → Invalid Date

我现在的习惯是直接给它喂一条“脏数据”当测试输入,比如故意传一个缺字段的对象,看它写的代码扛不扛得住。它扛不住,就让它补防御,而不是等线上用户帮我发现。

第三类是“改了源码,忘了生成产物或测试”。

像博客、文档、配置平台这类项目,很多数据是构建时生成的。只改源码不跑生成脚本,页面可能本地看不出问题,部署后才暴露。

我这个博客就是典型。rss.xmlsitemap.xml、侧栏的归档数据,都是跑生成脚本产出的静态文件,提交进仓库的。AI 改了文章解析逻辑,却经常忘了这些产物需要重新生成,因为 next dev 不会触发那些脚本,本地翻页一切正常。等部署上去用户点 RSS,拿到的还是旧数据。后来我干脆要求它在改动说明里明确写出“这次改动是否需要重新生成 sitemap/rss/sidebar”,把这个判断显式化,而不是指望它顺手记得。生成产物和源码不同步,是这类项目最容易隐身的一类 bug。

所以我现在 review AI 代码时,不只看它写了什么,还看它有没有证明自己写的东西真的经过验证。

还有一类问题不在代码本身,而在它的语气:它的自信和正确率不成正比。人写错代码时多少会心虚,会加个 TODO、会说“这块我不太确定”;AI 不会,它写对和写错时用的是同样笃定的口吻。一段调错库的 fetch 封装和一段完全正确的实现,它给你的解释听起来一样自信。这对 review 是个陷阱——你很容易被它流畅的措辞带着走,默认它想清楚了。我的对策是把它的解释和代码分开看:解释只当线索,不当结论,真正拍板还是回到代码和验证输出。尤其是它主动说“我特意处理了 xx 边界”的时候,我反而会多看一眼那个边界到底处理没处理,因为这种“主动邀功”的地方,恰恰是它凭印象补出来、实际没写的重灾区。

让它先复述,再动手

除了先读代码,我还加了一道很小但很有效的关卡:让它在动手前,用自己的话把我的需求复述一遍,并列出它打算怎么做。

1在写任何代码前,先用你自己的话复述一遍这个需求,
2然后列出你打算改哪些文件、每个文件改什么、为什么。
3先别写代码,等我确认。

这一步过滤掉的误解多得惊人。很多时候它复述出来我才发现,它理解的需求和我想的差了一截——我说“把标签页做成静态生成”,它复述成“加一个客户端筛选的标签页”,要是没让它先说,它就直接照着错的方向写完了,我再花更多时间把它掰回来。复述这一下几乎不花时间,却能在最便宜的阶段暴露理解偏差。

它列出的改动计划也顺便帮我预判风险。如果计划里冒出一个我没预期的文件,比如我只让它改列表页,它却说要动 lib/posts.ts,那我立刻就警觉了——要么是它发现了我没考虑到的依赖(值得听),要么是它准备顺手重构(要拦住)。无论哪种,在它写之前看到这个信号,都比事后从 diff 里扒出来划算。

我会保留一条人工决策线

AI 可以帮我读代码、列风险、写初稿、跑测试、解释失败,但关键决策我不会完全交给它。

比如:

  • 要不要改变公开 URL
  • 要不要引入新依赖
  • 要不要重构共享模块
  • 要不要调整错误处理协议
  • 要不要删除兼容逻辑

这些决定影响的是项目长期维护,不只是当前任务是否完成。AI 可以给建议,但最后应该由人确认。

判断哪些事该留人工线,我有个简单的标准:看这个决定能不能轻松回退。改一段函数实现,错了 revert 就好,这种我放心交给 AI 多试。但改公开 URL、删兼容代码、动数据格式,这些一旦上线就有外部依赖,回退成本很高,甚至回退不了,这种就必须人来拍板。AI 的成本模型和人不一样,它不会替你算“这个 URL 改了之后三年前的外链全废了”这种账,它只看当前任务是不是更优雅。

喂什么上下文,比问什么问题更关键

用久了我发现,同一个需求,效果好不好很多时候不取决于我把问题描述得多清楚,而取决于我给它看了哪些文件。上下文窗口是有限的,你塞进去的每一份文件都在占额度,塞对了它顺藤摸瓜,塞错了它被无关代码带偏,或者干脆因为关键文件没进上下文而开始“合理猜测”。

早期我图省事,习惯把一整个目录甩给它,想着信息越全越好。结果反而更糟:它要么被一堆边角料淹没抓不住重点,要么把某个测试 mock 文件里的写法当成项目主流风格照抄。后来我改成反着来——先只给它最小必要集:真正要改的那个模块、它依赖的核心工具、一两个能代表项目风格的样例。它读完如果发现信息不够,会主动说“我还需要看 xx”,这时候我再补,比一次性灌进去精准得多。

有几类文件我会优先塞进去,因为它们信息密度最高:类型定义文件(.d.ts 或核心的 interface),一份能看出目录约定的样例组件,还有 package.json——让它知道项目到底装了哪些库,能避免它引一个根本没依赖的包,或者重新造一个已经装了的轮子。相反,构建产物、node_modules.next 这类生成目录我会明确让它别读,读了纯属浪费额度还容易误导。

还有个细节是长会话里的上下文腐坏。一个任务聊久了,前面读过的文件内容会慢慢被后面的对话挤出窗口,它可能“忘了”开头看过的某个约定,又开始自由发挥。我遇到这种情况不会硬聊下去,而是把当前进展和结论让它总结成一小段,开个新会话重新来过,带着这段总结和最新的代码状态。与其在一个被稀释的上下文里反复纠偏,不如给它一个干净的起点。

把约定写进仓库,而不是每次重复说

聊了这么多,其实很多约束我不想每次对话都手敲一遍。重复成本太高,而且容易漏。

我的做法是把项目的关键约定沉淀成一个 agent 能读到的文件,比如根目录放一个 CLAUDE.md,把那些反复要叮嘱的东西固化下来:

1## 这个项目的约定
2
3- 文章解析统一走 `lib/posts.ts`,不要在页面里直接读文件系统
4- 时间处理用 dayjs,不要引新的日期库
5- 改完文章相关逻辑,必须重新生成 sitemap/rss/sidebar
6- 不要改动文章 slug 和已公开的 URL
7- 重构、改函数签名属于计划外改动,动手前先问

有了这层东西,每次起新任务的开场白能短很多,AI 也更不容易踩固定的那几个坑。它不是万能的,AI 偶尔还是会忽略,但比纯靠记忆和我每次现编要可靠。约定写进仓库还有个附带好处:人也能看,新同事和 AI 读的是同一份规则,省得两套标准打架。

我现在和 AI 协作写代码,最在意的不是它一次能写多少,而是整个过程有没有被约束住。

先读系统,再拆任务;改动前说计划,改动后跑验证;能小步就小步,能保留现有约定就保留现有约定。这套流程听起来像是给自己加活,实际用下来反而更省——因为它把返工挡在了最便宜的阶段。让它多读五分钟代码、多复述一句需求、多贴一段验证输出,成本都很低;等它写完二十个文件再发现方向错了,成本高得多。

说到底,这些做法没有一条是 AI 专属的,读代码、拆小步、说清计划、跑验证、把约定写下来,都是团队协作里早就成立的常识。只是过去这些常识是靠人的自觉在维持,现在多了一个执行力极强、但既不了解上下文也不会自我怀疑的协作者,那些原本可以松一松的环节,反而要收得更紧。用好它的前提,是先把这些老规矩重新捡起来。