结构化输出实战:让大模型输出可编程的 JSON

作者:Maurice | 灵阙学院


前言

大模型最令人头疼的生产问题不是"输出错了",而是"输出了一个几乎正确的 JSON"。

漏了一个逗号,多了个 markdown 代码块围栏,把数字输出成字符串——这类问题不会在开发阶段暴露,专挑凌晨三点生产告警的时候出现。

本文系统梳理结构化输出的三种实现路径、各主流厂商 API 的落地方法、Pydantic + Instructor 的 2025 最佳实践,以及高阶的"两步推理法"。目标只有一个:让大模型输出真正可编程的 JSON,而不是"看起来像 JSON 的字符串"。


一、为什么需要结构化输出

1.1 工程系统需要可解析的数据

大模型的默认输出是自然语言,设计上就是为人类阅读优化的。但当 LLM 嵌入工程系统时,下游往往是另一个程序:

  • 数据提取管道:从合同文本提取甲方、金额、日期
  • API 响应生成:LLM 直接充当微服务,输出符合 OpenAPI 规范的响应
  • 多步骤 Agent:每一步的输出作为下一步的输入,必须机器可读
  • 评测系统:模型打分结果需要写入数据库

这些场景对输出格式的要求是 100% 确定性,而不是"大概率正确"。

1.2 "几乎正确的 JSON"是最危险的 bug

与其说 LLM 会输出乱码,不如说它会输出"高度相似但不合规"的结构。常见的生产问题包括:

# 模型可能输出的各种"坏 JSON"

# 问题1:Markdown 代码块包裹
```json
{"name": "张三", "age": 28}

问题2:字段名不一致(age vs Age vs user_age)

{"Name": "张三", "Age": "28"}

问题3:数字变字符串

{"name": "张三", "age": "二十八"}

问题4:多余的解释文字

这是结果:{"name": "张三", "age": 28},希望符合您的需求。

问题5:截断(尤其在 streaming 场景)

{"name": "张三", "age": 28, "address": "北京市朝


每一种都会导致 `json.loads()` 抛出异常,或者字段值类型不匹配的 bug。

### 1.3 问题的本质

LLM 的解码过程是逐 token 的概率采样。在没有约束的情况下,模型会用自然语言的直觉来"猜测"什么是好的输出——而自然语言的直觉和 JSON RFC 规范并不完全一致。结构化输出的本质,是在解码层面施加约束,让模型只能生成符合规范的 token 序列。

---

## 二、三种实现方式对比

### 2.1 方式一:Prompt 约束(Prompt-Only)

最简单也最脆弱的方式。在 System Prompt 或 User Prompt 中明确要求模型输出 JSON:

```python
system_prompt = """
你是一个信息提取助手。
请严格按照以下 JSON 格式输出,不要包含任何其他文字:
{"name": "string", "age": "integer", "city": "string"}
"""

问题:模型会"努力遵守",但遵守率受模型能力、提示词质量、输出长度影响,在复杂 Schema 下可靠性显著下降。这是一种"软约束",没有任何技术保证。

2.2 方式二:JSON Mode

OpenAI、Anthropic(早期)、Google 都提供 JSON Mode。在 API 层面开启后,模型保证输出合法的 JSON(json.loads() 不报错),但不保证字段名、字段类型、Schema 结构

# OpenAI JSON Mode 示例
response = client.chat.completions.create(
    model="gpt-4o",
    response_format={"type": "json_object"},  # 开启 JSON Mode
    messages=[{"role": "user", "content": "提取姓名和年龄,以 JSON 输出"}]
)

局限:你可能得到 {"姓名": "张三", "年龄": 28},也可能得到 {"name": "张三", "年": 28}。格式合法,但 Schema 不确定。

注意:使用 JSON Mode 时,System Prompt 中必须提及 "JSON",否则 OpenAI API 会报错。

2.3 方式三:Structured Output(Schema 约束)

2024-2025 年各主流厂商相继推出的最高强度保证。在 JSON Mode 的基础上,额外传入 JSON Schema,模型通过受约束的解码(constrained decoding)保证输出完全符合 Schema。

# OpenAI Structured Output 示例
response = client.chat.completions.create(
    model="gpt-4o-2024-08-06",
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "person_info",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "age": {"type": "integer"}
                },
                "required": ["name", "age"],
                "additionalProperties": False
            }
        }
    },
    messages=[{"role": "user", "content": "从文本提取人员信息:张三今年28岁"}]
)

2.4 三种方式对比表

