期末的编程实操考试,张老师想挑出真正会写代码的学生。他出了一张 164 道题的试卷,每道题要求补全一个小函数。可两百份代码挨行看过去,别说一周,一个月都看不完。于是他想了个聪明的笨办法:每道题背后藏一份“评分细则”——只有几条测试用例。代码只要全部跑通,就判对;有一条失败,就判错。这就是 2021 年 OpenAI 发布 HumanEval 的底层思路,也是今天很多 AI 代码能力分数的来源。问题在于:评分细则太薄,判卷就容易被聪明的学生钻空子;覆盖太少,分数里就掺了水分。本文用一场“张老师出题、学生小陈应考”的故事,讲清楚测试用例覆盖为什么决定了大模型编程评测的可信度。
学懂测试用例覆盖,你就能拆解 pass@1 数字里的水分,在模型选型与评测报告中做出更可信的判断。
可视化演示
展示HumanEval测试用例覆盖:功能/边界/异常用例覆盖度、覆盖度提升对评测的影响、高覆盖提升评测有效性,通过交互理解评测可信度的关键因素。
本节配有交互动效(进入视口后自动加载,约 3KB)。
覆盖范围:功能-边界-异常 功能用例覆盖主路径 边界用例验证边界 Cov=Fun+Bnd 覆盖影响评测有效性
评测原理基础
教了二十年编程的张老师,这学期想从两百名学生里挑出真正会写代码的人。他连夜出了一张试卷:164 道编程题,每道题给出函数签名和一段中文描述,让学生补全实现。可当他真的开始判卷,才发现问题——程序题不像选择题,没有标准答案可以比对,两百份代码逐行读下来,他一个月都不用睡觉了。
2021 年,OpenAI 在 Codex 论文里也遇到了同样的难题。他们要评测大模型写代码的能力,于是发布了 HumanEval:164 道由人类专家手写的编程题。每道题由三部分组成:函数签名(比如 def sum_list(xs):)、任务描述(docstring)、以及几组隐藏在幕后的测试用例。和文本类评测不同,代码有一个天然优势——程序行为可以被机器客观检查,不必依赖人的主观判断。
张老师的做法,就是把“水平高低”这种模糊概念压缩成一道清晰的是非题:程序必须通过测试集里每一条用例才算正确。这个判定规则用数学写出来就是:
就这样,张老师的考试变成了一本会自己批改的试卷册。每一道题的命运,完全系在那些藏在幕后的测试用例身上——这是整个 Humaneval 评测体系的地基。
- 程序 P 与测试集 TP 是模型生成的代码,T 是这道题背后藏着的一组隐藏用例。判定 J 的角色就是张老师的那支自动判卷笔。
- 全称量词 \forall要求测试集里每一条用例都必须通过,一条失败整体判零分,这正是“全部通过才算对”的严谨写法。
- 分段取值满足条件取 1(正确),否则取 0(错误)。把模糊的能力评价压成 0/1 的明确信号,机器才能批量评分。
测试用例角色
出完试卷,张老师开始写评分细则。以“列表求和”这道题为例,他提笔写下了三条用例:输入 [2, 4, 6] 应该输出 12;输入 [3] 应该输出 3;输入 [-2, 3, 7] 应该输出 8。这三条用例就像是考场上的三个检查点,学生的代码必须逐点通过,才能拿到这道题的分数。
在 HumanEval 里,这些检查点是 Python 的 assert 风格单元测试。它们承担着三个角色。第一个角色是功能验证:确认代码能完成题目描述的基本任务,相当于检查学生“有没有听懂题目”。第二个角色是边界检测:试探空输入、极大极小值、特殊情况,看看代码在悬崖边上会不会失足。第三个角色是评分依据:模型从头到尾只能看到函数签名和描述,看不到任何一条测试,因此无法针对“已知答案”作弊。
一条测试用例的本质,就是一次输入与输出的严格比对:
张老师靠着这几条检查点判卷,就像厨师靠尝一口菜判断整锅汤的味道——每尝一口就多一分把握。可问题是,他只尝了三口。
- 输入与期望输出每条用例 t 由两部分组成:输入
t_in和期望输出t_exp。 - 程序实际行为f_P 表示程序 P 在给定输入下真实返回的结果,冒号左边是“程序做了什么”。
- 严格相等用等号要求实际输出与期望完全一致,差一个字符都算失败。assert 语句天然适合这种隐藏判卷,因为它要么静默通过,要么立刻报错。
覆盖缺口风险
考试结果出来了,学生小陈的“列表求和”得了满分,张老师却隐隐觉得不对劲。小陈的代码是把第一个元素直接当初始值,然后遍历剩余元素累加。用张老师的三条用例一测,全绿通过。可如果传入空列表 [],这段代码会直接崩溃——因为空列表根本没有“第一个元素”。
这就是典型的覆盖缺口:HumanEval 每个问题平均只有约 7.7 条用例,远远覆盖不了所有输入条件。空列表、极大整数、重复元素、特殊字符……这些边界条件就像没有设防的关卡,放走了大量“表面正确、暗藏 bug”的代码。后来研究者构建了 HumanEval+ 扩展套件,把用例扩充到原来的约 80 倍,发现不少模型的 pass@1 明显下滑——说明原来的高分里,掺着相当一部分“侥幸通过”。
为什么几条用例的差别会这么大?用数学看一眼就明白了。假设一段含 bug 的代码,面对第 i 条用例时有 p_i 的概率侥幸通过,那它通过全部 N 条用例的概率是:
小陈的成功不是实力,是运气。他那道“全绿”的答案之所以能骗过判卷,不是因为写对了,而是因为张老师的评分细则太薄——每一道没设防的边界,都是给侥幸留下的后门。
- 单条放走率 p_ip_i 表示第 i 条用例没能拦住 bug 的概率。比如一条只用正数测
sum_list的用例,很容易放走“空列表崩溃”的 bug。 - 连乘符号把 N 条用例各自放走 bug 的概率乘在一起,得到“所有用例都被骗过”的联合概率。
- 数值直觉若每条用例都有 70% 概率放走 bug,3 条用例时漏判率是 0.343,10 条时只剩 0.028。这就是扩展测试套件能挤出分数水分的原因。
用例设计策略
考试风波之后,年轻的助教主动请缨,帮张老师扩充评分细则。助教没有简单地把正数列表多加几组,而是先问了一个问题:这道题的输入空间到底长什么样?这就像一位认真的美食品鉴师,不会只吃刚出锅的那一口——还要尝尝放凉的、回锅的、加过量的盐的,才能判断这道菜真实的水准。
这种思考方式在软件测试里叫“等价类划分”:把行为相似的输入归成一类,从每一类里挑一个代表来写用例。对“列表求和”来说,正数列表、负数列表、零元素列表、空列表、超长列表,各自就是一类。每类挑一条,用最少的用例覆盖最广的输入空间。
在此基础上,还要做边界值分析。空输入、单元素列表、极大极小数值,这些悬崖边上的输入最容易暴露 bug,必须单独设卡。再往深处,还要考虑组合条件和异常输入:多参数函数要测“全真、全假、一真一假”的组合;遇到类型不匹配、超大整数、特殊字符时,代码能不能体面地失败而不是崩溃。这些策略可以浓缩成一个数学表达:
助教的新评分细则一上考场,不仅拦住了小陈的空列表崩溃,还揪出了另外三个学生的隐藏 bug。这件事让张老师明白了一个道理:好用例来自对问题空间的拆分,而不是用例数量的堆叠。
- 输入空间 U把程序所有可能的输入看成一个全集,边界值和异常值也都住在里面。
- 互不重叠的等价类每个 E_i 是一类行为相似的输入,类与类之间互不重叠,同一类里只需挑一个代表写用例。
- 并成全集所有等价类拼起来恰好覆盖整个输入空间 U,不漏掉任何一类输入。
评分指标解读
考试进入第二轮,张老师做了一个大胆的决定:每个学生可以交多份答案,只要其中任何一份通过了检查点,这道题就算答对。他给了学生 k 次机会,让学生们高兴坏了——小陈的同桌交了三次答案,第三次终于蒙对了一个边界情况,拿到了一分。
在 HumanEval 里,这个“多次机会”的设定就是 pass@k。模型为每个问题独立采样 n 个候选程序,其中有 c 个通过了全部测试用例。pass@k 问的是:如果从中随机挑 k 个,至少捞到一个正确解的概率是多少?如果用最简单的方式直接算,很容易得到有偏的估计;论文里用的是无偏估计公式:
但张老师很快发现,多给机会并不总是坏事,真正危险的是评分细则太薄。当测试用例覆盖不足时,pass@k 的每一个 k 值都被注了水——“正确”变成了“碰巧骗过了少数检查点”。机会越多,运气就越容易伪装成实力。
- n 与 c 的含义n 是模型为每个问题采样的候选程序数量,c 是其中通过全部测试的个数,显然 0 到 n 之间。
- 组合数分母\(\binom{n}{k}\) 表示从 n 个候选里任选 k 个的总选法数。
- 分子含义\(\binom{n-c}{k}\) 表示“选出的 k 个全是错误程序”的选法,两者相除就是“全错”的概率。
- 期望符号1 减去“全错”概率得到“至少一个正确”,再对全部 164 个问题取平均,得到最终的 pass@k。
评测质量维护
考试结束后,张老师把助教叫到办公室,认真复盘了整套评分细则。他意识到,一份值得长期使用的试卷,必须像软件团队的测试套件一样持续维护:人工审查每一条用例是否真的表达了题意,发现漏洞就补,跑完一轮再迭代。这不是一次性的工作,而是一个反复循环。
在真实的评测体系里,这套维护动作对应着几件具体的事。人工审查:确认每条用例断言的是“正确行为”而非“错误行为”,防止测试本身写错。用例扩增:像 HumanEval+ 那样,用程序化生成加人工筛选的方式,把边界用例铺满输入空间。持续迭代:警惕数据污染——如果模型训练时见过这些用例,分数就会失真,需要定期更换或扩展测试集。
维护的效果,可以用覆盖率来衡量。覆盖率就是“被用例摸到的条件数”占“全部该摸的条件数”的比例:
经过整整一个月的维护,张老师的评分细则从每道题 3 条扩展到了 20 多条,覆盖了空值、极值、异常等所有他能想到的边界。他终于敢对学生们说:这份试卷,确实能测出真本事。
- 已覆盖条件数n_covered 是测试套件实际触碰到的输入条件(边界、分支、异常)数量。
- 全部条件数n_total 是理论上应该检查的条件总数,由问题本身的复杂度决定。
- 比值意义覆盖率趋近 1 时,第三节的漏判概率会被压到极低,评测分数才接近真实能力。
未来演进方向
张老师的试卷在学校里出了名,其他老师纷纷来抄作业。可没过多久,最聪明的学生又开始满分了——他们摸清了出题套路。于是学校决定升级考试制度:不再只考单函数,而是考真实的工程任务;不再只有 Python,而是加入 JavaScript、Java、SQL;不再只看函数返回值,而是看整个仓库能不能跑通测试。
这正是 HumanEval 之后整个评测生态的演进路线。HumanEval 本身有天然的局限:只有 Python、只写短函数、只看单函数正确性,测不出真实工程能力。后续的 MBPP 增加了更多基础编程题,DS-1000 聚焦数据科学场景,SWE-bench 直接让模型修复真实代码仓库里的 issue——但无论怎么演进,最核心的逻辑没变:每个任务背后都藏着测试用例,用覆盖度决定对错。
在这个演进中,“覆盖”始终是地基。多语言扩展要求每种语言都有健壮的测试套件;复杂任务要求用例能模拟真实的调用上下文;仓库级评测要求隐藏测试覆盖到集成环节。再加上数据污染的追踪、动态执行的验证、智能体式评测的引入,未来的基准会越来越难“碰运气”。
张老师在学期总结上说了一句让学生们记了很多年的话:任何考试的信誉,都藏在考官看不见的评分细则里。对大模型评测来说,这句话翻译过来就是——测试用例覆盖,决定了你眼前那份漂亮分数,到底是真金还是掺了水的沙。
AI 应用场景
场景一:模型选型中的可信对比
阶段:模型评估与选型
解决的问题:直接用原版 HumanEval 对比候选代码模型,常见输入都能过、边界全漏检,分数差距可能只有一两个百分点,难以支撑选型决策。构建扩展测试套件后,才能暴露各模型在空值、极值、异常输入上的真实差异,让排名经得起复盘。
具体案例:某平台要在三个开源代码模型里挑一个作为代码助手基座,原版 HumanEval 分数非常接近且普遍虚高。团队给每个问题补充了 20~30 条边界与异常用例后,发现一个模型在空输入场景的崩溃率高达 15%,另一个模型则表现稳健,最终排名出现实质性变化,避免了上线后频繁报错。
场景二:微调数据的质量过滤
阶段:训练数据构建与迭代
解决的问题:大模型微调需要海量高质量“问题-代码”对。若只用基础用例筛数据,会把“表面正确、边界崩溃”的代码当成正样本,等于把 bug 教给模型。用扩展测试套件筛选,才能保证进入训练集的是真正健壮的代码。
具体案例:某团队微调代码模型前,从自动生成的 20 万条代码中先用原版功能用例初筛,再用扩展边界套件复筛,最终剔除了约 14% 通过初筛但边界失败的样本。训练出的模型在函数调用的空值处理上错误率下降近一半。
场景三:代码助手上线前的质量验收
阶段:部署前质量保障
解决的问题:AI 代码补全工具在 IDE 中给出的代码,如果只在常见路径上正确,开发者直接采用后容易在生产环境抛异常。QA 需要沿用评测覆盖方法论,为模型输出设计回归测试套件。
具体案例:某团队为自动生成的日期解析函数设计测试套件,覆盖闰年、2 月 30 日、时区偏移、非法字符串等边界,上线后时间解析类故障减少约 60%,验证了“覆盖设计”从评测集迁移到产品测试的有效性。
Python 演示
def sum_list(xs): """返回列表元素之和(空列表会抛异常,即边界遗漏的典型 bug)。""" total = xs[0] for x in xs[1:]: total += x return total def run_case(func, inp, expected): """执行单条测试用例,异常也算失败。""" try: return func(inp) == expected except Exception: return False BASIC_CASES = [ ([2, 4, 6], 12), ([3], 3), ([-2, 3, 7], 8),] EDGE_CASES = [ ([], 0), ([0, 0, 0], 0), ([-5, -1], -6), (list(range(100)), 4950),] def evaluate(func, cases): """返回 (通过条数, 总条数, 失败用例列表)。""" failed = [] passed = 0 for inp, expected in cases: if run_case(func, inp, expected): passed += 1 else: failed.append(inp) return passed, len(cases), failed def leak_probability(p_single, n_tests): """若每条用例有 p_single 概率放走 bug,N 条时的漏判率。""" return p_single ** n_tests def main(): p0, n0, f0 = evaluate(sum_list, BASIC_CASES) p1, n1, f1 = evaluate(sum_list, EDGE_CASES) print(f"原版 3 条用例: {p0}/{n0} 通过, 失败输入 {f0}") print(f"扩展 4 条用例: {p1}/{n1} 通过, 失败输入 {f1}") print(f"单条用例放走率 0.7 时: 3 条套件漏判率 {leak_probability(0.7, 3):.3f}, " f"10 条套件漏判率 {leak_probability(0.7, 10):.4f}") if __name__ == "__main__": main()
total = xs[0]直接拿第一个元素当初始值,空列表在这里抛 IndexError,是有意埋下的边界 bug。except Exception:评测时异常也按“失败”处理,和真实评测套件的容错逻辑一致。BASIC_CASES = [...]对应原版 HumanEval 稀疏的普通输入用例,只覆盖常见路径。EDGE_CASES = [...]对应扩展测试套件:空列表、全零、负数、超长列表。return p_single ** n_tests实现正文第三节的漏判率连乘公式:用例越多,漏判率越小。
常见误区
误区我觉得只要模型生成的代码能通过 HumanEval 的测试用例,就说明这段代码在真实场景里也能正常工作。测试都过了还能有什么问题?
正解测试通过只能说明代码在“被抽查到的输入”上表现正确。原版 HumanEval 每个问题平均只有约 7.7 条用例,大量边界(空列表、极大整数、特殊字符)根本没被检查。扩增用例后许多模型通过率明显下滑,正说明“测试通过”只是“当前证据支持结论”,不是“永远正确”。
为何常见:日常开发中“测试通过”常被视为可靠的完成信号,人们容易把采样验证当成全量验证,忽略了评测用例与真实输入的覆盖差距。
误区既然是覆盖度不够,那就拼命往测试集里加用例,加到几百条就绝对可靠了。量变总能引起质变吧?
正解用例需要设计而不是堆砌。如果新增用例都和已有用例属于同一个等价类(比如全是正数列表),覆盖度不会提升,反而拖慢评测。正确做法是等价类划分、边界值分析、组合条件、异常输入四管齐下,保证每条用例触碰不同的逻辑分支。
为何常见:看到“数量少导致漏判”,容易把根因误解为“数量”,而真正的根因是“覆盖的结构性缺口”。
误区让模型生成 10 次再算通过率,这不是给模型作弊放水吗?我觉得只有 pass@1 有意义,其他指标都不可信。
正解pass@k 刻画的是模型“在多次机会下找到正确解”的能力上限,对应真实场景中开发者让 AI 助手多生成几个候选再筛选的交互方式。它和 pass@1 描述不同维度的能力,两者都应参考。真正要警惕的,是低覆盖度测试套件造成的虚高,而不是 k 本身。
为何常见:人们习惯用“一次考试定录取”的思维看指标,忽略了代码生成产品天然允许“多生成、再挑选”的交互方式。
误区164 道题、每道题就那么多测试,这个数是死的,所以跑出来的分数应该很稳定,可以直接拿来做模型排名。
正解固定的测试集不代表稳定的结论。测试用例覆盖面窄时,不同模型答对的题可能高度重合又各带边界缺陷,分数差异可能只是“谁更幸运地躲过了未覆盖的边界”。使用扩展测试套件后,模型排名可能发生明显变化——可复现的只是同样的漏判,并不等于可信。
为何常见:确定性数字容易带来安全感,人们容易把“可复现”误解为“可信”,忽略了复现的可能是同一套不完整的检查。
面试题
Q1. HumanEval 基准主要用于评测哪类模型?
- A.文本摘要生成模型
- B.代码生成模型← 正确答案
- C.图像分类识别模型
- D.语音转写合成模型
答案:B。HumanEval 是 OpenAI 发布的代码生成评测集,要求模型根据函数签名与描述补全代码,所以主角是代码生成模型。文本、图像、语音模型各有自己的基准,不在 HumanEval 的考察范围内。
Q2. HumanEval 数据集中共有多少道手写编程题?
- A.64 道
- B.164 道← 正确答案
- C.264 道
- D.1024 道
答案:B。HumanEval 包含 164 道由人类专家编写的 Python 编程题。这个数量让它在早期 LLM 评测中成为标准参考,也正因为题量不大,测试用例覆盖度才显得格外关键。
Q3. 下列哪条用例最可能暴露“空列表求和”函数的 bug?
- A.传入 [2, 4, 6] 求和
- B.传入空列表 [] 求和← 正确答案
- C.传入 [3] 求和
- D.传入 [-1, 1] 求和
答案:B。如果一个求和函数直接取 xs[0] 作为初始值,空列表会抛异常。只有传入空列表的用例能触发这个边界 bug,这正是边界值分析的价值所在。
Q4. 原版测试用例覆盖不足,最主要的后果是什么?
- A.模型训练速度变慢
- B.错误代码被漏判放行← 正确答案
- C.数据集文件体积变小
- D.显卡显存占用升高
答案:B。用例少意味着许多输入条件没被检查,含 bug 的程序可能像小陈一样“全绿通过”,导致分数虚高。这与训练速度、文件体积、显存占用没有关系。
Q5. 等价类划分的核心思想是什么?
- A.每个输入单独写用例
- B.相似输入归为一类抽样← 正确答案
- C.只测试边界附近的值
- D.随机生成大量输入
答案:B。等价类划分把行为相似的输入归为一类,每类挑一个代表写用例,用最少用例覆盖整个输入空间。边界值分析是它的补充而非替代,随机生成则缺乏代表性。
Q6. 扩展测试套件相比原版 HumanEval 最主要改进了什么?
- A.增加更多边界与异常用例← 正确答案
- B.改用中文书写题目描述
- C.减少题目数量提升速度
- D.去掉函数签名降低难度
答案:A。像 HumanEval+ 这样的扩展套件,用程序化生成加筛选的方式补充大量空值、极值、异常输入用例,把覆盖度拉伸数十倍,从而挤出虚高的分数。
Q7. 关于 pass@k 指标,下列说法正确的有?
- A.k 个样本至少一个通过测试← 正确答案
- B.k 越大指标通常越高← 正确答案
- C.指标与生成能力完全无关
- D.只统计第一个生成结果
答案:AB。pass@k 衡量“生成 k 个候选里至少一个正确”的概率,因此 k 变大时概率通常不会下降、只会更高。它恰恰是生成能力的体现,也从不只统计第一个结果。
Q8. 下列哪些做法能提升测试用例覆盖度?
- A.覆盖空输入这类边界← 正确答案
- B.覆盖极大极小的数值← 正确答案
- C.只复制已有的测试用例
- D.加入类型不匹配异常← 正确答案
答案:ABD。空输入、极端数值、异常输入分别对应边界值分析和异常路径测试,能让用例摸到更多逻辑分支。复制已有用例不会带来任何新的覆盖。
Q9. 原版 HumanEval 平均每个问题大约包含多少条测试用例?
- A.约 3 条左右
- B.约 7.7 条← 正确答案
- C.约 80 条
- D.约 200 条
答案:B。可复现实验显示,HumanEval 每个问题平均约 7.7 条用例,远少于真实项目的测试密度。这也是后来 HumanEval+ 将用例扩展到原先约 80 倍的原因。
Q10. 某函数的代码在原版用例下全部通过,在扩展用例下被拦截,这说明什么?
- A.覆盖度直接影响评测有效性← 正确答案
- B.该函数在任何场景都会出错
- C.扩展用例覆盖了遗漏的边界← 正确答案
- D.原版测试用例完全没有价值
答案:AC。同一段代码在不同测试套件下结果不同,恰恰证明原版覆盖有缺口、扩展用例补齐了它。函数在多数常见输入下仍然正确,不能说“任何场景都出错”;原版用例也仍能拦截大量普通错误,并非没有价值。