线上有一条链路,把大模型返回的 JSON 用 json.loads 解开,直接喂给下游服务写库。它稳定跑了几个月,直到某天凌晨告警:一批请求集中抛 JSONDecodeError。回捞日志才发现,模型没换、prompt 也只微调了一句措辞,但它在 JSON 前面多输出了一句「好的,以下是结果:」——就这一句寒暄,让 json.loads 当场崩了。
更阴险的是同一天的另一批:模型没多说话,却把一个本该是整数的 amount 字段返回成了字符串 "120"。下游没做严格校验,用默认值把这条记录兜住写进了库。没有异常、没有告警,脏数据静默沉底,两周后对账才对不上。
这两种情况,功能测试当时都是全绿的。原因很简单:功能测试测的是「答得对不对」,没人测「输出格式契约稳不稳」。本篇要解决的,就是把结构化输出当成一份接口契约来测。
大模型的结构化输出不是「一段能解析的字符串」,而是一份会漂移的接口契约——契约就得校验,校验就得上门禁。
一、把结构化输出当接口契约,而不是当字符串
做接口测试的人对这个场景很熟:上下游约定好一个 schema,字段名、类型、必填项白纸黑字,任何一方偷偷改字段,契约测试立刻红。大模型输出本质上就是一个「不太守规矩的上游服务」——它大多数时候按约定返回 JSON,但会在你最想不到的时候多一句话、少一个 key、把 int 写成 str。
裸 json.loads 的问题在于,它只回答「这段文本是不是合法 JSON」,不回答「这段 JSON 符不符合我的契约」。合法 JSON 里完全可能缺字段、类型漂移、多出一堆你没定义的键。而 json.loads 对这些统统放行,把风险原封不动甩给下游。
工具选型上有两条常见路线。JSON Schema 是一份与语言无关的声明式规范,适合契约要跨服务、跨语言共享,或者要直接塞进模型 structured output 参数里约束生成的场景;pydantic 则是 Python 侧的运行时校验,天然和数据类、类型提示长在一起,适合校验逻辑就落在 Python 服务里、还要顺手做业务规则(比如金额必须为正)的场景。两者不是对立的:很多团队用 JSON Schema 对外声明契约、对内用 pydantic 落地校验。本篇代码走 pydantic v2 这条,因为下游就是 Python 服务,且我们需要在类型之外再加一条业务约束——这跟你写接口测试时「既要校验字段类型、又要断言业务规则」是同一件事。
正确的姿势是:先用 pydantic / JSON Schema 把输出契约显式定义出来,再对每一次响应做校验。校验不过,就判这次响应不合规——要么重试、要么拦截、要么告警,绝不让它带着脏数据往下走。下面这张表把两种做法摊开对照:
| 维度 | 裸 json.loads 直接信任模型输出 | JSON Schema / pydantic 契约校验 |
|---|---|---|
| 异常暴露时机 | 多在下游用到字段时才炸,甚至不炸 | 在解析入口当场暴露,责任边界清晰 |
| 脏数据入库风险 | 类型漂移被默认值兜住,静默入库 | 类型不符直接判不合规,拦在入库前 |
| 字段漂移可检测 | 缺字段/多字段无感,事后对账才发现 | 缺必填、多未知字段都能显式捕获 |
| 可回归性 | 「当时能跑」,无法复现畸形场景 | 畸形样本入库,每次改动都能重跑 |
结论很直接:json.loads 只是「能不能解析」,契约校验才是「符不符合约定」。这两件事,测试要分开守。
二、用 pydantic v2 定义契约并校验原始响应
先看契约定义和对模型原始响应的提取、校验。真实场景里模型经常带前后缀,所以要先做一步稳健的 JSON 提取,再交给 pydantic:
# contract.py —— 结构化输出契约与校验(依赖 pydantic v2)
import json
import re
from pydantic import BaseModel, ValidationError, ConfigDict, field_validator
class RefundResult(BaseModel):
"""下游服务依赖的输出契约:字段名、类型、必填项写死。"""
# extra="forbid":模型多吐未定义字段也判不合规,防止契约悄悄膨胀
model_config = ConfigDict(extra="forbid")
order_id: str
amount: int # 必须是整数,模型返回 "120" 会被拒绝
approved: bool
reason: str | None = None # 可空,但类型仍受约束
@field_validator("amount")
@classmethod
def amount_positive(cls, v: int) -> int:
if v <= 0:
raise ValueError("amount 必须为正整数")
return v
_JSON_BLOCK = re.compile(r"\{.*\}", re.DOTALL)
def extract_json(raw: str) -> str:
"""从模型原始输出里抠出 JSON 主体,容忍前后缀寒暄。"""
m = _JSON_BLOCK.search(raw or "")
if not m:
raise ValueError("响应中未找到 JSON 主体")
return m.group(0)
def validate_response(raw: str) -> RefundResult:
"""提取 → 解析 → 契约校验,任一环节失败都抛异常,绝不静默兜底。"""
payload = json.loads(extract_json(raw)) # 语法层:是不是合法 JSON
return RefundResult.model_validate(payload) # 契约层:符不符合约定
为什么这么写。第一,extract_json 用正则抠 {...} 主体,是为了容忍「好的,以下是结果:」这类前后缀——但请注意,提取只是抢救语法,契约校验才是主角,两者分层写清楚,出问题才知道是「解析崩了」还是「格式违约了」。第二,amount: int 配合 pydantic v2 的严格模式,会把字符串 "120" 直接判为类型不符,堵死「静默入库」那条路;这里我没开 strict=True 全局,而是靠显式类型加 field_validator,是因为业务里有时确实希望 "120" 被宽容转成 120——到底宽容还是严格,本身就是一条要跟下游确认的契约决策,别默认。第三,extra="forbid" 很关键:模型哪天多吐一个你没定义的字段,与其让它悄悄漂进下游,不如当场红给你看。踩过的坑是,早期图省事只写 json.loads,结果类型漂移全被 .get(key, default) 兜住,脏数据入库两周才发现,从那以后契约里凡是「必填 + 有类型」的字段一律不许给默认值。
三、pytest 参数化覆盖四类畸形样本
契约写好了,得测。把四类典型畸形样本做成参数化用例,每次改 prompt、换模型都重跑一遍——这就是把「输出格式」纳入回归:
# test_contract.py —— 畸形样本回归(pytest)
import pytest
from pydantic import ValidationError
from contract import validate_response, RefundResult
GOOD = '{"order_id": "A1", "amount": 120, "approved": true}'
MALFORMED = [
# 1) 多余前后缀:JSON 本身合法,靠提取救回,仍应通过
("prefix_suffix", '好的,以下是结果:\n' + GOOD + '\n希望有帮助', "pass"),
# 2) 类型漂移:amount 变成字符串,应判不合规
("type_drift", '{"order_id":"A1","amount":"120","approved":true}', "fail"),
# 3) 缺字段:少了必填 approved,应判不合规
("missing_key", '{"order_id":"A1","amount":120}', "fail"),
# 4) 多余字段:多吐未定义的 coupon,extra=forbid 应判不合规
("extra_key", GOOD.replace("}", ',"coupon":"X"}'), "fail"),
]
@pytest.mark.parametrize("name,raw,expected", MALFORMED)
def test_output_contract(name, raw, expected):
if expected == "pass":
result = validate_response(raw)
assert isinstance(result, RefundResult)
else:
with pytest.raises((ValidationError, ValueError)):
validate_response(raw)
def test_good_case_fields():
r = validate_response(GOOD)
assert r.amount == 120 and r.approved is True and isinstance(r.amount, int)
为什么这么写。参数化的价值不在「跑通」,而在把四类畸形固化成可复现的回归资产:prefix_suffix 故意设成 pass,是提醒团队「带寒暄但 JSON 合法」这种要靠提取救回、不该误杀;type_drift / missing_key / extra_key 三类设成 fail,对应线上真实踩过的三种坑。这样一旦有人把 extra="forbid" 删了、或把 amount 改成了 str,test_output_contract 立刻红——契约被谁放松了,PR 上看得一清二楚。踩过的坑是,早期只测 GOOD 用例,觉得「能解析就行」,结果畸形场景全靠线上暴雷;把畸形样本入库之后,换模型这种事才敢在合并前跑一遍。
四、Schema 校验流程:从原始输出到红绿判定
把上面两段串起来,一次响应在链路里的完整判定流程是这样的:
模型吐出原始输出(可能带寒暄、可能字段漂移)→ 先做 JSON 提取,抠出主体 → 语法层 json.loads 判它是不是合法 JSON → 契约层 pydantic model_validate 判它符不符合 schema(必填、类型、extra)→ 通过则结构化对象放行入库;失败则绝不静默兜底,而是按业务代价走三条路之一:触发一次带更强约束的重试、直接拦截并返回降级响应、或落一条告警把畸形样本回捞进回归集。
关键在于「失败即判不合规」这个动作必须发生在入库之前,而不是等下游对账时才发现。把这道校验放进 CI,就成了门禁。
这里要提醒一句:重试不是免费的兜底。给一次不合规响应挂上「自动重试」很诱人,但如果重试次数不设上限、每次都用同一句 prompt,模型很可能连续几次都在同一个字段上漂移,你只是把崩溃换成了超时和翻倍的 token 账单。稳妥的做法是给重试设硬上限(比如一次),并在重试请求里显式追加更强的格式约束,超过上限就转拦截与告警,把这条畸形样本落进回归集——让它下次在 CI 里被复现,而不是在线上反复赌运气。

