第11章:结构化输出与数据提取 —— 让 AI 直接返回你想要的数据格式

本章目标 :彻底掌握 LangChain 结构化输出的三种方案,从 Pydantic 模型定义到 with_structured_output() 绑定,再到思维链+提取的复合模式,能够在生产环境中可靠地从非结构化文本中提取结构化数据。
前期回顾

AI入门开发系列文章合集


本章你将学到

  • 为什么 LLM 的字符串输出不够用,什么是结构化输出
  • Pydantic 数据模型的定义方法(字段、类型、约束、描述)
  • with_structured_output() 的工作原理(function-calling 机制)
  • 三种输出方案:Pydantic 绑定 / JsonOutputParser / 思维链+提取
  • 嵌套模型、枚举类型、可选字段的处理技巧
  • 实体提取(NER)、简历解析、代码审查等真实场景案例

前置条件

要求 说明
Python 版本 3.10+
已完成章节 第1章(基础 LLM 调用)、第3章(Chain 链式调用)
环境变量 DASHSCOPE_API_KEY 已配置
安装依赖 uv syncpip install langchain langchain-openai pydantic

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

1.1 LLM 天生返回"自然语言",不是"数据"

来看一个真实场景:你让 LLM 分析用户评论的情感。

python 复制代码
# 问题:LLM 返回的是字符串,你怎么处理?
response = llm.invoke("分析'这个手机很棒'的情感")
# 可能返回:
# "这条评论表达了积极正面的情感,用户对产品非常满意。"
# 或者:
# "情感:正面\n置信度:0.95\n关键词:很棒"
# 或者:
# "positive(0.95)- 用户满意"

痛点:每次返回格式都可能不同,你无法可靠地用代码解析它。你需要写大量字符串处理代码,还要应对各种格式变体。

1.2 结构化输出的价值

结构化输出让 LLM 变成了一个数据解析引擎

  • ✅ 类型安全:result.confidencefloat,不是字符串 "0.95"
  • ✅ 自动校验:Pydantic 会检查 confidence 是否在 [0.0, 1.0] 范围内
  • ✅ IDE 补全:写代码时有类型提示,减少拼写错误
  • ✅ 可靠解析:无需手写正则表达式

二、核心概念速览

Pydantic:一个 Python 数据验证库,让你用类定义数据结构,自动校验类型和约束。是 FastAPI 和 LangChain 的基础依赖。

BaseModel:Pydantic 的基类,继承它来定义你的数据模式(Schema)。

Field:Pydantic 的字段描述器,可以设置默认值、校验规则、字段描述。字段描述会被 LLM 读取,是结构化输出准确的关键。

Schema:数据的结构定义,告诉 LLM"我需要你返回哪些字段、每个字段是什么类型"。

with_structured_output():LangChain 的核心方法,将 Pydantic 模型转为 function-calling 工具定义,强制 LLM 按格式输出。

Function Calling:现代 LLM(GPT-4、Qwen 等)的能力,允许外部系统传入工具定义,LLM 以 JSON 格式调用工具,是结构化输出的底层机制。

JsonOutputParser:LangChain 的 JSON 解析器,通过提示词引导 LLM 输出 JSON,兼容不支持 function-calling 的旧模型。


三、方案一:with_structured_output + Pydantic(推荐)

代码文件:lessons/11_structured_output/01_pydantic_output.py

3.1 从情感分析开始

python 复制代码
import os                                         # 读取环境变量
from enum import Enum                             # 定义枚举类型
from typing import Optional                       # 可选字段类型提示
from langchain_core.messages import HumanMessage, SystemMessage  # 消息类型
from langchain_openai import ChatOpenAI           # LLM 客户端
from pydantic import BaseModel, Field             # 数据验证库
from dotenv import load_dotenv                    # 加载 .env 文件

load_dotenv()                                     # 从 .env 读取 API 密钥

# ── 第一步:初始化 LLM ─────────────────────────────────────────
llm = ChatOpenAI(
    model="qwen-plus",                            # 使用通义千问 Plus 模型
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",  # 百炼 API 地址
    api_key=os.getenv("DASHSCOPE_API_KEY"),       # 从环境变量读取密钥
    temperature=0,                                # 设为 0:结构化任务要确定性输出
)

