FastAPI 路由操作数据库 + 服务启动:main.py 全拆解

0 阅读9分钟

FastAPI 路由操作数据库 + 服务启动:main.py 全拆解

学习日记 · 后端篇|13 个接口 · SSE 流式 · uvicorn 启动

上篇文章我们做了一个能用的聊天客户端:左侧历史对话、右侧打字机效果,看起来已经像模像样了。

但前端再漂亮,也得后端接口稳。今天我们就钻进 main.py,把每一个路由、每一个辅助函数、最后到 uvicorn 启动,逐个拆开看。

这篇文章会解决三个问题:

  1. 路由怎么写?每个接口到底做了啥?

  2. 流式问答怎么实现?SSE 到底是个啥?

  3. 怎么把页面和 API 一起启动起来?


一、先聊架构:为什么都放 main.py?

为了方便找代码,我把启动逻辑和 FastAPI 都放在 main.py 里。后续如果功能迭代,可以拆成 routers/services/core/ 等模块,但学习阶段单文件最直观。

整体结构大致是这样的:


main.py

├─ import & 配置加载

├─ app = FastAPI()

├─ 辅助函数:_sse / _collect_contexts / _persist_turn

├─ 路由(13 个接口)

├─ 静态资源挂载

└─ uvicorn.run(...) # 启动入口


二、13 个接口的全景图

先看一张路由清单,建立整体印象。后面挑几个关键的细讲:


GET /health 健康检查

GET /sessions 会话列表

POST /sessions 新建会话 body:{title}

GET /sessions/{id} 会话详情(含 messages)

PATCH /sessions/{id} 改标题 body:{title}

DELETE /sessions/{id} 删除会话

POST /sessions/{id}/messages 追加一条消息

GET /documents 知识库文件列表

POST /documents/upload 上传文件(multipart)

DELETE /documents/{filename} 删除文件并重建索引

POST /index/rebuild 仅重建 FAISS

POST /search 只检索不调 LLM

POST /ask/stream RAG + 流式回答(SSE)

看着多,但其实就是四类:健康检查 / 会话 CRUD / 文档管理 / 问答检索。下面挑几个关键的细看。


三、健康检查 /health:最简单但最常用

这个接口几乎不动数据库,纯粹是「看一眼系统状态」,前端右上角的状态条会定时调它。


@app.get("/health")

def health() -> dict:

return {

"status": "ok",

"chunks": len(state.get("chunks", [])),

"documents": len(list_documents(DATA_DIR)),

"sessions": len(db.list_sessions()),

"llm_configured": bool(os.getenv("OPENAI_API_KEY")),

"web_search_configured": web_search_configured(),

"db": str(db.DEFAULT_DB_PATH),

"config": {

"chunk_size": CHUNK_SIZE,

"chunk_overlap": CHUNK_OVERLAP,

"top_k": DEFAULT_TOP_K,

"min_score": MIN_SCORE,

},

}

亮点是 db 字段:直接把 SQLite 文件路径返回前端,方便用 Navicat 之类的工具直接打开看数据。配置有没有配齐、索引有多少块、存了多少会话,一眼看清楚。


四、会话管理:/sessions 的标准 CRUD

这一组接口是 chat.html 左侧历史对话的全部来源,标准的「增删查改」:


GET /sessions → list_sessions

POST /sessions → create_session

GET /sessions/{id} → get_session_detail(含 messages)

PATCH /sessions/{id} → update_session_title

DELETE /sessions/{id} → delete_session(级联删消息)

POST /sessions/{id}/messages → add_message

关键设计点:路由只是「薄壳」:参数校验完丢给 db.py(上一篇文章讲过的数据库封装)。删除会话时级联删除 messages,避免遗留垃圾数据。

关于 documents 这组接口,文档上传、索引重建等比较复杂,单独留一篇讲,先跳过。


五、仅检索 /search:不调大模型的轻量接口

chat.html 里有个「仅检索」开关,勾上之后走这个接口。只做向量检索,不调用 LLM,也不走联网——返回 Top-K 片段就完事。


@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)。


六、一次性问答 /ask:等大模型完整生成

顾名思义,等大模型整段生成完再一次性返回。不像 /ask/stream 那样边生成边推字。


