AI 应用开发——如何确保大模型输出标准的JSON文本?
💡核心思路:单一手段不可靠,必须用「约束生成 + 提示工程 + 后处理校验 + 重试兜底」组合拳,从「模型输出概率分布」到「业务可消费数据」全链路兜底。
为什么大模型输出 JSON 经常翻车
大模型本质是逐 token 采样的语言模型,并不天然理解 JSON 语法。常见翻车模式:
- 结构性错误:缺少
}/]、key 没加引号、尾随逗号、单引号代替双引号。 - 混入自然语言:在 JSON 前后输出
好的,下面是 JSON:或json ...代码块包裹。 - 字段幻觉:多输出未声明字段、少输出必填字段、字段类型错误(数字写成字符串)。
- 截断:受
max_tokens限制,长 JSON 末尾被截断。 - 转义错误:字符串内部含
"或换行未正确转义。 - 中文标点污染:
,:「」等全角字符混入。
直观理解:模型每一步都是在词表上做一次概率采样,没有任何一步会主动检查「整体是否还是合法 JSON」。
所有「防御层」的本质,都是在降低 ❌ 分支被采样的概率,或干脆把它从候选中删掉。
二、五层防御体系
三、第 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 的概率直接清零:
也就是说:采样过程被 schema「绑架」了,无论模型多想瞎写,违法 token 的概率恒为 0,所以输出一定能 parse 且符合 schema。
2. 其他厂商
- Anthropic Claude:使用
tool_use(Function Calling),把目标 JSON 当作工具的input_schema。 - Google Gemini:
generationConfig.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 Schema | vLLM、Transformers |
| XGrammar | GBNF / JSON Schema,速度最快 | vLLM、SGLang、MLC |
| llama.cpp GBNF | 语法文件 | llama.cpp |
| Guidance / LMQL | DSL 模板 | 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"
}
)
约束解码内部到底发生了什么?
关键点:
- 预编译: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. 提取
按优先级尝试:
- 整段
JSON.parse成功 → 直接返回。 - 用正则提取
json ...块。 - 用括号配对算法(栈匹配
{}[],跳过字符串内字符)截取首个完整 JSON。 - 找到第一个
{和最后一个}之间的子串。
第 3 步的「括号配对」是最容易写挂的:字符串里出现的 { } 不能算数。可以用一个最小状态机:
要点:进入「字符串内」状态后,所有括号都不计数,否则形如 {"text": "a } b"} 会被错误截断。
2. 自动修复
心智模型:自动修复 = 一个比 JSON.parse 更宽容的 parser。它接受 JSON 的「方言变体」(JSON5、JavaScript 字面量、Markdown 包裹等),扫描时一边解析一边纠正,最后输出严格 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 用完时最常见的形态是:最后一个字符串没闭合 + 后面缺一串 } ]。处理模板:
经验阈值:截断超过 30% 时不要硬修,宁可重试或调大 max_tokens,否则修出来的 JSON 字段虽然结构合法但语义已经坏掉。
注意事项
修复不是越多越好,下列情况应该直接报错而不是修,避免把「格式错误」悄悄升级为「业务错误」:
- 数字字段被写成自然语言(“价格大约 12 块钱”)→ 不要正则抠数字,让上层重试。
- 枚举字段拼写错误(
"status": "doing"但 schema 要求"in_progress")→ 走 schema 校验失败 + 重试,不要 fuzzy match。 - 模型连出了两段 JSON(中间有自然语言说明)→ 取第一段,同时打上异常样本标记、进数据飞轮。
- 修复后字符数 / 原始字符数 < 0.5:几乎必然丢字段,直接走降级。
- 修复后仍不过 schema:不要反复修,走第 5 层的「反思式重试」让模型自己重生。
修复管线的逻辑
📝工程必记:修复管线里永远保留「原始字符串 + 每一步修改之后的字符串 + 最终是否 parse 成功」,一是定位问题,二是后续 SFT / Prompt 调优 / 对比评测集的原材料。
3. Schema 校验
- Python:
pydantic/jsonschema。 - Java:
com.networknt:json-schema-validator或jakarta.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 层:重试与降级
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。能约束解码就别靠后处理修复。但后处理 + 校验 + 重试这一套,不管前面用什么方案,都必须有。
更多推荐
所有评论(0)