AI 编码代理生成的代码为什么总有隐藏 bug?我的 Code Review 实战清单

12 阅读9分钟

上周五晚上,我信心满满地让 Claude Code 帮我重构一个数据处理模块。它干得又快又漂亮——代码结构清晰,命名规范,注释齐全。我直接 merge 了。

然后周一早上,线上告警炸了。

一查,是重构后的代码在边界条件下会把空列表当成有效输入,跳过了校验逻辑,导致下游服务拿到脏数据。AI 写的代码,逻辑"看起来"完全没问题,但它默默吃掉了一个本该抛异常的场景。

这件事让我彻底改变了对 AI 编码代理的使用方式。不是不用——而是必须建立一套针对 AI 生成代码的 review 方法。

AI 代码的"好看陷阱"

用过 Claude Code、Cursor、OpenCode 这类 AI 编码工具的人大概都有过类似感受:AI 生成的代码格式永远完美

变量命名比你自己写的规范,注释比你写的详细,函数拆分比你做的合理。你一看,心想"这比我写得好多了",然后就放松了警惕。

但问题恰恰就在这里。AI 代码的危险不是"写得差",而是"看起来太好了"。

人类写的烂代码有个优点——它长得就很可疑,你会自然地多看两眼。但 AI 写的 bug 藏在漂亮的代码结构下面,像一个穿着西装的骗子,你很难靠直觉发现它。

HN 上最近在讨论 OpenCode(一个开源 AI 编码代理,12 万 GitHub Stars),评论区有个观点特别犀利:

"用 AI 生成所有代码,只在你把'尽快发布功能'的优先级放在代码质量之上时才有意义,因为只有这种情况下写代码本身才是瓶颈。"

我觉得这话只说对了一半。AI 完全可以帮你写出高质量代码,前提是你知道怎么 review 它。

AI 代码常见的 5 类隐藏 bug

我回顾了过去三个月用 AI 编码代理踩过的坑,总结了 5 类最常见的问题:

1. 边界条件遗漏

这是出现频率最高的。AI 非常擅长处理"正常路径",但经常忽略边界:

# AI 生成的代码
def get_average_score(scores: list[float]) -> float:
    return sum(scores) / len(scores)

# 看起来没问题?空列表呢?
# 正确的写法
def get_average_score(scores: list[float]) -> float:
    if not scores:
        raise ValueError("scores cannot be empty")
    return sum(scores) / len(scores)

更隐蔽的情况:

# AI 生成的分页查询
def get_page(items: list, page: int, size: int = 20) -> list:
    start = (page - 1) * size
    return items[start:start + size]

# page=0 呢?page=-1 呢?size=0 呢?
# AI 不会主动考虑这些
def get_page(items: list, page: int, size: int = 20) -> list:
    if page < 1:
        raise ValueError(f"page must be >= 1, got {page}")
    if size < 1:
        raise ValueError(f"size must be >= 1, got {size}")
    start = (page - 1) * size
    return items[start:start + size]

2. 错误处理被"吞掉"

AI 特别喜欢用 try-except 包裹一切,但经常把异常吞掉或者用一个笼统的 except Exception 了事:

# AI 经典操作
def fetch_user_data(user_id: str) -> dict:
    try:
        response = requests.get(f"{API_URL}/users/{user_id}", timeout=10)
        return response.json()
    except Exception:
        return {}  # 网络错误、超时、JSON 解析失败、404... 全都静默返回空字典

# 下游代码拿到空字典,不知道是"用户不存在"还是"服务挂了"
# 这两种情况的处理方式完全不同

正确的做法:

def fetch_user_data(user_id: str) -> dict:
    try:
        response = requests.get(f"{API_URL}/users/{user_id}", timeout=10)
        response.raise_for_status()
        return response.json()
    except requests.Timeout:
        logger.warning(f"Timeout fetching user {user_id}")
        raise
    except requests.HTTPError as e:
        if e.response.status_code == 404:
            return {}  # 用户确实不存在
        logger.error(f"HTTP error fetching user {user_id}: {e}")
        raise
    except requests.RequestException as e:
        logger.error(f"Request failed for user {user_id}: {e}")
        raise

3. 并发安全问题

