上个月,我在 code review 里看到一段生成得特别漂亮的 Go 代码,结构清晰、命名讲究,几乎挑不出毛病。我差点就点了 Approve。后来随手查了一下它调用的那个 API 签名,发现函数名和实际包里的完全对不上——这是个根本不存在的函数,但编译居然没报错,因为参数被一层边界条件巧妙地糊弄过去了。那一刻我意识到,AI 生成的代码比人写的更危险,因为它比你更像一个“写过代码的人”。
这段经历让我开始认真思考一个问题:AI 帮我们写代码已经是大势所趋,但它产出的代码到底能不能直接推到生产环境?我的答案是不能,至少不能不做检查就上。这篇文章就围绕我一直在用的“上线前四道检查”展开:从代码走查、静态分析,到安全扫描、依赖审计,再到测试覆盖、性能验证,最后是灰度发布和回滚预案。如果你也在用 AI 写代码、并承担着把它合入主分支的责任,这篇文章应该是你 merge 之前最需要过一遍的清单。
1. 为什么 AI 生成的代码不能“能跑”就上生产
1.1 AI 代码的六种典型“翻车姿势”
先说一个大家都认同的前提:AI 生成的代码在“表面的正确性”上做得相当好,它知道缩进、命名、注释规范,甚至能写出不错的单元测试案例。但生产环境从来不是看表面,而是看边界条件和异常链路下的表现。我总结了一下,AI 生成的代码最容易在六个方向上翻车。
第一是幻觉 API。这是最常见、也最隐蔽的问题。AI 在生成代码时会一本正经地调用一个不存在的库函数、一个早已废弃的接口签名,或者一个参数顺序完全不对的方法。就像我开头说的那个例子,IDE 不一定会报错,因为 AI 会用类型转换把它伪装过去。第二是边界条件缺失。AI 几乎不会主动处理空指针、超时、并发冲突、重复调用这些“正常流程之外”的场景。你拿一份正常数据去测完全没问题,一上生产就被极端情况击穿。第三是安全疏漏。AI 生成的代码可能会拼接 SQL、拼接 HTML、把敏感信息打进日志、或者直接用用户输入拼 URL 请求外部资源,这些在 demo 里跑得通,在生产里就是事故现场。第四是依赖错配。AI 倾向于使用“看起来最新”的依赖版本,或者把一个本来只用于内部开发的包当成了公共依赖,导致供应链风险。第五是性能盲区。AI 生成的正则表达式、多层嵌套循环、没加索引的数据库查询,在数据量小的时候毫无感知,数据量一上来就直接把 CPU 或者数据库连接池打爆。第六是幂等缺失。AI 生成的接口通常不考虑重复请求的问题,一旦上游重试,就会出现重复扣款、重复下单、重复写入这类数据错乱。
这些翻车姿势单独拿出来任何一个,都够一次线上 P0 事故。所以“能跑”之于生产环境,就像“能点火”之于飞机——起飞之前你还需要检查发动机、油路、仪表和应急预案。
1.2 “能跑”和“能上生产”是两件事
为什么很多同学会对 AI 生成的代码放松警惕?我觉得核心原因是他们把“功能正确”当成了“生产就绪”。功能正确只说明输入和输出符合预期,而生产就绪要求的是幂等性、可观测性、可回滚性和安全性。举一个我身边的真实例子:有人让 AI 写了一个订单创建接口,单测、集成测试都过了,上线第二天业务方反馈出现重复订单。排查下来发现,AI 生成的代码没有做幂等性设计,接口收到上游重试请求后,直接又往订单表插了一行。这个场景任何人手工写代码都会下意识加一个订单号唯一索引或者幂等表,但 AI 不会替你想到这一步,它只负责把“功能逻辑”翻译成代码。
所以,把 AI 生成的代码合入主干之前,至少要做一轮“生产环境视角”的审视。下面这张表是我日常工作的总览,四道检查各管一段,互相独立又互为补充。
| 检查次序 | 核心目标 | 主要手段 | 解决的典型风险 |
|---|---|---|---|
| 第一道 | 代码逻辑正确、质量合格 | 人工 code review + 静态分析 + 类型检查 | 幻觉 API、逻辑遗漏、并发错误 |
| 第二道 | 安全合规、依赖可信 | 密钥扫描、漏洞审计、SAST | 注入、越权、供应链风险 |
| 第三道 | 行为可验证、性能达标 | 单元测试、集成测试、压测 | 功能回归、性能劣化、数据错乱 |
| 第四道 | 发布可控、回滚可执行 | 灰度放量、指标监控、预案演练 | 全量故障、数据不可逆 |
这四道检查的行动顺序我是刻意安排的:先靠人眼解决“逻辑对不对”,再用工具解决“漏洞有没有”,接着用测试解决“行为稳不稳”,最后用发布策略解决“出事了能不能回来”。接下来我按这个顺序,把每一道检查的关键动作和踩坑经验展开讲。
2. 第一道检查:代码走查与静态分析,别被表面漂亮糊弄过去
2.1 人工走查时我会重点看这四个位置
很多人觉得 AI 代码写得已经很规范了,人工 review 就是走个形式。我的体会是恰恰相反,越规范的代码越需要带着“找茬”的心态去过,因为它太容易让你放松警惕。我给自己列了一个强制 check 的清单,无论 AI 生成的代码看起来多完美,这四处位置必须亲眼检查。
第一个位置是 API 签名与外部契约。AI 生成代码时会编造函数签名、结构体字段和方法名,因此我会对着官方文档或者本地依赖包里的真实定义逐个核对。重点看方法名、参数顺序、返回类型,以及是否存在 deprecated 标记。第二个位置是异常处理与资源释放。AI 喜欢在 happy path 上写满逻辑,但在异常分支上经常忘记关闭文件、数据库连接、HTTP resp.Body,或者没有设置 context timeout、调用超时。这类代码初期没问题,运行久了就是连接池泄漏和 goroutine 泄漏。第三个位置是并发安全与状态共享。它会很自然地用全局 map 存数据、不做锁、不处理 channel 关闭,或者直接在一个结构体上开多个 goroutine 改同一个字段,没有任何同步机制。第四个位置是数据操作与事务边界。AI 生成的 SQL 可能没有事务包裹,或者事务只覆盖了一条 insert 而不是一批关联操作,导致失败时写入一半的数据,这种问题在数据库层面非常致命。
每次人工走查时,我会把上面几个位置作为“必答题”而不是“加分题”。没有异常处理我会直接打回,没有做幂等我会追问,没有事务边界我必然挂一个 P1 评论。这样看起来苛刻,但挡掉过好几次潜在事故。
2.2 静态分析与类型检查怎么配才不鸡肋
人工走查主要解决业务逻辑问题,但有些坏味道靠眼睛看效率太低,必须交给静态分析和类型检查。我的原则是:静态分析必须比 IDE 默认提示更严格,类型检查必须开启严格模式,否则这个环节形同虚设。
以 Go 项目为例,我至少会开 golangci-lint 里的这几个 linter:govet、errcheck、ineffassign、staticcheck、gosec。配置好之后,在 CI 里跑一句:
golangci-lint run --timeout 5m --enable govet,errcheck,ineffassign,staticcheck,gosecerrcheck 会强制你处理所有错误返回值,这能很有效地避免 AI 代码里常见的“只调函数不判断 error”的问题。TypeScript 项目我会把 tsconfig 里的 strict 设为 true,并启用 eslint 的 typescript-eslint 推荐规则集。Python 项目我推荐 ruff + bandit + mypy,各自负责风格、安全基线和类型标注。命令可以这样组合:
ruff check . bandit -r app/ mypy app/ --strict在配置静态分析时有一个经验值得分享:不要对整个仓库全量跑历史代码,不然你会被存量问题淹没,根本看不出这次 AI 生成代码新引入了什么问题。我的做法是只针对本次 diff 做增量扫描,让问题在 code review 阶段就暴露,而不是淹没在海量历史告警里。
2.3 工具不是万能的,这三类问题必须靠人
静态分析和类型检查能解决很多“错别字”级别的问题,但有三类问题工具帮不上忙,必须靠人的业务判断。第一类是业务语义错误,比如把“金额向上取整”写成了“向下取整”,工具无法知道你的业务应该舍还是入;第二类是隐含的契约假设,例如一个函数看似支持并发调用,但内部依赖了某个不可重入的单例资源,这在代码层面不一定能扫描出来;第三类是性能策略选择,AI 可能选择了功能等价但复杂度很差的实现,比如在循环里重复查询数据库,这类问题工具不会报错,只能靠 review 时的嗅觉。
我自己就吃过这个亏。有一回 AI 生成的代码在静态检查和类型检查里全绿,却在一组热点接口上出现了 N+1 查询,数据量小时毫无感知,上了生产后被流量放大,数据库连接池瞬间耗尽。从那以后我给自己定了一条规矩:工具全绿只是通过第一道检查的“最低门槛”,真正拍板合入之前,必须人工把上面四个位置逐一确认过。
3. 第二道检查:安全扫描与依赖审计,别只看 CVE 列表
3.1 依赖锁定与来源审计,供应链风险最容易被忽视
AI 生成代码时经常顺手引入新的依赖,或者建议你把某个依赖升级到最新版本。这里有两层风险:第一层是依赖版本本身存在已知漏洞,第二层是依赖来源不可信。很多人只关注第一层,Snyk 或 npm audit 一跑没漏洞就觉得安全了,但第二层风险往往更致命。
只要项目里引入了第三方依赖,就要确保 lockfile 被提交到仓库。npm 看 package-lock.json,Python 看 poetry.lock 或 requirements.txt 的 hash,Go 看 go.sum。lockfile 的意义不只是统一版本,它能保证所有人都安装完全一致的依赖树,避免“我本地编译没问题、服务上构建就崩”的经典事故。锁定之后,再对新增依赖做一遍来源审计:这个包是不是来自官方 registry,有没有被同名包仿冒,维护者最近有没有异常动作。
审计命令我常用这几个,它们覆盖不同语言和维度:
npm audit --omit=dev pip-audit osv-scanner -r . trivy fs --security-checks vuln,config .如果项目里用的是 npm 或 Python 生态,我建议至少把 npm audit 和 pip-audit 加上;如果服务是部署在容器里的,可以再用 Trivy 扫一遍镜像。还有一点要特别提醒:AI 可能会使用一个冷门小包来“节省自己写代码的时间”,这些小包往往是供应链攻击的重点目标。每引入一个新依赖,我都会在 review 记录里要求别人说明这个包解决了什么问题、有没有更主流的选择。
3.2 密钥泄露扫描,警告出现就得当真
AI 生成代码时特别喜欢做一件事:从网上的示例代码里复制一段配置,而示例代码里往往带着真实的 API Key、AccessKey、数据库密码。这类密钥如果跟着代码进了仓库,哪怕只是短暂存在,也会被自动化爬虫扫到并滥用。所以我要求每轮 AI 代码合入之前,都必须跑一遍密钥扫描工具。
我常用的工具是 Gitleaks 和 TruffleHog,两个都支持本地扫描和 CI 集成。Gitleaks 的用法很简单:
gitleaks detect --source . --report-format json --report-path gitleaks-report.json --redact这条命令会扫出仓库里所有疑似密钥的内容,包括硬编码的 token、私钥、云厂商凭证。我要强调的一个经验是:看到工具警告“这是一个示例密钥”,不要因为“示例”就忽略。很多 AI 模型在训练阶段见过大量真实密钥,它们生成出来的“示例”可能就是脱敏不彻底的旧密钥,一旦有人拿去撞库,照样会有风险。我宁可多花十分钟换一个密钥,也绝不赌它没有泄露到外部。
3.3 SAST 静态安全测试,给代码装上“安检门”
密钥扫描和依赖审计解决的是“仓库及依赖层面”的安全问题,代码本身的注入、越权、SSRF 等漏洞,则需要 SAST 工具进一步排查。这个环节我最常用的是 Semgrep 和 CodeQL。Semgrep 的好处是规则可定制且上手快,可以直接内置一大堆安全规则,还能针对企业私有逻辑写专属规则。
我在项目里会跑这样一句:
semgrep scan --config=auto --config=p/owasp-top-ten --severity=ERROR这句命令会把 OWASP Top 10 相关的风险直接提升为 ERROR 级别,阻断合并请求。如果团队用的是 GitHub 企业版,可以再配上 CodeQL,它对 Java、Go、Python、JavaScript 等主流语言的污点分析非常成熟,能自动追踪用户输入是否流入了 SQL 查询、命令执行等危险 sink。比如 AI 生成了一段用字符串拼接方式构造 SQL 的代码,CodeQL 大概率会直接标红,提醒你改用参数化查询。
有人说 SAST 误报率高,我承认这一点,所以我在配置时只把 ERROR 级别的规则接入 CI 强制拦截,其他的留给开发人员手动确认。但无论如何,安全扫描这一道必须排在测试之前,因为代码一旦进了测试阶段,说明你已经花了很多精力,再返工成本很高。提前挡住不安全代码,后面的测试才能跑得有价值。
4. 第三道检查:测试覆盖与性能验证,用机器证明行为
4.1 别让 AI 替你决定“有没有测试”
很多 AI 编程助手会顺便生成测试代码,但我必须泼一盆冷水:AI 生成的测试用例,绝大多数只覆盖正常的 happy path,断言也写得非常宽松,比如只断言“没有报错”、断言“返回值非空”,却不验证具体值。这种测试存在的意义非常有限,它更像是为了“看起来有测试”而写的样板。
我的做法是三个补:补核心分支、补错误路径、补边界条件。核心分支指的是业务里真正复杂的状态流转,比如订单状态从“已支付”到“已发货”之间,AI 有没有考虑到中间态的非法转换;错误路径指的是网络超时、依赖服务异常、数据库写失败等场景,要看代码在异常下能不能正确回滚、释放资源、返回友好错误;边界条件则是空数组、超长字符串、并发重复请求这些极端输入。你可以在 AI 生成的单测基础上,把这三类用例补进去,然后观察覆盖率变化。
集成测试这块我建议尽量用真实的基础设施。现在 testcontainers 已经很好用了,Redis、MySQL、Kafka 都能在测试环境临时拉起一个真实实例来跑依赖测试,不用把希望全部寄托在 mock 上。我自己在写 AI 相关流程的集成测试时,至少会验证一个完整的“调用-落库-查库”往返链路,确认 AI 生成的代码拿到的数据结构和真实存储的一致,而不是仅仅在 mock 层自娱自乐。
4.2 压测和性能分析,必须设定 SLO 而不是“试一下”
性能验证这块,我踩过一次深刻的坑,所以现在要求团队对所有改动量较大的 AI 生成代码做压测,并设定明确的 SLO。SLO 怎么定?我的建议是取线上实际流量的 P99 延迟和错误率作为基准,再定义一个目标线。比如当前接口 P99 是 150ms,那我要求 AI 改动后压测结果 P99 不超过 200ms,错误率不超过 0.1%,如果超了就打回优化,而不是“看着差不多就行”。
压测工具我常用 wrk 和 k6。wrk 适合快速验证单一接口的极限吞吐,k6 则适合构造复杂的压测场景。一个简单的 k6 场景可以这样写:
import http from 'k6/http'; import { check, sleep } from 'k6'; export const options = { scenarios: { ramp: { executor: 'ramping-vus', stages: [ { duration: '1m', target: 100 }, { duration: '2m', target: 300 }, { duration: '1m', target: 0 }, ], }, }, }; export default function () { const res = http.get('http://localhost:8080/api/orders'); check(res, { 'status is 200': (r) => r.status === 200, 'latency < 200ms': (r) => r.timings.duration < 200, }); sleep(1); }压测的同时,记得开内存和 CPU 监控,观察是否存在泄漏趋势。Go 项目可以用 pprof 抓取堆栈,Java 项目可以用 JFR 或 Arthas,Python 项目用 tracemalloc。很多时候性能问题不是单纯“慢”,而是“越跑越慢”,这几乎都是资源泄漏或缓存无限增长导致的。
除了压测,数据库查询的 EXPLAIN 也必须看。AI 生成代码容易写出查询条件不走索引的 SQL,数据量小的时候没事,一旦主表有百万行数据,查询马上变成全表扫描,数据库 CPU 直接被打满。我见过一个典型案例:AI 为一个用户列表页写了一个“SELECT * FROM users WHERE email LIKE '%xxx%'”的查询,看起来没毛病,但线上库里上百万用户,这条 SQL 直接把数据库查挂了。
4.3 数据一致性与幂等,AI 代码最容易犯的“看不见的错”
测试和压测跑完,还有一个非常容易被忽略的环节:数据一致性与幂等验证。AI 生成的代码往往只考虑“正常调用一次”的行为,不考虑“重复调用”和“部分失败”的状态,而这恰恰是线上故障的高发地带。
以订单系统为例,如果 AI 生成的接口没做幂等处理,上游超时后重试就会产生两笔订单。我的解法很朴素:要求所有写接口带上幂等键,唯一的请求号作为主键或唯一索引,重复请求直接返回第一次处理的结果。这个设计不需要多复杂,但一定要有。另外,事务边界必须人工核对:涉及多张表更新的操作,必须在一个事务里,失败要回滚,不能出现 A 表插入成功、B 表插入失败的中间状态。我会让 AI 生成代码之后,自己画一遍“事务覆盖范围”的草图,确认每一步读写操作都被正确包裹。
数据一致性验证还有一个实用技巧:构造一份“脏数据测试集”,里面包含重复记录、空主键、超长字段、非法枚举值,然后用这份测试集跑一遍业务处理逻辑,观察 AI 生成的代码能否正确过滤或报错。没有这份测试集的 AI 代码,就像没经过暴雨测试的防水手机,看起来严丝合缝,实际一泡就漏。
5. 第四道检查:灰度发布与回滚预案,最后一道防线
5.1 渐进式放量,千万别一键切换所有流量
前面三道检查都过了,并不代表代码一定能安全上线。生产环境里的问题有很大一部分是由流量特征、用户行为、数据分布等因素触发的,这些在测试环境里很难完整模拟。所以我坚持对 AI 生成代码的发布采用渐进式放量,坚决反对一键全量切换。
渐进式放量有三种常见实现方式。第一种是按比例切流量,负载均衡层面把 1% 的流量引到新版本,比如 Nginx upstream 的 weight、Kubernetes 的金丝雀 deployment、或者注册中心按权重选取实例;第二种是按用户特征划分,比如只对内部员工、只对白名单用户、只对某个灰度标签的用户开放;第三种是特性开关,用 feature flag 在代码里动态控制新旧逻辑的分支。三种方式可以在不同阶段组合使用,比如先用内部用户做功能验证,再逐步放大流量比例。
我自己的放量节奏一般是 1% 观察一小时、5% 观察两小时、20% 观察半天、50% 观察一个完整业务周期,最后才放开到 100%。这里的“观察窗口”不能只看监控面板刷新了几次,而是要看一个完整业务周期有没有覆盖到。比如你的业务高峰期是晚上 8 点到 10 点,那就必须在高峰时段全程观测过,才算真正验证了生产环境的表现。
5.2 灰度期间盯什么指标,链路可观测是前提
灰度放量之后,很多人只是刷新一下“服务是否存活”的监控页面,这远远不够。我盯的是 RED 指标:Rate(请求量)、Errors(错误量或错误率)、Duration(延迟分布)。这三个指标缺一不可,因为只看请求成功率高可能掩盖错误总量上升,只看平均延迟又会被极端长尾掩盖 P99 劣化。告警规则我倾向于分成两个级别:错误率超过 1% 就触发警告,超过 5% 自动熔断或暂停放量;P99 延迟超过 SLO 阈值且持续 5 分钟,触发性能告警并回滚。
这些指标要能正确解读,前提是链路已接入全链路追踪。如果你的服务还没有 trace_id 贯穿日志,那灰度期间排查问题基本靠猜。每次请求进来,生成一个全局唯一的 trace_id,日志系统把所有微服务打印的日志按 trace_id 串联,才能快速定位是网关层超时还是业务逻辑异常。没有这个基础设施之前,我不建议你放任何大改动上生产,因为出了事你连“哪一环出问题”都判断不了。
5.3 回滚预案,重点不是代码回滚而是数据回滚
最后一个环节是回滚预案。很多人觉得回滚就是“把上一版镜像重新部署一下”,但那只适用于代码层面的回滚。如果 AI 生成的代码同时带了数据库变更,比如加了新表、删了列、改了字段长度,那么单纯回滚代码无法复原数据状态。
这里我有一条非常强硬的规则:合入 AI 生成的数据库迁移脚本之前,必须确认迁移是可逆的。可逆的标准是:存在一个明确的降级脚本,能让旧代码在迁移后的数据结构上正常运行。比如想删除一张表,正确的做法是先停掉对它的读写,再改成“保留表结构但写入新表”,最后才做物理删除。如果 AI 给你写了“DROP TABLE xxx”之类的迁移脚本,我会直接判定为危险操作,要求改写为“新增字段并双写”,再走一个完整的发布周期。数据库迁移之前,备份和恢复演练也不能省,至少做一次“从备份恢复到指定时间点”的演练,确保真的出事了能拉起来,而不是靠碰运气。
我在团队里会准备一张回滚检查表,每次发布 AI 相关代码前逐项打钩:新版代码的回滚步骤是否已给出、数据库迁移是否有降级路径、依赖服务的兼容性是否验证过、灰度期间的关键指标是否已确认、告警负责人和值班人是否已经在群里。这张表看起来琐碎,但真到凌晨三点线上告警的时候,你会发现它比任何经验都靠得住。
写在最后
我经常打一个比方:AI 编程助手像一个极其高效但偶尔“脑补事实”的实习生。它能在十分钟内给你写出一版像模像样的代码,但你绝不能让它独自承担上生产的责任。四道检查就是我给这个“实习生”配的导师团:走查负责教它懂业务,静态分析和安全扫描负责监督它别闯祸,测试和压测负责验证它手脚协调,灰度发布则保证即使它偶尔犯浑,也只会摔倒在自己家里而不是大街上。
我自己在实际操作中的体会是,四道检查不能靠人肉逐次手动执行,那样坚持不了多久。更好的做法是把它们固化成仓库里的脚本和 CI 流水线。静态分析、依赖审计、密钥扫描、SAST 这些环节从提交代码那一刻就自动跑;人工走查和性能压测则按改动量分级触发,改动大就全量走一遍,改动小就摘重点。这样坚持一个月之后,你会发现自己对 AI 生成代码的信任度明显提高了——因为“信任”不再是一句口号,而是一套流程。
如果你正在用 AI 写代码,我的建议非常简单:先别急着追求生成速度和代码量,花一个下午把这四道检查的脚本搭起来,然后把你最近一次 AI 生成的代码重新走一遍这个流程。大概率你会和我一样,在第一次执行时就看到几个平时根本注意不到的隐患。省下的那一次线上事故,比 AI 帮你多写一百行代码都值。