LLM生成测试的偏见:如何公平比较代码实现
2026/8/30 7:03:14 网站建设 项目流程

两个实现摆在一起,用同一组测试来决定谁留下来,听起来很公平。但如果这组测试不是人工逐条写的,而是 LLM 生成的,公平性就要打一个问号。LLM 生成的测试可以改变哪个实现获胜,这不是一个理论推演,而是带着明确实践后果的事情:它能决定你在代码生成评测里选哪个模型,在重构选型里保留哪个版本,在技术方案评审里推进哪条路线。问题是,大多数人只盯着测试跑出来的绿勾,却没有回头检查生成测试本身是否带了偏见。

1. 先想清楚:你是在评估正确性,还是在评估“像不像标准答案”

1.1 一个看似公平却可能被测试偏置改变的结局

假设你手头有两个函数实现:一个是早期快速验证用的递归版本,另一个是后来为了生产环境重写的迭代版本。如果只按需求描述来看,两者在正常输入上的输出几乎一致。你为了节省时间,让 LLM 基于第一个版本的源码生成了一组测试,然后拿这组测试去跑两个实现。结果是递归版本全绿,迭代版本挂了一片。你很可能得出结论:第一个版本更好,第二个版本有问题。

这个结论真的成立吗?不一定。

真正发生的情况可能是:LLM 在生成测试时,把递归版本里的一些“实现细节”当成了“必须行为”。比如递归版本会先判断输入是否为空列表,再判断是否只有一个元素,最后才走分治逻辑。LLM 生成的测试可能隐含地验证了这种判断顺序,或者某个中间状态,而迭代版本没有这些中间状态,但它同样返回了正确结果。于是测试把等价行为误判成了错误行为。

反过来,如果你不给 LLM 任何实现源码,只给函数签名和需求描述,结果可能又会不同。LLM 会基于训练数据里的“教科书解法”去设计期望行为。如果一个实现采用了一些非常规但合法的工程化策略,比如原地修改输入并返回None,或者使用缓存导致首次和第二次调用行为不同,LLM 生成的测试往往不会考虑这些,照样可能把正确的实现判为失败。

所以,当测试由 LLM 生成时,“哪个实现获胜”并不完全由实现的真实质量决定,还会由测试生成时的上下文、措辞和模型偏好决定。

1.2 测试不是在验证需求,而是在验证“某种印象”

人工写测试时,通常会先有需求文档、接口定义、业务规则,再围绕这些写断言。整个过程的核心是“需求是什么”。

LLM 生成测试时,看起来也能做到这一点,但它的判断依据并不完全独立。它可能会:

  • 根据提示词里附带的某个实现代码来推断需求;
  • 根据训练数据里最常见的实现模式来补全需求;
  • 根据函数名、变量名、注释里的关键词来脑补预期行为。

这些推断大多数时候是对的,因为大多数代码确实符合常规。但一旦存在两个行为等价、风格不同的实现,LLM 的“常规印象”就会变成一把偏尺。这把尺量出来的是“哪个实现更像训练数据里的标准答案”,而不是“哪个实现更符合真实需求”。

我在实际操作中遇到过类似的场景:对比重构前后的两个模块,旧版本用命令式风格,新版本用函数式风格。我用旧版本作为参考,让 LLM 生成测试,结果新版本频繁失败。后来发现测试断言里包含了旧版本特有的中间变量和日志格式,那些根本不是业务需求。测试从起点就不是在验证“谁是对的”,而是在验证“谁长得更像第一个实现”。

因此,使用 LLM 生成测试来比较实现时,第一件事不是急着跑测试,而是问自己:我想要评估的是纯正确性,还是某种行为兼容性?如果是纯正确性,就必须让测试生成过程远离任何单一实现的特异性。

2. LLM 生成的测试,偏差到底从哪来

2.1 训练数据里的“标准实现”阴影

LLM 训练资料里包含大量的开源代码、技术博客、问答社区。这些资料统计上存在很强的规律性:某些模式反复出现,比如sorted()返回新列表、list.append()修改原对象、函数出错时抛ValueError、列表为空时返回[]

这些规律对代码补全和测试生成有好处,因为大多数情况它们是合理的。但到了“比较多个等价实现”的场景,它们会变成隐性偏好。

一个典型例子:如果你让 LLM 为某个处理列表的函数生成测试,它很容易写出类似assert func([3,1,2]) == [1,2,3]的断言。这个断言隐含了“函数应该返回新列表”的期望。如果某个实现选择原地排序并返回None,虽然从接口合同上可能也说得通,但这组测试会立刻把它判成失败。

