FastAPI 路由操作数据库 + 服务启动:main.py 全拆解
学习日记 · 后端篇|13 个接口 · SSE 流式 · uvicorn 启动
上篇文章我们做了一个能用的聊天客户端:左侧历史对话、右侧打字机效果,看起来已经像模像样了。
但前端再漂亮,也得后端接口稳。今天我们就钻进 main.py,把每一个路由、每一个辅助函数、最后到 uvicorn 启动,逐个拆开看。
这篇文章会解决三个问题:
-
路由怎么写?每个接口到底做了啥?
-
流式问答怎么实现?SSE 到底是个啥?
-
怎么把页面和 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 的主路径,也是这次学习的重点。为什么要流式?因为大模型生成要几秒,流式可以「边生成边推字」,前端打字机效果更好,用户体验大幅提升。
整体四步走
-
校验 API Key、session_id(若有)
-
先
_collect_contexts(检索在开流之前做完) -
返回
StreamingResponse;event_gen()边 yield 边推 SSE -
事件顺序:
contexts→ 多次token→saved→done;出错则推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:收集上下文
根据请求里的 mode(rag / 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 系列实战笔记,欢迎关注不迷路。