跳转至

第3章 结构化 Prompt

学习目标

  • 理解结构化输出的必要性及 JSON/XML 两种格式的取舍
  • 掌握 OpenAI response_format 参数与 Structured Outputs 机制
  • 学会使用 JSON Schema 定义输出契约
  • 掌握 Pydantic 模型校验与输出解析的工程实践
  • 能够构建端到端的结构化输出流水线

问题导入

某团队开发了一个"简历信息提取 Agent",需要从简历文本中抽取姓名、电话、邮箱、技能等信息并存入数据库。最初的 Prompt 是:

请提取以下简历的关键信息:姓名、电话、邮箱、技能。
简历:张三,电话 13800138000,邮箱 zhangsan@xx.com,熟悉 Python 和 Java。

模型返回的是自然语言:"这位候选人叫张三,电话是 13800138000……",无法直接入库。工程师尝试加"请输出 JSON",结果得到的 JSON 各种格式问题:键名不统一、字段缺失、多了多余说明文本、甚至引号不匹配导致 json.loads 报错。

如何让模型稳定输出可被程序直接解析的结构化数据?本章解决这个问题。

核心概念

1. 为什么需要结构化输出

LLM 默认输出自然语言,而业务系统需要结构化数据。结构化输出的价值:

  • 可解析:程序直接 json.loads 入库,无需正则提取
  • 可校验:通过 Schema 校验字段完整性、类型正确性
  • 可集成:与 API、数据库、下游服务无缝对接
  • 可测试:断言字段值,自动化测试可行

2. JSON vs XML 输出格式

维度 JSON XML
解析难度 简单(标准库) 较复杂(需 lxml 等)
Token 效率 高(无闭合标签) 低(标签冗余)
模型友好度 高(训练数据多) 中
可读性 中 高(带标签语义)
适用场景 API、数据库、配置 文档、带层级语义

生产环境首选 JSON,除非业务系统强制 XML。

3. response_format 参数

OpenAI API 提供 response_format 参数控制输出格式:

  • {"type": "text"}:默认,自由文本
  • {"type": "json_object"}:JSON 模式,保证输出合法 JSON
  • {"type": "json_schema", "json_schema": {...}}:Structured Outputs,按 Schema 输出

4. Structured Outputs(结构化输出)

OpenAI 的 Structured Outputs 功能允许通过 JSON Schema 严格约束输出结构:

  • 字段名固定:模型必须按 Schema 定义的键名输出
  • 类型严格:字符串、数字、布尔、数组、嵌套对象
  • 必填可选:required 字段必须出现
  • 枚举限制:enum 约束取值范围

这是目前最可靠的结构化输出方案,模型层面保证符合 Schema。

5. Pydantic 模型校验

Pydantic 是 Python 生态主流的数据校验库:

  • 用类定义数据模型,自动生成 JSON Schema
  • 校验字段类型、必填、约束
  • 与 FastAPI 深度集成
  • 支持 model_validate 方法解析字典

6. 输出解析流程

完整流程:Prompt 设计 → 模型调用 → JSON 解析 → Schema 校验 → 业务处理 → 错误重试

原理图

flowchart TB
    A[业务需求] --> B[定义 Pydantic 模型]
    B --> C[生成 JSON Schema]
    C --> D[调用 LLM<br/>response_format=json_schema]
    D --> E[模型输出 JSON]
    E --> F[json.loads 解析]
    F --> G{解析成功?}
    G -->|否| H[重试/报错]
    G -->|是| I[Pydantic 校验]
    I --> J{校验通过?}
    J -->|否| H
    J -->|是| K[业务对象]
    K --> L[入库/API 返回]

    style B fill:#e1f5fe
    style D fill:#fff9c4
    style I fill:#f3e5f5
    style K fill:#c8e6c9
    style L fill:#c8e6c9
    style H fill:#ffcdd2

从业务需求出发定义模型,生成 Schema 约束 LLM 输出,经过解析与校验后得到可靠业务对象。

示例代码

1. 基础 JSON 模式

"""
第3章示例:基础 JSON 输出模式
"""
from openai import OpenAI
import json

client = OpenAI()


def extract_info_basic(text: str) -> dict:
    """使用 json_object 模式输出 JSON"""
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "从文本中提取信息,输出 JSON。"},
            {"role": "user", "content": f"提取姓名、电话、邮箱:\n{text}"},
        ],
        response_format={"type": "json_object"},
    )
    return json.loads(response.choices[0].message.content)


if __name__ == "__main__":
    resume = "张三,电话 13800138000,邮箱 zhangsan@xx.com"
    print(extract_info_basic(resume))
    # {'name': '张三', 'phone': '13800138000', 'email': 'zhangsan@xx.com'}