这并不代表 LLM 生成测试的能力不行,而是因为测试生成本质上是在做一种“概率填充”:它会把最常见、最顺滑的行为预期填进断言里。当被评估的实现偏离这种预期时,就会产生误报。

2.2 提示词上下文造成的锚定效应

提示词是影响测试生成偏差的最强变量。LLM 不是从真空中生成测试,它极度依赖上下文里的信息。

如果你在提示词里附上了实现 A 的完整源码,LLM 很自然地会照着 A 的代码结构去生成测试。它看到 A 里有一个辅助函数_normalize_input,就可能生成一条测试来验证这个函数被调用后的效果,而不是验证公开接口本身应该满足的规则。它看到 A 在某个边界条件下抛出了RuntimeError,就可能把“抛RuntimeError”写进测试断言。哪怕 B 的做法是返回一个错误码,逻辑上完全合理,也会被定义为不符合预期。

这种锚定效应是致命的,因为测试生成者已经“偷看”了其中一个选手的底牌。最终的测试结果,更像是“A 版的影子选手”和“B 版”之间的一场不对称比赛。

如果你希望比较两个候选实现,至少不能把其中一个的实现细节暴露给测试生成器。更极端一点,应该把两个实现都隐藏起来,只提供一个中立的、需求层面的测试生成提示词。

2.3 断言生成时的隐性假设

即便提示词足够中立,LLM 生成测试时还有一个常见的毛病:它偏爱具体的输出值断言,而不是属性断言。

比如要测一个排序函数,LLM 通常生成:

assert sort_list([3, 1, 2]) == [1, 2, 3] assert sort_list([]) == []

而不是:

result = sort_list([3, 1, 2]) assert len(result) == 3 assert result[0] <= result[1] <= result[2] assert result == sorted([3, 1, 2])

前者的优点是直观,缺点是它锁死了很多不必要的细节:它要求返回值是一个列表,要求顺序严格一致,要求对空列表返回空列表,甚至可能要求数字类型完全一致。如果某个实现返回的是元组(1, 2, 3),虽然从数据语义上和列表等价,但测试会直接失败。

更隐蔽的是,断言可能暗示函数不能有副作用、不能修改传入参数、不能随机顺序等。这些属性有时是需求,有时只是实现选择。当它们以断言形式出现在测试里时,就会成为“隐性契约”,实际上是在替实现做决定。

这也是为什么在实现比拼场景下,不能只信任 LLM 生成的原始测试。需要有人去读断言,判断哪些是本质需求,哪些只是模型生成的“默认假设”。

3. 让 LLM 只生成“候选素材”,把“裁判权”收回人手里

3.1 一个四步评估法:从生成到裁决

既然 LLM 生成的测试可能带偏见,正确的做法不是完全不用它,而是让它的角色从“裁判”降级为“幕僚”。它能快速生成大量测试思路,但最终判决必须经过一个更谨慎的流程。

我把这个流程总结成四步:

  1. 定义评估目标。先明确你比较两个实现时最关心什么。是输入输出正确性?异常处理?性能边界?内存与副作用?不同目标需要不同测试风格,不能混在一起。
  2. 生成候选测试。用不同的提示词、不同的模型、不同的角度生成多组测试。这个阶段的目标不是得到一组“完美的测试”,而是得到一组“尽可能不偏科的候选测试”。
  3. 测试自校验。拿一个你认为是“可信参考”的已知正确实现,去跑一遍生成的测试。如果测试连可信参考都过不了,优先怀疑测试本身。同时人工读一遍测试断言,删掉那些明显是在验证实现细节、日志、报错文本、中间变量或者特定编码格式的断言。
  4. 盲评实现。把被测实现的名字隐藏起来,或者打乱顺序,用同一组有效测试去跑所有实现。然后只输出失败指标,不直接输出“谁赢了”,最后再由人来看失败案例是否真正构成需求偏离。

这个流程的核心逻辑不复杂:把“生成测试”和“判定胜负”两个步骤分开,让模型负责生成多样性,让人负责最终裁决。

3.2 用“双盲”和“多视角”降低偏差

做实验时讲究双盲,评估实现也需要类似思路。

第一层盲:LLM 生成测试时,看不到被测实现的具体名称和源码。它只能看到函数签名、需求描述和边界条件说明。最好把接口里可能透露实现风格的部分也做脱敏处理。