维度 Prompt 约束 JSON Mode Structured Output
合法 JSON 保证
Schema 一致性保证
可靠性 低(70-90%) 中(99%+合法JSON) 高(接近100%)
灵活性 最高 中(Schema有限制)
支持厂商 全部 OpenAI、Gemini、Mistral等 OpenAI、Anthropic(Beta)、Google
延迟影响 轻微 轻微(+5-15ms)
Schema 深度限制 OpenAI 最多 5 层嵌套、100 属性
推荐场景 原型验证 简单内容生成 生产数据管道

结论:生产环境优先使用 Structured Output;不支持时退回 JSON Mode;仅在快速原型阶段使用 Prompt 约束。


三、各厂商 API 实战

3.1 OpenAI:原生 JSON Schema 支持

OpenAI 从 gpt-4o-2024-08-06 开始支持 Structured Output,Python SDK 支持直接传入 Pydantic 模型。

from openai import OpenAI
from pydantic import BaseModel
from typing import List, Optional

client = OpenAI()

class Address(BaseModel):
    street: str
    city: str
    province: str

class PersonInfo(BaseModel):
    name: str
    age: int
    occupation: Optional[str] = None
    address: Address

# 方式A:直接传 Pydantic 模型(推荐,SDK 自动转换为 JSON Schema)
completion = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "从用户提供的文本中提取人员信息。"},
        {"role": "user", "content": "张三是北京朝阳区的一名软件工程师,今年32岁。"}
    ],
    response_format=PersonInfo,
)

person = completion.choices[0].message.parsed
print(person.name)       # 张三
print(person.address.city)  # 北京

3.2 Anthropic Claude:Structured Output Beta(2025.11 发布)

Claude 于 2025 年 11 月正式发布 Structured Output(公测),支持 output_format 参数配合 JSON Schema,需要传 beta header。在此之前,生产环境通常用 tool_use 模拟结构化输出。

import anthropic
from pydantic import BaseModel

client = anthropic.Anthropic()

class ExtractedEntity(BaseModel):
    entity_type: str   # PERSON / ORG / LOCATION
    entity_name: str
    context: str

class ExtractionResult(BaseModel):
    entities: list[ExtractedEntity]
    confidence: float

# 方式A:Structured Output Beta(Claude Sonnet 4.5 / Opus 4.1)
response = client.beta.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=1024,
    messages=[{"role": "user", "content": "从以下文本提取实体:阿里巴巴创始人马云在杭州创建了公司。"}],
    betas=["structured-outputs-2025-11-13"],
    output_format={
        "type": "json_schema",
        "json_schema": ExtractionResult.model_json_schema()
    }
)

# 方式B:tool_use 模拟(兼容旧版 Claude,生产中仍广泛使用)
tools = [
    {
        "name": "extract_entities",
        "description": "提取文本中的实体信息",
        "input_schema": ExtractionResult.model_json_schema()
    }
]

response = client.messages.create(
    model="claude-opus-4-6",
    max_tokens=1024,
    tools=tools,
    tool_choice={"type": "tool", "name": "extract_entities"},
    messages=[{"role": "user", "content": "从以下文本提取实体:阿里巴巴创始人马云在杭州创建了公司。"}]
)

# 从 tool_use 响应中解析结果
tool_result = next(b for b in response.content if b.type == "tool_use")
result = ExtractionResult(**tool_result.input)

注意:tool_use 模拟的核心原理是把 Schema 放进工具的 input_schema,然后强制模型调用该工具(tool_choice: force)。由于工具调用本身有 Schema 验证,输出可靠性接近原生 Structured Output。

3.3 Google Gemini:response_schema 参数

Gemini 2.0/2.5 全系列支持 response_schema,可直接传 Pydantic 模型。

import google.generativeai as genai
from pydantic import BaseModel
from typing import List
import enum

class Sentiment(str, enum.Enum):
    POSITIVE = "positive"
    NEGATIVE = "negative"
    NEUTRAL = "neutral"

class ReviewAnalysis(BaseModel):
    sentiment: Sentiment
    key_points: List[str]
    score: float         # 0.0 - 10.0
    summary: str

genai.configure(api_key="YOUR_API_KEY")

model = genai.GenerativeModel(
    "gemini-2.0-flash",
    generation_config=genai.GenerationConfig(
        response_mime_type="application/json",
        response_schema=ReviewAnalysis,
    )
)

response = model.generate_content(
    "分析这条用户评论:'产品质量很好,但发货太慢了,等了两周。'"
)

import json
result = ReviewAnalysis(**json.loads(response.text))
print(result.sentiment)     # Sentiment.NEGATIVE or NEUTRAL
print(result.key_points)    # ['产品质量好', '发货速度慢']

