跳转至

能跑通,不等于扛得住:智能体生成代码的验收清单

页面打开正常,字段、校验、跳转都挑不出毛病,功能用例一条条过全是绿的。真正出事是在一个很窄的时间窗口里:一批人几乎同时进来,先变慢,接着开始 502,服务拉起来没过多久又塌了一次。

两次都不是业务逻辑写错了。第一次是请求堆在连接池上,第二次是恢复期的重试把数据库又压了一遍。这类问题有个共同点——单用户、小数据量、跑一遍就结束的验证,永远照不到。

这一周把代码扫描、依赖扫描、镜像扫描和 DAST 挨个接进流水线,反而更清楚地看见另一半:那些工具查的是「代码里有没有已知坏味道」,查不出「两百个人同时点提交时连接够不够用」。智能体生成的代码,最容易留口子的恰好是后一类地方。不是不能用,是验收方式得跟着换。

一、先说清楚风险落在哪

把生成代码当成「写得更差的人写的代码」没意义,它写得其实挺规整:命名一致、注释齐全、异常也都包了 try。正因为表面干净,评审时容易一眼扫过去就放行。

CodeRabbit 拿 470 个开源 PR 做过一次分类统计(320 个带 AI 协同标记,150 个当作纯人工),结果挺贴合上面的现象:

类别 AI 协同 PR 相对纯人工 PR
问题总数 1.7 倍(10.83 个/PR vs 6.45 个/PR)
逻辑与正确性 多 75%
异常处理与异常路径缺口 近 2 倍
并发与依赖正确性 约 2 倍
过量 I/O 约 8 倍
安全类问题 1.5–2.74 倍(不同口径)

最后两行是压测时最容易撞上的:性能类问题绝对数量不多,但一出现就集中在 I/O 上;并发类问题全是「小错、大后果」。报告也写了局限——AI 与人工的归属是靠 PR 里的协同标记推断的,样本是开源项目,不是受控实验,倍数只能当风险提示,不能当自己团队的基线。

另外两组数据摆在桌上,方向一致:

  • DORA 2024 的调研里,AI 采用率每提高 25%,交付吞吐量估计降 1.5%、交付稳定性降 7.2%。报告自己的解释是:生成变快导致变更批次变大,而小批量和健壮的测试机制没跟上。
  • Veracode 2025 那份测了 100 多个模型、80 个代码任务,45% 的生成结果带 OWASP Top 10 类问题,Java 的失败率 72%,XSS 和日志注入分别是 86%、88%。这也是前几天急着把 SAST、SCA 接进门的原因。

三条合起来只说明一件事:生成速度上去了,验证速度没上去。后面写的都是怎么把验证补上。

二、三层:只在「涌进来」时才现形的地方

flowchart LR A[请求集中到达] --> B[事件循环 / 线程池] B --> C[连接池借还] C --> D[SQL 执行计划与行锁] D --> E[磁盘 / 缓存]

单用户走这条链,每一层都宽裕;人一多,最窄的那层先塌,而且塌的样子往往不是「报错」,是「变慢 → 排队 → 超时 → 重试 → 更慢」。

2.1 请求层:并发模型写错了,本地永远测不出来

FastAPI 官方文档讲得很直白:需要 await 的库才用 async def;如果第三方库不支持 await(文档里直接点了「目前多数数据库库」),路径函数就写成普通 def,FastAPI 会把它放到外部线程池执行。反过来,在 async def 里塞一个同步阻塞调用,占住的是整个事件循环。

生成代码很爱写成这样——async def 看着更"现代",里面却用 requests、同步 ORM、time.sleep、同步文件读写。一个请求跑,毫无差别;两百个并发,worker 直接串行化,CPU 还不高,特别容易误判成「下游慢」。

同一层还有几个常见写法:

写法 单用户时 涌进来时
每个请求新建 HTTP client / 数据库连接 正常 握手开销、端口与连接数暴涨
全局 dict 当缓存 正常 竞态 + 无淘汰,内存一路涨
无超时、无退避的重试 正常 下游抖动时重试风暴,自己把自己打挂
异常分支里 return 但没释放资源 正常 借了不还,池慢慢被耗尽
同步锁包住整段业务逻辑 正常 吞吐被一把锁锁死

