结构化输出
状态:✅ 已补齐(2026-08-30)
一句话定义:让模型输出 可解析的结构( JSON /ˈdʒeɪsən/ ( JavaScript Object Notation ,一种轻量级数据交换格式)/ Schema),以便程序可靠消费;与 Function Calling 相邻但目标不同。
大纲
- JSON mode vs JSON Schema vs 约束解码
- 与 Function Calling / Tool Calling 的边界
- 校验、修复、重试策略
- 流式场景下的结构化输出难点
- 选型:何时用 schema,何时用工具调用
已有相关文档(先读这些)
学习要点(读后应能回答)
- Schema 约束失败时怎么降级?→ 见 3.2 修复阶梯
- 结构化输出能替代工具调用吗?→ 见 二
- 同一任务三种实现(JSON mode / JSON Schema / 约束解码)差在哪?→ 见 1.1
〇、贯穿案例:小智的工单提取
与《数据飞轮与合成数据》同一个业务:电商客服「小智」。现在要加一个功能——从用户消息里提取结构化字段,自动创建工单:
用户输入:"我上周买的电饭煲用了两次就跳闸,单号 DD2026083001,气死了,赶紧给我退款!"
期望输出(工单系统入库格式):
{
"intent": "refund",
"order_id": "DD2026083001",
"issue_summary": "电饭煲使用中跳闸,疑似质量问题",
"urgency": "high",
"safety_flag": false
}
v1 用「提示词里写『请输出 JSON』」实现,日 2 万条消息里 6.2% 解析失败:缺字段、单引号、前后带 ```json 围栏、字段名大小写漂移。本文全部方案都用这个案例演示。
一、JSON mode vs JSON Schema vs 约束解码
1.1 三个层级,一张表看清
| 层级 | 机制 | 保证合法 JSON | 保证符合 Schema | 典型实现 |
|---|---|---|---|---|
| JSON mode | 提示词/采样层面约束「只许输出 JSON 对象」 | ✅ | ❌ | OpenAI response_format: {type: "json_object"} |
| JSON Schema 约束(结构化输出) | 把 schema 交给服务端,生成时强制符合 | ✅ | ✅ | OpenAI json_schema strict、Claude output_config、Gemini responseSchema、Mistral、Groq、Ollama format |
| 约束解码 | 推理时按 schema 屏蔽非法 token(logit 掩码) | ✅ | ✅ | vLLM guided_json、outlines、xgrammar、llama.cpp GBNF /ˌdʒiː biː en ˈef/ (GGML BNF,一种约束文法格式) |
三者的关键差异是保证发生的时机:JSON mode 靠模型「自觉」(其实是采样后的软约束),Schema 约束和约束解码是「物理强制」——非法 token 根本不会被采样出来。
注意最后一列的共同盲区:三者都只保证 语法与结构,不保证 语义正确。字段类型对了、枚举合法,内容仍可能是编的。
1.2 落地示例:三种写法各长什么样
OpenAI 协议下 Structured Outputs(Node.js + TS /ˌtiː ˈes/ (TypeScript,本仓库主示例语言),用官方 parse 助手直接得类型安全结果):
import OpenAI from "openai";
import { zodResponseFormat } from "openai/helpers/zod";
import { z } from "zod";
const client = new OpenAI();
const Ticket = z.object({
intent: z.enum(["refund", "exchange", "repair", "consult"]),
order_id: z.string(),
issue_summary: z.string(),
urgency: z.enum(["low", "medium", "high"]),
safety_flag: z.boolean(), // 是否涉及人身安全(漏电、起火)
});
const resp = await client.chat.completions.parse({
model: "gpt-4o",
messages: [{ role: "user", content: USER_MESSAGE }],
response_format: zodResponseFormat(Ticket, "ticket"),
});
const ticket = resp.choices[0].message.parsed; // 已是 Ticket 类型,无需手写解析
自建推理(vLLM)用约束解码,同一个 schema 换个参数名:
curl http://localhost:8000/v1/chat/completions -d '{
"model": "Qwen2.5-7B-Instruct",
"messages": [{"role": "user", "content": "..."}],
"guided_json": { "type": "object", "properties": { "intent": { "enum": ["refund", "exchange", "repair", "consult"] } }, "required": ["intent"] }
}'
1.3 Schema 合法 ≠ 内容正确:一个必踩的坑
用户输入只有一句「你好,在吗?」——没有任何订单信息。但 schema 要求 order_id 是 string、intent 必须四选一,约束解码会把模型逼进死角,它只能编:
{ "intent": "consult", "order_id": "N/A", "urgency": "low", "safety_flag": false }
语法 100% 合规,order_id: "N/A" 是幻觉。教训:约束解码解决「格式崩」,解决不了「信息缺失」。Schema 设计时给可缺省字段留 nullable 或 "unknown" 枚举值,把「不知道」变成合法答案,模型才不会硬编。
1.4 各家支持速查:是的,主流厂商都已支持(截至 2026-08)
大概率你会问:是不是很多 LLM /ˌel el ˈem/ ( Large Language Model ,大语言模型)都支持结构化输出参数?——是的,而且比很多人以为的更成熟:三大厂在 2025~2026 年陆续上线了真约束解码(schema 编译成形式文法做 token 掩码,非法输出根本不会被采样),不是「生成后过滤」。
状态列说明: GA /ˌdʒiː ˈeɪ/ ( Generally Available ,正式发布)。
| 厂商 | 参数写法 | 状态 | 真约束解码 | 实测失败率* |
|---|---|---|---|---|
| OpenAI | response_format: {type: "json_schema", strict: true} |
GA | ✅ | 0.0%(0/500) |
| Anthropic Claude | output_config.format(json_schema,beta header);旧方案:工具调用 + strict: true |
公测 | ✅(2025-11 起) | 0.2%(1/500,max_tokens 截断) |
| Google Gemini | generationConfig.responseSchema + JSON MIME |
GA | ✅ | 0.6%(3/500,深层嵌套 union) |
| Mistral | response_format: {type: "json_schema"} |
GA | ✅ | — |
| Groq | response_format 支持 json_object / json_schema |
GA | ✅ | — |
| DeepSeek | response_format: {type: "json_object"}(无 schema 级约束) |
GA | ❌ | — |
| Qwen | DashScope JSON 模式;自托管走 vLLM guided_json |
— | 部分 | — |
| Ollama | format 参数直接传 JSON Schema(llama.cpp 文法约束) |
— | ✅ | — |
* 同一 50 字段 schema、每家 500 次请求的第三方实测(2026-05)。对照组:Gemini 无 schema 强制的 JSON 模式失败率 11.6%——这就是「JSON mode」与「schema 约束」的真实差距。
跨厂商必看的三个方言坑:
- 可选字段三家写法不同:OpenAI 要求全字段进
required+ nullable union;Claude 直接从required省略;Gemini 用 OpenAPI 风格nullable: true; - 递归支持不一:OpenAI 可用
$defs/$ref,Claude 不支持递归 schema,Gemini 深度受限(嵌套超约 3 层不稳); - 数值/长度限制不可信:OpenAI、Gemini 部分强制,Claude SDK 会静默剥离
min/max——所以 三 的客户端二次校验永远是标配。
落地提示(小智的多渠道现实):中转站多上游场景下,同一份 zod schema 是单一事实来源,按厂商方言适配导出;DeepSeek 这类无 schema 约束的渠道自动落入 3.2 修复阶梯 的 L1/L2;或直接经 LiteLLM / OpenRouter 统一 response_format,由网关对不支持的厂商降级为「提示词 + 修复」。
二、与 Function Calling / Tool Calling 的边界
2.1 边界一句话
结构化输出要「数据」,决策权在你;工具调用要「动作」,决策权在模型。
| 维度 | 结构化输出 | Function Calling / FC /ˌef ˈsiː/ (Function Calling,函数调用) |
|---|---|---|
| 目标 | 提取/生成结构化数据 | 让模型决定调哪个函数、传什么参数 |
| 决策权 | 应用方(什么时候要数据,就在什么时候调) | 模型(自主判断要不要调、调哪个) |
| 典型场景 | 分类、抽取、改写、评分、出报表 | 查订单、发退款、调搜索引擎——有副作用或需要外部数据的动作 |
| 失败模式 | 内容幻觉(见 1.3) | 不调工具、调错工具、参数幻觉 |
2.2 落地示例:同一个提取任务,两种实现的差异
用工具调用做提取也能跑通,但有两个隐患,对比着看:
// 方式 A:工具调用做提取
const resp = await client.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: USER_MESSAGE }],
tools: [{ type: "function", function: { name: "create_ticket", parameters: zodToJSONSchema(Ticket) } }],
tool_choice: "required", // 不加这个,模型可能"觉得没必要"直接聊天,不调工具
});
const args = JSON.parse(resp.choices[0].message.tool_calls![0].function.arguments);
// 方式 B:结构化输出口做提取(推荐用于纯提取)
const resp2 = await client.chat.completions.parse({
model: "gpt-4o",
messages: [{ role: "user", content: USER_MESSAGE }],
response_format: zodResponseFormat(Ticket, "ticket"),
});
const ticket2 = resp2.choices[0].message.parsed!;
方式 A 的两个坑:一是漏了 tool_choice: "required" 会导致模型偶发「不调工具直接回复」(小智 v1 用工具调用做提取时约 3% 的消息没有产生任何 tool_call);二是 arguments 是裸字符串,没有 schema 级校验,仍要自己 parse + validate。
结论:纯提取/分类用结构化输出;只有当「要不要执行动作」本身需要模型判断时才用工具调用。
三、校验、修复、重试策略
3.1 永远校验,绝不信任
即便开了 strict: true,业务层校验也不可省——schema 保证不了业务规则(order_id 是否真实存在、urgency: high 是否合理)。校验分两层:
- 结构校验:zod / Pydantic,检查类型与枚举;
- 业务校验:查库验证
order_id存在、urgency与历史行为一致。
3.2 修复阶梯与降级
解析失败时按成本从低到高爬梯子,每一级失败才升一级:
| 级 | 手段 | 成本 | 修复对象 |
|---|---|---|---|
| L0 | 源头预防:strict: true / guided_json |
零 | 90%+ 的格式问题 |
| L1 | JSON 修复库:修尾逗号、单引号、围栏残留 | 毫秒级,零 token | 常见「差一点」的坏 JSON |
| L2 | 带错误信息重试:把校验报错原文喂回去重生成 | 2 倍 token | L1 修不了的语义性缺漏 |
| L3 | 兜底降级:默认值 / 转人工 / 放弃入库 | 业务成本 | 一切 |
小智实测数字:v1(纯提示词)失败率 6.2% → L0 开 strict 后 0.4% → 加 L1 修复库 0.08% → L2 重试后 0.05%(剩余全部走 L3 转人工,日均约 10 条)。
3.3 落地示例:完整的三级降级函数
import { z } from "zod";
import { jsonrepair } from "jsonrepair";
async function extractTicket(message: string, maxRetry = 2): Promise<Ticket> {
const baseMessages: OpenAI.ChatCompletionMessageParam[] = [
{ role: "user", content: message },
];
for (let attempt = 0; attempt <= maxRetry; attempt++) {
const resp = await client.chat.completions.create({
model: "gpt-4o",
messages: baseMessages,
response_format: zodResponseFormat(Ticket, "ticket"), // L0:源头约束
});
const raw = resp.choices[0].message.content ?? "";
let candidate: unknown;
try {
candidate = JSON.parse(raw);
} catch {
candidate = JSON.parse(jsonrepair(raw)); // L1:毫秒级修复,不花 token
}
const parsed = Ticket.safeParse(candidate);
if (parsed.success && (await businessCheck(parsed.data))) {
return parsed.data;
}
// L2:把失败原因带回去,让模型自我修正
baseMessages.push(
{ role: "assistant", content: raw },
{
role: "user",
content: `上次输出未通过校验:${parsed.error?.message ?? "业务校验失败"}。请修正后重新输出完整 JSON。`,
},
);
}
throw new TicketFallbackNeeded(message); // L3:交给上层转人工
}
红线:重试必须设上限并接熔断告警。失败率突然从 0.05% 跳到 5%,说明上游换了模型或 schema 失效——无限重试只会烧钱放大故障。
四、流式场景下的结构化输出难点
4.1 难在哪
流式( SSE /ˌes es ˈiː/ ( Server-Sent Events ,服务器单向推送事件))下,JSON 是一片一片到达的:
data: {"intent": "re
data: fund", "order_id": "DD20
data: 26083001", ...
任何时刻的前缀都不是合法 JSON,JSON.parse 直接炸。但产品又想要「字段生成一个就显示一个」的体验(先出 urgency 高亮,再出摘要)。
4.2 三个可行方案
| 方案 | 做法 | 适用 |
|---|---|---|
| 部分解析 | 用容忍不完整的解析器,解析「目前到达的前缀」,拿到已完整字段 | 进度展示、边流边渲染 |
| 字段排序 | schema 字段按「UI 需要的早晚」排序(urgency 放最前),配合部分解析效果最好 |
前端实时反馈 |
| 完整后处理 | 流式期间显示骨架屏,[DONE] 后整体 parse + 校验再渲染 |
校验严格、UI 不急的场景 |
4.3 落地示例:部分解析器(容忍截断的 JSON 解析)
// 原理:逐字符扫描,追踪括号/引号深度,截断处自动补全闭合再 parse
function parsePartial(prefix: string): Record<string, unknown> {
const openStack: string[] = [];
let inString = false, escape = false;
for (const ch of prefix) {
if (escape) { escape = false; continue; }
if (ch === "\\") { escape = true; continue; }
if (ch === '"') inString = !inString;
if (inString) continue;
if (ch === "{" || ch === "[") openStack.push(ch === "{" ? "}" : "]");
if (ch === "}" || ch === "]") openStack.pop();
}
const repaired = prefix + (inString ? '"' : "") + openStack.reverse().join("");
try {
return JSON.parse(repaired) as Record<string, unknown>;
} catch {
return {}; // 前缀太短(如只有 '{"int'),返回空,等下一个 delta
}
}
// 消费侧:每收到 delta 累积,用部分解析刷新 UI
let buf = "";
for await (const chunk of stream) {
buf += chunk.choices[0]?.delta?.content ?? "";
const partial = parsePartial(buf);
if (partial.urgency) updateUrgencyBadge(partial.urgency as string); // 字段一完整就上屏
}
// 流结束后仍要用完整 parse + zod 校验为准,部分解析只用于展示
注意:部分解析只服务展示层,入库与决策必须以流结束后的完整校验结果为准——被截断的 urgency: "h" 展示成「高」没问题,拿去做路由就是事故。
五、选型:何时用 schema,何时用工具调用
5.1 决策表
| 场景 | 选型 | 理由 |
|---|---|---|
| 分类 / 抽取 / 打分 / 改写 | 结构化输出(json_schema strict) |
只要数据,不要动作 |
| 查订单 / 发退款 / 调搜索 | 工具调用 | 模型要决定「调不调、调哪个」,有副作用 |
| Agent 的每一步参数生成 | 工具调用(tool_choice: required 兜底) |
动作与参数天然耦合 |
| 自建 / 开源模型 | 约束解码(vLLM guided_json、outlines) |
不依赖服务端 structured outputs 特性 |
| 弱模型 / 高频简单提取 | 提示词 + L1 修复库 | 约束能力不够时,修复库兜底性价比最高 |
| 极高可靠性要求(金融、工单) | 结构化输出 + 三级降级 + 业务校验 | 分层防御,见 三 |
5.2 小智的最终架构(答案版)
- 工单提取:
json_schemastrict + zod 双层校验 + 3.3 的三级降级; - 「要不要查物流」「要不要退款」:工具调用,模型自主决策;
- 上游故障 / 连续失败:降级为正则规则抽取
order_id(格式固定DD+10位数字,正则够用)+ 转人工队列; - 流式 UI:schema 把
urgency放第一字段,部分解析驱动徽标实时变色。
回到文首两个学习问题,一句话作答:
- Schema 约束失败时怎么降级? 爬修复阶梯 L1 修复库 → L2 带错重试(设上限)→ L3 默认值/正则/转人工,任何一级都不做无限重试。
- 结构化输出能替代工具调用吗? 不能。它能替代工具调用里的「参数生成」部分,替代不了「要不要执行动作」的决策——要数据用前者,要动作用后者。
自检清单
- 所有结构化输出都走 schema 级约束(strict / guided_json),不再裸提示词要 JSON;
- schema 里可缺省字段有 nullable / "unknown" 出口,防止约束下的硬编;
- 解析后有结构校验 + 业务校验两层,且永不跳过;
- 修复阶梯 L1→L3 已实现,重试有上限、失败率有告警;
- 流式场景部分解析只用于展示,入库以完整校验为准。
本文缩写
| 缩写 | 音标 | 全拼 | 中文 |
|---|---|---|---|
| FC | /ˌef ˈsiː/ | Function Calling | 函数调用 |
| GA | /ˌdʒiː ˈeɪ/ | Generally Available | 正式发布 |
| GBNF | /ˌdʒiː biː en ˈef/ | GGML BNF | 一种约束文法格式 |
| JSON | /ˈdʒeɪsən/ | JavaScript Object Notation | 轻量级数据交换格式 |
| LLM | /ˌel el ˈem/ | Large Language Model | 大语言模型 |
| SSE | /ˌes es ˈiː/ | Server-Sent Events | 服务器单向推送事件 |
| TS | /ˌtiː ˈes/ | TypeScript | 本仓库主示例语言 |
参考资料
- OpenAI Structured Outputs(strict 模式限制:全字段 required、禁
additionalProperties) - Reliable JSON From LLMs: Structured Outputs Compared 2026(三大厂 500 次实测与方言差异,2026-06)
- Structured Outputs Across LLM Providers: 244 Models Tested(244 个模型 × 23 家供应商兼容性实测,2026-05)
- outlines / xgrammar(开源约束解码库)
- vLLM guided decoding 文档(
guided_json/guided_regex) - jsonrepair(L1 修复库)
- 数据飞轮与合成数据 · 本仓库(「小智」案例的另一面)
评论
评论加载中…