LLM 结构化输出实战:让大模型生成可靠 JSON 的技术原理与最佳实践

深入解析大语言模型结构化输出的技术原理,包括 JSON Mode、Constrained Decoding、Grammar-based Sampling 等机制,提供 OpenAI、Anthropic、开源模型的完整实战代码和避坑指南。

开发者效率 2026-05-28 15 分钟

2026 年初,Anthropic 发布了 Claude Opus 4.8,OpenAI 紧随其后推出了 GPT-5 Turbo,大模型的能力边界再次被刷新。但在实际工程中,开发者面临的最大痛点不是模型不够聪明,而是大模型的输出不可靠——你让它返回 JSON,它偏给你加一段自然语言解释;你定义了严格的 Schema,它偏偏少一个字段或者多一个嵌套。据统计,未经结构化约束的 LLM 调用中,JSON 格式错误率高达 15-30%,这意味着每 4 次 API 调用就有至少 1 次需要重试或人工修复。

🔐 一、为什么 LLM 输出 JSON 这么难

🧠 自回归生成的本质限制

大语言模型(LLM)本质上是一个 Token 级别的自回归预测器——每一步只根据已有的 Token 序列预测下一个最可能的 Token。这个过程中,模型并没有一个"全局结构意识",它不知道自己正在生成一个嵌套了 3 层的 JSON 对象,也不知道还差一个闭合的 }

这就好比让一个人蒙着眼睛拼拼图——每一步只能根据已经拼好的部分猜测下一块,但无法看到完整的设计图。当你要求模型输出一个复杂的 JSON Schema 时,以下问题频繁出现:

  • 多余的内容:JSON 前后加了 ```json ``` 代码块标记或自然语言解释
  • 结构错误:缺少闭合括号、多余逗号、嵌套层级错误
  • 类型不符:数字字段返回字符串,布尔值返回 "true" 而非 true
  • 字段缺失/多余:Schema 要求的字段漏掉,或添加了未定义的字段
  • 枚举不匹配:返回了 Schema 中未定义的枚举值

⚠️ 警告: 即使是 GPT-4o 和 Claude Opus 4.8 这样的顶级模型,在复杂 JSON Schema(超过 20 个字段、3 层嵌套)下,首次生成的格式正确率也仅有 85% 左右。不要盲目信任模型输出,必须做验证。

📊 三种结构化输出方案的原理对比

目前业界解决 LLM 输出格式问题,主要有三种技术路线。理解它们的原理和差异,是选择合适方案的前提:

方案 原理 可靠性 灵活性 延迟开销 代表实现
Prompt 约束 在提示词中描述期望格式 ⭐⭐ 低 ⭐⭐⭐⭐ 高 所有模型
JSON Mode API 层强制输出合法 JSON ⭐⭐⭐ 中 ⭐⭐⭐ 中 极低 OpenAI、Anthropic
Constrained Decoding 解码时限制 Token 概率分布 ⭐⭐⭐⭐⭐ 高 ⭐⭐ 低 llama.cpp、Outlines、vLLM

关键结论: Prompt 约束是最低成本但最不可靠的方案;Constrained Decoding 是最可靠但需要模型本地部署的方案;JSON Mode 是云端 API 的最佳平衡点。生产环境中,推荐 JSON Mode + 后置验证的组合策略。

🚀 二、主流平台的结构化输出实战

🔵 OpenAI Structured Outputs

OpenAI 在 2024 年推出了 Structured Outputs 功能,这是目前云端 API 中最成熟的结构化输出方案。它的核心思路是:将 JSON Schema 传递给 API,模型在生成时会严格按照 Schema 的约束进行解码。

// OpenAI Structured Output 实战 — 使用 Zod Schema 定义输出结构
import OpenAI from "openai";
import { zodResponseFormat } from "openai/helpers/zod";
import { z } from "zod";

// 定义严格的输出 Schema
const ProductAnalysis = z.object({
  name: z.string().describe("产品名称"),
  category: z.enum(["electronics", "clothing", "food", "other"]),
  sentiment: z.enum(["positive", "neutral", "negative"]),
  score: z.number().min(0).max(100).describe("评分 0-100"),
  key_points: z.array(z.string()).min(1).max(5).describe("关键要点"),
  price_range: z.object({
    min: z.number(),
    max: z.number(),
    currency: z.string(),
  }),
});

