跳到主要内容

LLM 输出的基于属性的测试:发现你的评估集从未想过的 Bug

· 阅读需 13 分钟
Tian Pan
Software Engineer

你的评测集(eval suite)显示准确率为 94%。但用户反馈,对于名字不是 "John" 或 "Alice" 的情况,该功能是失效的。这两者都是事实,而它们之间的差距有一个专门的名字:你精心挑选的测试集只编码了你已经预料到的失败模式。

基于属性的测试(Property-based testing,简称 PBT)诞生于 1999 年,旨在揭示确定性软件中正是这一类的盲点。将其应用于 LLM 输出时,它会自动生成数以万计的对抗性输入变体,探测手写测试用例在结构上无法触及的领域边界。2025 年的一项 OOPSLA 研究发现,平均每个基于属性的测试发现的变异 Bug 数量大约是普通单元测试的 50 倍。另一项研究测量出,PBT 和基于示例的测试(EBT)在不同的 Bug 上会失败——将两者结合后,检测率从 68.75% 提高到了 81.25%。这 12.5 个百分点的差距并非舍入误差,它代表了单一方法无法察觉的整整一类故障。

本文面向那些已经拥有评测集,并希望找出那些评测集在结构上无法发现的 Bug 的工程师。

为什么你的评测集设计上就存在盲点

当你编写一个精心挑选的评测集时,你会列举出你认为重要的场景。你会测试正常路径、已知的边界情况,以及之前让你吃过亏的特定失败案例。这很有价值,但这是一个测试你的想象力的过程,而不是测试完整的输入空间。

结构性问题在于:精心挑选的集合编码了你构思出的输入。而真实的用户流量则编码了你没想到的输入。一个在 customer_name: "Alice" 上测试过的客服机器人,在面对 customer_name: "张伟" 时可能会表现出不同的行为——这并非因为代码中有任何意图,而是因为训练数据的分布创建了一种开发者从未考虑过的隐式耦合。一个针对问题 "What is the refund policy?"(退款政策是什么?)进行测试的分类器,在面对语义相同但表述不同的 "Can I get a refund?"(我可以退款吗?)时可能会表现不同——这并非因为意图改变了,而是因为词汇表面发生了变化。

基于属性的测试反转了这一模型。你不再断言 f(input_1) == expected_1,而是断言一个普遍的不变量:对于所有生成的 inputf(transform(input)) 都应满足属性 P。框架会生成成千上万的输入并尝试证伪你的属性。一旦证伪成功,它会将被证伪的反例(counterexample)缩小到最小的失败用例。

对于确定性软件,这已经非常强大了。对于由 LLM 驱动的软件,这被认为更具必要性——而且正确实现的挑战也更大。

对于非确定性文本输出,“属性”意味着什么

标准的反对意见是,LLM 的输出是非确定性的字符串,没有唯一的正确答案。你无法断言 output == expected_string。这确实没错,但同时也抓错了重点。

LLM 输出的属性不是等值断言。它们是关于输入和输出之间关系的不变量,或者是关于输出结构特征的不变量。

结构一致性(Structural conformance) 是最简单的切入点。输出必须是有效的 JSON。输出必须包含必填字段。响应必须符合声明的 Schema。这些是二进制的、确定性的,并且能捕捉到模型或提示词(prompt)更改后高比例的回归错误。评估这些属性不需要调用 LLM——只需要一个 Schema 验证器。

语义不变性(Semantic invariance) 是最强大的一类。定义输入的转换 T,使得输出应满足某种已知关系。例如:

  • 改写等价性(Equivalence under paraphrase):"What is your return policy?" 和 "How do I return a product?" 应该得到等价的回答。通过测试两个输出的 Embedding 之间的余弦相似度是否 ≥ 0.8 来进行验证。
  • 人口特征不变性(Demographic invariance):在非性别相关的场景中,将 customer_name: "Michael" 替换为 customer_name: "Wei" 不应改变响应的情感倾向或结果。
  • 顺序不变性(Order invariance):重新排列 Prompt 中提供的事实不应改变结论。如果一个模型根据它先看到哪份支持文档而给出不同的答案(当所有文档内容一致时),那么它表现出的顺序敏感性将导致生产环境行为不可预测。
  • 添加不变性(Addition invariance):添加无关的填充文本不应反转分类结果。