# ── 第二步:定义输出 Schema ────────────────────────────────────
class Sentiment(str, Enum):
    """情感枚举:限制 LLM 只能从这三个值中选一个,避免返回"非常正面"之类的自定义值。"""
    POSITIVE = "positive"                         # 正面情感
    NEGATIVE = "negative"                         # 负面情感
    NEUTRAL = "neutral"                           # 中性情感

class SentimentResult(BaseModel):
    """情感分析结果。
    
    每个字段的 description 非常重要:LLM 会读取这些描述来理解每个字段的含义。
    描述写得越清楚,输出越准确。
    """
    sentiment: Sentiment = Field(
        description="整体情感倾向"                # LLM 看到这句话来理解该字段
    )
    confidence: float = Field(
        description="置信度,0.0-1.0 之间的浮点数",
        ge=0.0,                                   # ge = greater than or equal,最小值校验
        le=1.0,                                   # le = less than or equal,最大值校验
    )
    reason: str = Field(
        description="判断依据,一句话说明原因"    # 要求 LLM 给出判断理由
    )
    keywords: list[str] = Field(
        description="触发情感的关键词列表,最多 5 个"  # 限定关键词数量
    )

# ── 第三步:绑定 Schema,创建结构化 LLM ───────────────────────
# with_structured_output() 做了三件事:
#   1. 将 SentimentResult 转换为 JSON Schema
#   2. 将 Schema 包装为 function-calling 工具
#   3. 在 invoke() 时自动解析 JSON → SentimentResult 对象
structured_llm = llm.with_structured_output(SentimentResult)

# ── 第四步:调用并获取 Python 对象 ───────────────────────────
reviews = [
    "这款手机性能极强,拍照效果也很棒,但价格确实有点贵,总体来说还是值得购买!",
    "快递太慢了,包装破损,商品质量也差,完全是浪费钱,不推荐!",
    "收到了,外观还行,功能一般,没什么特别突出的地方。",
]

for i, review in enumerate(reviews, 1):
    messages = [
        SystemMessage(content="你是专业的用户评论分析师,请对以下用户评论进行情感分析。"),
        HumanMessage(content=review),             # 将评论作为用户消息传入
    ]
    
    # invoke() 直接返回 SentimentResult 对象,不是字符串!
    result: SentimentResult = structured_llm.invoke(messages)
    
    # 使用 .attribute 访问字段,有 IDE 类型提示,不会拼错字段名
    print(f"情感:{result.sentiment.value}")      # 访问枚举值
    print(f"置信度:{result.confidence:.2f}")     # float,可以直接格式化
    print(f"原因:{result.reason}")               # str
    print(f"关键词:{result.keywords}")           # list[str]

3.2 嵌套模型:简历信息提取

真实场景中数据往往是嵌套的。Pydantic 支持模型嵌套,LLM 能正确处理复杂结构:

python 复制代码
# ── 定义嵌套的工作经历模型 ────────────────────────────────────
class WorkExperience(BaseModel):
    """单条工作经历,会被嵌套在 Resume 中作为列表使用。"""
    company: str = Field(description="公司名称")
    role: str = Field(description="职位名称")
    duration: str = Field(description="在职时长,如 '2年3个月'")
    key_achievements: list[str] = Field(
        description="主要成就列表,最多 3 条"    # 告诉 LLM 数量限制
    )

# ── 主模型引用子模型 ──────────────────────────────────────────
class Resume(BaseModel):
    """简历结构化模型,包含嵌套的工作经历列表。"""
    name: str = Field(description="候选人姓名")
    email: Optional[str] = Field(
        default=None,                             # 邮箱可能不存在,设置默认值 None
        description="邮箱地址,不存在则为 null"  # 明确告诉 LLM 什么时候输出 null
    )
    years_of_experience: float = Field(description="总工作年限")
    skills: list[str] = Field(description="技术技能列表")
    work_history: list[WorkExperience] = Field(  # 嵌套模型列表
        description="工作经历列表,按时间倒序"
    )
    summary: str = Field(description="候选人一句话总结,适合 HR 快速了解")

