☰
FastAPI+SQLite打造轻量网页版YOLO训练平台
2026/10/22 5:33:54 网站建设 项目流程

去年下半年,一个做工业缺陷检测的朋友来找我,说他们团队已经受够了命令行。数据标注那边的小姑娘把几千张图片标完了,想立刻跑一轮 YOLO 看看效果,但训练要配环境、要敲命令、要盯日志,完全不是非技术角色能上手的事。懂训练参数的工程师又在忙别的,每次迭代都要人肉传话,一来一回小半天就没了。

他要的东西很明确:内网一台机器,打开浏览器,上传数据集压缩包,填几个参数点开始,然后页面上能看到训练进度、损失曲线,训完直接下载 best.pt。听起来不复杂,但真正做起来要面对一堆选择题——任务队列怎么做、进度怎么回传、训练进程挂了怎么办、SQLite 能不能扛住并发读写、要不要上 Redis 和 Celery。

这篇文章就把我当时的需求设计和技术选型完整梳理一遍。你如果也在做类似的"网页版训练"小项目,或者正纠结 FastAPI + SQLite 这个组合到底行不行,可以直接参考我这套方案,能少走不少弯路。

1. 先把场景说清楚:谁在什么情况下需要网页版训练

1.1 需求画像不是"做一个训练平台"

很多人一听"网页版 YOLO 训练",第一反应就是做一个对标 Colab 或者训练中台的复杂系统:多用户、配额管理、分布式 GPU 调度、模型仓库、权限体系……我一开始也差点往这个方向想,但坐下来跟实际使用的人聊了半小时,发现需求根本没那么大。

使用场景就三类人:

  • 标注团队:标完一批数据,想快速验证模型能不能收敛,他们只需要"上传数据集 → 点一个按钮 → 看结果"。
  • 算法工程师自己:在实验室或者工位上,不想每次训练都在命令行里输入一长串 yolo train 参数,尤其当机器不在身边、远程 SSH 不方便的时候,浏览器页面比终端友好得多。
  • 团队负责人:需要一眼看到当前有几轮训练在跑、各自处于什么阶段,而不是挨个问人。

这三类人都用同一台内网机器。机器数量是一台,GPU 是一块,用户规模是几个人到十几个人。这个规模决定了后面的所有技术选型都可以往"简单、皮实、能跑"方向走,根本不需要往"分布式、高并发、可扩展"方向卷。

1.2 需求边界:明确"不做"什么

在动手写代码之前,我最先列出来的不是要做哪些功能,而是不做什么:

  • 不做多用户权限系统:内网小团队,能访问到这台机器的人基本都可信。我用一个很轻的登录口令就够了,不引入用户体系。
  • 不做 GPU 资源池调度:只有一块 GPU,任务来了就排队,一次只跑一个,不需要复杂的队列调度器。
  • 不做分布式训练:单机单卡,YOLO 训练本来就是单机程序,不需要分布式通信。
  • 不做数据标注功能:标注在 LabelImg 或 X-AnyLabeling 这类工具里完成,平台只负责接收数据集压缩包。

把边界划清楚有个巨大的好处:后面所有的技术选型都会因此简化。比如没有用户系统,就不需要 JWT、不需要 OAuth;没有分布式,就不需要 etcd、不需要 Redis;没有多租户,就不需要考虑数据隔离。你如果刚开始做类似项目,一定先花时间把"不做什么"写下来,这比"要做什么"更能帮你省钱省时间。

1.3 单机部署的天然定位:内网工具,不是公网产品

"单机部署"四个字,本身就是一种架构宣言。它意味着:

  • 所有服务跑在同一台物理机上,目标不是水平扩容,而是故障恢复时尽量省事;
  • 数据不用跨机房同步,存储直接落在本地磁盘;
  • 并发量极低,每秒钟可能就几个请求,但单个请求背后挂着一个能吃满 GPU 的训练任务。

这种定位决定了我在技术选型时可以把"一致性""可扩展性"这些词暂时放一边,把"开发效率""排查方便""资源占用小"放在第一位。理解了这层,你就能理解为什么我会用 FastAPI 而不是 Spring Boot,用 SQLite 而不是 MySQL——不是它们不能做,而是这个场景用重型方案纯属自己给自己找不痛快。

