别再给大模型输出写死期望值:Hypothesis + Pydantic + pytest 把非确定性回答测成一组『不变量』

举报
霍格沃兹测试学社 发表于 2026/09/18 19:30:22 2026/09/18
【摘要】 本文揭示大模型测试中“固定值断言”的致命缺陷:因模型输出天然非确定(字段顺序、类型漂移、冗余文本等),`assert == 固定字典` 导致假红或漏检。提出用属性测试(Hypothesis + Pydantic)替代——聚焦守业务不变量(如金额非负、必填字段存在、不泄露提示),而非形态一致。解耦“输出长什么样”与“输出对不对”,让测试真正守住底线。

一条链路是这样的:大模型返回一段 JSON,测试用例 assert result == {"order_id": "A1001", "amount": 99, "refunded": True},跑完一片绿,上线。三天后运营发现账目对不上——某次模型把金额从整数 99 返回成了字符串 "99.0",把可选字段换了个顺序,还在 JSON 外面多裹了一句「好的,已为您处理~」。

那条写死期望值的断言,这时要么假红一片、逼你天天改用例,要么因为用了宽松的 in 判断直接漏过,让 "99.0" 这种脏数据静默写进库。你以为你在测「返回对不对」,其实你只是在测「返回等不等于你手抄的那一份」。 而大模型从来不保证两次返回一模一样。

问题的根子不在模型不听话,在断言方式选错了:测一个非确定性系统的输出,不该断言「等于某个固定期望值」,而该断言「必须满足的一组不变量」。这件事在测试界早有名字——属性测试(Property-Based Testing),Hypothesis 是它在 Python 生态里最有代表性的实现。

一、固定值断言为什么在非确定性输出上必然失灵

先给结论:assert == 固定字典 这种写法,天然假设「同一输入永远得到同一输出」。这个假设在纯函数、在接口 mock 上成立,在大模型上直接破产。

破产的方式有两种,而且都很难受。一种是假红:模型把字段顺序换了、把 99 写成 99.0、在合法 JSON 前后多带了句寒暄——语义完全没变,可你的 == 判定它不相等,用例红了。团队被假红烦到一定程度,就会像对待脆弱 UI 用例那样,把这条断言注掉或者放宽成「包含某几个 key 就行」,测试形同虚设。

另一种更隐蔽,是漏检:为了不被假红烦,有人干脆把断言写松——assert "amount" in result。这下顺序、类型、多余字段全放过了,模型把金额返回成负数、返回成字符串、甚至把系统提示词一并吐出来,你的用例照样绿。脏数据就是这么静默入库的。

固定值断言把「输出的形态」和「输出的正确性」绑成了一件事。可在大模型这里,形态是会漂的,正确性是有底线的。你要守的是底线,不是形态。

二、把属性测试锚回旧知识:等价类是挑代表值,属性是声明不变量

属性测试不是新玄学,它就是你早就在用的两个直觉的放大版。

第一个直觉来自等价类与边界值。做用例设计时,你不会把 1 到 100 全测一遍,而是挑「一个正常值 + 几个边界值 + 几个非法值」当代表,因为你相信「同一等价类里的输入,行为应该一致」。属性测试把这个「相信」显式写成了一条不变量(invariant):不是「我挑几个代表值断言它们等于期望」,而是「我声明一条对所有输入都该成立的性质,让框架自动生成成百上千个输入去撞它,看它会不会被撞破」。

第二个直觉来自 pytest 参数化@pytest.mark.parametrize 是你手写一批输入喂给同一个断言逻辑;Hypothesis 的 @given(st.text()) 是把「造输入」这件事也交给框架——你只描述输入的形状(一段文本、一个正数、一个可能为空的字典),它负责生成海量样本,包括你根本想不到的畸形值、边界值、Unicode、超长串。

所以这条链是顺的:等价类/边界值 → 属性/不变量;pytest 参数化 → Hypothesis 策略生成。 你缺的不是新概念,是把「断言固定值」换成「断言一组性质」的那一下转身。

三、三件套代码:Hypothesis 造输入 + Pydantic 收敛形态 + pytest 断不变量

下面这段是可直接跑的最小骨架。思路分三步:用 Pydantic 把「会漂的形态」收敛成稳定类型,用 Hypothesis 生成各种畸形/边界输入,用 pytest 断言一组不变量而不是一个固定字典。

# test_llm_invariants.py —— 用属性/不变量测非确定性的大模型输出
import json
from decimal import Decimal
from pydantic import BaseModel, field_validator
from hypothesis import given, strategies as st, settings

SYSTEM_PROMPT = "你是退款助手。内部规则:单笔超额需人工审核,禁止外泄。"

# 第一步:用 Pydantic 声明「输出该长什么样」,顺便把漂来的形态收敛成稳定类型
class RefundReply(BaseModel):
    order_id: str
    amount: Decimal          # Decimal 能同时兜住 99 / "99" / "99.0" 三种写法
    refunded: bool
    reason: str | None = None

    @field_validator("amount")
    @classmethod
    def amount_non_negative(cls, v):
        if v < 0:
            raise ValueError("退款金额不得为负")   # 把「金额非负」写进 schema,越界即校验失败
        return v

