‹ 博客列表

LangChain 1.2 入门(二十):结构化输出——让模型返回程序能吃的对象

自然语言回答没法直接给代码用:传统提示词加手工解析的四步赌局、四种 Schema 的两个共性,以及 with_structured_output、include_raw 与输出解析器的取舍。

LangChain 1.2 入门(二十):结构化输出——让模型返回程序能吃的对象

工具篇解决的是「模型能对外做点什么」,这一篇换个方向:模型返回的东西,怎么让程序稳定地接住。自然语言回答给人读很舒服,给代码读就不行——一句话换个说法、多一个「约」字,你的解析就崩了。结构化输出(Structured Output)就是把这个不确定性压下去。

20.1 结构化输出要的不是文本,是数据对象

LangChain 里的结构化输出,指要求模型最终返回一个符合预定义结构的数据:固定字段的 JSON、Pydantic 模型或 TypedDict,而不是无格式的自然语言。核心目标一句话:把「自然语言回答」变成「程序可以稳定消费的数据」。

比如问一部电影的信息,模型平时会这样回:

盗梦空间在 2010 年上映,导演是克里斯托弗·诺兰,评分 9.3。

结构化输出要的是这个:

{
    "title": "盗梦空间",
    "year": 2010,
    "director": "克里斯托弗·诺兰",
    "rating": 9.3
}

差别看着只是格式,实际是三种收益:代码可以直接读字段,不用再写正则从自然语言里抠;结果更稳定,不会因为模型换个说法就解析失败;更适合工程化,表单抽取、分类、路由、工具参数生成、工作流状态传递,全都依赖固定结构。

20.2 不用结构化输出会怎样

老办法是这样的:提示词里求模型返回 JSON,然后自己解析、自己校验。

# 1. 提示词要求 JSON
prompt = "以 JSON 格式返回:{name, age, occupation}"
response = model.invoke(prompt)

# 2. 手动解析
import json
data = json.loads(response.content)

# 3. 手动验证类型
if not isinstance(data["age"], int):
    raise ValueError("age must be int")

# 4. 手动创建对象
person = Person(**data)

这段代码能跑,但每一步都在赌:赌模型不加「好的,以下是 JSON:」,赌字段名没写错,赌类型是对的。换了模型、升级了版本,任何一环变动都要重新调提示词。

换成 with_structured_output(),四步并成一步:

# 一步到位
structured_llm = model.with_structured_output(Person)
person = structured_llm.invoke("张三是一名 30 岁的软件工程师")
# 自动解析、验证、创建对象

为什么这套机制受欢迎,原因有三条:提示词变干净,字段的 description 直接充当提示词的一部分,不用再苦口婆心地说「请只返回 JSON」;类型安全,编辑器能补全,运行前就能查错;稳定性来自模型底层,依托厂商的 JSON 模式或函数调用,输出错误率极低。

两者的实测差别可以看这张对照:

传统提示词加手工解析与结构化输出的四维对照:怎么拿数据、谁保证格式、拿到什么、出错时

20.3 四种 Schema,两个共性

LangChain 1.x 支持四种 Schema:

Schema 特点 返回值
Pydantic 字段校验、描述、嵌套结构,功能最丰富,生产首选 Schema 类实例
TypedDict 轻量类型约束,写起来接近普通 dict 字典
JSON Schema 与前后端、跨语言接口最通用,但要手写 JSON 字典
@dataclass 标准库写法,最省依赖 字典

记住两个共性,后面会反复用到:

  • 只有 Pydantic 返回 Schema 类实例,另外三种都返回 dict;
  • 只有 Pydantic 会在类型不匹配时抛异常,其余三种拿到什么就是什么。

还有一个现实问题:所有模型都支持吗?大多数现代模型支持(OpenAI 的 gpt-4 系列、Anthropic 的 claude-3、Groq 上的 llama-3),因为它们底层走的是函数调用;少数旧模型不支持,这时 LangChain 会自动回退到「提示词 + JSON 解析」,能用但不保证稳。

20.4 取结果的两种方式

20.4.1 with_structured_output:推荐

