LLM 结构化输出测试:Schema 契约 + 故障注入,让工具调用的 JSON 不再靠重试赌运气

Agent 和业务系统对接 LLM 的主流方式,已经从"让模型说人话、自己写正则抠字段"变成结构化输出:让模型直接返回贴合 JSON Schema 的对象,喂给下游函数调用或数据库写入。问题也集中在这一步------线上偶发的 JSONDecodeError、字段缺失、类型漂移,常常被一句"模型不稳定"糊过去,然后 except Exception: retry 兜底。重试确实能把成功率做上去,但它把成本、延迟,以及"错误数据已经写进库"的风险,全藏到了水面下。

我们给结构化输出做了一套可靠性测试:把 Schema 当契约、把模型输出当被测对象、把失败分桶量化,再叠一层故障注入。核心结论一句话:"能解析"和"语义对"是两件事,只盯 json.loads 成功率,会漏掉最危险的那一类失败。

一、先把"失败"拆成三类,否则成功率没有意义

把结构化输出失败拆成三类,度量才有方向:

  1. 语法失败:输出不是合法 JSON。截断、多余前言、尾逗号、单引号、markdown 围栏残留。
  2. 结构失败 :JSON 合法但不符合契约。缺必填字段、类型错("1" vs 1)、枚举越界、嵌套结构错、多出未知字段。
  3. 语义失败:完全符合 Schema,但字段值是错的。金额符号反了、日期格式对却归到了错误字段、原文推不出却被"编造"出来的值。

生产监控通常只盯第一类(try/except 捕获解析异常),结构失败被下游类型错误暴露出来,语义失败则被静默写进数据库------三类里危害最大、可见性最低的就是它。测试体系如果不做这个拆分,最后只会得到一个被 retry 粉饰过的"成功率"。

二、Schema 当契约:一份定义,同时驱动校验和测试

关键工程决策:让 Schema 有单点真相。用 pydantic 定义模型,model_json_schema() 导出给模型,model_validate() 做校验------同一个类既是给模型的指令,又是校验器,又是测试断言。这样能避免"文档里的 schema"和"代码里的校验逻辑"两张皮各自漂移。

复制代码
from pydantic import BaseModel, Field
from enum import Enum
from datetime import date

class Priority(str, Enum):
    low = "low"
    normal = "normal"
    high = "high"

class ExtractTicket(BaseModel):
    ticket_id: str = Field(pattern=r"^T-\d{6}$")
    amount: float = Field(gt=0, description="金额,单位元,必须为正数")
    due_date: date = Field(description="ISO-8601 日期,如 2026-09-11")
    priority: Priority
    tags: list[str] = Field(default_factory=list, max_length=5)

# 同一份契约,两个用途:
schema_for_model = ExtractTicket.model_json_schema()   # 传给模型
# result = ExtractTicket.model_validate(raw_obj)        # 校验/测试断言

一个容易被忽略的细节:把约束写进 Field(description=...),它会随 model_json_schema() 一起传给模型,等于把"字段说明的提示词"和"校验规则"合并到一处。模型看到什么约束,校验器就卡什么约束,永远同源。

三、约束解码:从"求模型配合"到"结构上不可能违规"

prompt-only 让模型"尽量输出 JSON",本质是概率约束,长尾必然出语法错。想要更硬的保障,有两档手段:

  • 服务端结构化输出 (如 response_format={"type": "json_schema", ...}):服务端在采样阶段按 Schema 生成语法,非法 token 在解码时被掩码,语法失败率趋近于 0。注意它只保证语法与结构,不保证语义------字段值仍然可能错。

  • 本地约束解码(outlines / llguidance / xgrammar):把 Schema 编译成 FSM 或正则,在 logits 上做 mask,适合离线跑回归、不依赖外部接口。

    import outlines
    from transformers import AutoModelForCausalLM, AutoTokenizer

    model = outlines.from_transformers(
    AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-7B-Instruct"),
    AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct"),
    )

    用 pydantic 模型直接约束解码:返回值保证符合 ExtractTicket 结构

    result = model(extract_prompt, ExtractTicket)

约束解码的内部机制值得说清楚:Schema 会被编译成一个有限状态机(或等价的正则),每个解码步只允许那些能让 FSM "仍有可能走到合法终态"的 token 通过,其余 token 的 logit 被置为 -inf。比如 schema 要求 "priority" 之后必须是枚举值,那么在这一步,"urgent""normalx" 这类 token 直接被掩掉,模型物理上产不出越界枚举。代价是每步都要做一次状态迁移和掩码计算,长 Schema 会有可观开销,这也是本地方案里 xgrammar 一类"提前编译语法缓存"工具存在的原因。

