提出将 Schema 测试分为两个方向:消费端验证(免费、快速、穷举)和生成端验证(付费、选点);用属性测试检测 nullable 字段误解等典型错误,成本效益最优。
JSON Schema 夹在模型和消费它的代码之间,这一对组合有两种独立的失败方式:消费方处理了 schema 所允许但代码不该处理的值,或者模型无法产出 schema 所要求的值。前者只需免费测试,后者才需要付费调用,而大多数测试套件只测后者。
两个方向,同一个 Schema
方向一从 schema 出发,经由实例到达消费方,途中不经过任何模型。它回答的问题是:我的代码能处理这个 schema 所允许的每一个值吗?
方向二从输入出发,经由模型、实例到达校验器。它回答的问题是:模型真的能在我的流量上满足这个 schema 吗?
将两者区分开来的意义在于,它们有着截然相反的经济账。方向一免费、快速、接近穷举,所以在单元测试时间里跑上几千个例子不成问题。方向二每条例子都要一次调用,所以只跑二十条,而且要精心挑选输入。把两者混为一谈的测试套件,最终往往花着模型的价格,却发现只是一个可空字段被解引用了。
Schema 还在做一件两个方向都没有测试的事,值得在此命名,以免你在这里徒劳寻找:schema 同时也是 prompt 的一部分。它的字段名和描述会被模型读取,所以把 reason 改名为 justification 会改变输出,尽管校验逻辑什么都没变。这就使得 schema 成为了一种带版本管理的产物,和 prompt 本身有着同样的维护要求——这也正是两者应当一同过审的原因。
从 Schema 生成实例
hypothesis-jsonschema 正是为这件事存在的。它的公开接口本质上只有一个函数:from_schema,接收一个 schema 并返回一个 Hypothesis strategy,用于生成满足该 schema 的值。
from hypothesis import given, settings
from hypothesis_jsonschema import from_schema
SCHEMA = {
"type": "object",
"required": ["decision", "amount_cents", "currency"],
"additionalProperties": False,
"properties": {
"decision": {"enum": ["approve", "review", "decline"]},
"amount_cents": {"type": "integer", "minimum": 0},
"currency": {"enum": ["EUR", "USD", "GBP", "JPY"]},
"reason": {"type": ["string", "null"], "maxLength": 200},
"evidence_ids": {"type": "array", "items": {"type": "string"}},
},
}
@settings(max_examples=500)
@given(from_schema(SCHEMA))
def test_consumer_handles_everything_the_schema_allows(instance):
result = apply_refund_decision(instance) # no model call
assert result.status in {"queued", "held", "rejected"}
这种方法找到的是一类特定且极其常见的 bug:schema 将 reason 定义为 string-or-null,而消费方直接调用了 .strip()。或者 evidence_ids 没有 minItems 约束,所以空数组是合法的,但消费方直接索引了第零个元素。模型很可能从未返回过这两种情况——直到某一天它返回了,而失败发生在线上而不是测试套件里。
hypothesis-jsonschema 针对的是较旧的 JSON Schema 版本而非最新版,其 README 标明了支持情况;在假设某个 2020-12 关键字被支持之前先查一下。这也说明了为什么你生成的 schema 应该保守——你链条上每个工具都支持的 JSON Schema 子集,比规范本身要小得多。
生成会击破它的输入
第二个方向生成 prompt 输入并校验返回的内容。真正有意思的工程在于选择那些有理由破坏结构化输出的输入形状,而不是均匀采样:
包含输出格式定界符的纯文本。客户备注里带着一个大括号、引号、反斜杠或代码围栏。没有约束解码的情况下,这是单次收益最高的输入类型。
目标 schema 的 enum 中没有的语言所写的文本。日语文本配英文 enum,往往会产生一个被翻译过的 enum 值——这是合法 JSON,但不是合法实例。
诚实的回答应该是"信息不足"而 schema 无法表达这一点的输入。这是最重要的一种,其发现的是设计 bug 而非模型 bug:一个必需的 decision enum 没有 insufficient_data 成员,迫使模型在三选一中瞎编一个。修复在 schema 端。
处于数值约束边界的输入——minimum: 1 对应零金额,刚好等于 maxLength 的备注。生成器能触达这些,手写的例子不会。
会将 schema 推向截断边界的超长输入。因输出 token 限制被截断的响应是无效 JSON,值得单独对 finish reason 作断言,这样这类失败会被打上标签,而不是被报告为解析错误。
校验,以及失败时应该断言什么
使用真正的校验器,而不是 json.loads 的 try/except 包装。Python 的 jsonschema 包提供了 iter_errors,它返回每一条违规而非仅返回第一条,且每个错误都携带一个指向违规位置的 json_path。这个差异决定了失败信息是否可操作。
from jsonschema import Draft202012Validator
VALIDATOR = Draft202012Validator(SCHEMA)
@settings(max_examples=20, deadline=None)
@given(refund_requests())
def test_model_output_validates(request):
raw = extract_decision(request) # one model call, parsed JSON
errors = sorted(VALIDATOR.iter_errors(raw), key=lambda e: e.json_path)
assert not errors, "schema violations:\n" + "\n".join(
f" {e.json_path}: {e.message}" for e in errors
)
关于整个练习有一点需要提醒。如果你的 provider 支持严格结构化输出——即根据 schema 做约束解码,而不是指令它"请返回 JSON"——那么结构化断言就变得毫无意义(它们总是成立),不再带来价值。这是一个好的结果,但也意味着测试必须迁移:要去断言跨字段一致性(decline 决策必须携带非空 reason)、出处(每个 evidence_ids 条目都在检索集合中)以及 enum 选择的语义——这些都不是约束解码能触及的东西。理解两种 provider 模式的区别是值得的——参见 JSON mode versus structured outputs and testing structured output。
同一个 schema 在不同 provider 上行为不同:有的在解码时强制执行,有的将其视为强烈提示,有的只接受 JSON Schema 关键字的子集并静默忽略其余。如果你跨 provider 路由,那就意味着一套测试、四种失败形态,有意义的做法是把它们作为一套来跑,看看 schema 在哪里真正被执行了。像 Multigrid 这样的网关正是这样一个接缝,它使 model id 成为测试参数而非代码路径。
pip install hypothesis hypothesis-jsonschema jsonschema pytest
把 schema 放在一个文件里,然后同时导入到 client 和测试中。一个 schema 存在两份的话,不出一个月它们就会产生差异,而测试所校验的是模型并未被给予的那份副本。
先写方向一的测试,max_examples 设高一些。它几秒内跑完,而且一定会有发现。
方向二的测试用 max_examples=20、deadline=None 来写,输入策略要刻意包含上述形状,而不是均匀采样的文本。
当方向二失败时,先判断是 schema 有错还是模型有错。如果 schema 无法为那个失败的输入表达正确答案,就修 schema;加个重试让模型猜得更用力是错误的修复方式,而且同样的输入到达线上之前它会一直 failure。
Writing Invariants for LLM Output You Can Actually Test
Property-Based Testing for LLM Output With Hypothesis
Snapshot Testing Structured JSON Output From a Model