AI 写代码之后,评审该看什么

当一半代码是 AI 生成的时候,code review 的重点必须变:从逐行找 bug 转向审边界、审安全、审“它理解对了吗”。
这两年我团队里写代码的动作,有一半以上已经不是我亲手敲的了。Copilot 在编辑器里补,Cursor 改一整个文件,Claude 或者本地模型在终端里帮我生成一段脚本。我自己更像是一个校对和决策者,而不是打字员。这种转变带来一个很实在的问题:当代码不是你一个字一个字写出来的,你该怎么审?
我见过不少团队,AI 接入之后评审反而变松了。理由听起来很合理,"反正都是模型按最佳实践生成的,应该没问题吧"。结果就是一些微妙的错误、过度设计的抽象、以及根本不存在的 API 悄悄溜进了主干。评审这件事在 AI 时代不仅没有贬值,反而更重要了,只是它看的东西变了。
这篇文章想聊的是,当产出从"人写的代码"变成"人指导、AI 生成的代码"之后,评审的重心应该怎么迁移,AI 代码有哪些反复出现的毛病,以及我目前在用的一份评审清单和一条"先自审、再人审"的工作流。如果你最近也在带人、也在审越来越多的 AI 生成 PR,希望这些能帮你把时间花在刀刃上。下面所有判断都来自我过去一年真实的踩坑,不是教科书上的漂亮话。
评审重心的迁移
过去我带新人做评审,第一条建议永远是"看风格、看命名、看有没有明显的语法问题"。因为这些是肉眼最容易抓、也最占评审时间的部分。可一旦代码是 AI 生成的,风格反而最可能达标:它不会忘记加类型注解,不会写出八层缩进,甚至会主动补 docstring。把评审时间继续花在这些地方,是巨大的浪费。
从"怎么写"到"为什么写"
AI 最擅长回答的是"怎么写",最不擅长的是"为什么写"。它看到一段需求描述,会迅速拼出一个能跑的版本,但不会追问这个需求背后的真实意图。所以我现在评审时,最先问的问题不是"这段代码对不对",而是"这段代码想解决什么问题,它真的解决了吗"。
举个例子,有一次我让模型给一个接口加缓存。它很聪明地用 functools.lru_cache 包了一层。看起来是对的,但那个接口依赖了请求里的用户身份,而 lru_cache 把第一次调用的结果按参数缓存了,结果第二个用户拿到了第一个用户的数据。代码"正确"地实现了缓存,却彻底违背了业务意图。这类问题,光看 diff 里那几行是发现不了的,必须回到意图层面。
还有一次更隐蔽。产品要"当用户连续登录失败五次就锁定账号"。AI 生成的逻辑是累计失败次数,但它把计数存在了进程内存的字典里。单机测试一切正常,上线到多实例部署后,五次失败分布在两台机器上,谁也没到五次,锁定永远不触发。它完美实现了"计数",却没理解"计数必须跨实例共享"这个隐藏前提。这种意图层面的坑,是 AI 时代评审最该盯的地方。
意图层、边界层、系统层
我把现在要重点看的维度分成三层:
- 意图层:这段代码是否真的满足了需求,有没有误解或打折实现。
- 边界层:空值、超时、并发、超限输入下会发生什么,这是 AI 最容易偷懒的地方。
- 系统层:它对其他模块、对数据库、对外部依赖的影响是否被考虑到了。
语法和风格被压到了最末,甚至交给工具自动做掉。这个顺序的调整,是 AI 时代评审最大的变化。过去评审百分之九十的精力在语法和风格,现在那部分该趋近于零,腾出来的精力要投到上面三层。
一个被忽略的维度:变更范围
AI 有个习惯:你要它改一个函数,它可能顺手把相邻三个文件也"顺手优化"了。这些顺手的改动里,常埋着意料之外的破坏。所以我评审时一定会先看 diff 的总范围,"这次改动真的都和任务相关吗"。凡是偏离原始需求的额外改动,要么让它拆成独立 PR,要么当场砍掉。控制变更范围,本身就是控制风险。AI 生成代码的 PR 尤其容易膨胀,限制范围是评审的第一道闸。
AI 代码常见的四类问题
我审过几百个 AI 生成的 PR,问题模式高度集中。下面这四类几乎每次都会遇到,我逐一展开讲,并给出我各自的应对办法。
过度防御
AI 极度害怕"出错",所以它的默认策略是层层防守。你会看到一个函数里同时做了 if value is None、空字符串判断、类型 isinstance 检查、还有 try/except Exception。单独看每一处都"没错",但合在一起,代码的真实意图被淹没在防御性噪音里,而且这些防御往往彼此矛盾,前面刚断言 value 必须是 int,后面又把它当可能为空处理。
更隐蔽的是,过度防御会掩盖真正的设计问题。本来应该在上游保证数据干净,现在被下游一堆判断稀释掉了。我的处理方式是:除非这是系统边界(用户输入、外部接口、解析第三方数据),否则删掉防御,让错误尽早暴露。
# AI 喜欢写成这样
def process(data):
if data is None:
return None
try:
if not isinstance(data, list):
data = [data]
except Exception:
return None
result = []
for item in data:
if item is None:
continue
try:
result.append(int(item))
except (ValueError, TypeError):
continue
return result or None# 我更希望看到这样:边界清晰,出错就出错
def process(items: list[int]) -> list[int]:
return [int(x) for x in items]注意第二段代码把"输入必须是 int 列表"的责任交还给了类型系统和调用方。如果上游传错了,立刻崩,比静默返回 None 好一万倍。静默失败最可怕的地方在于:它不报错,但数据已经开始悄悄错了,等到被发现往往已经污染了一层层的下游。我评审时对 except Exception 后面跟 return None 或 pass 的模式会格外警惕,这几乎总是把问题藏起来的信号。
幻觉 API
这是最危险的一类。AI 会信心十足地调用一个它"以为存在"的函数、参数或库方法。比如它说 requests 有个 requests.get_json(),或者给 pandas 用了一个并不存在的 DataFrame.flatten()。它甚至能编出看起来合理的文档字符串。
# 幻觉示例:pandas 并没有 DataFrame.flatten() 这个方法
import pandas as pd
df = pd.DataFrame({"a": [1, 2]})
normalized = df.flatten() # AttributeError: 'DataFrame' object has no attribute 'flatten'这类问题单靠人眼审很难百分百抓到,因为评审者也可能不记得某个库的全部细节。我现在的防线是:import 之后、合并之前必须跑一次。只要执行路径碰到了幻觉 API,立刻就会在运行时炸出来。所以我在下面"工具链"一节会强调,类型检查和静态分析在 AI 时代反而是更值钱了。识别幻觉 API 有个小技巧:凡是 AI 用了一个你"好像听过但不确定"的方法名,先去官方文档搜一下,而不是直接信它。经验告诉我,越是"听起来很合理"的名字,越可能是它编的。
跨文件一致性破坏
单文件生成时,AI 表现得很好;但只要任务涉及多个文件,它就很容易破坏一致性。典型场景:它在 A 文件里把某个函数的签名从 get_user(uid) 改成了 get_user(user_id: int),却忘了同步 B 文件里的调用。或者它在新增一个枚举值时,没有更新对应做校验的 if/elif 链,于是新值走入了默认分支。
# file_a.py —— AI 改了这里的签名
def get_user(user_id: int) -> User:
...
# file_b.py —— AI 没改这里,调用就断了
def greet(uid):
user = get_user(uid) # 旧签名叫 uid,新签名叫 user_id,类型也变了
return f"hi {user.name}"这种问题在 diff 里特别隐蔽,因为出问题的是"没被改动的那个文件"。我的评审习惯是:任何跨文件的改动,我都会先在脑子里画一张依赖图,专门找"这个函数/类型被谁用到",然后逐个确认签名和语义是否还对齐。如果 uid 原本是字符串而 user_id 期望 int,这种不一致在静态类型检查下会立刻暴露,这也是为什么我坚持开严格类型检查。一句话:跨文件改动永远要 grep 全仓,不能只看 AI 改的那几处。
无用抽象
AI 热衷于制造看似专业的抽象:"为了可扩展",它把一个简单的 if 改成了策略模式加工厂类;"为了复用",它抽出三个几乎一样的基类。结果代码行数翻了三倍,可读性和可维护性反而下降。
# AI 的"过度抽象"产物
class BaseHandler:
def handle(self, data): raise NotImplementedError
class OrderHandler(BaseHandler):
def handle(self, data):
return data["amount"] * data["price"]
def process_order(data):
return OrderHandler().handle(data) # 只有一个实现,工厂纯属多余# 拍平之后
def process_order(data: dict) -> float:
return data["amount"] * data["price"]我的一条铁律是:在你真正需要第三次复用之前,不要抽象。AI 不知道你业务的演化路径,它的"可扩展性"往往是伪需求。评审时看到单层使用的接口、只有一个实现的抽象基类、只为包装一行调用的函数,我基本都会要求拍平。抽象有成本:每多一层,读者就多一层心智负担,调试时多一个跳转。没有复用证据之前,简单就是正义。
一份可操作的评审清单
下面是我现在每个 AI 生成 PR 都会过一遍的清单。它不是教条,而是一张检查表,帮我在疲劳时也不漏掉关键维度。我在原来五类的基础上,又补了可观测性和性能两类,这两块 AI 也常偷工减料。
正确性
- 这段代码是否真的满足了原始需求,而不是需求的字面近似。
- 主流程在典型输入下是否产出预期结果,我是否实际跑过一遍。
- 是否有幻觉 API、不存在的参数、编造的库方法。
边界条件
- 空集合、空字符串、None、零、负数这类边界是否被正确对待。
- 超大输入(长列表、大文件)会不会把内存或响应时间拖垮。
- 时间戳、时区、浮点精度这类容易翻车的地方有没有特殊处理。
并发
- 有没有共享可变状态被多个协程或线程读写。
- 数据库事务的隔离级别是否合适,会不会出现脏写或丢失更新。
- 锁的粒度是否过粗导致性能问题,或过细导致漏锁。
# 一个 AI 常写错的并发例子:在 async 里做同步阻塞调用
async def fetch_all(urls):
results = []
for url in urls:
# requests 是同步阻塞的,会卡住整个事件循环
resp = requests.get(url)
results.append(resp.text)
return results
# 正确做法是用异步客户端
import aiohttp
async def fetch_all(urls):
async with aiohttp.ClientSession() as session:
tasks = [session.get(u) for u in urls]
responses = await asyncio.gather(*tasks)
return [await r.text() for r in responses]安全
- 所有外部输入是否都走了参数化查询,杜绝拼接 SQL。
- 有没有把密钥、token 写死在代码里。
- 文件路径是否做了遍历防护,反序列化是否用了危险的
pickle。 - 对外接口是否做了鉴权和速率限制。
测试覆盖
- 关键逻辑有没有测试,测试是只跑了 happy path 还是也覆盖了异常分支。
- 测试是否真的断言了行为,还是只断言了"没抛异常"。
- 测试本身是否脆弱,比如依赖了真实网络或当前时间。
# 脆弱测试的坏味道:依赖真实时间
def test_token_expires():
token = create_token(ttl=60)
time.sleep(61)
assert not token.is_valid()
# 更好的做法:注入时钟,测试可重复、可秒级运行
def test_token_expires(clock):
token = create_token(ttl=60, now=clock.now)
clock.advance(61)
assert not token.is_valid()可观测性与日志
- 关键分支是否有日志,出问题时能不能靠日志还原现场。
- 日志里有没有不小心打印了敏感信息(密码、token、身份证)。
- 失败时是静默吞掉,还是带了足够上下文往上抛。
性能与资源
- 是否有明显的 N+1 查询、重复计算、不必要的全表扫描。
- 打开的文件、连接、临时资源有没有正确关闭。
- 循环或递归有没有潜在的指数级膨胀。
一次真实 PR 的评审回顾
讲清单太抽象,说一个我上周审的真实例子。需求是"在订单列表接口里加上按创建时间倒序"。AI 给的 PR 改了四件事:加了排序、顺手把分页逻辑重写成了一个新的 Paginator 类、给 Order 模型加了一个看起来无关的 display_name 属性、还把序列化器从函数式改成了一个类。
我按清单过了一遍:排序本身对,但 Paginator 类只有一个调用方,纯属多余,砍掉;display_name 和需求无关,而且它把用户的真实姓名直接拼进去了,有隐私问题,砍掉;序列化器重写引入了类型变化,跨文件,怕有遗漏,要求回退到函数式。最后这个 PR 从改了六个文件缩到改了两个文件,功能没少,风险小了一大半。这就是"管范围、砍抽象、查意图"三板斧的实际效果。
评审不是把 AI 的代码改得更好看,而是把不属于这次任务、或会引入新风险的部分,坚决挡在门外。
让 AI 先自审,再人审
光靠人审,面对 AI 的产出速度是跟不上的。我现在的流程是让 AI 先做一轮自审,人只在最后把关。
具体做法是:生成代码后,我让同一个或另一个模型扮演"严格的资深评审",给它这份评审清单,让它逐条对照找问题。这一步能抓掉大量的幻觉 API、边界遗漏和风格问题。然后我再做人工评审,重点放在意图层和系统层,那是目前 AI 自审还做不好的部分。
一个关键技巧是:自审的 prompt 要给出明确的"找茬"指令和上下文,而不是泛泛地问"这段代码好吗"。我通常会把需求原文、相关文件、以及上面的清单一起喂给它,并明确要求"只列出问题,不要重写"。这样它的注意力会集中在批判性检查上,而不是又去生成新代码。
你是一名资深代码评审。下面是需求原文、相关上下文和代码 diff。
请对照评审清单逐条检查,只输出发现的问题(含文件路径与行号),
不要重写代码,不要客套。重点找:意图是否跑偏、边界遗漏、
并发隐患、安全漏洞、幻觉 API、跨文件不一致、无关的额外改动。自审不是让 AI 给自己盖章,而是用一个更冷酷的视角做第一遍筛子。人要做的是它做不了的判断:这段代码的意图对不对,它在系统里的位置合不合适。
工具链的新价值:lint 与类型检查
有意思的是,在"人来写"的时代,有些团队会觉得 lint 和强类型检查碍事、琐碎。到了 AI 时代,它们突然变成了不可替代的守门员。原因很简单:AI 会写幻觉 API,而类型检查器和 linter 是少数能确定性地指出"这个符号不存在"的自动化手段。
我现在会把下面这些卡在合并之前的 CI 里,且不允许忽略:
ruff或flake8做静态检查,重点开对未定义名称、未使用导入的规则。mypy或pyright做类型检查,模式设为严格,专门抓幻觉出来的方法签名。- 单元测试必须全绿,且覆盖率不能低于基线。
pre-commit把上面这些挡在本地提交之前,省得把明显问题推到远端。- 依赖扫描(如
pip-audit)自动发现 AI 顺手引入的、有已知漏洞的包版本。
# pyproject.toml 里的关键配置示例
[tool.mypy]
strict = true
warn_unused_ignores = true
disallow_untyped_defs = true
[tool.ruff]
select = ["F", "E", "I", "B"] # F=未定义/未使用, E=语法风格, I=导入, B=可误导性写法这些工具的价值不在于"帮你写更好看的代码",而在于它们以接近零成本的方式,把评审从"逐行人眼找拼写错误"里彻底解放出来,让人可以把全部注意力放到意图、边界和安全上。我甚至建议把"AI 生成代码必须通过严格 lint 和类型检查"写进团队的合并门槛,这不是刁难,而是给人和 AI 之间加了一道确定性的过滤网。当工具能确定性地挡掉低级错误,人就该去做工具做不了的事。
评审中的心理陷阱
聊完方法,说几个我自己踩过的"心态坑"。第一是"信任惯性":只要连续十个 PR 都干净,人就容易放松,第十一个藏着问题的也就混过去了。对抗它的办法不是更努力地看,而是把确定性检查交给工具,工具不会累,也不会被惯性麻痹。
第二是"补全冲动":看到 AI 生成的不完整代码,人本能想顺手帮它补完。但补完之后,这段代码到底是谁的主意就说不清了, responsibility 也模糊了。我的原则是:评审者只做判断,不做代写。要改让它改,或者明确说"按这个方向重生成",而不是我自己在 diff 里敲。
第三是"过度苛责":有人因为 AI 偶尔出错,就退回纯人写。这是因噎废食。AI 把从零到一的成本打下来了,评审把从一到优的风险管住了,两者配合的总效率依然远高于纯人写。问题是流程,不是工具。
当 AI 重写整个文件时该怎么办
AI 有时会"好心"把一个文件整体重写,比如为了改一个变量名把三百行全重排了。这种 diff 几乎没法审,因为全是新增和删除,人眼无法聚焦真正的改动。我的应对是:禁止整文件重写类任务,要求它"只改必要的行,保持其他行不变"。如果它已经重写了,我会让它用 git diff 解释每一处非必要的变动,解释不清的就 revert 重来。评审的前提是"能看懂改了什么",看不懂的改动不许进。
任务约束:只修改与本次需求相关的行,不要重新格式化、不要调整无关 import 顺序、
不要顺手重命名。输出后请用 git diff 说明每一处改动的理由。给团队的落地建议
如果你们团队也想把这套流程跑起来,我给几条从我们实践里提炼的建议:
- 先在 CI 里把 lint 和严格类型检查卡死,这是零成本的第一道闸,也最容易被接受。
- 准备一份团队共享的评审清单(就是上面那份),让每个人的评审口径一致,避免"看心情"。
- 鼓励"先自审再人审",把 AI 自审的 prompt 也存成团队资产,新人直接复用。
- 定期把典型的 AI 翻车案例(幻觉 API、跨文件不一致)整理成内部分享,提升集体嗅觉。
- 给"重写类任务"立规矩,避免无法审阅的大 diff 混进主干。
流程这东西,最怕"每个人凭感觉来"。把它写下来、卡进工具,AI 代码的质量才稳得住。
什么信号说明该打回重做
最后给一个速查:看到下面这些信号,我基本会直接打回,不让它进人审环节:
- diff 横跨大量不相关文件,且没有拆 PR。
- 出现了
except Exception: pass或静默return None这类藏错模式。 - 调用了不确定是否存在的 API 或参数。
- 引入单层使用的抽象类、工厂、基类。
- 测试只断言"没抛异常",没断言行为。
- 没有任何日志,出错后无法还原现场。
这些信号单独出现或许能商量,但凑齐两三个,基本就是"先回去重做"的红灯。
评审不是终点:把发现反推回提示词
评审出的每个问题,都不该只停留在"这次改掉"。我习惯把它们反推回两处源头:一是生成代码的提示词,二是团队的"禁止清单"。比如发现模型老爱写 except Exception: pass,我就在提示词里加一句"不要吞掉异常,出错要向上抛并带上下文";发现它总顺手重命名,就加"不要调整无关 import"。这样下一次生成,同类问题出现的频率会肉眼可见地下降。
同理,把反复出现的翻车模式沉淀成团队的"禁止清单",让所有人写提示词时默认带上。评审因此从"事后救火"变成了"事前免疫":你改的不是一个 PR,而是这一类 PR 的生成质量。AI 时代评审划算的地方在这里:人的判断被放大成了可复用的规则,而不是一次次重复劳动。
别把评审做成流水线
最后提醒一句:流程再好,也别把评审变成机械打勾。清单是辅助记忆的,不是替代思考的。我见过有人把自审清单丢给 AI 跑一遍,自己扫一眼结论就点了通过,这等于把最该人做的判断又外包了出去,风险一点没少。人审那一步,盯的永远是"意图对不对、系统影响合不合理",这两件事清单列不出来,只能靠你自己的工程判断。工具帮你看清细节,但"该不该进"这个决定,请始终留在人手里。
结语
AI 没有改变"评审是必要的"这个事实,但它改变了评审的内容。当机器承担了从零到一的书写,人类评审者最稀缺的能力变成了:理解真实意图、判断系统影响、以及对安全边界保持偏执。把语法和风格交给工具,把找茬交给 AI 自审,把自己留在最难也最值钱的那一层,这是我目前找到的最舒服的姿势。
评审的目标一直没变:确保这段代码值得进入我们的系统。这个目标,在 2026 年,比任何时候都更像一门需要刻意练习的手艺。工具会越来越强,但决定一段代码该不该上线的,依然是一个愿意为后果负责的人。评审从"挑错别字"升级为"判断价值与风险",这是 AI 给工程文化带来的变化里,最不显眼的一个,也是最实在的一个。