这里有个认知必须掰正:约束解码把失败面从"语法 + 结构 + 语义"压缩到"仅有语义",不是把测试省掉了,而是让测试该聚焦的地方变清晰了。既然语法和结构由解码器兜底,测试资源就应该全压到语义断言上。

四、故障注入:主动造坏包,测的是链路不是模型的乖

只测"模型正常输出"覆盖不到边缘。正确做法是把"模型可能产出的坏 JSON"预先造出来,注入解析 + 校验 + 修复链路,验证下游到底是"拒绝并标记",还是"静默吞下"。这一层被测对象是校验/修复层,不依赖模型本身,因此确定性、可进 CI。

故障类型 构造样例 期望行为
截断 {"amount": 12, "due_d 拒收 + 触发可重试路径
前言包裹 Here you go: {"ticket_id": ...} 清洗后可解析,或拒收
类型漂移 "amount": "12.5" 拒收或显式强制转换(需配置)
未知字段 多出 "note": "..." 默认禁止(防注入夹带)
枚举越界 "priority": "urgent" 拒收 + 告警,不静默降级
NaN / Infinity "amount": NaN 拒收(Python 默认会接受,很危险)
复制代码
import pytest
from pydantic import ValidationError
from extract_test import ExtractTicket, parse_with_guard  # 清洗 + 校验 + 修复

BAD_PAYLOADS = [
    ('{"amount": 12, "due_d', 'truncated'),
    ('Here you go: {"ticket_id": "T-000123", "amount": 10}', 'prose_wrapper'),
    ('{"ticket_id": "T-000123", "amount": "12.5", "due_date": "2026-09-11", '
     '"priority": "high"}', 'type_drift'),
    ('{"ticket_id": "T-000123", "amount": NaN, "due_date": "2026-09-11", '
     '"priority": "high"}', 'nan'),
]

@pytest.mark.parametrize("raw,kind", BAD_PAYLOADS)
def test_guard_never_silently_passes(raw, kind):
    result = parse_with_guard(raw)          # 硬约束:不允许抛出未捕获异常
    if kind in ("truncated", "nan"):
        assert result.rejected, f"{kind} 必须被拒收,不能静默通过"
    else:
        assert result.ok and ExtractTicket.model_validate(result.value)

再叠一层 hypothesis 做属性测试:随机生成超长字符串、emoji 与控制字符、深嵌套对象,断言不变量------要么通过校验、要么显式 rejected,绝不允许半成品流向下游

修复层本身也要能被单测。它跟重试的区别在于:重试是把同样的输入再问一遍模型,修复是先清洗、再带着校验错误信息回问一次,且只有可重试类错误才准回问

复制代码
RETRYABLE = {"truncated", "syntax"}

def parse_with_guard(raw: str, max_repair: int = 1):
    cleaned = strip_fences_and_prose(raw)        # 去围栏、去前言、补尾括号
    try:
        return Ok(ExtractTicket.model_validate_json(cleaned))
    except (json.JSONDecodeError, ValidationError) as e:
        kind = classify(e)                        # 语法/截断/结构/语义 分桶
        if kind not in RETRYABLE or max_repair == 0:
            return Rejected(kind=kind, raw=raw)   # 语义错直接拒收,不空转重试
        repaired = reask_model(cleaned, error=str(e))   # 把校验错误回喂给模型
        return parse_with_guard(repaired, max_repair - 1)

这段逻辑的意义不在代码本身,而在它把"重试"这个动作收进了一个有边界的、可测试的函数里。classify() 的分桶质量,直接决定了线上是"优雅拒收"还是"无限空转烧钱"。

五、四档策略对照:数字说明"重试"掩盖了什么

2000 条真实抽取请求、固定两个模型、同一套 Schema,人工抽检 300 条判语义:

策略 语法失败率 结构失败率 语义错误率 P95 延迟 每千次调用成本
prompt-only(尽量输出 JSON) 1.8% 3.1% --- 1.0x 1.0x
JSON mode(仅语法约束) 0.02% 2.7% --- 1.0x 1.0x
结构化输出 json_schema 0.00% 0.4% 4.2% 1.05x 1.05x
结构化输出 + 校验修复层 0.00% 0.0%(拒收/修复) 4.1% 1.08x 1.09x

三个结论直接改了我们的监控口径:

1)JSON mode 只压语法,结构失败几乎不动。 语法和结构是两个独立失败面。不少团队以为开了 JSON mode 就万事大吉,实际上 2.7% 的结构失败一条没少------它们会以"字段缺失/类型错"的形式在下游炸出来,比语法错更难定位。