# ── 提取简历信息 ───────────────────────────────────────────────
resume_llm = llm.with_structured_output(Resume)  # 绑定简历 Schema

resume_text = """
张伟,5年后端开发经验,擅长 Python、Go、分布式系统设计。
联系方式:zhangwei@example.com

工作经历:
2021.09 - 至今:字节跳动,高级后端工程师
  - 主导重构广告推荐系统,QPS 从 10万提升至 50万
  - 设计并落地分布式缓存方案,接口延迟降低 60%

2019.07 - 2021.08:美团,后端工程师
  - 负责外卖配送调度系统,日均处理订单 300万+
  - 优化 MySQL 慢查询,响应时间从 800ms 降至 50ms

技能:Python, Go, Kubernetes, Redis, Kafka, MySQL
"""

# 直接提取,一行代码,返回 Resume 对象
result: Resume = resume_llm.invoke([
    SystemMessage(content="请从简历文本中提取结构化信息。"),
    HumanMessage(content=resume_text),
])

print(f"姓名:{result.name}")                    # 直接访问字段
print(f"工作年限:{result.years_of_experience} 年")
for exp in result.work_history:                  # 遍历嵌套列表
    print(f"  [{exp.company}] {exp.role}")
    for ach in exp.key_achievements:
        print(f"    • {ach}")

四、方案二:实体提取(NER)

代码文件:lessons/11_structured_output/02_entity_extraction.py

命名实体识别(NER,Named Entity Recognition):从文本中识别并提取人名、地名、机构名等有意义的词语。

4.1 通用 NER 提取器

python 复制代码
from typing import Optional
from langchain_core.prompts import ChatPromptTemplate  # 提示词模板
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field

# ── 定义单个实体的数据结构 ────────────────────────────────────
class NamedEntity(BaseModel):
    """命名实体:文本中具有特定含义的词语。"""
    text: str = Field(description="实体文本,即原文中出现的词语")
    entity_type: str = Field(
        description="实体类型:PERSON(人名)/ ORG(机构)/ LOC(地名)/ DATE(日期)/ PRODUCT(产品)"
    )
    context: str = Field(description="实体在文中的上下文(前后各一句话)")

class NERResult(BaseModel):
    """命名实体识别结果,包含文章中所有提取到的实体。"""
    entities: list[NamedEntity] = Field(description="提取到的所有命名实体列表")
    entity_count: int = Field(description="实体总数量")
    dominant_topic: str = Field(description="文章主要话题,一句话概括")

# ── 构建提示词模板 ─────────────────────────────────────────────
# ChatPromptTemplate 支持变量插值,{text} 会在调用时被替换
ner_prompt = ChatPromptTemplate.from_messages([
    ("system",
     "你是一个专业的命名实体识别系统。请从文本中准确提取所有命名实体,"
     "包括人名、机构名、地名、日期、产品名等。"),
    ("human", "请对以下文章进行命名实体识别:\n\n{text}"),  # {text} 是占位符
])

# ── 构建提取链 ─────────────────────────────────────────────────
# LCEL 管道:提示词 → LLM → 结构化输出
# | 符号将组件串联,形成数据流水线
ner_chain = ner_prompt | llm.with_structured_output(NERResult)

# ── 执行提取 ───────────────────────────────────────────────────
news_text = """
马云在杭州宣布,阿里巴巴将在 2025 年投资 1000 亿元用于 AI 基础设施建设。
此前,字节跳动 CEO 梁汝波在北京召开的世界互联网大会上也表示,
将加大对 TikTok 和抖音算法团队的研发投入。
"""

result: NERResult = ner_chain.invoke({"text": news_text})  # {text} 被替换
print(f"实体总数:{result.entity_count}")
print(f"主要话题:{result.dominant_topic}")
for entity in result.entities:
    print(f"  [{entity.entity_type}] {entity.text}")

4.2 产品规格提取

电商场景中,用户上传的商品描述格式千变万化,需要自动解析成标准字段:

python 复制代码
class ProductSpec(BaseModel):
    """产品规格参数,从非结构化描述中提取标准化字段。"""
    name: str = Field(description="商品名称")
    brand: Optional[str] = Field(default=None, description="品牌名称,未提及则为 null")
    price: Optional[float] = Field(default=None, description="价格(人民币),未提及则为 null")
    dimensions: Optional[str] = Field(default=None, description="尺寸规格(长×宽×高),未提及则为 null")
    weight: Optional[str] = Field(default=None, description="重量,未提及则为 null")
    color_options: list[str] = Field(default_factory=list, description="可选颜色列表,无则为空列表")
    key_features: list[str] = Field(description="核心卖点列表,最多 5 条")
    target_audience: str = Field(description="目标用户群体")

# 不同格式的商品描述,全部能统一解析
descriptions = [
    """
    索尼 WH-1000XM5 头戴式降噪耳机,旗舰降噪性能,售价 2999 元。
    支持 30 小时续航,配备多点连接技术,可同时连接两台设备。
    提供午夜黑和银白色两个配色,重量仅 250g。适合商务出行和居家办公。
    """,
]

spec_llm = llm.with_structured_output(ProductSpec)
for desc in descriptions:
    spec = spec_llm.invoke([
        SystemMessage(content="从商品描述中提取标准化规格信息。"),
        HumanMessage(content=desc),
    ])
    print(f"商品:{spec.name}  品牌:{spec.brand}  价格:{spec.price}元")
    print(f"颜色:{spec.color_options}")
    print(f"核心卖点:{spec.key_features}")

4.3 5W1H 新闻要素提取

新闻分析的经典框架(5W1H:Who、What、When、Where、Why、How):

python 复制代码
class NewsElements(BaseModel):
    """新闻五要素(5W1H),结构化提取新闻核心信息。"""
    who: list[str] = Field(description="涉及的主要人物或机构")
    what: str = Field(description="发生了什么事件(核心事实)")
    when: Optional[str] = Field(default=None, description="事件发生时间")
    where: Optional[str] = Field(default=None, description="事件发生地点")
    why: Optional[str] = Field(default=None, description="事件发生原因")
    how: Optional[str] = Field(default=None, description="事件经过或方式")
    importance_score: int = Field(
        description="新闻重要性评分 1-10",
        ge=1, le=10                               # 约束范围,超出会触发 Pydantic 报错
    )

news_llm = llm.with_structured_output(NewsElements)
headline = "OpenAI 于 2024 年 12 月在旧金山发布 GPT-4o,该模型在多项基准测试中超越了 GPT-4,主要改进包括多模态能力的大幅提升。"
elements = news_llm.invoke([
    SystemMessage(content="提取新闻中的 5W1H 要素。"),
    HumanMessage(content=headline),
])
print(f"事件:{elements.what}")
print(f"时间:{elements.when}  地点:{elements.where}")
print(f"重要性:{elements.importance_score}/10")

五、方案三:JSON 链与思维链+提取

代码文件:lessons/11_structured_output/03_json_chain.py

5.1 JsonOutputParser:兼容性方案

当模型不支持 function-calling 时,用提示词引导输出 JSON:

python 复制代码
import json                                       # 处理 JSON 数据
from langchain_core.output_parsers import JsonOutputParser  # JSON 解析器
from langchain_core.prompts import ChatPromptTemplate

# ── JsonOutputParser 会生成格式说明,注入提示词 ──────────────
parser = JsonOutputParser()                       # 初始化解析器

# get_format_instructions() 返回类似于:
# "请以 JSON 格式输出,例如:{"key": "value"}"
format_instructions = parser.get_format_instructions()

recipe_prompt = ChatPromptTemplate.from_messages([
    ("system",
     "你是一个智能食谱生成器。根据食材生成一道菜的食谱。\n"
     "{format_instructions}"),                    # 将格式说明注入系统提示
    ("human", "我有这些食材:{ingredients},帮我生成食谱。"),
])

# partial() 预填充 format_instructions 变量,后续只需传 ingredients
recipe_chain = (
    recipe_prompt.partial(format_instructions=format_instructions)
    | llm                                         # 调用 LLM
    | parser                                      # 从字符串中提取并解析 JSON
)

result = recipe_chain.invoke({"ingredients": "鸡蛋、番茄、大蒜、盐"})
# result 是 dict,不是字符串
print(json.dumps(result, ensure_ascii=False, indent=2))

