大模型结构化输出实战:JSON Schema 与提示工程

把大模型接进业务系统时,第一道真正的坎往往不是模型效果,而是结构化输出不稳定:同一个抽取任务,九次返回干净 JSON,第十次多带一段”好的,以下是结果”的寒暄,直接把下游 json.loads 打崩。本文用三层手段治这个病——提示工程收敛格式、JSON Schema 约束解码从根上限制字符生成、Pydantic 做最后一道校验,并给出商用 API 与本地模型两条落地路径,让 LLM 的输出能直接落库。

一、为什么”让模型输出 JSON”没那么简单

大模型本质是逐 token 采样的概率机器,它没有”必须闭合花括号”的硬约束。你在提示词里写十遍”只返回 JSON,不要多余文字”,也只是把违规概率从 10% 压到 2%,而不是压到 0。而在生产链路上,2% 的失败率意味着每天上万次调用里有两百次脏数据,如果没有兜底,脏数据会一路流进数据库。

更麻烦的是失败形态五花八门:Markdown 代码围栏包裹、字段名大小写漂移、数字被写成中文、枚举值自由发挥、必填字段静默缺失、长文本被截断导致 JSON 半截。这些错误单看都不致命,但组合起来会让上游服务的异常分支比主逻辑还长。所以正确姿势不是”祈祷模型听话”,而是把格式变成系统约束,而非请求。

值得强调的是,结构化输出和工具调用是两件不同的事。工具调用关心”模型该调哪个函数、传什么参数”,属于动作决策;结构化输出关心”模型返回的数据能否被程序直接消费”,属于数据契约。二者底层技术相通,但设计目标不同,之前介绍过的大模型工具调用 Function Calling 实战解决前者,本文解决后者。

二、三条约束路线的取舍

目前把输出压成固定结构,主流有三条路线:纯提示词约束、借工具调用的参数 schema、以及原生的约束解码(constrained decoding)。它们的可靠性与成本差异很大,选错会白白付出工程量。

方案可靠性实现成本适用场景
提示词约束 + 后处理清洗低(约 95%~98%)极低原型验证、字段少、可人工复核
Function Calling 参数 schema中高已用工具调用链路,顺带复用
JSON Schema 约束解码高(接近 100% 合法)生产抽取、批量落库、无人值守
本地模型 GBNF / outlines 语法较高私有化部署、数据不出内网

结论很直接:能用约束解码就别只靠提示词。约束解码的原理是在采样阶段按 schema 生成状态机,把不合法的 token 概率直接掩掉——模型”物理上”写不出非法字符,因此不再需要靠正则去抠 JSON。提示词仍然重要,但它的职责从”保证格式”退回到”保证语义正确”,各司其职。

三、OpenAI 兼容接口:response_format 实战

绝大多数商用 API 与国内兼容网关都支持 response_format。用 json_schema 模式时要注意两个细节:一是 strict: true 才会真正启用严格约束;二是严格模式下通常要求所有字段都出现在 required 里,可选字段用联合类型(如 ["string", "null"])表达,而不是省略。

from openai import OpenAI

client = OpenAI()

schema = {
    "name": "resume_extract",
    "strict": True,
    "schema": {
        "type": "object",
        "properties": {
            "name":   {"type": "string"},
            "years":  {"type": "integer", "minimum": 0, "maximum": 50},
            "skills": {"type": "array", "items": {"type": "string"}},
            "level":  {"type": "string", "enum": ["junior", "mid", "senior"]},
            "email":  {"type": ["string", "null"]}
        },
        "required": ["name", "years", "skills", "level", "email"],
        "additionalProperties": False
    }
}

resp = client.chat.completions.create(
    model="gpt-4o-mini",
    temperature=0,
    response_format={"type": "json_schema", "json_schema": schema},
    messages=[
        {"role": "system", "content": "你是简历信息抽取器,只依据原文填写,缺失填 null,不要推测。"},
        {"role": "user", "content": resume_text}
    ]
)
data = json.loads(resp.choices[0].message.content)  # 可直接消费

三个容易踩的点值得单独记住。第一,additionalProperties: False 一定要写,否则模型会热心地多塞字段,导致落库时字段错位。第二,抽取类任务把 temperature 设为 0,减少同一输入的漂移,便于做回归测试。第三,枚举值一律用 enum 锁死,不要在提示词里用自然语言描述”请返回初级/中级/高级”,那等于把校验责任丢回给了自己。

四、用 Pydantic 把 Schema 和业务模型合一

手写 JSON Schema 很快就会失控:字段一多,schema 和业务实体两头维护,改一处忘一处。更好的做法是用 Pydantic 定义唯一契约,自动导出 schema,同时兼作运行时校验器——一份定义,既约束模型也保护数据库。

from typing import Literal, Optional, List
from pydantic import BaseModel, Field, ValidationError

class Resume(BaseModel):
    name: str = Field(description="候选人姓名,原文出现的写法")
    years: int = Field(ge=0, le=50, description="工作年限")
    skills: List[str] = Field(default_factory=list, max_length=20)
    level: Literal["junior", "mid", "senior"]
    email: Optional[str] = None

# 1) 导出 schema 给模型做约束解码
json_schema = Resume.model_json_schema()

# 2) 拿到回复后再做一次运行时校验,双保险
try:
    obj = Resume.model_validate_json(raw_text)
except ValidationError as e:
    # 把错误摘要回灌给模型做一次自修复
    repair_hint = e.json(include_url=False)
    raise