验收动作:并发下如果 CPU 不高而 P99 陡增,先抓线程栈 / 事件循环延迟,别急着加机器。

2.2 连接池:先倒下的常常是数据库

PostgreSQL 官方文档写着 max_connections 默认通常是 100(内核设置不支持时还会更少),其中还给超级用户保留了 3 个槽位。这是一道很硬的算术题:

4 个应用实例 × 每实例池上限 30 = 120
加上后台任务、迁移脚本、管理连接 → 已经超过 100

数据库侧会先出现 remaining connection slots are reserved,应用侧表现却是「偶发超时」,很容易被当成网络抖动。这个乘法在 code review 时三十秒就能算完,非等到压测才暴雷,说明压根没人算过。

HikariCP 官方 Wiki《About Pool Sizing》给了个经验起点 connections = (core_count × 2) + effective_spindle_count,但同一页也写明:数据全缓存时 spindle 取 0、这套公式没有针对 SSD 做过验证、算出来只是起点仍需在自己机器上实测调。所以「池不够就调大」不是答案,池太大反而把数据库拖死。

生成代码在这一层的典型问题:

  • 事务里调外部 HTTP,一个下游超时就占着连接不放;
  • 循环里逐条开连接 / 逐条 commit;
  • 异常路径没有 close,或 ORM session 没归还;
  • 异步 handler 里做同步阻塞,连接借出时间被拉长;
  • 长事务里夹着大批量更新,行锁一路攒着。

要看的指标就三个:hikaricp_connections_active、hikaricp_connections_pending、hikaricp_connections_timeout_total。pending 持续大于 0 且 RT 抖动,先查有没有长事务占坑,命令在性能瓶颈定位那篇里写过,不抄一遍。

2.3 数据层:小表跑得飞快,大表换执行计划

SQLAlchemy 官方文档明确:relationship() 的 lazy 默认是 "select",也就是延迟加载——访问关联集合时才发 SELECT,这就是 N+1;selectinload() 用一条额外查询把一整批对象的集合一次性取回来。官方给的排查建议也很实在:打开 SQL echo,数一数到底发了几条 SQL。

生成代码最爱写的几种:

# 列表接口:每条记录再查一次关联(N+1),小数据量时 RT 看不出异常
orders = session.scalars(select(Order).limit(20)).all()
for o in orders:
    o.items  # 每次访问触发一条 SELECT

# 查全表再在 Python 里过滤 / 分页
rows = session.execute(select(Order)).all()
page = rows[offset:offset + 20]

# 批量写:循环里逐条提交
for row in rows:
    session.add(row)
    session.commit()

再叠上 SELECT *、深分页 OFFSET、没有 LIMIT 的导出、缺唯一约束导致的重复提交,平时都"能用"。等到核心表从 10 万行长到 1 亿行,MySQL 可能选完全不同的执行计划,缓存命中率也变了——性能指标体系与容量评估第六节把这笔账算过,压测库必须同量级或至少同基数比例,否则结论全是假的。

更麻烦的是「崩了以后」:写接口没有幂等键或唯一约束,前端超时重试、网关重试、用户再点一次,数据库里就多出几份数据。第二次塌往往不是第一次的原因,是恢复期这批重复请求叠上来的。

三、把「涌进来」做成能重放的场景

只跑一次峰值并发不够,至少要四种:

场景 要回答什么 最低要求 不通过的样子
冒烟 脚本、断言、环境对不对 1 并发,跑通全链路 基线就是错的,后面全白跑
脉冲 / 尖峰 同一时刻集中到达会不会塌 到达率在 30 秒内从低拉到峰值 队列不收敛、连接池 pending 不回落
稳态 目标 TPS 能不能持续 ≥ 30 分钟,四条线同时达标 P99 随时间漂移、内存/连接持续增长
长稳 慢泄漏、资源不归还 数小时,最好跨一次定时任务 几小时后 OOM 或连接耗尽

3.1 一定用到达率模型