@app.post("/ask", response_model=AskResponse)

def ask_api(body: QueryRequest) -> AskResponse:

# ① 检查 API Key

if not os.getenv("OPENAI_API_KEY"):

raise HTTPException(400, "未配置 OPENAI_API_KEY,请先在 .env 中设置")

# ② 检索编排:拿材料

contexts = _collect_contexts(body)

# ③ 调大模型,一次拿完整答案

try:

answer = generate_answer(body.query, contexts, mode=body.mode)

except Exception as exc:

# 502:上游(大模型服务)出问题,不是你的请求格式错

raise HTTPException(502, f"LLM 调用失败: {exc}") from exc

# ④ 落库(没传 session_id 则 sid 为 None)

sid = _persist_turn(body.session_id, body.query, answer, contexts)

return AskResponse(

query=body.query,

answer=answer,

contexts=contexts,

session_id=sid,

)

步骤非常清晰:校验 Key → 检索 → 调模型 → 落库 → 返回。注意异常处理:LLM 出问题返回 502,表示「上游服务」故障,不是你的请求格式错。


七、流式问答 /ask/stream:SSE 的核心实现

这是 chat.html 的主路径,也是这次学习的重点。为什么要流式?因为大模型生成要几秒,流式可以「边生成边推字」,前端打字机效果更好,用户体验大幅提升。

整体四步走

  1. 校验 API Key、session_id(若有)

  2. _collect_contexts(检索在开流之前做完)

  3. 返回 StreamingResponseevent_gen() 边 yield 边推 SSE

  4. 事件顺序:contexts → 多次 tokensaveddone;出错则推 error 事件

完整代码


@app.post("/ask/stream")

def ask_stream_api(body: QueryRequest) -> StreamingResponse:

# 同步校验:失败直接 HTTP 错误,不进 SSE

if not os.getenv("OPENAI_API_KEY"):

raise HTTPException(400, "未配置 OPENAI_API_KEY,请先在 .env 中设置")

if body.session_id and db.get_session(body.session_id) is None:

raise HTTPException(404, "会话不存在")

# 检索放在 StreamingResponse 外面

# 这样检索失败能直接 400/502;成功后再开流推 token

contexts = _collect_contexts(body)

def event_gen():

# ① 先把检索结果整包推给前端(展示「引用了哪些片段」)

yield _sse("contexts", contexts)

answer_parts: list[str] = []

try:

# ② 大模型流式吐字;每个 token 推一条 SSE

for token in stream_answer(body.query, contexts, mode=body.mode):

answer_parts.append(token)

yield _sse("token", {"text": token})

# ③ 拼完整回答,写入 SQLite

answer = "".join(answer_parts)

if body.session_id:

_persist_turn(body.session_id, body.query, answer, contexts)

yield _sse("saved", {"session_id": body.session_id})

# ④ 正常结束标记

yield _sse("done", {"ok": True})

except Exception as exc:

# 流已经开始后不能再改 HTTP 状态码,只能用 event: error 通知前端

yield _sse("error", {"detail": str(exc)})

# media_type 告诉浏览器:这是 SSE 流,不要当普通 JSON 一次解析

return StreamingResponse(event_gen(), media_type="text/event-stream")

注意:这是个同步生成器。对学习 demo 足够,但高并发场景下通常会改成异步流(async def + async generator),避免阻塞事件循环。

为什么检索放在 StreamingResponse 外面?

因为 HTTP 响应一旦开始(SSE 流开启),状态码就锁死了,没法再改。检索放在外面,失败能直接返回 400/502;检索成功后再开流推 token,体验最稳。


八、四个关键辅助函数

流式接口看着长,其实核心是四个辅助函数撑起来的。

1) _sse:拼一条 SSE 文本


def _sse(event: str, data: dict | list) -> str:

"""

拼一条 SSE(Server-Sent Events)文本。

浏览器端 chat.html 会按空行切开,再读:

event: xxx

data: {...json...}

注意末尾必须有额外的 \\n\\n,表示「这一事件结束」。

ensure_ascii=False:中文直接写进 JSON,不要变成 \\uXXXX。

"""

return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"

