【LangChain 1.x】05、结构化输出|三种结构定义模式与两种结果获取方式

前言

上一篇,我们梳理了模型的几种调用方式(invoke、stream、batch 以及它们的异步变体),已经能让模型稳定地返回内容。

但在实际开发中,我们往往不满足于"拿到一段自然语言文本",在做信息抽取、表单填充、生成 API 调用参数时,更希望模型直接返回能被程序解析的结构化数据,而不是再写一堆正则去抠字段。

本篇,就和大家一起聊聊怎么让模型按我们定义的结构输出。从两个角度展开:定义结构的三种模式(Pydantic、TypedDict、JSON Schema),以及获取结果的两种方式(with_structured_outputJsonOutputParser)。

一、结构化输出的价值

1.1 为什么需要结构化输出

模型默认返回的是自由文本。如果想让程序自动处理结果(存数据库、传给下游接口),就得自己写正则或 JSON 解析,既脆弱又容易出错。

结构化输出让模型直接返回符合预设 schema 的对象,字段名、类型都有保证,省去手动解析。常见的应用场景:

  • 信息抽取(从简历、新闻里提取结构化字段)
  • 表单自动填充
  • 生成 API 调用参数
  • 评测打分(情感倾向、评分等固定字段)

1.2 一个避坑提示:DeepSeek 思考模式

开始之前,先说一个实际踩到的坑。DeepSeek 的 deepseek-v4-flash 默认开启思考模式(thinking mode),而这个模式目前和结构化输出用到的 tool_choice 不兼容,直接调用会报错:

txt 复制代码
openai.BadRequestError: ... 'Thinking mode does not support this tool_choice'

解决方法是在初始化模型时,通过 extra_body 关闭思考模式:

python 复制代码
deepseek_llm = init_chat_model(
    model="deepseek-v4-flash",
    model_provider="deepseek",
    api_key=DEEPSEEK_API_KEY,
    base_url=DEEPSEEK_BASE_URL,
    extra_body={
        "thinking": {
            "type": "disabled"
        }
    },  # 关闭思考模式后支持结构化输出,否则报错 Thinking mode does not support this tool_choice
)

关闭后就能正常使用 with_structured_output 了。如果你用的是其它模型(比如 OpenAI),一般没有这个问题。本篇后续示例都基于关闭思考模式后的 DeepSeek。

二、Pydantic 模式(推荐)

Pydantic 是三种方式里用得最多的一种,返回的是模型实例,带类型校验,写起来也直观。

2.1 基础用法

定义一个继承 BaseModel 的类,用 Field 描述每个字段,再通过 with_structured_output 绑定:

python 复制代码
from pydantic import BaseModel, Field

class CityInfo(BaseModel):
    """城市信息"""
    name: str = Field(description="城市名称")
    country: str = Field(description="所属国家")
    population: int = Field(description="人口数量")

structured_llm = deepseek_llm.with_structured_output(CityInfo)
result = structured_llm.invoke("介绍一下北京这座城市")

运行结果:

txt 复制代码
返回类型: <class '__main__.CityInfo'>
城市名称: 北京
所属国家: 中国
人口数量: 21893000

注意返回类型是 CityInfo 实例(不再是 AIMessage),可以直接用 . 属性访问字段,写起来和普通对象一样。

2.2 字段描述(Field)

Fielddescription 会传给模型,引导它正确填写。字段含义不明显时,这点尤其重要:

python 复制代码
class ProductInfo(BaseModel):
    """商品信息"""
    name: str = Field(description="商品名称")
    price: float = Field(description="商品价格,单位:元")
    category: str = Field(description="商品分类,如:电子产品、服装、食品等")
    in_stock: bool = Field(description="是否有库存")
txt 复制代码
商品名称: iPhone 15 Pro
价格: 7999.0 元
分类: 电子产品
有库存: True

description 写得越清楚,模型理解越准确,一些容易歧义的字段(比如价格单位、分类枚举值)建议在描述里点明。

