HelloAI
L4 第 14 篇 🐣 难度 🕒 12 分钟

结构化输出:JSON Mode / Grammars / Pydantic

让 LLM 输出可靠的机器可读结果——工业界 Agent / 工作流不可或缺的能力。

阿莱
2026/9/19

工业界用 LLM 干活,80% 的时间需要 LLM 输出结构化数据(不是聊天文本):

  • 从简历里抽取出 {name, email, skills[]}
  • 把用户问题分类成 {category, priority, sentiment}
  • Agent 决定下一步:{action, args}

但 LLM 天生爱”自由发挥”—— 返回 "{ 'name': 'John', ... }",外面包着 markdown ```json 围栏,多了一句解释……

这一篇讲:怎么让 LLM 可靠返回结构化数据

三层手段

从轻到重:

手段强制程度兼容性
Prompt 约定弱(靠模型自觉)任何 LLM
JSON Mode中(保证是 JSON,但 schema 不强制)OpenAI / Anthropic / Gemini
Schema 约束 / Grammars强(保证符合 schema)OpenAI 结构化输出 / 部分开源框架

一、Prompt 约定(基线)

最简单:在 prompt 里写清楚格式。

prompt = """Extract the following from the resume:
{
  "name": string,
  "email": string,
  "skills": list of strings
}

Output ONLY the JSON, no markdown, no explanation.

Resume: {text}
"""

问题

  • 模型可能加 markdown ```json 围栏
  • 字段可能漏
  • 多了一句”Here is the JSON:”

需要后处理(正则提取、try/except 反复要求)。

适用:原型阶段、对可靠性要求不高。

二、JSON Mode(保证 JSON)

OpenAI、Anthropic 等 API 都提供 response_format

OpenAI

response = openai.chat.completions.create(
    model="gpt-4",
    messages=[...],
    response_format={"type": "json_object"}
)

效果:模型强制返回有效 JSON。不会有 markdown 围栏、无关文字。

:不强制 schema——可能字段命名漂移(emailAddress 而不是 email),可能漏字段。

需要 prompt 里仍声明期望 schema,但解析时不会失败

Anthropic

Claude 通过 tool_use 来达到类似效果(把”输出”当作”调用一个函数”):

response = anthropic.messages.create(
    model="claude-3-5-sonnet",
    messages=[...],
    tools=[{
        "name": "extract_resume",
        "input_schema": {
            "type": "object",
            "properties": {
                "name": {"type": "string"},
                "email": {"type": "string"},
                "skills": {"type": "array", "items": {"type": "string"}}
            }
        }
    }],
    tool_choice={"type": "tool", "name": "extract_resume"}
)
result = response.content[0].input  # 直接拿到字典

tool_choice 强制 Claude 必须调用这个 tool —— 等于强制结构化输出。

三、严格 Schema(最稳)

OpenAI Structured Outputs(2024+)

from pydantic import BaseModel

class Resume(BaseModel):
    name: str
    email: str
    skills: list[str]

response = openai.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[...],
    response_format=Resume
)
result: Resume = response.choices[0].message.parsed

核心:OpenAI 在内部用 constrained decoding——在 token 采样时,只允许产生符合 schema 的下一个 token。

效果:

  • 100% 符合 schema(不像 JSON Mode 只保证语法)
  • 字段名、类型、required 全部强制
  • 嵌套对象、enum、array 都支持

Outlines / lm-format-enforcer(开源)

对自建 LLM(vLLM、llama.cpp)也能做严格 schema:

import outlines

model = outlines.models.transformers("Llama-3-8B")

@outlines.json(Resume)
def extract(text):
    return f"Extract resume info: {text}"

result = extract("...")  # 保证是 Resume 类型

底层同样是 constrained decoding——在每一步采样时遮蔽不合法的 token。

实战:Pydantic 模式

最干净的做法:用 Pydantic 定义所有 schema,配合 OpenAI 结构化输出或 instructor 库:

from pydantic import BaseModel, Field
from typing import Literal

class TicketClassification(BaseModel):
    category: Literal["billing", "technical", "general"]
    priority: Literal["low", "medium", "high", "urgent"]
    sentiment: Literal["positive", "neutral", "negative"]
    summary: str = Field(description="One sentence summary")
    needs_human: bool

