🍙 给每个请求过安检:FastAPI 是怎么把校验写进类型注解的

写在前面:前几节课我们写了 React 前端、NestJS 后端、LangGraph 工作流。今天换语言------Python 后端 。课程标题是《FastAPI + Vue3 + LangChain 实战》,readme 开篇一句话点明了选型理由:"是 python 高性能的 web 接口框架,上手简单快速,性能比肩 Go/Node。" 更关键的是后半句------"适合结合 langchain/langgraph 开发后端服务"。意思很明确:前面学的那些 AI 能力,需要一个"API 门面"把它们暴露出去,FastAPI 就是那扇门。今天我们用 5 份代码文件,从一行 Hello 写到接上大模型。以下所有代码均来自课堂真实文件。


一、FastAPI 是什么:两块拼图

readme 给了 FastAPI 一个非常"解剖学"的定义:

"FastAPI = Pythonic(zod 类型检测 用户输出,params,类)+ Starlette(负责 Web 底层,接受 http 服务,路由匹配,返回响应,处理网络,自带异步能力,是高性能的 Web 基座)"

拆开看,它是两块拼图拼起来的:

拼图 负责什么 类比
Pydantic 类型检测、数据校验(用户输出、params、类) 安检员
Starlette HTTP 服务、路由匹配、返回响应、异步能力 机场跑道与塔台

这个组合很聪明------Starlette 管"跑得快",Pydantic 管"不会错"

有意思的是 readme 括号里那句"zod 类型检测"------zod 是前端生态里的类型校验库(前面讲结构化输出时用过),Python 里的 Pydantic 跟它思路一致:用类型声明来定义数据长什么样,运行时自动校验。

为什么选它而不是 Flask / Django?

readme 列了四个卖点:

卖点 说明
上手简单快速 写少量代码
性能比肩 Go/Node 异步无阻塞、高并发
自动生成交互式接口文档 前后端 API 约定、Swagger 自动生成
自带类型提示 代码即文档

第三个卖点是我觉得最"省事"的------Swagger 文档是自动生成的,不需要你手写一份 API 文档然后等着它过期。

关于异步,readme 特意给了两个典型场景:

"Starlette 异步。async ------ 等数据库查询、文件读写。"

这两个都是 I/O 密集型操作------CPU 在等磁盘/网络回来,干等着纯属浪费。异步的意义就是:等数据库的时候,去处理别的请求。


二、环境安装:三行命令

readme 的安装步骤很干净:

bash 复制代码
pip install "fastapi[standard]"
pip install "uvicorn[standard]"
pip show fastapi

启动命令:

bash 复制代码
python -m uvicorn main:app --reload --port 8080

逐个解释:

命令/参数 含义
fastapi[standard] 装 FastAPI 及标准依赖(含 uvicorn、pydantic 等)
uvicorn 异步 Web 服务器,用来运行 FastAPI 项目
pip show fastapi 看看装了什么版本
main:app 模块 main.py 里的 app 对象
--reload 改代码自动重启(开发用)
--port 8080 指定端口

这里有个新手容易困惑的点 :FastAPI 是"框架",uvicorn 是"服务器"。框架负责定义"请求来了怎么处理",服务器负责"监听端口、接收连接、把请求交给框架"。两个都要装,两个角色不能混。


三、第一个接口:三行代码跑起来

main.py 的第一份快照,短到可以全文背诵:

python 复制代码
from fastapi import FastAPI
# 初始化FastAPI应用
app = FastAPI()

@app.get("/")
async def root():
    return {"message": "Hello qxh!"}

@app.get(f"/hello/{name}")
async def say_hello(name: str):
    return {"message": f"Hello {name}!"}

三个要素:

要素 代码 作用
应用实例 app = FastAPI() 整个服务的"入口对象"
装饰器 @app.get("/") 声明"这是个 GET 接口,路径是 /"
处理函数 async def root() 请求来了执行什么

注意 async def------FastAPI 原生支持异步处理函数。这就是 readme 说的"对异步(node)异步无阻塞 高并发"。

