前言
上一篇,我们梳理了模型的几种调用方式(invoke、stream、batch 以及它们的异步变体),已经能让模型稳定地返回内容。
但在实际开发中,我们往往不满足于"拿到一段自然语言文本",在做信息抽取、表单填充、生成 API 调用参数时,更希望模型直接返回能被程序解析的结构化数据,而不是再写一堆正则去抠字段。
本篇,就和大家一起聊聊怎么让模型按我们定义的结构输出。从两个角度展开:定义结构的三种模式(Pydantic、TypedDict、JSON Schema),以及获取结果的两种方式(with_structured_output 和 JsonOutputParser)。
一、结构化输出的价值
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)
Field 的 description 会传给模型,引导它正确填写。字段含义不明显时,这点尤其重要:
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便于调试,能同时拿到原始消息和解析错误- 信息缺失时模型会"补值"而非报错,关键业务要自己加校验
- DeepSeek 用
下一篇,我们会进入工具调用(Tool Calling),看看怎么让模型不只是输出结构化数据,还能真正去调用外部函数。