
看完这篇,你会用 Pydantic 把各种“奇葩请求”挡在门外,让接口更稳、报错更清楚。
开篇:来自前端的"惊喜"
你定义的接口是这样的:
@router.post("/generate")
async def generate(data: dict):
prompt = data["prompt"]
width = data["width"]
height = data["height"]
你期望收到的请求是这样的:
{
"prompt": "一个美丽的风景",
"width": 1024,
"height": 768
}
但生产环境实际收到的,让你怀疑人生:
{"prompt": null}
{"prompt": ""}
{"width": "1024"}
{"width": -100}
{"prompt": "test", "widht": 1024}
{}
{"prompt": "x"*100000}
翻译成人话:
| 前端传的 | 后端的反应 |
|---|---|
null | AttributeError: 'NoneType'... |
| 空字符串 | 模型:??? |
| 字符串 “1024” | TypeError 或者更诡异的行为 |
| 负数 -100 | 模型直接罢工 |
字段拼错 widht | KeyError |
空对象 {} | KeyError: 'prompt' |
| 10万字符 | 内存爆炸 |
永远不要相信前端传来的数据。
这不是对前端同事的不信任,而是对墨菲定律的敬畏。
Pydantic:你的防弹衣
FastAPI 原生支持 Pydantic。把 dict 换成 Pydantic 模型,世界立刻清净了:
# app/models/schemas.py
from pydantic import BaseModel, Field
from typing import Optional
class GenerateShotImageRequest(BaseModel):
"""分镜图片生成请求"""
# 必填字段:... 表示"你必须传"
prompt: str = Field(
...,
description="生成提示词",
min_length=1, # 不能是空字符串
max_length=2000 # 别想撑爆我
)
# 可选字段:有默认值,不传也行
width: int = Field(
default=1024,
description="图片宽度",
ge=256, # >= 256
le=2048 # <= 2048
)
height: int = Field(
default=1024,
description="图片高度",
ge=256,
le=2048
)
seed: int = Field(
default=-1,
description="随机种子,-1 表示随机"
)
style: Optional[str] = Field(
default=None,
description="风格预设"
)
使用起来无比丝滑:
@router.post("/shot-image")
async def generate_shot_image(request: GenerateShotImageRequest):
# 走到这里,数据已经是:
# - 类型正确 ✓
# - 范围合法 ✓
# - 必填字段存在 ✓
result = await adapter.generate(
prompt=request.prompt, # 一定是非空字符串
width=request.width, # 一定是 256-2048 的整数
height=request.height,
seed=request.seed
)
return result
如果数据不合法,FastAPI 自动返回 422:
{
"detail": [
{
"loc": ["body", "prompt"],
"msg": "Field required",
"type": "missing"
},
{
"loc": ["body", "width"],
"msg": "Input should be greater than or equal to 256",
"type": "greater_than_equal"
}
]
}
哪个字段有问题、什么问题,一清二楚。
自定义验证器:处理复杂逻辑
Field 约束不够用?上验证器:
from pydantic import BaseModel, field_validator, model_validator
class GenerateShotImageRequest(BaseModel):
prompt: str
width: int = 1024
height: int = 1024
@field_validator('prompt')
@classmethod
def prompt_must_not_be_empty(cls, v: str) -> str:
"""空白字符串?想得美"""
if not v.strip():
raise ValueError('Prompt cannot be empty or whitespace only')
return v.strip() # 顺手 trim 一下
@field_validator('width', 'height')
@classmethod
def dimensions_must_be_multiple_of_64(cls, v: int) -> int:
"""某些模型要求尺寸是 64 的倍数"""
if v % 64 != 0:
v = (v // 64) * 64 # 自动修正,用户无感
return v
@model_validator(mode='after')
def check_aspect_ratio(self) -> 'GenerateShotImageRequest':
"""宽高比别太离谱"""
ratio = self.width / self.height
if ratio > 4 or ratio < 0.25:
raise ValueError('Aspect ratio must be between 1:4 and 4:1')
return self
三种验证器:
| 验证器 | 作用 | 适用场景 |
|---|---|---|
@field_validator | 验证单个字段 | 格式检查、自动修正 |
@model_validator | 验证字段之间关系 | 条件依赖、组合约束 |
| 返回值 | 替换原值 | 自动 trim、自动修正 |
枚举类型:只能传这几个值
from enum import Enum
class ShotType(str, Enum):
WIDE = "wide"
MEDIUM = "medium"
CLOSE_UP = "close-up"
EXTREME_CLOSE_UP = "extreme-close-up"
class ControlType(str, Enum):
CANNY = "canny"
DEPTH = "depth"
POSE = "pose"
class GenerateShotImageRequest(BaseModel):
prompt: str
shot_type: ShotType = ShotType.MEDIUM
control_type: Optional[ControlType] = None
前端传了个不存在的值:
{"shot_type": "super-close"}
直接打回:
{
"detail": [{
"loc": ["body", "shot_type"],
"msg": "Input should be 'wide', 'medium', 'close-up' or 'extreme-close-up'",
"type": "enum"
}]
}
连可选值都帮你列出来了,前端改起来贼方便。
嵌套模型:套娃验证
复杂请求体?嵌套起来:
class AnimationParams(BaseModel):
"""动画参数"""
duration: float = Field(default=3.0, ge=1.0, le=10.0)
fps: int = Field(default=24, ge=12, le=60)
camera_movement: Optional[str] = None
class CharacterRef(BaseModel):
"""角色参考"""
image_path: str
weight: float = Field(default=0.8, ge=0.0, le=1.0)
class GenerateVideoRequest(BaseModel):
"""视频生成请求"""
shots: list[ShotData] = Field(..., min_length=1, max_length=100)
animation: AnimationParams = Field(default_factory=AnimationParams)
character_refs: list[CharacterRef] = Field(default_factory=list)
Pydantic 会递归验证每一层,一个都逃不掉。
条件验证:开关打开了,配置呢?
class GenerateShotImageRequest(BaseModel):
prompt: str
use_control_net: bool = False
control_image_path: Optional[str] = None
control_type: Optional[ControlType] = None
@model_validator(mode='after')
def validate_control_net(self) -> 'GenerateShotImageRequest':
"""开了 ControlNet 就必须传参考图"""
if self.use_control_net:
if not self.control_image_path:
raise ValueError(
'control_image_path is required when use_control_net is True'
)
if not self.control_type:
raise ValueError(
'control_type is required when use_control_net is True'
)
return self
再也不会出现"开关打开了但没传图"的尴尬。
响应模型:出口也要把关
不只是入口,出口也得验证:
class GenerationResult(BaseModel):
"""生成结果"""
image_url: str
seed: int
generation_time: float
class GenerateShotImageResponse(BaseModel):
"""接口响应"""
success: bool = True
message: Optional[str] = None
data: Optional[GenerationResult] = None
@router.post("/shot-image", response_model=GenerateShotImageResponse)
async def generate_shot_image(request: GenerateShotImageRequest):
result = await generate(...)
return GenerateShotImageResponse(
success=True,
data=GenerationResult(
image_url=result["url"],
seed=result["seed"],
generation_time=result["time"]
)
)
响应模型的好处:
- 自动过滤敏感字段——只返回定义的字段,内部字段不会泄露
- 自动生成 API 文档——Swagger UI 直接可用
- 格式一致性保证——前端永远知道会收到什么
美化验证错误响应
默认的 422 格式不太友好,改成和其他错误一致:
from fastapi.exceptions import RequestValidationError
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc: RequestValidationError):
"""把 422 变成好看的 400"""
errors = []
for error in exc.errors():
field = ".".join(str(x) for x in error["loc"][1:])
errors.append({
"field": field,
"message": error["msg"],
"type": error["type"]
})
return JSONResponse(
status_code=400,
content={
"success": False,
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": errors
}
}
)
现在返回:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{
"field": "prompt",
"message": "Field required",
"type": "missing"
}
]
}
}
和业务错误格式统一,前端处理起来舒服多了。
验证流程图
四层过滤,能进入业务逻辑的都是"好数据"。
验证规则速查表
收藏这张表,用的时候直接抄:
| 验证类型 | 写法 | 示例 |
|---|---|---|
| 必填 | Field(...) | name: str = Field(...) |
| 可选 | Optional[T] | bio: Optional[str] = None |
| 默认值 | Field(default=x) | page: int = Field(default=1) |
| 最小值 | ge= / gt= | age: int = Field(ge=0) |
| 最大值 | le= / lt= | score: int = Field(le=100) |
| 字符串长度 | min_length / max_length | name: str = Field(min_length=1) |
| 正则匹配 | pattern= | email: str = Field(pattern=r".*@.*") |
| 枚举值 | Enum 类型 | status: StatusEnum |
| 列表长度 | min_length / max_length | tags: list = Field(max_length=10) |
| 单字段验证 | @field_validator | 见上文 |
| 跨字段验证 | @model_validator | 见上文 |
防御性编程六原则
- 所有接口都用 Pydantic 模型——
dict是万恶之源 - 必填字段用
...——明确就是明确 - 数值加范围——防止负数、天文数字
- 字符串加长度——防止内存爆炸
- 复杂逻辑用验证器——保持模型定义清晰
- 响应也用模型——入口出口都把关
总结:信任是个奢侈品
在后端开发的世界里,"信任"是个奢侈品。
前端传的数据,可能是:
- 用户手滑打错
- 前端代码 bug
- 恶意攻击
- 网络传输出错
你的代码必须假设:所有输入都是恶意的,直到被证明是安全的。
Pydantic 就是那个帮你"验明正身"的安检员。
有了它:
KeyError再见TypeError再见AttributeError: 'NoneType'...再见
你的业务代码只需要处理业务逻辑,不用再担心"这个值会不会是 null"。
这才是真正的"防御性编程"。
📌 我在公众号「程序员义拉冠」持续分享 AI 应用开发实战。点此 获取本系列合集

2203

被折叠的 条评论
为什么被折叠?