一项 2026 年的研究在推理问题上针对 8 种保持语义的转换测试了 7 个模型。表现最好的模型(30B 参数)有 20.4% 的情况违反了语义不变性。表现最差的违反率高达 73%。更引人注目的是,在这个维度上,30B 的模型显著优于参数量大 10 到 100 倍的模型。规模(Scale)并不能可靠地代表语义稳健性——这一发现是能力基准测试所无法察觉的。

单调性(Monotonicity) 适用于置信度或质量评分:增加更多相关的证据不应降低回答质量。如果你的系统提供置信度评分,增加第二个佐证来源不应降低该分数。

长度和格式限制(Length and format bounds) 适用于输出具有已知结构要求的情况:响应必须少于 N 个 Token,列表必须恰好包含 K 个项目,输出必须以特定的前缀开头。这些是完全确定性的属性,无需 LLM 评审员即可测试。

参数化输入生成:三种行之有效的方法

机械性的挑战在于生成能够证伪你属性的输入。三种策略涵盖了大多数实际场景。

实体替换(Entity substitution) 是最容易上手的切入点。将命名实体替换为语义等价的替代品 —— 角色相同,指代不同。对于一个客服机器人:在 ["Alice", "Maria", "Zhang Wei", "Mohammed", "Priya"] 中变换 customer_name,在完整的 SKU 目录中变换 product,在已知的请求类别中变换 issue_type。这是一个笛卡尔积,它生成的覆盖范围是任何人类都不会手动编写的,并且它能可靠地暴露人口统计学或特定产品的行为漂移。

在实践中,Python 的 Hypothesis 库通过 st.sampled_from(entity_list) 来处理这种情况。Promptfoo 通过 YAML 的 vars 声明实现相同的效果。同样的输入会针对你的结构和语义不变属性进行运行。

数值扰动(Numeric perturbations) 针对的是阈值敏感度。在金融、医疗或分析任务上训练的 LLM 经常在心理学上的整数关口表现出行为变化,而这些数字并不对应实际的领域阈值。测试 amount$0.99, $1.00, $1.01, $999.99, $1000, $1000.01 时的表现。测试 confidence_threshold 在 0.49, 0.50, 0.51 时的表现。一项针对 LLM-RAG 应用的 QA 研究发现,改变生成温度(temperature)和 top-p 参数会导致情感估计在最大值时从 0.99 下降到 0.35 —— 这种 64% 的退化在固定输入的测试套件中是不可见的。

指令重排(Instruction reordering) 针对的是多项研究中记录的前提条件顺序敏感性。一个多步骤提示词 —— “总结文档,然后对情感进行分类” —— 的表现可能与重排后的版本 “对情感进行分类,然后总结文档” 不同。来自 ICLR 2024 的研究表明,在推理问题中重排前提条件会显著降低测试模型的准确性。对于任何具有多个指令的系统提示词(system prompt),重排应该是一个标准的各种特性:输出质量不应依赖于指令序列。

Oracle 问题:为什么 LLM 的 PBT 需要深思熟虑

经典的基于属性的测试(PBT)受益于廉价、自动化的 Oracle(判定标准)。如果每一对相邻元素都满足 a ≤ b,那么 sorted(list) 显然是正确的。对于开放式的 LLM 输出,不存在等价物。

这是主要的工程挑战,而不是输入生成。你有三种 Oracle 选项,每种都有权衡。

结构化 Oracle(Structural oracles) 速度快、成本低且具有确定性 —— 但不完整。JSON schema 验证告诉你响应格式良好;但它没有说明内容是否准确或适当。仅依赖结构化属性的团队会产生虚假的自信。

嵌入向量相似度 Oracle(Embedding similarity oracles) 是语义不变性测试的实用变通方案。计算两个在语义上应该等价的输出的嵌入向量之间的余弦相似度。断言相似度 ≥ 0.8。这在模型版本之间是稳定的(不像精确字符串匹配),并且不需要运行另一个 LLM 调用来进行评估。局限性在于:余弦相似度是语义等价的代理指标,而不是保证。

LLM-as-judge Oracle 是主观属性中保真度最高的选项,但它们增加了推理成本并引入了另一个非确定性来源。有选择地使用它们 —— 用于结构化和相似度检查不足以应对的高风险行为属性。Semgrep 在 CI 中使用 GPT-4o 作为 14 条提示词链的裁判;他们基于提示词哈希缓存 LLM 响应,以保持不触及提示词的运行在 CI 中的确定性。

