15-API接口基础与FastAPI入门
15 - 电商问数:API 接口基础与 FastAPI 入门
本章课程目标:
- 理解为什么问数智能体最后要封装成 HTTP API。
- 搭建一个最小可测试的
/api/query接口。 - 理解普通响应、流式响应和 SSE 协议之间的关系。
- 理解 FastAPI 的三个工程基础:
lifespan、middleware、Depends。 - 为下一章接入真实
QueryService做好准备。
学习建议: 这一章先学 API 骨架,不急着接入真实问数工作流。重点看请求怎么进来、响应怎么出去、为什么要流式返回、SSE 每段数据如何约定,以及 FastAPI 的路由、依赖、生命周期分别管什么。把这些基础看清,下一章再接 QueryService 时就不会觉得文件关系散。
对应代码分支: 15-api-streaming-basics
官方文档参考:
- FastAPI 流式响应:https://fastapi.org.cn/advanced/custom-response/#streamingresponse
- SSE 协议讲解:https://www.ruanyifeng.com/blog/2017/05/server-sent_events.html
- FastAPI 生命周期事件:https://fastapi.org.cn/advanced/events
- FastAPI 中间件:https://fastapi.org.cn/tutorial/middleware
- FastAPI 依赖注入:https://fastapi.org.cn/tutorial/dependencies
第 14 章结束后,问数智能体的核心链路已经能在后端内部跑通,但是,到这一步它还不是一个真正的应用。因为现在调用方式更像内部调试:开发者在命令行里运行图,观察日志和输出。真正交付给前端或其他系统时,需要把这条能力封装成一个 HTTP API。
本章只做第一步:先把 API 的基础形态和流式协议讲清楚。真正接入 QueryService 会放到下一章。
1、查询接口要解决什么问题
最终我们需要一个查询接口,接收前端提交的自然语言问题:
1 | { |
后端收到问题后,要调用前面已经搭好的 LangGraph 问数智能体,并把执行过程持续返回给前端。
这个接口至少要满足四个要求:
| 要求 | 说明 |
|---|---|
| 接收用户问题 | 前端通过请求体传入 query |
| 执行问数工作流 | 后端调用 graph.astream(...) |
| 实时返回进度 | 每执行到关键节点,就把进度推给前端 |
| 返回最终结果 | SQL 执行完成后,把查询结果返回给前端 |
这里最关键的是“实时返回进度”。问数智能体不是普通 CRUD 接口,它中间会经过多个步骤。如果后端等所有节点都执行完再一次性返回,用户只能看到页面一直转圈,不知道系统是在正常执行、卡住了,还是已经失败了。
更好的体验是:
1 | 正在抽取关键词... |
这就是本章要引出的第一个核心概念:流式响应。
2、先搭一个最小 FastAPI 接口
FastAPI 是一个用于构建 API 服务的 Python Web 框架。简单说,它负责把 Python 函数暴露成 HTTP 接口,让前端、Apifox、Postman 或其他业务系统可以通过 URL 调用后端能力。
一个最小 FastAPI 应用大概长这样:
1 | from fastapi import FastAPI |
这里有三个关键信息:
| 代码 | 含义 |
|---|---|
app = FastAPI() |
创建 FastAPI 应用实例 |
@app.get("/health") |
声明一个 GET 接口 |
return {"status": "ok"} |
返回字典,FastAPI 自动转成 JSON |
本项目不会把所有接口都堆在 main.py 里,而是先按下面的结构拆开:
1 | shopkeeper-agent/ |
| 文件 | 职责 |
|---|---|
main.py |
创建 FastAPI 应用,并挂载路由 |
query_router.py |
定义查询接口,例如 /api/query |
query_schema.py |
定义请求体结构,例如 query: str |
2.1 定义请求体 QuerySchema
项目对应文件路径:shopkeeper-agent/app/api/schemas/query_schema.py
1 | from pydantic import BaseModel |
QuerySchema 的作用是告诉 FastAPI:当前接口的请求体里应该有一个字符串字段 query。
当前端发送:
1 | { |
路由函数里就可以通过 query.query 取出用户问题。这里两个 query 含义不同:
1 | 第一个 query:函数参数名,是 QuerySchema 对象 |
2.2 创建查询路由 query_router
项目对应文件路径:shopkeeper-agent/app/api/routers/query_router.py
本章先用 fake_streamer() 模拟流式输出,不接入真实问数智能体:
1 | import asyncio |
这段代码先抓住两点。
第一,APIRouter 用来组织一组接口。虽然当前只有一个查询接口,也先放进 query_router,后续接口变多时不至于全部挤在 main.py 里。
第二,fake_streamer() 是一个异步生成器。它不是一次性 return 一个完整结果,而是每隔 1 秒 yield 一段内容。下一章接入真实智能体时,会把它替换成 QueryService.query(...)。
2.3 在 main.py 中挂载路由
项目对应文件路径:shopkeeper-agent/main.py
1 | from fastapi import FastAPI |
app.include_router(query_router) 很关键。如果只在 query_router.py 里定义了接口,但没有挂载到 app 上,FastAPI 应用并不知道这个路由存在。打开 /docs 时,也看不到 /api/query。
3、为什么查询接口要用流式响应
普通 HTTP 接口通常是这样的:
1 | 客户端发起请求 |
如果用 Python 类比,普通响应更像 return:
1 | async def query(): |
它的特点是:必须等所有逻辑执行完,客户端才能拿到响应。
流式响应更像 yield:
1 | async def query_stream(): |
每 yield 一次,后端就可以向客户端写出一段内容。套到问数接口上,就是:
1 | 客户端提交问题 |
所以,流式响应解决的不是“能不能返回结果”的问题,而是“长流程执行过程中能不能持续给用户反馈”的问题。
4、流式响应:FastAPI 如何持续写出数据
官方文档参考:
- FastAPI 流式响应(StreamingResponse):https://fastapi.org.cn/advanced/custom-response/#streamingresponse
FastAPI 中实现流式返回,核心就是 StreamingResponse。官方文档中说明,StreamingResponse 可以接收生成器、异步生成器或其他可迭代对象,并把其中产出的内容持续写入响应。
本章示例里对应这段代码:
1 | return StreamingResponse( |
这里要注意三件事。
4.1 StreamingResponse 接收的是生成器
fake_streamer() 内部使用 yield:
1 | async def fake_streamer(): |
return 会结束函数,yield 会暂停函数,把当前内容先交出去,下一次还能继续执行。这个特性天然适合“边执行边输出”的问数工作流。
4.2 sleep 只是为了看清流式效果
示例中的:
1 | await asyncio.sleep(1) |
不是业务逻辑,只是为了测试时能肉眼看到 step:0、step:1、step:2 一条一条返回。如果没有暂停,十条消息瞬间输出,看起来就像一次性返回。真实项目里,这个等待时间会被实际节点耗时代替,例如召回、调用大模型、执行 SQL。
4.3 media_type 要声明为 text/event-stream
这一行也很重要:
1 | media_type="text/event-stream" |
它告诉客户端:这不是普通 JSON,也不是普通文本,而是一段 SSE 事件流。如果不声明这个类型,后端即使在持续写出数据,前端或接口测试工具也不一定会按事件流处理。
5、SSE 协议:前后端约定的流式格式
StreamingResponse 解决的是“服务端怎么持续写出响应”。SSE 解决的是“持续写出的文本应该是什么格式,前端才容易解析”。
SSE 全称是 Server-Sent Events,可以理解成“服务端向客户端持续发送事件”。它的交互方式是:
1 | 客户端发起一次 HTTP 请求 |
这正好适合本项目:用户先提交一个问题,后续主要是服务端把执行进度和结果推回来。
5.1 SSE 和 WebSocket 怎么选
先看结论:当前项目的问题提交是一次性的,后续主要是后端持续返回进度和结果,所以 SSE 更贴合需求;WebSocket 更适合前后端都要持续主动发送消息的场景。
| 方案 | 特点 | 适合场景 |
|---|---|---|
| WebSocket | 双向通信,客户端和服务端都能持续发送消息 | 在线协作、聊天、游戏 |
| SSE | 主要是服务端持续向客户端推送消息 | 任务进度、日志流、模型输出流 |
本项目里,前端只需要先提交一次问题,后续接收服务端推送即可。因此用 SSE 更轻量,也更贴合当前需求。
5.2 SSE 的最小消息格式
SSE 消息本质上是 UTF-8 文本。最常见格式是:
1 | data: 这里是要发送的内容 |
注意一条消息后面要有一个空行,也就是两个换行符:
1 | \n\n |
所以在 Python 中通常写成:
1 | yield "data: step:0\n\n" |
如果要发送 JSON,也可以写成:
1 | yield 'data: {"type": "progress", "step": "抽取关键词"}\n\n' |
下一章 QueryService 会使用类似写法:
1 | yield f"data: {json.dumps(chunk, ensure_ascii=False, default=str)}\n\n" |
这行代码可以拆成三层理解:
1 | data: # SSE 的 data 字段 |
5.3 SSE 外层和 JSON 内层
本项目后续会把流式消息设计成几类:
| 类型 | 用途 | 示例 |
|---|---|---|
progress |
返回节点执行进度 | 抽取关键词、生成SQL |
result |
返回最终查询结果 | SQL 查询出的数据 |
error |
返回异常信息 | SQL 执行失败、模型调用失败 |
也就是说,外层是 SSE:
1 | data: ...\n\n |
内层是项目自己的 JSON 协议:
1 | { "type": "progress", "step": "抽取关键词", "status": "running" } |
这层关系一定要分清:SSE 是传输格式,JSON 是业务内容。
6、测试最小流式接口
启动后端服务:
1 | uv run fastapi dev main.py |
然后打开:http://127.0.0.1:8000/docs
如果能看到 POST /api/query,说明 query_router 已经成功挂载。
在 Swagger UI 中发送:
1 | { |

Swagger 中可能会发现页面一直 loading,等全部结束后才一次性展示完整结果。这不代表后端没有流式输出,只是 Swagger UI 通常不会逐条渲染 SSE。
想观察真实流式效果,可以用两种方式:
| 工具 | 观察方式 |
|---|---|
| 浏览器开发者工具 | Network 面板查看响应是否持续增长 |
| Apifox / Postman | 选择 POST /api/query,观察 SSE 是否一条条出现 |

如果看到 step:0 到 step:9 大约每秒出现一条,就说明本章的最小流式接口已经跑通。
7、FastAPI 三件套:lifespan、middleware、Depends
前面已经把最小查询接口和流式协议跑通了。接下来再补三个 FastAPI 工程基础。
它们分别解决不同问题:
| 工程问题 | FastAPI 能力 | 本项目中的用途 |
|---|---|---|
| 应用启动和关闭时如何管理资源 | lifespan |
初始化和关闭外部客户端 |
| 每个请求前后如何统一执行逻辑 | middleware | 生成 request_id,辅助日志追踪 |
| 路由函数依赖的对象如何创建 | Depends |
组装 QueryService、Repository、Session |
这三个概念本章先理解到“各自管什么”就够了,具体代码会在后面两章展开。
7.1 lifespan:应用级资源什么时候初始化
FastAPI 的生命周期事件用于在应用开始接收请求前执行初始化逻辑,并在应用关闭时执行清理逻辑。现在推荐的写法是通过 FastAPI(lifespan=...) 传入一个异步上下文管理器。
为什么本项目需要它?因为问数接口依赖很多外部资源:
- Embedding 客户端;
- Qdrant 客户端;
- Elasticsearch 客户端;
- 元数据库 MySQL 连接;
- 数仓 MySQL 连接。
这些资源不应该每来一个请求就重新初始化一次。更合理的方式是:
1 | 应用启动时: |
FastAPI 中的 lifespan 写法类似下面这样:
1 | from contextlib import asynccontextmanager |
可以先把 yield 理解成分界线:
1 | yield 前:应用启动时执行 |
下一章会把真实代码写成:
1 |
|
7.2 middleware:所有请求都经过的统一逻辑
这里的中间件不是 Kafka、RabbitMQ 那类消息中间件,而是 Web 框架中的请求中间件。它可以理解成包在所有接口外层的一段逻辑。
一次请求大致会经过:
1 | 客户端请求 |
FastAPI 中定义 HTTP 中间件的基本写法是:
1 | from fastapi import Request |
这里两个参数很重要:
| 参数 | 含义 |
|---|---|
request |
当前请求对象,可以读取路径、方法、请求头等 |
call_next |
把请求继续交给后续路由处理函数 |
中间件适合放所有接口都需要的横切逻辑,例如:
- 统一鉴权;
- 统计请求耗时;
- 添加响应头;
- 记录访问日志;
- 为每个请求生成
request_id。
本项目第 17 章会用中间件为每次请求生成唯一 request_id,让并发日志可以按请求维度追踪。
7.3 Depends:声明“我需要什么”
依赖注入(Depends)可以先按字面理解:接口函数只声明自己需要什么对象,FastAPI 负责在请求到来时调用依赖函数,把对象创建好并传进来。
官方示例的简化写法如下:
1 | from typing import Annotated |
这段代码里,read_items() 没有手动调用 common_parameters(),它只是声明:
1 | 我需要一个 commons |
请求进来时,FastAPI 会自动调用依赖函数。
放回本项目,查询接口最终会这样写:
1 |
|
这表示:
1 | query 来自请求体 |
路由层不需要关心 QueryService 里面又依赖哪些 Repository、Session、Client。这些对象会交给下一章的 dependencies.py 分层组装。
8、依赖注入再往下看一层
上一节只讲了 Depends 的基本写法。本节再补两个后面马上会用到的特性:子依赖和带 yield 的依赖项。
8.1 子依赖
依赖可以继续依赖别的依赖。例如:
1 | query_router |
FastAPI 会自动解析这棵依赖树。更重要的是,同一个请求里,如果多个依赖共用同一个子依赖,FastAPI 会做请求级缓存,不会在一次请求中重复创建同一个依赖对象。
注意这个缓存是请求级别,不是全局单例。每来一个新请求,依赖函数仍然会重新执行。
8.2 带 yield 的依赖项
数据库 Session 这类请求级资源,适合用带 yield 的依赖项:
1 | async def get_meta_session(): |
执行顺序可以理解为:
1 | 请求需要 meta_session |
这里也要区分 lifespan 和带 yield 的依赖项:
| 对比项 | lifespan |
带 yield 的依赖项 |
|---|---|---|
| 生命周期范围 | 整个应用 | 单次请求 |
| 典型资源 | 客户端、连接池、全局配置 | 数据库 Session、请求级临时资源 |
| 本项目例子 | 初始化 Qdrant、ES、MySQL manager | get_meta_session()、get_dw_session() |
简单记:
1 | lifespan 管应用级资源 |
本章小结:
/api/query不是普通 JSON 接口,而是一个需要持续返回执行过程的流式接口。- 本章先用
fake_streamer()验证 API 骨架和流式响应,下一章再接入真实QueryService。 - FastAPI 用
StreamingResponse承接异步生成器,每yield一次就向客户端写出一段内容。 - 本项目用 SSE 作为前后端流式协议,最核心格式是
data: xxx\n\n。 lifespan、middleware、Depends分别解决应用级资源管理、请求级统一逻辑、依赖对象组装问题。
下一章会正式把 fake_streamer() 替换成真实的 QueryService.query(...),让 /api/query 接到 LangGraph 问数工作流上。