AI 写的代码通过了所有测试,然后搞崩了生产环境

上个月,我一个朋友在团队里推了 AI 辅助编程。Cursor、Copilot 全用上,开发效率翻了一倍——至少前两周是这样的。

直到第三周,线上出了个 bug:用户在高峰期偶尔会看到别人的订单数据。不是每次都发生,一天大概三四次,很难复现。

查了一下午,定位到一段 AI 生成的代码——一个看似很标准的并发请求处理逻辑。代码写得漂亮,注释齐全,测试覆盖率 95%,CI 每次都是绿的。但有一个并发竞争条件,测试没覆盖到,Code Review 也没看出来。

修完 bug 后,他在群里说了句话:

"AI 写的代码通过了所有测试,然后搞崩了生产环境。"

这句话我记到现在。因为它触及了一个很多人在回避的问题:当 AI 能写代码也能写测试的时候,"测试通过"这四个字意味着什么?


AI 生成的"完美代码"

先看一个典型的场景。

你让 AI 实现一个用户余额扣减的功能:

def deduct_balance(user_id: str, amount: float) -> bool:
    """扣减用户余额,余额不足时返回 False"""
    user = db.get_user(user_id)
    if user.balance < amount:
        return False
    user.balance -= amount
    db.save_user(user)
    return True

然后你让 AI 写测试:

def test_deduct_sufficient_balance():
    user = create_user(balance=100.0)
    assert deduct_balance(user.id, 50.0) == True
    assert db.get_user(user.id).balance == 50.0

def test_deduct_insufficient_balance():
    user = create_user(balance=30.0)
    assert deduct_balance(user.id, 50.0) == False
    assert db.get_user(user.id).balance == 30.0

def test_deduct_exact_balance():
    user = create_user(balance=50.0)
    assert deduct_balance(user.id, 50.0) == True
    assert db.get_user(user.id).balance == 0.0

def test_deduct_negative_amount():
    user = create_user(balance=100.0)
    assert deduct_balance(user.id, -10.0) == False

测试写了四个,覆盖充足、不足、刚好、负数四种情况。运行一下,全绿。覆盖率 92%。看起来没问题。

然后呢?

然后两个请求同时到达,同时读到 balance=100,同时判断 100 >= 50,同时扣减,同时写入 balance=50。本来应该扣两次变成 0,结果只扣了一次。用户的钱凭空多了 50 块。

这叫并发竞争条件(Race Condition)。在生产环境里,它是低频的、难以复现的、但一旦触发后果严重的 bug。

测试没覆盖到,不是因为写测试的人能力不够,而是因为这个 bug 不在任何人的"显而易见"列表里

AI 更不可能想到。AI 生成测试的逻辑是:理解代码 → 找出明显的分支 → 为每个分支写一个用例。它不会想到"如果有人在高并发下调用这个函数会怎样",因为代码本身没有显式的并发逻辑——并发是系统层面的问题,不是函数层面的问题。


测试覆盖率的三个幻觉

幻觉一:行覆盖 = 质量覆盖

很多团队用行覆盖率(Line Coverage)作为质量指标:80% 以上算合格,90% 以上算优秀。

AI 很擅长刷这个指标。因为它知道怎么写测试才能让每一行都走到。

但行覆盖率是最浅的质量指标。它只告诉你"这行代码被执行过",不告诉你"这行代码在所有有意义的输入下都表现正确"。

看一个例子:

def calculate_discount(price: float, user_tier: str) -> float:
    discounts = {
        'bronze': 0.05,
        'silver': 0.10,
        'gold': 0.15,
        'platinum': 0.20,
    }
    rate = discounts.get(user_tier, 0.0)
    return round(price * (1 - rate), 2)

AI 生成的测试可能长这样:

def test_bronze_discount():
    assert calculate_discount(100.0, 'bronze') == 95.0

def test_silver_discount():
    assert calculate_discount(100.0, 'silver') == 90.0

def test_gold_discount():
    assert calculate_discount(100.0, 'gold') == 85.0

def test_unknown_tier():
    assert calculate_discount(100.0, 'unknown') == 100.0

行覆盖率 100%。每个分支都走过了。

但有一个问题没被测到:round(price * (1 - rate), 2) 在处理某些浮点数时会有精度问题。比如:

>>> calculate_discount(19.99, 'silver')
17.99  # 看起来对
>>> calculate_discount(0.29, 'gold')
0.24   # 但 0.29 * 0.85 在浮点数里是 0.24649999999999999
       # round 后是 0.25,而不是直觉上的 0.24

这不是 AI 的错,是浮点数运算的固有特性。但如果你在做金融相关的应用,这个精度问题可能导致账单对不上。行覆盖率告诉你"这行代码被执行过了",但它不会告诉你"这个计算在所有价格下都是准确的"。

