跳到主要内容

LLM 系统的基于属性的测试:即便输出多变也需遵循的不变量

· 阅读需 14 分钟
Tian Pan
Software Engineer

一家金融科技公司的产品团队发布了一款基于大语言模型 (LLM) 的文档摘要生成器。他们的评估数据集包含 200 个经过人工筛选并附带人工评分的示例,质量得分达到 87%。在生产环境中,当用户上传短备忘录时,系统偶尔会返回比原始文档更长的摘要。评估数据集中没有任何篇幅少于 300 字的备忘录。而“对于摘要任务,输出长度 ≤ 输入长度”这一属性从未被测试过。直到一位客户截下了这个荒唐的界面并将其发布到网上,才有人察觉到这个问题。

这就是属性测试 (Property-Based Testing, PBT) 所填补的核心空白。评估数据集衡量的是你“想到的”测试场景中的准确性。而属性测试衡量的则是,在所有可能发生的情况中,你的系统是否始终遵守了预定义的契约。

什么是属性测试,以及它为何与众不同

基于示例的测试(软件工程中的主流范式)通过断言“给定输入 X,输出应为 Y”来工作。你刻意挑选输入——典型案例、边缘案例、边界值——并手动编写预期输出或从标记数据中筛选。对于确定性代码,这种方法非常有效。但对于 LLM 系统,它在两个方面失效了:输出空间是无限的自然语言,且函数本身具有概率性。

属性测试颠倒了这种方法。你不再指定“输入-输出”对,而是指定不变量 (Invariants)——即对于任何有效输入都必须成立的结构或语义属性。随后,框架会自动生成成百上千个输入,并检查该属性是否在所有输入中均成立。

传统软件中的经典示例:与其测试 sort([3,1,2]) == [1,2,3],不如测试“输出始终是非递减的,且包含与输入相同的元素”这一属性。无论你输入什么列表,该属性都应当成立。Hypothesis (Python)、fast-check (JavaScript/TypeScript) 和 PropEr (Erlang) 是这类方法的代表性工具。它们生成随机输入,发现失败案例,然后自动将失败案例缩减 (Shrink) 为最小形式,从而使 Bug 易于诊断。

这种思维转变意义重大。编写优秀的属性需要你思考“系统必须始终保证什么”,而不仅仅是“它通常产生什么”。对于 LLM 而言,这比听起来更容易实现,因为即使对于输出具有非确定性的系统,也存在几类依然成立的不变量。

适用于 LLM 的不变量

长度边界。 这是最机械的不变量,但在生产环境中经常被违反。摘要生成器产生的输出应短于输入(允许一定的容差)。推文生成器应保持在 280 个字符以内。要求“最多五个要点”的结构化提取提示词永远不应返回六个要点。这些属性不需要对质量进行主观判断——它们是二元的、可测试的,并且经常在评估数据集未覆盖的长尾输入中失效。在数千个不同长度的生成输入中测试这些属性,可以捕捉到截断 Bug、Token 限制边界情况,以及人工筛选评估容易忽略的提示词指令失效。

模式一致性 (Schema Conformance)。 当你的 LLM 被要求返回结构化输出(如 JSON、YAML 或特定的对象形状)时,模式一致性就是一个不变量。它必须对每个输入都成立,而不仅仅是评估集中的 50 个示例。虽然约束解码(通过 Outlines、Guidance 或 OpenAI 的结构化输出模式)有所帮助,但模式一致性测试能揭示更多边缘情况:深度嵌套的模式、大型枚举字段、跨字段约束,以及那些会导致模型产生破坏 JSON 结构的冗长套话的提示词。关于 JSONSchemaBench 的研究发现,不同的约束解码引擎在不同的模式模式下会失效——在开发环境中通过的测试,在不同的部署环境中可能会超时或失败。

语义单调性。 这是一个更微妙但强大的不变量:如果系统中更相关的上下文应该产生更好(或至少不更差)的输出,那么就可以对其进行单调退化测试。检索到五个相关文档的 RAG 系统应该比只检索到三个文档的系统提供更准确的答案。包含完整问题的分类提示词应该优于截断版本。你无法断言确切的输出,但你可以使用嵌入相似度或 LLM 即评审员 (LLM-as-judge) 的评估方式来断言方向性,即:得分(更多上下文)≥ 得分(较少上下文)。违反此属性的行为通常会揭示上下文集成 Bug、检索失败以及无意中抑制有用信号的提示词模板。