Pydantic 的 description 会被写入导出的 schema,模型在生成时能看到字段语义,等于把一部分提示词沉到了契约里,比在 system prompt 里堆一大段字段说明更精准。这套写法在 LangChain 的结构化输出封装里也是同一思路,如果你的链路已经在用 Agent 编排,可以直接复用之前LangChain Agent 工具调用与自主编排里的模型客户端,只把输出解析层换成 Pydantic 契约。

五、本地模型怎么做:Ollama 与 vLLM 的约束

私有化场景数据不能出内网,同样能拿到强约束。Ollama 支持在请求里直接传 format 字段(可传 "json",新版本也支持完整 JSON Schema);vLLM 则通过 guided decoding 参数暴露同类能力,底层用的是 outlines 一类的语法约束引擎。

# Ollama:传入完整 schema 做约束
curl http://localhost:11434/api/chat -d '{
  "model": "qwen2.5:7b",
  "stream": false,
  "options": {"temperature": 0},
  "format": {
    "type": "object",
    "properties": {
      "sentiment": {"type": "string", "enum": ["pos", "neg", "neutral"]},
      "score": {"type": "number"}
    },
    "required": ["sentiment", "score"]
  },
  "messages": [{"role": "user", "content": "评价:物流很快但包装破损"}]
}'

# vLLM(OpenAI 兼容端点):guided_json 走约束解码
curl http://localhost:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "Qwen2.5-7B-Instruct",
    "temperature": 0,
    "messages": [{"role": "user", "content": "抽取工单要素"}],
    "extra_body": {"guided_json": {"type": "object",
      "properties": {"module": {"type": "string"}, "severity": {"type": "integer"}},
      "required": ["module", "severity"]}}
  }'

本地路线有个额外收益:约束解码会砍掉大量无效搜索空间,小参数模型在固定结构任务上的表现会明显好于放任自由生成,7B 级别模型做字段抽取常常够用,没必要上更大的模型。模型本地管理与量化调优可参考站内Ollama 模型管理:Modelfile 自定义与调优,高吞吐服务化部署则见vLLM 部署实战:高吞吐推理服务调优

六、重试、自修复与降级三件套

即使有约束解码,也仍会遇到”格式合法但语义错误”的情况:日期写成去年、金额少一个零、把推测值当成原文事实。所以生产链路必须补上三层兜底,且严格区分”格式错误”与”语义错误”的处理方式。

import json, time
from pydantic import ValidationError

def extract_with_repair(text, max_retry=2):
    messages = build_messages(text)
    for attempt in range(max_retry + 1):
        raw = call_llm(messages)                 # 已带 json_schema 约束
        try:
            return Resume.model_validate_json(raw)
        except ValidationError as e:
            if attempt == max_retry:
                log_to_dlq(text, raw, str(e))    # 进死信队列,人工复核
                return None
            # 自修复:把上次错误输出 + 校验错误一起回灌
            messages += [
                {"role": "assistant", "content": raw},
                {"role": "user",
                 "content": f"上次输出不符合契约,错误:{e.errors()};请仅修正错误字段后重新输出。"}
            ]
            time.sleep(1.5 * (attempt + 1))       # 退避,避免打爆限流

关键设计有三点:一是把错误信息回灌而不是简单重跑,模型看到具体校验报错的修复成功率远高于盲目重试;二是重试上限要小(1~2 次),失败即入死信队列,别让重试风暴拖垮整条流水线;三是死信数据必须留原文与原始输出,这是后续优化提示词与 schema 的唯一素材。这套评测与回归的思路,和之前写过的向量检索评测实战:Recall@K 与 RAG 质量度量一脉相承——没有度量就没有优化。

七、生产避坑清单

坑点症状解法
只靠提示词约束格式偶发 JSON 解析失败启用 json_schema / guided_json 约束解码
未设 additionalProperties模型多返回字段,落库错位显式设为 false
schema 与业务模型两头维护字段改动不同步Pydantic 单一契约导出 schema
max_tokens 给太小长结果被截断成半截 JSON按字段规模预估并留 30% 余量
无限重试限流雪崩、成本失控限重试 2 次 + 退避 + 死信队列
缺失字段用空串填充脏数据污染统计可选字段用 null,禁止模型推测
temperature 默认值同一输入结果漂移抽取类任务固定为 0

另外提醒一点:schema 不是越复杂越好。嵌套超过三层、字段超过二十个的巨型 schema,会显著拉低小模型的准确率,也让约束解码的状态机开销上升。遇到复杂结构,优先拆成多次调用(先分类,再按类别抽取对应字段),准确率通常比一把梭更高,也更好定位问题环节。

八、小结

让大模型输出稳定的结构化数据,靠的不是更用力地写提示词,而是三层递进的工程约束:提示工程负责语义正确、JSON Schema 约束解码负责格式一定合法、Pydantic 校验加自修复负责最后兜底与可观测。把这三层搭起来后,LLM 的输出才算真正具备”可以直接进数据库”的资格,抽取类业务也就从演示 Demo 变成了能无人值守跑批的生产组件。落地时建议按本文顺序推进:先用 Pydantic 定义契约,再接约束解码,最后补重试与死信队列,每一步都能独立验证收益。

上一篇 前端状态管理实战:Pinia 进阶与最佳实践
下一篇 Redis 持久化踩坑:RDB 与 AOF 数据丢失复盘