四、Pydantic + Instructor:2025 生产最佳实践

4.1 为什么需要 Instructor

原生 API 的 Structured Output 解决了"格式保证"问题,但没有解决:

  • 验证失败后的自动重试:Schema 合规了,但业务逻辑不合法(如 score > 10)怎么办?
  • 多 Provider 统一接口:同一套代码切换 OpenAI / Anthropic / Gemini
  • Streaming 场景的增量解析:实时输出部分结果

Instructor 库(3M+ 月下载量)专门解决这些问题。

4.2 基础用法

import instructor
from openai import OpenAI
from pydantic import BaseModel, Field, field_validator
from typing import List

# 用 instructor 包装标准 client
client = instructor.from_openai(OpenAI())

class ContractParty(BaseModel):
    name: str = Field(description="合同主体名称")
    role: str = Field(description="甲方或乙方")
    credit_code: str = Field(description="统一社会信用代码,18位")

    @field_validator("credit_code")
    @classmethod
    def validate_credit_code(cls, v: str) -> str:
        if len(v) != 18:
            raise ValueError(f"信用代码必须18位,当前为{len(v)}位")
        return v

class ContractInfo(BaseModel):
    parties: List[ContractParty]
    contract_amount: float = Field(description="合同金额,单位元")
    signing_date: str = Field(description="签署日期,格式 YYYY-MM-DD")
    contract_type: str

# Instructor 自动处理验证失败的重试(默认最多3次)
contract = client.chat.completions.create(
    model="gpt-4o",
    response_model=ContractInfo,
    messages=[
        {"role": "system", "content": "你是一个合同信息提取助手。"},
        {"role": "user", "content": CONTRACT_TEXT}  # 合同原文
    ],
    max_retries=3  # 验证失败自动重试,并把错误信息反馈给模型
)

print(contract.contract_amount)  # 直接访问属性,无需 json.loads

4.3 多 Provider 统一接口

import instructor
import anthropic
import google.generativeai as genai

# 切换 Provider,只需改这一行
# OpenAI
client = instructor.from_openai(OpenAI())

# Anthropic
client = instructor.from_anthropic(anthropic.Anthropic())

# Google Gemini
client = instructor.from_gemini(
    client=genai.GenerativeModel("gemini-2.0-flash"),
    mode=instructor.Mode.GEMINI_JSON
)

# 调用代码完全一致
result = client.chat.completions.create(
    model="...",
    response_model=ContractInfo,
    messages=[...]
)

4.4 Instructor 重试机制详解

当 Pydantic 验证失败时,Instructor 会把验证错误作为额外的 user message 追加进对话,让模型"看到自己的错误并修正":

[第1次调用]
User: 提取合同信息...
Assistant: {"credit_code": "91310000MA1FL"}  <- 只有14位,验证失败

[自动重试:Instructor 追加错误信息]
User: 提取合同信息...
Assistant: {"credit_code": "91310000MA1FL"}
User: 验证失败:信用代码必须18位,当前为14位。请修正后重新输出。
Assistant: {"credit_code": "91310000MA1FLXXXX07"}  <- 修正,验证通过

这个机制的优雅之处在于:它把"格式错误"变成了对话上下文,让模型有机会自我纠正,而不是直接抛出异常。


五、两步推理法(Advanced Pattern)

5.1 为什么直接要求 JSON 会降低推理质量

研究发现,当模型被强制以 JSON 格式输出时,推理质量会下降。原因在于:

  1. Token 生成顺序影响注意力:JSON 中 "answer" key 出现在 "reasoning" 之前时,模型实际上是"先说答案再补理由",违背了思维链的本质
  2. 格式约束占用模型容量:模型要同时思考"内容是什么"和"格式是否合规",两者竞争注意力
  3. 嵌套 Schema 增加负担:复杂 Schema 要求模型在生成每个 token 时都维护当前所处的 Schema 路径

在 GSM8k 数学推理任务上,加入 reasoning 字段并让它先于 answer 输出,准确率提升 60%。

5.2 两步推理法实现

import instructor
from openai import OpenAI
from pydantic import BaseModel

client = instructor.from_openai(OpenAI())

# Step 1:自由推理,无格式约束
def step1_reason(question: str) -> str:
    """让模型用自然语言自由思考,不施加 JSON 约束"""
    response = OpenAI().chat.completions.create(
        model="gpt-4o",
        messages=[
            {
                "role": "system",
                "content": "你是一个专业分析师,请用自然语言详细分析以下问题。"
            },
            {"role": "user", "content": question}
        ]
    )
    return response.choices[0].message.content

