FastAPI 从零上手:5 个 demo,从一条路由接到 DeepSeek

一、先回答:为什么 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 应用的后端特别适合它:

  1. 接口要等模型 → ASGI 异步,等待时不占线程
  2. AI 接口几乎都是 POST + 复杂嵌套 body → BaseModel 天然表达嵌套结构
  3. 返回结构老在变 → 文档跟着代码自动更新,前后端不脱节
  4. 和 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 再执行。那里面要处理的问题完全是另一类------怎么让模型的输出可靠到可以直接执行。有问题欢迎评论区交流 👋

相关推荐
蜗牛互联网1 小时前
Java Agent 工具调用的 allowlist、参数校验与调用预算
java·开发语言·人工智能·后端·oracle
用户813267933251 小时前
行情数据晚到几秒,会让量化策略失去优势吗?从信号时间到回测偏差
后端·github·api
九零HTTP1 小时前
一次 TCP 连接的一生:从三次握手到四次挥手
后端
ZOnePieceC1 小时前
消息队列之Kafka
后端
yunwei371 小时前
eBPF 入门实践教程十七:编写 eBPF 程序统计随机/顺序磁盘 I/O
linux·后端·性能优化
花间相见1 小时前
【计算基础|网络07】HTTPS(下):ECDHE 握手与优化
后端
QuantiCore_IO1 小时前
从请求风暴到可维护的数据管道:量化系统为什么需要批量接口?
后端·github·api
136096757231 小时前
.env 的三个必查项
后端
长安米粒贵1 小时前
接口超时了,为什么重试反而把系统打垮?聊聊超时预算的 4 个误区
后端
码上观网1 小时前
SONiC 整体框架:一条配置如何走到 ASIC
后端