💡核心思路:单一手段不可靠,必须用「约束生成 + 提示工程 + 后处理校验 + 重试兜底」组合拳,从「模型输出概率分布」到「业务可消费数据」全链路兜底。

为什么大模型输出 JSON 经常翻车

大模型本质是逐 token 采样的语言模型,并不天然理解 JSON 语法。常见翻车模式:

  • 结构性错误:缺少 } / ]、key 没加引号、尾随逗号、单引号代替双引号。
  • 混入自然语言:在 JSON 前后输出 好的,下面是 JSON:json ... 代码块包裹。
  • 字段幻觉:多输出未声明字段、少输出必填字段、字段类型错误(数字写成字符串)。
  • 截断:受 max_tokens 限制,长 JSON 末尾被截断。
  • 转义错误:字符串内部含 " 或换行未正确转义。
  • 中文标点污染 「」 等全角字符混入。

直观理解:模型每一步都是在词表上做一次概率采样,没有任何一步会主动检查「整体是否还是合法 JSON」

❌ 选到逗号或换行或 EOS

✅ 选到合法 token

Prompt输入

采样 t₁

生成 左大括号

采样 t₂

生成 name 键

采样 t₃

生成值 XXX

采样 tₙ

截断 / 多逗号 / 缺右括号

合法 JSON

所有「防御层」的本质,都是在降低 ❌ 分支被采样的概率,或干脆把它从候选中删掉。


二、五层防御体系

1. 模型层
结构化输出能力

2. 解码层
约束解码 / Grammar

3. 提示层
Schema + Few-shot

4. 后处理层
提取 + 修复 + 校验

5. 兜底层
重试 + 降级


三、第 1 层:优先使用模型原生的「结构化输出」能力

这是最有效的一层,能直接把 JSON 错误率从 5%~20% 降到接近 0。

1. OpenAI / 兼容协议

  • response_format: { type: "json_object" }:保证输出可被 JSON.parse,但不保证字段结构。
  • response_format: { type: "json_schema", json_schema: {...}, strict: true }:在 token 层强制按 schema 采样,结构、类型、枚举值 100% 满足
{
	"model": "gpt-4o-mini",
	"response_format": {
		"type": "json_schema",
		"json_schema": {
			"name": "resume_extract",
			"strict": true,
			"schema": {
				"type": "object",
				"properties": {
					"name": { "type": "string" },
					"years": { "type": "integer", "minimum": 0 },
					"skills": { "type": "array", "items": { "type": "string" } }
				},
				"required": ["name", "years", "skills"],
				"additionalProperties": false
			}
		}
	}
}

json_schema 严格模式是怎么做到 100% 合规的? 它把 schema 编译成一个状态机,在每一步采样前根据当前状态算出「此刻哪些 token 合法」,把其余 token 的概率直接清零:

强制清零

强制清零

强制清零

当前已生成 name 键后的冒号

词表上的原始概率分布

字符串起始 token
p = 0.55 ✅

数字 token 123
p = 0.20 ❌ schema 要求 string

null token
p = 0.15 ❌ required

其他 token
p = 0.10 ❌

保留并重新归一化

采样下一个 token
数学上必合法

也就是说:采样过程被 schema「绑架」了,无论模型多想瞎写,违法 token 的概率恒为 0,所以输出一定能 parse 且符合 schema。

2. 其他厂商

  • Anthropic Claude:使用 tool_use(Function Calling),把目标 JSON 当作工具的 input_schema
  • Google GeminigenerationConfig.responseMimeType = "application/json" + responseSchema
  • 国产模型(通义/智谱/Kimi/DeepSeek):大多兼容 OpenAI 的 response_format,DeepSeek 还支持 json_object 模式。
  • 开源模型:见下一节「约束解码」。
    ⚠️注意:json_schema 严格模式下,schema 不能用未支持的 JSON Schema 关键字(如 $ref 深层引用、pattern 在部分厂商不支持),最好先在 OpenAI 的文档里核对支持列表。

四、第 2 层:自部署模型的「约束解码」

本地/私有化部署(vLLM、SGLang、TGI、llama.cpp)时,可以在 logits 采样阶段 直接屏蔽掉违反语法的 token,使输出数学上不可能不合法。

