FastAPI 学习日记:从 RAG 脚本到可调用的 Web API
学习日记 · 服务化篇|8 个关键节点,从「模型跑通」到「服务上线」
跑通 RAG 容易,把它变成「别人能调用的服务」难。这篇记录我用 FastAPI 把检索、生成、流式输出统统装进 HTTP 接口的全过程。
引子:脚本跑通,只是起点
前面的日记里,RAG 那条流水线已经能跑了:读文档 → 切块 → M3E 编码 → FAISS 检索 → LLM 生成。
但它只能在终端里 python main.py 自己跟自己玩。同事要调用、前端要接入、要开个页面给人输入问题——都得改代码。
所以这一下午的时间,都花在同一个问题上:怎么让这套东西变成别人通过 URL 就能调用的服务。
答案是 FastAPI。下面按我实际动手的顺序,把 8 个节点逐个拆开。
一、先回答一个问题:FastAPI 到底解决了什么?
没有框架的时候,这些东西都得自己写:
-
监听端口
-
解析 URL
-
读 JSON
-
拼响应
繁琐,而且每个项目都要重新造一遍轮子。把几个常见词摆在一起,定位就很清楚了:
| 组件 | 它是干嘛的 |
| --- | --- |
| FastAPI | 应用本身,定义路由、参数、响应 |
| uvicorn | 真正监听端口的服务器 |
| Flask / Django | 也是 Python Web 框架;FastAPI 更偏 API,原生支持类型提示和自动文档 |
| RAG | 业务逻辑层;FastAPI 负责把它暴露成 /search、/ask 等接口 |
一句话:FastAPI = 把你的 Python 函数,变成别人通过 URL 能访问的接口。
二、整体骨架:8 个节点分别在哪儿
先给一张全局图,心里有个谱,后面按顺序拆:
python test7.py
↓
uvicorn 监听 8001
↓
lifespan 启动钩子
└─ load_or_build_rag_index() → 装填全局 state
state = { model, index, chunks, sources }
↓
请求进来,直接从 state 取模型和索引(不再重新加载)
↓
GET /health 看一眼系统状态
POST /search 只检索,不调 LLM
POST /ask 检索 + LLM 一次性返回
POST /ask/stream 检索 + LLM 流式返回(SSE)
↓
浏览器打开 /docs,全部接口在线可调试
8 个节点:装依赖 → 全局 state → lifespan → 创建 app → Pydantic 模型 → 基础路由 → SSE 流式 → uvicorn 启动。
三、第一步:装好「门面」和「服务员」
我装的两个核心包:
pip install fastapi==0.115.12 uvicorn==0.34.2
| 包 | 职责 |
| --- | --- |
| FastAPI | 写接口逻辑 |
| uvicorn | 把接口跑起来,真正监听端口 |
少任何一个都不行:一个负责「写」,一个负责「跑」。
四、第二步:让模型只加载一次
这是写 FastAPI 服务最容易踩的坑——如果什么都不做,每个请求都会重新加载一次 M3E 模型和 FAISS 索引。
模型动辄几百 MB,索引动辄几 GB。每请求加载一次,服务直接卡死。
解决办法:用进程级字典保存状态。
# 进程级状态:模型、索引、文档块全放这儿,所有请求共享同一份
state: dict = {}
服务启动时一次性把模型、索引、文档块加载进去;之后所有请求都读这份 state。
这就引出下一个关键点——lifespan。
五、第三步:lifespan —— 服务启停的「开关钩子」
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
"""服务启停钩子:启动时备货,关闭时收拾。"""
# ① 启动:有索引就读盘(快),没有就读 data/ 重建(首次会慢一次)
model, index, chunks, sources = load_or_build_rag_index()
state.update(
{
"model": model,
"index": index,
"chunks": chunks,
"sources": sources,
}
)
print(f"[startup] 索引就绪,共 {len(chunks)} 个块")
yield # ← 把控制权交给 uvicorn,之后所有请求都能用这份 state
# ② 关闭:释放资源
state.clear()
print("[shutdown] state 已清空")
三个阶段各干一件事:
| 阶段 | 做什么 |
| --- | --- |
| yield 之前 | 启动时备货(加载模型、索引) |
| yield 之时 | 放开控制权,让所有请求共用 state |
| yield 之后 | 关闭时收拾(释放资源) |
类比一下:FastAPI ≈ 开了一家店;lifespan 就是「开门时备货、关门时收拾」。
踩坑提示:这段代码我一开始写成了外面再套一层
if (INDEX_DIR / "index_store").exists():判断,两个分支里还调同一个函数。其实没必要——load_or_build_rag_index()内部已经做了「有就读盘、没有就重建」的判断(上一篇日记里拆过它的实现)。写两层属于重复判断。
六、第四步:创建 FastAPI 应用
app = FastAPI(
title="RAG FastAPI 学习版",
version="0.2.0",
description="test7:M3E + FAISS +(可选)LLM;含 SSE 流式学习",
lifespan=lifespan,
)
| 参数 | 作用 |
| --- | --- |
| title | 应用标题,显示在 /docs 页面顶部 |
| version | 版本号,也显示在文档里 |
| description | 简短说明 |
| lifespan=lifespan | 绑定上面的钩子函数 |
不传 lifespan 会怎样?模型就得在每个请求里现加载,慢到你怀疑人生。
七、第五步:Pydantic —— API 的「数据合同」
这是 FastAPI 最优雅的设计之一。
class QueryRequest(BaseModel):
query: str = Field(..., min_length=1, description="用户的问题")
top_k: int = Field(3, ge=1, le=10, description="召回片段数")
min_score: float | None = Field(None, ge=-1.0, le=1.0, description="相似度阈值")
为什么非要用 class?不用也能写,但你得手写一堆判断:
data = await request.json()
if "query" not in data or not data["query"]:
raise HTTPException(400, "缺 query")
if not isinstance(data.get("top_k"), int):
raise HTTPException(400, "top_k 必须是整数")
# ... 还有一堆 if 排着队
用 class 之后,FastAPI 自动帮你做四件事:
-
自动校验:
query为空 → 直接 422;top_k传成"abc"→ 也 422 -
自动文档:
/docs里出现可填表单和字段说明 -
类型清楚:函数签名写
body: QueryRequest,取值时body.query像对象属性一样,比body["query"]清晰 -
响应也规范:
SearchResponse/AskResponse保证返回字段统一
响应模型也一并定义好:
class SearchResult(BaseModel):
text: str
source: str
score: float
class SearchResponse(BaseModel):
query: str
results: list[SearchResult]
class AskResponse(BaseModel):
query: str
answer: str
contexts: list[SearchResult]
这些
class不是「面向对象炫技」,而是在定义 API 的数据合同:请求长什么样、响应长什么样,白纸黑字写下来,前端照着写就不会错。
八、第六步:基础路由 —— 把功能变成接口
1. 健康检查 /health
@app.get("/health")
def health() -> dict:
return {
"status": "ok",
"chunks": len(state.get("chunks", [])),
"documents": len(list_documents(DATA_DIR)),
"llm_configured": bool(os.getenv("OPENAI_API_KEY")),
}
最不起眼但最常用:看一眼索引里有多少块、知识库里有多少文档、LLM 有没有配好。
2. RAG 检索 /search(不调大模型)
@app.post("/search", response_model=SearchResponse)
def search_api(body: QueryRequest) -> SearchResponse:
results = search(
body.query,
state["model"],
state["index"],
state["chunks"],
state["sources"],
top_k=body.top_k,
min_score=body.min_score,
)
return SearchResponse(query=body.query, results=results)
只走向量检索,不烧 LLM 的钱,很适合验证「知识库里到底有没有命中」。
3. 检索 + LLM 一次性回答 /ask
@app.post("/ask", response_model=AskResponse)
def ask_api(body: QueryRequest) -> AskResponse:
# ① 没有 Key 直接 400,白跑一趟检索没意义
if not os.getenv("OPENAI_API_KEY"):
raise HTTPException(400, "未配置 OPENAI_API_KEY,请先在 .env 中设置")
# ② 检索:拿材料
contexts = search(
body.query,
state["model"],
state["index"],
state["chunks"],
state["sources"],
top_k=body.top_k,
min_score=body.min_score,
)
# ③ 调大模型,一次拿完整答案
answer = generate_answer(body.query, contexts)
return AskResponse(query=body.query, answer=answer, contexts=contexts)
到这里,「前端能调、后端能返回」的最短链路就跑通了。
/ask和/ask/stream的差别,只在于第二步之后的等待方式:一个等全,一个边等边给。
九、第七步:SSE 流式输出 —— 让答案边生成边显示
这一步是体验的分水岭。
为什么需要流式? 等整段答案生成完再返回,用户得盯着空白屏幕 5-10 秒;流式输出则是每生成一个 token 立刻推给前端,打字机效果。
SSE 是什么
-
全称:Server-Sent Events(服务端推送事件)
-
特征:
media_type="text/event-stream"+ 生成器不断yield -
本质:一条 HTTP 长连接,服务端单向往客户端推文本
三种接口的分工:
| 接口 | 行为 |
| --- | --- |
| /ask | 一次性返回完整答案 |
| /api/stream | 演示用流式,不依赖任何模型 |
| /ask/stream | 真实的 RAG 流式问答 |
关键语法:yield
yield 是 Python 里「暂停并交出一个值」的关键字。带 yield 的函数叫生成器:不是一次算完再返回,而是每次被要数据时吐出一块,再从断点继续。
最小流式演示(不依赖任何模型)
import asyncio
import json
from fastapi.responses import StreamingResponse
@app.get("/api/stream")
async def stream_demo() -> StreamingResponse:
async def generate():
for i in range(5):
payload = {"content": f"回答部分 {i + 1}", "done": i == 4}
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
await asyncio.sleep(0.5)
return StreamingResponse(generate(), media_type="text/event-stream")
浏览器打开就能看到每 0.5 秒推一行数据。
真实 RAG 流式问答
先准备两个小工具:SSE 公共响应头 + 拼事件文本的函数。
# SSE 公共响应头
_SSE_HEADERS = {
"Cache-Control": "no-cache", # 不缓存,否则浏览器可能攒着一起给
"Connection": "keep-alive", # 长连接
"X-Accel-Buffering": "no", # 关掉 nginx 缓冲,漏一层就成「假流式」
}
def _sse(event: str, data) -> str:
"""
拼一条 SSE 文本。末尾必须有额外两个换行,表示「这一事件结束」。
ensure_ascii=False:中文直接写进 JSON,不要变成 \\uXXXX。
"""
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
然后是接口本体:
@app.post("/ask/stream")
def ask_stream_api(body: QueryRequest) -> StreamingResponse:
# 检索放在开流之前:失败能直接返回 400/502 的 JSON
contexts = search(
body.query,
state["model"],
state["index"],
state["chunks"],
state["sources"],
top_k=body.top_k,
min_score=body.min_score,
)
def event_gen():
# ① 先推检索引用,前端能立刻显示「引用了哪几段」
yield _sse("contexts", contexts)
try:
# ② 每生成一个 token 就推一条
for token in stream_answer(body.query, contexts):
yield _sse("token", {"text": token})
# ③ 正常结束标记
yield _sse("done", {"ok": True})
except Exception as exc:
# ④ 流已开始,HTTP 状态码改不了了,只能推 error 事件
yield _sse("error", {"detail": str(exc)})
return StreamingResponse(
event_gen(),
media_type="text/event-stream",
headers=_SSE_HEADERS,
)
四步流程一句话记:① 推 contexts → ② 推 token → ③ 推 done → ④ 异常推 error。
试一下(Windows CMD,^ 是续行符):
curl.exe -N -X POST http://127.0.0.1:8001/ask/stream ^
-H "Content-Type: application/json" ^
-d "{\"query\":\"RAG 是什么?\"}"
-N关掉 curl 自己的缓冲,否则你看到的还是一坨,不是一行一行冒出来。
十、第八步:用 uvicorn 启动服务
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"test7:app", # 「模块:变量」,告诉 uvicorn 去哪找 app
host="0.0.0.0", # 允许外部机器访问,不只是 localhost
port=8001, # 避开 main.py 用的 8000
reload=True, # 代码改动自动重启(开发期爽到飞起)
app_dir=str(TEST_DIR), # 指定模块搜索目录
)
| 参数 | 作用 |
| --- | --- |
| "test7:app" | 模块名 + 变量名 |
| host="0.0.0.0" | 监听所有网卡,本机其它设备也能访问 |
| port=8001 | 端口号,避开已被 main.py 占用的 8000 |
| reload=True | 改代码自动重启,仅开发用,生产务必关掉 |
| app_dir | 让 uvicorn 在指定目录里找 test7.py |
启动后浏览器打开 http://127.0.0.1:8001/docs,就能看到 FastAPI 自动生成的接口文档——直接在线测试每个接口。
十一、踩坑复盘
| # | 坑 | 原因 / 解法 |
| --- | --- | --- |
| 1 | 每个请求都重新加载模型 | 没做进程级缓存。挂到全局 state,在 lifespan 里加载一次 |
| 2 | lifespan 里多写一层 if/else | load_or_build_rag_index() 内部已判断,外层再判属于重复 |
| 3 | 检索塞进生成器里 | HTTP 流一旦开启状态码就锁死,检索失败没法返 400。检索必须在 StreamingResponse 外做完 |
| 4 | 前端收不到事件 | SSE 文本末尾少了一个换行。必须是 \n\n 两个 |
| 5 | 中文变 \u4e2d\u6587 | json.dumps 忘了 ensure_ascii=False |
| 6 | 线上流「一坨」出来 | 反向代理(nginx)开了缓冲,响应头加 X-Accel-Buffering: no |
| 7 | 422 报错看不懂 | QueryRequest 没定义路由里用到的字段(比如 min_score)。Pydantic 模型和路由字段必须对齐 |
| 8 | 端口被占用起不来 | main.py 占了 8000,新服务换 8001 |
| 9 | reload=True 上了生产 | 它会额外起进程监视文件,性能与稳定性都不合适,上线要关 |
十二、写在最后:把脚本变成服务的三个关键认知
1. 状态属于进程,不属于请求
模型、索引、配置必须在 lifespan 里加载一次,存到全局 state,所有请求共享。这是从「脚本」到「服务」最重要的一次思维切换。
2. 类型是文档,也是合同
Pydantic 不止是「写起来好看」,它替你做掉 90% 的参数校验 + 自动文档生成。定义好 QueryRequest,前端就照着 /docs 写。
3. 流式输出不是装饰,是体验
一次性返回 vs 流式返回,用户感知的响应速度差 3-5 倍。
金句
FastAPI 教给我的不是「如何写接口」,而是「如何把一段脚本,封装成一个可以被别人调用的服务」。
从「自己跑」到「别人能跑」,这是开发者到产品经理的分水岭。
互动时间
你学 FastAPI 的时候,最先踩的是哪个坑?
-
模型每次请求都重载?
-
Pydantic 校验报错看不懂?
-
SSE 流式前端怎么接?
-
uvicorn 启动报错?
评论区聊聊,我挑几个高频问题,下一篇专门拆解。
下一篇写:SSE 前端怎么接、怎么用 EventSource 渲染流式答案。
如果这篇对你有帮助,欢迎点赞 / 收藏 / 评论三连支持一下 👀
后续继续更新 FastAPI + RAG 系列实战笔记,欢迎关注不迷路。
关注公众号一起学习交流:我造了个AI