2)结构化输出把结构失败压到 0.4%,剩的是 schema 覆盖不到的边角。 那 0.4% 主要来自"语义化约束"------比如 max_length 是长度约束而非值约束,模型仍可能给越界的长文本;这类只能靠校验层补齐。

3)语义错误率 ~4% 不随解码策略变化。 从 prompt-only 到约束解码,语义错误率纹丝不动。含义很直接:解码约束管不了语义,语义质量只能靠带 golden 值的评测集 + LLM-as-Judge 或规则断言覆盖。 把预算全花在升级解码器上,对"答错"这件事几乎无收益。

六、踩坑记录

  • Python 的 json.loads 默认接受 NaN / Infinity 含这些字面量的输出"解析成功"却会把 float('nan') 污染进下游。必须显式 json.loads(s, parse_constant=...) 拦截,或在校验层直接拒收------这是最隐蔽的一类漏网。
  • 只测模型输出、不测修复层。 约束解码下模型几乎不产坏包,但网络截断、代理污染、上游拼错 prompt 一样会喂进坏 JSON。故障注入测的是链路鲁棒性,不是模型的乖。
  • 未知字段必须默认禁止(extra="forbid")。 否则攻击者可以借检索到的文档,把指令塞进未知字段,下游一旦透传就是间接提示注入的入口。禁止未知字段既是契约收紧,也是一道安全边界。
  • 枚举用 str Enum,越界值要拒绝并告警,不要静默降级。 静默落到默认值会掩盖 prompt 退化------线上枚举越界率抬头,往往意味着模型版本或提示词已经悄悄变了。
  • 重试要区分"可重试"和"不可重试"。 语法/截断类可以重试收敛;语义错误重试永远不收敛(同样输入稳定产出同样的错值)。无限 except + retry 只会在语义错误上空转,把延迟和成本一起烧掉。
  • 别指望 temperature=0 救语义。 它能降语法抖动,但服务端已经做了结构化约束后,温度对结构失败影响很小,语义错误基本不变。

七、收尾

结构化输出的质量要拆成语法 / 结构 / 语义三层,分别用约束解码、Schema 校验、评测集三道闸门去管,再用故障注入压链路的鲁棒性。这套做法本身不复杂,难的是把"能解析 = 成功"的惯性思维改掉------重试只是把失败藏起来,不是把失败消掉。 进阶方向两个:一是把语义错误率做成带 golden 值的断言集,进 CI 当回归门禁;二是把 Schema 版本纳入契约测试,Schema 一变更就自动回放历史坏包,防止"改个字段名悄悄破坏下游"。


如果你也在做 AI 应用(RAG / Agent / LLM),不知道质量怎么测------我最近在给 AI 应用做免费质量体检,出一份可执行的测评报告(检索命中率、回答忠实度、噪声敏感度等维度),感兴趣可以直接私信我。

相关推荐
阿里云大数据AI技术1 小时前
淘宝直播 AI 分身:基于阿里云 Milvus 的商品知识召回实践
人工智能
猎头南楼1 小时前
大模型后训练与 Agent 自迭代:两类工程能力的观察
人工智能·深度学习·机器学习
镜像视界(浙江)科技有限公司1 小时前
《视频孪生之上:二维展示终结,三维空间计算重构城市逻辑》——跨摄像连续表达 × 三角测量厘米级定位 × 动态轨迹建模,构建新一代城市空间
大数据·人工智能·算法·矩阵·音视频·空间计算
你不是我我1 小时前
【AI 测评】想用自己的声音给视频配音?Index-TTS本地部署这样做
人工智能·音视频
IT_陈寒1 小时前
Java Stream处理大集合,我的内存怎么就炸了
前端·人工智能·后端
Captaincc2 小时前
AI 用量桌面端-桌面宠物自定义指南
前端·人工智能
悟空码字2 小时前
四轮对话两张配图:用 WorkBuddy 优化公众号发文配图的实战指南
人工智能·后端·腾讯
wordbaby2 小时前
「搜索引擎与知识检索」第四篇:超越扁平文本:知识的组织与检索
人工智能
空堂与归2 小时前
deepseekHarness桌面端不开端口:Electron 自定义协议拆解
人工智能