FastAPI 多模型响应实践:UserIn/UserOut/UserInDB 继承复用与 Union anyOf 响应类型
本篇基于 FastAPI 官方教程“Extra Models”(额外模型)章节,讲解如何为一个业务实体(如用户)声明多个关联的 Pydantic 模型——输入模型、输出模型与数据库模型,以及如何通过类继承消除字段重复;同时覆盖用 Union 声明 OpenAPI anyOf 联合响应、列表响应与任意 dict 响应的完整写法,并结合 FastAPI 源码说明响应校验与序列化的底层流程。读完后,你能掌握“一个实体多种状态”场景下的模型组织方案,并理解 response_model 在框架内部从校验到序列化的调用链。
为什么要为同一个实体准备多个模型
延续 FastAPI 教程中此前的用户创建示例,实际项目中很常见的情形是:同一个业务实体需要多个相互关联的模型。这对用户模型尤其典型,因为:
- 输入模型(input model)需要能够携带明文密码;
- 输出模型(output model)绝不应该包含密码;
- 数据库模型通常需要存储的是密码的安全哈希值(hashed password)。
危险(Danger):永远不要以明文存储用户密码。应始终存储一个可事后校验的“安全哈希”。如果还不了解什么是“密码哈希”,可以在 FastAPI 教程的安全章节中学习。
下面完整给出教程 tutorial001 的示例代码,它展示了三类模型及其密码字段、以及各自的使用位置:
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr
app = FastAPI()
class UserIn(BaseModel):
username: str
password: str
email: EmailStr
full_name: str | None = None
class UserOut(BaseModel):
username: str
email: EmailStr
full_name: str | None = None
class UserInDB(BaseModel):
username: str
hashed_password: str
email: EmailStr
full_name: str | None = None
def fake_password_hasher(raw_password: str):
return "supersecret" + raw_password
def fake_save_user(user_in: UserIn):
hashed_password = fake_password_hasher(user_in.password)
user_in_db = UserInDB(**user_in.model_dump(), hashed_password=hashed_password)
print("User saved! ..not really")
return user_in_db
@app.post("/user/", response_model=UserOut)
async def create_user(user_in: UserIn):
user_saved = fake_save_user(user_in)
return user_saved
关键数据流是:POST /user/ 接收 UserIn(含明文 password)→ fake_save_user 中计算 hashed_password,并用 UserInDB(**user_in.model_dump(), hashed_password=...) 构建数据库模型 → 路由声明 response_model=UserOut,最终只把 username、email、full_name 序列化返回,password 不会出现在响应中。
警告(Warning):辅助函数
fake_password_hasher和fake_save_user只是用于演示数据流动,并不提供任何真实的安全保障。
**user_in.model_dump() 的数据流详解
这一节继承原文档中对 UserInDB(**user_in.model_dump(), hashed_password=hashed_password) 这行核心代码的逐层拆解。
Pydantic 的 .model_dump() 方法
user_in 是一个 UserIn 类的 Pydantic 模型对象。Pydantic 模型提供 .model_dump() 方法,它会返回一个包含模型数据的 dict。
例如,创建对象:
user_in = UserIn(username="john", password="secret", email="john.doe@example.com")
然后调用:
user_dict = user_in.model_dump()
此时变量 user_dict 中就是一个 dict(而非 Pydantic 模型对象)。调用 print(user_dict) 会得到:
{
'username': 'john',
'password': 'secret',
'email': 'john.doe@example.com',
'full_name': None,
}
解包(Unpacking)一个 dict
如果取 user_dict 这样的字典,用 **user_dict 的形式传给函数(或类),Python 会“解包”它:把字典的键值对直接作为关键字参数传入。因此:
UserInDB(**user_dict)
等价于:
UserInDB(
username="john",
password="secret",
email="john.doe@example.com",
full_name=None,
)
更严谨地写(直接基于 user_dict 的实际内容,无论它将来包含什么):
UserInDB(
username = user_dict["username"],
password = user_dict["password"],
email = user_dict["email"],
full_name = user_dict["full_name"],
)
从一个 Pydantic 模型的内容创建另一个 Pydantic 模型
由于 user_dict 来自 user_in.model_dump(),下面这段代码:
user_dict = user_in.model_dump()
UserInDB(**user_dict)
等价于:
UserInDB(**user_in.model_dump())
因为 user_in.model_dump() 返回的就是一个 dict,而我们在前面加上 ** 后传给 UserInDB,Python 就把它解包了。这样就从一个 Pydantic 模型的数据创建了另一个 Pydantic 模型。
解包 dict 并追加额外的关键字参数
再加上一个额外的关键字参数 hashed_password=hashed_password:
UserInDB(**user_in.model_dump(), hashed_password=hashed_password)
...就等价于:
UserInDB(
username = user_dict["username"],
password = user_dict["password"],
email = user_dict["email"],
full_name = user_dict["full_name"],
hashed_password = hashed_password,
)
这正是 tutorial001 中 fake_save_user 完成“输入模型 → 数据库模型”转换的标准写法。
用继承减少模型重复(UserBase)
减少代码重复是 FastAPI 的核心理念之一:重复代码会提高 bug、安全问题以及多处代码不同步(改了这里忘了那里)的风险。上面的三个模型共享了大量数据,属性名和类型都在重复声明。
更好的做法是:声明一个 UserBase 基础模型,其他模型通过子类化继承它的属性(类型声明、校验等)。所有数据转换、校验、文档生成都照常工作,而你只需要声明各模型之间的差异(带明文 password、带 hashed_password、或不带密码)。完整示例见 tutorial002:
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr
app = FastAPI()
class UserBase(BaseModel):
username: str
email: EmailStr
full_name: str | None = None
class UserIn(UserBase):
password: str
class UserOut(UserBase):
pass
class UserInDB(UserBase):
hashed_password: str
def fake_password_hasher(raw_password: str):
return "supersecret" + raw_password
def fake_save_user(user_in: UserIn):
hashed_password = fake_password_hasher(user_in.password)
user_in_db = UserInDB(**user_in.model_dump(), hashed_password=hashed_password)
print("User saved! ..not really")
return user_in_db
@app.post("/user/", response_model=UserOut)
async def create_user(user_in: UserIn):
user_saved = fake_save_user(user_in)
return user_saved
注意两个细节:
UserOut(UserBase)甚至不需要新增字段(pass),因为输出模型恰好就是基础模型的全部字段;UserIn与UserInDB各自只追加了差异字段password/hashed_password。
对应的测试 test_tutorial001_tutorial002.py 对两种写法分别验证了 POST /user/ 的请求校验与响应结构,确保重构后行为一致。
响应声明为 Union(OpenAPI anyOf)
你可以把某个接口的响应声明为两个(或多个)类型的 Union,即响应可以是其中任意一种类型。在 OpenAPI 中它会以 anyOf 定义。做法是使用标准 Python 类型标注 typing.Union(完整示例见 tutorial003):
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class BaseItem(BaseModel):
description: str
type: str
class CarItem(BaseItem):
type: str = "car"
class PlaneItem(BaseItem):
type: str = "plane"
size: int
items = {
"item1": {"description": "All my friends drive a low rider", "type": "car"},
"item2": {
"description": "Music is my aeroplane, it's my aeroplane",
"type": "plane",
"size": 5,
},
}
@app.get("/items/{item_id}", response_model=PlaneItem | CarItem)
async def read_item(item_id: str):
return items[item_id]
备注:定义
Union时,应把最具体的类型放在第一位,其次再放较不具体的类型。在下例中,较具体的PlaneItem位于Union[PlaneItem, CarItem]的CarItem之前。
源码层面:anyOf 如何进入 OpenAPI Schema
从源码结构看,anyOf 是 FastAPI 内置 OpenAPI Schema 模型的一个字段:在 fastapi/openapi/models.py 中,Schema 模型定义了 allOf、anyOf、oneOf 等 JSON Schema 组合关键字。当 response_model 是一个 Union 类型时,FastAPI 生成的 OpenAPI 响应 schema 就会是一个 anyOf 结构,其中每个分支用 $ref 引用对应模型。
这一点在测试 test_tutorial003.py 中被完整固化:GET /openapi.json 返回的 200 响应 schema 为:
{
"title": "Response Read Item Items Item Id Get",
"anyOf": [
{"$ref": "#/components/schemas/PlaneItem"},
{"$ref": "#/components/schemas/CarItem"}
]
}
同时该测试用 TestClient 分别请求 item1(车)与 item2(飞机),验证返回的 JSON 与各自模型完全一致。
Union 在 Python 3.10 中的写法差异
在上例中,PlaneItem | CarItem(即 Union[PlaneItem, CarItem])是作为 response_model 参数值传入的。因为它是传给参数的值而不是类型注解,所以即使在 Python 3.10 上也要保证它是一个有效的类型表达式(3.10 的 | 语法在运行期的类型表达式中可用;但在低版本 Python 中必须写作 Union[PlaneItem, CarItem])。
如果它出现在类型注解位置,可以像这样使用竖线:
some_variable: PlaneItem | CarItem
但如果在赋值语句 response_model=PlaneItem | CarItem 中写成运行期的“类与类的 | 运算”而不符合类型语法规则,Python 会尝试对 PlaneItem 和 CarItem 执行一个非法操作(把两个类当作普通对象做 or/按位运算语义的合并),从而产生错误,而不是把它解释成类型注解。因此“参数值”与“类型注解”两种位置在使用 Union 时要区分对待。
列表响应:list[Model]
同样的方式,你也可以声明返回对象列表的响应,使用标准 Python list 即可(示例见 tutorial004):
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
description: str
items = [
{"name": "Foo", "description": "There comes my hero"},
{"name": "Red", "description": "It's my aeroplane"},
]
@app.get("/items/", response_model=list[Item])
async def read_items():
return items
响应中每个元素都会按 Item 模型校验与序列化;测试 test_tutorial004.py 验证了 GET /items/ 返回的两个对象及 OpenAPI schema 的数组定义。
用任意 dict 声明响应
你也可以不用 Pydantic 模型,仅声明键和值的类型,用普通 dict 来声明响应(示例见 tutorial005):
from fastapi import FastAPI
app = FastAPI()
@app.get("/keyword-weights/", response_model=dict[str, float])
async def read_keyword_weights():
return {"foo": 2.3, "bar": 3.4}
这在事先不知道合法字段/属性名(而 Pydantic 模型要求预先声明字段)的场景下非常有用。测试 test_tutorial005.py 验证了该接口返回的键值类型校验行为。
源码印证:response_model 的校验与序列化流程
上述“响应必须是某模型(或 Union、list、dict)”的约束,在框架内部的落点是 fastapi/routing.py 中的 serialize_response():
- 路由注册时,FastAPI 根据
response_model创建response_field(见 fastapi/routing.py 处的create_model_field); - 端点返回后,框架调用
serialize_response(field=response_field, response_content=返回值); - 其中先执行
field.validate(response_content, {}, loc=("response",))——这一步对返回值做与请求体同源的 Pydantic 校验;对 Union 响应,即“尝试匹配PlaneItem或CarItem之一”; - 校验失败会抛出
ResponseValidationError,由框架转为 500 响应; - 校验通过后,用
field.serialize/field.serialize_json完成序列化,include、exclude、by_alias、exclude_unset、exclude_defaults、exclude_none等参数决定了最终 JSON 字段的选择(这也是为什么返回UserInDB对象、声明response_model=UserOut时,hashed_password会被过滤掉)。
也就是说,“输入模型带密码、输出模型不带密码”不仅是文档层面的约定,而是由 response_field 的校验与序列化在每次请求时强制执行的。
小结
- 为同一个业务实体(如用户)自由使用多个 Pydantic 模型并按需继承:输入模型带明文
password、数据库模型带hashed_password、输出模型不带任何密码字段——实体不需要“一物一模型”,当实体有多种“状态”(password、password_hash、或无密码)时,多模型 + 继承是标准方案; - 用
user_in.model_dump()得到dict,再用**解包传给另一个模型构造函数,可高效完成“模型到模型”的转换,并支持追加额外关键字参数; - 响应可以是
Union(OpenAPI 中的anyOf,最具体的类型放前面)、list[Model],或仅声明键值类型的任意dict; - 所有这些约束都由 FastAPI 的
response_field在响应序列化阶段统一校验与过滤,参见 fastapi/routing.py,并有 tests/test_tutorial/test_extra_models/ 下的测试套件覆盖验证。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0626
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00