FastAPI
先搭建一个最小的FaastAPI接口
from fastapi import FastAPI
# 创建 FastAPI 应用实例
app = FastAPI()
# 声明一个 GET 接口
@app.get("/health")
async def health_check():
# 返回普通 dict 时,FastAPI 会自动把它序列化成 JSON 响应
return {"status": "ok"}
# 返回字典,FastAPI 自动转成 JSON
定义请求体QuerySchema
from pydantic import BaseModel
class QuerySchema(BaseModel):
query: str
创建查询路由query_router
本章先用 fake_streamer() 模拟流式输出,不接入真实问数智能体:
import asyncio
from fastapi import APIRouter
from starlette.responses import StreamingResponse
from app.api.schemas.query_schema import QuerySchema
# APIRouter 用来组织一组接口。虽然当前只有一个查询接口,也先放进 query_router,后续接口变多时不至于全部挤在 main.py 里
query_router = APIRouter()
# fake_streamer() 是一个异步生成器。它不是一次性 return 一个完整结果,而是每隔 1 秒 yield 一段内容。
# 下一章接入真实智能体时,会把它替换成 QueryService.query(...)。
async def fake_streamer():
# 先用 10 个 step 模拟智能体执行过程,方便验证流式响应是否可用
for i in range(10):
# 暂停 1 秒只是为了观察流式效果;真实项目中这里会被节点执行耗时代替
await asyncio.sleep(1)
# SSE 消息以 data: 开头,并以两个换行符结尾
# 客户端会把每次 yield 的内容识别为一条独立事件
yield f"data: step:{i}\n\n"
@query_router.get("/api/query")
async def query_handler(query: QuerySchema):
# query 参数会由 FastAPI 根据请求体自动解析成 QuerySchema 对象
# StreamingResponse 会把 fake_streamer() 每次 yield 的内容持续写给客户端
return StreamingResponse(fake_streamer(), media_type="text/event-stream")
在main.py中挂载路由
from fastapi import FastAPI
from app.api.routers.query_router import query_router
# 创建后端应用实例
app = FastAPI()
# 把 query_router.py 中定义的 /api/query 注册到应用上
# 如果没有这一行,/docs 页面里看不到该接口,客户端也访问不到它
app.include_router(query_router)
为什么查询接口要用流式响应
普通响应更像 return,它的特点是:必须等所有逻辑执行完,客户端才能拿到响应。流式响应更像 yield,每 yield 一次,后端就可以向客户端写出一段内容。套到问数接口上,就是
客户端提交问题
-> 后端返回:抽取关键词
-> 后端返回:召回字段信息
-> 后端返回:生成 SQL
-> 后端返回:执行 SQL
-> 后端返回:最终结果
所以,流式响应解决的不是“能不能返回结果”的问题,而是“长流程执行过程中能不能持续给用户反馈”的问题。
FastAPI如何持续写出数据
FastAPI 中实现流式返回,核心就是 StreamingResponse。官方文档中说明,StreamingResponse 可以接收生成器、异步生成器或其他可迭代对象,并把其中产出的内容持续写入响应。
return StreamingResponse(
# 传入异步生成器,StreamingResponse 会不断消费其中 yield 出来的内容
fake_streamer(),
# 声明为 SSE 事件流,客户端才会按流式事件来处理响应
media_type="text/event-stream",
)
注意三点:
1. StreamingResponse接收的是生成器,fake_streamer() 内部使用 yield:
async def fake_streamer():
for i in range(10):
# 放慢输出速度,方便在浏览器 Network 或 Apifox 中观察逐条返回
await asyncio.sleep(1)
# 每 yield 一次,后端就向客户端写出一条 SSE 消息
yield f"data: step:{i}\n\n"
return 会结束函数,yield 会暂停函数,把当前内容先交出去,下一次还能继续执行。这个特性天然适合“边执行边输出”的问数工作流
2. sleep只是为了看清流式效果。 await asyncio.sleep(1)
3. media_type 要声明为text/event-stream. 它告诉客户端:这不是普通 JSON,也不是普通文本,而是一段 SSE 事件流。如果不声明这个类型,后端即使在持续写出数据,前端或接口测试工具也不一定会按事件流处理
SSE协议
SSE 全称是 Server-Sent Events,可以理解成“服务端向客户端持续发送事件”。它的交互方式是:客户端发起一次 HTTP 请求 -> 服务端保持连接 -> 服务端持续发送多条事件消息 -> 发送完成后关闭连接
1. sse的最小格式
data: 这里是要发送的内容。 注意一条消息后面要有一个空行,也就是两个换行符:\n\n
在 Python 中通常写成:yield "data: step:0\n\n",如果要发送 JSON,也可以写成:yield 'data: {"type": "progress", "step": "抽取关键词"}\n\n'
2. sse外层和json内层
后续会把流式消息设计成几类:
| 类型 | 用途 | 示例 |
|---|---|---|
progress |
返回节点执行进度 | 抽取关键词、生成SQL |
result |
返回最终查询结果 | SQL 查询出的数据 |
error |
返回异常信息 | SQL 执行失败、模型调用失败 |
也就是说,外层是 SSE:data: ...\n\n, 内层是项目自己的 JSON 协议:{ "type": "progress", "step": "抽取关键词", "status": "running" }
这层关系一定要分清:SSE 是传输格式,JSON 是业务内容
FastAPI三件套: lifespan + middleware + Depends
| 工程问题 | FastAPI 能力 | 本项目中的用途 |
|---|---|---|
| 应用启动和关闭时如何管理资源 | lifespan |
初始化和关闭外部客户端 |
| 每个请求前后如何统一执行逻辑 | middleware | 生成 request_id,辅助日志追踪 |
| 路由函数依赖的对象如何创建 | Depends |
组装 QueryService、Repository、Session |
1. lifespan 应用级资源什么时候初始化
FastAPI 的生命周期事件用于在应用开始接收请求前执行初始化逻辑,并在应用关闭时执行清理逻辑。
为什么本项目需要它?因为问数接口依赖很多外部资源。Embedding 客户端 + Qdrant 客户端 + Elasticsearch 客户端 + 元数据库 MySQL 连接 + 数仓 MySQL 连接。这些资源不应该每来一个请求就重新初始化一次。更合理的方式是:
应用启动时:-> 初始化客户端和连接能力
请求处理期间:-> 复用已经初始化好的客户端
应用关闭前:-> 统一释放连接
FastAPI 中的 lifespan 写法类似下面这样:
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
# 应用启动时初始化外部客户端,后续请求可以直接复用这些客户端
qdrant_client_manager.init()
embedding_client_manager.init()
es_client_manager.init()
meta_mysql_client_manager.init()
dw_mysql_client_manager.init()
# yield 处:FastAPI 应用进入运行状态,开始接收和处理请求
yield
# 应用关闭前释放外部连接,避免连接泄漏
await qdrant_client_manager.close()
await es_client_manager.close()
await meta_mysql_client_manager.close()
await dw_mysql_client_manager.close()
# 把生命周期函数交给 FastAPI,框架会在启动和关闭时自动调用
app = FastAPI(lifespan=lifespan)
yield 前:应用启动时执行 yield 后:应用关闭时执行
2. middleware: 所有请求都经过统一逻辑
一次请求大致会经过:客户端请求 -> 中间件前半段 -> 路由处理函数 -> 中间件后半段 -> 客户端响应
FastAPI 中定义 HTTP 中间件的基本写法是:
from fastapi import Request
@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
# call_next 之前:请求还没有进入具体路由,适合做鉴权、打 request_id 等统一处理
response = await call_next(request)
# call_next 之后:路由已经生成响应,适合补充响应头、记录耗时等
return response
这里有两个参数:
| 参数 | 含义 |
|---|---|
request |
当前请求对象,可以读取路径、方法、请求头等 |
call_next |
把请求继续交给后续路由处理函数 |
中间件适合放所有接口都需要的横切逻辑,例如:
- 统一鉴权;
- 统计请求耗时;
- 添加响应头;
- 记录访问日志;
- 为每个请求生成
request_id。
3. Depends 声明我需要什么
接口函数只声明自己需要什么对象,FastAPI 负责在请求到来时调用依赖函数,把对象创建好并传进来。
from typing import Annotated
from fastapi import Depends, FastAPI
app = FastAPI()
async def common_parameters(skip: int = 0, limit: int = 100):
# 依赖函数可以像普通函数一样接收参数、组织数据,并返回给接口使用
return {"skip": skip, "limit": limit}
@app.get("/items/")
async def read_items(
# Annotated[真实类型, Depends(依赖函数)]
# 表示 commons 的类型是 dict,获取方式是调用 common_parameters
commons: Annotated[dict, Depends(common_parameters)],
):
return commons
read_items() 没有手动调用 common_parameters(),它只是声明:我需要一个 commons,commons 的获取方式是 Depends(common_parameters)。请求进来时,FastAPI 会自动调用依赖函数。
依赖注入再往下
1. 子依赖
依赖可以继续依赖别的依赖。FastAPI 会自动解析这棵依赖树。更重要的是,同一个请求里,如果多个依赖共用同一个子依赖,FastAPI 会做请求级缓存,不会在一次请求中重复创建同一个依赖对象
2. 带yield的依赖项
数据库 Session 这类请求级资源,适合用带 yield 的依赖项:
async def get_meta_session():
# 每次请求需要数据库 Session 时,先从 session_factory 创建一个请求级 Session
async with meta_mysql_client_manager.session_factory() as meta_session:
# yield 把 Session 交给 Repository 使用;请求结束后会回到这里并退出 async with
yield meta_session
执行顺序为: 请求需要 meta_session -> 进入 async with,创建 session -> yield session 给 Repository 使用 -> 请求结束 -> 退出 async with,session 被关闭或归还
| 对比项 | lifespan | 带 yield 的依赖项 |
|---|---|---|
| 生命周期范围 | 整个应用 | 单次请求 |
| 典型资源 | 客户端、连接池、全局配置 | 数据库 Session、请求级临时资源 |
| 本项目例子 | 初始化 Qdrant、ES、MySQL manager | get_meta_session()、get_dw_session() |
lifespan 管应用级资源 yield 依赖项管请求级资源

浙公网安备 33010602011771号