方案形式适用引擎
Outlines正则 / Pydantic / JSON SchemavLLM、Transformers
XGrammarGBNF / JSON Schema,速度最快vLLM、SGLang、MLC
llama.cpp GBNF语法文件llama.cpp
Guidance / LMQLDSL 模板Transformers

vLLM 调用示例(OpenAI 兼容接口):

from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="x")

resp = client.chat.completions.create(
    model="Qwen2.5-7B-Instruct",
    messages=[{"role": "user", "content": "提取简历"}],
    extra_body={
        "guided_json": {
            "type": "object",
            "properties": {
                "name": {"type": "string"},
                "years": {"type": "integer"}
            },
            "required": ["name", "years"]
        },
        "guided_decoding_backend": "xgrammar"
    }
)

约束解码内部到底发生了什么?

推进状态

LLM forward

原始 logits
词表 V 上的分数

JSON Schema 或 GBNF

编译为
有限状态自动机

当前状态 → 合法 token 掩码

非法 token 的 logit 置为 负无穷

softmax + 采样

下一 token 必合法

关键点:

  • 预编译:grammar/schema 只在请求开始时编译一次,后续每步只是查表 → XGrammar 能做到接近零开销。
  • token 级而非字符级:BPE token 可能跨越多个语法符号,所以 mask 表会比直觉复杂得多,这也是为什么自己手写约束很难、要用现成库。
  • 和模型解耦:任何能拿到 logits 的本地推理引擎都能接,不需要重新训练模型。

五、第 3 层:提示工程兜底(即使模型支持结构化输出也建议加)

1. 系统提示模板

你是一个严格的 JSON 生成器。请严格遵守以下规则:
1. 仅输出符合下方 JSON Schema 的 JSON 对象,不要输出任何解释、Markdown、代码块标记。
2. 所有字符串使用双引号;不要输出尾随逗号;不要输出注释。
3. 未知字段统一为 null,不要编造值。
4. 输出必须能被 JSON.parse 直接解析。

JSON Schema:
{...}

Few-shot 示例:
输入:...
输出:{...}

