Tiana随笔

AI 的代码不难读,只是不像你写的

· 工程与效率

标签:#AI 编程 #工程

AI 的代码不难读,只是不像你写的

盲评里 AI 代码的可读性在 89.5% 的样本上不输人类,但和人类代码的风格不一致率从 66% 到 90%,模型越强越不一致。78.7 万对函数的对比进一步显示两种代码的缺陷画像根本不同。这篇讲这个落差对评审意味着什么,以及规范为什么该换机器来守。附一个可跑的风格画像对比脚本。

评审 AI 生成的代码时,有一种很常见的体感:没有明显 bug,命名也挑不出错,但就是觉得"不像我们组的代码"。过去一两年,这种直觉常被当成偏见或者不适应新工具,现在有两组研究把它量化了。结论有点反直觉:问题不在"难读",在"不像"。这两件事听起来像一回事,落到工程上是两回事。

难读这一关,其实已经过了

第一组研究做的是盲评。研究者拿五个模型(CodeLlama-7B、StarCoder2-7B、DeepSeekCoder 两个尺寸、GPT-4)针对真实开源项目里的任务生成代码,和人类的原始实现放在一起,从可读性、简洁性、健壮性三个维度打分。结果是:89.5% 的样本里,AI 代码的可读性与人类相当或者更好;健壮性这个比例是 94.2%;可读性最高的那个模型不是 GPT-4,是 DeepSeekCoder-6.7B。

这个数字的含义要读准。它说明"AI 代码难读"作为一个整体判断已经站不住了。任何一段单拎出来的 AI 代码,平均质量不输人写的。如果你的团队还在用"生成的代码看不懂"当否决理由,这个理由在数据上撑不住。

不像,是能测出来的

同一组研究问了下一个问题:把 AI 代码和对应的人类实现逐对比较,风格上不一致的比例是多少。答案是从 66.2% 到 90.1%,下限是 CodeLlama-7B,上限是 GPT-4。

这里有个反直觉的细节:模型越强,和人类风格越不一致。能力最弱的模型反而最"像人",因为它主要在模仿训练数据里的常见写法;能力强的模型有自己的偏好,输出的是它认为的最优解,而不是这个仓库的习惯。

研究者把不一致拆成了五个维度、二十四种类型:格式、命名、表达式写法、控制流、容错方式。占比最高的一类叫 API 偏好不一致。人写 str.partition 你写 re.split,人用 os.path 你用 pathlib,人手写循环你用推导式。每一处都"对",合在一起就是另一个人的手笔。

这种不一致单看全是小事,摊到整个仓库就是成本。一个十人团队维护了五年的代码库,新人头三个月学的是"这个仓库怎么写字";agent 落地之后,这部分知识对机器不生效,它每天产出的代码按自己的标准来。评审的人面对的不再是一段陌生的代码,是一种陌生的方言,判断成本从"对不对"变成了"像不像加对不对"。更麻烦的是方言不止一种:同一个仓库里 Claude、GPT、本地小模型各写各的,彼此也不一致。

缺陷画像也不一样

第二组研究规模更大,发在 arXiv 上(编号 2609.12708),对比了 787,562 对函数:每一对都是一个人写的函数,加上一个由 docstring 生成的 AI 版本,覆盖 Python、Java、C 三种语言,涉及三万四千多个开源仓库。

结构上的结论:AI 函数的行数和分支复杂度约为人类的一半。风格上的结论更扎眼:用 n-gram 语言模型算代码的"自然度",AI 生成的代码会聚成一簇,和人类代码清楚地分开。翻译成人话,AI 代码是模板化的,彼此长得像,跟哪个具体仓库都不像。

缺陷类型的差异对评审最有用。人类代码上的静态分析告警,集中在成熟代码库才有的一类问题:认知复杂度超标、越包访问、历史遗留的绕路逻辑。AI 代码的告警集中在另一头:未使用的参数、脚手架残留、同一个 guard 模式在多个函数里原样复制。安全那一栏是分语言的:Python 和 Java 里,AI 产生的漏洞样本是人类的 1.4 到 6.5 倍,高危占比也更高;C 里反过来,人类写的高危内存安全问题更多。他们还拿 Claude Opus 4.8 跑了一个六百题的子集,约三分之二的题仍带缺陷,约三分之一带安全发现。

这组数字对"评审该看什么"有直接含义:拿人类的评审直觉去扫 AI 代码,会盯错地方。人写的代码里,越包访问和复杂度超标是坏味道;AI 写的代码里,这几个指标反而是最不用操心的。

为什么 prompt 调教救不回来

第一组研究也测了提示词工程:把风格要求写进 prompt,能不能拉近差距。结果是混合的,个别维度的确改善,但代价是简洁度下降,甚至功能正确率受损。