先定义结构,再绑到模型上,调用方式和普通 invoke 一样:

from langchain.chat_models import init_chat_model
from pydantic import BaseModel, Field
import os
from dotenv import load_dotenv

load_dotenv(override=True)
model = init_chat_model(
    model="gpt-5.4-mini",
    model_provider="openai",
    api_key=os.getenv("CLOSEAI_API_KEY"),
    base_url=os.getenv("CLOSEAI_BASE_URL"),
)

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

model_with_structure = model.with_structured_output(Movie)
result = model_with_structure.invoke("给我介绍下电影《星际穿越》")

print(type(result))          # <class '__main__.Movie'>
print(result.title)          # 星际穿越
print(result.rating)         # 9.3

返回的是 Movie 实例,属性直接点。这一点决定了它和另外三种模式的用法差异:不用 result["title"],而是 result.title。

20.4.2 include_raw=True:还要原始响应

有时候你需要 token 用量、finish_reason 这些元数据,或者想自己处理解析失败。给 with_structured_output 加上 include_raw=True,返回的就不再是对象,而是三件套:

model_with_structure = model.with_structured_output(Movie, include_raw=True)
resp = model_with_structure.invoke("给我介绍下电影《星际穿越》")

print(type(resp))     # <class 'dict'>
print(resp.keys())    # dict_keys(['raw', 'parsed', 'parsing_error'])
{
    'raw': AIMessage(content='', ... response_metadata={'token_usage': {
        'completion_tokens': 93, 'prompt_tokens': 373, 'total_tokens': 466, ...},
        'finish_reason': 'tool_calls', ...},
        tool_calls=[{'name': 'Movie', 'args': {'title': '星际穿越', 'year': 2014,
                     'director': '克里斯托弗·诺兰', 'rating': 9.3}, ...}]),
    'parsed': Movie(title='星际穿越', year=2014, director='克里斯托弗·诺兰', rating=9.3),
    'parsing_error': None
}

三个字段各管一段:raw 是原始 AIMessage(想看用量、想知道模型走了 tool_calls 还是 JSON 模式,就看这里);parsed 是解析后的对象;parsing_error 是解析错误——注意它只对 Pydantic 有意义,另外三种 schema 不符合时压根不会报错,这个字段永远是 None。

20.4.3 输出解析器:老办法,不推荐

在 with_structured_output 之前,标准做法是「提示词要求格式 → 模型生成文本 → 解析器转换」,用 JsonOutputParser 串一条链:

from langchain_core.output_parsers import JsonOutputParser
from langchain_core.prompts import ChatPromptTemplate

prompt_template = ChatPromptTemplate.from_messages([
    ("system", "回答用户问题,必须始终输出一个包含 title(电影标题)和 year(上映年份)的 JSON 对象"),
    ("human", "问题:{question}"),
])

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

parser = JsonOutputParser(pydantic_object=Movie)
chain = prompt_template | model | parser

response = chain.invoke({"question": "介绍电影《盗梦空间》"})
print(response)   # {'title': '盗梦空间', 'year': 2010}

它有三个明显短板:靠提示词约束格式,模型加一句解释就解析失败;解析器只做 JSON 解析,字段缺失、类型错误要自己补;多一层链,调试时更难定位问题在哪。新项目没有理由再用它,唯一例外是老代码里已经这么写了。

20.5 小结

这一篇建立概念:结构化输出是把自然语言回答转成程序能消费的数据;四种 Schema 里只有 Pydantic 返回实例并做校验;取结果首选 with_structured_output(),需要原始响应就加 include_raw=True;输出解析器属于历史方案。

下一篇把 Pydantic 这一种讲透:description 为什么等于提示词,可选字段、默认值、枚举、列表、嵌套各自怎么写,以及 with_structured_output 在底层到底做了哪四步。

上一篇:工具实战——多工具循环、tool_choice 与踩坑清单;系列第一篇:为什么需要 LangChain。

本文根据公开课程课件整理,并结合 LangChain 官方文档核对与补充。

本文来自 https://aimock.cn/blog,站内重新编排。
内容仅供学习交流,转载请注明出处。