2.3 嵌套结构

实际业务里结构往往层层嵌套,Pydantic 支持模型嵌套和列表:

python 复制代码
class Address(BaseModel):
    """地址信息"""
    province: str = Field(description="省份")
    city: str = Field(description="城市")
    district: str = Field(description="区县")

class Company(BaseModel):
    """公司信息"""
    name: str = Field(description="公司名称")
    address: Address = Field(description="公司地址")
    employee_count: int = Field(description="员工数量")
    main_business: list[str] = Field(description="主要业务列表")
txt 复制代码
公司名称: 阿里巴巴
地址: 浙江省杭州市余杭区
员工数量: 200000
主要业务: ['电商', '云计算', '物流']

嵌套对象用 . 链式访问(result.address.province),list[str] 这样的列表字段也能正常解析。

2.4 include_raw=True

默认情况下 with_structured_output 只返回解析后的对象。如果调试时想同时看到原始消息和解析错误,可以加上 include_raw=True

python 复制代码
structured_llm = deepseek_llm.with_structured_output(CityInfo, include_raw=True)
result = structured_llm.invoke("介绍一下上海这座城市")

返回值变成 dict,包含三个键:

txt 复制代码
返回类型: <class 'dict'>
keys: ['raw', 'parsed', 'parsing_error']

解析结果 (parsed): 上海 - 中国
解析错误 (parsing_error): None
原始消息类型 (raw): AIMessage
Token 使用: {'input_tokens': 323, 'output_tokens': 70, 'total_tokens': 393, ...}
  • parsed:解析后的结构化对象
  • raw:原始 AIMessage,可以拿到 usage_metadata 等信息
  • parsing_error:解析失败时的异常,正常为 None

排查结构化输出问题时,include_raw 很有用。

2.5 类型验证

Pydantic 会对字段做类型校验。比如把 age 定义为 int

txt 复制代码
--- 正常情况 ---
姓名: 张三, 年龄: 25 (类型: int)

模型返回的 age 会被强制成 int,这也是 Pydantic 相比 TypedDict 的一个优势(后面会对比)。

三、TypedDict 模式

TypedDict 是 Python 标准库的轻量级结构,返回的是普通 dict,不带运行时校验。

3.1 基础用法

字段描述用 typing.Annotated

python 复制代码
from typing import Annotated, TypedDict

class CityInfo(TypedDict):
    name: Annotated[str, "城市名称"]
    country: Annotated[str, "所属国家"]
    population: Annotated[int, "人口数量"]

运行结果:

txt 复制代码
返回类型: <class 'dict'>
完整结果: {'name': '北京', 'country': '中国', 'population': 21893000}
城市名称: 北京
所属国家: 中国
人口数量: 21893000

返回的是普通 dict,字段用 ['key'] 访问。

不过有一点要提醒:TypedDict 模式偶尔会出现字段缺失的情况(测试时就遇到过只返回 {'name': '北京'} 的情形,再跑一次又正常了),而 Pydantic 模式字段一般都完整。这和模型实现、method 选择有关(method 下一节会讲)。实际使用 TypedDict 时,建议用 dict.get 做一层容错,避免字段偶发缺失时直接 result['country'] 触发 KeyError

3.2 嵌套结构

TypedDict 的嵌套写法和 Pydantic 类似,返回的同样是 dict:

txt 复制代码
公司名称: 阿里巴巴
地址: 浙江省杭州市余杭区
员工数量: 200000
主要业务: ['电商', '云计算', '物流']

3.3 与 Pydantic 对比

同一段商品信息,两种方式的返回有差别:

txt 复制代码
--- Pydantic ---
类型: ProductPydantic, 结果: name='iPhone 15 Pro' price=7999.0
price 类型: float

--- TypedDict ---
类型: dict, 结果: {'name': 'iPhone 15 Pro', 'price': 7999}
price 类型: int

