资讯动态

基于FastAPI和SQLite的YOLO训练网页化实践,单机部署方案详解

发布时间:2026/10/22 22:05:59 来源:尧图企业网站定制
先说结论把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里都很成熟但对这个场景的适配度差别不小。对比维度FlaskDjangoFastAPI异步原生支持弱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和GPUWeb服务一旦因为内存压力重启训练也就跟着没了。我采用的方式是让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调度器检查到没有其他运行中任务runningrunningworker训练流程正常完成succeededrunning训练过程异常退出failedrunning用户点击停止或收到取消信号canceledpending用户主动撤销canceledrunning服务重启后自检发现进程不存在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(fUPDATE tasks SET {keys} WHERE id?, values) conn.commit() conn.close() def handle_stop(signum, frame): update_task(TASK_ID, statuscanceled, finished_atdatetime.now()) sys.exit(0) def train(task_id, task): update_task(task_id, statusrunning, started_atdatetime.now()) params json.loads(task[params]) from ultralytics import YOLO model YOLO(task[model_type]) model.train( datatask[data_yaml], epochsparams[epochs], imgszparams[imgsz], batchparams[batch], projectstr(RUNS_PATH), nameftask_{task_id}, verboseTrue, ) 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_sessionTrue这样后续停止任务时才能对整个进程组做操作。import subprocess import sys from fastapi import HTTPException app.post(/api/tasks, response_modelTaskOut) async def create_task(payload: TaskCreateIn, dbDepends(get_db)): row db.execute( SELECT id FROM tasks WHERE status IN (pending, running) ).fetchone() if row: raise HTTPException(status_code409, 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)], cwdBASE_DIR, start_new_sessionTrue, ) return {id: task_id, status: pending}停止接口不要直接杀task_id对应的pid而是杀掉整个进程组。因为训练库在部分场景下会启动子进程只杀父进程会导致子进程残留、显存不释放。用start_new_sessionTrue启动的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_sessionTrue结果杀掉Web进程后训练进程变成孤儿进程继续占着显存再启动新任务直接显存不足。后来统一改成进程组管理停止任务时用os.killpg杀掉整个进程组之后每次停止任务我都会执行一次显存查询确认已经释放。另外一个经验是终止训练不等于立刻释放显存。同一块GPU上前一个进程退出后显存释放还需要一点时间。如果紧接着启动新任务偶尔会报显存不足。我现在的做法是在停止接口里加了3秒的等待等确认worker进程消失后再允许新任务启动。这道保险让整个系统的并发体验稳定了不少也避免了用户误以为机器坏了。5.4 大文件上传失败50GB数据集怎么传数据集打包上传最小的也有几百MB大的能到几十GB。如果用await file.read()一把梭读取全部内容内存直接爆掉。正确做法是流式分块写磁盘每读1MB写一次磁盘同时统计磁盘剩余空间剩余空间小于数据集大小就提前拒绝。上传完成后立即解压校验数据集的文件夹名要做路径消毒防止压缩包内藏有路径穿越的内容。这些安全习惯虽然简单但在内部工具里同样不能省。对于超大压缩包还可以增加一个“上传中”的状态展示。前端用fetch的上传进度事件就能显示进度条。数据集上传是耗时操作没有进度提示很容易让用户怀疑页面卡死。这个体验问题在内部工具中同样重要别觉得只有几个人用就不用管。最后再分享一些个人体会这套方案我后来又扩展过几次把YOLO分类和分割的训练脚本都接进了同一个worker入口接口层几乎没有改动。现在回头看最值的一笔技术决策就是两件事把训练抽成独立进程、把状态抽成数据表。想做网页版训练工具的人建议先把这两条骨架搭好再考虑花哨的展示和炫酷的前端。剩下的细节跑起来遇到一个处理一个就好。踩过几次坑之后我的习惯也固定下来了宁可方案朴素一点也不要用复杂度换功能。内部工具的第一目标不是全面而是平稳可用。一个能稳定跑几个月不崩的简单系统远比一个功能堆砌但频繁出问题的复杂系统有价值。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