先说结论:把YOLO训练做成网页版,技术底座用FastAPI加SQLite,单机部署,完全够用。这个结论我花了不少时间才敢下,因为“训练平台”这四个字听起来就很重,容易让人联想到任务队列、分布式调度、复杂权限体系。但当你真正只是想把本地训练流程Web化,让数据集上传、训练启动、进度查看、模型下载都在浏览器里完成时,需求设计和技术选型并没有想象中复杂。下面这套方案,就是我基于实际场景摸索出来的,适合那些“本地训练已经很熟,但想把它变成一个团队可用工具”的开发者参考。
1. 需求设计:先把场景想清楚,再决定做成网页版
在动手写代码之前,我花了不少时间想一个不太像技术的问题:这个网页版到底解决谁的什么痛苦。想清楚这个问题,后面所有架构和选型都不会跑偏。想把YOLO训练搬到网页端的人,通常不是不会用命令行,而是受够了数据集在某个文件夹、训练命令在某个终端、日志在某个窗口、权重散落在多个目录里的无序状态。尤其当一个小团队里有多个人分别跑实验时,每个人都在重复准备数据、重复敲命令、重复翻日志,最后谁也说不清是哪份配置产出了哪份权重。
1.1 什么场景真正需要网页版,什么场景不需要
这个判断很关键。如果只是偶尔训练一两次,而且基本是自己用,命令行比网页快得多,完全没必要做Web。真正值得做的信号有三个:第一,训练是高频重复动作,每次都在改模型规格、batch、imgsz、epochs这些参数,需要一个可留痕的入口;第二,需要把训练过程共享给别人看,比如同事要确认数据是否处理正常,合作方要等模型结果,拉一个网页远比远程终端方便;第三,你希望训练结果成为可追溯的资产,每一次任务从参数配置到评估指标再到权重文件,都能查到记录。
反过来,如果项目还在探索期,数据集本身都没有固定,标注质量忽高忽低,那优先解决的是数据问题而不是平台问题。另一种情况是团队已经用上了成熟的实验管理平台,那就更不需要重复造轮子。这个选择本质上是投入产出比问题:页面工具只能把流程标准化,不能把混乱的数据变干净。
1.2 功能清单:训练过程需要被看见的每一环
我整理需求的方法很简单,就是把一次手工训练的完整动作拆开,逐个动作对应到网页功能。手工训练大概是:数据集整理好,改一份数据配置,敲一行训练命令,盯着输出目录和终端日志,等到结束,翻最后几行指标,把最优权重拷走。针对这套动作,网页版需要五个核心能力:数据集管理、训练任务创建、训练日志与进度展示、评估结果与模型下载、快速推理验证。
数据集管理要能上传打包好的图片和标注文件,能查看样本数量,可以删除或者重建版本。训练任务创建页要能选择数据集、选择模型规格,填训练超参数。训练过程开始后,接口要能返回当前轮数、训练阶段、损失和指标变化,前端页面按固定节奏刷新。训练结束之后,用户能看损失曲线和评估指标,能下载权重文件,也可以直接上传一张图片跑推理看效果,不用手写预测脚本。这些功能全部对应手工动作,没有一个是多余的。
1.3 需求边界:砍掉这些功能,反而更稳定
单机部署这四个字,直接决定了后续所有取舍。我明确不做的功能有:多租户权限系统、多机分布式训练、可视化训练编排、在线数据标注。多租户权限系统看起来很完整,但单机场景下使用的人撑死不超过十个,没有外部注册入口,做一套登录和角色权限会占掉大量时间,带来的安全感并没有那么高。多机分布式更不做,一台工作站真的需要跨机训练时,应该直接用专业调度平台,而不是自己写页面。
在线数据标注属于另一个完整产品,先把它排除掉,让用户用外部标注工具生成标注文件,再导入这套系统就好。边界清晰之后,技术选型的指向就变得非常明确:这套系统本质上是一个训练流程管理工具,负责把分布在各处的状态集中到一块屏幕上,而不是一个万能的完整平台。砍功能不是偷懒,是为了把核心链路做得更稳。
2. 技术选型:为什么FastAPI + SQLite能扛住这个场景
这套方案里最有争议的决策,可能就是选了SQLite。很多人一听训练平台,第一反应就是上MySQL或者PostgreSQL。但单机部署的本质特征是数据量不大、并发不高、运行时间很长。一条训练任务从开始到结束可能跑几个小时甚至几十个小时,这期间Web服务要做的事情并不多:接收任务、记录日志、下发状态。这类低频小写入的场景,SQLite的吞吐量绰绰有余。FastAPI则是另一个方向的自然选择,Python生态里论写异步API的顺手程度,它排第一梯队。
2.1 FastAPI对比Flask、Django:这个场景选谁更顺手
选后端框架的时候,我主要比较的就是Flask、Django和FastAPI。这三个在Python里都很成熟,但对这个场景的适配度差别不小。
| 对比维度 | Flask | Django | FastAPI |
|---|---|---|---|
| 异步原生支持 | 弱,WSGI体系,需额外方案 | 支持,但整体较重 | ASGI原生,接口天然异步 |
| 参数校验 | 手写校验,代码量大 | 有Form和Serializer体系 | Pydantic声明式校验 |
| 接口文档 | 无自动生成 | 管理后台方便,API文档一般 | 自动生成Swagger/ReDoc |
| 学习成本 | 低 | 高,需理解ORM和Admin体系 | 中等,上手很快 |
| 适合场景 | 轻量小服务 | 完整业务系统 | API服务、内部工具 |
Flask写起来灵活,但参数校验、接口文档这些都要自己补。训练任务涉及大量参数输入,前端传错一个类型,如果后端没有准确报错,排查问题的时间会翻倍。FastAPI用Pydantic在接口层做强校验,传错了直接返回400和具体的字段错误,省掉很多沟通成本。Django则是明显重了,为了一个内部工具引入整套ORM、Admin后台、迁移机制,维护成本高于收益。FastAPI配合SQLite操作也很简单,直接用标准库sqlite3加轻量封装就够了,不需要重型数据库驱动。
还有一个日常开发中的隐形优点值得提:FastAPI自动生成的接口文档。用户想在网页上查接口、测接口,或者让同事临时模拟一次请求,打开文档页面就能做。这在团队协作时相当于一份免费说明书,省去很多解释工作。
2.2 SQLite在单机部署下到底够不够用
直接算一笔账就清楚了。假设每天跑十个训练任务,一年也就两三千条任务记录。每个任务按每五个epoch写一条日志,一百轮训练最多写二十条摘要,再加上少量事件记录,日志表一年也就几万行。数据集元数据同样是几百到几万条的水平。这个数据规模放在任何数据库里都没有压力,SQLite的读写性能完全够用。
SQLite是文件型数据库,单机访问没有网络开销,读起来反而比客户端服务器架构的数据库更快。真正要克服的是写入并发问题,默认模式下写操作会锁整个库文件,对长时间运行的训练场景来说不太友好。解决办法是开启WAL模式、设置busy_timeout,并保证每一条事务都很短,后面问题排查部分我会专门展开。另一个隐藏优势容易被忽略:备份极简单。停掉服务,把数据库文件拷走,就是全量备份。内部工具要搬迁或存档,这是最省事的方案。
2.3 前端方案:不引入前端工程也能做出可用页面
前端这块我强烈建议不要上前后端分离工程。我见过太多人为内部工具搭了Vue加ElementUI加Vite的完整前端工程,结果光是维护npm依赖和构建环境就比后端还费劲。这里需要的只是一个能打开页面、能显示数据、能定时刷新的应用,FastAPI直接渲染HTML模板就足够了。
我当时用的是最朴素的方式:一个HTML模板,一段原生JavaScript,用fetch调用接口,setInterval定时刷新。核心页面只有任务列表、任务详情、数据集管理、模型列表这几张。样式上如果嫌原生CSS太素,可以从CDN引入一个现成的UI库,完全不需要本地打包构建。内部工具的取舍标准应该是可维护性,而不是工程规范。前端链路越轻,后面出问题的概率越低,同事接手也容易。
2.4 训练任务进程管理:Web服务与训练进程的分工
FastAPI可以开后台任务,但在同一个进程里直接跑YOLO训练是非常冒险的做法。训练是计算密集任务,会长时间占用CPU和GPU,Web服务一旦因为内存压力重启,训练也就跟着没了。我采用的方式是让FastAPI进程只负责任务调度和状态记录,真正训练用subprocess.Popen启动一个独立worker脚本。worker脚本从命令行接收task_id,读训练配置,调用YOLO训练,再把状态写回数据库。
这样做有三个直接好处。第一,训练进程和Web进程隔离,Web服务重启不会打断正在进行的实验。第二,可以通过系统信号或杀掉进程组的方式安全终止任务,不会出现按了停止但训练还在跑的尴尬。第三,worker的输出日志自然重定向到独立文件,前端按需读取,不占数据库空间。到这里技术选型形成闭环:FastAPI提供接口,SQLite记录状态,subprocess负责计算隔离。
3. 架构设计与数据模型:先画好图再写代码
需求和技术选型都定下来后,剩下的工作是搭骨架。一个清晰的目录结构,决定了后续所有功能是否好加。
3.1 整体目录结构:让模型、数据、日志各归其位
我先给出一个可以直接用的目录结构,按这个布局,后面加功能会非常顺。
my-yolo-web/ ├── app/ │ ├── main.py # FastAPI入口 │ ├── api/ │ │ ├── tasks.py # 任务管理接口 │ │ ├── datasets.py # 数据集管理接口 │ │ └── inference.py # 推理验证接口 │ ├── db.py # 数据库连接与工具函数 │ ├── models.py # Pydantic请求/响应模型 │ └── settings.py # 路径与全局配置 ├── scripts/ │ ├── trainer_worker.py # 独立训练进程 │ └── dataset_prep.py # 数据集预处理/校验 ├── static/ # 前端静态资源 ├── templates/ # HTML模板 └── storage/ ├── datasets/ # 原始数据集 ├── runs/ # YOLO训练输出目录 ├── logs/ # worker日志文件 └── webapp.db # SQLite数据库文件storage下的四个目录和业务一一对应。datasets下每个数据集一个子目录,runs直接交给训练库自己生成,logs按task_id命名,webapp.db是全系统的状态中心。全局参数,比如默认训练轮数、GPU卡号、模型保存目录,全部集中到settings.py。这样各模块互不干扰,找文件、清理磁盘、备份数据都有明确位置,不会出现训练脚本里到处写死路径的情况。
3.2 数据表设计:任务、数据集、日志分开存
数据库我建了三张核心表:datasets、tasks、task_logs。下面是经过实际使用验证过的建表语句。
CREATE TABLE datasets ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, data_yaml TEXT NOT NULL, -- 训练用的数据配置文件路径 image_count INTEGER DEFAULT 0, path TEXT NOT NULL, -- 数据集在磁盘上的根目录 created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, dataset_id INTEGER REFERENCES datasets(id), model_type TEXT NOT NULL, -- yolov8n、yolo11s等基础模型名 params TEXT NOT NULL, -- JSON格式的训练超参数 status TEXT DEFAULT 'pending', -- pending/running/succeeded/failed/canceled stage TEXT DEFAULT '', -- preparing/training/evaluating current_epoch INTEGER DEFAULT 0, total_epochs INTEGER DEFAULT 0, process_pid INTEGER, -- worker进程PID error_message TEXT, -- 失败原因 best_model_path TEXT, -- 最优权重绝对路径 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, started_at DATETIME, finished_at DATETIME ); CREATE TABLE task_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL REFERENCES tasks(id), log TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );每个字段都有明确用途。params用JSON字符串保存全部训练超参数,历史任务可以一键复现。status表示任务的生命周期状态,stage表示当前处于准备数据、训练中还是评估中,二者分离是为了前端能同时展示两个维度。process_pid用来终止任务和探活,best_model_path记录最终产物,模型列表可以直接从这里拿数据。task_logs单独建表,而不是直接读日志文件,是为了方便按任务检索、分页和清理。需要再强调一次:日志写入频率必须控制,这是SQLite锁冲突最主要的来源。
3.3 任务状态机:从排队到完成的全部流转路径
任务状态我用了五个值管理:pending、running、succeeded、failed、canceled。流转关系如下:
| 当前状态 | 触发条件 | 下一状态 |
|---|---|---|
| pending | 调度器检查到没有其他运行中任务 | running |
| running | worker训练流程正常完成 | succeeded |
| running | 训练过程异常退出 | failed |
| running | 用户点击停止或收到取消信号 | canceled |
| pending | 用户主动撤销 | canceled |
| running | 服务重启后自检发现进程不存在 | failed |
状态机设计的核心目标,是让任何时刻都能从数据库看出任务到底在哪一步。其中重启恢复逻辑最容易被忽略:FastAPI服务启动时,要扫描tasks表,把所有卡在running但进程已经不存在的任务统一标记为failed,否则用户会看到永远无法结束的幽灵任务。另一个保护是创建任务前检查状态,如果已经存在running或pending的任务,直接返回“已有任务在执行”的提示。从源头避免并发冲突,比事后处理简单得多。
3.4 并发与队列策略:单GPU环境如何防抖
单机单GPU场景下,显存是硬约束。两个训练任务同时跑基本会把显存打爆,即使没有爆,两者的训练速度也会被严重拖累。最简单可靠的策略就是一次只允许一个训练任务执行。我在创建任务接口里做原子判断:在一个事务里读取当前任务状态表,如果存在running或pending,就拒绝新任务;如果没有,写入新任务并提交。这个判断配合前面的状态机,已经足够应付单机场景,完全不需要引入消息队列。
如果你有双卡并且想并行跑两个任务,可以把互斥维度从全局改成GPU编号。tasks表增加一个gpu_id字段,创建任务时指定卡号,判断条件变成“该GPU上是否存在running任务”。这个扩展路径非常清晰,改动量也很小。队列这块我把话说得直白一点:别为单机工具引入中间件,等真需要集群的时候再换也不迟。
4. 关键实现环节:从上传数据集到启动训练的完整链路
前面都是规划和设计,真正跑起来之后,核心链路其实就是:上传数据、创建任务、启动worker、日志回流、模型沉淀。
4.1 训练Worker脚本:让YOLO训练过程可查询、可中断
worker脚本是整个系统的执行者,核心逻辑拆成三块:读任务配置、启动训练、写状态。下面是一个精简后可以直接参考的实现框架。
import json import signal import sqlite3 import sys import subprocess from datetime import datetime from pathlib import Path DB_PATH = Path("storage/webapp.db") RUNS_PATH = Path("storage/runs") def update_task(task_id, **fields): fields = {k: v for k, v in fields.items() if v is not None} if not fields: return keys = ", ".join(f"{k}=?" for k in fields) values = list(fields.values()) + [task_id] conn = sqlite3.connect(DB_PATH) conn.execute(f"UPDATE tasks SET {keys} WHERE id=?", values) conn.commit() conn.close() def handle_stop(signum, frame): update_task(TASK_ID, status="canceled", finished_at=datetime.now()) sys.exit(0) def train(task_id, task): update_task(task_id, status="running", started_at=datetime.now()) params = json.loads(task["params"]) from ultralytics import YOLO model = YOLO(task["model_type"]) model.train( data=task["data_yaml"], epochs=params["epochs"], imgsz=params["imgsz"], batch=params["batch"], project=str(RUNS_PATH), name=f"task_{task_id}", verbose=True, ) if __name__ == "__main__": signal.signal(signal.SIGTERM, handle_stop) TASK_ID = int(sys.argv[1]) task = load_task(TASK_ID) train(TASK_ID, task)代码里几个点要特别说明。注册SIGTERM信号处理函数后,用户点击停止时worker会先更新任务状态为canceled再退出,避免状态卡在running。日志写入不要高频执行,每个epoch更新一次current_epoch字段就够了,具体loss和指标可以从训练库自动生成的results.csv里解析,没必要全部落库。训练正常完成后,要在函数末尾把最优权重复制到models目录,并更新best_model_path字段,这样runs目录被清理时模型资产也不会丢。
4.2 FastAPI接口层:前后端交互的七个核心接口
接口数量不需要多,按功能拆就是七组。我用表格列出来,方便对照功能清单做接口审查。
| 方法 | 路径 | 作用 |
|---|---|---|
| POST | /api/datasets | 上传并创建数据集 |
| GET | /api/datasets | 获取数据集列表 |
| POST | /api/tasks | 创建训练任务 |
| GET | /api/tasks | 获取任务列表 |
| GET | /api/tasks/{id} | 获取任务详情与进度 |
| GET | /api/tasks/{id}/logs | 增量获取训练日志 |
| POST | /api/tasks/{id}/stop | 停止训练任务 |
| POST | /api/inference | 使用已有模型对图片推理 |
创建任务接口是最核心的一个,FastAPI实现起来非常直接。要注意的地方是启动worker时加上start_new_session=True,这样后续停止任务时才能对整个进程组做操作。
import subprocess import sys from fastapi import HTTPException @app.post("/api/tasks", response_model=TaskOut) async def create_task(payload: TaskCreateIn, db=Depends(get_db)): row = db.execute( "SELECT id FROM tasks WHERE status IN ('pending', 'running')" ).fetchone() if row: raise HTTPException(status_code=409, detail="已有任务在执行,请等待完成或取消") cur = db.execute( "INSERT INTO tasks (name, dataset_id, model_type, params, status) " "VALUES (?, ?, ?, ?, 'pending')", (payload.name, payload.dataset_id, payload.model_type, payload.params.json()) ) db.commit() task_id = cur.lastrowid subprocess.Popen( [sys.executable, "scripts/trainer_worker.py", str(task_id)], cwd=BASE_DIR, start_new_session=True, ) return {"id": task_id, "status": "pending"}停止接口不要直接杀task_id对应的pid,而是杀掉整个进程组。因为训练库在部分场景下会启动子进程,只杀父进程会导致子进程残留、显存不释放。用start_new_session=True启动的worker,停止时用os.killpg(pid, signal.SIGTERM)就能连带子进程一起退出。这个细节我踩过坑,会在问题排查部分再展开。
4.3 日志与进度推送:前端怎么实时看到训练状态
前后端交互我直接用轮询,没有引入WebSocket。单机内部工具的并发量很低,轮询的体验和长连接没有明显差别,但实现和维护成本要低一个等级。协议设计很简单:前端每10秒拉一次任务详情接口,拿到status、stage、current_epoch、total_epochs这些字段;每15秒拉一次日志增量接口。
日志增量接口需要支持游标。前端第一次请求时last_offset为0,服务端返回日志列表和新的last_offset,前端下一次请求带上这个值来继续读。这样无论任务跑了多久,都只需要传输新增日志,页面内存不会越积越多。用户点击停止之后,前端要临时把轮询间隔缩短到2秒,这样能快速拿到任务已停止的反馈,体验上好很多。
4.4 模型与数据生命周期管理:避免存储越用越乱
系统用久了,最容易出现的问题是磁盘空间被悄悄耗尽。训练产生的runs目录、上传的数据集、日志文件,都是磁盘杀手。我给模型管理定了一条规则:worker在训练结束后把最优权重复制到models目录,文件名带上任务ID和完成时间,例如best_task_103_20240512.pt。这样runs目录可以随时清理,模型资产却不会丢。
数据集侧要注意引用关系:删除数据集接口必须检查tasks表里是否有任务还在引用它,否则用户删了数据,数据库里的任务配置还在,重新复现任务时就会报路径不存在。磁盘监控我也建议做成每天跑一次的脚本,统计storage目录整体大小,超过阈值就提醒。不要等磁盘满了才想起来清理,那时训练脚本和各种临时文件会开始大量报错,定位问题反而更耗时。
5. 常见问题排查与避坑实录
真正落地之后,我才意识到麻烦的不是写接口,而是稳定性问题。下面几个坑基本都是每个版本都会踩一次的经典场景。
5.1 SQLite is locked报错:不只是并发问题
典型场景:训练回调里每个epoch都在写数据库,同时前端日志接口又在读,两个连接同时操作就出现database is locked。只加WAL模式和busy_timeout,仍然会有偶发的锁冲突。我最终的解决思路是减少SQLite写入频率:训练细节全部写文件,数据库只存摘要和状态;日志表不是每epoch插一条,而是每五到十个epoch合并写一条summary。SQLite的写压力降下来之后,锁冲突基本绝迹。
另一个容易被忽略的点是连接管理。每个请求都新建连接、用完立即关闭,比长时间持有一个连接更安全。SQLite的连接在Python多线程环境下如果被共享使用,很容易出现奇怪的游标错误。保持连接短命、事务短小,是这个场景最稳的做法。日志方面如果后续并发量真的起来了,可以定时把日志刷到单独文件,数据库只留最后读取位置索引,这条路是通的。
5.2 任务异常退出后状态卡在running怎么办
OOM、手动kill、断电重启,都会导致worker进程消失而任务状态还停在running。我的双重保障方案是:worker启动时把进程PID写入tasks表,前端详情接口在发现PID对应的进程不存在时,主动把状态更新为failed并提示“训练进程已中断”;FastAPI服务启动时再做一次全局自检,把所有running任务统一标记为failed,写入error_message等于“服务重启导致任务中断”。
这两个机制加起来,用户很难遇到幽灵任务。就算遇到,前端也能看到一个明确的失败状态和错误信息。除了状态修复,还可以在tasks表里加一个心跳时间戳字段,worker每个epoch更新一次,前端据此判断任务是否长时间没有推进。这个字段对定位“训练是不是卡死了”很有帮助,值得加。
5.3 GPU显存与进程残留:服务器跑几天后变卡
这个坑很容易被忽视。我最初启动worker时直接subprocess.Popen,没有加start_new_session=True,结果杀掉Web进程后,训练进程变成孤儿进程继续占着显存,再启动新任务直接显存不足。后来统一改成进程组管理,停止任务时用os.killpg杀掉整个进程组,之后每次停止任务我都会执行一次显存查询确认已经释放。
另外一个经验是:终止训练不等于立刻释放显存。同一块GPU上,前一个进程退出后,显存释放还需要一点时间。如果紧接着启动新任务,偶尔会报显存不足。我现在的做法是在停止接口里加了3秒的等待,等确认worker进程消失后再允许新任务启动。这道保险让整个系统的并发体验稳定了不少,也避免了用户误以为机器坏了。
5.4 大文件上传失败:50GB数据集怎么传
数据集打包上传,最小的也有几百MB,大的能到几十GB。如果用await file.read()一把梭读取全部内容,内存直接爆掉。正确做法是流式分块写磁盘:每读1MB写一次磁盘,同时统计磁盘剩余空间,剩余空间小于数据集大小就提前拒绝。上传完成后立即解压校验,数据集的文件夹名要做路径消毒,防止压缩包内藏有路径穿越的内容。这些安全习惯虽然简单,但在内部工具里同样不能省。
对于超大压缩包,还可以增加一个“上传中”的状态展示。前端用fetch的上传进度事件就能显示进度条。数据集上传是耗时操作,没有进度提示很容易让用户怀疑页面卡死。这个体验问题在内部工具中同样重要,别觉得只有几个人用就不用管。
最后再分享一些个人体会
这套方案我后来又扩展过几次,把YOLO分类和分割的训练脚本都接进了同一个worker入口,接口层几乎没有改动。现在回头看,最值的一笔技术决策就是两件事:把训练抽成独立进程、把状态抽成数据表。想做网页版训练工具的人,建议先把这两条骨架搭好,再考虑花哨的展示和炫酷的前端。剩下的细节,跑起来遇到一个处理一个就好。
踩过几次坑之后,我的习惯也固定下来了:宁可方案朴素一点,也不要用复杂度换功能。内部工具的第一目标不是全面,而是平稳可用。一个能稳定跑几个月不崩的简单系统,远比一个功能堆砌但频繁出问题的复杂系统有价值。