Pydantic 按 schema 把 price 强制成 float(7999.0),TypedDict 则原样保留模型返回的 int(7999),不做强制。这就体现了两者的核心差异:

  • Pydantic :返回模型实例,带类型校验,字段用 . 访问
  • TypedDict :返回普通 dict,轻量无校验,字段用 ['key'] 访问

如果对类型一致性要求高,优先 Pydantic;只是想要个轻量 dict、且能接受偶尔补字段,可以考虑 TypedDict。

四、JSON Schema 模式

JSON Schema 直接用原生 dict 描述结构,不依赖 Python 类型系统。

4.1 基础用法

python 复制代码
city_schema = {
    "title": "CityInfo",
    "description": "城市信息",
    "type": "object",
    "properties": {
        "name": {"type": "string", "description": "城市名称"},
        "country": {"type": "string", "description": "所属国家"},
        "population": {"type": "integer", "description": "人口数量"},
    },
    "required": ["name", "country", "population"],
}

structured_llm = deepseek_llm.with_structured_output(city_schema)
txt 复制代码
返回类型: <class 'dict'>
城市名称: 北京
所属国家: 中国
人口数量: 21893000

4.2 嵌套结构

python 复制代码
company_schema = {
    "title": "Company",
    "type": "object",
    "properties": {
        "name": {"type": "string", "description": "公司名称"},
        "address": {
            "type": "object",
            "properties": {
                "province": {"type": "string", "description": "省份"},
                "city": {"type": "string", "description": "城市"},
            },
            "required": ["province", "city"],
        },
        "employee_count": {"type": "integer", "description": "员工数量"},
    },
    "required": ["name", "address", "employee_count"],
}
txt 复制代码
公司名称: 阿里巴巴
地址: 浙江省杭州市
员工数量: 200000

4.3 适用场景

JSON Schema 模式返回的也是普通 dict,但它最大的优势在于 schema 本身就是一份 dict,可以从外部 json/yaml 文件加载、动态生成。适合需要跨语言、跨框架共享 schema 的场景,比如和前端表单、其它语言的服务复用同一份结构定义。

五、method 参数与底层机制

with_structured_output 还有一个 method 参数,决定模型用哪种机制产出结构化结果。常见有三种。

5.1 function_calling

把 schema 转成工具定义,模型通过 tool_calls 返回。这是兼容性最好的方式,也是多数模型的默认行为。

python 复制代码
structured_llm = deepseek_llm.with_structured_output(BookInfo, method="function_calling")
txt 复制代码
返回类型: BookInfo
书名: 三体
作者: 刘慈欣
年份: 2008
标签: ['科幻', '硬科幻']

5.2 json_mode

让模型返回有效 JSON,但 schema 不被 API 严格强制,需要在提示词里说明字段:

python 复制代码
structured_llm = deepseek_llm.with_structured_output(BookInfo, method="json_mode")
result = structured_llm.invoke(
    "介绍《三体》这本书... 请返回包含 title、author、year、tags 字段的 JSON"
)

因为 schema 约束弱,所以提示词里最好把字段列出来,这也是前面 TypedDict 偶尔字段缺失的一个可能原因。

5.3 json_schema

OpenAI 的 Structured Outputs,API 层面严格强制 schema。实测 DeepSeek(关闭思考模式后)也支持这种方式:

python 复制代码
structured_llm = deepseek_llm.with_structured_output(BookInfo, method="json_schema")
txt 复制代码
返回类型: BookInfo
书名: 三体
作者: 刘慈欣
年份: 2008
标签: ['科幻', '硬科幻']

5.4 三种方式对比

method schema 约束 是否需提示词辅助 兼容性
function_calling 较强(工具定义) 多数模型支持
json_mode 弱(仅保证返回 JSON) 部分模型支持
json_schema 强(API 层面强制) OpenAI、DeepSeek 等支持

一些个人体会:

  • 默认用 function_calling,兼容性最好,绝大多数场景够用
  • 需要 schema 严格保证时用 json_schema(前提是模型支持)
  • json_mode 适合简单场景,记得在提示词里把字段说清楚

