6.结构化输出

23 阅读13分钟

LangChain 结构化输出 (Structured Output) 核心机制总结

1. 为什么需要结构化输出?(with_structured_output)

大模型天生只能输出自由的自然语言(人话),而下游业务代码只接受严格的数据格式(机器话)with_structured_output 的核心使命就是作为连接两者的“模具”与“桥梁”。

如果不使用结构化输出的痛点:

  • 废话太多:模型经常会在 JSON 数据外层加上“好的”、“没问题”等客套话,导致原生的 json.loads() 直接崩溃。
  • 格式不稳定:每次输出的字段名(Key)和类型(Type)可能会凭空改变,无法被代码可靠解析。
  • 信息缺失:在执行信息抽取任务时,模型偶尔会漏掉关键字段,引发程序 KeyError 报错。

2. 如何让模型实现结构化输出?

在 LangChain 中,要让大模型按规矩办事,核心方法就是调用模型实例上的 with_structured_output(schema) 方法。你需要预先定义一个“结构说明书(Schema)”并传给它。

定义这份“说明书”,目前主流有以下 4 种方式:

  1. Pydantic:【绝对首选】支持各种强校验(如 min_lengthgt=0 等限制条件),生态集成最好,报错信息精准。
  2. TypedDict:Python 原生的类型字典。仅仅起代码提示作用,没有任何运行时强制校验能力。
  3. JSON Schema:跨语言的通用 JSON 描述规范。只在需要“动态从数据库读取结构”或者“跨语言服务间传递”的特殊极客场景下使用(极不推荐人工手写)。
  4. dataclass:Python 标准库的数据类。能用,但对复杂嵌套和精细约束校验的支持远不如 Pydantic 完善。

💡 实战忠告:除非有“严禁引入任何第三方包”的死规定,否则请无脑使用 Pydantic


3. Pydantic 结构化输出完全指南

在实际业务中,我们几乎总是将 with_structured_output 与 Pydantic 结合使用。下面按照从基础到进阶的顺序,演示所有的核心用法:

3.1 Pydantic 的具体使用

1. 基础使用:提取简单对象

这是最常见的场景:从一段自然语言文本中提取固定的几个字段。核心在于利用 Field(description="...") 给大模型下达明确的指令

from pydantic import BaseModel, Field

# 1. 定义数据结构
class Person(BaseModel):
    # Field(description="...") 是专门写给大模型看的“提示词”
    # 大模型会严格根据这里的描述,去自然语言中寻找并提取对应的答案
    name: str = Field(description="姓名")
    age: int = Field(description="年龄")
    occupation: str = Field(description="职业")

# 2. 绑定结构化输出
structured_model = model.with_structured_output(Person)

# 3. 调用模型
result = structured_model.invoke("张三是一名30岁的软件工程师")

# 4. 直接像使用普通 Python 对象一样调用属性
print(type(result)) # <class '__main__.Person'>
print(f"姓名:{result.name}, 职业:{result.occupation}") 

2. 进阶用法 1:处理缺失信息 (Optional)

如果一段文本中不包含某个字段的信息,直接提取可能导致大模型“瞎编乱造”。通过引入 Optional,可以明确告诉大模型:“这个字段是选填的,如果没有请填 None”。

from typing import Optional

class Person(BaseModel):
    name: str = Field(description="姓名")
    # 如果用户的文本里没提年龄,大模型会乖乖返回 None,而不会自己瞎猜
    age: Optional[int] = Field(description="年龄") 
3. 进阶用法 2:限定选择范围 (Enum / Literal)

在做文本分类任务时,我们希望大模型的输出结果被严格限定在几个特定的词以内。Pydantic 支持两种实现方式:轻量级的 Literal 和正规的 Enum 类。

方式一:使用 Literal (轻量级,最常用) 直接把可选项写在方括号里,代码极其简洁。

from typing import Literal

class CustomerFeedback(BaseModel):
    issue: str = Field(description="问题描述")
    # 强制大模型做单选题,只能从“低”、“中”、“高”这三个词里面选一个输出
    urgency: Literal["低", "中", "高"] = Field(description="紧急程度") 

方式二:使用 Enum 类 (企业级,更规范) 需要通过 Python 内置的 enum 模块单独定义一个枚举类。

from enum import Enum
from pydantic import BaseModel, Field