2. Structured Outputs + Pydantic 严格校验

"""
Structured Outputs:用 Pydantic 定义模型,保证输出严格符合 Schema
"""
from openai import OpenAI
from pydantic import BaseModel, Field, field_validator
from typing import Optional
import json

client = OpenAI()


class ResumeInfo(BaseModel):
    """简历信息模型"""
    name: str = Field(description="姓名")
    phone: str = Field(description="电话号码")
    email: str = Field(description="邮箱地址")
    skills: list[str] = Field(default_factory=list, description="技能列表")
    years_of_experience: int = Field(default=0, description="工作年限")

    @field_validator("phone")
    @classmethod
    def validate_phone(cls, v: str) -> str:
        if not v.startswith("1") or len(v) != 11:
            raise ValueError("电话号码格式不正确")
        return v

    @field_validator("email")
    @classmethod
    def validate_email(cls, v: str) -> str:
        if "@" not in v:
            raise ValueError("邮箱格式不正确")
        return v


def extract_resume_structured(text: str) -> ResumeInfo:
    """使用 Structured Outputs 提取简历信息"""
    response = client.beta.chat.completions.parse(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": "从简历文本中提取结构化信息。无法确定的字段留空或填默认值。",
            },
            {"role": "user", "content": text},
        ],
        response_format=ResumeInfo,
    )
    return response.choices[0].message.parsed


if __name__ == "__main__":
    resume_text = """
    张三,男,5 年开发经验。
    电话:13800138000,邮箱:zhangsan@example.com
    技能:Python, Java, Docker, Kubernetes
    """
    info = extract_resume_structured(resume_text)
    print(info.model_dump_json(indent=2))
    # {
    #   "name": "张三",
    #   "phone": "13800138000",
    #   "email": "zhangsan@example.com",
    #   "skills": ["Python", "Java", "Docker", "Kubernetes"],
    #   "years_of_experience": 5
    # }

3. 带重试机制的解析流水线

"""
端到端结构化输出流水线:含解析失败重试
"""
from openai import OpenAI
from pydantic import BaseModel, ValidationError
import json
import logging

client = OpenAI()
logger = logging.getLogger(__name__)


class ProductInfo(BaseModel):
    name: str
    price: float
    category: str
    in_stock: bool


def extract_product(text: str, max_retries: int = 3) -> ProductInfo:
    """带重试的产品信息提取"""
    last_error = None

    for attempt in range(max_retries):
        try:
            response = client.beta.chat.completions.parse(
                model="gpt-4o-mini",
                messages=[
                    {
                        "role": "system",
                        "content": "从文本提取商品信息。"
                        + (f"上次错误:{last_error},请修正。" if last_error else ""),
                    },
                    {"role": "user", "content": text},
                ],
                response_format=ProductInfo,
            )
            return response.choices[0].message.parsed
        except ValidationError as e:
            logger.warning("第 %d 次校验失败: %s", attempt + 1, e)
            last_error = str(e)
        except json.JSONDecodeError as e:
            logger.warning("第 %d 次 JSON 解析失败: %s", attempt + 1, e)
            last_error = str(e)

    raise RuntimeError(f"提取失败,已重试 {max_retries} 次")


if __name__ == "__main__":
    text = "iPhone 15 Pro,售价 8999 元,分类:手机,目前有货"
    product = extract_product(text)
    print(product.model_dump_json(indent=2))

4. 手动 Schema 定义(不依赖 Pydantic)

"""
直接使用 JSON Schema 字典定义输出结构
"""
from openai import OpenAI
import json

client = OpenAI()

PRODUCT_SCHEMA = {
    "type": "json_schema",
    "json_schema": {
        "name": "product_info",
        "strict": True,
        "schema": {
            "type": "object",
            "properties": {
                "name": {"type": "string"},
                "price": {"type": "number"},
                "category": {"type": "string", "enum": ["手机", "电脑", "配件"]},
                "tags": {"type": "array", "items": {"type": "string"}},
            },
            "required": ["name", "price", "category", "tags"],
            "additionalProperties": False,
        },
    },
}


def extract_with_schema(text: str) -> dict:
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "提取商品信息。"},
            {"role": "user", "content": text},
        ],
        response_format=PRODUCT_SCHEMA,
    )
    return json.loads(response.choices[0].message.content)

企业案例

数据提取 Agent

某 HR 科技公司开发简历解析 Agent,每天处理 10 万份简历,需提取 30+ 个字段(基本信息、教育经历、工作经历、项目经验、技能等),存入结构化数据库供搜索匹配。

V1 方案:用正则 + 规则提取。问题:简历格式多样(PDF/Word/纯文本),正则维护成本极高,准确率仅 60%。

