本文是《Python 在 AI-Infra:从语言机制到生产交付》系列的第 5 篇(共十一篇)。上一篇:类型系统——类型信息的分发与消费;下一篇:并发、异步与任务协作。
前两篇沿着”类型信息如何流动”展开:上篇讲怎么写(typing 工具箱),中篇讲怎么分发、谁消费(存根、mypy、运行时读注解)。本篇的组织轴切换——不再讨论类型信息本身,而是讨论用这些能力去构建什么:数据契约。
所谓数据契约,就是对”一组数据长什么样”的正式约定。在 AI Infra 里它无处不在:推理服务的请求体与响应体、训练任务的配置文件、模型仓库里的元数据、Worker 之间传递的消息。Java 工程师熟悉的 Bean Validation、Jackson、@ConfigurationProperties 在这里对应的是 @dataclass、Pydantic BaseModel、BaseSettings——但它们不是一一对应的翻译,而是同一份类型注解的三种消费方式,各有适用位置。
本篇围绕的核心问题是:
什么时候用
@dataclass、什么时候上 Pydantic、什么时候TypedDict就够?校验该发生在哪里,序列化与配置怎么跟着契约走?
答案的骨架是中篇末尾那句话——边界要校验,内部要干净:外部数据进门时用 Pydantic 付一次校验成本,进门之后用零开销的 @dataclass 传递,靠 mypy 做静态保证。
一、总览
1. 在三篇地图上的位置
类型信息提供层
┌──────────────────────────────────────────────┐
│ 类型表达 │
│ ├── Python 内建注解语法 │
│ ├── 内建泛型:list[str]、dict[str, int] │
│ ├── typing │
│ └── typing_extensions │
│ │
│ 类型载体与分发 │
│ ├── inline types:.py │
│ ├── stub:.pyi │
│ ├── typeshed │
│ ├── types-* │
│ └── py.typed / PEP 561 │
└──────────────────────────────────────────────┘
│
│ 读取、解释、推理、执行
▼
类型信息消费层
┌──────────────────────────────────────────────┐
│ 静态消费 │
│ ├── mypy │
│ ├── Pyright / Pylance │
│ ├── IDE、CI │
│ └── 类型推断、收窄、兼容性检查 │
│ │
│ 动态消费 │
│ ├── isinstance / issubclass │
│ ├── get_type_hints / __annotations__ │
│ ├── @dataclass(读注解生成代码) │
│ ├── Pydantic(元类 + pydantic-core) │
│ ├── beartype │
│ └── Annotated 元数据解析 │
└──────────────────────────────────────────────┘
│
│ 用消费层的能力构建
▼
工程落地:数据契约
┌──────────────────────────────────────────────┐
│ 数据建模 │
│ ├── @dataclass:内部数据传递 │
│ ├── Pydantic BaseModel:边界校验 │
│ └── TypedDict:字典形状约束 │
│ │
│ 契约的输入输出 │
│ ├── 序列化 / 反序列化 │
│ ├── JSON Schema / OpenAPI 生成 │
│ └── BaseSettings:配置即契约 │
└──────────────────────────────────────────────┘
本篇讲图里最下面那格。它使用前两格的能力,但组织轴不同:前两格沿着”类型信息如何流动”,这一格沿着”程序的数据结构如何设计”。
2. 本文的章节安排
| 章 | 主题 | 内容 |
|---|---|---|
| 二 | 工程落地:数据契约设计 | dataclass、Pydantic、序列化与 Schema、BaseSettings、选型指南 |
| 三 | 附录 | 类型工具选择决策树;Java 与 Python 数据契约对照 |
| 四 | 本文小结 | |
| 五 | 自测 | 2 道题 |
二、工程落地:数据契约设计
前两篇沿着”类型信息如何流动”展开:怎么表达(上篇)、怎么分发、谁来消费(中篇)。这一篇组织轴切换——不再讨论类型信息本身,而是讨论用这些能力去构建什么:数据契约。
所谓数据契约,就是对”一组数据长什么样”的正式约定。在 AI-Infra 系统里,它无处不在:
- 请求体:
/v1/chat/completions收到的 JSON 应该有哪些字段、什么类型、什么取值范围; - 配置项:从
.env、YAML 读进来的一堆字符串,怎么变成强类型的配置对象; - 模型元数据:模型名、量化方式、并行度、KV cache 配置在进程间传递时的形状;
- 内部数据结构:一次推理请求在引擎内部流转时携带的上下文。
在 Java 里这件事由多个技术栈拼起来:Lombok / record 消除模板代码,Bean Validation 做校验,Jackson 做序列化,@ConfigurationProperties 做配置绑定。Python 则高度收敛——@dataclass 和 Pydantic 两个工具覆盖了绝大部分场景。看一眼 Pydantic 模型的定义:
from pydantic import BaseModel, Field
from typing import Annotated, Literal
class InferenceConfig(BaseModel):
model_name: str
backend: Literal["cuda", "rocm", "cpu"]
max_tokens: Annotated[int, Field(ge=1, le=32768)] = 2048
temperature: Annotated[float, Field(ge=0.0, le=2.0)] = 1.0
top_p: float = 1.0
这一个类定义同时做了三件事:
- 静态类型检查:mypy/pyright 能检查代码中对
InferenceConfig字段的使用; - 运行时校验:Pydantic 确保传入的数据满足约束(
ge=1、le=32768); - 文档 / Schema 生成:FastAPI 自动从这个模型生成 OpenAPI 文档。
这就是”数据契约”的价值——一处声明,三处受益。而它能成立,靠的正是中篇第四章讲的机制。
前置阅读:如果你想先搞清楚
@dataclass和 Pydantic 怎么读到类型注解、exec代码生成和元类分别在什么时机介入,见中篇第四章「类型信息消费层(下):动态消费」的”框架如何消费注解”一节。本章只讲用法与取舍。
1. dataclass:标准库的数据类
从原生 __init__ 到 @dataclass
最传统的写法是显式定义构造函数:
class User:
def __init__(self, id: int, name: str, email: str):
self.id = id
self.name = name
self.email = email
问题很明显:大量 self.x = x 的模板代码。字段一多就难以维护,而且还要手写 __repr__、__eq__ 才能方便调试和比较。
Python 3.7 引入的数据类消除了这些样板。它利用类变量类型标注(PEP 526)语法:
from dataclasses import dataclass
@dataclass
class User:
id: int
name: str
email: str
实例化方式不变,但 __init__、__repr__、__eq__ 都自动有了:
user = User(id=1, name="Alice", email="alice@example.com")
print(user) # User(id=1, name='Alice', email='alice@example.com')
print(user == User(1, "Alice", "alice@example.com")) # True
对应 Java:
// Java 14 之前:Lombok
import lombok.Data;
@Data
public class User {
private Long id;
private String name;
private String email;
}
// Java 14+:官方 record,定位与 @dataclass 极其相似
public record User(Long id, String name, String email) {}
__post_init__ 手工校验及其局限
@dataclass 不做运行时校验——这一点在中篇第四章已经说明原因:它只把注解当字段清单,不理解注解的语义。所以下面这行不会报错:
user = User(id="abc", name=123, email=None) # 静默通过
如果需要校验,得借助 __post_init__ 钩子(实例化后自动触发):
from dataclasses import dataclass
import re
@dataclass
class User:
id: int
name: str
email: str
def __post_init__(self):
# 1. 手动校验类型
if not isinstance(self.id, int):
raise TypeError("id 必须是 int 类型")
# 2. 手动用正则校验邮箱
if not re.match(r"^[\w\.-]+@[\w\.-]+\.\w+$", self.email):
raise ValueError("邮箱格式不正确")
这条路能走通,但代价很快显现:
- 逐字段手写:每个字段的类型、范围、格式都要自己
if; - 不做类型转换:外部传进来的
"123"不会变成123,只能自己转; - 错误不聚合:第一个
raise就中断了,用户看不到全部问题; - 与注解脱节:注解写
int,校验逻辑另写一遍,两边可能不一致。
Java 生态在这一点上遇到了完全相同的问题——record 和 Lombok 都只解决”数据容器”,不具备校验能力,所以才需要引入 Hibernate Validator。Python 的答案则是 Pydantic(下一节)。
frozen 与 slots
两个常用参数:
from dataclasses import dataclass
# frozen=True:不可变,实例创建后不能改字段,且自动获得 __hash__
@dataclass(frozen=True)
class ModelKey:
model_name: str
revision: str
key = ModelKey("llama-3", "main")
# key.revision = "dev" # 报错:FrozenInstanceError
cache: dict[ModelKey, object] = {key: ...} # 可以做 dict 的 key
# slots=True(3.10+):用槽位存储属性,省去 __dict__
@dataclass(slots=True)
class RequestContext:
request_id: str
model_name: str
deadline: float
frozen=True 对应 Java 的 record(天然不可变);slots=True 没有 Java 对应物,它解决的是 Python 特有的每实例 __dict__ 开销问题。
__slots__的内存收益取决于对象数量和字段类型,详见《Python 内存管理与优化》。
2. Pydantic:带校验的数据模型
Pydantic 是 Python 生态中最流行的运行时数据校验框架,也是 FastAPI 的核心基石。它把数据建模、类型转换和深度校验融合在一起——相当于 Java 的 record + Bean Validation + Jackson 三者合一。
BaseModel 基础与类型强制转换
from pydantic import BaseModel, EmailStr
class User(BaseModel):
id: int
name: str
email: EmailStr
与 @dataclass 最大的差别是它真的会校验,并且会转换:
# 自动校验 + 类型转换(coercion)
user = User(id="1", name="Alice", email="alice@example.com")
print(user.id, type(user.id)) # 1 <class 'int'> ← 字符串 "1" 被转成了 int
# 校验失败时抛出详细错误
User(id="abc", name="Bob", email="not-an-email")
# pydantic_core.ValidationError: 2 validation errors for User
# id
# Input should be a valid integer, unable to parse string as an integer
# email
# value is not a valid email address
注意两点:一是 EmailStr 这类语义类型开箱即用;二是两个错误一次性全部报出来,而不是遇到第一个就中断——这正是中篇第四章提到的”收集所有错误路径”。
Field:默认值与约束
Field() 用来表达注解本身表达不了的约束(范围、长度、正则):
from pydantic import BaseModel, EmailStr, Field
class User(BaseModel):
id: int
email: EmailStr
# 1. 基础默认值:不传时默认为 "user"
role: str = "user"
# 2. 完全可选字段:允许为 None,不传时默认就是 None
bio: str | None = None
# 3. 业务边界约束
name: str = Field(default="Anonymous", min_length=2, max_length=20)
age: int = Field(default=18, ge=0, le=120)
约束也可以写在 Annotated 里,这是 Pydantic v2 更推荐的形式,因为它让类型和元数据分离得更干净(见上篇第二章「类型信息提供层(上):类型表达」的 Annotated 一节):
from typing import Annotated
class User(BaseModel):
name: Annotated[str, Field(min_length=2, max_length=20)] = "Anonymous"
age: Annotated[int, Field(ge=0, le=120)] = 18
对应 Java:需要 Hibernate Validator 配合注解,且必须在调用处用 @Valid 开启切面校验,否则注解形同虚设:
import jakarta.validation.constraints.*;
public record User(
@NotNull Long id,
@Size(min = 2, max = 20) String name,
@Email @NotBlank String email,
@Min(0) @Max(120) Integer age
) {}
关键差别:Java 的校验默认不发生,要靠 @Valid 触发;Pydantic 的校验默认发生,是 __init__ 的一部分,无法绕过。
ValidationError 与错误聚合
Pydantic 抛出的 ValidationError 是结构化的,可以直接转成 API 响应:
from pydantic import ValidationError
try:
User(id="abc", email="bad", age=200)
except ValidationError as e:
print(e.error_count()) # 3
for err in e.errors():
print(err["loc"], err["type"], err["msg"])
# ('id',) int_parsing Input should be a valid integer...
# ('email',) value_error value is not a valid email address
# ('age',) less_than_equal Input should be less than or equal to 120
loc 是字段路径,嵌套模型时会是 ("items", 0, "name") 这样的元组,能精确定位到出错位置。FastAPI 正是拿这个结构直接生成 422 响应体的。
AI-Infra 实例
# vLLM: vllm/entrypoints/openai/protocol.py
class ChatCompletionRequest(OpenAIBaseModel):
model: str
messages: list[ChatCompletionMessageParam]
temperature: float | None = None
max_tokens: int | None = None
stream: bool | None = False
# FastAPI 路由直接使用 Pydantic 模型
@app.post("/v1/chat/completions")
async def create_chat_completion(request: ChatCompletionRequest):
# 进入函数体时 request 已经过校验,类型安全
...
相当于 Spring Boot 的 @RequestBody + @Valid + Swagger,但零配置。
3. 序列化、反序列化与 Schema 生成
数据契约不只是”在内存里长什么样”,还包括怎么进来、怎么出去、怎么被外部理解。这是 Pydantic 相比 @dataclass 的另一个主要优势。
model_dump 与 model_dump_json
config = InferenceConfig(model_name="llama-3", backend="cuda")
config.model_dump()
# {'model_name': 'llama-3', 'backend': 'cuda', 'max_tokens': 2048, ...}
config.model_dump_json()
# '{"model_name":"llama-3","backend":"cuda","max_tokens":2048,...}'
# 常用选项
config.model_dump(exclude={"top_p"}) # 排除字段
config.model_dump(exclude_defaults=True) # 只输出被显式设置过的字段
config.model_dump(mode="json") # 把 datetime/UUID 等转成 JSON 可序列化的形式
@dataclass 也能序列化,但要自己动手,且不处理嵌套的非 JSON 原生类型:
from dataclasses import asdict
import json
json.dumps(asdict(some_dataclass)) # datetime 字段会直接抛 TypeError
对应 Java:Jackson 的 ObjectMapper.writeValueAsString(),配合 @JsonProperty、@JsonIgnore 控制字段。
model_json_schema 与 OpenAPI
InferenceConfig.model_json_schema()
# {
# 'type': 'object',
# 'properties': {
# 'model_name': {'type': 'string', 'title': 'Model Name'},
# 'backend': {'enum': ['cuda', 'rocm', 'cpu'], 'type': 'string'},
# 'max_tokens': {'type': 'integer', 'maximum': 32768, 'minimum': 1, 'default': 2048},
# ...
# },
# 'required': ['model_name', 'backend']
# }
注意 Literal["cuda", "rocm", "cpu"] 被翻译成了 JSON Schema 的 enum,Field(ge=1, le=32768) 变成了 minimum/maximum——类型注解和约束被完整地传递到了外部契约。FastAPI 的 /docs 页面就是拿这个 schema 渲染的。
对应 Java:需要额外引入 Swagger / springdoc 注解,且与 Bean Validation 的注解是两套体系。
解析 YAML / JSON 配置文件
AI-Infra 项目大量使用 YAML 配置(vLLM 的引擎参数、DeepSpeed 的并行策略、训练任务的超参)。裸读 YAML 得到的是一个 dict[str, Any],类型信息全丢——这正是数据契约要解决的问题。
model_validate() 把任意 dict 转成校验过的模型:
import yaml
from pydantic import BaseModel, Field
from typing import Literal
class ParallelConfig(BaseModel):
tensor_parallel_size: int = Field(default=1, ge=1)
pipeline_parallel_size: int = Field(default=1, ge=1)
class ServingConfig(BaseModel):
model_name: str
dtype: Literal["float16", "bfloat16", "float32"] = "bfloat16"
max_model_len: int = Field(default=4096, ge=1)
parallel: ParallelConfig = ParallelConfig() # 嵌套模型
with open("serving.yaml") as f:
raw = yaml.safe_load(f) # dict[str, Any],无类型保障
config = ServingConfig.model_validate(raw) # 校验 + 转换 + 嵌套构建
对应的 serving.yaml:
model_name: meta-llama/Llama-3-8B
dtype: bfloat16
max_model_len: 8192
parallel:
tensor_parallel_size: 4
这样做的收益:
- 嵌套自动构建:
parallel那一段 dict 自动变成ParallelConfig实例,不用手工递归; - 拼写错误立刻暴露:YAML 里写成
dtype: bf16会在启动时报错,而不是等到加载权重时才崩; - 启动即失败:配置错误在进程启动的一瞬间抛出,不会浪费几分钟加载模型后才失败——这对动辄几十 GB 权重的推理服务尤其重要;
- IDE 补全:后续代码写
config.parallel.tensor_parallel_size有完整提示。
如果 YAML 中有多余字段,默认会被忽略;想让它报错(防止配置项拼错被静默吞掉),加上 extra="forbid":
from pydantic import ConfigDict
class ServingConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
...
反过来,把模型写回 YAML:
yaml.safe_dump(config.model_dump(mode="json"))
4. BaseSettings:配置即契约
配置是数据契约的一个特例:数据源是环境变量和 .env 文件,内容全是字符串,需要解析成强类型对象。pydantic-settings 提供的 BaseSettings 专门做这件事。
基本用法与 .env
from pydantic import PostgresDsn
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# 1. 强类型声明
APP_NAME: str = "Awesome App" # 环境变量没配时用默认值
DEBUG: bool = False # 自动把 "True"、"true"、"1" 解析为 True
PORT: int = 8000 # 自动把 "8000" 解析为数字 8000
# 还可以使用 Pydantic 的高级类型,自动校验 URL 格式
DATABASE_URL: PostgresDsn
# 2. 配置读取行为
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
# 实例化时自动读取环境变量和 .env 文件
settings = Settings()
print(settings.APP_NAME)
print(settings.PORT) # int,不是 str
对应的 .env:
DEBUG=True
PORT=9000
DATABASE_URL=postgresql://user:pass@localhost:5432/dbname
优先级、前缀与 Fail-Fast
- 大小写不敏感(默认):类里定义
PORT,环境变量写成port=1234也能识别。 - 优先级(由高到低):
- 实例化时显式传入的值(
Settings(PORT=5000)) - 操作系统环境变量(
export PORT=...) .env文件中的值- 类中定义的默认值
- 实例化时显式传入的值(
- 前缀支持:项目复杂时为防止环境变量冲突可加前缀,在
SettingsConfigDict中设env_prefix="APP_",则APP_PORT映射到PORT。 - Fail-Fast:执行
settings = Settings()的那一瞬间,任何必填配置缺失或类型错误(比如PORT被配成"hello")都会立刻抛异常并阻止程序启动。
最后这一条是配置契约最重要的性质:把配置错误从运行期挪到启动期。
多环境配置
指定多个 .env 文件,右边的覆盖左边的:
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
DB_HOST: str
API_KEY: str
model_config = SettingsConfigDict(
env_file=(".env.base", ".env.production"),
env_file_encoding="utf-8",
)
或者根据环境变量动态选择配置文件(类似 Spring Profile):
import os
from pydantic_settings import BaseSettings, SettingsConfigDict
# 先从系统环境获取当前运行环境,默认 development
run_env = os.getenv("ENV", "development")
class Settings(BaseSettings):
DEBUG: bool
DATABASE_URL: str
# 动态加载 .env.development 或 .env.production
model_config = SettingsConfigDict(
env_file=f".env.{run_env}",
env_file_encoding="utf-8",
)
settings = Settings()
对比 Spring @ConfigurationProperties
两者目的相同——把松散的配置字符串映射为强类型对象——但实现差异明显:
| 特性 | Pydantic BaseSettings | Spring @ConfigurationProperties |
|---|---|---|
| 底层核心技术 | 类型注解运行时解析 | 反射、Setter 或构造器注入 |
| 默认支持格式 | .env、系统环境变量、JSON / YAML(需插件) |
.properties、.yml / .yaml |
| 框架耦合度 | 完全独立,任何脚本都能直接实例化 | 深度绑定 Spring 容器,需为 Bean |
| 前缀映射机制 | 扁平化为主,env_prefix="APP_" 匹配 APP_PORT |
天然层级嵌套,prefix = "app" 匹配 app.database.url |
| 校验触发时机 | 实例化时立即校验 | 容器启动阶段,需配合 @Validated |
| 宽松绑定 | 较严格,主要靠大小写不敏感 | 极宽松,server.port、server_port、SERVER_PORT 都能映射 |
5. 选型指南:dataclass vs Pydantic vs TypedDict
三者对比
| 特性 | TypedDict | dataclass | Pydantic |
|---|---|---|---|
| 运行时类型 | 普通 dict |
自定义类实例 | 自定义类实例 |
| 运行时校验 | 无 | 无(除非手写 __post_init__) |
自动校验 |
| 类型转换 | 无 | 无 | 自动转换("1" → 1) |
| 类型检查 | 静态 | 静态 | 静态 + 运行时 |
| 性能开销 | 零(就是 dict) | 极低 | 有(校验成本) |
| 序列化 | 天然是 dict,直接 json.dumps |
需 asdict(),不处理特殊类型 |
内置 model_dump_json() |
| Schema 生成 | 无 | 无 | model_json_schema() |
| 适用场景 | 已经是 dict 的数据 | 内部数据传递 | 系统边界、外部输入 |
决策树
flowchart TB
Q0{"这组数据从哪来?"}
Q0 -->|"外部:HTTP / JSON / YAML / 环境变量"| Q1{"来自环境变量或 .env?"}
Q1 -->|"是"| S["Pydantic BaseSettings"]
Q1 -->|"否"| M["Pydantic BaseModel<br/>进门时解析并校验一次"]
Q0 -->|"内部:模块之间传递"| Q2{"需要什么性质?"}
Q2 -->|"不可变:可做 dict key、跨线程共享"| F["@dataclass(frozen=True) / NamedTuple"]
Q2 -->|"可变状态"| D["@dataclass"]
Q2 -->|"实例数量极大、内存敏感"| SL["@dataclass(slots=True)"]
Q0 -->|"数据本身就是 dict:第三方 API 返回值、已解析的 JSON"| Q3{"只想约束形状、不改运行时行为?"}
Q3 -->|"是"| TD["TypedDict"]
Q3 -->|"否,要校验值"| M
classDef q fill:#fff7e0,stroke:#c98a00,color:#222
classDef a fill:#eef4fb,stroke:#5b8dc9,color:#222
class Q0,Q1,Q2,Q3 q
class S,M,F,D,SL,TD a
核心原则:边界校验一次,内部自由传递
在系统边界用 Pydantic 校验一次,内部传递 @dataclass 对象。
这正是中篇第四章「静态与运行时的协作边界」中信任边界(Trust Boundary)模式在数据建模上的体现:外部数据不可信,进门时付一次校验成本;进门之后数据已经可信,用零开销的 @dataclass 传递,靠 mypy 做静态保障。
vLLM 的源码就是这个模式:API 层(entrypoints/openai/protocol.py)用 Pydantic 定义请求体,引擎内部(SamplingParams、SchedulerConfig)一律用 @dataclass。热路径上不做重复校验。
反过来的两种常见错误:
- 内部到处用 Pydantic:每次构造对象都跑一遍校验,在每 token 都要构造对象的推理热路径上会成为可观的开销;
- 边界用 dataclass:外部脏数据长驱直入,错误在很深的调用栈里才暴露,排查成本极高。
顺带一提,
attrs是比@dataclass更早、功能更全的第三方库,但在 AI-Infra 生态中已基本被”标准库@dataclass+ Pydantic”的组合取代,新项目一般不需要引入。
三、附录:选型决策树与 Java 数据契约对照
1. 类型工具选择决策树
需要定义数据结构?
├── 数据来自外部(API/JSON/YAML/用户输入)? → Pydantic BaseModel
├── 数据来自环境变量 / .env? → Pydantic BaseSettings
├── 数据本身就是 dict,只想约束形状? → TypedDict
└── 内部传递?
├── 需要不可变 → @dataclass(frozen=True) / NamedTuple
├── 实例数量极大 → @dataclass(slots=True)
└── 其他 → @dataclass
│
需要定义接口?
├── 你控制实现类的代码?
│ ├── 是 → ABC(抽象基类)
│ └── 否 → Protocol
│
需要约束参数值?
├── 几个固定字符串? → Literal
├── 枚举类型 + 穷尽检查? → Enum + assert_never
└── 数值范围/格式? → Annotated + Pydantic Field
│
需要泛型?
├── 简单的"输入输出类型一致"? → TypeVar
├── 自定义泛型容器? → Generic[T]
└── 装饰器保留签名? → ParamSpec
│
函数有多种调用方式? → @overload
│
需要运行时类型检查?
├── 数据建模 + 校验 → Pydantic
├── 函数级防御 → beartype
└── 简单分支判断 → isinstance
2. Java 与 Python 数据契约对照
| 场景 | Java | Python |
|---|---|---|
| 不可变数据载体 | record |
@dataclass(frozen=True) |
| 减模板代码 | Lombok @Data / @Value / @Builder |
@dataclass |
| 运行时字段校验 | Bean Validation + @Valid(需显式触发) |
Pydantic BaseModel(默认触发) |
| JSON 序列化 / 反序列化 | Jackson @JsonProperty |
Pydantic model_dump() / model_validate() |
| API Schema 生成 | Swagger / springdoc 注解 | Pydantic model_json_schema()(自动) |
| 配置绑定 | Spring @ConfigurationProperties |
Pydantic BaseSettings |
| 字典类型约束 | Map<K,V> + DTO |
TypedDict |
| 轻量返回值 | record / 匿名类 |
NamedTuple |
| 可替换接口 | interface |
Protocol |
| 枚举 | enum |
enum.Enum / Literal |
| 实现机制 | 编译期改 AST(Lombok)/ 运行时反射(Validator) | 运行时 exec 代码生成(dataclass)/ 元类 + Rust(Pydantic) |
核心差异:Java 用多个独立框架拼出完整的数据契约能力,每个框架各管一段;Python 用 Pydantic 一个库覆盖了校验、转换、序列化、Schema 生成、配置绑定的全部环节。
四、本文小结
回头看这三篇,Python 的类型系统其实是一条链路:注解把类型意图写下来,存根和 py.typed 把它分发出去,mypy 和 Pydantic 在两端各自消费它,最后落到数据契约上变成可执行的约束。
与 Java 的最大差异不在于语法,而在于这条链路是拆开的。Java 把声明、检查、载体、反射合为一体,你没得选;Python 把每一环都做成可插拔的组件,你可以只写注解不做检查,也可以只在边界上做运行时校验而内部完全不管。这种自由度是代价也是优势——代价是需要自己决定在哪里投入,优势是可以按项目实际情况精确控制。
无论是 Java 靠注解切面实现的 Bean Validation,还是 Python 用元类与 Rust 引擎构建的 Pydantic,本质都是把开发者从繁琐的”防错代码”中解放出来。理解了 __annotations__ 与元类之后会发现,Pydantic 并不是什么不可知的魔法——它只是充分利用了 Python 的动态性,把校验逻辑下沉到了语言机制层面。
对 AI-Infra 工程来说,实践上最值得记住的就三条:
- 注解要写,哪怕暂时不上 mypy——它首先是给人读的;
- 边界要校验,用 Pydantic 挡住所有外部输入,让错误在启动时或入口处暴露;
- 热路径要干净,内部传递用
@dataclass,不要在每 token 的循环里反复做运行时检查。
再遇到 AI Infra 源码中的类型注解,就不会觉得是天书了。关键不是一次记住所有工具,而是理解每个工具解决的问题——在真实代码中遇到时能查到、能读懂、能用对。
五、自测
-
@dataclass与 PydanticBaseModel各该用在什么位置?为什么?答案
dataclass用于内部热路径:零运行时校验、开销只是属性访问;Pydantic 用于系统边界(配置、请求、外部输入):解析 + 校验 + 错误信息,让错误在启动时或入口处暴露。每 token 的循环里做 Pydantic 校验是常见的性能事故。 -
为什么“注解要写,哪怕暂时不上 mypy”?
答案
注解首先是给人读的接口文档,其次是让 IDE 能补全与跳转,再次是让将来上 mypy 时不必回头补;而边界上的 Pydantic 校验直接依赖注解生成。不写注解的代价在读别人代码时才显现。
下一篇
本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/python-data-contract-design-dataclass-pydantic-and-settings.html)的前提下,欢迎各种形式的转载、翻译或商业引用。
COMMENTS
评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。