2. 技术选型:我为什么死磕 FastAPI + SQLite 这个组合

2.1 FastAPI 是这个场景里最顺手的那把刀

先把结论放这:如果你也是纯 Python 技术栈、做内部工具、单机部署,FastAPI 几乎是当前最优解。

为什么不是 Flask?Flask 不是不行,但 FastAPI 在三个点上明显更适合这个项目:

第一,异步原生,和训练任务的 IO 等待天然匹配。网页端不停地轮询任务状态,后端需要频繁读库、读文件、返回 JSON,这些操作大部分是 IO 等待。FastAPI 的 async 特性配合异步数据库驱动,不用写线程池就能扛住这种"小请求量但频率高"的轮询请求。Flask 默认同步,高频率轮询时得自己调线程池。

第二,基于 Pydantic 的请求校验省掉大量防御代码。前端提交的训练参数有好几个:模型尺寸、epochs、batch_size、imgsz、device。如果用手写代码校验类型和范围,至少要写三四十行。FastAPI 直接在路由函数签名里声明参数类型,不符合要求的请求在进入业务逻辑前就被拦住了。

from pydantic import BaseModel, Field class TrainTaskCreate(BaseModel): name: str = Field(..., max_length=64) model_size: str = Field("n", pattern="^(n|s|m|l|x)$") epochs: int = Field(100, ge=1, le=500) batch_size: int = Field(16, ge=1, le=128) imgsz: int = Field(640, ge=320, le=1280) device_id: int = Field(0, ge=0, le=7) dataset_id: str = Field(..., min_length=8, max_length=64)

这段代码既是校验规则,又是接口文档的一部分。前端同学直接拿我的/docs页面就能看到完整字段规范,省了一轮沟通。

第三,自动生成的 OpenAPI 文档对内部工具极其友好。FastAPI 启动后自带/docs和/redoc页面,所有接口的入参、出参、错误码一目了然。对于没有独立前端工程师的小项目,这几乎是白嫖的接口文档。

对比下来,Django REST Framework 在这个场景里太重了——它的 ORM、Admin、迁移体系对单机小工具来说都是负担。如果项目未来确定会扩张成多机、多团队、多租户,那从一开始上 Django 没问题;但纯做一个内部训练工具,FastAPI 的开发效率高得多。

2.2 SQLite 到底行不行:算完账心里就有底了

说实话,我在决定用 SQLite 的时候也有过犹豫。数据库选型在很多人眼里是件严肃的事,上来就问"并发多少?数据量多大?",然后轻飘飘一句"SQLite 不适合生产环境"。但那是针对互联网业务,不是针对单机内部工具。

我先把最担心的问题列出来,然后一项项算:

并发问题。网页端轮询和训练进程写进度是同时发生的,确实有并发读写。但 SQLite 的 WAL 模式专门解决了"读多写少"场景的并发瓶颈。Web 服务这边开一个连接,训练回调那边再开一个连接,只要把busy_timeout设好,实际并发几百个短查询完全没有问题。

数据量问题。训练任务一天跑十次,一年也就三千多条记录。加上训练事件表,每条训练每个 epoch 写一条事件,一年下来撑死几万条。SQLite 单文件处理几万条记录是毫秒级,完全不需要一个独立数据库服务。

运维问题。这才是最大的杀招。MySQL 要装服务、配账号、管备份、调参数、处理连接池;SQLite 就是一个文件,备份等于复制文件,迁移等于移动文件,排障可以随时打开看。对于一台谁都不想花太多心思维护的内网机器,SQLite 等于零运维。

唯一要记住的是:连接 SQLite 后必须手动执行几条 PRAGMA,否则默认配置在并发场景下确实会报database is locked。

import sqlite3 conn = sqlite3.connect(DB_PATH, check_same_thread=False) conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA busy_timeout=5000") conn.execute("PRAGMA synchronous=NORMAL")

这三条分别解决:并发读写锁粒度、遇到锁时的等待时间、以及同步刷盘的性能开销。配合使用之后,我在实测中几乎没有再遇到锁问题。