const openai = new OpenAI();

async function analyzeProduct(review: string) {
  const completion = await openai.beta.chat.completions.parse({
    model: "gpt-4o-2024-08-06",
    messages: [
      { role: "system", content: "分析用户评论,提取产品信息和情感倾向。" },
      { role: "user", content: review },
    ],
    response_format: zodResponseFormat(ProductAnalysis, "product_analysis"),
  });

  const result = completion.choices[0].message.parsed;
  // result 的类型是 z.infer<typeof ProductAnalysis>,完全类型安全
  return result;
}

📌 记住: OpenAI 的 response_format: { type: "json_schema" }beta.chat.completions.parse() 是两个不同层级的 API。前者只保证输出合法 JSON,后者还会做 Zod Schema 验证并返回类型安全的结果。生产代码强烈推荐使用后者。

OpenAI 的 Structured Outputs 有一个重要限制:不支持所有 JSON Schema 特性。以下特性是不支持的:

// ❌ 以下 Schema 特性在 OpenAI Structured Outputs 中不支持
const UnsupportedSchema = {
  // 不支持 anyOf / oneOf(union types)
  // 不支持 additionalProperties: true(必须显式设置为 false)
  // 不支持 format: "date-time"(只支持基本 format)
  // 不支持 $ref(引用)
  // 不支持默认值(default)
  // Schema 最大层级嵌套深度为 5 层
  // Schema 最大 Token 数为 100
};

🟠 Anthropic Tool Use 模拟结构化输出

Anthropic 的 Claude 模型目前没有独立的 JSON Mode 或 Structured Outputs 功能,但可以通过 Tool Use(工具调用)巧妙地实现结构化输出。这个技巧的本质是:定义一个"伪工具",让模型通过填充工具参数的方式输出结构化数据。

// Anthropic Tool Use 模拟结构化输出 — TypeScript 实现
import Anthropic from "@anthropic-ai/sdk";

interface CodeReviewResult {
  files: Array<{
    path: string;
    issues: Array<{
      line: number;
      severity: "error" | "warning" | "info";
      message: string;
      suggestion: string;
    }>;
  }>;
  summary: {
    total_issues: number;
    risk_level: "low" | "medium" | "high" | "critical";
    estimated_fix_time: string;
  };
}

async function reviewCode(code: string): Promise<CodeReviewResult> {
  const client = new Anthropic();

  const response = await client.messages.create({
    model: "claude-sonnet-4-20250514",
    max_tokens: 4096,
    tools: [
      {
        name: "submit_code_review",
        description: "提交代码审查结果",
        input_schema: {
          type: "object",
          properties: {
            files: {
              type: "array",
              items: {
                type: "object",
                properties: {
                  path: { type: "string" },
                  issues: {
                    type: "array",
                    items: {
                      type: "object",
                      properties: {
                        line: { type: "number" },
                        severity: {
                          type: "string",
                          enum: ["error", "warning", "info"],
                        },
                        message: { type: "string" },
                        suggestion: { type: "string" },
                      },
                      required: ["line", "severity", "message", "suggestion"],
                    },
                  },
                },
                required: ["path", "issues"],
              },
            },
            summary: {
              type: "object",
              properties: {
                total_issues: { type: "number" },
                risk_level: {
                  type: "string",
                  enum: ["low", "medium", "high", "critical"],
                },
                estimated_fix_time: { type: "string" },
              },
              required: ["total_issues", "risk_level", "estimated_fix_time"],
            },
          },
          required: ["files", "summary"],
        },
      },
    ],
    tool_choice: { type: "tool", name: "submit_code_review" }, // 强制使用工具
    messages: [
      { role: "user", content: `请审查以下代码:\n\n${code}` },
    ],
  });

  // 提取工具调用的参数作为结构化结果
  const toolUse = response.content.find((block) => block.type === "tool_use");
  return toolUse!.input as CodeReviewResult;
}

