ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

LLM生成的测试集:谁在决定代码实现的胜负

LLM生成的测试集:谁在决定代码实现的胜负 这轮测试跑完方案 A 通过方案 B 挂了三处。我换了一组由 LLM 生成的测试再跑结果反过来方案 B 全过方案 A 挂了两处。两轮的代码完全一样业务需求完全一样唯一变的是测试集。这就是我今天想聊的核心问题LLM 生成的测试会改变哪个实现获胜。不是测试写错了也不是两个实现里肯定有一个是错的。真实场景里“实现正确”本质上是“在某个测试集约束下符合预期”。测试集才是裁判。当裁判由 LLM 自动生成时生成方式、提示词、模型偏好、边界用例的选择都会影响最终胜负。这篇文章把这个机制拆开讲先讲测试集为什么权重这么大再给一个可复现的实验流程最后聊批量评估时怎么避免被测试集带偏。适合正在做代码评测、算法选型、单测自动生成或者想评估 LLM 代码能力的人看。1. 谁决定实现方案“正确”不是功能是测试集1.1 正确性是“相对测试集而言”的软件工程里说的“实现正确”通常不是哲学意义上的正确而是给定一组输入程序输出符合预期。问题在于“预期”从哪来如果需求文档写得非常精确预期可以从文档推导但大多数项目的需求文档不可能覆盖所有输入。于是测试集就成了事实上的规格说明书。这一点在对比两个实现时尤其明显。实现 A 用一种处理逻辑实现 B 用另一种逻辑两者都能在当前测试集上通过不代表它们在所有输入上都等价。只要测试集没有覆盖到某个输入两个实现在那个输入上的差异就不会被看见。反过来如果测试集恰好只覆盖了 A 更擅长的场景B 的真实能力就被低估了。所以“哪个实现赢了”这个结论永远要带一个前提在哪个测试集上赢的。测试集不是中性测量工具它本身就是评估标准的一部分。1.2 为什么 LLM 生成的测试更容易带偏结果人写测试时多少会带着对需求的理解也会主动问“大小写要不要敏感”“空字符串怎么处理”。LLM 生成测试时更倾向于在提示词没有说明的地方直接做假设。它会把缺失的细节补成自己训练数据中常见的形态然后用测试断言把这些假设固化下来。这些假设固化之后会产生一个连锁效应如果团队把 LLM 生成的测试当作评估标准LLM 的偏好就变成了需求。比如需求只说“统计文本中的单词频率”没有说是否忽略大小写。LLM 如果按“忽略大小写”写断言那么一个区分大小写的实现就会被判失败。这个实现本身不一定错它只是没有猜中 LLM 的假设。这就是“LLM 生成的测试会改变哪个实现获胜”的核心机制测试不是中立的而 LLM 生成测试时又特别擅长把隐藏假设写得像理所当然。2. 搭建一个可复现的小实验两个实现 两组 LLM 测试2.1 实验设计和环境准备先把问题变小小到几分钟能跑完一轮但足够暴露差异。这里用一个常见的“词频统计”任务举例给定一段文本返回每个单词的出现次数按次数从高到低排序。我准备两个合法但实现思路不同的版本。实现 A 用字符串分割保留大小写def word_freq(text): words text.split() counter {} for w in words: counter[w] counter.get(w, 0) 1 return sorted(counter.items(), keylambda x: x[1], reverseTrue)实现 B 用正则提取单词统一转小写import re from collections import Counter def word_freq(text): words re.findall(r[a-zA-Z0-9], text.lower()) return Counter(words).most_common()两个版本都能跑但对大小写、标点、空字符串的处理不同。只要测试断言偏向某个版本的行为另一个版本就可能失败。实验环境不用复杂一台能跑 Python 的机器装好 pytest再有一个 LLM 的调用入口API 或本地模型都行。如果只用 API普通笔记本就够了如果本地跑模型要另外预留内存或显存具体量级取决于模型体积。2.2 两组测试的生成方式我建议生成两组测试故意让生成条件不同。第一组叫“盲生成”提示词里只放任务描述不放任何实现代码。任务给定一段文本统计每个单词出现次数按次数从高到低排序。 请用 pytest 写一组测试用例覆盖正常输入、空输入、标点符号、大小写等场景。第二组叫“面向实现生成”把实现 A 的代码一起放进提示词让模型基于这段代码生成测试。然后再单独把实现 B 的代码放进提示词重复一次。这样一共得到三套测试盲生成测试、面向 A 生成的测试、面向 B 生成的测试。每套测试都分别跑 A 和 B结果放到一起看。运行方式很简单pytest test_*.py -v如果提示没有收集到测试用例先检查文件名是否以test_开头测试函数是否以test_开头。Python 下常见collected 0 itemsRust 场景下有类似no tests found for given includes:基本都是收集规则没满足不是代码本身的问题。2.3 实验结果怎么看把结果整理成一张矩阵测试集实现 A实现 B盲生成测试可能通过可能通过面向 A 生成的测试通过率更高通过率可能偏低面向 B 生成的测试通过率可能偏低通过率更高这不是为了证明“哪个实现好”而是为了演示测试的生成条件变了赢家可能就变了。如果面向 A 生成的测试让 A 赢面向 B 生成的测试让 B 赢这恰恰说明测试集里混进了实现细节偏好。真正稳定的评估应该不会因为“测试生成时看过哪个实现”而轻易翻转结论。3. 测试质量怎么判断四个关键维度3.1 覆盖率只是“跑到了”不是“验证了”很多人拿到 LLM 生成的测试第一反应是看行覆盖率。覆盖率当然有价值但它回答的是“代码哪些行被执行了”回答不了“这些行的行为是不是对的”。一个测试可以覆盖某行代码但断言只写了一句assert result is not None这行代码逻辑错了照样可能通过。在对比实现胜负的场景里覆盖率不能作为公平性的依据。重要的不是测试碰到了多少行而是它验证了多少真实行为。3.2 断言强度决定测试有没有分量判断一条测试有没有用我一般先看断言。弱断言常见形态assert result is not None没有明确输入预期只断言函数没抛异常断言内容跟需求无关比如检查某个内部变量名称强断言常见形态针对具体输入断言完整输出断言返回类型、内容、顺序、数量对边界输入给出明确预期弱断言会让两个实现都通过失去比较价值更强的断言才能暴露差异。但如果断言过强比如把“按出现次数排序”写成“按字典序排序”又会误杀正确实现。所以断言强度要匹配需求不是越强越好。3.3 行为级测试和实现级测试的公平性差异行为级测试只看输入输出不关心函数内部怎么实现。比如“输入Hello hello输出[(hello, 2)]”就要求忽略大小写这是行为约束。实现级测试会检查函数名、参数名、内部变量、模块路径、调用方式。比如断言words text.split()这一行存在或者断言某个内部函数被调用这就是实现级测试。对比两个实现时行为级测试更公平因为它允许不同实现用不同方式达成相同行为。LLM 如果看到了实现代码很容易生成实现级测试。这不是它“坏”而是提示词引导它这样做。想要公平就尽量让测试生成时只看到行为规格不看实现。3.4 稳定性和可复现性LLM 生成测试有随机性。同一段提示词温度不同、模型版本不同生成的测试可能完全不一样。我建议每组测试至少生成两次看结果是否稳定。如果两次生成的测试对一个实现给出相反结论说明结论本身不可靠问题出在生成环节而不是实现环节。可复现性也是工程问题。最好在生成测试时记录模型名、提示词、温度、随机种子保存原始测试文件。不然一个月后想回头看不知道当时那组测试是怎么来的结论就无法复核。4. 换一组测试赢家为什么换了偏差来源拆解4.1 提示词里的实现代码就是最大的偏差源如果提示词里放了实现 A 的代码LLM 大概率会照着 A 的 API、参数名、返回格式写测试。这些测试天然贴合 AB 很容易因为“返回类型不一致”“函数名不同”被判失败。这不是 LLM 故意作弊而是它在完成“生成适配这段代码的测试”这个隐含任务。你给它看代码它就会写适配代码的测试你给它看需求它才会写适配需求的测试。想让测试保持中立先把实现代码从提示词里拿掉。4.2 边界用例选择本身就是偏好LLM 生成测试时会从训练数据中挑它认为重要的边界场景。它可能关注空列表、负数、超大数字、特殊字符也可能完全忽略中文字符、Unicode 组合字符、换行符、制表符。每个边界场景的选择都会放大某个实现的特点。回到词频统计的例子实现 A 用 split对标点不敏感实现 B 用正则会提取单词但忽略标点。如果 LLM 生成的测试包含hello,world这种带标点的输入两个实现的输出就不一样。哪一版算对取决于需求。如果需求没说测试选了这个输入就相当于替需求做了决定。这就是偏差的具体来源。4.3 测试数量少偶然性大于结论一组只有 5 条用例的测试哪怕有 4 条都偏袒 AB 也只能输。测试数量越少单条断言的权重越大结论越容易被偶然因素左右。我见过有人拿 LLM 生成的 8 条测试去评两个模型写出的代码然后宣布一个模型更强。这个结论几乎站不住。更好的做法是扩大测试数量至少覆盖正常路径、多个边界路径、异常路径同时生成多套测试交叉看胜负。如果多套测试都指向同一个结论可信度才高。4.4 规格不明确测试就是唯一解释很多开发任务的需求就是一句话。一句话可以有很多种合理理解。LLM 生成测试时必然要把它理解成某个具体版本于是测试成了规格的唯一解释。后面所有实现都只能在这个解释下竞争。这个问题没有完美解法但可以缓解在生成测试之前先把容易有歧义的点列出来比如大小写是否敏感、排序是升序还是降序、空输入返回什么、重复元素怎么处理。能确定的先写进提示词不能确定的就重点检查 LLM 给了什么假设再由人来定。5. 批量评估时怎么避免测试集“安排”胜负5.1 多提示词、多套测试集交叉验证批量评估最怕的是一套测试集定生死。我建议把测试集做成多个版本规格盲生成版只给任务描述行为规格版任务描述加明确的边界约定面向实现版每份实现各生成一套人工修订版在自动生成基础上人工修正断言每个实现都要面对所有这些测试集。最后统计的不是“谁全过”而是“谁在多少套测试集里胜出”。如果一个实现只在“面向它自己生成的测试”里赢在其他测试集里都输那它的优势很可能是测试偏袒出来的。5.2 把失败分类不看 pass/fail 一个数字批量跑完别只看通过率。同一个失败原因可以完全不同。我一般把失败分成几类输出错误结果和预期不一样异常实现本身抛了未捕获异常接口不匹配参数名、返回类型和测试假设不一致超时或资源问题跑太慢或内存不够断言不严谨测试本身预期错了分类之后能看得很清楚如果失败大多是“接口不匹配”说明测试是面向另一个实现写的不是能力问题如果失败集中在“输出错误”才是实现本身的逻辑问题。5.3 人类抽检断言尤其是核心业务逻辑LLM 生成测试可以当草稿但不能当最终标准。对核心业务逻辑至少要人工抽检一轮断言确认预期输出是对的。LLM 可能会把错误的预期写得非常有信心比如在词频统计的测试里断言Hello和hello是两个不同单词。如果需求要求忽略大小写这条断言就是错的。抽检不一定要全部看。优先抽边界输入、异常输入、涉及排序或去重的断言这些地方最容易被 LLM 带偏。5.4 记录生成参数保证结论可追溯批量评估时把每套测试的生成参数记下来模型、温度、随机种子、提示词版本、生成时间、测试文件路径。这些信息放进一个评估记录里比结论本身还重要。因为只要参数变了测试可能就变了结论可能就要重算。如果不记录后续很难回答“为什么上次 A 赢这次 B 赢”。大多数时候答案都是提示词或模型版本悄悄变了。6. 给实践者的排查清单和落地建议6.1 发现结果可疑时的排查顺序如果你拿到一份“测试显示某个实现明显更好”的结论先不要急着信。按这个顺序排查先看测试是行为级还是实现级是不是在照抄某个实现的 API再看断言强度是不是用弱断言“假装通过”再看边界输入是否符合真实需求有没有把 LLM 的假设当成需求再看测试生成时是否看过实现代码生成条件是否公平最后看测试数量和重复次数是不是样本太少导致偶然结果这五步走完大部分“离奇胜负”都能找到原因。6.2 不同场景下的取舍不同场景对测试公平性的要求不一样。学习演示两组测试都跑正好展示测试集对结论的影响算法选型或代码评测盲生成测试加行为级断言加人工审核避免实现细节干扰自动化回归多次生成并合并用例保留测试版本定期人工更新生产环境核心逻辑的断言必须人工确认LLM 只负责补充边界场景没有一套方案适合所有场景。关键是知道自己这个场景要的是“行为正确”还是“实现适配”。6.3 几条务实经验最后留几条我自己的习惯。先跑单条测试再跑批量。一开始就生成几百条测试失败时根本不知道是测试问题还是实现问题。不要一上来就调高温度追求“多样性”。测试生成阶段低一点的温度更稳定先确认断言正确再考虑多样性。报错不一定是实现的问题。先看测试本身预期对不对、输入适不适合、断言强不强。我见过很多次实现是对的测试预期写错了。输出目录和日志要留好。批量评估一定会有失败和重试没有日志就不知道失败发生在哪一步。我自己的结论是LLM 生成测试很有用但它生成的是“对问题的一种理解”不是“唯一事实”。真正落地时最该盯住的不是让哪个实现赢而是把测试的生成条件、断言强度和边界输入看清楚。测试能帮你做决定但不该让测试悄悄替你做决定。
返回列表