六、另一种方式:JsonOutputParser

前面几章用的都是 with_structured_output,它把 schema 绑定到模型上,走的是 tool_choice 机制,所以 DeepSeek 需要关闭思考模式才能用。

LangChain 还提供了另一条路:JsonOutputParser 输出解析器,配合提示词模板和链(chain)使用。它的思路是通过提示词要求模型输出 JSON,再用解析器把结果解析成 dict。因为不依赖 tool_choice,所以不需要关闭思考模式------这正好绕开了前面的避坑点。

6.1 基础用法

python 复制代码
from langchain_core.output_parsers import JsonOutputParser
from langchain_core.prompts import ChatPromptTemplate
from pydantic import BaseModel, Field

class Movie(BaseModel):
    title: str = Field(description="电影标题")
    year: int = Field(description="上映年份")

# 创建解析器,绑定 Pydantic 结构
parser = JsonOutputParser(pydantic_object=Movie)

# 提示模板,明确要求输出 JSON 字段
prompt = ChatPromptTemplate.from_template("""
回答用户问题。
问题:{question}
你必须始终输出一个包含 title(电影标题) 和 year(上映年份) 的 JSON 对象。
""")

# 用管道符组装成链:提示 → 模型 → 解析器
chain = prompt | deepseek_reasoner_llm | parser

response = chain.invoke({"question": "介绍电影《盗梦空间》"})

这里并没有关闭 DeepSeek 模型(deepseek-v4-flash)的思考模式,同样能正常返回:

txt 复制代码
返回类型: <class 'dict'>
结果: {'title': '盗梦空间', 'year': 2010}
电影: 盗梦空间 (2010)

6.2 自动生成格式说明

手写"输出 title 和 year 的 JSON"有点繁琐,JsonOutputParser 提供了 get_format_instructions(),能根据 Pydantic 结构自动生成格式说明,用 partial 注入提示词:

python 复制代码
parser = JsonOutputParser(pydantic_object=Movie)

prompt = ChatPromptTemplate.from_template("""
回答用户问题。
{format_instructions}
问题:{question}
""").partial(format_instructions=parser.get_format_instructions())

chain = prompt | deepseek_reasoner_llm | parser

运行结果:

txt 复制代码
结果: {'title': '星际穿越', 'year': 2014}
电影: 星际穿越 (2014)

这样字段多了也不用一个个手写描述。

6.3 两种方式对比

维度 with_structured_output JsonOutputParser
原理 schema 绑定模型,走 tool_choice 提示词约束 + 解析
是否依赖 tool_choice
DeepSeek 思考模式 需关闭 可直接用
schema 约束强度 强(尤其 json_schema) 弱(靠提示词)
返回值 Pydantic 实例 / dict dict

一些个人体会:

  • 需要 schema 严格约束、字段类型校验时,优先 with_structured_output
  • 想保留思考模式、或模型不支持 tool_choice 时,用 JsonOutputParser
  • JsonOutputParser 更灵活(提示词可自由定制),但约束弱一些,复杂结构可能需要更强的提示词

七、实战案例

6.1 简历信息提取(复杂嵌套结构)

把一段非结构化的简历文本,一次性提取成多层嵌套对象------技能列表、多段工作经历、教育经历:

python 复制代码
class WorkExperience(BaseModel):
    company: str = Field(description="公司名称")
    position: str = Field(description="职位")
    duration: str = Field(description="工作时间,如 2020-2023")
    description: str = Field(description="工作内容简述")

class Resume(BaseModel):
    name: str = Field(description="姓名")
    age: int = Field(description="年龄")
    skills: list[str] = Field(description="技能列表")
    work_experiences: list[WorkExperience] = Field(description="工作经历列表")
    education: Education = Field(description="教育经历")
txt 复制代码
姓名: 张三 (年龄: 28)
技能: ['Python', 'Java', 'Go', 'Docker', 'Kubernetes']