2.3 训练任务怎么异步:为什么我放弃 Celery + Redis

这是整个选型里最值得讲的一个点。训练任务本质是一个长时间运行的计算任务,肯定不能直接放在 HTTP 请求里同步执行。业界标准答案一般是 Celery + Redis,但在单机场景下,这套东西带来的复杂度大于它解决的问题。

先说为什么不能同步:YOLO 训练一轮要几分钟到几十分钟,如果 FastAPI 的请求处理函数里直接subprocess.run(),这个请求会一直挂着。浏览器那边要么超时断开,要么以为请求失败了。而且 FastAPI 的 worker 会被这个训练任务占住,其他请求全部排队,整套服务处于半瘫痪状态。

于是很多人就想到 Celery。但 Celery 解决的是"分布式任务队列调度",它需要额外维护一个 broker(通常是 Redis)和多个 worker 进程。单机部署下,Redis 是一笔多余的内存占用,Celery worker 和训练进程之间还要解决环境变量传递、模型产物路径同步等问题,复杂度不降反升。

我的方案很朴素:用 Web 进程直接 spawn 一个独立的训练子进程,训练进程通过写数据库来汇报状态。核心逻辑是:

import subprocess import uuid def launch_train_worker(task: dict): task_id = task["id"] cmd = [ "/opt/miniconda3/envs/yolo/bin/python", "/opt/yolo_web/worker/train_worker.py", "--config", f"/opt/yolo_web/data/task_configs/{task_id}.json", ] # 重定向日志到文件,日志文件同时供网页端查阅 log_file = open(f"/opt/yolo_web/data/logs/{task_id}.log", "w") proc = subprocess.Popen(cmd, stdout=log_file, stderr=subprocess.STDOUT) # 把子进程 PID 写进数据库,后续取消任务、孤儿清理都要用 update_task(task_id, status="running", pid=proc.pid)

这个方案的好处有几个:

  • 崩溃隔离:训练代码和 Web 服务互不干扰,训练进程 OOM 或崩溃不会把 FastAPI 带崩;
  • 资源解耦:训练进程吃满 CPU 或 GPU,也不会阻塞 Web 服务响应健康检查;
  • 符合直觉:一个任务对应一个进程、一个日志文件、一行数据库记录,排查问题时心智负担极小。

训练进程内部的进度汇报,我用的是 Ultralytics 的回调机制,而不是解析终端日志。在train_worker.py里注册一个on_train_epoch_end回调,每个 epoch 结束时把 loss、mAP、当前 epoch 写入一个进度 JSON,再由 Web 端负责把进度 JSON 同步进 SQLite。这样避免了从日志里 grep 数据的脆弱做法,准确率更高。

from ultralytics import YOLO def on_epoch_end(trainer): metrics = trainer.metrics # 走 HTTP 回调或直接写进度 json,我选择了直接写 json + 数据库 write_progress(trainer.task_id, { "epoch": trainer.epoch, "epochs": trainer.epochs, "loss": metrics.pop("train/loss", 0), "mAP50": metrics.get("metrics/mAP50(B)", 0), })

这一套下来,我完全不需要 Redis,不需要额外的 worker 进程,架构图上只有"FastAPI 服务 + 若干训练子进程 + SQLite 一个文件",非常简单清楚。

3. 需求设计的枢纽:任务状态机和数据表长什么样

3.1 状态机是这个平台的心脏

如果你看过我其他项目的代码就会知道,我习惯先把状态流转图画清楚再写表结构。这个项目里,任务状态是所有功能的基础,前端展示、后端校验、取消逻辑、恢复逻辑全都围着它转。

整个任务的生命周期是:

pending(刚创建,还没开始处理)→queued(等待 GPU 空闲)→running(训练子进程已拉起)→success(训练结束,权重产物已生成)或failed(训练过程中异常退出)或canceled(用户主动取消)。

几个容易忽略的细节:

pending 和 queued 为什么要分开。如果创建任务后有人还没上传数据集就点开始,或者前一个任务还在跑 GPU,新任务就得排队。pending表示数据检查中,queued表示检查通过、在等 GPU 锁。分开以后,前端可以清楚告诉用户"你的数据集正在校验"还是"你的任务在排队等显卡"。

所有状态迁移必须记录时间。started_at、finished_at这两个字段看似简单,但后期做任何统计(比如平均训练时长、失败率)都离不开它们。状态机设计里最容易犯的错就是只记录当前状态不记录迁移时间。

终止状态必须包括失败原因。failed状态不能只给一个光秃秃的状态值,必须配套error_message字段。实际运行中 90% 的失败来自数据集目录结构不正确、显存不足、路径带中文这几种原因,没有错误信息的话用户完全不知道发生了什么。

3.2 数据表设计:五张表讲清楚整个业务

SQLite 的文件路径定在/opt/yolo_web/data/app.db,表的设计我尽量精简,最终只留了五张核心表:

CREATE TABLE datasets ( id TEXT PRIMARY KEY, name TEXT NOT NULL, archive_path TEXT NOT NULL, extracted_path TEXT NOT NULL, file_count INTEGER, status TEXT DEFAULT 'ready', created_at DATETIME ); CREATE TABLE tasks ( id TEXT PRIMARY KEY, name TEXT NOT NULL, dataset_id TEXT NOT NULL, model_size TEXT NOT NULL, epochs INTEGER, batch_size INTEGER, imgsz INTEGER, device_id INTEGER, status TEXT DEFAULT 'pending', progress_json TEXT, -- 存放 epoch、loss、mAP 等最新进度 pid INTEGER, -- 训练子进程 PID error_message TEXT, created_at DATETIME, started_at DATETIME, finished_at DATETIME ); CREATE TABLE training_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL, event_type TEXT, -- epoch_end / error / canceled / checkpoint payload TEXT, -- 结构化的 JSON 数据 created_at DATETIME ); CREATE TABLE artifacts ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT, type TEXT, -- weights / logs / confusion_matrix file_path TEXT ); CREATE TABLE system_config ( key TEXT PRIMARY KEY, value TEXT );

这套表结构的最重要特点是:任务主表只存最新状态,历史细节全部丢给training_events表。training_events是追加式的,前端画损失曲线、画 mAP 曲线都从这张表取数据。两个表分工明确:主表面向"现在什么情况",事件表面向"过程发生了什么"。

3.3 进度数据写库频率:太多会锁库,太少会卡顿

训练回调往数据库写进度的时候,频率控制是个容易被低估的坑。YOLO 一个 epoch 通常几十秒到几分钟不等,回调在每个 epoch 结束时触发一次,这个频率对 SQLite 非常友好。

但我第一次做的时候犯过一个错误:不仅每个 epoch 写一次,还在每个 batch 结束的时候也去写数据库,结果 Web 端的 SQLite 频繁被打到database is locked。后来我把策略调整为:

  • 每个 epoch 结束:写一条training_events,更新任务主表的progress_json;
  • 每个 batch 结束:只把数据写进内存里的一个临时 dict,下一个 epoch 结束时一次性处理;
  • 每 30 秒:Web 端从数据库读一次最新进度,用于前端展示。

这样数据库写入频率降到了一个 epoch 一次,SQLite 完全无压力。前端那边的轮询也不用太激进,3 到 5 秒一次足够平滑。

3.4 文件存储目录规划

数据库只存元数据,真正的数据集和权重文件散落在磁盘上。目录结构从一开始就规划好,避免后期想清理却不知道文件在哪:

/opt/yolo_web/ ├── app.py # FastAPI 入口 ├── worker/ │ └── train_worker.py # 训练子进程执行器 ├── data/ │ ├── app.db # SQLite 数据库文件 │ ├── uploads/ # 临时存放上传的压缩包 │ ├── datasets/ # 解压后的数据集目录 │ ├── train_runs/ │ │ └── <task_id>/ # 每个任务一个目录 │ │ ├── weights/ # best.pt, last.pt │ │ └── logs/ # 指向详情页的日志 │ └── task_configs/ # 每个任务的参数 JSON