# 独立定义枚举类(注意:强烈建议继承 str,写成 str, Enum,这样才能与大模型生成的文本无缝对齐)
class UrgencyEnum(str, Enum):
    LOW = "低"
    MEDIUM = "中"
    HIGH = "高"

class CustomerFeedback(BaseModel):
    issue: str = Field(description="问题描述")
    # 将枚举类作为类型传入
    urgency: UrgencyEnum = Field(description="紧急程度")

💡 LiteralEnum 的区别与选择:

  • 底层效果:两者结合大模型时效果完全一样,都会被 Pydantic 转化为 JSON Schema 中的 Enum 格式发给大模型。
  • 选择 Literal:如果选项很少(比如只有“是/否”、“高/中/低”),且只在当前结构中使用一次。它的优势是代码最清爽,能用则首选
  • 选择 Enum:如果选项特别多(如几十个省份、复杂的业务状态码),或者这个枚举集需要在整个项目的多个文件中被重复导入使用。它的优势是维护性强,且 IDE(如 PyCharm/VSCode)的代码自动补全提示支持更好。
4. 进阶用法 3:列表提取与嵌套结构

当业务需求比较复杂,需要提取“多个同类事物(列表)”或“有层级包含关系”的数据时,可以直接嵌套不同的 Pydantic 类。

from typing import List

# 先定义子结构
class Actor(BaseModel):
    name: str = Field(description="演员姓名")
    role: str = Field(description="饰演的角色")

# 再定义主结构
class Movie(BaseModel):
    title: str = Field(description="电影标题")
    director: str = Field(description="导演")
    # 嵌套结构:提取多个演员作为一个列表
    cast: List[Actor] = Field(description="演员列表") 

# 模型执行后,result.cast 就会是一个标准的 Python List,里面全是 Actor 对象
5. 进阶用法 4:极严苛的参数校验 (Validation)

利用 Pydantic 强大的数值和字符串限制规则,可以防止大模型产生“幻觉”输出不合理的数据(比如负数的价格)。

class Product(BaseModel):
    # 限制最小长度为2
    name: str = Field(description="产品名称", min_length=2)
    # gt (greater than) 限制价格必须严格大于 0
    price: float = Field(description="价格", gt=0)
    # ge (greater than or equal) 限制库存必须大于等于 0
    stock: int = Field(description="库存", ge=0)

💡 防坑指南:当大模型给出不符合上述强规则的数据(如价格是 -100)时,structured_model.invoke() 走到最后解析那一步会直接抛出 ValidationError 异常并终止。这恰好证明了强校验拦截了脏数据。


4. TypedDict 的使用与避坑指南

在查阅许多旧教程或官方文档时,你可能会频繁看到 TypedDict。但强烈建议在实战中尽量避开它。在了解它为什么坑之前,我们先看看它的基本用法。

4.1 TypedDict 的基本用法

它的核心逻辑依然是定义数据格式,然后传给 with_structured_output。但由于它本身没法写描述,所以必须借助 Annotated 来补充提示词。

from typing_extensions import TypedDict, Annotated

# 1. 定义数据结构:使用 Annotated[类型, "描述"] 强行绑定提示词
class MovieTypedDict(TypedDict):
    title: Annotated[str, "电影的名称"]
    year: Annotated[int, "上映年份"]

# 2. 绑定模型并调用
structured_model = model.with_structured_output(MovieTypedDict)
result = structured_model.invoke("介绍一下星际穿越,是2014年上映的")

# 3. ⚠️ 注意返回值类型:它返回的不是对象,而是一个纯字典!
print(type(result))  # <class 'dict'>
print(result["title"]) # 只能用字典的 [] 语法取值,绝对不能用 result.title

4.2 令人困惑的“三大槽点”(避坑指南)

为什么说实战中要尽量避开它?因为它充满了误导性,且缺乏运行时的安全保障机制。

槽点 1:披着类外衣的“假对象”

虽然语法上你写了 class MovieTypedDict(TypedDict):,但这完全是 Python 强加类型提示时的历史包袱。在代码运行阶段,实例化出来的东西根本不是类的对象,依然是一个最普通、最光秃秃的 Python 字典 (dict)。这导致你没法像 Pydantic 那样优雅地使用 movie.title 取值,严重割裂了代码体验。

槽点 2:被迫打补丁的 Annotated

