让 LLM 稳定输出结构化数据:JSON、Schema 与容错
"让模型吐 JSON,是 AI 应用最常见的工程需求,也是最常见的翻车点——多一个字段、少一个逗号、混进一句解释,解析就崩。本文按可靠性递进讲四档方案:prompt 约束、JSON mode、Function Calling、受限解码(grammar),再给一套解析+容错的兜底,让结构化输出从'大多数时候行'变'生产稳'。"
本文要点
- 主题聚焦:AI、TypeScript
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
只要你的 AI 应用要把模型输出接进下游代码(基本上都有这个需求),你就逃不开"让模型吐结构化数据"。而只要你做过,就一定被它坑过:要求 JSON,它给你 JSON 外面裹一段"好的,结果是:";要求字段 A,它给你字段 B;偶尔心情好还送你一段 Markdown 解释。
这一篇按可靠性递进讲四档方案,你按场景选到够用的那一档就行。
一、为什么"在 prompt 里说'只输出 JSON'"不够
最朴素的方案:
请把以下内容提取为 JSON,只输出 JSON,不要任何其他文字。
{name, age, email}
它在 demo 里 90% 能成,但在生产里那 10% 的失败就是事故——因为这是靠模型自觉,没有任何强制。模型偶尔会:
- 多输出一句解释。
- 用单引号或尾随逗号(非法 JSON)。
- 编一个 schema 里没有的字段。
- 输出到一半截断。
靠 prompt 约束的 JSON,不能用于关键路径。 它是最低档,只适合原型或可容忍失败的场景。
二、第二档:JSON Mode / Response Format
主流厂商 API 现在都提供"JSON 模式"(OpenAI response_format: {type:"json_object"}、Gemini 的 responseMimeType、Anthropic 没有原生 JSON mode 但可用工具间接实现)。
它保证输出是合法 JSON(语法层面不会错),但不保证 schema——字段名、类型、嵌套结构仍可能偏离你的期望。也就是说:"是个 JSON"被保证了,"是你想要的那个 JSON"没有被保证。
适用:schema 简单、容忍字段出入的场景。比纯 prompt 强一档,但别指望它保证结构。
三、第三档:Function Calling / Tool Use(推荐主力)
这是目前生产里最实用的结构化输出方式。你定义一个"工具",它的参数 schema 就是你想要的结构:
const tools = [{
type: "function",
function: {
name: "extract_person",
description: "Extract a person's info from the text.",
parameters: {
type: "object",
required: ["name", "email"],
properties: {
name: { type: "string" },
age: { type: "number" },
email: { type: "string", format: "email" },
role: { type: "string", enum: ["user", "admin"] },
},
},
},
}];
模型被训练成"调用工具时填参数",而填参数天然是结构化的——厂商会在这一路做约束,字段名、类型、enum 基本对得上。你拿到的 tool_call.arguments 就是符合 schema 的 JSON。
为什么它比 JSON mode 强?因为:
- schema 显式约束(required、enum、type 都生效)。
- 模型天然理解"填表"这件事,比"吐 JSON"更稳。
- 厂商专门对这一路做了强化训练。
适用:绝大多数生产场景。哪怕你并不真的要"调工具",也可以借 function calling 拿到结构化输出——这是业界通行技巧。
四、第四档:受限解码(Grammar / Outlines)
如果你要接近 100% 的 schema 保证(比如字段必须 enum、数字范围、嵌套结构),用受限解码:在解码时用语法强制,模型只能在合法 token 里走。
代表项目:Outlines、lm-format-enforcer、各大推理框架的 grammar 约束(GBNF)。它们的原理是:你给一个 schema/语法,解码器在每一步屏蔽掉会让输出非法的 token,从根上保证输出符合结构。
# Outlines 示例(本地模型)
from outlines import models, generate
model = models.transformers("mistral-7b")
schema = '{"name": str, "age": int, "email": str}'
generator = generate.json(model, schema)
result = generator("Extract: ...") # 100% 符合 schema
代价:只对自托管/开源模型可行(闭源 API 不开放解码层)。所以这一档适合你自建模型 + 对结构正确性要求极高的场景。
五、解析与容错:不管哪一档都要有
无论你选哪档,下游解析都要做容错——因为现实里总会遇到意外:
function safeParse(raw: string): Record<string, unknown> {
// 1. 直接解析
try { return JSON.parse(raw); } catch {}
// 2. 提取首个 {...} 或 [...] 片段(剥掉解释文字)
const m = raw.match(/[{[][\s\S]*[}\]]/);
if (m) { try { return JSON.parse(m[0]); } catch {} }
// 3. 修复常见问题(尾随逗号、单引号)后重试
try { return JSON.parse(m[0].replace(/,\s*([}\]])/g, "$1").replace(/'/g, '"')); } catch {}
throw new Error("unparseable model output");
}
加上校验(zod / ajv):解析出来不等于符合 schema,用 schema validator 验一遍,不通过就带着错误信息重试(把"你输出的字段缺了 email"回灌给模型让它改)。
这套"解析 → 提取 → 修复 → 校验 → 带错误重试"的兜底链,是把任何一档方案推向"生产稳"的必备。别假设模型永远输出正确,假设它偶尔会错,并让代码能恢复。
六、按场景选档
| 场景 | 推荐档位 |
|---|---|
| 原型 / 容忍失败 | 第一档(prompt) |
| schema 简单、不关键 | 第二档(JSON mode) |
| 生产主力 | 第三档(function calling) |
| 自托管模型 + 必须零错误 | 第四档(受限解码) |
| 任何档位,下游 | 必加解析+校验+重试 |
小结
让 LLM 输出结构化数据,可靠性是从"靠嘴说"到"解码层强制"逐档提升的:
- prompt 约束:最弱,原型用。
- JSON mode:保证语法,不保证结构。
- Function calling:生产主力,schema 基本对得上。
- 受限解码:接近 100% 保证,但要自托管模型。
无论选哪档,下游都必备解析 → 修复 → 校验 → 带错误重试的容错链。生产系统里,"模型偶尔输出错"是必然事件,工程的责任是让它即便错了也能恢复——这才是"结构化输出"从 demo 走向生产的分水岭。
Related