一、先回答:为什么 AI 应用的后端普遍选 FastAPI
先看一个拆解,这是整篇文章的地基:
ini
FastAPI = Pydantic + Starlette
把等号右边拆开看:
| 组件 | 负责什么 | 类比 |
|---|---|---|
| Starlette | Web 底层:接收 HTTP、路由匹配、返回响应、异步能力 | 相当于 Express / Koa 那一层 |
| Pydantic | 数据处理:类型校验、类型转换、序列化 | 相当于 zod |
这个组合解释了 FastAPI 的两个特点。
第一个是"写得少"。 你只需要声明类型,校验、转换、错误响应全都自动生成:
python
@app.get("/p/{article_id}")
async def article_detail(article_id: int):
return { "article_id": article_id }
article_id: int 这一句声明,就换来了三件事------路由参数自动转成 int、传 abc 自动返回 422 错误、接口文档里自动标注参数类型。传统框架里这三件事是三个地方的代码。
第二个,也是 AI 应用特别看重的一点:自动生成交互式文档。
启动服务后访问 /docs,你会拿到一份能直接点的 Swagger UI------每个接口的入参、出参、请求体结构、必填项全都列出来,还能当场试。
这件事的价值在协作时才显出来:
前后端不需要再单独开会对 API 契约,文档就是代码本身。
在 AI 应用里这点尤其重要,因为接口的返回结构经常变------今天返回纯文本,明天要带上 token 用量,后天要支持流式。接口一改文档自动跟着改,省掉了"文档和代码对不上"这个经典坑。
再补三条实际的:
- 异步无阻塞。 AI 接口的特点是等待------等模型返回,动不动几秒十几秒。同步框架下这段时间线程就占死了,并发一上来直接排队。FastAPI 是 ASGI 原生异步,等模型的时候可以把 CPU 让出去。
- 性能比肩 Go / Node。 出在 Starlette 的异步内核上,不是靠堆机器。
- 和 LangChain / LangGraph 是同一套语言。 同进程直接调用,不用跨语言再搭一层 RPC。
环境安装
bash
pip install "fastapi[standard]"
pip install "uvicorn[standard]"
这里两个包要分清楚,它们是两个角色:
- FastAPI 是框架------负责写代码、定义路由的那层
- uvicorn 是 ASGI 服务器------负责把框架跑起来、监听端口、处理网络的那层
类比一下:FastAPI 是应用,uvicorn 是那个把应用托起来的容器。所以你有两种启动方式:
bash
# 方式一:命令行启动
python -m uvicorn main:app --reload --port 8080
# 方式二:写在代码里(下面 5 个 demo 用的都是这种)
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
--reload 是开发时用的热重载:改完代码存盘,服务自己重启,不用手动 Ctrl+C。
二、demo 01:先让一个接口跑起来
fastapi-demo/01/main.py:
python
from fastapi import FastAPI
# 初始化 FastAPI 应用
app = FastAPI()
# 装饰器模式
@app.get("/")
async def root():
return { "message": "你好,文强" }
# pydantic name -> str
@app.get("/hello/{name}")
async def say_hello(name: str):
return { "message": f"你好 {name}"}
十几行,但已经把 FastAPI 的骨架讲完了。
app = FastAPI() 是应用实例,后面所有路由都挂在它身上。
@app.get("/") 是装饰器模式------它做的事是「把下面这个函数注册成一条路由」。函数本身还是普通函数,装饰器负责把它接进路由表。这和 Flask、Express 的思路是一样的:
python
@app.get("/") # 注册为 GET / 的处理器
async def root(): ...
{name} 是路径参数,写在路径里的占位符。函数参数名对上就能拿到值:
bash
GET /hello/文强 → { "message": "你好 文强" }
注意注释里那句 # pydantic name -> str------这是全文最重要的伏笔。name: str 这个注解不是给人看的,Pydantic 会真的拿它做校验。 demo 01 里还看不出威力,到 demo 03、04 就明白了。
最后是 async def。 这里写成同步的 def 也能跑,但先养成写 async 的习惯------因为后面要调模型,那是个需要等待的 IO 操作。注释里那句「异步无阻塞,高并发」,说的就是这件事。
启动后别急着写前端,先打开两个地址看看:
http://localhost:8000/docs------ Swagger UI,可以点着试http://localhost:8000/redoc------ ReDoc,更适合阅读的文档
这就是上面说的「自动生成交互式接口文档」,你还一行文档都没写。
三、demo 02:接上 DeepSeek,第一个 AI 接口
fastapi-demo/02/main.py:
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,
"replay": resp.content
}
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000,
reload=True)
这个文件里有四个关键点。
3.1 为什么 langchain_openai 能调 DeepSeek
先看这一行------它没写 langchain_deepseek,用的是 langchain_openai:
python
from langchain_openai import ChatOpenAI
这不是写错了。DeepSeek 的服务端兼容 OpenAI 的接口协议 ------同样的路径、同样的请求体格式、同样的返回结构。所以任何 OpenAI 的客户端,只要把 base_url 指过去,就能直接调 DeepSeek:
python
llm = ChatOpenAI(
api_key = os.getenv("DEEPSEEK_API_KEY"),
base_url = os.getenv("DEEPSEEK_BASE_URL"), # ← 这一行是唯一的开关
model = os.getenv("DEEPSEEK_MODEL"),
temperature=0.7
)
这是当前模型生态的一个重要事实:协议在收敛。 一大批国产模型都提供了 OpenAI 兼容端点,好处是你换模型供应商时不用换 SDK、不用改业务代码,改个 base_url 就行。LangChain 的封装价值也在这里------上层写法完全一致。
三个配置项走环境变量,配在 .env 里:
ini
DEEPSEEK_API_KEY=sk-xxxxxxxx
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
DEEPSEEK_MODEL=deepseek-chat
load_dotenv() 负责把 .env 读进 os.environ,os.getenv 再取出来。
⚠️ 一条底线:key 永远不进代码、不进 git。
.env必须写进.gitignore。密钥一旦推到公开仓库,会被人扫到并在几分钟内刷爆------这不是危言耸听,是每周都在发生的事。
3.2 BaseModel:第一次出现"请求体"
python
class ChatReq(BaseModel):
prompt: str
前面 demo 01 的参数是写在 URL 路径里的。但这里不一样------prompt 可能是一大段话,塞进 URL 既不安全(长度限制、特殊字符)也不合适(URL 会被日志记录)。
所以改用 POST + 请求体:
python
@app.post("/chat")
async def chat(req: ChatReq):
这是 AI 接口和普通接口的一个分水岭:几乎所有 AI 接口都是 POST。 因为:
| GET | POST | |
|---|---|---|
| 参数位置 | URL query / path | 请求体 body |
| 适合传 | 少量、简单、可公开的参数 | 大段文本、复杂结构 |
| 会被缓存/记日志 | 是 | 否 |
| prompt 放这里 | ❌ 长度受限、会进访问日志 | ✅ |
req: ChatReq 这个注解的意思是:FastAPI 从请求体里解析 JSON,用 ChatReq 校验它,然后给你一个 ChatReq 实例 。前端传 {"prompt": "..."} 是合法的,传 {"foo": 1} 会直接 422 被打回------你的函数体根本不会被执行。
这就是 Pydantic 的价值:校验发生在业务代码之前 。你不需要在函数里写 if "prompt" not in body。
3.3 一个真实存在的坑:invoke() 会阻塞事件循环
python
@app.post("/chat")
async def chat(req: ChatReq):
resp = llm.invoke(req.prompt) # ← 注意这里
invoke 是同步 方法。在 async def 里调用一个同步的、要等十几秒的 IO 操作,会把整个事件循环卡住------这一瞬间所有其他请求都得排队。
单机自测完全感觉不到,一旦上并发就是灾难。正确做法是用异步版本:
python
resp = await llm.ainvoke(req.prompt)
LangChain 的每个 invoke 都有对应的 ainvoke。改了这一个词,demo 02 才算真的"异步无阻塞"。
3.4 小瑕疵:replay 是 reply 的笔误
python
return {
"input": req.prompt,
"replay": resp.content # ← 应为 reply
}
不影响运行,但前端联调时会对着文档找 replay 这个字段,容易懵一下。接口字段名一旦定下来,改起来就是破坏性变更------所以起名时多看一眼。
四、demo 03:Annotated,把校验规则写进类型里
demo 01 里 name: str 只说了"是字符串"。但如果业务要求"文章 ID 必须 ≥ 2",或者"分页至少第 1 页",怎么办?
最朴素的写法是手动判断:
python
@app.get("/p/{article_id}")
async def article_detail(article_id: int):
if article_id < 2:
raise HTTPException(status_code=400, detail="article_id 必须 >= 2")
return { "article_id": article_id }
能跑,但校验逻辑和业务逻辑混在一起了 ,而且每加一条规则就多一个 if。
FastAPI 的答案是 Annotated------在类型注解上再挂一层元数据:
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 }
@app.get("/article/list")
async def article_list(page: Annotated[int, Query(ge=1)] = 1,
size: Annotated[int, Query(ge=10)] = 10):
return { "page": page, "size": size }
拆开读 Annotated[int, Path(ge=2)]:
int------ 基础类型,Pydantic 据此做类型转换Path(ge=2)------ 附加规则:ge= greater or equal,大于等于 2- 整体读作:"一个整数,且必须 ≥ 2"
Path 和 Query 的区别只在参数从哪来:
| 来源 | 例子 | |
|---|---|---|
Path |
URL 路径里的占位符 | /p/{article_id} |
Query |
URL 问号后面的参数 | /article/list?page=2&size=5 |
规则本身是通用的,ge / gt / le / lt 都能用。
现在对比一下:原来那个 if 加 raise,被压缩成了一个注解。 而且它带来的不只是少写几行:
- 校验失败自动返回 422,错误信息里带上是哪个字段、违反了什么规则
- 接口文档里会自动标注这个约束 ,前端点开
/docs就知道page最小是 1
readme.md 里那两句话
课程 03/readme.md 只有两行,但都是要点:
arduino
Annotated 丰富类型注解,
Annotated[int, Path(ge=2)]
自动做类型转换
第一句在说 Annotated 的定位:它不改变类型本身,只是往上叠元数据 。Annotated[int, ...] 对 Python 来说仍然是 int,Path(ge=2) 是给 FastAPI 和 Pydantic 看的。
第二句"自动做类型转换"值得单独说。URL 里的一切本质上都是字符串 ------/article/list?page=2 传过来的 page 是 "2",不是 2。FastAPI 根据 int 注解自动转,转不动就报错。所以:
ini
?page=2 → 200,page = 2(int)
?page=abc → 422,自动报错,函数不执行
没有注解的话,你拿到的是字符串 "2","2" > 1 这种比较会静默出错------这是 Python 动态类型最经典的坑之一。
五、demo 04:BaseModel + Field,请求体的完整校验
demo 03 讲的是路径和查询参数 ,demo 04 转向请求体。
python
from fastapi import FastAPI
# BaseModel 校验能力的基类
# Field 模型字段添加额外的校验规则,最大值,最小值
from pydantic import BaseModel, Field
from typing import Annotated
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):
# Item 实例 转成简单的字典
# ** 展开字典
result = {"item_id": item_id, **item.model_dump()}
return result
class LoginIn(BaseModel):
# ... 必填选项
email: Annotated[str, Field(..., description="邮箱地址")]
password: Annotated[str, Field(..., min_length=6, max_length=20,
description="密码")]
@app.post('/login')
async def login(data: LoginIn):
email = data.email
password = data.password
return {"email": email, "password": password}
5.1 BaseModel 就是请求体的 schema
Item 这个类声明了:请求体必须 有 name(字符串)和 price(浮点数),可选 有 description 和 tax。
可选是怎么表达的?看这两行:
python
description: str | None = None
tax: float | None = None
str | None 读作"字符串或空",= None 给了默认值------所以不传也不会报错 。反过来,name: str 没有默认值,就是必填,不传直接 422。
这个「有没有默认值 = 必填还是选填」的规则,是 Pydantic 里最需要记住的一条。
5.2 model_dump() 和 ** 展开
python
result = {"item_id": item_id, **item.model_dump()}
两件事:
model_dump()把 Pydantic 实例转成普通字典 (Pydantic v2 的写法,v1 里叫.dict(),现在已经废弃了)**是字典展开 ,把{"name": ..., "price": ...}摊开合并进外层字典
等价于手写:
python
result = {
"item_id": item_id,
"name": item.name,
"description": item.description,
"price": item.price,
"tax": item.tax
}
字段一多,model_dump() 的优势就出来了------加字段不用改这里。
这里还顺便展示了一个 FastAPI 的实用能力:同一个接口可以同时接收路径参数和请求体 。item_id 从路径来,item 从 body 来,FastAPI 按类型注解自动分辨该去哪取。
5.3 Field 给字段加规则
再看 LoginIn:
python
email: Annotated[str, Field(..., description="邮箱地址")]
password: Annotated[str, Field(..., min_length=6, max_length=20)]
Annotated[int, Path(ge=2)] 和 Annotated[str, Field(min_length=6)] 是同一个心智模型 ------只不过 Path/Query 管的是 URL 参数,Field 管的是模型字段。
第一个 ...(Ellipsis)是 Pydantic 的**"必填"标记**。前面说"有默认值就是选填",那想明确表达必填 怎么办?就是这个 ...。在这两个字段上它其实是冗余的(本来就没默认值),但写上更清楚。
description 不是给校验用的,是给接口文档用的 ------它出现在 /docs 的字段说明里。这就是"代码即注释":
注释里那句「代码就是注释」,说的正是这件事。
5.4 一个不该在真实项目里出现的返回
python
@app.post('/login')
async def login(data: LoginIn):
return {"email": email, "password": password} # ← 密码原样返回了
demo 里为了演示"取到了值"这么写没问题,但真实项目里这是严重的安全问题:
- 密码绝不能出现在响应体里------它会进浏览器、进日志、进各种中间件
- 密码也绝不能明文落库------要存哈希(bcrypt / argon2),而且不可逆
- 前端提交密码时必须走 HTTPS
这就是上面说的:Pydantic 能保证"password 是个 6-20 位的字符串",但保证不了"它不会被泄露"。 类型校验的边界到此为止。
六、demo 05:把前面拼起来
最后一个 demo 开始做一个 Todo 的增删改查:
python
from fastapi import FastAPI
from pydantic import BaseModel, Field
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="查询单个待办")
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"}
几个新东西:
summary=给接口起个中文名 ,直接显示在/docs上,比函数名可读response_model=List[Todo]声明返回结构,文档里会展开成数组Todo只有title和done,没有id------ 因为 id 是服务端生成的,不该由前端传
6.1 这里有个真实的缩进 bug
python
for item in todos:
if item["id"] == todo_id:
return item
return {"msg": "找不到这条todo"} # ← 缩进在 for 里面
最后那个 return 和 if 是同一层 ,都在 for 循环体里。这意味着:第一次循环没匹配上,就直接返回"找不到"了------后面还有多少条都不会再看。
正确写法是把 return 挪出循环:
python
for item in todos:
if item["id"] == todo_id:
return item
return {"msg": "找不到这条todo"} # ← 循环全部走完才返回
这个 bug 特别值得记一句:
Pydantic 能拦住"
todo_id是负数"(gt=0生效了),但它拦不住"循环写错了"。
这正是类型校验的能力边界------它能保证数据形状正确,保证不了业务逻辑正确。demo 05 也顺带提醒了一件事:缩进在 Python 里是语法,不是风格,值得多看一眼。
6.2 风格上的一个小提醒
python
todo_id: Annotated[int, Field(..., gt=0, description="...")]
这里用的是 Field。但 todo_id 是路径参数 ,按 demo 03 的规律,这里更地道的是 Path:
python
todo_id: Annotated[int, Path(..., gt=0, description="...")]
Field 是给模型字段 用的,Path / Query 是给 URL 参数 用的。功能上多数情况能跑通,但语义上分开写,读代码的人一眼就知道这个值从哪来。
6.3 这个 demo 还没写完
注意最上面这两行:
python
todos = []
next_id = 1
todos 从头到尾没被 append 过,next_id 也没被用过。 也就是说:
- 没有
POST /todos创建接口 - 没有
PUT/DELETE - 所以
todos永远是空的,两个查询接口只会返回[]或"找不到"
这其实是最好的练习:把上面 5 个 demo 学到的东西全用一遍------
python
@app.post("/todos", summary="创建待办")
def create_todo(todo: Todo):
global next_id
item = {"id": next_id, **todo.model_dump()}
todos.append(item)
next_id += 1
return item
写这一小段,你会同时用上:BaseModel 校验请求体(demo 02)、model_dump() 展开(demo 04)、response_model 声明返回(demo 05)。
七、回头看这 5 个 demo 的一条线
把每一步"新引入的东西"和"它解决的问题"列出来:
| demo | 引入的能力 | 解决的问题 |
|---|---|---|
| 01 | @app.get 路由 + 路径参数 + async def |
让一个接口跑起来 |
| 02 | BaseModel 请求体 + .env + LangChain 接 DeepSeek |
让接口能调模型 |
| 03 | Annotated + Path / Query |
URL 参数的校验与类型转换 |
| 04 | Field 字段规则 + model_dump() |
请求体的完整校验 |
| 05 | summary / response_model + 串起来 |
完整的接口形态 |
一条线读下来,其实只讲了一件事:
在 FastAPI 里,类型注解就是校验规则,也是接口文档。
article_id: int、Annotated[int, Path(ge=2)]、prompt: str ------ 写法不同,本质是同一件事的三个粒度。你声明类型,剩下的转换、校验、报错、文档全都是自动的。
这也是为什么 AI 应用的后端特别适合它:
- 接口要等模型 → ASGI 异步,等待时不占线程
- AI 接口几乎都是 POST + 复杂嵌套 body →
BaseModel天然表达嵌套结构 - 返回结构老在变 → 文档跟着代码自动更新,前后端不脱节
- 和 LangChain 同进程 → 不用跨语言再搭一层
最后记三句话
第一句:async def 里不要调同步阻塞方法。 demo 02 的 llm.invoke() 就是反例,换成 await llm.ainvoke() 才是对的。等待类操作的一切性能问题,根源几乎都在这里。
第二句:类型校验的边界,是数据形状,不是业务逻辑。 demo 05 那个缩进 bug 和 demo 04 那个明文回显密码,Pydantic 都拦不住。别把校验通过当成安全。
第三句:密钥永远走环境变量。 load_dotenv() + .env + .gitignore,这三件套是底线。
本文 5 个 demo 都在
backend/py/fastapi-demo/下(01/到05/),每个目录一个main.py,配好.env之后python main.py直接能跑。02 需要langchain-openai、python-dotenv;03-05 装完fastapi[standard]就行。这 5 个 demo 停在了 Todo 的查询上,增删改查留给读者补完------补完那个
POST /todos,FastAPI 的基础就算过关了。下一步是 text2sql:把自然语言转成 SQL 再执行。那里面要处理的问题完全是另一类------怎么让模型的输出可靠到可以直接执行。有问题欢迎评论区交流 👋