1. 为什么我让AI写代码,却把审查权死死攥在自己手里
让AI写代码这件事,现在几乎成了日常。Claude Code、各种Agent工具、本地模型接入VS Code,写个函数、补个测试、重构一段逻辑,几秒钟就能给你吐出一大段。但我自己踩过一个很典型的坑:有一次让AI帮我改一个权限校验模块,它给出的代码逻辑上完全正确,跑起来也没报错,但它在处理边界条件时,把一个本该拒绝的请求放行了。原因很简单——它只看了我给的上下文,没看到系统里另一处更早的拦截逻辑。
这件事之后我就定了个规矩:AI可以写,但审查权必须在我手里,而且审查要有清单、有流程、有打回机制。标题里说的"四条清单,两次打回",就是我这套流程的核心。四条清单分别覆盖功能正确性、边界与异常、安全与权限、可维护性;两次打回指的是——第一次打回是逻辑层面的问题,第二次打回是安全或权限层面的问题,两次都过不了,这段代码就不进主干。
这套流程不是拍脑袋想出来的,是我在真实项目里反复调整后沉淀下来的。它适合所有用AI辅助写代码的开发者,不管你是用Claude Code、还是本地模型接入编辑器,只要AI参与了代码生成,这套审查框架都能直接用。下面我把四条清单拆开讲,每一条都配上我实际打回过的案例和判断依据。
1.1 审查权为什么不能交给AI自己
很多人会想,既然AI能写,那让它自己审不就行了?我试过,结论是:AI自审的盲区恰好是最危险的地方。它审自己的代码时,倾向于确认"我写的逻辑是自洽的",而不是"这段代码放进整个系统里是否安全"。比如它写了一个文件删除功能,自审时会检查路径拼接对不对、异常捕获全不全,但它不会主动去想"这个路径有没有可能被外部输入污染"。
更关键的是,AI对权限和边界的理解是"声明式"的,你告诉它"这个接口需要管理员权限",它就会加上校验,但它不会去验证"这个校验能不能被绕过"。而人类审查者,尤其是写过安全相关代码的人,会本能地去想绕过路径。这就是审查权必须留在人手里的根本原因。
我现在的做法是:AI生成代码后,我先不看逻辑,先看它动了哪些文件、涉及哪些权限、有没有引入新的外部依赖。这一步叫"影响面扫描",是四条清单里安全清单的前置动作。扫描完再逐条过清单,效率比直接读代码高很多。
1.2 四条清单的定位与分工
四条清单不是并列关系,而是有优先级的。功能正确性清单排第一,因为逻辑错了后面都白搭;边界与异常清单排第二,因为大部分线上事故都出在边界;安全与权限清单排第三,但它有一票否决权;可维护性清单排第四,属于"可以打回但不阻塞"的级别。
| 清单 | 核心问题 | 打回级别 | 典型触发场景 |
|---|---|---|---|
| 功能正确性 | 逻辑是否自洽、是否符合需求 | 第一次打回 | 条件判断写反、循环边界错 |
| 边界与异常 | 空值、极值、并发、超时 | 第一次打回 | 空数组未处理、超时未捕获 |
| 安全与权限 | 越权、注入、敏感数据泄露 | 第二次打回(一票否决) | 权限校验可绕过、日志打印密钥 |
| 可维护性 | 命名、注释、耦合度 | 建议修改 | 函数过长、魔法数字 |
这个优先级顺序很重要。我见过有人先纠结命名规范,结果功能逻辑是错的,白白浪费一轮审查。正确的顺序是:先确认功能对,再看边界全不全,然后查安全有没有洞,最后才看代码漂不漂亮。
2. 功能正确性清单:AI最容易在"条件判断"上翻车
功能正确性清单是我用得最频繁的一条,因为AI写代码出错,十次里有六次是逻辑层面的。它不像人类程序员会犯"忘了写某个分支"这种低级错误,它犯的错更隐蔽——条件判断的语义偏移、循环边界的off-by-one、状态更新的顺序问题。
2.1 条件判断的语义偏移
AI在把自然语言需求翻译成代码时,经常会把"且"和"或"搞混,或者把"非"的作用范围弄错。我打回过一个典型案例:需求是"当用户不是管理员且不是资源所有者时,拒绝访问",AI写出来的条件是:
if not user.is_admin or not user.is_owner: deny_access()看起来没问题?实际上这个条件在"用户是管理员但不是所有者"时也会拒绝访问,因为not user.is_admin为False,但not user.is_owner为True,整体为True。正确的写法应该是:
if not (user.is_admin or user.is_owner): deny_access()这就是典型的德摩根定律应用错误。AI不是不懂德摩根定律,它在生成时倾向于逐词翻译,把"不是A且不是B"直接翻成not A or not B,而正确的逻辑等价是not (A or B)。我的审查习惯是:凡是看到not和and/or混用的条件,一律手动展开真值表验证。
2.2 循环边界与状态更新顺序
另一个高频翻车点是循环。AI写循环时,边界条件经常差一。比如需求是"处理列表里除最后一个元素外的所有元素",它可能写成:
for i in range(len(items)): if i == len(items) - 1: continue process(items[i])逻辑上没错,但更简洁且不易错的写法是for item in items[:-1]。我打回这类代码的理由不是"能跑就行",而是边界表达越显式,后续修改时越不容易引入新bug。当有人后来把"除最后一个"改成"除最后两个"时,items[:-1]改成items[:-2]一目了然,而循环里的continue条件就要重新推敲。
状态更新顺序的问题更隐蔽。AI在写多步状态变更时,有时会先更新派生状态再更新源状态,导致中间态不一致。我审查时会特别关注:有没有在同一个函数里既改了A又改了依赖A的B,且顺序可能影响结果。如果有,就要求拆成两步或者加注释说明顺序依赖。
2.3 功能清单的实操检查项
我把功能正确性清单固化成了几个必查项,每次审查AI代码时逐条过:
- 所有条件判断的真值表是否手动验证过,尤其是含
not的复合条件 - 循环边界是否用切片或显式范围表达,而非依赖
continue/break跳过 - 多步状态更新是否有顺序依赖,依赖是否被注释或测试覆盖
- 函数返回值在所有分支上是否一致(有没有分支漏了return)
- 需求里的"且/或/非"是否与代码里的逻辑运算符一一对应
这几项过完,功能层面的问题基本能拦住。我统计过,AI生成的代码里,功能正确性打回率大概在30%左右,其中条件判断问题占了一半以上。
3. 边界与异常清单:线上事故的重灾区
功能对了不代表能上线。我经历过一次线上故障,AI写的代码在正常输入下跑了三个月没问题,结果某天上游传了一个空列表进来,直接空指针崩溃。事后复盘,那段代码的功能逻辑完全正确,但它没处理空输入。这就是边界与异常清单存在的意义。
3.1 空值、极值与类型边界
AI对"正常路径"的处理通常很好,但对边界值的敏感度不够。我审查时会强制检查四类边界:空值(null/None/空字符串/空列表)、极值(最大最小值、零、负数)、类型边界(整数溢出、浮点精度、字符串编码)、以及"看起来正常但语义特殊"的值(比如空对象、全零ID)。
一个具体案例:AI写了一个分页函数,逻辑是offset = (page - 1) * page_size。功能正确,但当page=0时,offset变成负数,数据库查询直接报错。我打回后要求加上page = max(1, page)的防御。AI当时还"辩解"说调用方应该保证page从1开始——这就是AI的典型思维:它假设调用方守规矩。但真实系统里,调用方永远可能不守规矩。
审查AI代码时,永远假设调用方会传最离谱的参数进来。AI默认"输入是合法的",人类审查者要默认"输入是恶意的或错误的"。
3.2 异常捕获的粒度与吞异常问题
AI写异常处理有两个极端:要么不写,要么写一个巨大的try...except Exception把什么都吞掉。后者更危险,因为它会让错误静默消失。我打回过一段代码,AI在文件读取外面包了except Exception: pass,理由是"防止程序崩溃"。但这样做的后果是,文件不存在、权限不足、磁盘满,全都无声无息,上层完全不知道出了问题。
我的审查标准是:异常捕获必须明确捕获类型,且捕获后必须有日志或向上传递。except Exception: pass这种写法,见到一次打回一次。如果确实需要兜底捕获,也必须记录日志并标记为"未知错误",而不是静默忽略。
3.3 并发与超时的隐藏边界
并发问题AI很难自己发现,因为它生成的代码通常是单线程视角的。我审查时会特别关注:有没有共享状态被多个请求同时修改、有没有"先检查后执行"的竞态窗口、有没有对外部调用设置超时。
一个真实打回案例:AI写了一个"检查文件是否存在,不存在则创建"的逻辑,代码是if not os.path.exists(path): create_file(path)。单线程没问题,但两个请求同时进来,都判断不存在,都去创建,第二个就会报错或覆盖。正确做法是用open(path, 'x')这种原子操作,或者加文件锁。AI不会主动想到这一层,因为它没有被要求考虑并发。
超时也是同理。AI调用外部接口时,默认不设超时,因为它假设对方会及时响应。但真实网络里,不设超时等于把控制权交给对方。我要求所有外部调用必须有显式超时,且超时时间要写进代码而不是靠默认值。
3.4 边界清单的检查表
| 边界类型 | 检查项 | 打回示例 |
|---|---|---|
| 空值 | 参数、返回值、集合是否可能为空 | 空列表未判空直接取[0] |
| 极值 | 零、负数、最大最小值 | page=0导致offset为负 |
| 类型 | 溢出、精度、编码 | 大整数转int32截断 |
| 并发 | 共享状态、竞态窗口 | 检查后创建非原子 |
| 超时 | 外部调用是否设超时 | HTTP请求无timeout |
| 异常 | 捕获类型是否明确、是否吞异常 | except Exception: pass |
这张表我基本是背下来的,审查时逐项过,五分钟能扫完一个中等规模的改动。
4. 安全与权限清单:一票否决的那条线
安全与权限清单是我唯一设置"一票否决"的清单。功能有问题可以改,边界漏了可以补,但安全漏洞一旦上线,可能就是数据泄露或者越权访问。AI在安全方面的表现,说实话,比功能层面差很多,因为它对"攻击者视角"没有概念。
4.1 权限校验的可绕过性
AI写权限校验时,倾向于在"入口处"加一个检查,然后假设后面的代码都安全。但真实系统里,权限校验必须在"每个敏感操作前"都做,因为代码路径可能被绕过。我打回过一个典型案例:AI在API入口加了管理员校验,但内部的一个工具函数也能删除数据,且没有校验。如果这个工具函数被其他路径调用,就绕过了入口检查。
我的审查原则是:权限校验要贴着敏感操作写,而不是贴着入口写。删除数据的函数自己要有权限参数,而不是依赖调用方已经检查过。这叫"防御性权限设计",AI默认不会这么做,需要审查时强制要求。
4.2 敏感数据在日志和错误信息中的泄露
这是AI最容易犯的安全错误,没有之一。它写日志时,会把整个请求对象打印出来,里面可能包含密码、token、身份证号。它写错误信息时,会把数据库查询语句和参数一起返回给前端,泄露表结构。
我打回过一段代码,AI在登录失败时返回了"用户不存在"和"密码错误"两种不同的错误信息。功能上这很友好,但安全上这是漏洞——攻击者可以借此枚举出哪些用户名是存在的。正确做法是统一返回"用户名或密码错误"。AI不理解这种"友好即危险"的逻辑,需要审查者点出来。
审查AI代码时,搜一遍所有日志语句和错误返回,确认没有打印或返回:密码、token、密钥、身份证号、手机号、数据库语句、内部路径。
4.3 注入类问题的AI盲区
SQL注入、命令注入、路径穿越,这些经典漏洞AI理论上都"知道",但它在生成代码时,如果上下文里没有明确要求"用参数化查询",它可能会图省事用字符串拼接。我见过AI写"SELECT * FROM users WHERE name = '" + name + "'",理由是"这样更直观"。
审查时我的做法是:凡是看到SQL、shell命令、文件路径的拼接,一律打回要求改成参数化或白名单校验。不看它有没有实际漏洞,只看它有没有用危险模式。因为危险模式今天没被利用,不代表明天不会被利用。
4.4 权限清单的强制项
- 每个敏感操作(增删改、导出、权限变更)前是否有独立校验
- 校验逻辑是否可被绕过(内部函数、其他调用路径)
- 日志和错误信息是否泄露敏感数据
- SQL/命令/路径是否使用参数化或白名单
- 敏感操作是否有审计记录(谁在什么时候做了什么)
这五项里任何一项不过,直接第二次打回,不进入下一轮。我宁可多花半小时改权限,也不愿意事后花三天处理安全事件。
5. 可维护性清单:不阻塞但必须记录
可维护性清单是四条里唯一"可以打回但不阻塞"的。意思是,如果功能、边界、安全都过了,但代码写得很难维护,我会记录问题但允许合并,同时开一个技术债任务后续处理。为什么不阻塞?因为可维护性是长期问题,而功能和安全是短期风险,优先级不同。
5.1 命名与注释的AI通病
AI写命名有两个倾向:要么过于通用(data、result、temp),要么过于冗长(userAuthenticationTokenValidationResult)。前者让读者不知道变量是什么,后者让代码行超长。我审查时会要求:变量名要能回答"这是什么",函数名要能回答"它做什么"。
注释方面,AI喜欢写"复述代码"的注释,比如# 将i加1对应i += 1。这种注释没有价值。我要求注释只写"为什么",不写"是什么"。比如# 这里用倒序遍历是因为删除元素会影响后续索引,这种注释才有意义。
5.2 函数长度与耦合度
AI生成的函数经常偏长,因为它倾向于把所有逻辑写在一个函数里。我审查时会看:这个函数能不能用一句话说清楚它做什么?如果不能,就说明它做了太多事,需要拆。拆的标准不是行数,而是"职责数量"。
耦合度方面,AI喜欢在函数内部直接访问全局变量或外部状态。我要求所有依赖通过参数传入,这样函数才是可测试的。一个需要访问三个全局变量才能运行的函数,测试起来非常痛苦。
5.3 可维护性问题的记录方式
因为不阻塞,我会把可维护性问题记在一个单独的文件里,格式是:文件路径、行号、问题描述、建议改法。每周花半小时集中处理。这样既不阻塞当前开发,又不让技术债无限累积。
| 问题类型 | 记录格式 | 处理优先级 |
|---|---|---|
| 命名不清 | 文件:行号 - 变量x含义不明 | 低 |
| 函数过长 | 文件:行号 - 函数f职责过多 | 中 |
| 魔法数字 | 文件:行号 - 数字86400含义不明 | 低 |
| 全局依赖 | 文件:行号 - 依赖全局config | 高 |
6. 两次打回的实操流程与判断标准
前面讲了四条清单,现在讲"两次打回"具体怎么执行。这不是简单的"审两遍",而是两次审查的侧重点和判断标准完全不同。
6.1 第一次打回:逻辑与边界
第一次打回发生在功能正确性和边界与异常清单的审查之后。判断标准是:这段代码在正常输入和边界输入下,行为是否符合需求。如果不符合,打回,要求AI修改,修改后重新走第一次审查。
第一次打回的特点是"可修复"。大部分逻辑和边界问题,只要指出具体哪里不对,AI都能改对。我打回时会给出具体的反例输入和期望输出,而不是笼统地说"逻辑有问题"。比如"当page=0时期望返回第一页,实际返回错误",这样AI能精准定位。
第一次打回的常见原因分布,我大致统计过:
- 条件判断语义错误:约40%
- 边界未处理:约30%
- 异常处理不当:约20%
- 其他逻辑问题:约10%
6.2 第二次打回:安全与权限
第二次打回发生在安全与权限清单审查之后,且只在第一次审查通过后才进行。判断标准是:这段代码是否存在可被利用的安全风险或权限绕过。如果有,打回,且这次打回是"一票否决"——不修复不允许合并。
第二次打回的特点是"不可妥协"。安全问题上没有"差不多就行",要么修好,要么不用。我打回时会说明具体的攻击场景,比如"攻击者可以通过构造name参数执行任意SQL",让AI理解为什么必须改。
第二次打回比第一次少很多,因为大部分安全问题在第一次审查时就能顺带发现。但一旦触发,处理时间通常更长,因为安全修复往往涉及架构调整,不是改几行代码能解决的。
6.3 两次都过之后的动作
两次审查都通过后,代码可以合并,但还有两个动作:一是把这次审查中发现的问题模式记录下来,用于优化后续的AI提示词;二是如果可维护性清单有问题,开技术债任务。
记录问题模式这件事很有价值。比如我发现AI经常在"且/或"上出错,就在提示词里加一句"注意德摩根定律,复合条件请展开验证"。这样下次AI生成时,这类错误会明显减少。审查不只是拦问题,也是训练AI的过程。
7. 把清单变成习惯:我的日常审查节奏
讲了这么多清单和流程,最后说说怎么把它变成日常习惯。一开始我也觉得四条清单太繁琐,但坚持两周后就形成了条件反射,现在审查一个中等改动大概十分钟,比事后修bug快得多。
7.1 审查节奏与时间分配
我的节奏是:AI生成代码后,先花一分钟做影响面扫描(动了哪些文件、涉及哪些权限),然后按清单顺序过。功能清单三分钟,边界清单三分钟,安全清单两分钟,可维护性清单一分钟。总共十分钟左右。
这个时间分配是有依据的。功能和安全是重点,所以时间多;可维护性不阻塞,所以快速扫过。如果某个清单发现问题,时间会延长,但那是必要的投入。
7.2 常见误判与调整
审查久了会遇到误判。比如有一次我打回了一段代码,认为它有并发问题,后来发现那个函数只在单线程环境调用。这提醒我:审查时要确认代码的运行环境,不能默认所有代码都是并发的。
另一个误判是过度要求边界处理。有些内部工具函数,调用方是受控的,不需要处理所有边界。我现在的做法是:先看函数的可见性(public还是private),public的严格要求,private的适度放宽。
7.3 给不同基础读者的建议
如果你是刚接触AI辅助开发的新手,建议先从功能正确性清单开始,只查条件判断和边界。这两项能拦住大部分问题。等你熟悉了,再加安全清单。
如果你已经用AI写了很久代码,但没系统审查过,建议从安全清单入手。因为功能问题通常会在测试时暴露,但安全问题往往要到出事才发现。先把安全底线守住,再补其他清单。
如果你在团队里推广这套流程,建议先从"两次打回"的机制开始,让团队习惯"AI写的代码必须审"这件事,再逐步细化清单。机制比清单更重要,因为清单可以调整,机制是底线。
7.4 一个具体的审查实例
最后给一个完整的审查实例。AI生成了一个"根据用户ID删除评论"的函数:
def delete_comment(comment_id): comment = db.query("SELECT * FROM comments WHERE id = " + comment_id) if comment: db.execute("DELETE FROM comments WHERE id = " + comment_id) log.info(f"Deleted comment: {comment}") return True return False我按清单过一遍:
- 功能正确性:逻辑基本对,但没检查调用者是否有权删除
- 边界与异常:comment_id为空或非数字时,SQL会报错;没处理数据库异常
- 安全与权限:SQL拼接有注入风险;日志打印了完整comment对象,可能含敏感信息;没有权限校验
- 可维护性:函数名可以,但缺少类型标注
第一次打回:要求加参数校验和异常处理。第二次打回:要求改参数化查询、加权限校验、日志只打ID不打内容。修改后:
def delete_comment(comment_id: int, operator_id: int) -> bool: if not isinstance(comment_id, int) or comment_id <= 0: raise ValueError("invalid comment_id") if not has_permission(operator_id, "delete_comment"): raise PermissionError("no permission") try: affected = db.execute( "DELETE FROM comments WHERE id = %s", (comment_id,) ) if affected: log.info(f"Deleted comment id={comment_id} by operator={operator_id}") return True return False except DatabaseError as e: log.error(f"Delete comment failed id={comment_id}: {e}") raise这段改完,四条清单全过。整个过程花了大概十五分钟,但避免了一个注入漏洞、一个越权漏洞和一个日志泄露。这十五分钟,比事后处理安全事件划算太多。
审查AI代码这件事,说到底就是把"信任但验证"落到实处。AI是个很好的执行者,但它不是好的审查者。四条清单是我的验证框架,两次打回是我的执行机制。你可以根据自己的项目特点调整清单内容,但"AI写、人审、有清单、有打回"这个核心结构,我建议不要省。