大模型看似听话地返回 JSON,却常夹杂解释文字,甚至把数字写成字符串,直接 json.loads 就报错;更棘手的是失败并不稳定复现,靠更严厉的提示词也无法根治。本文给出“约束输出—解析—校验—重试”的完整流程:用 JSON Schema 锁定字段与类型,剥离围栏杂质,再用 Pydantic 严格校验。既然模型本质不是序列化器,程序该如何守住这道可靠的契约?
— 此摘要由AI分析文章内容生成,仅供参考。
先看一个很多开发者都会遇到的失败现场。你告诉模型“只返回 JSON,不要解释”,它却返回了这样一段:
好的,以下是根据你的要求生成的结果:
{
"intent": "search",
"keywords": ["大模型", "JSON"],
"limit": "10"
}
如果你直接把这段字符串丢给 json.loads,会立刻撞上 JSONDecodeError。就算手动把解释文字去掉,limit 仍然是字符串 "10",后续比较、入库或传给下游函数时还会继续出错。更麻烦的是,这类问题经常不是稳定复现的:同一句提示词,这次多了围栏,下次缺字段,再下一次把枚举值写成近义词。

很多人第一反应是把提示词写得更严厉,例如“必须严格输出 JSON,不要解释”。这确实有一定作用,但不能把它当成可靠契约。大模型生成内容的本质是预测下一个 token,而不是执行一个真正的 JSON 序列化器。它可能理解“应该输出 JSON”,但无法保证每一次都严格遵守类型、必填字段和枚举值。因此,更实际的做法是像对接一个不完全可靠的外部服务一样,在程序侧建立一套“约束输出—解析—校验—重试”的流程。
先建立约束:JSON Schema 比“请输出 JSON”更可靠
第一层防线放在请求上。与其只靠自然语言提示,不如使用平台提供的结构化输出能力。很多兼容接口都支持 response_format 参数,常见的有两种模式:JSON Object 模式只保证模型返回标准 JSON 字符串,但不保证字段结构;JSON Schema 模式则允许你传入一份 Schema,明确限制字段、类型、必填项和枚举值。
以一段通用请求为例,Schema 可以写成这样:
{
"type": "object",
"properties": {
"intent": {
"type": "string",
"enum": ["search", "create", "update", "delete"]
},
"keywords": {
"type": "array",
"items": {"type": "string"},
"minItems": 1
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 50
}
},
"required": ["intent", "keywords", "limit"],
"additionalProperties": false
}
这里有几个值得坚持的设计原则:能用枚举就不要放任模型自由发挥;数字字段明确类型为 integer,避免出现 "10";必填字段写进 required;additionalProperties 设为 false,不让模型随意添加字段。Schema 本身就是一种机器可读的约束,比自然语言更稳定。
不同平台对参数名和细节的要求不一样。有的平台在 JSON Object 模式下会要求提示词里必须出现 JSON 关键词,否则接口直接报错;换成 JSON Schema 模式后通常不需要这个关键词。接入前最好先翻对应文档确认,但整体的“请求层加约束”思路是相通的。
解析阶段:先把围栏和“人话”分开
即使开启了结构化输出,模型仍可能返回 “`json 围栏、前后的解释文字,或者 JSON 后面多出一句话。吞掉这些杂质是解析前的必要步骤。
一个保守但实用的提取思路是:先查找 “`json ... “ 围栏,如果找到就取中间内容;否则寻找第一个 { 或 [,再匹配最后一个 } 或 ]`,截取中间部分。示例代码如下:
import json
import re
def extract_json(text: str) -> str:
# 优先处理 Markdown 代码围栏
fenced = re.search(r"```(?:json)?s*(.*?)s*```", text, re.DOTALL)
if fenced:
return fenced.group(1)
# 否则尝试截取最外层 JSON 对象或数组
starts = [i for i in (text.find("{"), text.find("[")) if i != -1]
if not starts:
return text
start = min(starts)
end = max(text.rfind("}"), text.rfind("]"))
if end > start:
return text[start:end + 1]
return text
这种提取方式不能处理所有极端情况,比如字符串内部包含 } 时可能截断,但它足够应付大多数“JSON 外混入解释文字”的场景。对于单引号、尾逗号这类轻微语法损伤,可以谨慎使用 JSON repair 类工具做保守修复,但修复后的内容仍然必须走完整校验,不能在修复后就直接信任。
用 Pydantic 同时完成结构和业务校验
提取出字符串并 json.loads 成功后,下一步是校验结构。这里推荐用 Pydantic 定义契约模型,把字段类型、必填项、数组约束和枚举值都写进代码里:
from typing import List, Literal
from pydantic import BaseModel, Field, ValidationError
class IntentResult(BaseModel):
intent: Literal["search", "create", "update", "delete"]
keywords: List[str] = Field(min_length=1)
limit: int = Field(ge=1, le=50)
之后调用 IntentResult.model_validate(data),Pydantic 会一次完成类型转换、必填检查、长度和范围校验。如果模型返回的是前面例子里的 limit: "10",Pydantic 会直接抛出 ValidationError,而不是让错误继续向下游传播。
结构校验通过不代表结果真的可用。业务规则仍然需要程序自己判断,不能把“置信度”当成证据,也不能让模型代替你决定一个高风险动作是否合法。例如,当模型输出 delete 意图,但用户输入中并没有明确的删除授权词时,这就是业务风险,而不是格式问题。可以在 Pydantic 校验后再加一层业务检查:
def check_business(result: IntentResult, user_text: str) -> None:
if result.intent in {"delete", "update"}:
if "删除" not in user_text and "更新" not in user_text:
raise ValueError(f"用户输入未明确授权 {result.intent} 操作")
这个检查必须写在后端,不能只靠模型自觉。
有限重试:把校验错误喂回模型
校验失败后,不建议无限循环重试,更不建议盲目重复同一句提示词。更有效的方式是把错误位置和原因整理成简短反馈,回灌给模型,让它知道上一次哪里不符合要求。
下面是一个最小可用的重试流程:
def call_with_retry(base_prompt: str, user_text: str, max_attempts: int = 3):
last_error = ""
for attempt in range(max_attempts):
prompt = base_prompt
if last_error:
prompt += f"nn上次输出校验失败:n{last_error}n请修正后只输出 JSON,不要添加任何解释。"
raw = model_call(prompt)
try:
data = json.loads(extract_json(raw))
result = IntentResult.model_validate(data)
check_business(result, user_text)
return result
except (json.JSONDecodeError, ValidationError, ValueError) as exc:
last_error = summarize_error(exc)
return None
其中 summarize_error 可以从 ValidationError.errors() 中提取字段路径和错误消息,比如 limit: Input should be a valid integer;model_call 是你自己的模型请求封装。重试次数建议控制在 2 到 3 次,每次重试都应有日志记录,方便事后定位。

如果重试次数用尽仍然失败,不要硬把脏数据塞给下游。可以返回一个安全默认值,或者把任务标记为“需人工处理”。对敏感操作尤其如此:宁可暂停流程,也不要让程序在字段不完整时继续执行删除、发布或转账等动作。
结果是否可用:三层正确缺一不可
判断一个模型输出是否真正可用,可以按三层来检查:第一层是语法正确,即字符串能被 JSON 解析器读取;第二层是结构正确,即字段、类型、必填项和枚举值符合 Schema;第三层是业务正确,即结果与输入事实相符、不触发越权风险。三层全部通过,才允许进入主流程。
常见的失败现象和处理方向可以归纳成一张表:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
JSONDecodeError: Extra data | JSON 后有多余文本 | 先提取首尾 JSON 再解析 |
keywords Field required | 缺少必填字段 | Schema 标记必填,失败后回灌错误 |
Input should be a valid integer | 数字被字符串包装 | Schema 设置 integer,错误回灌重试 |
| 模型返回额外字段 | 没有禁止额外属性 | Schema 设置 additionalProperties: false |
| 枚举值拼写不一致 | 模型自由发挥 | Schema 使用 enum 限制取值 |
最后再强调一次:这套流程并不能让模型变得永远正确。结构化输出能显著降低格式错误,重试能修复一部分可识别的问题,但模型本质上仍然可能不遵循格式要求,业务判断也不能交给模型完成。可靠的做法不是追求“一次就绝对稳定”,而是让每一个环节都有明确的失败出口和兜底路径。这样,当模型偶尔不老实的时候,你的程序不会跟着一起崩。

评论列表 (8条):
加载更多评论 Loading...