# Step 2:格式化,将推理结果转换为 JSON
class AnalysisResult(BaseModel):
    conclusion: str
    confidence_level: str     # HIGH / MEDIUM / LOW
    key_evidence: list[str]
    recommended_actions: list[str]
    risk_factors: list[str]

def step2_format(reasoning_text: str, schema_class) -> AnalysisResult:
    """将自然语言推理结果格式化为 JSON"""
    return client.chat.completions.create(
        model="gpt-4o",
        response_model=schema_class,
        messages=[
            {
                "role": "system",
                "content": "将以下分析内容整理为结构化 JSON,不要改变分析的核心结论。"
            },
            {"role": "user", "content": f"待整理的分析内容:\n\n{reasoning_text}"}
        ]
    )

# 完整流程
def analyze_with_two_steps(question: str) -> AnalysisResult:
    reasoning = step1_reason(question)
    result = step2_format(reasoning, AnalysisResult)
    return result

result = analyze_with_two_steps("分析某电商平台 Q3 用户留存率下降 12% 的可能原因")
print(result.conclusion)
print(result.recommended_actions)

5.3 Schema 设计中的 reasoning 字段技巧

如果不想用两步法(有额外 API 调用成本),可以在 Schema 中设计 reasoning 字段,并保证它排在实质性答案字段之前。大多数 LLM 按字段定义顺序生成:

class EvaluationResult(BaseModel):
    # reasoning 排在最前面,强制模型先"思考"再"打分"
    reasoning: str = Field(description="评分依据,详细说明打分理由")
    score: int = Field(ge=1, le=10, description="综合评分 1-10")
    strengths: list[str] = Field(description="优势列表")
    weaknesses: list[str] = Field(description="不足列表")
    verdict: str = Field(description="总体判断:APPROVE / REJECT / REVIEW")

六、复杂 Schema 设计原则

6.1 嵌套对象与数组

from pydantic import BaseModel
from typing import List, Optional
from enum import Enum

class InvoiceStatus(str, Enum):
    PENDING = "pending"
    APPROVED = "approved"
    REJECTED = "rejected"

class LineItem(BaseModel):
    description: str
    quantity: float
    unit_price: float
    tax_rate: float = 0.13    # 默认13%增值税
    amount: float

class Invoice(BaseModel):
    invoice_number: str
    invoice_date: str
    seller_name: str
    buyer_name: str
    line_items: List[LineItem]
    total_amount: float
    status: InvoiceStatus = InvoiceStatus.PENDING
    notes: Optional[str] = None

6.2 Schema 设计原则

宁简勿繁,避免深嵌套

反模式 推荐做法
超过 4 层嵌套 拆分为多个独立 Schema,分步提取
全部字段 required Optional 字段给默认值,减少模型失败概率
字段名含歧义 Field(description=...) 明确语义
单个 Schema 超过 30 个字段 拆分子 Schema,分批提取后合并
递归 Schema(如树状结构) OpenAI Structured Output 不支持,用 JSON Mode + Prompt

枚举优于字符串:能用枚举的地方不用自由字符串,大幅降低模型随意发挥的空间。

# 不推荐
class Result(BaseModel):
    status: str     # 模型可能输出 "ok" / "success" / "done" / "completed"

# 推荐
class Status(str, Enum):
    SUCCESS = "success"
    FAILED = "failed"
    PENDING = "pending"

class Result(BaseModel):
    status: Status  # 只能是三个值之一

七、错误处理与容错

7.1 重试策略

import json
import re
from tenacity import retry, stop_after_attempt, wait_exponential

@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=8))
def extract_with_retry(text: str) -> dict:
    """带指数退避的重试"""
    response = call_llm(text)
    return json.loads(response)

def extract_with_fallback(text: str) -> dict | None:
    """完整的容错链"""
    # 尝试1:Structured Output
    try:
        return call_structured_output(text)
    except Exception:
        pass

    # 尝试2:JSON Mode + json.loads
    try:
        raw = call_json_mode(text)
        return json.loads(raw)
    except json.JSONDecodeError:
        pass

    # 尝试3:正则提取 JSON 块
    try:
        raw = call_plain_llm(text)
        pattern = r'```json\s*([\s\S]*?)\s*```|(\{[\s\S]*\})'
        match = re.search(pattern, raw)
        if match:
            json_str = match.group(1) or match.group(2)
            return json.loads(json_str)
    except Exception:
        pass

    # 降级:返回 None,上游决定如何处理
    return None

7.2 Streaming 场景的增量 JSON 解析