有个细节值得提醒:不要在任务完成后清理数据集。我一开始觉得反正任务完了,数据集压缩包没用了,直接删掉省磁盘。结果用户第二天想复跑一轮调参数,发现数据集没了,只能重新上传。后来我的策略是:压缩包解压后保留解压目录,只在创建任务时加一个"该数据集被几个任务引用过"的字段,用户主动删除数据集时才清理目录。

4. 端到端跑通:从上传数据集到下载权重的完整链路

4.1 数据集上传这一步,坑最多

上传功能看起来简单,实际上是个重灾区。首选要处理的就是大文件上传。用户在标注工具里导出的数据集压缩包动辄几个 GB,FastAPI 默认能处理,但 uvicorn 如果前面再挂 nginx,nginx 默认client_max_body_size只有 1MB,必须显式调大:

nginx.conf client_max_body_size 10240m;

另外,上传接口最好做成"先落盘再解析",而不是"边接收边解析"。我的做法是前端先把文件上传到一个临时路径,后端收到后立刻返回一个dataset_id,然后再异步解压、校验、更新状态。这样用户在浏览器界面上看到的是"上传中 → 校验中 → 就绪"这样的进度条,而不是一直白屏等转圈。

解压后的目录结构校验是另一个大坑。YOLO 训练要求的数据目录是有固定结构的:

dataset/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── data.yaml

很多用户压缩包里的目录结构五花八门,有的把 images 和 labels 放在根目录下没分 train/val,有的 yaml 文件里的路径写的是绝对路径,解压到另一台机器上直接就废了。

我的校验逻辑写在解压之后:

  1. 检查 images 和 labels 目录是否存在;
  2. 检查 train 和 val 子目录是否都存在;
  3. 读取 data.yaml,把里面的path字段改成服务器上的绝对路径;
  4. 统计图片文件数量,和标签文件数量对比,超过 5% 差异就拒绝这个数据集。

最后一步最重要,它能拦住绝大多数的"标注完了发现标签文件导出不全"的问题。这个校验如果放在训练开始后才发现,用户会花很长时间排队,心情会很差。

4.2 创建任务:设计一个让非技术用户能看懂的表单

任务创建接口收到前端的参数后,会做两件事:生成任务 ID、组装参数 JSON、把任务状态置为queued。为了照顾标注团队的同事,我把前端表单设计成了"选数据集 → 选模型规格 → 填训练轮数 → 开始训练"四步,模型规格里的 s/m/l/x 对应到 Ultralytics 的模型文件。

这里有个参数默认值的建议:epochs 不要默认给 300。我第一次给默认 300,测试的时候忘了改,直接训了一下午。非技术用户不太懂 epoch 意味着什么,如果默认值太大,他们很容易点一次就占住 GPU 半天。我最后把默认值设为 100,并且在前端加了醒目提示:"模型将在达到指定轮数后自动停止,训练时长约 1-2 小时。"

4.3 训练执行器:子进程、日志和数据库三方协作

训练子进程的核心代码逻辑是这样的:

def main(): cfg = load_config(sys.argv[1]) task_id = cfg["task_id"] model_path = f"yolo{cfg['model_size']}.pt" # ultralytics 会自动下载 model = YOLO(model_path) model.train( data=cfg["data_yaml"], epochs=cfg["epochs"], batch=cfg["batch_size"], imgsz=cfg["imgsz"], device=cfg["device_id"], project=str(Path(RUN_DIR) / task_id), name="weights", exist_ok=True, workers=4, )

执行器本身不直接写数据库,它负责把 Ultralytics 的指标收集起来,通过一个写文件的回调同步出去。为什么不让子进程直连 SQLite?因为训练进程和 Web 进程是两个独立的进程,都需要各自打开数据库连接,虽然 WAL 模式下没问题,但为了减少锁竞争,我在执行器里只写进度 JSON 文件,再由 Web 端的定时任务把 JSON 同步进数据库。你也可以直接写数据库,看自己取舍,只要控制好频率即可。

日志处理同样值得说。subprocess.Popen把 stdout 和 stderr 都重定向到同一个日志文件,这个文件既给用户看(网页端有一个"查看实时日志"的按钮),也给自己排查问题用。日志读取接口我做成"只返回新增部分":