更深入的覆盖指标——比如分支覆盖(每个条件分支的真假都测过)和路径覆盖(每条可能的执行路径都测过)——会更严格,但也更难达到。AI 生成的测试通常停留在行覆盖层面,因为这是最省力、产出最快的方式。

幻觉二:测试用例 = 边界条件

AI 生成测试用例的模式很固定:

  1. 正常输入(Happy Path)
  2. 明显的不合法输入(负数、null、空字符串)
  3. 边界值(0、最大值、最小值)

这个模式是对的,但不够。真正的 bug 通常藏在 AI 想不到的边界条件里:

AI 通常会测 AI 通常不会测
空字符串 Unicode 表情符号、RTL 文字
null 部分字段为 null 的对象
负数 浮点数精度问题、NaN、Infinity
单个元素 10 万元素的列表性能
正常日期 闰年、时区切换、夏令时
单线程调用 并发调用、重入调用
正常网络 超时、重试、部分失败

这不是说 AI 笨。是说这些边界条件需要领域知识

AI 不知道你在做金融应用,所以它不会想到去测浮点精度。AI 不知道你的用户在中东,所以它不会想到去测 RTL 文字。AI 不知道你的服务部署在三个时区,所以它不会想到去测夏令时切换。

这些不是"测试知识",是"业务知识"。而业务知识,只有你(或你的团队)知道。

幻觉三:通过的测试 = 正确的行为

这是最危险的幻觉。

测试通过了,只说明"代码做了测试要求它做的事"。如果测试本身的要求是错的,代码就会"正确地做错事"。

AI 写测试时,它的需求来源是什么?是你的 prompt。如果你的 prompt 说"写一个函数,把用户输入存到数据库",AI 就会写一个函数,把用户输入存到数据库。然后它会写一个测试,验证"输入被存到了数据库"。

但如果你的 prompt 没说"要先做 SQL 注入过滤"呢?AI 不会自动加上。代码能工作,测试能通过,但存在安全漏洞。

更微妙的情况是,AI 可能写了一个过于宽松的断言:

def test_process_orders():
    result = process_orders(orders)
    assert len(result) > 0  # 只要返回了东西就算过
    assert isinstance(result[0], dict)  # 只要是 dict 就算过

这个测试能告诉你"函数返回了非空的 dict 列表"。但它不能告诉你"返回的数据是正确的"。如果 process_orders 内部有个 bug 导致金额算错了,这个测试照样通过。

这就是所谓的弱断言(Weak Assertion)。AI 倾向于写弱断言,因为弱断言更容易写、更容易通过、更不容易因为实现细节变化而失败。但弱断言的保护作用也弱得多。


AI 代码的真正风险在哪

说了这么多,我不是在说"AI 写不出好代码"。

恰恰相反,AI 写的代码在模式正确性、代码风格、常见错误的避免上,往往比很多初级开发者写得好。AI 不会忘记检查 null,不会写出明显的 off-by-one 错误,不会忘记关闭文件句柄。

AI 的问题不是"写不好代码",而是"不知道你在乎什么"。

具体来说:

AI 不知道你的业务约束。 你知道你的应用最多同时处理 1000 个请求,AI 不知道。你知道你的用户群体有 30% 用安卓旧版本,AI 不知道。你知道财务数据必须精确到分,AI 不知道。

AI 不知道你的基础设施限制。 你知道数据库连接池只有 20 个,AI 不知道。你知道某个外部 API 有每分钟 100 次的限流,AI 不知道。你知道你们的监控系统不支持某种日志格式,AI 不知道。

AI 不会考虑维护成本。 AI 写了一段用三个嵌套装饰器加元编程实现的逻辑,功能完全正确,测试全绿。但三个月后你的同事要加一个新功能,花了两天才看懂这段代码在干什么。AI 不会考虑"这段代码的可读性",因为它不读代码,它生成代码。

AI 不会考虑故障排查。 当线上出了问题,需要看日志定位问题时,AI 生成的代码可能没有足够的日志点、没有有意义的错误信息、没有清晰的错误传播链路。因为测试不需要这些东西——测试在受控环境下运行,不需要日志来排查问题。


怎么跟 AI 合作写出真正可靠的代码

问题不在 AI,在协作方式

如果你把 AI 当外包——"帮我实现这个功能,写好测试"——那你会得到外包级别的质量:功能实现了,测试通过了,但深度不够。

如果你把 AI 当结对编程的搭档——你负责方向和边界,AI 负责实现和速度——那结果会好得多。

下面是几个具体的策略。

策略一:你来写测试,让 AI 写代码

这是最有效的方式。测试是需求的精确表达——当你手写测试时,你在做一件 AI 做不到的事:明确地定义什么是"正确"。

# 你来写测试——这些测试包含了你的业务知识
def test_deduct_concurrent_requests():
    """并发扣减时,不能出现超额扣减"""
    user = create_user(balance=100.0)
    # 模拟 10 个并发请求,每个扣 10 元
    results = concurrent_map(
        lambda _: deduct_balance(user.id, 10.0),
        range(10)
    )
    # 最多只能成功 10 次
    assert sum(1 for r in results if r) <= 10
    # 最终余额不能为负
    assert db.get_user(user.id).balance >= 0