k6 官方文档《Open and closed models》把这件事说透了:闭环模型(ramping-vus)里,新迭代要等上一次结束才开始,系统越慢、发出来的请求越少,尾部延迟被自己藏起来,这叫协调遗漏;开环的 constant-arrival-rate / ramping-arrival-rate 按时间表发起迭代,跟响应时长解耦,VU 数由 k6 自动扩。

要模拟「一批人同时在场」,闭环模型恰恰会把最危险的那一刻抹平。脚本骨架:

import http from 'k6/http';
import { check } from 'k6';

export const options = {
  scenarios: {
    spike: {
      executor: 'ramping-arrival-rate',
      startRate: 10,
      timeUnit: '1s',
      preAllocatedVUs: 50,
      maxVUs: 500,
      stages: [
        { duration: '2m',  target: 10  },   // 预热,缓存到稳态
        { duration: '30s', target: 300 },   // 集中涌入
        { duration: '5m',  target: 300 },   // 稳态
        { duration: '1m',  target: 10  },   // 观察退坡后队列能不能收敛
      ],
    },
  },
  thresholds: {
    http_req_failed: ['rate<0.01'],
    http_req_duration: ['p(95)<500', 'p(99)<1000'],
    'http_req_duration{name:submit}': ['p(95)<800'],  // 写接口单独判
  },
};

export default function () {
  const payload = JSON.stringify({ user_id: __VU, seq: __ITER });
  const res = http.post(`${__ENV.BASE_URL}/api/submit`, payload, {
    headers: { 'Content-Type': 'application/json' },
    tags: { name: 'submit' },
  });
  check(res, { 'status 2xx': (r) => r.status >= 200 && r.status < 300 });
}

两个容易漏的点:

  • 阈值才让 CI 变红。 k6 官方文档写明,只有 thresholds 失败才会返回非零退出码;只写 check 不改变退出状态。这跟前面几天反复踩的「结果没人认领」是同一件事——报告生成了没人拦,等于没测。
  • maxVUs 撞顶本身就是结论。 系统变慢时 k6 会加 VU 维持到达率,加不动就会出现 dropped iterations,说明已经撑不住设定的速率了。

另外,参数一定要打散(上面用 __VU/__ITER 只是最简示意,真实场景要从 CSV 取用户池)。所有人用同一个 ID,缓存全命中、行锁不冲突,压出来的数字比生产好看得多。压测机自己的 CPU、端口、带宽也要盯,别把施压端瓶颈当服务端容量。

四、验收清单:可以直接贴进 PR 模板

4.1 生成之前,把约束写进需求里

模型没见过的东西不会自己补。下面这些不写,默认拿到的就是「单机、小数据量、happy path」的实现:

峰值:同时在线 ___,写接口峰值 ___ TPS,读接口 ___ QPS
数据量:核心表 ___ 行,热点分布 ___,保留周期 ___
SLA:写 P95 ___ ms / P99 ___ ms,错误率 < ___%
资源上界:连接池 × 实例数 ___ < DB max_connections ___
必须有的:超时 ___ ms、重试 ___ 次带退避、幂等键、事务边界、限流与降级

4.2 评审时逐条过(单用户正确、并发不正确)

# 模式 为什么单测发现不了 怎么查
1 async def 里同步阻塞 一个请求没有并发 搜 requests. / time.sleep / 同步驱动,看调用栈
2 循环内查库、N+1 列表只有 3 条 开 SQL echo,数单请求 SQL 条数
3 每请求新建 client / 连接 单请求只建一次 看 client 是模块级还是函数内
4 事务里调外部服务 下游单请求很快 圈 @transaction / with session.begin() 边界
5 异常路径不释放资源 用例没造异常 故意让下游抛错,跑 10 分钟后看池
6 无界缓存 / 全局可变状态 单进程单请求 看有没有淘汰策略与锁
7 无超时、无退避的重试 下游不抖就不会触发 注入一次 3 秒延迟,看请求量放大的倍数
8 写接口无幂等键 只提交一次 同一 payload 连发两次,看行数
9 全表查询后在内存分页 测试数据 100 行 EXPLAIN 的 type / rows,搜无 LIMIT 的查询
10 池上限 × 实例数 > DB 上限 本地只有 1 个实例 做那道乘法

4.3 一票否决