AI 生成的代码绝大多数是单线程思维。如果你的应用有并发场景,它几乎不会主动考虑线程安全:

# AI 生成的缓存实现
class SimpleCache:
    def __init__(self):
        self._cache = {}
    
    def get_or_set(self, key: str, factory):
        if key not in self._cache:
            self._cache[key] = factory()  # 并发下可能重复计算
        return self._cache[key]

# 多线程环境下这段代码会导致:
# 1. 重复执行 factory()(如果 factory 有副作用就完蛋)
# 2. 字典可能在遍历时被修改(Python 3.x 较安全但不保证)

线程安全版本:

import threading

class SimpleCache:
    def __init__(self):
        self._cache = {}
        self._lock = threading.Lock()
    
    def get_or_set(self, key: str, factory):
        if key in self._cache:
            return self._cache[key]
        with self._lock:
            if key not in self._cache:  # 双重检查
                self._cache[key] = factory()
            return self._cache[key]

4. 类型假设不安全

AI 经常假设数据的结构跟你描述的完全一致,不做防御性检查:

# AI 生成的 API 响应解析
def parse_order(data: dict) -> Order:
    return Order(
        id=data["id"],
        amount=data["items"][0]["price"] * data["items"][0]["quantity"],
        user_email=data["user"]["email"]
    )

# data["items"] 为空列表?data["user"] 为 None?
# 线上接口返回的数据永远不要无条件信任

防御性写法:

def parse_order(data: dict) -> Order:
    items = data.get("items") or []
    if not items:
        raise ValueError(f"Order {data.get('id')} has no items")
    
    first_item = items[0]
    price = first_item.get("price", 0)
    quantity = first_item.get("quantity", 0)
    
    user = data.get("user") or {}
    email = user.get("email", "")
    
    return Order(
        id=data["id"],
        amount=price * quantity,
        user_email=email,
    )

5. 资源泄漏

AI 有时候会忘记关闭文件句柄、数据库连接或 HTTP session:

# AI 生成的批量文件处理
def process_files(paths: list[str]) -> list[dict]:
    results = []
    for path in paths:
        f = open(path)
        data = json.load(f)  # 如果这里抛异常,f 永远不会关闭
        results.append(process(data))
        f.close()
    return results

# 用 context manager
def process_files(paths: list[str]) -> list[dict]:
    results = []
    for path in paths:
        with open(path) as f:
            data = json.load(f)
        results.append(process(data))
    return results

我的 AI 代码 Review 清单

踩够了坑之后,我给自己整了一个 checklist,每次 AI 生成代码后对着过一遍:

✅ 快速扫描(30 秒)

  • 空值/空列表:函数入参有没有处理 None、空字符串、空列表?
  • 错误处理:有没有 bare exceptexcept Exception?异常被吞了还是传播了?
  • 资源管理:文件、连接、session 有没有用 withtry-finally

✅ 逻辑审查(2 分钟)

  • 边界值:page=0、size=0、负数、超大数字?
  • 并发安全:这段代码会在多线程/多进程下运行吗?共享状态有没有保护?
  • 类型假设:外部数据(API 响应、用户输入、数据库查询)有没有做防御性检查?
  • 幂等性:这个操作重复执行会怎样?会不会重复扣费、重复发通知?

✅ 杀手级问题(1 分钟)

  • 删掉这段代码的注释,纯看逻辑,它还说得通吗?(AI 的注释经常美化了逻辑漏洞)
  • 给这个函数传入最刁钻的参数,它会返回什么?
  • 这段代码凌晨 3 点报错了,错误信息够不够定位问题?

用 AI 写测试来验 AI 的代码

一个反直觉的操作:让 AI 自己给自己的代码写测试。

关键在于 prompt。不要说"给这个函数写单元测试",而是说:

给这个函数写边界条件测试和异常路径测试。
重点覆盖:空输入、超大输入、类型错误、并发调用、网络超时。
不需要测正常路径,专注异常场景。

AI 在"攻击自己的代码"这件事上意外地好用。因为它没有人类程序员的"我写的代码不会有 bug"的心理偏见。

# 让 AI 写的攻击性测试示例
import pytest

