结构化输出

状态:✅ 已补齐(2026-08-30)
一句话定义:让模型输出 可解析的结构JSON /ˈdʒeɪsən/ ( JavaScript Object Notation ,一种轻量级数据交换格式)/ Schema),以便程序可靠消费;与 Function Calling 相邻但目标不同。

大纲

  1. JSON mode vs JSON Schema vs 约束解码
  2. 与 Function Calling / Tool Calling 的边界
  3. 校验、修复、重试策略
  4. 流式场景下的结构化输出难点
  5. 选型:何时用 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 约束」的真实差距。

跨厂商必看的三个方言坑

  1. 可选字段三家写法不同:OpenAI 要求全字段进 required + nullable union;Claude 直接从 required 省略;Gemini 用 OpenAPI 风格 nullable: true
  2. 递归支持不一:OpenAI 可用 $defs / $ref,Claude 不支持递归 schema,Gemini 深度受限(嵌套超约 3 层不稳);
  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 是否合理)。校验分两层:

  1. 结构校验:zod / Pydantic,检查类型与枚举;
  2. 业务校验:查库验证 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", ...

任何时刻的前缀都不是合法 JSONJSON.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_schema strict + 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 本仓库主示例语言

参考资料