💡 提示: tool_choice: { type: "tool", name: "submit_code_review" } 是关键——它强制模型必须调用这个工具,而不是选择输出自然语言。这等价于 OpenAI 的 function_call: { name: "xxx" } 参数。

🟢 开源模型的 Constrained Decoding

对于本地部署的开源模型(Llama、Mistral、Qwen 等),Constrained Decoding 是最可靠的结构化输出方案。它的原理是在每一步解码时,根据 JSON Schema 动态计算合法的 Token 集合,将不合法 Token 的概率设为 0。

# 使用 Outlines 库实现 Constrained Decoding — Python 示例
import outlines
from pydantic import BaseModel, Field
from enum import Enum
from typing import List

# 定义 Pydantic 模型作为输出约束
class Sentiment(str, Enum):
    positive = "positive"
    negative = "negative"
    neutral = "neutral"

class ReviewAnalysis(BaseModel):
    summary: str = Field(description="一句话总结")
    sentiment: Sentiment
    confidence: float = Field(ge=0.0, le=1.0)
    keywords: List[str] = Field(min_length=1, max_length=10)
    rating: int = Field(ge=1, le=5)

# 加载本地模型(首次运行会下载模型)
model = outlines.models.transformers("Qwen/Qwen2.5-7B-Instruct")

# 创建带 Schema 约束的生成器
generator = outlines.generate.json(model, ReviewAnalysis)

# 生成 — 输出 100% 符合 Schema,不可能出现格式错误
review_text = "这家餐厅的菜品非常新鲜,服务态度也很好,但等位时间太长了。"
result = generator(
    f"分析以下评论的情感和评分:\n{review_text}"
)

print(result)
# ReviewAnalysis(
#   summary="菜品新鲜服务好但等位久",
#   sentiment=Sentiment.positive,
#   confidence=0.85,
#   keywords=["菜品新鲜", "服务好", "等位长"],
#   rating=4
# )

Outlines 的 Constrained Decoding 之所以能保证 100% 的格式正确率,是因为它在 Token 采样阶段就排除了所有不合法的 Token。具体实现方式是:

  1. 将 JSON Schema 编译为一个有限状态自动机(FSM)
  2. 在每一步解码时,根据当前 FSM 状态计算合法的 Token 集合
  3. 将不合法 Token 的 logit 设为负无穷(概率为 0)
  4. 从合法 Token 中采样

⚠️ 警告: Constrained Decoding 会增加推理延迟(约 10-30%),因为每一步都需要计算合法 Token 集合。此外,过度约束可能导致模型"被迫"生成低质量内容——例如当 Schema 要求一个 enum 但模型没有足够信息判断正确值时,它会随机选一个。建议在 Schema 中保留适度的灵活性。

💡 三、生产环境的最佳实践与避坑指南

🛡️ 分层防御策略

单一方案无法覆盖所有异常情况,生产环境中应采用分层防御策略:

// 分层防御:JSON Mode + Schema 验证 + 重试 + 降级
import { z } from "zod";
import OpenAI from "openai";

const openai = new OpenAI();

// 第一层:定义 Schema
const TaskResultSchema = z.object({
  status: z.enum(["completed", "failed", "partial"]),
  data: z.object({
    items: z.array(z.object({
      id: z.string(),
      value: z.number(),
      label: z.string(),
    })),
    total: z.number(),
  }),
  errors: z.array(z.string()).optional(),
});

type TaskResult = z.infer<typeof TaskResultSchema>;