工作经历:
  1. 阿里巴巴 - 后端工程师 (2020-2023)
     负责电商订单系统开发
  2. 字节跳动 - 高级工程师 (2023-至今)
     负责推荐系统后端

教育经历: 北京大学 计算机科学 本科 2019届

6.2 商品评价分析(多字段联合提取)

一段评价文本,联合提取情感、评分、关键词、优缺点、总结:

txt 复制代码
情感倾向: 中性
评分: 6.5/10
关键词: ['屏幕', '拍照', '夜景模式', '电池续航', '价格', '性价比']
优点: ['屏幕显示效果好,色彩鲜艳', '拍照清晰,夜景模式表现亮眼']
缺点: ['电池续航一般,重度使用撑不了一天', '价格偏高', '性价比一般']
总结: 手机屏幕和拍照表现出色,但电池续航和价格是短板,整体性价比一般,适合注重拍照的用户。

6.3 容错处理:信息缺失时模型的行为

include_raw=True 观察。当输入信息不完整时:

txt 复制代码
--- 测试 1: 张三,25岁 ---
解析成功: 张三, 25岁

--- 测试 2: 这个人叫王五 ---
解析成功: 王五, 0岁

这里有一个值得注意的点:第二个用例并没有提供年龄,模型并没有报错,而是直接填了 0。也就是说,结构化输出在信息缺失时,倾向于"补一个值"而不是失败,parsing_error 往往不会触发。

如果业务上对缺失敏感(比如年龄为 0 是不合理的),需要自己在结果上加一层校验,不能完全依赖 parsing_error 来兜底。

八、总结

本篇从「定义结构」和「获取结果」两个角度,梳理了结构化输出的用法:

  • 定义结构的三种模式:Pydantic(推荐,带类型校验)、TypedDict(轻量 dict)、JSON Schema(原生 dict,适合跨语言共享)

  • 获取结果的两种方式

    • with_structured_output:schema 绑定模型,约束强,但走 tool_choice,DeepSeek 需关闭思考模式
    • JsonOutputParser:提示词 + 解析,约束弱一些,但不依赖 tool_choice,思考模式可用
  • method 参数function_calling(默认,兼容性好)、json_mode(需提示词辅助)、json_schema(API 强制,需模型支持)

  • 几个实战要点

    • DeepSeek 用 with_structured_output 前记得关闭思考模式,或改用 JsonOutputParser
    • TypedDict 模式可能字段缺失,访问时建议用 get 容错
    • include_raw=True 便于调试,能同时拿到原始消息和解析错误
    • 信息缺失时模型会"补值"而非报错,关键业务要自己加校验

下一篇,我们会进入工具调用(Tool Calling),看看怎么让模型不只是输出结构化数据,还能真正去调用外部函数。

相关推荐
俊俊谢2 小时前
从零搭建:本地LangChain Agent调用远程LLaMA-Factory模型服务
langchain·llama
怕浪猫2 小时前
第15章 安全、伦理与合规
langchain·openai·agent
向日的葵00613 小时前
langchain的Tools教程(三)
python·langchain·tools
马里马里奥-1 天前
大模型应用开发笔记04:LangChain 工具(Tool)与中间件(Middleware)详解
笔记·中间件·langchain
常常有1 天前
LangChain 学习记录 03:提示词模板 PromptTemplate 与 ChatPromptTemplate 详解
学习·langchain
大流星1 天前
LangChainJS之Chain链(四)
javascript·langchain
To_OC1 天前
手搓一个迷你版 Cursor:从零实现能自动写代码的编程 Agent
人工智能·langchain·llm
先吃饱再说2 天前
组合多个远程 MCP Server:让 Agent 同时调用高德地图、Chrome DevTools 和文件系统
langchain·llm·mcp
先吃饱再说2 天前
跨进程调用工具:MCP + LangChain 让 Agent 不再“锁死”在单一语言
langchain·llm·mcp