5.2 思维链 + 结构化提取(CoT+Extraction)

思维链(Chain of Thought,CoT):让 LLM 先一步步推理,再得出结论。适合复杂分析任务,推理过程让最终结构化结论更准确:

python 复制代码
from langchain_core.output_parsers import StrOutputParser  # 字符串解析器

# ── 定义投资分析结果模型 ──────────────────────────────────────
class InvestmentAnalysis(BaseModel):
    """投资分析结论,从推理文字中提取。"""
    recommendation: str = Field(description="投资建议:买入/持有/卖出")
    target_price: float = Field(description="目标价格(元)")
    risk_level: str = Field(description="风险等级:低/中/高")
    key_factors: list[str] = Field(description="关键决策因素,3-5条")
    time_horizon: str = Field(description="持有周期:短期(<1年)/中期(1-3年)/长期(>3年)")

# ── 第一步:推理链(输出字符串,自由推理) ───────────────────
analysis_prompt = ChatPromptTemplate.from_messages([
    ("system", "你是资深股票分析师,请对股票信息进行深入分析,"
               "考虑基本面、技术面、市场环境等维度,写出详细推理过程。"),
    ("human", "分析此股票:\n{stock_info}"),
])
analysis_chain = analysis_prompt | llm | StrOutputParser()  # 输出是字符串

# ── 第二步:提取链(从推理文字中提炼结构化结论) ─────────────
extract_prompt = ChatPromptTemplate.from_messages([
    ("system", "你是信息提取专家,从以下分析文章中提取结构化投资建议,"
               "不要添加原文没有的内容。"),
    ("human", "从以下分析中提取投资建议:\n\n{analysis}"),
])
extract_chain = extract_prompt | llm.with_structured_output(InvestmentAnalysis)

# ── 执行两步链 ─────────────────────────────────────────────────
stock_info = """
比亚迪 (002594) 当前价格 278元,PE 25倍(行业均值30倍),
近6个月涨幅+18%,Q3营收同比+32%,净利润同比+18%。
风险:原材料价格波动,海外监管不确定性。
"""

print("第一步:LLM 自由推理...")
analysis_text = analysis_chain.invoke({"stock_info": stock_info})  # 推理文字
print(f"推理(节选):{analysis_text[:150]}...")

print("\n第二步:从推理中提取结构化结论...")
result: InvestmentAnalysis = extract_chain.invoke({"analysis": analysis_text})  # 提取
print(f"建议:{result.recommendation}  目标价:{result.target_price}元")
print(f"风险:{result.risk_level}  周期:{result.time_horizon}")
for factor in result.key_factors:
    print(f"  • {factor}")

六、三种方案对比与选型

方案 方法 类型安全 适用场景 备注
A with_structured_output(Model) ✅ Pydantic 对象 生产首选,支持 function-calling 的模型 推荐
B JsonOutputParser ⚠️ dict 旧模型兼容,快速原型 无类型校验
C 推理 + 提取(两步) ✅ Pydantic 对象 复杂分析任务(需要推理过程) 多一次 LLM 调用

七、逐步追踪:with_structured_output() 做了什么?

理解底层机制有助于排查问题:

javascript 复制代码
1. 你调用:llm.with_structured_output(SentimentResult)
   ↓
2. LangChain 将 SentimentResult 转为 JSON Schema:
   {
     "name": "SentimentResult",
     "properties": {
       "sentiment": {"enum": ["positive", "negative", "neutral"], "description": "整体情感倾向"},
       "confidence": {"type": "number", "minimum": 0, "maximum": 1, ...},
       ...
     }
   }
   ↓
3. LangChain 将 Schema 包装为 OpenAI function 工具,随请求发送给 LLM
   ↓
4. LLM 理解工具定义,返回:
   {"function_call": {"name": "SentimentResult", "arguments": "{\"sentiment\":\"positive\",...}"}}
   ↓
5. LangChain 解析 arguments JSON → 调用 SentimentResult(**data)
   ↓
6. Pydantic 校验数据类型和约束 → 返回 SentimentResult 对象给你

八、常见错误与排查