访问 http://localhost:8080/ 得到 {"message": "Hello qxh!"}。就这么简单。

顺便说一个坑:路径参数不能写成 f-string

上面第二个接口用了 @app.get(f"/hello/{name}")------这个写法其实是错的 ,而课堂在后面的文件里特意纠正了这一点。main.py 的第四份快照里有一行很有价值的注释:

python 复制代码
# {item_id} 是 FastAPI 的路径参数占位符,交给框架去匹配
# 不能写成 f-string,否则 Python 会在定义时就去求值这个变量
@app.put('/items/{item_id}')
async def update_item(item_id: int, item: Item):

原因在于执行时机------装饰器是在"函数定义时"执行的,不是"请求到来时"执行的。

python 复制代码
@app.get(f"/hello/{name}")   # ❌ 定义函数时就要求值 name
async def say_hello(name: str):   # name 是参数,此刻还不存在

f"/hello/{name}" 里的 name 在那时只是个函数参数,模块作用域里根本没有这个变量------Python 会在导入时直接抛 NameError

正确写法是普通字符串,把占位符原样交给 FastAPI:

python 复制代码
@app.get("/hello/{name}")    # ✅ 大括号原样保留,框架自己解析

这条规则值得单独记住------它不容易从语法上看出问题,但一运行就炸。为什么?因为装饰器跟函数体执行的时机完全不同:装饰器先执行,函数体等请求来了才执行。


四、Annotated:给参数加"安检要求"

最基础的接口跑通了,接下来是 FastAPI 的真正特色------参数校验

课堂有一份专门的笔记文件,只有两行:

arduino 复制代码
Annotated 丰富类型注解,
Annotated[int, Path(ge=2)]
自动做类型转换

Annotated 是 Python 的类型注解工具------它允许你在类型之外附加元数据

Path:路径参数的规则

python 复制代码
from fastapi import FastAPI, Path, Query
from typing import Annotated

@app.get("/p/{article_id}")
async def article_detail(article_id: Annotated[int, Path(ge=2)]):
    return {"article_id": article_id}

Annotated[int, Path(ge=2)] 拆开读:

部分 含义
int 这个参数是整数
Path(ge=2) 它来自路径,且必须 >= 2

于是:

请求 结果
/p/2 ✅ 返回 {"article_id": 2}
/p/5 ✅ 返回 {"article_id": 5}
/p/1 ❌ 422 错误(不满足 ge=2)
/p/abc ❌ 422 错误(不是整数)

这就是 readme 说的"路由参数 /user/123 pydantic 约束一定是整数" ------你不需要写一行 if not isinstance(...),只靠注解。

更妙的是"自动类型转换"

笔记第二行写的"自动做类型转换"------URL 里的所有东西本质上都是字符串/p/2 里的 2 不是数字,是字符 "2"

FastAPI 会按注解自动转换:

javascript 复制代码
URL: /p/2   →   字符串 "2"   →   按 Annotated[int, ...] 转成   数字 2
                                 ↑ 校验也在这里发生

转换和校验是一起做的 ------能转成 int 且满足 ge=2,才放行。这就是"安检"的完整含义。

Query:查询参数的规则

python 复制代码
# http://0.0.0.0:8000/article/list?page=2&size=5
@app.get("/article/list")
async def article_list(page: Annotated[int, Query(ge=1)], size: int = 10):
    return {"page": page, "size": size}

对比一下两个参数:

参数 写法 行为
page Annotated[int, Query(ge=1)] 必填,且 >= 1
size int = 10 可选,默认 10

?page=2&size=5 就是查询字符串的形态。和 Path 的区别只是"参数从哪来"------路径里还是 URL 问号后面。

课堂还留了注释掉的简化版本,对比很清楚:

python 复制代码
# @app.get("/article/list")
# async def article_list(page:int = 1, size:int = 10):

简化版能跑(有默认值),但没有任何校验 ------?page=0?page=-5 都会被放行。加上 Query(ge=1) 之后,非法值直接被拦在门外。


