单例不能救你——这不是 bug,是进程模型的本质
一个让人想砸键盘的开发体验
你正在用 FastAPI + uvicorn 写一个本地 embedding 服务,模型用的是 BGE-M3(约 2 GB 内存)。开发时为了改完代码立刻看到效果,你在启动命令里加了 --reload:
uvicorn app:app --reload
理想的工作流是:写代码 → 保存 → uvicorn 自动重启 → 测试新代码。但你很快发现实际情况是这样的——
每次保存代码,BGE-M3 都要重新从磁盘加载到内存,整整 30 秒。一上午改十几次代码,光是等模型加载就花掉 5 分钟。Console 里反复出现:
INFO: Detected change in 'app.py'. Reloading...
INFO: Shutting down
INFO: Started reloader process [406958] using WatchFiles
INFO: Started server process [407001]
INFO: Loading model BAAI/bge-m3...
... (30 秒) ...
INFO: Application startup complete.
更迷惑的是——你明明在代码里写了"只加载一次模型"的逻辑:一个单例类、一个 @lru_cache 装饰的工厂函数、一个 module-level 全局变量。但每次 reload 之后还是要重新加载。是单例失效了?是缓存没生效?
都不是。是 --reload 的工作方式天生就是这样。这不是 bug——理解了背后的进程模型,你就明白这件事根本不可能在 reload 模式下被解决。
--reload 到底做了什么
uvicorn --reload 不是热替换某个 Python 模块,它是杀掉整个 worker 进程,然后启动一个全新的。
当你保存代码时,uvicorn 启动了一个独立的 watcher 进程(基于 WatchFiles),它监控项目目录里所有 .py 文件。一旦检测到任何变动,watcher 就会做三件事:
1. 给旧 worker 进程发送 SIGTERM 信号 → 旧进程退出
2. 启动一个全新的 Python 解释器进程 → PID 完全不同
3. 在新进程里 import 你的 app 模块 → 走完所有启动逻辑
注意第三步——新进程的内存是完全空白的,它不继承旧进程的任何 Python 对象。所有你以为"全局存在"的东西——模块级变量、类变量、单例、lru_cache 缓存——这些概念都建立在"同一个进程"的前提上。一旦换了进程,它们全部从初始状态开始。
所以你写的这种代码:
class SimpleEmbedding:
_shared_model = None
@classmethod
def get_model(cls):
if cls._shared_model is None:
cls._shared_model = SentenceTransformer("BAAI/bge-m3")
return cls._shared_model
在同一个进程内运行毫无问题——第一次调用加载模型,之后每次都返回那个缓存的引用。但 reload 一次,整个进程被换掉,类的定义被重新执行,_shared_model 又是 None,模型不得不重新加载。单例不能跨进程,这是 Python 进程模型的硬约束。
为什么不能让新进程"继承"模型
听起来 fork 应该会复制父进程的内存?理论上 Unix 的 os.fork() 加上 copy-on-write 机制是这么做的——子进程能"看到"父进程的内存页,只在写入时才复制一份。但 uvicorn 的 --reload 用的不是 os.fork()——它是用 subprocess 启动一个全新的 Python 解释器,从命令行参数开始执行。父进程那 2 GB 的模型权重根本不会跨进程传递。
就算硬要走真正的 fork 路径(比如改用 gunicorn --preload 配合多 worker),CoW 在大模型场景下也几乎立刻崩——模型推理过程中会触发 GC、修改 PyTorch 张量内部状态、引用计数的变更——任何一个都会让那块内存页被复制到子进程独立的物理内存里。CoW 在"只读"场景下省内存,但 PyTorch / NumPy 这种状态频繁变化的对象上几乎无效。
更何况 reload 模式根本不走 fork——是 watcher 拉起的全新解释器进程,连 CoW 的机会都没有。
几种诱人但解决不了的尝试
意识到是 reload 的问题之后,很多人的第一反应是"那我能不能想办法把模型保存下来"?常见的几种尝试都走不通——
用 pickle 把模型 dump 到磁盘:2 GB 数据写文件再读回来,比直接从模型 hub 加载还慢。何况 PyTorch 模型对象内部有大量张量、可能有 GPU 引用、可能有动态构建的图——这些都没法干净地被 pickle 序列化再"原样恢复"。
multiprocessing.shared_memory:理论上可以建一块共享内存放模型权重,所有进程都能读。但 watcher 拉起的新进程不知道去哪儿连这块内存——你得自己实现"去查找现有共享内存名 + 反序列化模型对象"的逻辑,工程复杂度直接翻倍,而且 PyTorch 模型对象的状态机不适合放在共享内存里。
os.environ 或临时文件存"模型 ID":存了也没用,新进程拿到一个 ID 之后照样要去模型 hub 重新拉权重到内存。
用更小的量化模型:模型加载从 30 秒变 5 秒,但本质问题没解决。而且开发用量化、上线用全精度,又给自己埋了"两套模型行为不一致"的坑。
这些方向都是在和 reload 的本质机制对抗。正确的方向是承认这件事在 reload 模式下不能解决,转而调整开发流程。
三条真正可行的路径
第一条最干脆——开发时直接关掉 reload。
# 假设你的 app.py 里通过 settings.debug 控制 reload:
# uvicorn.run(app, reload=settings.debug)
DEBUG=false python app.py
# 或者绕过 settings,直接用 uvicorn 命令不加 --reload:
uvicorn app:app
代价是改完代码要手动重启服务(终端 Ctrl-C 再启动)。听起来麻烦,实际上:模型只在你主动重启时加载一次,其他时间改代码完全不触发。配合 IDE 的"一键重启"快捷键,体验反而比 reload 流畅——因为你掌控了重启时机,知道每次重启对应了哪批改动。这是大多数 ML 工程师最后都会回到的方案。
第二条折中——保留 reload,但禁用模型预加载。把模型从"启动时立刻加载"改成"首次请求时再加载":
# config.py
import os
PRELOAD_MODELS = os.getenv("PRELOAD_MODELS", "true").lower() == "true"
# app.py
from sentence_transformers import SentenceTransformer
from config import PRELOAD_MODELS
embedder = None
if PRELOAD_MODELS:
embedder = SentenceTransformer("BAAI/bge-m3")
def get_embedder():
global embedder
if embedder is None:
embedder = SentenceTransformer("BAAI/bge-m3")
return embedder
@app.post("/embed")
def embed(text: str):
return {"vector": get_embedder().encode(text).tolist()}
开发时启动命令带上环境变量:
PRELOAD_MODELS=false uvicorn app:app --reload
效果是 reload 后服务秒起,第一次请求慢(30 秒加载模型),之后每次请求都快。如果你开发期间大部分时间在改和模型无关的代码——HTTP 路由、参数校验、日志格式、依赖注入——这条很合适。reload 的快速反馈和模型不重复加载这两个目标,第二条都顾上了。
代价是每次 reload 后第一次请求要等模型加载完才返回——你的前端 / 测试脚本要做好"首次请求会慢 30 秒"的心理准备,最好显式打个日志或加 progress。
第三条——接受现状,开发时根本不在本地跑模型。把推理服务独立出去,常驻一个 Ollama / vLLM / Triton 进程跑模型,FastAPI 应用只是个 HTTP client。开发改业务逻辑时,FastAPI 应用秒重启;模型服务一天加载一次,常驻不动。
# 业务侧 FastAPI 不再 load 模型,而是 HTTP 调推理服务
import httpx
EMBEDDING_SERVICE = "http://localhost:11434"
@app.post("/embed")
async def embed(text: str):
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{EMBEDDING_SERVICE}/api/embeddings",
json={"model": "bge-m3", "prompt": text},
)
return resp.json()
这条对开发体验的提升是最大的(FastAPI 改一次重启 0.5 秒),代价是要多维护一个推理服务进程。但这是生产环境本来就该长的样子——业务和模型推理本就不该耦合在一个进程里。所以"开发时这么做"其实是在用开发体验逼近最终架构,是顺路把架构债提前还了。
实战里怎么选
我自己的工作模式:刚搭新项目、模型行为还在调时,用第一条(关 reload,手动重启)。模型稳定、主要在改业务时,切到第二条(reload + 懒加载)。项目走到要持续维护的阶段,搬到第三条(独立推理服务)。三条不互斥,按项目阶段切。
最坑的是从来不切——一直在 reload 模式下硬扛 30 秒等待,一上午心智被磨干净。
这件事的更广视野
uvicorn + 本地大模型的这个困境,其实是 Python 进程隔离模型 + 大对象生命周期这个老问题的一个具体表现。所有几百 MB 起步的 ML 资产在 Python 服务里都会撞上同一堵墙——除了 embedding 模型,还有 LLM 本地部署、大词表 tokenizer、向量索引(FAISS 索引动辄几 GB)、加载好的 spaCy 大模型、CV 领域的 YOLO / SAM 权重。
根源是 Python 没有 JVM 那种"长生不老"的运行时——它本来就是一个解释器进程,进程死了什么都没了。这条路上社区的解法基本就是三类:
常驻服务化——把模型独立部署成长跑服务(Triton、vLLM、Ollama、TGI),业务进程通过 HTTP / gRPC 调用。模型加载发生一次,几个小时甚至几天才重启一次,开发循环秒级。这是工业上的标准做法。
共享内存 + IPC——上面提过的 shared_memory + RPC 方案,理论行得通但工程复杂度高,需要自己处理生命周期、版本、错误。除非有非常硬的延迟要求(每次 HTTP 调用的几毫秒都不能多),否则不值得。
轻量化开发循环——mock 模型、用更小的同族模型(bge-small 80 MB 而不是 bge-m3 2 GB)跑开发回路、关键路径再用真模型验证。
第一条是真正的长治久安——如果你在写一个会长期维护的 AI 服务,把"业务"和"模型推理"解耦应当是默认架构。它一并解决了 reload 痛、多服务复用同一份模型权重、模型版本独立升级、GPU 资源隔离等好几个问题。
一句话收尾
--reload 杀进程换新的、新进程没历史;2 GB 的 BGE-M3 重新从盘加载。这不是 bug,这是 Python 进程模型的本质。开发时要么关 reload、要么把模型懒加载、要么把模型搬出主进程——三条路任挑一条,但不要再期待单例或全局变量能救你。
参考资料
- uvicorn 启动选项文档 —
--reload的官方说明 - WatchFiles 项目 — uvicorn reload 用的文件监控库
- Python
multiprocessing.shared_memory文档 - Triton Inference Server — NVIDIA 出的工业级模型服务
- vLLM — 高吞吐 LLM 推理服务
- Ollama — 最简单的本地模型常驻服务,开发场景神器
- Copy-on-write fork in Python — 解释为什么 CoW 在 Python 大对象场景下几乎无效
版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。
(采用 CC BY-NC-SA 4.0 许可协议进行授权)
本文标题:番外 16:uvicorn --reload 与本地大模型的相处难题
本文链接:https://www.sshipanoo.com/blog/ai/ai-for-python/番外16-uvicorn-reload与本地模型/
本文最后一次更新为 天前,文章中的某些内容可能已过时!