研究人员已经为五类 NLP LLM 任务编目了 191 种不同的蜕变关系(metamorphic relations):等价性、差异性、集合等价性、距离和集合距离。这是一个实用的库,团队可以从与其任务类型相关的子集开始逐步实施。

不会破坏流水线的三层 CI 集成

实际的挑战是,为每个属性运行 100 个输入变体意味着 100 倍的推理成本。解决方案是采用将成本与提交频率相匹配的分层架构。

第一层:每次提交时的结构化属性。 JSON schema 符合性、必填字段的存在、长度范围、格式断言。这些不需要 LLM 调用 —— 使用记录/缓存的响应。二进制的通过/失败。速度快到足以在不到一分钟内阻塞一个 PR。

第二层:PR 合并时的统计属性断言。 对于语义不变性和参数化输入属性,将每个属性测试运行 N 次(通常为 10-50 次),并断言最小通过率 —— 比如 “10 次运行中必须通过 8 次”,而不是二进制通过。使用针对嵌入向量的语义相似度,而不是精确的字符串匹配。在合并到 main 分支之前进行拦截,而不是在每次提交时。

第三层:定期的全量参数扫描。 针对完整的属性套件运行全面的实体替换、数值扰动和指令重排。每晚运行或由模型版本更改触发。此处的失败会阻止模型升级,而不是功能部署。

阈值设计至关重要。将 temperature 设置为 0 并不能使 LLM 变得具有确定性 —— 研究显示,在同一会话中,相同运行的准确率差异高达 15%,而在某些基准测试中,最好与最差之间的差距达到 70%。这意味着一个在 10 次运行中失败 3 次的属性测试不一定是一个随机失败的测试(flaky test);它可能正在暴露真实的、可复现的方差。根据观察到的基线方差设置你的通过率阈值,而不是一个整数。

一种行之有效的工作流是:使用 Hypothesis 配合 VCR 风格的 HTTP 录制器,以便生成的输入针对第一层回放的已记录 LLM 响应运行,仅在第二层和第三层运行时才回退到实时 API 调用。这为你提供了 PBT 的输入生成能力,同时兼具快照测试的确定性,从而实现最快的反馈循环。

PBT 无法提供信心的地方

有两种失败模式值得明确承认。

完整性天花板。 无法得知你的属性套件是否完整。研究发现,即使是最好的 LLM 也只能针对 API 文档中可提取属性的 21% 自动合成正确的属性测试。那些对于你选择的属性不可见的 Bug 仍然是不可见的。PBT 扩大了你的覆盖范围,但并未将其封闭。

复合系统行为。 单元级别的属性测试——即单个 Prompt 调用——无法揭示从多步组合中产生的问题。四个个体稳定性为 95% 的 LLM 调用,产生的系统级稳定性大约只有 81%。Agent 流水的级联行为需要集成级的属性,而不仅仅是单元级的。这是可以解决的,但需要在流水线级别设计属性,而不仅仅是在单个调用边界。

这两种局限性都表明,应该将 PBT 与其他测试方法结合使用,而不是取代它们。2025 年的一项研究发现——PBT 和基于示例的测试会在不同的 Bug 上失败——这是关键见解:它们是互补的,而非替代品。

从哪里开始

从小规模开始的 ROI(投资回报率)非常强劲。选择你流水线中风险最高的三个 Prompt 调用——即处理最广泛用户输入、涉及最敏感业务逻辑或在生产环境中观察到最大方差的调用。为每一个调用编写一个结构化属性(模式一致性)和一个语义不变性属性(人口统计学或改写不变性)。

在每次 PR 合并时运行 Tier 2 测试。这涵盖了六个属性,每次部署大约增加 300 次额外的 LLM 调用,并且能够可靠地发现你当前的评估集(Eval set)无法察觉的行为漂移。对于任何处理用户提供的命名实体的 Prompt,增加实体替换扫描——这是人口统计学不变性失败最常出现的地方。

50 倍的 Bug 发现倍数不会立即到来。它随着你添加属性以及参数化覆盖范围的扩大以匹配生产流量的实际输入分布而累积。从狭窄的范围开始,积极进行埋点,并让失败报告引导你下一步该扩展到哪里。

你的评估集是你已经探索过的领土的地图。基于属性的测试则是你发现边界之外事物的方式。

References:Let's stay in touch and Follow me for more thoughts and updates