在开发 LLM 应用时,你一定遇到过这样的痛点:模型"口吐芬芳"给你一段自然语言,你却要写一堆正则、json.loads() 甚至 eval() 去提取信息,不仅脆弱,而且维护成本极高。
其实,LangChain 早已提供了 结构化输出(Structured Output) 的优雅方案,只需定义一个 Schema,模型就会按你的要求返回类型安全的数据。今天我们就来全面对比四种主流方式:Pydantic 、TypedDict 、JSON Schema 和 @dataclass ,并额外补充两种获取结构化结果的进阶技巧------让你不仅能拿到干净的对象,还能捕获原始消息和令牌用量。带你避开那些隐藏的"坑"!
🧱 环境准备
所有示例均基于 LangChain + OpenRouter 的 DeepSeek 模型,你也可以换成 OpenAI、Anthropic 等任何支持函数调用的模型。
python
from langchain.chat_models import init_chat_model
from dotenv import load_dotenv
import os
load_dotenv(override=True)
model = init_chat_model(
model="deepseek-v4-flash",
model_provider="openai",
api_key=os.getenv("OPENROUTER_API_KEY"),
base_url=os.getenv("OPENROUTER_BASE_URL")
)
1️⃣ 方案一:Pydantic(生产环境首选)
Pydantic 是 Python 数据验证的事实标准,LangChain 对它支持最完善。你只需继承 BaseModel,加上类型注解和 Field 描述,调用 with_structured_output 即可获得类型安全的实例。
基础示例
python
from pydantic import BaseModel, Field
class Person(BaseModel):
name: str = Field(description="姓名")
age: int = Field(description="年龄")
occupation: str = Field(description="职业")
structured = model.with_structured_output(Person)
res = structured.invoke("张三是一名30岁的软件工程师")
print(res) # name='张三' age=30 occupation='软件工程师'
print(type(res)) # <class '__main__.Person'>
✨ 高级特性
- 可选字段 & 默认值
使用Optional或设置default,模型会尽力推理,缺失时填充默认值(但注意部分厂商可能不支持默认值)。 - 枚举限制
用Enum或Literal锁死可选值,比如紧急程度只能选"高/中/低"。 - 嵌套与列表
支持多层嵌套(建议 ≤3 层)和list[Model],轻松提取复杂信息。 - 字段约束
Field(..., ge=0, le=150)等约束会在实例化时校验 ,若 LLM 输出非法数据会抛出ValidationError,相当于多了一道安全防线。
⚠️ 注意:约束条件只在 Pydantic 实例化时生效,部分模型可能不遵守,所以服务端返回的 JSON 仍可能"越界",务必捕获异常。
🔧 Pydantic 到底在背后做了什么?(核心工作流程)
很多同学只管调用,但不知道 LangChain 是怎么把 Pydantic 类变成 LLM 能懂的东西的。了解这个流程,能帮你更好地排查 Bug。整个过程分为 6 步:
| 步骤 | 做了什么 | 关键动作 |
|---|---|---|
| 1️⃣ | 定义模型 | 你写 class Person(BaseModel),加上类型和描述 |
| 2️⃣ | 生成 Schema | LangChain 调用 model.model_json_schema() 自动转成 JSON Schema 字典 |
| 3️⃣ | 包装成工具 | 将这个 JSON Schema 作为 LLM 的 Tool(工具/函数) 传入请求参数中 |
| 4️⃣ | LLM 推理 | 模型看懂 Schema,按规则生成严格符合格式的 JSON 字符串 |
| 5️⃣ | Pydantic 校验 | LangChain 拿到 JSON,调用 Pydantic 进行类型、约束(如 le=150)的校验 |
| 6️⃣ | 实例化返回 | 校验通过后,JSON 被转换为** Python 对象**(Person 实例),交付给你 |
为什么要懂这个?
如果第 5 步校验失败(例如模型抽风输出了 age=200),程序会直接抛出 ValidationError。如果你不清楚这个流程,可能会误以为是模型没返回数据,而实际上是数据"不干净"被拦截了。
2️⃣ 方案二:TypedDict(轻量级类型提示)
如果你不想引入 Pydantic 的重量,但又希望有类型提示,TypedDict 是最佳选择。它只是类型声明,不执行运行时校验,适合快速原型。
python
from typing_extensions import TypedDict, Annotated
class Movie(TypedDict):
title: Annotated[str, "电影名称"]
year: Annotated[int, "上映年份"]
director: Annotated[str, "导演"]
rating: Annotated[float, "评分"]
structured = model.with_structured_output(Movie)
res = structured.invoke("星际穿越")
print(res) # 字典类型
print(type(res)) # <class 'dict'>
亮点 :Annotated 中可添加描述,LangChain 会将其转为 Schema 的 description。你还可以用 ... 占位符表示必填字段。
缺点:无验证,字段类型错误不会报错,只依赖 LLM 的"自觉"。
3️⃣ 方案三:JSON Schema(原始字典)
当你需要动态生成 Schema 或不想定义任何类时,直接写 JSON Schema 字典即可。LangChain 通过 method="json_schema" 支持。
python
schema = {
"type": "object",
"properties": {
"title": {"type": "string", "description": "电影名称"},
"year": {"type": "integer", "description": "上映年份"}
},
"required": ["title", "year"]
}
structured = model.with_structured_output(schema, method="json_schema")
res = structured.invoke("盗梦空间")
print(res) # {'title': '盗梦空间', 'year': 2010}
这种方法最灵活,但完全失去了类型安全和 IDE 提示,适合临时脚本或配置驱动场景。
4️⃣ 方案四:@dataclass(标准库简洁方案)
Python 内置的 @dataclass 也可以"冒充" Schema,LangChain 同样支持。配合 Pydantic 的 Field 添加描述,但无运行时验证。
python
from dataclasses import dataclass
from pydantic import Field
@dataclass
class Movie:
title: str = Field(description="标题")
year: int = Field(description="年份")
structured = model.with_structured_output(Movie)
res = structured.invoke("流浪地球")
print(res) # Movie(title='流浪地球', year=2019)
优点是无额外依赖,缺点和 TypedDict 类似------不校验类型,且 Field 的支持可能不如 Pydantic 全面。
🆕 5️⃣ 进阶:如何获取结构化结果(含原始响应和令牌用量)
除了直接拿到解析后的对象,有时我们还需要原始的 AIMessage (用于调试、获取 tool_calls 或审计),或者想统计 Token 消耗。LangChain 提供了两种便捷方式。
5.1 使用 with_structured_output(..., include_raw=True)
在调用 with_structured_output 时传入 include_raw=True,返回的将是一个字典,包含三个字段:
raw:原始的AIMessage对象(包含完整的响应元数据)parsed:解析后的结构化对象(如果解析成功)parsing_error:解析过程中的异常(如果有)
python
from pydantic import BaseModel, Field
class Movie(BaseModel):
title: str = Field(description="电影标题")
year: int = Field(description="上映年份")
director: str = Field(description="导演")
rating: float = Field(description="评分(10分制)")
# 开启 include_raw
structured = model.with_structured_output(Movie, include_raw=True)
resp = structured.invoke("给我介绍下电影《星际穿越》")
print(type(resp)) # <class 'dict'>
print(resp.keys()) # dict_keys(['raw', 'parsed', 'parsing_error'])
# 查看解析后的对象
print(resp['parsed']) # Movie(title='星际穿越', year=2014, ...)
# 查看原始消息的令牌用量
print(resp['raw'].usage_metadata) # 含 input_tokens, output_tokens 等
适用场景:你既需要结构化数据,又需要监控成本或调试原始输出。
5.2 使用 JsonOutputParser(传统管道方式)
LangChain 还提供了 JsonOutputParser,配合 ChatPromptTemplate 和管道 | 操作符使用。这种方式更加显式,适合需要自定义 Prompt 的场景。
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="上映年份")
parser = JsonOutputParser(pydantic_object=Movie)
prompt = ChatPromptTemplate.from_messages([
("system", "回答用户问题,必须始终输出一个包含 title 和 year 的 JSON 对象"),
("human", "问题:{question}")
])
chain = prompt | model | parser
response = chain.invoke({"question": "介绍电影《盗梦空间》"})
print(response) # {'title': '盗梦空间', 'year': 2010}
注意 :JsonOutputParser 返回的是字典 ,而不是 Pydantic 实例。若想获得 Pydantic 对象,可改用 PydanticOutputParser。且此方式不能直接获取原始 AIMessage,需要额外处理。
对比:
with_structured_output(include_raw=True)更简洁,一步到位,推荐在大多数场景使用。JsonOutputParser更灵活,适合需要精细控制 Prompt 和解析流程的老项目。
📊 横向对比:选哪个?
| 方案 | 运行时校验 | 自动类型转换 | IDE 友好 | 可获取原始消息 | 典型场景 |
|---|---|---|---|---|---|
| Pydantic | ✅ 强校验 | ✅ | ⭐⭐⭐⭐⭐ | ✅(include_raw) | 生产级 API,数据质量严苛 |
| TypedDict | ❌ | ❌ | ⭐⭐⭐ | ✅(include_raw) | 快速原型,字典操作 |
| JSON Schema | ❌ | ❌ | ⭐ | ✅(include_raw) | 动态 Schema,临时调用 |
| @dataclass | ❌ | ❌ | ⭐⭐⭐⭐ | ✅(include_raw) | 轻量数据类,无校验需求 |
| JsonOutputParser | ❌(只解析) | ❌ | ⭐⭐⭐ | ❌(需额外处理) | 自定义 Prompt 管道,传统方式 |
我的建议 :正式项目无脑选 Pydantic 并开启 include_raw=True,既能保证数据质量,又能监控 Token 成本。若追求极致性能且对数据质量足够信任,TypedDict 也是不错的选择。
🧩 避坑指南
- 默认值「失灵」:不同模型供应商对默认值的支持不一致,实测 OpenRouter 表现良好,但某些闭源模型可能忽略默认值,所以最好在业务层做兜底。
- 嵌套不宜太深:LLM 对深层嵌套(>3 层)理解有限,容易漏字段或乱填,尽量扁平化。
- 描述是王道 :字段的
description直接影响提取准确性,务必写清楚示例或范围。 - 校验异常处理 :使用 Pydantic 时,务必捕获
ValidationError,否则程序可能崩溃。 include_raw=True的副作用 :返回的是字典而非对象,记得取parsed字段才是你的结构化数据。- JsonOutputParser 与 with_structured_output 的区别:前者更"底层",后者更"智能",优先推荐后者。
🚀 总结
LangChain 的 with_structured_output 将"结构化输出"这件事变得异常简单,我们只需根据场景选择合适的 Schema 定义方式。无论是追求严谨的 Pydantic,还是灵活的 JSON Schema,都能显著提升开发效率,告别繁琐的字符串解析。而 include_raw=True 和 JsonOutputParser 则为我们提供了更细粒度的控制,让成本监控和调试变得轻而易举。
如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、转发,让更多人避开那些坑! 有任何疑问,也欢迎在评论区留言交流 👇