这个测试表达了一个业务约束:并发扣减不能出现超额。AI 看到这个测试后,它会自动选择加锁或用其他并发安全的方式来实现。

关键不是谁来写代码,是谁来定义"正确"。

策略二:让 AI 写代码,你来写边界测试

如果你让 AI 同时写代码和测试,你可以做一件事:看完 AI 的代码后,自己补充边界测试。

# AI 生成的测试(它会写这些)
def test_happy_path(): ...
def test_null_input(): ...

# 你补充的测试(AI 想不到的)
def test_concurrent_access(): ...       # 并发安全
def test_very_large_input(): ...        # 性能边界
def test_unicode_emoji(): ...           # 字符编码
def test_clock_skew(): ...              # 时间同步问题
def test_partial_failure_recovery(): ... # 故障恢复

这个过程本身就是 code review。通过写测试,你在强迫自己思考"这段代码在什么情况下会出问题"。这个思考过程,比看十遍代码都有效。

策略三:Code Review 聚焦"AI 想不到的事"

AI 生成的代码,不需要审风格。AI 的代码风格通常比大部分人都好。

把 code review 的精力集中在 AI 的盲区上:

  • 业务逻辑:这个实现符合业务规则吗?有没有遗漏的业务约束?
  • 异常处理:如果外部服务挂了会怎样?如果数据库超时了会怎样?
  • 安全性:输入做了校验吗?权限检查到位了吗?有没有注入风险?
  • 性能:这个算法在大数据量下还行吗?有没有 N+1 查询?
  • 可观测性:出问题时能排查吗?日志够吗?指标有吗?

这些不是 AI 的弱项,是 AI 的信息盲区。它不是想不到,是不知道你需要想到。

策略四:要求 AI 解释它的测试策略

这是一个简单但有效的方法。让 AI 写完代码和测试后,问它一个问题:

你测了哪些情况?没测哪些?为什么?

AI 会给你一个列表:

已测试:
- 正常余额扣减 ✓
- 余额不足 ✓
- 刚好扣完 ✓
- 负数金额 ✓

未测试:
- 并发请求(需要多线程测试框架)
- 数据库操作失败的情况(需要 mock)
- 超大金额(浮点精度问题)

这个列表本身就是有价值的。它告诉你哪些边界是 AI 意识到的但选择跳过的(可能是因为实现复杂度高),哪些是 AI 完全没意识到的(你可以提醒它)。

你可以接着说:"把并发和数据库失败的情况也测了。"AI 会补充相应的测试。


一个完整的工作流示例

最后,看一个实际的工作流。假设要实现一个请求限流器(Rate Limiter)。

❌ 错误的做法

你:帮我写一个限流器,每分钟最多 100 次请求
AI:(生成代码 + 测试,测试全绿)
你:(直接合并)

结果:限流器在单机上工作正常,但你的服务有三台实例,每台独立限流,实际限流效果变成了 300 次/分钟。

✅ 正确的做法

你:帮我写一个限流器,每分钟最多 100 次请求。
    注意:我们有三台实例,需要分布式限流。
    你先说说你会怎么设计。

AI:(给出方案:用 Redis + sliding window,解释优缺点)

你:好,写代码吧。测试要覆盖:
    - 正常限流
    - 刚好到阈值
    - 超过阈值被拒绝
    - 窗口切换
    - Redis 不可用时的降级策略

AI:(生成代码 + 测试)

你:(Code Review,聚焦:)
    - Redis 连接超时怎么处理?
    - 时钟不同步会影响窗口计算吗?
    - 如果 Redis 恢复后,旧的限流数据怎么处理?
    (发现问题,要求 AI 修改)

AI:(修复)

你:(补充一个手动测试:模拟 Redis 故障,验证降级行为)

合并。

两种方式都用了 AI。但第二种方式产出的代码,质量高了不止一个档次。区别不在于 AI 的能力,在于人的参与深度


结论

AI 写的代码通过了所有测试,然后搞崩了生产环境。

这个故事的关键不是"AI 不可靠",而是"我们太依赖测试来判断代码质量了"。

测试是必要的,但不够。它只能验证你想到的情况,不能保证你没想到的情况不会出问题。当 AI 同时负责写代码和写测试时,这个 gap 会被放大——因为 AI 的"没想到"和你的"没想到"可能是同一个方向。

AI 不是"代码质量"的替代品,是"代码产出速度"的放大器。 如果你的 code review 流程本来就不够严谨,AI 只会让有问题的代码以更快的速度进入生产环境。

把 AI 当结对编程的搭档。你负责定义正确、设定边界、审查盲区。AI 负责快速实现、处理细节、减少重复劳动。

这样合作,AI 写的代码,测试通过了,生产环境也不会崩。