import instructor
from openai import OpenAI

client = instructor.from_openai(OpenAI(), mode=instructor.Mode.TOOLS)

# Instructor 支持 streaming partial 解析
for partial_result in client.chat.completions.create_partial(
    model="gpt-4o",
    response_model=ContractInfo,
    messages=[{"role": "user", "content": CONTRACT_TEXT}],
    stream=True
):
    # partial_result 是 Partial[ContractInfo],字段可能为 None(尚未生成)
    if partial_result.parties:
        # 只要有数据就开始处理,不等全部生成完
        print(f"已提取 {len(partial_result.parties)} 个合同主体")

八、性能与成本考量

8.1 结构化约束对速度的影响

Structured Output 基于受约束解码(constrained decoding),在每个 token 生成前需要对词表进行 Schema 过滤。实测影响:

  • 首 token 延迟(TTFT):+10-30ms(Schema 编译一次后缓存)
  • 后续 token 速度:几乎无影响(约束检查 < 1ms/token)
  • 总体影响:对于典型 200-500 token 的输出,延迟增加通常 < 5%

8.2 JSON 的 Token 成本

JSON 的键名会消耗大量 Token,这在高并发场景下不可忽视:

# 同样的数据,不同格式的 Token 消耗差异

JSON(含字段名):
{"name": "张三", "age": 28, "city": "北京", "occupation": "工程师"}
约 25 tokens

纯值(压缩格式,需要客户端解包):
张三|28|北京|工程师
约 8 tokens(节省 68%)

成本优化建议

  • 高频调用场景:考虑压缩字段名(a 代替 address),用文档记录映射关系
  • 使用 additionalProperties: false 避免模型生成多余字段
  • 合理设置 maxTokens,过大的预算会让模型"话痨"

8.3 Schema 缓存

OpenAI 对 Structured Output 的 Schema 编译结果有服务端缓存。首次使用某个 Schema 时有轻微的额外延迟,之后缓存命中几乎无额外开销。同一个 Schema 的多次调用会复用缓存。


九、实战场景速查

9.1 数据提取

class EmailMetadata(BaseModel):
    sender: str
    recipients: list[str]
    subject: str
    intent: str          # 询价 / 投诉 / 咨询 / 合作
    urgency: str         # HIGH / MEDIUM / LOW
    action_required: bool
    key_info: list[str]  # 需要关注的关键信息点

# 适用:CRM 邮件自动分类、合同信息提取、发票 OCR 后处理

9.2 API 响应生成

class APIResponse(BaseModel):
    success: bool
    code: int
    message: str
    data: dict | None = None
    errors: list[str] = []

# 适用:LLM 直接生成符合 OpenAPI 规范的响应体

9.3 多步骤 Agent 的步骤输出

class AgentStep(BaseModel):
    step_id: int
    action: str             # tool_call / reasoning / final_answer
    tool_name: str | None
    tool_args: dict | None
    reasoning: str          # 先推理,后行动
    confidence: float       # 0.0 - 1.0

# 适用:ReAct 框架、工具调用 Agent、代码执行 Agent

9.4 评测打分

class EvalResult(BaseModel):
    reasoning: str          # 必须放在最前,强制先思考
    dimension_scores: dict[str, float]   # 各维度得分
    overall_score: float
    pass_fail: bool
    feedback: str

# 适用:LLM-as-Judge 评测框架、内容审核、代码质量评分

十、小结:选型决策树

生产环境中需要解析 LLM 输出?
│
├── 是否需要 Schema 保证?
│   ├── 否 → JSON Mode(OpenAI / Gemini)
│   └── 是 → Structured Output
│       ├── OpenAI → response_format: json_schema + strict: true
│       ├── Anthropic → beta Structured Output 或 tool_use 模拟
│       └── Google → response_schema 参数
│
├── 有复杂业务验证逻辑?
│   └── 是 → Instructor + Pydantic(自动重试+多 Provider)
│
├── 推理质量要求高?
│   └── 是 → 两步推理法(Step1 自由推理,Step2 格式化)
│
└── 有 Streaming 需求?
    └── 是 → Instructor create_partial 增量解析

结构化输出本质上是"把工程契约写进 AI 调用"——不是靠提示词祈祷,而是用 Schema 约束确保每次调用的输出都是程序可消费的数据。2025 年的主流 LLM 已经具备了足够成熟的技术支撑,开发者不再需要在"灵活性"和"可靠性"之间二选一。

生产环境中,Pydantic + Instructor + Structured Output 三件套,是目前最经过验证的组合。


Maurice | maurice_wen@proton.me