TypedDict 天生只能写类型,没有地方写给大模型看的提示词。为了解决这个残疾问题,被迫引入了 Annotated 这个补丁。 Annotated(读音:/ˈænəteɪtɪd/,意为“带有注解的”)的固定格式必须是:Annotated[真正的类型, "给大模型看的描述"]。这种写法极其啰嗦,完全不如 Pydantic 的 Field(description="...") 来得优雅直观。

槽点 3:缺乏回程校验与 ... 的表现差异

TypedDict 在收到 JSON 数据后没有任何强制校验能力。如果你在定义时使用了 ...(省略号,代表该字段是必填项,没有默认值):

rating: Annotated[float, ..., "电影评分"]

当遇到原文中找不到该信息时,不同大模型的反应会完全不受控:

  • 某些模型(如 CloseAI):会直接在字典中丢掉这个 Key。如果下游代码去取值,会直接报 KeyError 崩溃。
  • 某些模型(如 OpenRouter):为了完成必填任务,会**凭空捏造(幻觉)**一个毫无根据的默认值(比如 0)塞进去。

终极结论TypedDict 语法啰嗦、概念误导、缺乏安全拦截,甚至会逼迫大模型产生幻觉。请将它封印在理论知识库中,日常工程实战一律无脑拥抱 Pydantic


5. 使用 JSON Schema (极客场景)

除了使用 Python 代码(Pydantic、TypedDict)来间接定义格式外,LangChain 也允许你直接手写原生的 JSON Schema 字典来规范大模型的输出。

正如前面多次提到的,大模型的 API 实际上只认识 JSON Schema,这是底层唯一的通信协议。无论是 Pydantic 还是 TypedDict,在交给 LangChain 底层时,最终都会被翻译成这种格式。

5.1 基本用法与嵌套结构

你需要手写一个完全符合 JSON Schema 规范的 Python 字典,并在绑定模型时显式指定 method="json_schema"

# 1. 纯手写一个 JSON Schema 字典(包含极其啰嗦的嵌套结构)
project_schema = {
    "title": "MovieInfo",
    "description": "包含电影标题、导演和演员列表",
    "type": "object",
    "properties": {
        "title": {"type": "string", "description": "电影标题"},
        "director": {"type": "string", "description": "导演"},
        "cast": {  # 嵌套结构:定义一个演员数组
            "type": "array",
            "description": "演员列表",
            "items": {
                "type": "object",
                "properties": {
                    "name": {"type": "string", "description": "演员姓名"},
                    "role": {"type": "string", "description": "演员角色"}
                },
                "required": ["name", "role"]
            }
        }
    },
    "required": ["title", "director", "cast"] # 指定最外层的必填项
}

# 2. 绑定到模型,注意必须显式传入 method="json_schema"
structured_model = model.with_structured_output(project_schema, method="json_schema")

# 3. 调用模型
response = structured_model.invoke("介绍一下星际穿越,包含导演和主要演员")

# 4. ⚠️ 返回值类型:与 TypedDict 一样,返回的是纯字典,依然缺乏运行时对象的强校验
print(type(response)) # <class 'dict'>
print(response["title"]) 

5.2 为什么极不推荐人工手写?

  1. 极其啰嗦和反人类:如上面的代码所示,哪怕只是定义一个非常简单的“电影-演员”嵌套结构,JSON Schema 的嵌套层级和代码行数也会迅速膨胀。写起来毫无代码美感,且极易漏写括号或拼错单词(如错把 properties 拼成 property)。
  2. 完全没有代码提示与强校验:IDE 无法给你任何编写时的类型提示,大模型返回的也是纯粹的字典,完全无法享受 Pydantic 那种强大的“回程强拦截”和面向对象的优雅调用。

💡 实战忠告与唯一适用场景: 在日常写死代码的开发中,绝对不要人工手写 JSON Schema

它唯一的生存空间是:“动态配置平台”。比如,你需要做一个让非程序员配置表单的低代码平台,系统需要根据用户在网页上点选的字段,在后台动态拼装出一套提取规则发给大模型。在这种极端场景下,拼接纯 JSON 字典远比在代码里动态生成 Pydantic 类要容易实现得多。


6. 使用 @dataclass

虽然 LangChain 的 with_structured_output 也支持 Python 标准库的 @dataclass,但它在实战中的表现非常“鸡肋”。我们可以通过一个简单的例子来看清它的真面目:

6.1 示例代码与三大痛点

from dataclasses import dataclass
from pydantic import Field # 痛点1:依然要白嫖 Pydantic

@dataclass
class Movie():
    # 虽然是 dataclass,但为了写提示词,还得用 Pydantic 的 Field
    title: str = Field(description="电影标题")
    year: int = Field(description="电影上映年份")

structured_model = model.with_structured_output(Movie)
response = structured_model.invoke("给出盗梦空间的信息")

# 痛点2与3:返回的并非 Movie 对象,而是字典,且缺乏强校验
print(type(response)) # 输出:<class 'dict'>
print(response["title"])

从上面的代码中,我们可以总结出 @dataclass 在这里的三个致命问题:

  1. 借鸡生蛋(代码缝合怪):原生 @dataclass 没法写给大模型看的描述词,必须去借用 Pydantic 的 Field 来打补丁。既然语法写出来已经和 Pydantic 一模一样了,为什么不直接用真正的 BaseModel 呢?
  2. 缺乏强校验(形同虚设):哪怕大模型胡说八道,传回了错误的数据类型,@dataclass 也不会像真正的 Pydantic 那样立刻报错拦截。
  3. 打回原形(返回纯字典):辛辛苦苦写了个类,最后大模型返回的依然是个纯字典 (dict),你还是得老老实实拿 ["title"] 去取值,享受不到面向对象编程的便利。

终极结论:在这个场景下,@dataclass 完全是一个**“阉割版的 Pydantic”。由于它形同虚设的校验和缝合怪般的语法,实战中请直接无视它,所有场景一律无脑使用 Pydantic** 即可。


7. 进阶技巧:获取原始响应与 Token 消耗 (include_raw=True)

在绝大多数情况下,with_structured_output 默认只会返回最终解析好的对象(比如一个 PersonMovie 实例)。

但是,如果在开发调试阶段,你不仅想拿到结构化数据,还想偷窥大模型的原始返回文本,或者想查看本次调用的 Token 消耗花销,只需要在绑定时传入 include_raw=True 参数即可。

7.1 示例代码

from pydantic import BaseModel, Field
from rich import print as rprint

class Movie(BaseModel):
    """电影信息"""
    title: str = Field(description="电影标题")
    year: int = Field(description="上映年份")
    director: str = Field(description="导演")
    rating: float = Field(description="评分(10分制)")

# ⚠️ 关键点:传入 include_raw=True 参数
structured_model = model.with_structured_output(Movie, include_raw=True)
response = structured_model.invoke("帮我介绍一下星际穿越这个电影")

print(type(response)) # 此时返回值不再是 Movie 对象,而是 <class 'dict'>
rprint(response)      # 打印这个包含了大量细节的“超级大礼包字典”

7.2 打印结果解析

当你加了 include_raw=True 后,返回值会变成一个包含三大核心字段的字典:

{
    # 1. raw: 大模型最原始的返回对象 (AIMessage),里面包含了原始字符串、完整的 Token 消耗统计等元数据
    'raw': AIMessage(
        content='{"title":"星际穿越","year":2014,"director":"克里斯托弗·诺兰","rating":8.6}',
        additional_kwargs={'refusal': None, ...},
        response_metadata={
            'token_usage': {
                'completion_tokens': 37, 
                'prompt_tokens': 170, 
                'total_tokens': 207  # 👈 在这里可以清楚地看到 Token 花销
            },
            'model_name': 'gpt-5.4-mini-2026-03-17',
            # ... 其他底层通信元数据 ...
        },
        id='lc_run--019e8795-992d-7920-a06c-ff6738ca1ff0-0',
        usage_metadata={'input_tokens': 170, 'output_tokens': 37, 'total_tokens': 207}
    ),
    
    # 2. parsed: 成功解析出来的 Pydantic 对象,业务代码中实际需要使用的数据
    'parsed': Movie(title='星际穿越', year=2014, director='克里斯托弗·诺兰', rating=8.6),
    
    # 3. parsing_error: 如果解析过程中大模型返回了无法被 Pydantic 识别的错误格式,报错信息会放在这里
    'parsing_error': None
}

💡 总结include_raw=True 是开发调试时的利器。它让你既能安全地拿到强校验后的结构化数据(response["parsed"]),又能随时查阅底层的通信细节和账单消耗(response["raw"])。