‹ 博客列表

LangChain 1.2 入门(二十二):TypedDict、JSON Schema 与 @dataclass——轻量结构的代价

三种轻量写法都不做校验:TypedDict 的 Annotated 与 … 语义、JSON Schema 的 method 供应商限制、@dataclass 返回未校验字典,用 fake server 跑一遍校验对比。

LangChain 1.2 入门(二十二):TypedDict、JSON Schema 与 @dataclass——轻量结构的代价

Pydantic 功能最全,但不是所有场景都值得上。定义个临时字典结构、跟别的语言对接、或者只想少装一个依赖时,TypedDict、JSON Schema、@dataclass 更省事——代价是它们都不做校验。这一篇逐个说清写法与边界。

22.1 TypedDict:会写类型声明的字典

普通 dict 什么都装,你无从知道里面该有什么:

movie = {"title": "盗梦空间", "year": 2010}

TypedDict(Python 3.8+)在此之上补了类型信息:这个字典有哪些字段、每个字段是什么类型。适合需要快速定义结构、又不需要 Pydantic 那套重量级功能的场景。

from typing_extensions import TypedDict

class MovieDict(TypedDict):
    title: str
    year: int
    director: str
    rating: float

movie: MovieDict = {
    "title1": "盗梦空间",     # 字段名写错
    "year": 2010,
    "director": "克里斯托弗·诺兰",
    "rating": 8.8,
}
print(movie)

注意 title1 这个错误:运行不会报错,只有 IDE 的静态类型检查会标出来。TypedDict 只是类型声明,不是运行时校验器——这是它和 Pydantic 最本质的区别。

22.2 Annotated:给字段补一句说明

TypedDict 里的字段没有 Field() 可用,想给模型写描述要借助 Annotated:在类型之外附加元数据,形式和语义都类似 Pydantic 的 Field。

from typing_extensions import TypedDict, Annotated

class MovieTypedDict(TypedDict):
    """电影的详细信息"""
    title: Annotated[str, "电影的正式名称,例如《盗梦空间》"]
    year: Annotated[int, "电影的公映年份,使用四位数字表示"]
    director: Annotated[str, "电影导演的全名"]
    rating: Annotated[float, "电影在 10 分制下的评分,可包含一位小数"]

structured_llm = model.with_structured_output(MovieTypedDict)
response = structured_llm.invoke("给我介绍下电影《星际穿越》")

print(type(response))   # <class 'dict'>
print(response)
# {'title': '星际穿越', 'year': 2014, 'director': '克里斯托弗·诺兰', 'rating': 8.6}

返回值是普通字典,取值用 response['title']。嵌套结构同样支持,List[Actor] 照写:

from typing import TypedDict, List, Annotated

class Actor(TypedDict):
    """演员情况"""
    name: Annotated[str, "演员姓名"]
    role: Annotated[str, "饰演的角色"]

class Movie(TypedDict):
    """电影情况"""
    title: Annotated[str, "电影标题"]
    year: Annotated[int, "上映年份"]
    director: Annotated[str, "导演"]
    cast: Annotated[List[Actor], "演员列表"]
    rating: Annotated[float, "评分"]

structured_llm = model.with_structured_output(Movie)
resp = structured_llm.invoke("给我介绍下电影《盗梦空间》")
print(resp["cast"][0])   # {'name': '莱昂纳多·迪卡普里奥', 'role': '多姆·柯布'}

Annotated 还能表达「必填」:中间的 ... 是 Python 字面量,等价于 Ellipsis,下游框架可以据此定制行为——在 LangChain 里,Annotated[str, ..., "导演"] 表示该字段必须存在,模型不能省略。

class MovieDict(TypedDict):
    """电影的详细信息"""
    title: Annotated[str, ..., "电影标题"]
    year: Annotated[int, ..., "电影上映年份"]
    director: Annotated[str, Ellipsis, "导演"]        # 必填
    rating: Annotated[float, "电影评分,满分十分"]     # 没写 ...,可省略