def test_get_average_score_empty():
    with pytest.raises(ValueError, match="cannot be empty"):
        get_average_score([])

def test_get_average_score_single():
    assert get_average_score([42.0]) == 42.0

def test_get_average_score_nan():
    result = get_average_score([float('nan'), 1.0])
    assert result != result  # NaN 检测... 但这是期望行为吗?

def test_get_page_zero():
    with pytest.raises(ValueError):
        get_page([1, 2, 3], page=0)

def test_get_page_negative():
    with pytest.raises(ValueError):
        get_page([1, 2, 3], page=-1)

def test_get_page_beyond_range():
    result = get_page([1, 2, 3], page=100, size=20)
    assert result == []  # 超出范围返回空,而不是报错

我的 AI 编码工作流

现在我的日常流程是这样的:

  1. 描述需求:用自然语言告诉 AI 我要做什么,包括边界条件和错误处理要求
  2. AI 生成代码:让它写初版
  3. 对照 checklist review:过一遍上面的清单,标出问题
  4. 让 AI 写测试:专注异常路径
  5. 跑测试修 bug:通常第一轮会发现 2-3 个问题
  6. 手动确认关键逻辑:特别是涉及金额、权限、数据删除的部分

整个过程比纯手写快 3-4 倍,同时代码质量不会下降。关键就在于第 3 步和第 4 步——这两步省不得。

一个实际的例子

最近我在做一个定时任务,每天从多个数据源拉取数据,合并后存入数据库。让 AI 写的初版:

async def sync_all_sources():
    sources = await get_active_sources()
    tasks = [fetch_source(s) for s in sources]
    results = await asyncio.gather(*tasks)
    
    merged = merge_results(results)
    await save_to_db(merged)
    
    logger.info(f"Synced {len(merged)} records from {len(sources)} sources")

看起来简洁优雅。但用 checklist 一过,问题全出来了:

  1. asyncio.gather 默认某个 task 失败会立即抛异常,其他 task 的结果丢失
  2. 没有超时控制,某个数据源响应慢会拖住整个任务
  3. 没有重试机制
  4. save_to_db 失败了数据就丢了,没有持久化中间结果
  5. 日志不够,哪个源成功了哪个失败了看不出来

修改后:

async def sync_all_sources():
    sources = await get_active_sources()
    if not sources:
        logger.warning("No active sources found, skipping sync")
        return
    
    results = await asyncio.gather(
        *[fetch_with_timeout(s) for s in sources],
        return_exceptions=True
    )
    
    successful = []
    for source, result in zip(sources, results):
        if isinstance(result, Exception):
            logger.error(f"Failed to fetch {source.name}: {result}")
            await record_failure(source, result)
        else:
            successful.append(result)
            logger.info(f"Fetched {len(result)} records from {source.name}")
    
    if not successful:
        logger.error("All sources failed, aborting sync")
        return
    
    merged = merge_results(successful)
    
    try:
        await save_to_db(merged)
        logger.info(f"Synced {len(merged)} records from {len(successful)}/{len(sources)} sources")
    except Exception as e:
        # 保存到本地文件作为兜底
        await dump_to_local(merged)
        logger.error(f"DB save failed, dumped to local: {e}")
        raise


async def fetch_with_timeout(source, timeout: float = 30.0):
    try:
        return await asyncio.wait_for(
            fetch_source(source),
            timeout=timeout
        )
    except asyncio.TimeoutError:
        raise TimeoutError(f"{source.name} timed out after {timeout}s")

代码量多了不少,但每一行都有存在的理由。线上跑了两周,零事故。

总结

AI 编码代理是真的好用,我现在几乎每天都在用。但它不是魔法——它是一个写代码特别快、格式特别好、但不会主动替你想边界条件的"初级工程师"。

你不会让一个刚入职的实习生写完代码直接上线,对吧?同样的道理。

三个核心原则:

  1. AI 写初版,人 review 逻辑——别因为代码好看就跳过 review
  2. 用 AI 攻击 AI——让它给自己写异常路径测试
  3. 关键路径手动确认——涉及钱、权限、数据安全的逻辑,自己过一遍

掌握了这套方法,AI 编码代理就不是定时炸弹,而是真正的效率倍增器。