class LogTailEndpoint: def __init__(self): self.offset = {} def read_log(self, task_id: str, client_offset: int) -> tuple[str, int]: path = LOG_DIR / f"{task_id}.log" if path not in self.offset: self.offset[path] = 0 with open(path) as f: f.seek(client_offset) new_content = f.read() new_offset = f.tell() return new_content, new_offset

这样即使日志文件有几 MB 也不会每次全量返回,轮询的流量小很多。

4.4 进度回传:WebSocket 还是轮询?

单机小工具,我强烈建议先用轮询,不要一上来就上 WebSocket。原因很现实:

  • 轮询的实现就是前端 setInterval 每 3 秒请求一次/api/tasks/{id},后端去数据库查一行、返回 JSON,SQLite 对这种读请求的响应是亚毫秒级的,完全不用优化;
  • WebSocket 需要前后端同时维护长连接状态,训练任务本来就是"频繁状态变化但事件量不大"的场景,WebSocket 的优势不明显,复杂度倒是很明显。

但有一个例外:如果你在页面里做了一个"实时更新损失曲线"的特效,希望曲线像流水一样丝滑,那 WebSocket 是必要的。否则轮询够用,至少我目前这个版本完全没感觉到卡顿。

4.5 权重产物下载:直接暴露静态文件就行

训练结束后,best.pt和last.pt会出现在任务目录下的weights文件夹里。最简单的暴露方式就是让 FastAPI 挂一个静态目录:

app.mount("/artifacts", StaticFiles(directory="/opt/yolo_web/data/train_runs"), name="artifacts")

前端拼一个 URL 就能下载。要注意的是安全边界:因为整个工具运行在内网,这个静态目录直接暴露没有太大风险。如果哪天真要放到公网,这个挂载点必须加鉴权中间件,否则任何人都能下载你的模型文件。

5. 单机部署的并发与稳定性:我踩过最深的几个坑

5.1 uvicorn 多 worker 是第一个大坑

做 Web 服务的人习惯性会用多个 worker 来提升并发,但在这种"Web 服务 + 后台任务"混合架构下,多 worker 会引入一个致命问题:训练子进程是被某个 worker 拉起的,但前端轮询请求可能被另一个 worker 处理,导致后端查不到对应任务的状态。

举个例子:用户创建了一个任务,请求恰好落到了 worker A,A 启动了训练子进程并写入了数据库。紧接着前端轮询,请求被负载到 worker B,B 去数据库查询状态,发现任务确实存在(因为状态是共享的),这没问题。问题的关键在于:如果用户想取消任务,取消请求落到了 worker B,B 拿着训练子进程的 PID 去 kill,但它手里根本没有这个进程对象,只能靠 PID 硬找。这在 Linux 下勉强能行,但状态的一致性、日志句柄的管理都会变得混乱。

真正的灾难场景是 worker 重启。uvicorn 任何一个 worker 因为代码 bug 挂掉,Supervisor 把它重新拉起来,但这个 worker 之前启动的训练子进程却成了孤儿,父进程没了,状态永远停在running,没人能更新它。

所以我的最终部署方案是:uvicorn 只开一个 worker,用 supervisor 或 systemd 守护进程,保证 FastAPI 服务本身挂掉后能自动重启。一个 worker 足够应付内网十几个人同时使用的场景。训练子进程由 Web 进程 spawn 出来,父子关系清晰,取消、清理、状态更新都顺理成章。

5.2 SQLite 并发读写:从报错到稳定,只靠三行 PRAGMA

前面说过,SQLite 在 WAL 模式下并发读写表现不错,但实际踩坑过程中发现,仅仅是 WAL 还不够,必须配合busy_timeout。我测试的时候见过最典型的一个报错:

sqlite3.OperationalError: database is locked

发生这个报错通常是因为:训练进度回调在写数据库,同时前端轮询在读数据库,两个操作撞在一起。WAL 模式允许读和写并发,但写和写仍然互斥。我们的场景里写操作频率很低,所以撞上的概率不大,但一旦撞上就是错。加一行busy_timeout=5000之后,MySQL 风格的"等待锁释放"逻辑就有了,体验瞬间稳定。