对比实验很能说明问题:让模型抽取「盗梦空间上映于 2010 年,我们并不知道它的导演是谁」。

# director 标了 ...(必填),rating 没标
{'title': '盗梦空间', 'year': 2010, 'director': ''}

# 两个都没标 ...
{'title': '盗梦空间', 'year': 2010, 'director': '克里斯托弗·诺兰', 'rating': 0}

必填但信息缺失时,字段仍然出现,值为空字符串;非必填时字段直接被省略。这个行为不同模型提供商也有差异,不能当严格契约用。

22.3 JSON Schema:跨语言的那一套

前后端、跨语言接口之间传结构,最通用的描述方式就是 JSON Schema。在 LangChain 里可以直接把字典传给 with_structured_output:

json_schema = {
    "title": "Movie",
    "description": "A movie with details",
    "type": "object",
    "properties": {
        "title": {"type": "string", "description": "The title of the movie"},
        "year": {"type": "integer", "description": "The year the movie was released"},
        "director": {"type": "string", "description": "The director of the movie"},
        "rating": {"type": "number", "description": "The movie's rating out of 10"},
    },
    "required": ["title", "year", "director", "rating"],
}

structured_model = model.with_structured_output(json_schema, method="json_schema")
response = structured_model.invoke("给出盗梦空间的信息")
print(type(response))   # <class 'dict'>

几个关键字是固定写法,含义如下:

关键字 作用
title 给整个 Schema 或某个属性一个可读标题,不能是中文,只为人看
description 更详细的文字说明,给模型看
type 数据类型:string、number、integer、boolean、object、array、null
properties 对象里有哪些属性,各自的类型与说明
required type 为 object 时使用,列出必须存在的属性名

method="json_schema" 表示使用模型供应商提供的专用结构化输出能力。这个参数取决于供应商和 LangChain 适配器的实现——比如 DeepSeek 的服务就不支持 json_schema 模式,写了会直接失败。换模型前先确认这一点,比调提示词更重要。

嵌套结构要手写 array 和 items,比 Pydantic 啰嗦得多:

project_schema = {
    "title": "MovieInfo",
    "description": "包含电影标题、上映年份、导演、演员和评分的电影对象",
    "type": "object",
    "properties": {
        "title": {"type": "string", "description": "电影标题"},
        "year": {"type": "integer", "description": "上映年份"},
        "director": {"type": "string", "description": "导演"},
        "cast": {
            "type": "array",
            "description": "演员列表",
            "items": {
                "type": "object",
                "properties": {
                    "name": {"type": "string", "description": "演员姓名"},
                    "role": {"type": "string", "description": "演员角色"},
                },
                "required": ["name", "role"],
            },
        },
        "rating": {"type": "number", "description": "评分(10 分制)"},
    },
    "required": ["title", "year", "director", "cast", "rating"],
}
structured_model = model.with_structured_output(project_schema)

结论:除非结构来自外部系统(前端表单配置、跨语言服务),否则不建议手写 JSON Schema——没有校验、没有类型补全,字段名写错只有运行时才知道。

22.4 @dataclass:标准库的轻量写法

@dataclass 是标准库 dataclasses 提供的类装饰器,加上它之后,Python 会根据字段自动生成 __init__、__repr__、__eq__,写法接近手写这些方法的普通类。

from dataclasses import dataclass
from pydantic import Field

@dataclass
class Movie():
    """电影的详细信息"""
    title: str = Field(description="电影标题")
    year: int = Field(description="电影上映年份")
    director: str = Field(description="导演")
    rating: float = Field(description="电影评分,满分十分")

structured_model = model.with_structured_output(Movie)
response = structured_model.invoke("给出盗梦空间的信息")
print(response)
print(type(response))
{'title': '盗梦空间', 'year': 2010, 'director': '克里斯托弗·诺兰', 'rating': 8.8}
<class 'dict'>