五、BaseModel:给请求体也过一遍安检

路径参数和查询参数管完了,还有请求体------POST/PUT 里那坨 JSON。

main.py 第四份快照演示了完整的做法:

python 复制代码
from pydantic import BaseModel, Field

app = FastAPI()

class Item(BaseModel):
    name: str
    description: str | None = None
    price: float
    tax: float | None = None

@app.put('/items/{item_id}')
async def update_item(item_id: int, item: Item):
    result = {"item_id": item_id, **item.model_dump()}
    return result

用类型声明定义"数据长啥样"

Item 这个类就是一个"数据模板":

字段 类型 说明
name str 必填
description `str None = None`
price float 必填
tax `float None = None`

注释里说:

"基类 类型检测的功能"

一句话概括 BaseModel 的定位------继承它,就获得了校验能力。

校验是自动的:

请求体 结果
{"name": "书", "price": 9.9} ✅ description 和 tax 用默认值 None
{"name": "书"} ❌ 缺 price,422
{"name": "书", "price": "abc"} ❌ price 转不成 float,422

model_dump + ** 展开

python 复制代码
result = {"item_id": item_id, **item.model_dump()}

这两步配合得很漂亮:

代码 做了什么
item.model_dump() 把 Item 实例转成普通字典 {"name": ..., "price": ...}
** 把字典"展开"合并进外层字典

最终得到一个扁平字典:

json 复制代码
{
  "item_id": 5,
  "name": "书",
  "description": null,
  "price": 9.9,
  "tax": null
}

注释写得很简洁:

"Item 实例 转成简单的字典 / ** 展开字典"

** 是 Python 的字典展开运算符 ------前面讲 Harness 那节课见过它的"关键词参数"用法(kw["command"] 展开成 command=...),这里是"合并字典"的用法。同一个符号,两种场景。

Field:更细的校验规则

python 复制代码
class LoginIn(BaseModel):
    # ... 必填选项
    email: Annotated[str, Field(..., description="邮箱地址")]
    password: Annotated[str, Field(..., min_length=6, max_length=20, description="密码")]

Field 是"字段级校验器":

参数 含义
... 必填(这是 Python 的 Ellipsis,在 Pydantic 里表示"必须传")
min_length / max_length 长度区间
description 描述(会显示在自动生成的文档里)

那个 ... 值得单独说------注释明确标了:

"... 必填选项"

Field(...) 里的 ... 不是"省略",而是**"必填"的标记**。跟 Field(default=False)(有默认值、可选)形成对比。

注册接口的密码校验就靠这一行------6 到 20 位,短了长了都打回。

校验失败会怎样?

这点特别值得一提:校验失败不是 500 错误,而是 422。

500 是"服务器内部错误"(你代码炸了),422 是"请求实体无法处理"(你传的数据不合法 )。FastAPI 自动区分了这两种情况------校验不通过时返回 422 加一段清晰的错误说明,告诉你哪个字段、什么原因。不用你写一行错误处理代码。


六、response_model:出厂也要复检

main.py 的第五份快照------Todo 接口------展示了另一个能力:

python 复制代码
from typing import Optional, List, Annotated

app = FastAPI(title="Todo增删改查")
todos = []
next_id = 1

class Todo(BaseModel):
    title: Annotated[str, Field(min_length=1, max_length=100,
        description="待办事项标题, 1-100个字符之间")]
    done: Annotated[Optional[bool], Field(default=False, description="是否完成")]

# 代码就是注释
@app.get("/todos", summary="查询所有待办", response_model=List[Todo])
def get_all_todos():
    return todos

@app.get("/todos/{todo_id}", summary="查询单个待办", response_model=Todo)
def get_todo(todo_id: Annotated[int, Field(..., gt=0,
    description="Todo ID,必须大于0")]):
    for item in todos:
        if item.id == todo_id:
            return item
    return {"msg":"找不到这条todo"}

response_model:管出口

前面讲的全是"管入口"(请求参数校验)。response_model出口------

