AI工具教程

让大模型稳定返回 JSON:结构约束、校验与失败重试

AI智能摘要

大模型看似听话地返回 JSON,却常夹杂解释文字,甚至把数字写成字符串,直接 json.loads 就报错;更棘手的是失败并不稳定复现,靠更严厉的提示词也无法根治。本文给出“约束输出—解析—校验—重试”的完整流程:用 JSON Schema 锁定字段与类型,剥离围栏杂质,再用 Pydantic 严格校验。既然模型本质不是序列化器,程序该如何守住这道可靠的契约?

— 此摘要由AI分析文章内容生成,仅供参考。

先看一个很多开发者都会遇到的失败现场。你告诉模型“只返回 JSON,不要解释”,它却返回了这样一段:

纯文本
好的,以下是根据你的要求生成的结果:
{
  "intent": "search",
  "keywords": ["大模型", "JSON"],
  "limit": "10"
}

如果你直接把这段字符串丢给 json.loads,会立刻撞上 JSONDecodeError。就算手动把解释文字去掉,limit 仍然是字符串 "10",后续比较、入库或传给下游函数时还会继续出错。更麻烦的是,这类问题经常不是稳定复现的:同一句提示词,这次多了围栏,下次缺字段,再下一次把枚举值写成近义词。

开发者面对模型返回的杂乱 JSON 输出

很多人第一反应是把提示词写得更严厉,例如“必须严格输出 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 dataJSON 后有多余文本先提取首尾 JSON 再解析
keywords Field required缺少必填字段Schema 标记必填,失败后回灌错误
Input should be a valid integer数字被字符串包装Schema 设置 integer,错误回灌重试
模型返回额外字段没有禁止额外属性Schema 设置 additionalProperties: false
枚举值拼写不一致模型自由发挥Schema 使用 enum 限制取值

最后再强调一次:这套流程并不能让模型变得永远正确。结构化输出能显著降低格式错误,重试能修复一部分可识别的问题,但模型本质上仍然可能不遵循格式要求,业务判断也不能交给模型完成。可靠的做法不是追求“一次就绝对稳定”,而是让每一个环节都有明确的失败出口和兜底路径。这样,当模型偶尔不老实的时候,你的程序不会跟着一起崩。

热门话题

52okp 是一名关注人工智能、开源软件与效率工具的技术内容创作者,长期实践 Stable Diffusion、ComfyUI、AI 智能体、MCP、Codex 和各类开源项目。通过实际安装、配置与测试,整理可复现的操作教程、问题排查方法和工具使用经验。

登录用户才能发表评论! 登录账户

取消回复

评论列表 (8条):

加载更多评论 Loading...

延伸阅读:

Goose:不只是代码建议,这个开源 AI Agent 能直接帮你执行、编辑并测试工程任务

不少 AI 编程工具止步于代码建议,真正的工程任务还要读项目、改文件、运行命令并验证结果;让 Agent 直接操作虽能推...

52okp
2026-09-28

用 Python 清洗 Excel 表格中的空值和重复行:保留原文件并生成校验报告

收集表里的空行、重复记录和首尾空格看似好处理,规则含糊却可能误删有效数据。本文提供一套 Python 脚本,按工作表清洗...

52okp
2026-09-25
    返回顶部