资讯详情 FastAPI+SQLite打造轻量网页版YOLO训练平台
📅 2026/10/22 5:33:59
去年下半年一个做工业缺陷检测的朋友来找我说他们团队已经受够了命令行。数据标注那边的小姑娘把几千张图片标完了想立刻跑一轮 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 几乎是当前最优解。为什么不是 FlaskFlask 不是不行但 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_length64) model_size: str Field(n, pattern^(n|s|m|l|x)$) epochs: int Field(100, ge1, le500) batch_size: int Field(16, ge1, le128) imgsz: int Field(640, ge320, le1280) device_id: int Field(0, ge0, le7) dataset_id: str Field(..., min_length8, max_length64)这段代码既是校验规则又是接口文档的一部分。前端同学直接拿我的/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_threadFalse) conn.execute(PRAGMA journal_modeWAL) conn.execute(PRAGMA busy_timeout5000) conn.execute(PRAGMA synchronousNORMAL)这三条分别解决并发读写锁粒度、遇到锁时的等待时间、以及同步刷盘的性能开销。配合使用之后我在实测中几乎没有再遇到锁问题。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, stdoutlog_file, stderrsubprocess.STDOUT) # 把子进程 PID 写进数据库后续取消任务、孤儿清理都要用 update_task(task_id, statusrunning, pidproc.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 数据集上传这一步坑最多上传功能看起来简单实际上是个重灾区。首选要处理的就是大文件上传。用户在标注工具里导出的数据集压缩包动辄几个 GBFastAPI 默认能处理但 uvicorn 如果前面再挂 nginxnginx 默认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 文件里的路径写的是绝对路径解压到另一台机器上直接就废了。我的校验逻辑写在解压之后检查 images 和 labels 目录是否存在检查 train 和 val 子目录是否都存在读取 data.yaml把里面的path字段改成服务器上的绝对路径统计图片文件数量和标签文件数量对比超过 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 fyolo{cfg[model_size]}.pt # ultralytics 会自动下载 model YOLO(model_path) model.train( datacfg[data_yaml], epochscfg[epochs], batchcfg[batch_size], imgszcfg[imgsz], devicecfg[device_id], projectstr(Path(RUN_DIR) / task_id), nameweights, exist_okTrue, workers4, )执行器本身不直接写数据库它负责把 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}后端去数据库查一行、返回 JSONSQLite 对这种读请求的响应是亚毫秒级的完全不用优化WebSocket 需要前后端同时维护长连接状态训练任务本来就是频繁状态变化但事件量不大的场景WebSocket 的优势不明显复杂度倒是很明显。但有一个例外如果你在页面里做了一个实时更新损失曲线的特效希望曲线像流水一样丝滑那 WebSocket 是必要的。否则轮询够用至少我目前这个版本完全没感觉到卡顿。4.5 权重产物下载直接暴露静态文件就行训练结束后best.pt和last.pt会出现在任务目录下的weights文件夹里。最简单的暴露方式就是让 FastAPI 挂一个静态目录app.mount(/artifacts, StaticFiles(directory/opt/yolo_web/data/train_runs), nameartifacts)前端拼一个 URL 就能下载。要注意的是安全边界因为整个工具运行在内网这个静态目录直接暴露没有太大风险。如果哪天真要放到公网这个挂载点必须加鉴权中间件否则任何人都能下载你的模型文件。5. 单机部署的并发与稳定性我踩过最深的几个坑5.1 uvicorn 多 worker 是第一个大坑做 Web 服务的人习惯性会用多个 worker 来提升并发但在这种Web 服务 后台任务混合架构下多 worker 会引入一个致命问题训练子进程是被某个 worker 拉起的但前端轮询请求可能被另一个 worker 处理导致后端查不到对应任务的状态。举个例子用户创建了一个任务请求恰好落到了 worker AA 启动了训练子进程并写入了数据库。紧接着前端轮询请求被负载到 worker BB 去数据库查询状态发现任务确实存在因为状态是共享的这没问题。问题的关键在于如果用户想取消任务取消请求落到了 worker BB 拿着训练子进程的 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_timeout5000之后MySQL 风格的等待锁释放逻辑就有了体验瞬间稳定。另外还要注意连接池问题。FastAPI 中如果每个请求都新开一个 SQLite 连接文件句柄会爆炸。我给 FastAPI 配了依赖注入的数据库连接管理每个请求复用同一个连接def get_db(): conn sqlite3.connect(DB_PATH, check_same_threadFalse) try: yield conn finally: conn.close()check_same_threadFalse这个参数不能省因为 FastAPI 的异步协程可能在不同线程间切换默认情况下 SQLite 会拒绝跨线程使用连接。5.3 单 GPU 的资源冲突并发提交两个任务会把自己卡死单机上只装了一块 GPU但用户完全可能同时提交两个训练任务。如果两个进程同时调用model.train(device0)显存不够的一方会直接报 CUDA OOM然后整个训练失败。我的解决方案很简单在system_config表里维护一把全局锁。创建任务前后端检查锁是否被占用占用则把任务状态置为queued否则把锁置为占用并把状态置为running。训练结束时训练回调负责释放锁并检查是否有排队的任务有则触发启动下一个。这个设计比让用户手动管理等 GPU 靠谱得多。需要注意的是锁的释放逻辑要写透训练失败、用户取消、进程被杀这三种情况都要把锁释放出来否则后面积压的任务会一直死等。5.4 守护与自启动systemd 是内网机器的最佳伴侣内网部署的机器通常没有专业运维必须尽量做到断电重启之后服务自己回来。我用 systemd 管理两个服务[Unit] DescriptionYOLO Web Service Afternetwork.target [Service] Typesimple Useryolo WorkingDirectory/opt/yolo_web EnvironmentPATH/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 Restartalways RestartSec5 [Install] WantedBymulti-user.targetRestartalways加上RestartSec5意味着 FastAPI 服务挂掉后五秒自动拉起。启动时还要在 app 初始化里做一件事扫描所有状态为running但没有对应存活进程的任务统一标记为failed错误信息写服务重启导致训练中断。否则就会出现我前面说的孤儿任务问题用户看到任务永远在跑实际上进程早没了。共享 GPU 的小技巧如果 Web 服务和训练进程跑在同一用户下开机自启动服务时尽量不要设EnvironmentDISPLAY这类图形环境变量Ultralytics 有时候会尝试初始化 matplotlib 的显示后端导致无头环境报错。在 worker 脚本里显式设matplotlib.use(Agg)能规避这个问题。6. 实测效果一次 100 轮训练下来的真实数据6.1 训练任务规模对 SQLite 完全没压力我在配置完这套系统后用一个约 4000 张图的缺陷检测数据集做了一次完整训练模型是 yolo11nepochs 100batch 16。整个训练过程大约 1 小时 40 分钟期间指标结果tasks 表记录数约 120 条包含测试数据training_events 表记录数约 2500 条每个 epoch 2-3 条事件SQLite 文件大小3.2 MB网页轮询接口响应时间1-3 ms日志文件大小约 25 MBINFO 级别日志比较多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 掉主进程 PIDDataLoader 的子进程可能还活着继续占着显存或内存。正确的做法是 kill 整个进程组import os, signal os.killpg(os.getpgid(pid), signal.SIGTERM)但这样要求创建进程时设置start_new_sessionTrue让训练进程成为新的会话组长。我改造完这一块之后取消任务才能彻底释放 GPU。这是一个非常经典的看起来取消了实际上没完全取消的问题建议做训练平台的朋友都检查一下自己代码里有没有同样问题。6.3 踩过的坑torch 和 Web 进程的 fork 冲突训练子进程是subprocess.Popen从 FastAPI 里 spawn 出来的理论上和 Web 进程完全独立。但某个版本下训练进程的 DataLoader 默认用fork方式创建多进程如果启动 Web 服务时正在使用某些线程比如 uvicorn 的线程池fork 出来的子进程有概率继承到父进程的锁状态导致死锁。规避方法有两个一是训练脚本内部固定start_methodspawn二是训练数据加载的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表上做更细的可视化把学习率曲线、验证集损失一起画出来。核心思路就是别急着推翻架构先看看现有数据能不能支撑更多需求撑不住了再升级也不迟。