V2 方案:LLM + 请输出 JSON。问题:JSON 格式不稳定,json.loads 失败率 15%,需要大量异常处理。

V3 方案:Structured Outputs + Pydantic 多层模型。设计如下:

class EducationItem(BaseModel):
    school: str
    degree: str
    major: str
    start_date: str
    end_date: str

class WorkItem(BaseModel):
    company: str
    position: str
    start_date: str
    end_date: str
    description: str

class FullResume(BaseModel):
    basic_info: ResumeInfo
    education: list[EducationItem]
    work_experience: list[WorkItem]
    skills: list[str]
    self_evaluation: Optional[str]

V3 上线后:JSON 解析成功率 99.8%,字段准确率 92%,处理速度提升 3 倍(无需复杂异常处理)。关键经验:

  1. 嵌套模型设计:复杂结构用嵌套 Pydantic 模型,逐层校验
  2. strict=True:开启严格模式,模型层面保证符合 Schema
  3. 容错设计:可选字段用 Optional,必填字段给默认值
  4. 监控告警:记录校验失败案例,持续优化 Prompt

常见问题

Q1:json_object 和 json_schema 模式有什么区别?

A:json_object 只保证输出是合法 JSON,但不约束结构(键名、类型可能变);json_schema 严格按 Schema 输出,字段名、类型、必填都受约束。生产环境优先用 json_schema。

Q2:Structured Outputs 支持嵌套对象和数组吗?

A:支持。可以定义多层嵌套的 Pydantic 模型或 JSON Schema,包括 list[SubModel]、Optional[SubModel] 等。注意 additionalProperties: False 必须设置。

Q3:模型输出仍偶尔不符合 Schema 怎么办?

A:①检查 Schema 是否过于复杂(建议单个 Schema 字段不超过 20 个);②开启 strict=True;③加重试机制(本章示例 3);④降低 temperature 到 0;⑤在 Prompt 中提供 Few-shot 示例。

Q4:Pydantic v1 和 v2 在 Schema 生成上有差异吗?

A:有。Pydantic v2 性能更高,API 有变化(如 model_validate 替代 parse_obj,model_dump 替代 dict())。OpenAI SDK 的 response_format 同时支持两者,但推荐用 v2。

Q5:如何处理模型"幻觉"填了不存在的数据?

A:在 Prompt 中强调"无法确定的字段留空或填 null",并在 Pydantic 模型中用 Optional 类型 + field_validator 校验合理性。对关键字段可加二次确认调用。

面试题

  1. 简答题:response_format 的三种取值是什么?各自约束力如何?

  2. 原理题:Structured Outputs 是如何在模型层面保证输出符合 Schema 的?与单纯 Prompt 提示"输出 JSON"有何本质区别?

参考要点:约束解码(constrained decoding)在 token 采样阶段强制符合 Schema,而非依赖 Prompt 提示。

  1. 设计题:设计一个 Pydantic 模型,用于从新闻中提取"事件"(含时间、地点、人物、事件类型、影响),事件类型限定为枚举。

  2. 实践题:如何实现"校验失败后自动把错误信息反馈给模型重试"?写出核心逻辑。

  3. 对比题:JSON 与 XML 在 LLM 结构化输出中各自优劣?为什么生产环境首选 JSON?

练习题

  1. 基础实现:用 json_object 模式实现一个"电影信息提取"函数,输入电影介绍文本,输出包含片名、导演、年份、评分的 JSON。

  2. Schema 进阶:用 Pydantic 定义一个"订单模型",包含订单号、商品列表(嵌套)、收货地址(嵌套)、总金额,并实现字段校验(金额>0、订单号格式)。

  3. 容错处理:为本章示例 2 的 extract_resume_structured 添加重试机制,当 Pydantic 校验失败时把错误信息反馈给模型重试最多 3 次。

  4. 性能对比:对比 json_object 和 json_schema 两种模式在同一任务下的成功率与响应延迟,撰写实验报告。

小结

结构化 Prompt 是 LLM 走向生产的关键一环,让模型输出从"自然语言"变为"可编程数据"。本章核心要点:

  • JSON 是首选格式:解析简单、Token 效率高、模型友好。
  • Structured Outputs 最可靠:通过 response_format=json_schema 在模型层面保证符合 Schema,远比 Prompt 提示"输出 JSON"可靠。
  • Pydantic 是校验利器:定义模型、生成 Schema、校验输出、与 FastAPI 集成,一站式解决。
  • 嵌套模型设计复杂结构:用嵌套 Pydantic 模型处理多层业务对象,逐层校验。
  • 容错设计不可少:必填校验 + 重试机制 + 监控告警,构建鲁棒流水线。

下一章将学习复杂 Chain 模式,让模型具备多步推理与工具调用能力,应对更复杂的业务场景。