第二层盲:评估脚本只负责把同一组测试跑在各个实现上,并输出失败用例清单。输出文件里不写“A 实现失败 5 条,B 实现失败 2 条”,而是写“实现 1 失败 5 条,实现 2 失败 2 条”。具体哪个实现对应哪个编号,只在最后揭晓。

多视角同样重要。不要只用同一种提示词模板生成测试。应该准备几套不同视角的提示词:一套偏 happy path,一套偏边界,一套偏异常输入,一套偏性能压力。这样即使每一套都有偏差,最终合并后的测试集合仍然可能覆盖更多样化的行为空间。

我自己的经验是:只生成一次测试,失败结果的偶然性很大。同一份测试换一个提示词,胜负很可能就变了。但如果从 5 条不同的需求描述方向生成测试,并且最后过滤掉无效断言,结果的稳定性会明显提升。

3.3 结果异常时的排查链路

当你发现某个实现全面落败,或者某个实现全绿但直觉告诉你它不应该赢时,不要急着下结论。按下面这条链路排查:

  1. 先看失败断言本身。失败是断言的返回值不对,还是抛错类型不对?断言里是否隐含着函数必须返回新对象、必须不修改原对象、必须抛某个特定异常?这些问题往往说明测试把实现细节当成了契约。
  2. 再看生成测试时的提示词。测试生成时是否暴露过某个实现源码?是否把某个实现的核心思路描述得太详细?如果有,就需要重新生成一组独立测试。
  3. 再看测试覆盖范围。测试集中是否全是正常路径?是否缺空输入、极大数据、重复数据、特殊编码、并发调用和超大异常等边界?覆盖不足会导致某一类实现凭空获得优势。
  4. 重新生成一组测试。换一个模型、换一套提示词、去掉所有实现细节,再跑一遍。如果胜负结果反转,基本可以确认上一组测试有偏。
  5. 让人工裁决失败样本。把失败样本抽出来,只保留输入、输出和预期,问一个不熟悉实现细节的人:这条测试评判的到底是不是需求本身?这一步往往能最快发现问题。

3.4 伪代码:最小可行的评估流程

为了更直观,我给出一个最小流程的伪代码。它不适合直接搬到生产环境,但适合用来理解整体逻辑。

# 伪代码:只展示评估结构,不依赖某个具体库 # 1. 准备多个实现,但评估时不直接暴露实现名 implementations = { "candidate_1": source_a, "candidate_2": source_b, } # 2. 基于需求描述生成多组候选测试,提示词里不出现实现源码 prompt = "请根据以下函数签名和需求描述,生成 pytest 测试,覆盖正常、边界、异常输入。" prompt += signature + requirements generated_sets = [] for i in range(5): generated_sets.append(llm_generate(prompt + f"\n第 {i} 组:请尝试不同的测试策略")) # 3. 使用一个已知参考实现过滤测试,删除无效断言 valid_tests = [] for test_file in generated_sets: if run_with_reference(test_file) == "all_pass": valid_tests.append(test_file) # 4. 对每个候选实现运行同样的测试,但不打印实现名 for idx, (name, source) in enumerate(implementations.items()): report = run_tests(source, valid_tests) print(f"candidate_{idx + 1}: {report}") # 5. 人工抽检失败样本,判断是真实需求偏差还是测试偏见

这段伪代码里最重要的是第 2 步和第 3 步:不让生成器看到候选实现,同时先用参考实现剔除明显不合理的测试。如果你直接把生成测试和运行候选实现绑定在一起,代码看起来很快,但结果可信度很低。

4. 什么场景值得用,什么场景别勉强

4.1 LLM 生成测试真正适合的场景

LLM 生成测试在以下场景里价值很高:

  • 快速摸清实现边界:你想知道一个函数在空输入、超大数据、乱序输入、重复元素下是否稳定。LLM 生成一组测试,能在几分钟内给你一个初步结论。
  • 代码生成模型的相对排序:你在比较多个模型或同一个模型不同参数下的代码输出,希望用统一测试集做预筛选。只要测试集本身经过一轮去偏,它仍然能作为快速排序依据。
  • 教学和代码讲解:给初学者生成测试,帮助他们理解接口契约和边界条件,这不涉及生产决策,允许一定程度的偏差。
  • 内部方案预选:阶段性评审多个候选实现,先淘汰明显错误的方案,再对剩余方案做人工深度评审。

在这些场景里,LLM 生成测试可以当作“高密度建议源”,它的目标是减少重复劳动,而不是代替最终判断。

4.2 不适合使用的场景