关键点:末尾必须有 \n\n,表示「这一事件结束」。浏览器端会按空行切分事件。ensure_ascii=False 让中文直接写进 JSON,避免变成 \uXXXX

2) _collect_contexts:收集上下文

根据请求里的 moderag / chat / web_search),走不同分支拿材料。这是流式接口能「按时推 contexts 事件」的源头。

3) stream_answer:大模型流式生成

封装在 llm.py 里,逐 token yield。每次拿到一段就 yield 一次,对应前端的一个 token 事件。

4) _persist_turn:写入一轮问答


def _persist_turn(

session_id: str | None,

query: str,

answer: str,

contexts: list[dict],

) -> str | None:

"""

把「一轮完整问答」写入 SQLite。

调用时机:

- /ask 生成完 answer 之后

- /ask/stream 全部 token 拼完之后

写入内容:

1) user 消息 = query

2) assistant 消息 = answer + contexts(检索片段,便于历史里回看引用)

3) 若标题还是「新对话」→ 用问题前 24 字当标题

若请求没带 session_id,直接跳过(不落库)。

"""

if not session_id:

return None

if db.get_session(session_id) is None:

raise HTTPException(404, "会话不存在")

db.add_message(session_id, "user", query)

db.add_message(session_id, "assistant", answer, contexts)

session = db.get_session(session_id)

if session and session["title"] in {"新对话", "New chat"}:

db.update_session_title(session_id, query[:24])

return session_id

add_message 往 messages 表追加一条记录——这部分细节上一篇数据库文章讲过(db.py),这里不再展开。


九、静态资源:前后端为什么能同源?

main.py 里其实挂了两层。

① 整目录挂载


app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")

static/ 下的文件(图片、CSS、JS)可通过 http://127.0.0.1:8000/static/xxx 直接访问。

② 页面路由直接返回 HTML


@app.get("/")

def home() -> FileResponse:

return FileResponse(STATIC_DIR / "index.html")

@app.get("/chat")

def chat_page() -> FileResponse:

"""对话客户端(对接 /ask/stream、文档管理、SQLite 会话)。"""

return FileResponse(STATIC_DIR / "chat.html")

浏览器访问 /chat 时,后端把磁盘上的 chat.html 读出来当响应返回。

整条访问链路


python main.py

↓

uvicorn 监听 8000

↓

浏览器打开 http://127.0.0.1:8000/chat

↓

FastAPI 匹配 GET /chat

↓

FileResponse(static/chat.html)

↓

浏览器渲染页面

↓

页面里的 fetch("/ask/stream") 仍打到同一台 8000 服务

一句话:页面和 API 都在 8000 端口,前后端同源,前端用相对路径就能请求,不必另开 nginx 或 Vite,部署省心。


十、uvicorn 启动:一行命令跑起来

main.py 最后一段:


if __name__ == "__main__":

import uvicorn

uvicorn.run(

"main:app", # 模块 main 里的变量 app

host="0.0.0.0", # 本机其它设备也可访问

port=8000,

reload=True, # 改代码自动重启(开发用;生产一般关掉)

)

几个关键参数:

| 参数 | 作用 |

| --- | --- |

| main:app | 「模块:变量」格式,告诉 uvicorn 去哪找 FastAPI 实例 |

| host=0.0.0.0 | 监听所有网卡,本机其它设备也能访问 |

| port=8000 | 端口号,可改 |

| reload=True | 改代码自动重启,仅开发用,生产请关掉 |


写在最后

至此,main.py 的每一个路由、每一个辅助函数、到 uvicorn 启动都过了一遍。整条链路串起来就一句话:

FastAPI = 路由薄壳 + 业务编排 + 静态托管

路由只负责校验参数 + 调业务函数 + 返回响应,复杂逻辑都下沉到 db.py / llm.py / search.py 里。流式问答的关键是 StreamingResponse + 同步生成器 + _sse 工具函数。

下一篇会讲功能拓展:怎么实现闲聊模式与联网搜索模式,让 RAG 系统更智能。敬请期待~


如果这篇拆解对你有帮助,欢迎点赞 / 收藏 / 评论三连支持一下 👀

后续继续更新 RAG 系列实战笔记,欢迎关注不迷路。