Agent 和业务系统对接 LLM 的主流方式,已经从"让模型说人话、自己写正则抠字段"变成结构化输出:让模型直接返回贴合 JSON Schema 的对象,喂给下游函数调用或数据库写入。问题也集中在这一步------线上偶发的 JSONDecodeError、字段缺失、类型漂移,常常被一句"模型不稳定"糊过去,然后 except Exception: retry 兜底。重试确实能把成功率做上去,但它把成本、延迟,以及"错误数据已经写进库"的风险,全藏到了水面下。
我们给结构化输出做了一套可靠性测试:把 Schema 当契约、把模型输出当被测对象、把失败分桶量化,再叠一层故障注入。核心结论一句话:"能解析"和"语义对"是两件事,只盯 json.loads 成功率,会漏掉最危险的那一类失败。
一、先把"失败"拆成三类,否则成功率没有意义
把结构化输出失败拆成三类,度量才有方向:
- 语法失败:输出不是合法 JSON。截断、多余前言、尾逗号、单引号、markdown 围栏残留。
- 结构失败 :JSON 合法但不符合契约。缺必填字段、类型错(
"1"vs1)、枚举越界、嵌套结构错、多出未知字段。 - 语义失败:完全符合 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, AutoTokenizermodel = 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 应用做免费质量体检,出一份可执行的测评报告(检索命中率、回答忠实度、噪声敏感度等维度),感兴趣可以直接私信我。