前面我们已经让模型按照 JSON Schema 返回结构化结果,但“能解析成 JSON”不等于“业务上可用”:年份可能超出范围,邮箱可能不是合法格式,必填字段也可能是空字符串。生产代码不能只相信模型的承诺,而要把输出当作不可信的外部输入,先校验,再决定是否接受。本篇只聚焦一个核心闭环:模型输出 → 程序校验 → 有限次修复 → 最终失败处理

结构正确不代表内容正确

模型输出至少有三层可靠性:第一层是传输成功,接口返回了响应;第二层是语法正确,例如文本可以被 json.loads 解析;第三层是符合业务约束,例如 score 必须是 0 到 100 的整数、email 必须像邮箱地址。

结构化输出主要帮助第二层,甚至可以帮助字段类型和必填项,但它不会替我们判断事实是否正确,也不会自动理解全部业务规则。因此,模型输出应当和用户输入、HTTP 请求一样,经过明确的边界校验。校验失败时,不能把错误数据继续传给数据库、邮件系统或下游工具。

用 Pydantic 声明可执行的规则

Pydantic 的模型既是文档,也是可执行的校验器。下面这个例子从一段文本中提取联系人信息,并额外约束姓名非空、年龄范围和邮箱格式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pydantic import BaseModel, ConfigDict, EmailStr, Field


class Contact(BaseModel):
model_config = ConfigDict(extra="forbid")

name: str = Field(min_length=1, max_length=50)
age: int = Field(ge=0, le=120)
email: EmailStr


raw = '{"name":"小林","age":28,"email":"lin@example.com"}'
contact = Contact.model_validate_json(raw)
print(contact.name, contact.email)

model_validate_json() 会先解析 JSON,再按照模型字段校验类型和约束;成功后得到的是 Contact 实例,而不是一段未经检查的字典。extra="forbid" 会拒绝未声明的额外字段,避免模型悄悄塞进程序没有考虑过的数据。运行示例前安装依赖:pip install pydantic email-validator。这里没有放任何密钥,校验逻辑可以脱离模型接口单独测试。

注意,类型转换不是万能的。Pydantic 可能把某些数字字符串转换为整数,但不会替你判断“这个人是否真的 28 岁”。格式校验和事实核验是两回事,后者需要数据库、规则或人工确认。

第一次失败:把错误变成明确反馈

调用模型后,不要直接访问 data["name"]。先捕获校验异常,并把结构化的错误信息整理成下一次请求能理解的反馈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import json

from pydantic import ValidationError


def validate_contact(raw: str) -> Contact:
try:
return Contact.model_validate_json(raw)
except ValidationError as exc:
errors = [
{"field": ".".join(str(x) for x in item["loc"]),
"message": item["msg"]}
for item in exc.errors()
]
raise ValueError(json.dumps(errors, ensure_ascii=False)) from exc

错误信息应该只包含字段路径和规则,不要把整段敏感原文无条件写入日志或重新发送。对于模型来说,“输出必须是 JSON”太宽泛;“age 必须是 0 到 120 的整数,email 必须是有效邮箱”才是可执行的修复指令。

自动修复必须有上限

修复不是让模型无限循环重试,而是把上一次的输出和校验错误一起交给模型,请它只改错处。下面的完整示例使用 OpenAI Python SDK 的 Chat Completions 接口;模型、密钥和兼容服务地址全部来自环境变量。它展示流程,实际使用前仍应确认所选模型和服务商支持相应接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import json
import os

from openai import OpenAI
from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError


class Contact(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=50)
age: int = Field(ge=0, le=120)
email: EmailStr


client = OpenAI(
api_key=os.environ["OPENAI_API_KEY"],
base_url=os.environ.get("OPENAI_BASE_URL"),
)
model = os.environ.get("MODEL_NAME", "gpt-4o-mini")


def ask(text: str, previous: str | None = None,
errors: list[dict] | None = None) -> str:
instruction = (
"从用户文本提取 name、age、email,只返回一个 JSON 对象,"
"不要 Markdown,不要解释。"
)
if previous and errors:
instruction += (
f"\n上次 JSON 是:{previous}\n校验错误是:"
f"{json.dumps(errors, ensure_ascii=False)}\n只修复这些错误。"
)
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": instruction},
{"role": "user", "content": text},
],
response_format={"type": "json_object"},
)
return response.choices[0].message.content or ""


def extract_contact(text: str, max_attempts: int = 2) -> Contact:
raw = ask(text)
for attempt in range(max_attempts + 1):
try:
return Contact.model_validate_json(raw)
except (ValidationError, ValueError) as exc:
if attempt == max_attempts:
raise RuntimeError("模型输出连续校验失败") from exc
if isinstance(exc, ValidationError):
errors = exc.errors()
else:
errors = [{"loc": [], "msg": "不是合法 JSON"}]
raw = ask(text, previous=raw, errors=errors)
raise AssertionError("unreachable")


if __name__ == "__main__":
print(extract_contact("小林 28 岁,邮箱 lin@example.com"))

这里的 max_attempts=2 表示初次生成后最多再修复两次。每次修复都可能消耗 token、增加延迟,且模型可能把一个错误改成另一个错误,所以必须设置上限。达到上限后应记录请求标识和校验摘要,返回可控的失败结果或进入人工处理,绝不能把最后一段原始文本当成成功数据。

何时不该自动修复

有些失败不是格式问题。模型拒答、响应为空、内容被截断、接口超时,都应该分别记录并走对应的错误处理;把它们统统塞给“修复提示词”只会掩盖真正原因。涉及转账、删除数据、发送外部消息等高风险动作时,即使校验通过,也应增加业务规则和人工审批,不能因为 JSON 合法就自动执行。

还要区分“可修复字段”和“不可猜字段”。如果输入没有提供年龄,模型不应凭常识补一个数字。可以把字段改成可选类型,要求缺失时返回 null,再由业务层决定是否继续。校验器负责发现问题,不能凭空创造事实。

常见问题

为什么已经使用 JSON Mode 还要校验? JSON Mode 只解决语法层面,通常不保证字段、范围和业务含义。它不能替代 Pydantic 或其他程序校验。

修复时能否只把错误消息发给模型? 最好同时提供原始 JSON、字段规则和错误位置,否则模型不知道要在什么上下文中修改。发送前应脱敏,并限制输入长度。

应该重试多少次? 交互式场景通常初次生成加一到两次修复即可。若连续失败,问题可能是提示词、Schema 或输入本身,而不是“再试一次”能解决的。

EmailStr 报缺少依赖怎么办? 安装 email-validator。依赖缺失属于部署问题,应在环境构建或启动检查阶段暴露,而不是运行到用户请求时才发现。

小结

  • 模型返回成功、JSON 合法、符合业务规则,是三个不同层次,必须逐层验证。
  • 用 Pydantic 把类型、范围、必填和额外字段规则写成可执行约束。
  • 校验失败时可以把错误反馈给模型进行有限次修复,但必须设置次数上限并准备失败分支。
  • 校验通过不等于事实真实,更不等于可以执行高风险操作;关键数据仍需业务核验和审批。
  • 下一步将学习提示词版本管理与基础测试,让这些约束能够持续回归,而不是改一次提示词就重新冒险。