错误信息 原因 解决方法
ValidationError: confidence must be <= 1.0 LLM 返回了超出范围的值(如 1.5 在字段描述中加强约束说明,同时保留 ge/le 校验
OutputParserException: Failed to parse LLM 输出不是有效 JSON(模型不支持 function-calling) 改用 JsonOutputParser,或换用支持 function-calling 的模型
字段为 None 但你期望有值 字段定义为 Optional[str] 且原文没有相关信息 检查字段描述是否足够清晰,或将字段改为必填(移除 Optional
枚举字段返回了意外的值 Enum 里的取值与描述不一致 确保 Field(description=...) 中的取值与 Enum 定义完全一致
嵌套列表为空 [] 原文中没有相关信息,或提示词没有引导提取 在系统提示中明确说明"如果找不到请返回空列表"
temperature=0.7 导致字段取值不稳定 高温度增加随机性,结构化任务需要确定性 结构化提取任务统一设置 temperature=0

九、最佳实践总结

python 复制代码
# ✅ 最佳实践示范

# 1. 结构化任务:temperature=0,确保确定性输出
llm = ChatOpenAI(model="qwen-plus", temperature=0, ...)

# 2. 每个字段都写 description,且描述精确
class GoodModel(BaseModel):
    score: int = Field(
        description="质量评分 1-10,其中 1=极差,5=一般,10=完美",  # 给出评分标准
        ge=1, le=10                   # 加上范围校验兜底
    )

# 3. Optional 字段明确说明什么时候为 null
email: Optional[str] = Field(
    default=None,
    description="邮箱地址;如果文本中未提及,则返回 null"  # 明确说明 null 场景
)

# 4. 列表字段说明数量限制
items: list[str] = Field(
    description="核心要点列表,3-5 条,每条不超过 20 字"  # 限定数量和长度
)

# 5. 使用 Enum 替代自由文本字段,限制取值范围
class Status(str, Enum):
    PASS = "pass"
    FAIL = "fail"
    # 不要写 status: str = Field(description="pass 或 fail")
    # 因为 LLM 可能返回"通过"、"合格"、"PASS"等各种变体

下一章预告

掌握了结构化输出之后,下一章我们进入高级 RAG(检索增强生成)技术。

第12章 将解答:

  • 为什么用户问的问题往往找不到正确答案?(表述不一致问题)
  • Multi-Query:一问变多问,全面覆盖语义空间
  • Contextual Compression:只留下最相关的内容,降噪提质
  • HyDE:用假设答案的向量去检索,突破专业术语壁垒

结构化输出是"数据进数据出"的钥匙,高级 RAG 是"找到正确信息"的利器。两者结合,才能构建真正可靠的生产级 AI 应用。


AI入门开发系列文章合集
作者:阿聪谈架构

公众号:阿聪谈架构 (分享后端架构 / AI / Java 技术文章)

相关代码关注公众号:【阿聪谈架构】 回复:AI专栏代码

相关推荐
神奇小汤圆7 小时前
Java面试八股文+场景题+答案,100万字精华版,全网仅此一份
后端
OpenBayes贝式计算7 小时前
外语、方言、少数民族语言全覆盖:Hy-MT1.5 支持 1056 个翻译方向;MIT 联合发布 MathNet:涵盖 2.7 万道奥数真题的多模态数学推理基准
人工智能
数据仓库搬砖人7 小时前
XGBoost 调参指南
后端
OpenCSG7 小时前
CSGHub v2.1.0开源版本更新
人工智能
沪漂阿龙7 小时前
Dify 面试题详解:开源 LLM 应用开发平台、RAG 知识库、Workflow 工作流、Agent 智能体一文讲透
人工智能·架构
移动云开发者联盟7 小时前
存智赋能 共筑AI存储新生态,移动云聚力技术创新夯实AI数据基石
大数据·人工智能
user29876982706547 小时前
五、Hooks 实战:验证、通知与环境初始化
人工智能
Nayxxu7 小时前
Claude 长上下文调用成本分析:token、缓存、摘要与切片策略
人工智能
学以智用7 小时前
.NET Core 仓储模式(Repository Pattern)完整教程
后端·.net