FastAPI 学习日记:从 RAG 脚本到可调用的 Web API

0 阅读11分钟

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 自动帮你做四件事:

  1. 自动校验query 为空 → 直接 422;top_k 传成 "abc" → 也 422

  2. 自动文档/docs 里出现可填表单和字段说明

  3. 类型清楚:函数签名写 body: QueryRequest,取值时 body.query 像对象属性一样,比 body["query"] 清晰

  4. 响应也规范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