出现任意一条,先别谈上线:

  • 单请求的 SQL 条数随返回列表长度线性增长;
  • 池上限 × 实例数 ≥ 数据库 max_connections(没给后台和管理连接留量);
  • 写接口没有唯一约束或幂等键,重复提交能产生双份数据;
  • 压测库的数据量与生产差一个数量级以上;
  • async 路径里存在同步阻塞 I/O;
  • 压测没有开环场景,或没有阈值断言(CI 不会红)。

4.4 门禁与可观测

□ 压测脚本进仓库,阈值 = 书面的 SLA,跑在 staging 的部署后阶段
□ PR 描述里附压测摘要:场景、数据量、稳态区间、P95/P99、池水位
□ 慢查询日志、连接池 active/pending、HTTP 5xx 接进监控并有告警
□ 有限流 / 熔断 / 降级开关,且验证过触发后的行为
□ 生成代码拆小批次合入,别一次塞一个几千行的 PR

最后一条是 DORA 那份报告里最实用的建议:把变更批次压小。一次生成一大坨、评审时只能扫一眼,等于把风险打包签收。

五、为什么反而更需要专业程序员

生成工具把「写出第一版」变便宜了,没把「判断它在什么条件下会崩」变便宜。后面这件事,恰恰是单测、SAST、SCA 都替不了的:

  • 把业务上的「集中到达」翻译成可重放的负载模型,选对开环还是闭环;
  • 把非功能约束(峰值、数据量、SLA、幂等、事务边界)在写代码之前定义清楚;
  • 读 EXPLAIN、看锁等待和连接池水位,从「CPU 不高但 P99 很高」里认出事件循环被堵;
  • 决定哪里允许排队、哪里必须限流、崩了以后怎么退坡和回滚;
  • 对压测结论签字:数字能追溯到原始样本,不是「感觉挺快」。

有个数据值得提一下,但要连着它的下文一起看:METR 2025 年 7 月那次 RCT里,16 位资深开源开发者在自己熟悉的大仓库中用 AI,实际耗时多 19%,本人却仍觉得快了 20%。METR 已在 2026 年 2 月声明该结果不代表当前工具水平,新一轮实验存在明显的选择偏差(不愿脱离 AI 的开发者系统性缺席)。所以别把它当「AI 没用」的证据。值得记住的只是那道感知偏差:主观速度不可靠,结论得靠外部度量。这恰好是测试该站的位置。

代码是生成出来的,责任不是。

坑位表

现象 根因 处理
功能全绿、集中访问就 502 连接池耗尽或事件循环被阻塞 开环脉冲场景复现,查 pending 与线程栈
恢复后很快又崩一次 重试风暴 + 写接口不幂等 退避重试、幂等键/唯一约束、限流
CPU 不高但 P99 暴涨 同步阻塞占住 event loop / 锁竞争 按 FastAPI 官方建议改普通 def,或换异步驱动
压测报告很好看,生产很慢 数据量不在一个量级、参数没打散 同量级数据 + 参数化 + 缓存预热
闭环压测一切正常 协调遗漏把长尾藏了 用 ramping-arrival-rate 交叉验证
阈值写了 CI 却没红 只用了 check,没用 thresholds 阈值失败才返回非零退出码
加了机器反而更慢 数据库连接数被更多实例打满 缩小单实例池,或上连接池代理
列表接口偶发慢 N+1,数据量一涨才显形 selectinload / 补索引,开 SQL echo 复核
池调到很大还是超时 长事务占坑,不是池小 缩短事务边界,外部调用挪出事务

先记到这

这周把扫描链路补齐了,但那一层解决的是「代码里有没有已知问题」,这层解决的是「一起用会不会塌」,两套不能互相替代。上面的 k6 脚本只是场景骨架,300 这个速率和几个毫秒阈值得按自己的 SLA 换掉,别照抄。

还没做的:故障注入(让下游慢 3 秒、让主库抖一下)没排进流水线;读写分离下的主从延迟没验;CodeRabbit 那组倍数是开源 PR 样本,只能当线索,自己团队的问题分布要自己攒。等攒够两三轮压测数据,再回来把阈值定死。

相关阅读