单例不能救你——这不是 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、要么把模型懒加载、要么把模型搬出主进程——三条路任挑一条,但不要再期待单例或全局变量能救你。

参考资料

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:番外 16:uvicorn --reload 与本地大模型的相处难题

本文链接:https://www.sshipanoo.com/blog/ai/ai-for-python/番外16-uvicorn-reload与本地模型/

本文最后一次更新为 天前,文章中的某些内容可能已过时!