2. 高效技巧

  • Schema 内联到 Prompt:即使用了 response_format,把 schema 也写进 prompt,能进一步降低字段缺失。
  • Few-shot 至少 1~2 个完整示例:包含「边界情况」和「字段为空」的样例。
  • 明确禁止项「禁止输出 json 代码块包裹」「禁止输出任何说明文字」
  • assistant 前缀引导:在 assistant 消息中预填 {,让模型从 { 接着写,能显著减少前置废话。
  • 温度调低:JSON 抽取类任务 temperature ≤ 0.2,必要时 top_p = 1

3. Spring AI 写法参考

BeanOutputConverter<Resume> converter = new BeanOutputConverter<>(Resume.class);
String format = converter.getFormat(); // 自动生成 schema 描述

Prompt prompt = new PromptTemplate("""
    根据以下文本抽取简历信息。
    {format}
    文本:{text}
    """).create(Map.of("format", format, "text", input));

Resume resume = converter.convert(
    chatModel.call(prompt).getResult().getOutput().getContent()
);

六、第 4 层:后处理「提取 + 修复 + 校验」

即使前三层都做了,仍要把后处理当作最后防线

1. 提取

按优先级尝试:

  1. 整段 JSON.parse 成功 → 直接返回。
  2. 用正则提取 json ... 块。
  3. 用括号配对算法(栈匹配 { } [ ],跳过字符串内字符)截取首个完整 JSON。
  4. 找到第一个 { 和最后一个 } 之间的子串。

第 3 步的「括号配对」是最容易写挂的:字符串里出现的 { } 不能算数。可以用一个最小状态机:

开始

遇到左括号 depth=1

其它字符 跳过

遇到右括号 depth-1

depth 归零

遇到未转义引号

转义引号 或其它字符

遇到未转义引号

找开头

解析中 (depth>0)

字符串内

完成

要点:进入「字符串内」状态后,所有括号都不计数,否则形如 {"text": "a } b"} 会被错误截断。

2. 自动修复

心智模型:自动修复 = 一个比 JSON.parse 更宽容的 parser。它接受 JSON 的「方言变体」(JSON5、JavaScript 字面量、Markdown 包裹等),扫描时一边解析一边纠正,最后输出严格 JSON。

修复器内部大概模式

代码外

字符串内

原始输出

一 剥离 markdown 包裹

二 全角标点转半角

三 定位首个左括号

四 流式扫描状态机

当前状态

替单引号
给 key 加引号
删注释
剪尾逗号
映射字面量

裸换行转转义
裸 tab 转转义

五 末尾按栈补齐

六 严格 parse

严格 JSON

2.1 一个最小可用的 Python 实现
import re
import json

_FULLWIDTH = str.maketrans({
    ",": ",", ":": ":", ";": ";",
    "「": '"', "」": '"', "“": '"', "”": '"',
    "‘": "'", "’": "'",
})

def quick_repair(raw: str) -> str:
    s = raw.strip()

    # 1. 去掉 ```json ... ```或 ```... ```
    s = re.sub(r"^```(?:json|JSON)?\s*", "", s)
    s = re.sub(r"\s*```\s*$", "", s)

    # 2. 截到首个 { 或 [
    candidates = [i for i in (s.find("{"), s.find("[")) if i >= 0]
    if not candidates:
        raise ValueError("no JSON found")
    s = s[min(candidates):]

    # 3. 全角 → 半角(谨慎:这会影响中文字符串内容,
    # 如果不能接受,改成只在字符串外替换)
    s = s.translate(_FULLWIDTH)

    # 4. 字面量映射
    s = re.sub(r"\bTrue\b",  "true",  s)
    s = re.sub(r"\bFalse\b", "false", s)
    s = re.sub(r"\b(None|NaN|Infinity|-Infinity)\b", "null", s)

    # 5. 删尾随逗号
    s = re.sub(r",(\s*[}\]])", r"\1", s)

    # 6. 给 unquoted key 加引号(简易版)
    s = re.sub(r"([{,]\s*)([A-Za-z_][\w\-]*)\s*:", r'\1"\2":', s)

    # 7. 先试严格 parse
    try:
        return json.dumps(json.loads(s), ensure_ascii=False)
    except json.JSONDecodeError:
        pass

    # 8. 括号栈补齐截断
    return json.dumps(_close_truncated(s), ensure_ascii=False)

def _close_truncated(s: str):
    stack, in_str, esc = [], False, False
    for ch in s:
        if in_str:
            if esc:           esc = False
            elif ch == "\\":  esc = True
            elif ch == '"':   in_str = False
        else:
            if ch == '"':           in_str = True
            elif ch in "{[":        stack.append(ch)
            elif ch in "}]" and stack: stack.pop()
    tail = ""
    if in_str:
        tail += '"'                # 字符串先补闭合
    while stack:
        tail += "}" if stack.pop() == "{" else "]"
    return json.loads(s + tail)
2.2 Jackson 容错配置

json-repair 没有官方 JVM 实现,Java 侧最实用的是把 Jackson 拉到「宽松模式」再加一层预处理:

ObjectMapper mapper = JsonMapper.builder()
    .enable(JsonReadFeature.ALLOW_SINGLE_QUOTES)            // 单引号
    .enable(JsonReadFeature.ALLOW_UNQUOTED_FIELD_NAMES)     // key 无引号
    .enable(JsonReadFeature.ALLOW_TRAILING_COMMA)           // 尾随逗号
    .enable(JsonReadFeature.ALLOW_JAVA_COMMENTS)            // // 和 /* */
    .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS)  // 字符串内裸换行
    .enable(JsonReadFeature.ALLOW_NON_NUMERIC_NUMBERS)      // NaN / Infinity
    .build();

String preprocessed = raw
    .replaceAll("(?s)^```(?:json)?\\s*", "")
    .replaceAll("\\s*```\\s*$", "")
    .replaceAll(",\\s*([}\\]])", "$1");                    // 防御性去尾逗号

JsonNode node;
try {
    node = mapper.readTree(preprocessed);
} catch (JsonProcessingException e) {
    // 带括号栈补齐的小函数(类似上面 Python _close_truncated)
    node = mapper.readTree(closeTruncated(preprocessed));
}

如果项目里允许 Python 小服务,也可以直接部署一个 /repair HTTP 接口,主流量 Java 调用,能复用社区里调试最充分的 json-repair。

截断(最高频也最棘手)

max_tokens 用完时最常见的形态是:最后一个字符串没闭合 + 后面缺一串 } ]。处理模板:

