1. 项目缘起与整体设计思路把 YOLO 训练搬到浏览器里这个念头最早来自一个很具体的场景团队里几位做标注和业务对接的同事每次想跑一版模型都得排队等我这台带显卡的机器命令行参数记不住日志看不懂训练到一半断了还得重新来。与其一遍遍帮他们敲命令不如做一个网页界面把「上传数据集、选参数、开训、看进度、下载权重」这条链路全部收进浏览器。这就是这个项目的出发点——单机部署的 YOLO 训练管理平台后端用 FastAPI数据落 SQLite前端一个轻量页面不依赖任何云服务一台带显卡的机器就能跑起来。先说清楚它解决什么问题。传统的 YOLO 训练流程是「准备数据 → 写 data.yaml → 敲 train 命令 → 盯终端日志 → 训练完手动找 best.pt」这套流程对熟悉的人没问题但对非算法岗的同事门槛太高。网页版要做的就是把这条链路产品化任务提交变成表单训练状态变成进度条日志变成可滚动面板模型文件变成可下载链接。适合谁来参考我认为有三类人一是想给自己团队搭内部工具的后端开发者二是刚接触 FastAPI 想找个完整项目练手的同学三是手里有单卡机器、想低成本做模型迭代的小团队。为什么是 FastAPI 而不是 Flask 或 Django这是整个选型里我思考最久的一点。Flask 生态成熟、上手快但训练任务本质是长耗时异步任务Flask 的同步模型处理后台任务要么上 Celery 要么自己开线程前者引入 Redis 依赖后者管理麻烦。FastAPI 原生基于 asyncio配合BackgroundTasks或者独立进程池能比较优雅地把「接口响应」和「训练执行」解耦。Django 功能全但太重一个单机工具没必要背上 ORM、Admin、中间件那一整套。FastAPI 的另一个隐性优势是自动生成 OpenAPI 文档前端联调时直接看/docs就行省掉大量沟通成本。数据库选 SQLite 的理由更直接单机部署、并发写入极低、零运维。训练任务表、日志表、模型产物表加起来数据量撑死几万行SQLite 完全扛得住。有人会担心 SQLite 的写锁问题但在这个场景里写操作只发生在「创建任务、更新状态、追加日志」这几个点而且训练进程本身是串行或有限并行的根本压不出并发瓶颈。真到了多卡多任务高并发那也不是 SQLite 该背的锅那时候该换 PostgreSQL 了。所以这个选型的前提是明确单机、明确低并发脱离这个前提谈选型都是耍流氓。整体架构我画成三层最上面是 Web 层FastAPI 提供 REST 接口和静态页面中间是任务调度层负责把训练任务丢进执行队列、监控进程状态、回收日志最下面是存储层SQLite 存元数据文件系统存数据集和权重。三层之间通过明确的接口通信Web 层不直接碰训练进程调度层不关心 HTTP 细节。这种分层的好处是将来想把训练执行换成独立 worker 甚至远程节点只需要改调度层的实现Web 层几乎不用动。提示单机部署不等于单进程。FastAPI 用 uvicorn 起服务时训练任务一定要放到独立进程里跑绝不能在请求处理函数里同步执行训练否则一个训练任务就能把整个 Web 服务卡死。2. 核心模块拆解与技术选型细节2.1 任务模型设计一张表说清训练生命周期任务表是整个系统的核心设计得好不好直接决定后续代码顺不顺。我最初想用「一个任务一行、状态字段更新」的简单模型后来发现日志和指标增长很快全塞一行不现实于是拆成三张表tasks存任务元信息和当前状态task_logs存训练日志行task_metrics存每个 epoch 的损失和 mAP 等指标。三张表通过task_id关联查询时按需 join。tasks表的关键字段我列一下id主键、name任务名、status状态pending/running/success/failed/canceled、config_json训练参数快照、dataset_path数据集路径、output_dir产物目录、pid训练进程号、created_at、started_at、finished_at。这里有个设计细节值得说config_json 存的是提交那一刻的完整参数快照而不是引用某个配置文件。原因是训练参数可能被后续修改如果只存引用回溯历史任务时就对不上账了。快照虽然占点空间但换来的可追溯性完全值得。状态机我定义得很克制只有五个状态流转关系是单向的pending → running → success/failed/canceled。不允许从 success 回到 running也不允许跳过 running 直接到 success。这种严格的状态机让前端渲染逻辑变得简单——每个状态对应一种 UI 表现不用处理各种中间态。pid字段的用途是取消任务时能精准 kill 掉对应进程避免误杀。2.2 训练执行方案子进程 日志重定向训练执行这块我踩过坑值得展开讲。最开始的实现是用subprocess.Popen起一个 Python 进程跑 YOLO 训练脚本stdout 和 stderr 通过管道读回来写数据库。问题出在管道缓冲区YOLO 训练日志输出很密集如果读取速度跟不上管道满了之后训练进程会阻塞在写日志上表现为训练卡住不动。这个 bug 排查了很久因为从任务状态看是 running但 GPU 利用率掉到零。解决办法是双管齐下一是把日志直接重定向到文件训练进程写文件不经过管道不会阻塞二是单独起一个读取线程 tail 这个文件按行解析后写数据库。文件重定向还有个额外好处——训练中途服务重启日志文件还在可以续读。读取线程用open(file, r)配合循环readline遇到文件末尾就 sleep 一小会儿再试简单可靠。进程管理上我用一个全局字典维护task_id → Popen 对象的映射取消任务时根据 task_id 找到进程先发 SIGTERM 给它机会优雅退出等几秒还没退再 SIGKILL。这里要注意YOLO 训练进程可能会 fork 出子进程比如 dataloader workerkill 父进程不一定能带走所有子进程所以更稳妥的做法是用进程组启动时设置start_new_sessionTruekill 时对整个进程组发信号。2.3 参数配置与校验把命令行参数变成表单YOLO 的训练参数非常多全暴露给用户是灾难。我的做法是分两档常用参数放表单直接填高级参数折叠起来用 JSON 文本框兜底。常用参数包括 epochs、batch size、imgsz、学习率、模型规格n/s/m/l/x、数据集选择。高级参数就是原样透传给训练脚本的额外键值对。参数校验这块FastAPI 的 Pydantic 模型帮了大忙。我定义了一个TrainConfig模型每个字段带类型和范围约束比如epochs: int Field(ge1, le1000)、batch: int Field(ge1, le128)。请求进来先过 Pydantic 校验不合法直接返回 422根本进不到训练逻辑。这里有个经验batch size 的合法范围跟显存强相关固定上限只能防呆真正合理的值需要根据模型规格和 imgsz 动态估算。我在前端加了个提示根据选择的模型规格给出建议 batch 范围但不强制。数据集管理我做得比较轻量服务器上约定一个datasets/目录每个子目录是一个数据集里面必须有data.yaml。接口扫描这个目录返回列表用户下拉选择即可。这样避免了在网页上做文件上传的复杂度——大文件上传、断点续传、存储清理都是坑单机场景下让用户自己把数据放到目录里反而更省事。3. 实操过程与关键环节实现3.1 环境准备与依赖安装先把基础环境搭起来。我假设你有一台带 NVIDIA 显卡的 Linux 机器驱动和 CUDA 已经装好。Python 建议 3.9 以上我用的是 3.10。虚拟环境用 venv 就够没必要上 conda。python -m venv venv source venv/bin/activate pip install fastapi uvicorn[standard] sqlalchemy pydantic python-multipart pip install ultralytics这里说明一下依赖选择uvicorn[standard]带上 uvloop 和 httptools性能比纯 Python 实现好不少sqlalchemy用它的 Core 或 ORM 都行我用的 ORM 模式写起来快ultralytics是 YOLO 官方库训练脚本直接调它的 API。注意 ultralytics 会自带 torch如果机器上已经有匹配 CUDA 版本的 torch建议先装 torch 再装 ultralytics避免它自动拉一个 CPU 版本覆盖掉。目录结构我这样组织project/ ├── app/ │ ├── main.py # FastAPI 入口 │ ├── models.py # SQLAlchemy 模型 │ ├── schemas.py # Pydantic 模型 │ ├── database.py # 数据库连接 │ ├── trainer.py # 训练执行逻辑 │ └── routers/ │ └── tasks.py # 任务相关接口 ├── static/ # 前端静态文件 ├── datasets/ # 数据集目录 ├── runs/ # 训练产物目录 └── app.db # SQLite 文件3.2 数据库初始化与模型定义数据库连接用 SQLAlchemy 的create_engineSQLite 需要加一个参数connect_args{check_same_thread: False}否则多线程访问会报错。这个参数的含义是允许连接跨线程使用配合 FastAPI 的线程池是必须的。from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime from sqlalchemy.orm import declarative_base, sessionmaker from datetime import datetime engine create_engine( sqlite:///./app.db, connect_args{check_same_thread: False} ) SessionLocal sessionmaker(bindengine, autoflushFalse) Base declarative_base() class Task(Base): __tablename__ tasks id Column(Integer, primary_keyTrue) name Column(String(128), nullableFalse) status Column(String(16), defaultpending) config_json Column(Text) dataset_path Column(String(256)) output_dir Column(String(256)) pid Column(Integer, nullableTrue) created_at Column(DateTime, defaultdatetime.utcnow) started_at Column(DateTime, nullableTrue) finished_at Column(DateTime, nullableTrue)日志表我加了个自增主键和task_id索引因为按任务查日志是最频繁的操作没索引的话日志一多查询就慢。指标表存 epoch、loss、mAP50、mAP50-95 这几个字段前端画曲线用。3.3 训练进程的启动与监控训练执行的核心函数我写成这样接收 task_id从数据库读出配置拼出训练命令用 subprocess 启动然后把 pid 写回数据库最后进入日志读取循环。import subprocess, os, signal, time def run_training(task_id: int): db SessionLocal() task db.query(Task).get(task_id) cfg json.loads(task.config_json) log_file os.path.join(task.output_dir, train.log) os.makedirs(task.output_dir, exist_okTrue) cmd build_train_command(cfg, log_file) with open(log_file, w) as f: proc subprocess.Popen( cmd, stdoutf, stderrsubprocess.STDOUT, start_new_sessionTrue ) task.pid proc.pid task.status running task.started_at datetime.utcnow() db.commit() tail_log(task_id, log_file, proc) proc.wait() task.status success if proc.returncode 0 else failed task.finished_at datetime.utcnow() db.commit() db.close()build_train_command这个函数负责把配置字典翻译成命令行参数。我用的方式是直接调 ultralytics 的 Python API 而不是命令行因为 API 更灵活能拿到训练过程中的回调。但为了日志统一我还是把输出重定向到文件。这里有个取舍用 API 的话训练脚本本身是一个 Python 文件subprocess 启动的是python train_worker.py --config xxxworker 内部再调 ultralytics。这样比直接拼yolo train命令更可控。tail_log函数是日志读取的核心逻辑是循环读文件新增行解析后写数据库。解析规则我用正则匹配 YOLO 的日志格式比如Epoch 1/100这种行提取 epoch 数包含mAP50的行提取指标。解析失败的行原样存日志表不丢信息。3.4 接口设计与前端联调接口我按 RESTful 风格设计核心几个方法路径作用POST/api/tasks创建训练任务GET/api/tasks任务列表分页GET/api/tasks/{id}任务详情GET/api/tasks/{id}/logs拉取日志支持增量POST/api/tasks/{id}/cancel取消任务GET/api/tasks/{id}/download下载 best.ptGET/api/datasets数据集列表日志接口的增量拉取是个细节前端传一个since_id后端只返回 id 大于它的日志行。这样前端轮询时不用每次拉全量日志多了也不卡。轮询间隔我设的 2 秒训练日志更新频率大概就是这个量级再密没必要。创建任务的接口用 FastAPI 的BackgroundTasks把run_training丢到后台。但要注意BackgroundTasks是在请求返回后执行的如果训练函数是阻塞的它会占用线程池。更稳妥的做法是用concurrent.futures.ThreadPoolExecutor自己管理一个执行池或者干脆用multiprocessing起独立进程。我最后用的是线程池因为训练本身是子进程线程只是负责监控阻塞影响不大。前端我没用框架纯 HTML 原生 JS一个页面搞定。任务列表用表格每行显示状态、进度、操作按钮。进度条根据 epoch 数算百分比。日志面板用pre标签加自动滚动到底部。这套前端很土但够用单机工具没必要上 React。4. 常见问题与排查技巧实录4.1 训练卡住不动怎么排查这是最常见的问题表现是任务状态 running 但日志不再更新、GPU 利用率掉零。排查顺序我总结成一张表现象可能原因排查方法日志停在某行不动管道缓冲区满检查是否用了文件重定向GPU 利用率零dataloader 卡死看 CPU 占用检查数据路径进程还在但无输出死锁py-spy dump 看堆栈显存 OOM 后卡住batch 太大看日志有无 CUDA OOM我遇到最多的是数据路径问题。YOLO 的 data.yaml 里train和val路径如果是相对路径是相对于 data.yaml 所在目录解析的不是相对于工作目录。这个坑很隐蔽因为命令行跑没事换到子进程跑就找不到数据。解决办法是在 data.yaml 里统一用绝对路径或者在启动训练前把工作目录切到数据集目录。4.2 服务重启后任务状态错乱单机部署绕不开服务重启。如果重启时正好有任务在跑数据库里状态是 running但进程已经没了前端就会一直显示运行中。我的处理方案是服务启动时做一次状态修复把所有 running 状态的任务标记为 failed并追加一条日志说明「服务重启导致任务中断」。这个逻辑放在 FastAPI 的 startup 事件里执行。app.on_event(startup) def fix_stale_tasks(): db SessionLocal() stale db.query(Task).filter(Task.status running).all() for t in stale: t.status failed t.finished_at datetime.utcnow() db.add(TaskLog(task_idt.id, content服务重启任务中断)) db.commit() db.close()有人会想能不能续训理论上 YOLO 支持 resume但需要 last.pt 和完整的训练状态实现复杂度高单机工具里我选择不做直接标记失败让用户重跑。这是个明确的取舍不是偷懒。4.3 SQLite 并发写入报 database is locked虽然前面说并发低但实际跑起来还是遇到过 locked 错误。原因是训练监控线程和 Web 请求线程同时写数据库SQLite 默认的写锁等待时间很短。解决办法有两个一是给 engine 加connect_args{timeout: 30}让写操作等待更久二是把日志写入批量攒一攒比如攒 20 行或 1 秒写一次减少写频率。我两个都用了之后基本没再出现 locked。注意SQLite 的 WAL 模式能显著改善读写并发执行PRAGMA journal_modeWAL即可开启。开启后读操作不会被写操作阻塞对这个场景很合适。4.4 模型文件下载与清理训练产物默认在runs/detect/train/weights/下best.pt 和 last.pt 两个文件。下载接口用 FastAPI 的FileResponse设置好filename让浏览器识别。这里有个细节如果多个任务产物目录重名下载会串。所以我在创建任务时就给 output_dir 带上 task_id 前缀保证唯一。磁盘清理是个容易被忽略的问题。训练几次之后 runs 目录能占几十 GB。我加了个简单的清理接口按任务删除产物目录同时删数据库记录。没做自动清理因为删模型是危险操作交给用户手动决定更稳妥。4.5 显存不足的预防与处理batch size 设大了直接 CUDA OOM训练进程崩溃。预防手段是在创建任务时根据模型规格和 imgsz 估算显存需求给用户一个建议值。估算公式大致是显存 ≈ 模型参数量 × 4 字节 × 若干倍系数 batch × imgsz² × 特征图系数。这个公式不精确但能给出量级参考。更实际的做法是让用户先跑一个 epoch 试试或者提供一个「自动 batch」选项让 ultralytics 自己探测。处理 OOM 崩溃时训练进程返回非零码任务标记 failed日志里会有 CUDA out of memory 字样。我在前端把这个错误高亮显示并提示「尝试减小 batch size 或 imgsz」。这种针对性的错误提示比一句「训练失败」有用得多。5. 部署与性能优化经验5.1 单机部署的进程模型部署时我用 systemd 管理 FastAPI 服务配置里设置Restartalways保证崩溃自愈。uvicorn 的 worker 数设成 1因为训练任务的状态在内存字典里维护多 worker 会导致状态不一致。有人会问单 worker 性能够不够实测下来Web 接口本身很轻瓶颈在训练不在 Web单 worker 完全够用。训练进程和 Web 进程的资源隔离要注意。训练吃满 GPU 和大量内存如果和 Web 进程共享内存空间可能把 Web 拖垮。所以训练一定要用独立进程而且最好限制一下它的内存使用避免 OOM 时把系统搞崩。我用的是resource.setrlimit在子进程里限制内存上限超了就让训练进程自己挂掉不影响 Web。5.2 日志与指标的存储优化日志表增长很快一个训练任务几百个 epoch 能产生几万行日志。全量存数据库会让查询变慢。我的优化是日志按任务分表不现实但可以定期归档把已完成超过一定时间的任务日志导出到文件数据库里只留最近的部分。指标表数据量小全留没问题。另一个优化是日志内容截断。有些日志行特别长比如打印整个配置存之前截断到 2000 字符避免单行撑爆。这个截断阈值可以配置我设的 2000 够用。5.3 前端轮询的节流前端每 2 秒轮询一次任务列表和日志任务多了之后请求量不小。优化手段是列表接口只返回摘要字段不返回 config_json 这种大字段日志接口用增量拉取多个任务同时运行时只轮询当前展开的那个任务的日志其他任务只更新状态。这样请求量能降一个数量级。还有个细节是轮询的退避策略。任务处于 pending 或 running 时正常轮询一旦变成终态就停止轮询避免无意义的请求。这个逻辑在前端用定时器管理状态变化时清掉定时器。6. 后续可扩展的方向这套东西跑通之后能扩展的点其实不少。最直接的是多任务队列现在虽然能创建多个任务但实际执行是并发的如果显存不够会互相抢。加一个队列按顺序执行能避免资源冲突。实现上就是维护一个待执行列表当前任务结束后从队列取下一个。再就是训练指标可视化。现在指标存了但前端只是列表展示用 ECharts 画个 loss 和 mAP 曲线直观得多。数据接口已经现成前端加个图表库就行。还有一个我觉得挺有价值的方向是模型对比。同一个数据集跑不同参数的任务把结果放一起对比 mAP帮用户选最优配置。这需要把指标表的数据聚合查询接口层面加个对比接口即可。最后说个我自己的体会单机工具的价值不在于功能多全而在于把重复劳动固化下来。这个项目做完之后团队里跑训练从「找我帮忙」变成「自己点几下」我省下的时间远超开发它的时间。技术选型上FastAPI SQLite 这套组合在单机场景下几乎没有对手轻、快、够用别被「生产环境要用 XX」的思维绑架工具的第一要务是解决问题不是炫技。
阅读完成 · 觉得有帮助?