这个结果符合直觉。风格不一致不是"这次没说清楚"的问题,是训练分布的问题。模型见过全互联网的写法,输出是整个分布的加权结果,你的团队规范在这个分布里只占一小片。每次靠 prompt 去纠正,等于用几百个 token 去对抗几万亿 token 的先验,还不占用上下文预算。指望每个工程师把规范背进 prompt,不如承认这条路走不通。

规范的执行主体,该换人了

既然风格问题是统计问题,就该用统计工具解。我会把规范拆成三层,分别给不同的执行者。

格式层全部交给机器。black、ruff format、isort 这一类工具进 pre-commit 和 CI,缩进、引号、导入顺序在到达人眼之前就已经不存在分歧。这一层问题在今天任何团队里都不该出现在评审记录里。

风格层用 lint 规则钉住团队的偏好。ruff 和 flake8 都支持自定义规则,ESLint 生态更是如此。"我们用 partition 不用 re.split"、"不引入这个新依赖",这类决策写一次,之后机器替你守。API 偏好恰恰是研究里占比最高的不一致类型,也是最适合规则化的类型。

人的评审清单换内容。不再看缩进和命名(机器管了),盯三件 AI 代码真正高发的事:重复样板,同一个 guard 模式是不是在好几个函数里被原样复制;错误处理路径,是不是所有异常都被一层 try 吞掉了;API 选型,是不是悄悄引入了项目里没人用的新写法、新依赖。

"代码是写给人看的"这句话不用改,但执行分工变了:人继续当读者,机器先当格式编辑。评审省下来的注意力,花在只有人能判断的那部分上。

落地顺序也有讲究。先把格式层和风格层的工具接进 CI,这一步不依赖任何研究结论,纯粹是把本来就有的纪律执行到位;然后跑一两周的画像脚本,看你们团队的"不像"集中在哪个维度,是 API 偏好还是样板重复;最后才动评审清单。倒过来做会把负担压回给评审人,而他们手里没有判断风格一致性需要的统计视角,只有直觉,直觉对付不了分布。

拿一段代码自己看

下面这个脚本把上面的思路落成一个可跑的最小版本:给两份 Python 代码各算一份风格画像,再对比差异。指标都是研究结论的粗代理:函数大小对应"AI 函数约为人类一半",重复行占比对应"样板模板化",f-string 占比对应 API 偏好。

#!/usr/bin/env python3
"""对比两份 Python 代码的风格画像,量化"像不像一个团队写的"。

用法:
    python3 style_drift.py                  # 用内置示例跑
    python3 style_drift.py repo_dir patch_dir
"""
from __future__ import annotations

import ast
import re
import sys
from pathlib import Path

# 成熟仓库的味道:函数偏长、docstring 时有时无、沿用旧 API
REPO_SAMPLE = '''
def load_user_config(config_path, defaults=None):
    """读取用户配置,缺省项回退到 defaults。"""
    merged = dict(defaults or {})
    if not config_path.exists():
        logger.warning("config missing, use defaults only")
        return merged
    raw = config_path.read_text(encoding="utf-8")
    for line in raw.splitlines():
        if not line.strip() or line.startswith("#"):
            continue
        key, _, value = line.partition("=")
        merged[key.strip()] = value.strip()
    validate_user_config(merged)
    return merged


def validate_user_config(cfg):
    # 历史遗留:字段曾经叫 user_name,兼容两种写法
    name = cfg.get("user_name") or cfg.get("username")
    if not name:
        raise ConfigError("username required")
    return True
'''

# 典型 AI 补丁的味道:函数短小、全带 docstring、样板重复
PATCH_SAMPLE = '''
def load_config(path: str) -> dict:
    """Load configuration from a file."""
    if not path:
        raise ValueError(f"path must not be empty: {path}")
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def save_config(path: str, data: dict) -> None:
    """Save configuration to a file."""
    if not path:
        raise ValueError(f"path must not be empty: {path}")
    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f)
'''


def profile(sources: list[str]) -> dict:
    """算一份风格画像:函数大小、命名、docstring、API 偏好、样板度。"""
    funcs, fstrings, formats = [], 0, 0
    substantive: list[str] = []
    for src in sources:
        tree = ast.parse(src)
        substantive += [l.strip() for l in src.splitlines()
                        if len(l.strip()) > 15]
        for node in ast.walk(tree):
            if isinstance(node, ast.JoinedStr):
                fstrings += 1
            if (isinstance(node, ast.Call)
                    and getattr(node.func, "attr", "") == "format"):
                formats += 1
            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                funcs.append(node)
    snake = sum(1 for f in funcs
                if re.fullmatch(r"[a-z_][a-z0-9_]*", f.name))
    docs = sum(1 for f in funcs if ast.get_docstring(f) is not None)
    locs = [f.end_lineno - f.lineno + 1 for f in funcs]
    counts: dict[str, int] = {}
    for line in substantive:
        counts[line] = counts.get(line, 0) + 1
    dup = sum(c for c in counts.values() if c > 1)
    return {
        "函数个数": len(funcs),
        "平均函数行数": round(sum(locs) / len(locs), 1) if locs else 0,
        "命名合规率": round(snake / len(funcs), 2) if funcs else 1.0,
        "docstring率": round(docs / len(funcs), 2) if funcs else 0,
        "f-string占比": round(fstrings / max(1, fstrings + formats), 2),
        "重复行占比": round(dup / max(1, len(substantive)), 2),
    }