字符串未闭合

对象未闭合

数组未闭合

成功

失败

扫描到尾部

末态

先补一个双引号

按栈倒序补右大括号

按栈倒序补右中括号

再试 parse

返回

把尾部不完整的 key 或 value 整段砍掉
再补括号

经验阈值:截断超过 30% 时不要硬修,宁可重试或调大 max_tokens,否则修出来的 JSON 字段虽然结构合法但语义已经坏掉。

注意事项

修复不是越多越好,下列情况应该直接报错而不是修,避免把「格式错误」悄悄升级为「业务错误」:

  • 数字字段被写成自然语言(“价格大约 12 块钱”)→ 不要正则抠数字,让上层重试。
  • 枚举字段拼写错误"status": "doing" 但 schema 要求 "in_progress")→ 走 schema 校验失败 + 重试,不要 fuzzy match。
  • 模型连出了两段 JSON(中间有自然语言说明)→ 取第一段,同时打上异常样本标记、进数据飞轮。
  • 修复后字符数 / 原始字符数 < 0.5:几乎必然丢字段,直接走降级。
  • 修复后仍不过 schema:不要反复修,走第 5 层的「反思式重试」让模型自己重生。
修复管线的逻辑

失败

失败

失败

仍失败

成功

成功

成功

成功

LLM 原始输出

严格 parse

预处理 剥 markdown 加全角转半角加截首段

严格 parse

宽松 parse json-repair 或 Jackson 宽松

括号栈补齐 加 正则补丁

严格 parse

抛错 进入第 5 层重试

Schema 校验

📝工程必记:修复管线里永远保留「原始字符串 + 每一步修改之后的字符串 + 最终是否 parse 成功」,一是定位问题,二是后续 SFT / Prompt 调优 / 对比评测集的原材料。

3. Schema 校验

  • Python:pydantic / jsonschema
  • Java:com.networknt:json-schema-validatorjakarta.validation + Bean。
  • Node:zod / ajv
from pydantic import BaseModel, ValidationError

class Resume(BaseModel):
    name: str
    years: int
    skills: list[str]

try:
    resume = Resume.model_validate(data)
except ValidationError as e:
    # 进入第 5 层:重试或降级
    ...

主要的是经过校验+异常处理之后我们就可以进行重试和补救机制了,这个是很重要的一层


七、第 5 层:重试与降级

LLM 调用

JSON.parse 成功?

自动修复

修复后能 parse?

Schema 校验通过?

带错误信息重试

返回业务

重试次数 < N?

降级: 返回默认值/转人工

1. 反思式重试(Critique & Retry)

把上一轮的错误输出 + 具体报错塞回 prompt,让模型自我修正:

你上一次的输出是:
<<<
{previous_output}
>>>

它无法通过 JSON Schema 校验,错误为:
{validation_error}

请直接输出修正后的、合法的 JSON,不要解释。

2. 配置建议

  • 最多重试 2~3 次,超过即降级。
  • 重试时 温度归零 + 缩短上下文
  • 记录失败样本进入数据飞轮,用于后续 SFT/评测集。

各方案对比速查

方案合规率侵入性性能开销推荐场景
纯 Prompt 约束低 (~85%)原型 / 简单字段
Function Calling高 (~99%)Claude / 通用闭源
response_format: json_schema极高 (~100%)OpenAI 兼容生态
约束解码 (XGrammar/Outlines)100%编译期开销自部署开源模型
后处理修复提升 ~10%所有场景必备兜底

一句话总结

能用 json_schema 就别只靠 Prompt。能约束解码就别靠后处理修复。但后处理 + 校验 + 重试这一套,不管前面用什么方案,都必须有。

更多推荐