另外还要注意连接池问题。FastAPI 中如果每个请求都新开一个 SQLite 连接,文件句柄会爆炸。我给 FastAPI 配了依赖注入的数据库连接管理,每个请求复用同一个连接:

def get_db(): conn = sqlite3.connect(DB_PATH, check_same_thread=False) try: yield conn finally: conn.close()

check_same_thread=False这个参数不能省,因为 FastAPI 的异步协程可能在不同线程间切换,默认情况下 SQLite 会拒绝跨线程使用连接。

5.3 单 GPU 的资源冲突:并发提交两个任务会把自己卡死

单机上只装了一块 GPU,但用户完全可能同时提交两个训练任务。如果两个进程同时调用model.train(device=0),显存不够的一方会直接报 CUDA OOM,然后整个训练失败。

我的解决方案很简单:在system_config表里维护一把"全局锁"。创建任务前,后端检查锁是否被占用,占用则把任务状态置为queued,否则把锁置为占用并把状态置为running。训练结束时,训练回调负责释放锁,并检查是否有排队的任务,有则触发启动下一个。

这个设计比让用户手动管理等 GPU 靠谱得多。需要注意的是锁的释放逻辑要写透,训练失败、用户取消、进程被杀这三种情况都要把锁释放出来,否则后面积压的任务会一直死等。

5.4 守护与自启动:systemd 是内网机器的最佳伴侣

内网部署的机器通常没有专业运维,必须尽量做到"断电重启之后服务自己回来"。我用 systemd 管理两个服务:

[Unit] Description=YOLO Web Service After=network.target [Service] Type=simple User=yolo WorkingDirectory=/opt/yolo_web Environment=PATH=/opt/miniconda3/envs/yolo/bin:/usr/bin:/bin ExecStart=/opt/miniconda3/envs/yolo/bin/uvicorn app:app --host 0.0.0.0 --port 8000 --workers 1 Restart=always RestartSec=5 [Install] WantedBy=multi-user.target

Restart=always加上RestartSec=5意味着 FastAPI 服务挂掉后五秒自动拉起。启动时还要在 app 初始化里做一件事:扫描所有状态为running但没有对应存活进程的任务,统一标记为failed,错误信息写"服务重启导致训练中断"。否则就会出现我前面说的"孤儿任务"问题,用户看到任务永远在跑,实际上进程早没了。

共享 GPU 的小技巧:如果 Web 服务和训练进程跑在同一用户下,开机自启动服务时尽量不要设Environment=DISPLAY这类图形环境变量,Ultralytics 有时候会尝试初始化 matplotlib 的显示后端,导致无头环境报错。在 worker 脚本里显式设matplotlib.use("Agg")能规避这个问题。

6. 实测效果:一次 100 轮训练下来的真实数据

6.1 训练任务规模对 SQLite 完全没压力

我在配置完这套系统后,用一个约 4000 张图的缺陷检测数据集做了一次完整训练,模型是 yolo11n,epochs 100,batch 16。整个训练过程大约 1 小时 40 分钟,期间:

指标结果
tasks 表记录数约 120 条(包含测试数据)
training_events 表记录数约 2500 条(每个 epoch 2-3 条事件)
SQLite 文件大小3.2 MB
网页轮询接口响应时间1-3 ms
日志文件大小约 25 MB(INFO 级别日志比较多)

3.2MB 的数据库,MySQL 光初始化就要几十 MB 的任务量,完全不是一个量级。SQLite 在"小团队 + 低频写入"的场景下,性能上绰绰有余。

轮询接口响应这么快,还有一个原因是前端每次请求只回传当前任务状态,不重新读取整个事件表。画曲线时前端单独拉一次历史上所有 epoch 的数据,这个请求稍慢一点,大概在 30-50ms,也在承受范围之内。

6.2 踩过的坑:kill 命令杀掉的不是训练进程