def compare(a: dict, b: dict, name_a: str, name_b: str) -> list[str]:
    """画像差异翻成人话。阈值是拍的,按团队口味改。"""
    notes = []
    if b["平均函数行数"] < a["平均函数行数"] * 0.6:
        notes.append(f"{name_b} 的函数比 {name_a} 小四成以上,"
                     "与研究里 AI 代码约为人类一半大小的结论同向")
    if abs(b["docstring率"] - a["docstring率"]) >= 0.4:
        notes.append("docstring 覆盖差异明显,多数人不会给小函数全配文档")
    if abs(b["f-string占比"] - a["f-string占比"]) >= 0.4:
        notes.append("字符串格式化的偏好不同,这是研究里占比最高的"
                     "API 偏好不一致")
    if b["重复行占比"] - a["重复行占比"] >= 0.05:
        notes.append(f"{name_b} 的重复行占比高出 "
                     f"{b['重复行占比'] - a['重复行占比']:.2f},"
                     "样板在复制,评审时值得点开看")
    return notes or ["两份代码的风格画像没有明显分歧"]


def main() -> None:
    if len(sys.argv) == 3:
        left = [p.read_text(encoding="utf-8")
                for p in Path(sys.argv[1]).rglob("*.py")]
        right = [p.read_text(encoding="utf-8")
                 for p in Path(sys.argv[2]).rglob("*.py")]
        name_a, name_b = sys.argv[1], sys.argv[2]
    else:
        left, right = [REPO_SAMPLE], [PATCH_SAMPLE]
        name_a, name_b = "仓库现状", "AI 补丁"
        print("没有给目录参数,用内置示例数据对比\n")
    a, b = profile(left), profile(right)
    header = f"{'指标':<12}{name_a:>10}{name_b:>10}"
    print(header)
    print("-" * len(header) * 2)
    for k in a:
        print(f"{k:<12}{a[k]:>10}{b[k]:>10}")
    print()
    for note in compare(a, b, name_a, name_b):
        print("提示:", note)


if __name__ == "__main__":
    main()

跑出来的结果:

没有给目录参数,用内置示例数据对比

指标                仓库现状     AI 补丁
----------------------------------------------------------------
函数个数                 2         2
平均函数行数            10.0       6.0
命名合规率              1.0       1.0
docstring率         0.5       1.0
f-string占比         0.0       1.0
重复行占比              0.0       0.2

提示: docstring 覆盖差异明显,多数人不会给小函数全配文档
提示: 字符串格式化的偏好不同,这是研究里占比最高的API 偏好不一致
提示: AI 补丁 的重复行占比高出 0.20,样板在复制,评审时值得点开看

示例里最值得看的一行是命名合规率:两边都是 1.0。命名规范本来就不是分歧点,两边都守规矩。分歧全在研究说的那些维度上:docstring 密度、API 偏好、样板重复。把这个脚本指到你的仓库和一个 agent 的产出目录上,得到的就是你们团队自己的"不像"清单。画像本身不下判断,判断标准在 compare 里那几个阈值,按团队口味改。

数据说明

第一组研究是《Beyond Functional Correctness: Investigating Coding Style Inconsistencies in Large Language Models》,基于 CoderEval 的两百三十个真实任务,五个模型里四个是 7B 级别的开源模型,GPT-4 是唯一的前代闭源旗舰,Python 单语言。第二组是 arXiv 2609.12708,那不勒斯费德里科二世大学和北卡罗来纳大学夏洛特分校的工作,截至写作时仍在同行评审中。它的配对设计有一个对 AI 不利的偏差:人类函数带着仓库上下文,AI 只拿到 docstring,所以真实差距只会比测出来的更大,不会更小。安全那一栏的 1.4 到 6.5 倍是"带漏洞样本的倍数",不是漏洞个数的倍数,口径在原文里也是分开报的。

两组研究互相咬合的地方只有一处:风格不一致和样板模板化都指向同一个机制,模型输出的是分布的均值,不是这个仓库的方言。这一条我当作文章的立论基础,属于我的判断;其余数字都按原论文口径转述。

相关文章