
聊聊 AI 后端那些事儿 · 第 3 篇 | 阅读大约需 11 分钟
那些奇葩的请求参数
小禾的接口定义得很清楚:
python
@router.post("/generate")
async def generate(data: dict):
prompt = data["prompt"]
width = data["width"]
height = data["height"]
# ...
期望的请求体是这样的:
json
{
"prompt": "一个美丽的风景",
"width": 1024,
"height": 768
}
但生产环境收到的请求,让小禾大开眼界:
json
{"prompt": null}
{"prompt": ""}
{"width": "1024"}
{"width": -100}
{"prompt": "test", "widht": 1024}
{}
{"prompt": "x" * 100000}
分别是:
- null 值
- 空字符串
- 字符串而不是数字
- 负数
- 字段名拼写错误
- 空对象
- 超长字符串
每一个都能让后端崩溃。
python
# 各种崩溃方式
data["prompt"] # KeyError: 'prompt'
data["prompt"].strip() # AttributeError: 'NoneType' object...
int(data["width"]) # ValueError: invalid literal
model.generate(width=-100) # 模型报错
小禾意识到:永远不要相信前端传来的数据。
Pydantic 救场
FastAPI 原生支持 Pydantic,可以自动验证请求数据。
小禾把 dict 换成了 Pydantic 模型:
python
# 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="风格预设"
)
在端点中使用:
python
@router.post("/shot-image")
async def generate_shot_image(request: GenerateShotImageRequest):
# request 已经是验证过的数据
# 类型正确、范围合法、必填字段存在
result = await adapter.generate(
prompt=request.prompt, # 一定是非空字符串
width=request.width, # 一定是 256-2048 的整数
height=request.height, # 一定是 256-2048 的整数
seed=request.seed # 一定是整数
)
return result
现在如果请求数据不合法,FastAPI 会自动返回 422 错误:
json
{
"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"
}
]
}
清清楚楚,哪个字段有问题,什么问题。
自定义验证器
有些验证逻辑比较复杂,需要自定义:
python
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() # 返回处理后的值
@field_validator('width', 'height')
@classmethod
def dimensions_must_be_multiple_of_64(cls, v: int) -> int:
"""尺寸必须是 64 的倍数(某些模型要求)"""
if v % 64 != 0:
# 自动调整到最近的 64 倍数
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:验证字段之间的关系- 返回值会替换原值,可以做自动修正
枚举类型限制
有些字段只能是特定的值:
python
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
如果传了不在枚举里的值:
json
{"shot_type": "super-close"}
会返回:
json
{
"detail": [{
"loc": ["body", "shot_type"],
"msg": "Input should be 'wide', 'medium', 'close-up' or 'extreme-close-up'",
"type": "enum"
}]
}
嵌套模型
复杂的请求体可以用嵌套模型:
python
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 会递归验证嵌套的每一层。
条件验证
有时候字段之间有依赖关系:
python
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
这样就不会出现"开关打开了但没传图"的情况。
响应模型验证
不只是请求,响应也可以验证:
python
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 文档
- 保证响应格式一致
美化验证错误响应
默认的 422 响应格式有点丑,可以自定义:
python
from fastapi.exceptions import RequestValidationError
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc: RequestValidationError):
"""美化验证错误响应"""
errors = []
for error in exc.errors():
field = ".".join(str(x) for x in error["loc"][1:]) # 跳过 "body"
errors.append({
"field": field,
"message": error["msg"],
"type": error["type"]
})
return JSONResponse(
status_code=400, # 用 400 而不是 422
content={
"success": False,
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": errors
}
}
)
现在响应变成了:
json
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{
"field": "prompt",
"message": "Field required",
"type": "missing"
},
{
"field": "width",
"message": "Input should be greater than or equal to 256",
"type": "greater_than_equal"
}
]
}
}
跟其他错误响应格式一致了。
验证规则速查表
小禾整理了一份常用验证规则:
| 验证类型 | Pydantic 写法 |
|---|---|
| 必填 | Field(...) |
| 可选 | Optional[T] 或 T = None |
| 默认值 | Field(default=xxx) |
| 最小值 | Field(ge=xxx) 或 Field(gt=xxx) |
| 最大值 | Field(le=xxx) 或 Field(lt=xxx) |
| 字符串长度 | Field(min_length=x, max_length=y) |
| 正则匹配 | Field(pattern=r"xxx") |
| 枚举值 | 使用 Enum 类型 |
| 列表长度 | Field(min_length=x, max_length=y) |
| 自定义逻辑 | @field_validator |
| 跨字段验证 | @model_validator |
验证流程图
flowchart TB
A[收到请求] --> B{JSON 格式正确?}
B -->|否| C[返回 400 JSON 解析错误]
B -->|是| D{字段类型正确?}
D -->|否| E[返回 400 类型错误]
D -->|是| F{字段约束满足?}
F -->|否| G[返回 400 约束错误]
F -->|是| H{自定义验证通过?}
H -->|否| I[返回 400 验证错误]
H -->|是| J[进入业务逻辑]
层层过滤,确保进入业务逻辑的数据都是合法的。
防御性编程原则
小禾总结了几条原则:
- 所有接口都用 Pydantic 模型 :不要用
dict - 必填字段用
...:明确表示必填 - 数值类型加范围限制:避免负数、超大数
- 字符串加长度限制:防止超长输入
- 复杂逻辑用验证器:保持模型定义清晰
- 响应也用模型:保证格式一致
小禾的感悟
csharp
前端传什么,
你永远猜不到。
null、空串、负数、超长,
每一个都是炸弹。
Pydantic 是防弹衣,
把炸弹挡在业务逻辑之外。
类型注解是第一道防线,
Field 约束是第二道,
验证器是第三道。
三道防线都过了,
才能进入你的代码。
别信任任何输入,
别假设任何数据,
防御性编程,
让你睡得安稳。
小禾把所有接口都加上了 Pydantic 验证。
从此,再也没有因为"前端传了个奇怪的值"而崩溃的情况了。
下一篇预告:用户说"太慢了",但我不知道慢在哪
一行代码,让你看清性能瓶颈。
敬请期待。