写在前面:前几节课我们写了 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_id、internal_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 扛不住的全文检索。