返回的是未经校验的字典,不是 Movie 实例——这点和 Pydantic 完全相反。

还有个容易误解的细节:@dataclass 修饰后的类虽然行为上接近手写 __init__ 的普通类,但两者并不等价。前者会被标准库标记为数据类并携带字段元信息,可以作为 LangChain 的 Schema;手写 __init__ 的普通类不行。

22.5 校验差异:用 fake server 试一次

光看文档说「只有 Pydantic 校验」不够直观,课件用了一个更狠的办法:起一个假的 DeepSeek 服务端,让它固定返回字段名错误的 JSON。

import json, time
from http.server import BaseHTTPRequestHandler, HTTPServer

class FakeDeepSeekHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers.get("Content-Length", 0))
        json_body = json.loads(self.rfile.read(length).decode("utf-8"))
        response = {
            "id": "chatcmpl-test", "object": "chat.completion",
            "created": int(time.time()), "model": "any",
            "choices": [{
                "index": 0,
                "message": {
                    "role": "assistant", "content": "",
                    "tool_calls": [{
                        "id": "call_1", "type": "function",
                        "function": {
                            "name": json_body["tools"][0]["function"]["name"],
                            "arguments": json.dumps(
                                {'title1': '盗梦空间', 'year2': 2010,
                                 'director': '克里斯托弗·诺兰', 'rating': 9.3},
                                ensure_ascii=False),
                        },
                    }],
                },
                "finish_reason": "stop",
            }],
            "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
        }
        body = json.dumps(response, ensure_ascii=False).encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

HTTPServer(("127.0.0.1", 8889), FakeDeepSeekHandler).serve_forever()

关键是那两个字段名:服务端返回 title1、year2,而客户端要的是 title、year。前两个字段对不上,四种 Schema 的表现立刻分叉:

# Pydantic
Traceback...
ValidationError: 2 validation errors for MovieModel
title
  Field required [type=missing, input_value={'title1': '盗梦空间' ...}, input_type=dict]
year
  Field required [type=missing, input_value={'title1': '盗梦空间' ...}, input_type=dict]

# TypedDict / JSON Schema / @dataclass
{'title1': '盗梦空间', 'year2': 2010, 'director': '克里斯托弗·诺兰', 'rating': 9.3}
<class 'dict'>

结果一目了然:

四种 Schema 的校验差异对照:Pydantic 返回 Schema 实例并在字段不匹配时抛 ValidationError,TypedDict、JSON Schema、@dataclass 返回字典且原样透传

这个实验值得自己复现一次:它把「校验」这件抽象的事变成了可见的差异。类型不安全的数据比没有数据更危险——拿不到值时程序会报错,拿到错值时程序会悄悄算错。

22.6 四种模式怎么选

场景 选择 理由
生产环境、要校验、要类型补全 Pydantic 唯一返回实例并校验,出错位置明确
临时脚本、只要个 dict TypedDict 写法轻,Annotated 还能写描述
结构来自外部系统、跨语言 JSON Schema 通用格式,但注意供应商是否支持 json_schema 模式
不想引入 Pydantic 依赖 @dataclass 标准库,写法最简,但不校验

另外两条经验:先用 with_structured_output 跑通,再考虑 include_raw=True 拿原始响应排查问题;结构一旦稳定就固定下来,别在运行期动态拼 Schema——动态结构最难调试。

22.7 小结

三种轻量模式都能拿到结构化的字典,但都不做校验,出问题时错误会推迟到下游才暴露。选择逻辑很简单:要求正确性就上 Pydantic,只要求形状就用 TypedDict 或 @dataclass,需要跨语言才用 JSON Schema。

结构化输出到这里告一段落。下一章进入智能体:前面 Tools 有了手脚、结构化输出有了可靠的接口,接下来看谁来调度它们——LangChain 1.2 的 Agent。

上一篇:Pydantic 模式——从字段描述到可选值、枚举与嵌套;系列第一篇:为什么需要 LangChain。

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

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