// 第二层:带重试的结构化调用
async function getStructuredOutput<T>(
  schema: z.ZodSchema<T>,
  messages: OpenAI.ChatCompletionMessageParam[],
  maxRetries = 3
): Promise<T> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = await openai.chat.completions.create({
        model: "gpt-4o-2024-08-06",
        messages,
        response_format: { type: "json_object" }, // JSON Mode
        temperature: attempt > 1 ? 0.1 : 0.3, // 重试时降低温度
      });

      const content = response.choices[0].message.content;
      if (!content) throw new Error("Empty response");

      // 第三层:Zod 验证
      const parsed = JSON.parse(content);
      return schema.parse(parsed); // 验证失败会抛出 ZodError

    } catch (error) {
      if (attempt === maxRetries) {
        // 第四层:降级返回默认值
        console.error(`Structured output failed after ${maxRetries} attempts`);
        return schema.parse(getFallbackResult());
      }
      // 重试前将错误信息加入下一轮 prompt
      messages.push({
        role: "assistant",
        content: "I'll fix the output format.",
      });
      messages.push({
        role: "user",
        content: `Previous output had error: ${error.message}. Please fix and return valid JSON.`,
      });
    }
  }
  throw new Error("Unreachable");
}

function getFallbackResult(): unknown {
  return {
    status: "failed",
    data: { items: [], total: 0 },
    errors: ["Service temporarily unavailable"],
  };
}

⚠️ 常见坑点与解决方案

经过大量生产实践,以下是最常见的坑点和对应的解决方案:

坑点 原因 解决方案
模型输出 ```json ``` 包裹 Prompt 中要求了"输出 JSON" 使用 JSON Mode,或在 prompt 中明确说"不要使用代码块标记"
数字被引号包裹返回字符串 LLM 无法区分 "42"42 Zod 验证 + .coerce.number() 自动转换
日期格式不统一 模型随机选择 ISO/Unix/中文格式 Schema 中用 z.string().regex() 强制格式,或用 z.coerce.date()
长文本被截断 max_tokens 不够 根据 Schema 估算最大 Token 数,留 20% 余量
中文内容英文字段名的困惑 模型混淆语言 Schema 用英文字段名,description 用中文说明
数组元素类型不一致 模型"创造性"地混合类型 z.array() 内定义严格的元素 Schema

📈 性能与成本对比

选择结构化输出方案时,性能和成本是关键考量因素:

指标 Prompt 约束 JSON Mode (OpenAI) Constrained Decoding (本地)
格式正确率 70-85% 95-99% 100%
平均重试次数 1.5-2.5 次 0.01-0.1 次 0 次
额外 Token 消耗 +200-500 (prompt) +0 +0
额外延迟 0 ms ~50 ms +10-30% 推理时间
适用场景 简单、一次性任务 云端 API 生产环境 本地部署、高可靠性需求
月成本估算 (100万次调用) $500-800 (含重试) $600-700 硬件成本 ~$200/月

关键结论: 对于 90% 的 Web 应用场景,OpenAI 的 JSON Mode + Zod 验证 + 1 次重试是最优方案——开发成本低、可靠性够高、不需要本地部署。只有在金融、医疗等对格式正确率要求 100% 的场景下,才需要投入 Constrained Decoding 的额外工程成本。

✅ 总结

LLM 结构化输出已经从一个"能用就行"的辅助功能,演变为生产系统中不可或缺的基础设施。选择合适的方案需要综合考虑可靠性、成本和工程复杂度:

  • 云端 API 用户:优先使用平台原生的 JSON Mode 或 Structured Outputs,配合 Zod/Pydantic 做二次验证
  • 本地部署用户:使用 Outlines 或 vLLM 的 Constrained Decoding,获得 100% 格式保障
  • 避免:仅靠 Prompt 约束来控制输出格式,这在生产环境中是不可接受的
  • 避免:设计过于复杂的 JSON Schema(超过 5 层嵌套、50+ 字段),即使技术上可行,模型的语义准确率也会大幅下降
  • ⚠️ 始终:对 LLM 的结构化输出做后置验证,永远不要信任"看起来像 JSON"的输出就直接入库

相关工具推荐:

  • 🔧 Zod — TypeScript Schema 验证库,与 OpenAI SDK 深度集成
  • 🔧 Pydantic — Python 数据验证,Outlines 和 Instructor 的核心依赖
  • 🔧 Outlines — 开源 Constrained Decoding 引擎
  • 🔧 Instructor — 支持多平台的结构化输出 Python 库
  • 🔧 jsjson.com JSON Schema 验证工具 — 在线验证你的 JSON Schema 是否符合规范

📚 相关文章