改写下的幂等性(蜕变测试)。 这可能是捕捉幻觉和推理失败最强大的属性类别。其不变量是:语义等价的输入应产生语义等价的输出。“法国的首都是哪里?”和“请写出法国首都城市的名称”都应返回巴黎,而不是一个返回巴黎,另一个因为识别到表面略有不同的措辞而返回里昂。这是 LLM 蜕变测试 (Metamorphic Testing) 的基础,该技术最近催生了一些严密的幻觉检测框架。MetaQA (ACM 2025) 利用 LLM 回复的同义词和反义词变异来验证事实一致性。如果系统断言“棒球在日本很流行”,那么模型也应验证其反义词“棒球在日本不流行”为假。违反此类属性会暴露事实冲突的幻觉,而基于重复采样的等一致性方法往往会完全遗漏这些问题。

针对 Drowzee (OOPSLA 2024) 的研究进一步扩展了这一思路,利用从维基百科知识库导出的蜕变关系构建了一个事实冲突幻觉检测器,从而捕捉到那些模型自我一致性检查反而强化(而非纠正)了的幻觉。

内容安全属性。 该不变量是:在定义的输入空间内,任何提示词都不应诱导包含毒性内容、个人可枚举信息 (PII) 泄露或违反政策材料的回复。这很自然地映射到属性测试——框架生成多样化的输入(包括对抗性变异),并由安全分类器检查输出。核心见解在于安全属性是布尔值:输出要么违反约束,要么不违反。属性测试可以针对数千个生成的输入运行一夜,从而捕捉到由于覆盖范围不足而导致人工红队测试数据集可能遗漏的安全漏洞。

智能体系统 (Agentic Systems) 中的结构不变量。 当 LLM 被用作编排器——调用工具、生成计划、生成结构化论证时,存在一些超出输出内容范畴的不变量。计划应当是无环的。工具参数应当符合模式验证。标记为幂等的动作应当确实具有幂等性。2026 年记录的一次生产故障涉及一个多步骤智能体,其中截断的工具输出导致了不完整的文件写入;由于该智能体缺乏“在继续操作前断言工具调用结果完整性”的属性,这种静默失败演变成了一场长达 20 小时的调试马拉松。

工具与框架:如何选择

Hypothesis (Python) 仍然是这项工作中最成熟的工具。它的状态测试能力 (RuleBasedStateMachine) 让你能够将多轮 LLM 交互建模为状态机,生成一系列操作序列并验证在整个交互过程中(而不只是单轮对话)不变量是否成立。@settings(max_examples=1000) 装饰器可以扩展测试覆盖范围,适合在 CI 中进行夜间运行。

fast-check (TypeScript/JavaScript) 是构建在 Node.js 上的 LLM 应用(常见于全栈 AI 产品)的理想工具。它的任意组合性(Arbitrary composability)使得生成结构化提示词、用户角色或文档输入作为 LLM 封装函数的类型化输入变得非常直观。

PropEr (Erlang/Elixir) 在 AI 应用中较少见,但对于基于 BEAM 构建的系统(特别是测试 Phoenix 应用中的对话状态机)非常值得了解。

对于需要语义评估的 LLM 特定不变量,你通常会将 PBT 框架与评估器结合使用:例如用于嵌入相似度的 sentence-transformer 模型、基于规则的安全分类器或 LLM-as-judge。PBT 框架负责输入生成和缩减(Shrinking);评估器则产生框架所需的布尔型通过/失败信号。

DeepEval 与 pytest 集成,并以可调用函数的形式提供 14 种以上的评估指标(幻觉、毒性、答案相关性、架构正确性)——这意味着它们可以在 Hypothesis 测试中用作属性检查器。这种组合可以在无需从头构建评估基础设施的情况下,实现语义属性测试。

为什么这不同于评估数据集(Eval Datasets)

这种区别在实践中非常重要。评估数据集回答的是“我的系统在代表性样本上的表现如何?”而基于属性的测试回答的是“我的系统是否曾违反过合约?”这是两个不同的问题,混淆它们的团队往往会发布评估分数很高但带有 Bug 的产品。

评估数据集是静态的。一个包含 500 个示例的数据集无论经过多么精心的策划,也只能覆盖 500 个输入。基于属性的测试则持续探索输入空间,生成测试作者从未预料到的案例。来自 HumanEval 属性测试的研究非常具有启发性:PBT 和基于示例的测试各自独立发现了 68.75% 的 Bug,但两者结合则发现了 81.25%。每种方法发现的 Bug 是 不同的 —— PBT 发现了与性能相关的故障和结构化输入中的边界情况;基于示例的测试(EBT)则发现了需要精确示例的特定边界条件。两者互不替代。

评估数据集需要标记期望输出。对于复杂任务,这需要昂贵的人工标注或模型对齐(Model-as-judge alignment)。基于属性的测试仅需要一个可验证的不变量 —— 这通常可以自动化而无需人工标注。对于许多属性(长度限制、架构一致性、内容安全),检查器是确定性的,且在大规模运行时成本低廉。