真正的红线在下面这些情况:

  • 生产环境关键模块的最终决策:比如支付链路、权限控制、数据迁移脚本。这些地方容不得“测试偏好”,任何微小偏差都可能造成生产事故。
  • 安全敏感或合规强相关的代码:LLM 生成的测试很难覆盖所有攻击面。如果你用这类测试去证明“安全没问读”,几乎等于没测。
  • 需要长期维护的测试资产:测试代码写一次,以后要跑几年。LLM 生成的测试往往缺少上下文注释、需求链接和变更历史,长期维护成本很高。如果团队打算把这组测试沉淀成正式回归资产,最好转成人写,并且补足需求说明。
  • 对覆盖率和可追溯性有硬性要求的项目:某些项目要求每个需求变更都能追溯到对应测试。LLM 生成的测试在这方面天然薄弱,需要额外补充大量管理信息。

总体判断标准是:如果错误结论带来的成本很低,可以依赖 LLM 生成测试;如果错误结论会进入生产环境或长期资产,就必须引入人工审计环节。

4.3 三种测试来源的对比

测试来源偏差倾向覆盖偏向成本典型场景
人工编写测试依赖写测试的人对需求的理解,相对可控覆盖由人的关注点决定,可能忽略没想到的边界高,尤其需要长期维护生产环境核心模块、正式回归测试
LLM 生成的原始测试受训练数据和提示词影响,容易偏向“常见解”常常集中在 happy path 和简单边界低,生成快快速验证、学习、初步筛选
LLM 生成 + 人工审查通过审查过滤掉明显偏置断言,相对平衡在 LLM 基础上人工补充关键边界中,仍需要有人深度参与方案预选、模型对比、重构前后评估

这三者不是互斥关系。比较高效的组合是:先用 LLM 生成大量候选测试,再用人工审掉其中一部分,最后把保留的测试作为统一基准。这样既利用了速度,也守住了底线。

5. 最核心的工程经验:先评估测试生成过程,再评估实现

5.1 不要盲目信任 LLM 生成的“绿勾”

回到最开始的问题:LLM 生成的测试可以改变哪个实现获胜。这背后真正危险的,不是 LLM 生成测试错得离谱,而是它错得看起来非常合理。

你看到一组测试全绿,会很自然地把结果归因于“这个实现质量高”。但如果你看过生成测试的提示词带着某个参考实现源码,或者断言里锁死了返回类型和异常类型,你就会意识到,这个绿勾只是“符合模型预期”的标志,不是“满足需求”的证明。

我现在做方案对比时,已经养成一个习惯:先检查测试生成过程,再去看胜负结果。如果生成测试时没有做盲化处理,没有多视角生成,没有对测试本身做校验,那么无论测试结果多漂亮,都只当参考,不当判决。

5.2 怎么把这个判断落到日常开发里

你不需要每次都搭一个复杂的双盲评估流程。但可以做一个很轻量的动作:在让 LLM 生成测试之前,先写一段需求描述,并且要求自己不看实现源码,只基于需求描述去生成。生成之后,快速扫一眼断言里有没有明显不合理的限制。

再往前一步,如果对比两个实现,至少准备两组提示词:一组给需求描述,另一组给参考实现源码。然后分别生成测试,再对比结果。如果两组结果方向相同,说明结论比较稳定;如果方向不同,说明测试生成过程本身已经成了扰动变量,反而证明它是结果反转的来源。

这种检查不会花很多时间,但它能让你的测试结论从“模型说了算”变成“在受控条件下给出方向”。这两个判断级别,在工程决策上差别很大。

5.3 未来测试生成工具越强,越要保留“裁判意识”

LLM 生成测试的能力会越来越强,这是趋势。但工具越强大,我们越容易把判断权让渡给它。测试生成工具的进步,不应该表现为“测试别人全包了,我只管看结果”,而应该表现为“它能高效地把覆盖面扩大,但哪些行为是契约,哪些行为是实现细节,仍然由人来决定”。

从这个角度看,LLM 生成的测试,是评估工作中最容易被误用的加速器。它能帮你发现很多想不到的边界条件,也能在实现对比中提供参考信号。可一旦你把裁判权完全交给它,你真正评估的就不是实现,而是模型对“正常实现”的想象。而这个想象,恰恰会改变谁赢。

下一次你让 LLM 生成测试去比较两个实现时,先给测试生成流程做一次“偏见审计”:它看到了什么,它忽略了什么,它默认了什么。把这三点回答清楚,再决定要不要相信结果。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询