写法 校验方向
item: Item 进来的数据必须是 Item 形状
response_model=List[Todo] 出去的数据也必须是 Todo 列表

这一层校验的价值在于:防止你意外泄露不该返回的字段。 比如数据库里 Todo 表还有 user_idinternal_note 这类内部字段,如果你直接返回原始对象,它们就跟着出去了。声明了 response_model=Todo,FastAPI 会按 Todo 的定义过滤------只有声明过的字段才会出现在响应里。

summary:给文档加标题

python 复制代码
@app.get("/todos", summary="查询所有待办", response_model=List[Todo])

summary 参数写的是接口的中文名------它会显示在自动生成的 Swagger 文档里。加上前面 Field 里的 description整个 API 文档几乎不用手写。

这呼应了 readme 说的"自动生成交互式接口文档(前后端 api 约定,swagger,自动生成)"。

那份 Todo 文件里还有一句特别精辟的注释:

python 复制代码
# 代码就是注释

title: Annotated[str, Field(min_length=1, max_length=100, description="待办事项标题, 1-100个字符之间")]------

这一行代码同时是:类型声明、校验规则、文档说明。 三件事用一套注解表达完,这就是 FastAPI 的设计哲学。

gt=0:路径参数也能用 Field

python 复制代码
todo_id: Annotated[int, Field(..., gt=0, description="Todo ID,必须大于0")]

这里用的是 Field 而不是 Path------因为 Field 也可以用在路径参数上做约束(gt=0 意思是大于 0)。约束规则是通用的,只是来源不同。


七、接上大模型:FastAPI 遇上 LangChain

前面都是"空接口"------真正的重头戏在第二份 main.py把 LLM 接进 API。

python 复制代码
from dotenv import load_dotenv
import os
from fastapi import FastAPI
from pydantic import BaseModel
from langchain_openai import ChatOpenAI

load_dotenv()

app = FastAPI(title="LangChain & FastAPI")

llm = ChatOpenAI(
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url=os.getenv("DEEPSEEK_BASE_URL"),
    model=os.getenv("DEEPSEEK_MODEL"),
    temperature=0.7,
)

class ChatReq(BaseModel):
    prompt: str

# 校验请求体的类型
@app.post("/chat")
async def chat(req: ChatReq):
    resp = llm.invoke(req.prompt)
    return {
        "input": req.prompt,
        "output": resp.content,
    }

if __name__ == "__main__":
    import uvicorn
    uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

三块积木拼起来

这份文件把几节课的东西拼到了一起:

积木 来自哪节课 在代码里
ChatOpenAI LangChain 课程 llm = ChatOpenAI(...)
环境变量管理 部署课(.env load_dotenv() + os.getenv
请求体校验 今天 class ChatReq(BaseModel)

细节解读

1. 模型配置全部走环境变量

python 复制代码
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url=os.getenv("DEEPSEEK_BASE_URL"),
model=os.getenv("DEEPSEEK_MODEL"),

API Key 硬编码进代码是大忌 (会跟着 Git 提交出去)。走 .env 是标准做法------接的是 DeepSeek,但用的是 OpenAI 兼容协议,所以还是 ChatOpenAI 这个类。

2. 请求体的类型约束

python 复制代码
class ChatReq(BaseModel):
    prompt: str

注释说:

"校验请求体的类型"

调用方必须传 {"prompt": "..."}。少传或类型不对,422 打回------在调用大模型之前就把非法请求挡掉了。 这点很重要:LLM 调用是花钱的,别让垃圾请求浪费 token。

3. 同步调用 LLM

python 复制代码
resp = llm.invoke(req.prompt)

用了 invoke(同步)而不是 ainvoke(异步)。虽然函数声明成了 async def,但里面是同步调用------这会阻塞事件循环

按 readme 强调的"异步无阻塞 高并发",更严谨的写法应该是 await llm.ainvoke(req.prompt)。不过作为教学示例,先跑通是最重要的。这也是一个值得留意的优化点------生产环境里 LLM 调用往往要几秒,同步阻塞会让并发能力大打折扣。

4. input/output 都返回

python 复制代码
return {
    "input": req.prompt,
    "output": resp.content,
}

把输入和输出一起返回------这是个很实用的设计,方便前端做对话记录,也方便调试时核对"问的什么、答的什么"。

5. 双启动方式

python 复制代码
if __name__ == "__main__":
    import uvicorn
    uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

前面 readme 教的是命令行 python -m uvicorn main:app --reload --port 8080,这里换成了代码里启动 ------python main.py 直接跑。两种方式等价,看习惯。注意 host="0.0.0.0"------监听所有网卡,这样同局域网的手机也能访问(只写 127.0.0.1 就只有本机能访问)。


八、一份完整的 FastAPI 能力清单

把 5 份文件的知识点汇总:

能力 写法 文件
定义应用 app = FastAPI(title="...") 全部
路径路由 @app.get("/hello/{name}") 1
异步处理 async def 全部
路径参数校验 Annotated[int, Path(ge=2)] 3
查询参数校验 Annotated[int, Query(ge=1)] 3
请求体模型 class Item(BaseModel) 4、5
字段级规则 Field(..., min_length=6, max_length=20) 4、5
响应模型 response_model=List[Todo] 5
文档标题 summary="查询所有待办" 5
接入 LLM llm.invoke(req.prompt) 2
环境变量 load_dotenv() + os.getenv 2

从"Hello World"到"接上大模型",一共就这么点东西。 这就是 readme 说的"写少量代码"------不是营销话术,注解本身就是代码。


九、为什么后端选 FastAPI 来做 AI 服务

最后回到选型。readme 那句话值得再读一遍:

"专门用于后端 API,适合结合 langchain/langgraph 开发后端服务。"

为什么 AI 服务特别适合 FastAPI?我想了三个理由:

第一,AI 服务的输入输出天然是结构化的。

你要给 LLM 传 prompt、传参数、传历史消息,还要收结构化结果。FastAPI 的 Pydantic 模型刚好是干这个的------前面学的结构化输出(Zod Schema 约束),在 Python 端就是 Pydantic。

第二,AI 调用是 I/O 密集型的。

调一次 LLM 要等好几秒,这段时间 CPU 完全空闲。异步框架能在这段时间服务其他请求------这正是 readme 强调"异步无阻塞 高并发"的原因。

第三,自动文档省掉了前后端联调的扯皮。

AI 应用的接口经常变(加个参数、改个返回结构),手写文档永远滞后。FastAPI 的 Swagger 直接从代码生成------改完代码刷新页面,文档就是新的。


PS:这节课最大的感受是------FastAPI 把"校验"这件事从"业务代码"变成了"类型注解"。以前写接口,要手动 if-else 检查每个参数;现在只写 Annotated[int, Path(ge=2)],剩下的交给框架。代码即校验,代码即文档。下篇我们换个话题------从"接口怎么写"到"数据怎么搜",聊聊 MySQL 扛不住的全文检索。

相关推荐
qq_426003962 小时前
启动playwright录制codegen生成自动化测试脚本
python·自动化
虎头金猫2 小时前
4K 视频总卡在公网带宽?用 N1 + OpenList 把网盘播放链路重新理顺
运维·服务器·网络·python·容器·beautifulsoup·pandas
长沙三为智能科技2 小时前
家政小程序开发从0到上线:五阶段交付流程与验收清单
python
伞伞悦读3 小时前
【第38期】Python 模块与包详解:import、from、模块搜索路径、包结构和 __init__
开发语言·python
只睡四小时3 小时前
Canvas 弹道联机实战:700 行 + 固定时间步长
python·websocket·html5·游戏开发·canvas
奇思妙想聪明勤奋的小羊4 小时前
DeepAgents第5章:子Agent 与上下文隔离—让 Agent学会委派
人工智能·python·学习·语言模型
lpfasd1234 小时前
2026年第38周GitHub趋势周报
python·科技·github
IZero074 小时前
Jev 与 Laya
python·语言模型