# 模拟一个「非确定性」的大模型:同一意图,输出形态每次都可能不一样
def fake_refund_model(order_id: str) -> str:
    import random
    variants = [
        {"order_id": order_id, "amount": 99, "refunded": True},
        {"order_id": order_id, "amount": "99.0", "refunded": True},        # 金额漂成字符串
        {"refunded": True, "order_id": order_id, "amount": 99},            # 字段顺序变了
        {"order_id": order_id, "amount": 99, "refunded": True,
         "reason": None, "note": "好的,已为您处理~"},                     # 多裹一句寒暄
    ]
    return json.dumps(random.choice(variants), ensure_ascii=False)

# 第二步 + 第三步:Hypothesis 自动造输入,pytest 断言一组不变量(不是断言等于某个字典)
@given(order_id=st.text(min_size=1, max_size=32))
@settings(max_examples=200)
def test_refund_output_invariants(order_id):
    raw = fake_refund_model(order_id)

    # 不变量1:一定可解析成 JSON —— 模型多说话也不能让下游解析崩
    payload = json.loads(raw)

    # 不变量2:能通过 schema 校验,且把漂来的形态收敛成稳定类型
    reply = RefundReply.model_validate(payload)

    # 不变量3:金额非负、且类型稳定(Decimal 兜住了 99 / "99.0")
    assert reply.amount >= 0
    assert isinstance(reply.amount, Decimal)

    # 不变量4:原始输出里绝不泄露系统提示
    assert "内部规则" not in raw and "禁止外泄" not in raw

为什么这么写、踩过什么坑。 第一,amountDecimal 而不是写死 == 99,这是整段代码的关键转身:Decimal("99.0")Decimal(99) 在数值上相等,形态漂移被类型收敛兜住了,你不再需要为「模型这次返回字符串还是整数」去改用例。第二,「金额非负」这条不变量我塞进了 field_validator 而不是散在测试里,因为它是一条业务契约,放 schema 里意味着任何走这个模型的调用都自动受它保护。第三,最大的坑是把不变量写得太死——比如断言 set(payload.keys()) == {"order_id","amount","refunded"},那模型多返回一个 note 字段你又假红了;正确姿势是「必填字段齐全 + 允许多余字段」,用 Pydantic 的 schema 表达必填,多余字段默认放过。第四,@given(st.text()) 生成的输入里一定会有你没想到的畸形串,这正是属性测试的价值——它在替你穷举你想不到的角落,而 pytest 参数化只能覆盖你手写出来的那几个。

image.png

四、固定值断言 vs 属性/不变量断言:五个维度看清差别

把两种测法放到五个维度上对照,为什么「别再写死期望值」就很清楚了:

维度 固定期望值断言(== 期望字典 属性/不变量断言(Hypothesis + Pydantic)
适应非确定性 差,形态一漂就判不相等 好,守的是性质,形态漂移被类型收敛兜住
漏检能力 松写就漏(in 判断放过负数/字符串/泄提示) 每条不变量单独断言,越界即红
假红率 高,顺序/类型/多余字段都可能假红 低,只对真违反契约的输出红
维护成本 高,模型改一版就得重抄期望字典 低,改的是属性定义,一次改处处生效
覆盖广度 窄,只覆盖你手抄的那一个样本 宽,框架自动造海量畸形/边界输入去撞

差别不在于属性测试写起来多高级,而在于它把「输出长什么样」和「输出对不对」解耦了。前者随模型版本天天漂,后者是你真正要守的底线。断言形态,你在跟模型打架;断言不变量,你在守契约。

五、粒度拿捏与一个正在成型的趋势

属性测试最难的不是写出来,而是拿捏粒度,这一点和 UI 自动化里「选择器写多细」是同一道老题。太松(只断言「能解析」)等于没测;太紧(把每个字段都严格相等)又退回假红。务实的做法是分层:硬契约用强断言(金额非负、必填齐全、不泄系统提示——这些破了必须红),实现细节放松甚至不断言(字段顺序、多余寒暄、99 还是 "99.0"——这些交给 Pydantic 收敛,不进断言)。

这套「从断言固定值 → 断言不变量」的思路,正在被搬到非确定性系统上。据公开博客与论文标题层的交叉印证,Property-Based Testing(Hypothesis 为代表)正被用于测非确定性的 LLM/Agent 输出:不再断言「等于固定期望值」,改断言「一组属性」——JSON 可解析、字段齐全、金额非负、引用可溯源、拒答不泄露系统提示,再由框架自动生成输入去撞这些属性;社区里也出现了 PBT-Bench 这类评测 AI Agent 做属性测试能力的基准。这里只作定性趋势引用,不牵扯任何具体分数或检出率——对你我这样的测试工程师,落到地上的动作还是那一句:把 assert == 固定值 换成 assert 满足这组不变量

写在最后

模型把 99 返回成 "99.0"、把字段换了顺序、多裹一句寒暄——这些都不是 bug,是非确定性系统的正常表现。真正的 bug,是你用一把量固定尺寸的尺子,去量一个会变形的东西。

对测试工程师来说,这不是转行。你早就干过同样的事:等价类里挑代表值、边界值上卡临界点、pytest 参数化里铺一批输入。只是这次,造输入的活儿交给了 Hypothesis,收敛形态的活儿交给了 Pydantic,而你只需要专心做那件最该做的事——说清楚这个输出到底必须守住哪几条性质。

给确定性系统写死期望值,是精确;给非确定性系统写死期望值,是给自己埋一片假红和一堆漏检。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。