评估数据集会过时。当你的模型改变、提示词改变或输入分布发生偏移时,如果数据集不能反映新的分布,评估分数就会变得具有误导性。属性测试与分布无关 —— 它们测试的是合约而非示例,因此在不同模型版本之间依然有效。

实际的工作流程是:使用评估数据集来评估质量并比较模型版本;使用属性测试来执行合约并捕捉回归缺陷。

失败模式与虚假信心

属性测试也有其失败模式,在押注它们之前有必要先了解这些模式。

弱属性会带来虚假信心。 “输出非空”这一属性对于几乎任何 LLM 的几乎任何输入都会通过。这在技术上算作属性测试通过,但几乎抓不住任何有用的东西。编写有用的属性需要领域知识 —— 你必须知道你的系统必须保证什么,而不仅仅是它倾向于产生什么。

覆盖范围缺口是不可见的。 属性测试探索的是输入空间的采样切片,而非全部。通过 1000 个生成的输入,发生率低于 0.1% 的罕见失败模式可能不会出现。统计采样并不能像针对已知边界情况的手动测试用例选择那样保证覆盖率。

评估器会引入自身的错误。 当你的属性检查器是一个语义相似性函数或 LLM-as-judge 时,评估器中的误报和漏报会传播到测试结果中。一个遗漏了某些毒性输出的安全分类器会给你虚假的信心。在信任评估器作为属性检查器之前,先在已知示例上对其进行校准。

LLM 测试的缩减(Shrinking)很难。 Hypothesis 最有价值的功能之一是自动缩减 —— 当它发现一个失败的输入时,它会寻找仍会导致失败的 最小 输入。对于“输入”是文档或对话的 LLM 测试,缩减的意义较小,最小失败案例可能无法提供启发。

成本。 每个测试函数运行 1000 次 LLM 调用是昂贵的。实际的缓解措施:在 CI 中运行廉价的属性测试(架构、长度、安全分类器),并在夜间计划或每次发布前运行昂贵的语义属性测试(LLM-as-judge 评估)。

属性测试能发现哪些评估数据集无法捕捉的问题

研究证据和生产经验得出了一个一致的结论:

评估数据集可以捕捉已知任务类型的质量回退。属性测试则能捕捉长尾输入中的契约违反(contract violations)。属性测试特别能发现的故障模式包括:

  • 输入长度敏感性漏洞:当输入非常短、非常长或包含异常的 token 分布时,提示词(prompts)会失效 —— 这些输入与经过精心挑选的评估示例完全不同。
  • 改写后的指令遵循失败:模型在处理系统提示词中的精确措辞时能够遵循“仅返回 JSON”,但当用户添加了一个礼貌的前缀,稍微改变了实际指令时,模型就会失败。
  • 幻觉一致性失败:MetaQA 方法表明,许多幻觉的产生是不一致的 —— 模型会在一种措辞中断言一个错误事实,但在另一种措辞中又正确地予以否认。使用单一提示词的评估数据集永远无法发现这一点。
  • 分布偏移下的安全绕过:在经过精心挑选的对抗性提示词上进行评估的安全分类器和护栏(guardrails),会漏掉属性化生成(property-based generation)偶然发现的新型越狱。
  • 约束压力下的 Schema 失败:面向 LLM 的大型 Schema 如果包含许多跨字段约束,只有在针对数百种不同输入形态进行测试时才会暴露问题 —— 而不是评估集中的那 20 个例子。

无需重写测试套件即可入门

实际的切入点是针对你的应用程序选择两到三个特定的不变量(invariants),并仅为这些不变量编写属性测试。从验证成本最低的开始:输出长度限制和 Schema 符合性不需要调用 LLM 即可评估。使用现成的分类器添加内容安全检查。然后,如果你的应用程序有明确的语义单调性(semantic monotonicity)预期(如检索系统、摘要生成器),则添加基于阈值的语义属性测试。

来自研究文献和生产实践的关键洞察是:针对 LLM 的属性测试并不是要取代工程师对测试内容的专业判断,而是该判断的力量倍增器(force multiplier)。你仍然需要识别不变量。然后,框架会详尽地探索这些不变量是否成立。

在生产环境中捕捉到最多 Bug 的系统,是将 LLM 视为具有契约义务的软件组件,而不是仅通过采样进行评估的黑盒。属性测试就是你写下这些契约并自动执行的方式。


提到的框架:Hypothesis (Python), fast-check (JavaScript/TypeScript), PropEr (Erlang/Elixir), DeepEval (Python, LLM 专用指标)。

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