为什么"输出 JSON"是个被低估的难题
很多教程告诉你"在 Prompt 里加一句'输出 JSON'"就够了。真到生产里你会发现模型的花式翻车:
- 前言后语:「好的,这是您要的结果:
{...},希望有帮助」——JSON.parse直接报错 - 键名漂移:你让输出
price,它给你价格或price_cny - 类型混搭:
count上一条是3,下一条变"3"或"三个" - 嵌套炸裂:本该是数组的字段,信息多了它给你塞成对象
- 未转义字符:字段值里带引号、带换行没转义,整段 JSON 报错
单条 70% 的可解析率,放到 Agent 每轮调几十次工具的场景里,几乎必然崩。所以结构化输出必须靠多层约束 + 容错解析双保险。
六层约束是怎么起作用的
| 层 | 约束 | 解决的翻车类型 |
|---|---|---|
| 1 唯一根节点 | 强制单个对象、起手 { 收尾 } | 多对象拼接、数组包裹 |
| 2 字段表 | 明确 key 名 + 必填标记 | 键名漂移、随意增删字段 |
| 3 类型契约 | 每字段类型 + 取值约束 | 类型混搭、枚举越界 |
| 4 反例 | 显式列出错误样板 | 代码围栏、前言后语 |
| 5 自检 | 输出前内部校验不外显 | 尾随逗号、未转义 |
| 6 失败回退 | 信息不足填 null + _reason | 编造数据污染下游 |
注意第 6 层:很多翻车来自模型"硬填"。给它一个合法的空值出口(null + _reason),比让它编一个看似合理的值安全得多。
容错解析器(Prompt 之外的最后一道防线)
即便 Prompt 约束到位,线上也要假设模型偶尔会越界。下面这个 Python 解析器兜底常见问题:
import json, re
def safe_json_load(text: str):
# 1) 去掉代码围栏
text = re.sub(r"^```(?:json)?\s*", "", text.strip(), flags=re.IGNORECASE)
text = re.sub(r"\s*```$", "", text)
# 2) 抠出第一个 {...} 片段,丢弃前言后语
m = re.search(r"\{.*\}", text, flags=re.DOTALL)
if not m:
raise ValueError("未找到 JSON 对象")
candidate = m.group(0)
# 3) 去尾随逗号、单引号转双引号
candidate = re.sub(r",(\s*[}\]])", r"\1", candidate)
# 4) 解析失败则逐行定位错误,便于排错
try:
return json.loads(candidate)
except json.JSONDecodeError as e:
# 最后手段:宽松解析(单引号→双引号)
loose = candidate.replace("'", '"')
return json.loads(loose)
把它接在每次 LLM 调用后,可解析率能从 70% 提到 99% 以上。
常见误区
- 只靠 JSON mode:各家模型的"JSON mode"只保证语法合法,不保证字段名/类型符合你的 schema,仍需 Prompt 第 2、3 层约束。
- schema 写在用户消息:把字段表和反例放进 system 消息更稳,用户消息里只放原始材料,避免被材料带偏格式。
- 要求"输出 markdown 表格再转 JSON":多一次转换多一层翻车风险,能直接 JSON 就别绕。
与平台内置的函数调用相比,这套模式的好处是模型无关、离线可测、失败可回退——换 GPT、Claude、Qwen 都能用,适合做跨模型的统一数据出口。