五、把校验接进 CI:失败即 exit 1
最后一环,是让契约校验在每次改 prompt、换模型的 PR 上自动跑,红绿分明:
# .github/workflows/schema-gate.yml
name: schema-gate
on: [pull_request]
jobs:
contract-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: {
python-version: "3.11" }
- name: Install deps
run: pip install pydantic pytest
- name: Run output-contract tests
# pytest 非零退出即熔断;畸形样本回归全过才允许合并
run: pytest test_contract.py -v
- name: Validate recorded golden responses
run: python - <<'PY'
import sys, json
from contract import validate_response
# golden.jsonl 每行一条线上采集的真实响应,逐条过契约
bad = 0
for line in open("golden.jsonl", encoding="utf-8"):
line = line.strip()
if not line:
continue
try:
validate_response(json.loads(line)["raw"])
except Exception as e:
bad += 1
print("[REJECT]", type(e).__name__, e)
print(f"不合规响应数 = {
bad}")
sys.exit(1 if bad else 0) # 有任意一条不合规就熔断
PY
为什么这么分两步。第一步 pytest 守的是「契约定义本身没被改坏」——畸形样本回归全过,说明 pydantic 模型还按预期拒绝类型漂移和多余字段。第二步守的是「真实采集的响应还能过当前契约」——golden.jsonl 是从线上回捞的真实模型输出,逐条过校验,sys.exit(1 if bad else 0) 让任意一条不合规都能熔断合并。两步拆开,PR 上一眼能看出到底是「契约被改坏了」还是「模型输出漂了」,这两种 fail 的排查方向完全不同。踩过的坑是,最初只跑 pytest,觉得用例全绿就万事大吉,结果某次换了模型、真实输出开始带寒暄,pytest 全绿但线上解析崩了一片——从此加了 golden 响应这一步,把「真实输出」也纳入门禁。文中 golden 条数、不合规阈值均为本文示例,真实值按你的业务代价定:脏数据入库会赔钱的场景,就得零容忍。
结构化输出这道门禁搭好之后,最实在的变化不是拦住了几次解析崩溃,而是团队终于敢换模型、敢改 prompt 了——因为每一次改动,都有一批畸形样本和真实响应在合并前替你验过格式契约。
别再把大模型的 JSON 当「能解析的字符串」,把它当一份会漂移的接口契约来测——契约不校验,脏数据迟早静默入库。