这个坑花了我大半天时间排查。团队成员反馈说取消任务后,GPU 显存监控显示还是被占着,但网页上的任务状态已经变成canceled。我查了代码发现,取消接口的逻辑是找到pid字段,然后kill(pid)。问题出在train_worker.py里:

proc = subprocess.Popen(cmd, ...) pid = proc.pid

这个cmd是系统 Python 解释器去跑训练脚本。但 YOLO 训练内部会启用 DataLoader 的多个子进程来加载数据,这些子进程和主训练进程是同一个进程组的。如果你只 kill 掉主进程 PID,DataLoader 的子进程可能还活着,继续占着显存或内存。

正确的做法是 kill 整个进程组:

import os, signal os.killpg(os.getpgid(pid), signal.SIGTERM)

但这样要求创建进程时设置start_new_session=True,让训练进程成为新的会话组长。我改造完这一块之后,取消任务才能彻底释放 GPU。这是一个非常经典的"看起来取消了,实际上没完全取消"的问题,建议做训练平台的朋友都检查一下自己代码里有没有同样问题。

6.3 踩过的坑:torch 和 Web 进程的 fork 冲突

训练子进程是subprocess.Popen从 FastAPI 里 spawn 出来的,理论上和 Web 进程完全独立。但某个版本下,训练进程的 DataLoader 默认用fork方式创建多进程,如果启动 Web 服务时正在使用某些线程(比如 uvicorn 的线程池),fork 出来的子进程有概率继承到父进程的锁状态,导致死锁。

规避方法有两个:一是训练脚本内部固定start_method="spawn",二是训练数据加载的workers参数不要给太大,4 到 8 就够了。我最后两个都做了,从此再没出现过训练子进程假死的现象。

6.4 踩过的坑:前端轮询频率太高会把 SQLite 打成 SQLITE_BUSY

调试时发现一个诡异的现象:打开两个浏览器页面同时刷任务列表,过一段时间就报 SQLITE_BUSY。查了半天才明白,是前端代码把轮询间隔写成了 300ms——如果用户开了多个标签页,每 0.3 秒一个查询,即使单个查询很快,也会在写入和读取之间造成大量锁竞争。

而且 SQLite 的锁是文件级锁,300ms 的轮询间隔下,每个请求都会尝试开启读事务,很快就和训练进程的写入撞车。

我把轮询间隔统一改成 3 秒,并在后端做了简单的内存缓存:查询结果 1 秒内不变就返回缓存。这样数据库的读压力降了一个数量级,问题彻底消失。

6.5 磁盘清理与日志增长策略

训练日志和中间产物是磁盘杀手。YOLO 每跑一轮训练会在runs/detect/trainN里存很多中间文件(如混淆矩阵、PR 曲线、标签图片),几十个任务跑下来磁盘空间很快就吃紧。

我的处理策略是:

  • 训练结束时只保留best.pt和last.pt,其余可视化图片和中间权重统一删除(Ultralytics 会自动生成一堆 PNG,占比还挺大);
  • 日志文件按任务保留,用户可以从页面下载,但有一个定时任务清理 90 天前的日志;
  • 数据集目录不做自动清理,由用户在前端主动删除,删除时检查是否被任务引用过。

定时清理用 cron 就行,脚本里写清楚"只处理finished_at超过 90 天的任务目录",避免误删正在跑的任务。

结尾:给同样想做的朋友几句实在话

这套方案做完之后,最大的感受是:工具类项目的复杂度应该尽可能压在代码里,而不是压在架构上。FastAPI + SQLite + 子进程这种组合,看起来不如 Redis + Celery + PostgreSQL 那么"正规",但它恰好匹配了单机部署的需求,开发周期短、出问题好排查、改起来也不心疼。对一个小团队内部工具来说,这三点比什么都重要。

如果你想在这个方案上继续扩展,有几个方向可以优先考虑:对接训练完成的 Webhook 回调,训练结束后自动通知钉钉或企业微信;把训练参数调优能力做成一个简单的随机搜索界面;或者在training_events表上做更细的可视化,把学习率曲线、验证集损失一起画出来。核心思路就是别急着推翻架构,先看看现有数据能不能支撑更多需求,撑不住了再升级也不迟。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询