class CustomerTicket(BaseModel):
    classification: TicketClassification
    suggested_response: str
    estimated_resolution_minutes: int
import instructor
from openai import OpenAI

client = instructor.from_openai(OpenAI())

result = client.chat.completions.create(
    model="gpt-4o",
    response_model=CustomerTicket,
    messages=[{"role": "user", "content": ticket_text}]
)
# result.classification.category → "billing"
# result.suggested_response → "..."

工业界 80% 的”LLM 做分类 / 抽取”任务用这个套路。

嵌套 + 列表 + 条件

复杂 schema 也行:

class Person(BaseModel):
    name: str
    age: int

class Event(BaseModel):
    title: str
    date: str
    attendees: list[Person]
    is_virtual: bool
    meeting_link: str | None = None  # 仅 is_virtual=True 时有

class Schedule(BaseModel):
    week_of: str
    events: list[Event]

LLM 会自动遵守嵌套结构——这比写正则去解析自由文本鲁棒 10 倍。

性能影响

手段速度质量
Prompt 约定最快不稳定,需要后处理
JSON Mode稳,字段名可能漂移
Schema 严格略慢(多 5-15%)100% 符合

为什么略慢:constrained decoding 要在每步过滤候选 token。 为什么值得:消除了”解析失败”这一类错误。

反模式

❌ 用正则解析自由文本

# 脆弱:模型一改格式就坏
match = re.search(r'name:\s*"([^"]+)"', llm_output)

❌ 多次重试拿”合法 JSON”

for _ in range(5):
    out = llm.chat(...)
    try:
        return json.loads(out)
    except:
        continue
raise Error("Failed 5 times")

不用重试——用 schema 强制一次就对。

❌ schema 设计成”自由文本字段”

class Bad(BaseModel):
    response: str  # ← LLM 在这里又开始自由发挥

response 进一步拆开summary / action / details 等结构化字段。

3 个工程经验

1. 用 Literal 替代 str 做枚举

priority: Literal["low", "medium", "high"]  # ✓
priority: str                                # ✗ 模型可能输出 "very high"

2. 用 Field 描述意图

summary: str = Field(description="One sentence summary, max 100 chars")

这个 description 会被传给 LLM 当做 prompt。

3. 用 Optional 处理”可能没有”的字段

phone: str | None = None

避免模型瞎编一个不存在的电话号码。

工业界全景

按 2026 现状选型:

场景推荐
OpenAI 用户,要稳定Structured Outputs + Pydantic
Claude 用户,要稳定Tool Use + tool_choice 强制
自建 LLM(vLLM / llama.cpp)Outlines / instructor
跨厂商抽象LangChain JsonOutputParser / DSPy
极快原型Prompt + JSON Mode
💡 一个观察

2024 前,“用 LLM 抽取数据”是不可靠工程。 2024 后,结构化输出让这件事成为生产级稳定。

这是 LLM 工业化的关键一跃: 从”问个问题、生成段文字” 到”作为某个数据流水线的可靠组件”。

如果你做 LLM 应用—— 结构化输出应该是你掌握的第一个”工程级”工具。

下一篇推荐:L4-15 Prompt CachingL4-09 Tool Use 工程

🚧 3 个常见坑

⚠️ 实战避坑

坑 1:用自然语言「请输出 JSON」 2024+ 模型有原生 JSON Mode / Schema 强制——别再用「请严格输出 JSON 格式」 这种祈祷式 prompt。

坑 2:Schema 写得太宽松 用 Pydantic / Zod 严格类型——optional 字段全写 required 然后给默认值,比「或者 null 或者字符串」 稳定。

坑 3:不处理 schema violation 即使有强制 schema,仍可能失败——必须 try parse / retry / fallback,不能裸 .parse()。

📬

读到这里说明你认真在学 🎯

订阅每周精选 —— 下一篇新文章 / 新可视化第一时间送到邮箱。

💬

讨论区

· 用 GitHub 账号登录评论
⚠️ Giscus 评论未配置 —— 在 src/components/Comments.astro 顶部填入 仓库 ID 和分类 ID(见组件注释里的配置步骤)。