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 倍是"带漏洞样本的倍数",不是漏洞个数的倍数,口径在原文里也是分开报的。
两组研究互相咬合的地方只有一处:风格不一致和样板模板化都指向同一个机制,模型输出的是分布的均值,不是这个仓库的方言。这一条我当作文章的立论基础,属于我的判断;其余数字都按原论文口径转述。