☰
FastAPI+SQLite单机部署:浏览器端YOLO训练管理平台实战
2026/10/22 11:32:56 网站建设 项目流程

1. 为什么要把 YOLO 训练搬进浏览器

做过视觉项目的人都有一个共同的痛点:模型训练这件事,天然是"命令行生物"。打开终端、激活环境、敲一长串参数、盯着滚动的 loss 日志、训练完了再去翻 runs 目录找权重文件。一个人用没问题,但只要涉及到团队协作、远程服务器、或者需要给非技术同事演示,这套流程立刻就变得笨重。

我所在的团队之前就遇到过这个场景:一台带显卡的机器放在机房,几个人轮流排队跑训练。谁先跑、跑到哪一步、用的哪份数据集、mAP 是多少,全靠群里喊话和本地记事本记录。有一次两个人同时改了同一份 data.yaml,结果一个训到一半发现类别数对不上,白跑六个小时。这种事故出一次就够让人下决心改造了。

于是就有了这个项目:把 YOLO 训练做成网页版,用 FastAPI 做后端,SQLite 做数据存储,单机部署。核心目标很明确——让任何人打开浏览器就能提交训练任务、查看进度、下载权重、对比历史结果,不需要碰命令行,也不需要懂 Python 环境。

这个方案适合谁?我总结下来是三类人:一是小团队里负责算法但不想天天当"运维"的开发者;二是需要给业务方或导师展示训练过程的学生和研究者;三是想把零散的训练脚本沉淀成可复用工具的个人开发者。它不追求分布式、不追求多租户,就是把单机这一台机器的算力榨干、管好、记录清楚。

关键词里提到的 FastAPI、SQLite、单机部署,其实正好对应了这个需求的三个层面:接口层要轻快好写、存储层要零配置、部署层要一台机器搞定。下面我把整个需求设计和技术选型的思考过程拆开讲,包括我踩过的坑和最后落地的方案。

2. 需求拆解:网页版训练到底要解决什么

2.1 从"能跑"到"好管"的需求分层

很多人一上来就想做"网页版 YOLO",但没想清楚网页版到底要替代命令行的哪些部分。我建议先把需求分成三层,从下往上依次是执行层、管理层、展示层。

执行层是最底层的,负责真正把训练跑起来。这一层其实和命令行没区别,还是调用 ultralytics 的 YOLO 接口或者直接 subprocess 起训练进程。关键问题是:训练进程和 Web 服务进程怎么共存?如果直接在 FastAPI 的请求处理函数里同步跑训练,那这个请求会阻塞几分钟到几小时,整个服务直接卡死。所以执行层必须解决异步化和进程隔离。

管理层是中间层,负责记录"谁在什么时候用什么配置跑了什么任务,结果如何"。这一层是网页版相比命令行的最大增量价值。命令行时代这些信息散落在终端历史、日志文件、文件夹命名里,网页版要把它结构化地存起来。这就引出了 SQLite 的用武之地——任务表、数据集表、模型表、指标表,几张表就能把整个训练生命周期管起来。

展示层是最上层,负责把训练进度、loss 曲线、验证结果可视化。这一层决定了用户体验,也是"网页版"三个字最直观的体现。进度条、实时日志、指标图表,这些在命令行里要么没有,要么很难看。

我的经验是:先把这三层的边界划清楚,再动手写代码。我见过太多项目一上来就写前端页面,结果后端任务调度一团糟,最后推倒重来。

2.2 单机部署的边界与取舍

标题里明确写了"单机部署",这不是偷懒,而是深思熟虑后的取舍。单机意味着所有组件跑在同一台机器上:Web 服务、训练进程、数据库、文件存储。这样做的好处是部署极简,一台带显卡的机器,装好依赖,uvicorn一跑就完事,不需要考虑网络分区、服务发现、分布式锁这些复杂问题。

但单机也有明确的边界,必须在设计阶段就认清楚:

  • 并发训练数量受限于显存。一张 24G 显存的卡,跑 YOLOv8n 可能能同时跑两三个,跑 YOLOv8x 就只能跑一个。所以任务队列必须支持"排队等待"而不是"无脑并发"。
  • Web 服务和训练进程抢资源。训练吃满 GPU 和 CPU 时,Web 服务响应会变慢。所以训练进程要限制线程数,别把 CPU 也占满。
  • SQLite 的写入并发有限。SQLite 同一时刻只允许一个写操作,训练进程频繁写指标时,Web 请求的写操作可能被阻塞。这个后面会讲怎么缓解。

把这些边界想清楚,就不会在项目做到一半时突然发现"单机扛不住"而返工。单机方案的天花板是明确的,我们要做的是在天花板下把体验做到最好。

2.3 核心功能清单

基于上面的分层,我把功能清单列出来,这也是后续技术选型的依据:

功能模块具体功能优先级
任务管理创建训练任务、查看任务列表、取消任务、删除任务高
参数配置选择模型、数据集、超参数、训练轮数高
进度监控实时进度、当前 epoch、loss 曲线高
结果管理查看 mAP、下载权重、查看验证图中
数据集管理上传数据集、查看数据集信息中
历史对比多任务指标对比低

优先级高的功能是 MVP 必须有的,中低优先级的可以迭代。我个人的做法是先做一个"能提交任务、能看到进度、能下载权重"的最小闭环,跑通之后再补数据集管理和对比功能。这样能最快验证技术方案是否可行。

3. 技术选型:FastAPI + SQLite 为什么是单机最优解

3.1 FastAPI 相比 Flask 和 Django 的取舍

后端框架的选择上,我对比了 Flask、Django、FastAPI 三个主流选项。结论是 FastAPI 最适合这个场景,理由有三条。

第一是异步原生支持。训练任务的提交、状态查询、日志拉取这些接口,天然适合异步。FastAPI 基于 Starlette,async def写起来很自然,配合asyncio可以轻松处理"提交任务后立即返回、后台慢慢跑"的模式。Flask 虽然也能做,但需要额外引入 Celery 之类的任务队列,复杂度上去了。

第二是自动生成 API 文档。FastAPI 内置 Swagger UI,写完接口直接访问/docs就能看到交互式文档,还能直接在页面上测试接口。这在前后端联调时太省事了,前端同事不用追着问"这个接口参数是什么",自己看文档就行。

第三是Pydantic 数据校验。训练参数很多,模型名、数据集路径、学习率、batch size,每个都有类型和范围要求。用 Pydantic 定义请求模型,参数校验自动完成,非法请求直接返回 422,不用手写一堆 if-else。

Django 功能全但太重,ORM、Admin、模板引擎这些在这个项目里大部分用不上,反而增加了启动开销。Flask 轻但异步支持弱,需要自己拼装组件。FastAPI 正好卡在中间,轻量且现代。

3.2 SQLite 在训练管理场景下的真实表现

很多人一听 SQLite 就觉得"玩具数据库",不适合生产。但在单机训练管理这个场景下,SQLite 反而是最优解,原因在于数据特征。

训练管理的数据写入模式是:任务创建时写一次,训练过程中每个 epoch 写一次指标,训练结束时更新一次状态。假设一个任务跑 100 个 epoch,那就是 100 次左右的写入,分散在几小时里。这种低频写入对 SQLite 来说毫无压力。读取方面,前端可能每隔几秒轮询一次进度,读取频率高但都是小查询,SQLite 完全扛得住。

SQLite 的真正优势是零配置。不需要装数据库服务、不需要建用户、不需要配连接池,一个.db文件就是全部。备份就是复制文件,迁移就是拷贝文件。对于单机部署来说,这种简单性带来的运维收益远大于性能上的那点损失。

当然,SQLite 的坑也要提前知道。最大的坑是并发写入:默认情况下,一个连接在写的时候,其他连接写会被阻塞,超过 5 秒(默认 busy_timeout)就报database is locked。解决办法后面会详细讲,核心是开启 WAL 模式加上合理的超时设置。

3.3 训练执行方案:subprocess 还是进程内调用

这是整个项目最关键的技术决策之一。有两种方案:

方案 A:进程内调用。在 FastAPI 的异步任务里直接from ultralytics import YOLO然后model.train()。优点是简单,能直接拿到训练对象,回调方便。缺点是训练会占用 Web 服务的进程资源,一旦训练崩溃可能拖垮整个服务,而且 GIL 限制下多任务并行困难。

方案 B:subprocess 子进程。用subprocess.Popen起一个独立的 Python 进程跑训练脚本,Web 服务只负责管理进程和读取输出。优点是进程隔离彻底,训练崩了不影响 Web 服务,可以真正并行多个训练。缺点是需要处理进程间通信,日志要通过 stdout 管道读取。

我最终选了方案 B,理由是稳定性优先。训练是个长任务,任何意外都可能发生(OOM、数据格式错误、依赖冲突),进程隔离能保证 Web 服务始终可用。日志通过 stdout 实时读取,解析出进度信息再写入数据库,前端轮询数据库即可。

具体实现上,训练脚本单独写一个train_worker.py,接收 JSON 格式的参数,训练过程中把进度以特定格式打印到 stdout,Web 服务用线程读取管道并解析。这个设计后面会详细展开。

4. 数据库设计:几张表撑起整个训练管理

4.1 任务表与状态机的设计

任务表是整个系统的核心,我把它设计成这样:

CREATE TABLE tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', model_name TEXT NOT NULL, dataset_path TEXT NOT NULL, epochs INTEGER NOT NULL, batch_size INTEGER NOT NULL, img_size INTEGER NOT NULL, learning_rate REAL NOT NULL, config_json TEXT, pid INTEGER, progress REAL DEFAULT 0, current_epoch INTEGER DEFAULT 0, best_map REAL, output_dir TEXT, error_msg TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, started_at TIMESTAMP, finished_at TIMESTAMP );

这里有几个设计要点值得说。status字段是个状态机,取值有pending、running、completed、failed、cancelled五种。状态流转必须严格:pending只能到running或cancelled,running只能到completed、failed或cancelled。我在代码里用了一个状态转移检查函数,防止非法流转导致数据混乱。

config_json字段存完整的训练配置,包括那些不常查询但需要保留的参数。这样表结构不用为每个新参数加列,扩展性好。查询时如果需要按某个参数筛选,可以用 SQLite 的 JSON 函数,虽然性能一般但够用。

pid字段记录训练进程的进程号,用于取消任务时杀进程。这里有个坑:进程号会被系统回收,如果任务已经结束但状态没更新,杀进程可能误杀别的进程。所以杀之前要先检查进程是否还在运行,并且核对进程的启动命令。

4.2 指标表与日志表的分工

指标表和日志表分开设计,是因为它们的读写模式完全不同。

CREATE TABLE metrics ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL, epoch INTEGER NOT NULL, box_loss REAL, cls_loss REAL, dfl_loss REAL, precision REAL, recall REAL, map50 REAL, map50_95 REAL, lr REAL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (task_id) REFERENCES tasks(id) ); CREATE TABLE logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (task_id) REFERENCES tasks(id) );

指标表每个 epoch 一行,数据规整,用于画曲线和对比。日志表是流水账,训练过程中的所有输出都往里塞,用于排查问题。日志表增长很快,一个长训练可能产生几万行,所以要定期清理或者只保留最近 N 行。

实操心得:日志表我加了一个策略,每个任务只保留最近 2000 行日志,超出部分自动删除。因为排查问题通常看最近的输出就够了,历史日志意义不大,还能防止数据库膨胀。

4.3 索引与查询优化

表建好了,索引不能忘。最常用的查询是"按任务 ID 查指标"和"按状态查任务",所以这两个字段要加索引:

CREATE INDEX idx_metrics_task_id ON metrics(task_id); CREATE INDEX idx_tasks_status ON tasks(status); CREATE INDEX idx_logs_task_id ON logs(task_id);

另外,前端轮询进度时查的是tasks表的progress和current_epoch,这两个字段更新频繁。如果每次更新都写数据库,SQLite 的写入压力会比较大。我的做法是降低写入频率:训练进程每完成一个 epoch 才更新一次数据库,而不是每个 batch 都更新。前端轮询间隔设为 2 到 3 秒,这样既能保证进度及时,又不会给数据库太大压力。

5. 后端实现:从接口设计到进程管理

5.1 接口设计与 Pydantic 模型

FastAPI 的接口设计围绕任务的生命周期展开,核心接口有这几个:

from fastapi import FastAPI, HTTPException from pydantic import BaseModel, Field from typing import Optional app = FastAPI(title="YOLO Training Manager") class TaskCreate(BaseModel): name: str = Field(..., min_length=1, max_length=100) model_name: str = Field(default="yolov8n.pt") dataset_path: str = Field(...) epochs: int = Field(default=100, ge=1, le=1000) batch_size: int = Field(default=16, ge=1, le=128) img_size: int = Field(default=640, ge=320, le=1280) learning_rate: float = Field(default=0.01, gt=0, le=1.0) @app.post("/api/tasks") async def create_task(task: TaskCreate): # 校验数据集路径是否存在 # 写入数据库,状态为 pending # 触发调度器 pass @app.get("/api/tasks") async def list_tasks(status: Optional[str] = None): pass @app.get("/api/tasks/{task_id}") async def get_task(task_id: int): pass @app.get("/api/tasks/{task_id}/metrics") async def get_metrics(task_id: int): pass @app.post("/api/tasks/{task_id}/cancel") async def cancel_task(task_id: int): pass

Pydantic 的Field用起来很顺手,ge、le、gt这些约束直接写在模型里,参数校验自动完成。比如epochs限制在 1 到 1000 之间,用户传 0 或者 10000 都会被拒绝,返回清晰的错误信息。

这里有个细节:dataset_path的校验不能只靠 Pydantic,因为路径是否存在是运行时才知道的。所以要在接口函数里额外检查,路径不存在就返回 400。这种"格式校验用 Pydantic、业务校验用代码"的分工要清晰。

5.2 任务调度器的实现

调度器是后端的心脏,负责从pending任务里挑一个出来跑。最简单的实现是一个后台循环:

import asyncio import subprocess import json async def scheduler_loop(): while True: # 检查是否有正在运行的任务 running = get_running_task_count() if running < MAX_CONCURRENT: task = get_next_pending_task() if task: start_training(task) await asyncio.sleep(5)

MAX_CONCURRENT是并发训练数,单机场景下建议设为 1,除非显存足够大。设成 1 的好处是任务串行执行,不会互相抢显存导致 OOM。如果确实要并发,也要根据显存大小谨慎设置。

start_training函数负责起子进程:

def start_training(task): cmd = [ "python", "train_worker.py", "--task-id", str(task["id"]), "--config", json.dumps(task["config"]) ] process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1 ) # 更新任务状态为 running,记录 pid update_task_status(task["id"], "running", pid=process.pid) # 起一个线程读取输出 threading.Thread(target=read_output, args=(task["id"], process)).start()

这里用bufsize=1开启行缓冲,保证日志能实时读到。stderr=subprocess.STDOUT把错误输出也合并到标准输出,统一处理。

5.3 日志解析与进度更新

read_output函数是连接训练进程和数据库的桥梁:

def read_output(task_id, process): for line in process.stdout: # 写入日志表 save_log(task_id, line) # 尝试解析进度信息 progress = parse_progress(line) if progress: update_task_progress(task_id, progress) process.wait() # 根据返回码更新最终状态 if process.returncode == 0: update_task_status(task_id, "completed") else: update_task_status(task_id, "failed")

parse_progress是关键,它要从 YOLO 的输出里提取 epoch、loss、mAP 这些信息。YOLO 训练时的输出格式比较固定,比如1/100 ... box_loss ... mAP50 ...,用正则表达式就能解析。但要注意,不同版本的 ultralytics 输出格式可能略有差异,所以正则要写得宽松一些,解析失败就跳过,不能因为解析失败导致整个流程崩溃。

踩坑记录:我一开始用严格的正则,结果 ultralytics 升级后输出格式变了,进度解析全挂。后来改成"能解析就解析,解析不了就只存日志",鲁棒性好了很多。训练本身不受影响,只是进度显示可能不准,这个降级是可以接受的。

6. 前端与实时进度:轮询还是 WebSocket

6.1 轮询方案的简单可靠

实时进度展示有两条路:轮询和 WebSocket。我最终选了轮询,理由是简单可靠。

轮询的逻辑很直白:前端每隔 2 秒调一次/api/tasks/{id},拿到最新的progress、current_epoch、best_map,更新页面。实现简单,调试方便,浏览器兼容性好,断线重连也不用特殊处理。

WebSocket 虽然实时性更好,但引入了连接管理、心跳、重连这些复杂度。对于训练进度这种"秒级更新就够"的场景,轮询完全够用。而且训练本身是分钟到小时级的任务,2 秒的延迟用户根本感知不到。

轮询的优化点在于减少请求量。如果同时打开多个任务详情页,每个页面都在轮询,请求量会上去。我的做法是列表页轮询间隔长一点(5 秒),详情页短一点(2 秒),并且页面切到后台时暂停轮询,切回来再恢复。

6.2 进度条与指标曲线的实现

进度条用最朴素的 HTML 加 CSS 就能做,不需要引入图表库:

<div class="progress-bar"> <div class="progress-fill" style="width: 45%"></div> </div> <span>45% - Epoch 45/100</span>

指标曲线用 Chart.js 或者 ECharts,从/api/tasks/{id}/metrics拉数据,x 轴是 epoch,y 轴是 loss 或 mAP。这里有个细节:训练进行中曲线是动态增长的,每次轮询拿到新数据后要更新图表,而不是重新创建。Chart.js 的update()方法可以做到平滑更新。

验证图展示稍微麻烦一点,因为 YOLO 训练过程中会生成val_batch0_pred.jpg这类图片。我的做法是训练进程把图片路径写进数据库,前端通过一个静态文件接口访问。FastAPI 挂载静态目录很简单:

from fastapi.staticfiles import StaticFiles app.mount("/outputs", StaticFiles(directory="runs"), name="outputs")

这样前端就能通过/outputs/task_1/val_batch0_pred.jpg访问到图片。

6.3 前后端数据格式约定

前后端联调最容易出问题的就是数据格式。我的经验是提前约定好,写进文档。比如任务对象统一返回这样的结构:

{ "id": 1, "name": "exp001", "status": "running", "progress": 45.0, "current_epoch": 45, "total_epochs": 100, "best_map": 0.723, "created_at": "2024-01-01T10:00:00", "metrics": [...] }

时间统一用 ISO 格式字符串,数值统一用数字类型不用字符串,状态用固定的枚举值。这些约定看起来琐碎,但能省掉大量联调时的扯皮。我习惯在项目初期就写一份简单的接口文档,哪怕只是 Markdown 表格,也比口头约定强。

7. 部署与运维:单机环境下的稳定性保障

7.1 环境隔离与依赖管理

单机部署最容易忽视的是环境隔离。训练用的依赖和 Web 服务用的依赖如果混在一起,版本冲突是迟早的事。我的做法是用虚拟环境隔离,Web 服务和训练进程用同一个虚拟环境(因为都要用 ultralytics),但依赖版本严格锁定。

python -m venv venv source venv/bin/activate pip install fastapi uvicorn ultralytics pip freeze > requirements.txt

requirements.txt一定要提交到版本控制,部署时用pip install -r requirements.txt保证环境一致。我见过太多"在我机器上能跑"的问题,根源就是依赖版本不一致。

另外,CUDA 和 PyTorch 的版本匹配是个大坑。ultralytics 依赖 torch,torch 又依赖特定版本的 CUDA。部署前一定要确认显卡驱动、CUDA、torch 三者兼容。这个没有通用方案,只能查官方兼容性表,装完跑个torch.cuda.is_available()验证。

7.2 进程守护与开机自启

Web 服务不能裸跑uvicorn,因为终端一关服务就没了。生产环境要用进程守护工具,我推荐systemd,Linux 下最稳。

[Unit] Description=YOLO Training Manager After=network.target [Service] User=youruser WorkingDirectory=/path/to/project ExecStart=/path/to/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 Restart=always RestartSec=5 [Install] WantedBy=multi-user.target

Restart=always保证服务崩溃后自动重启,RestartSec=5避免频繁重启。配好之后systemctl enable一下,开机自启也搞定了。

训练进程的管理稍微复杂,因为它是动态起的。我的做法是训练进程不交给 systemd 管,而是由 Web 服务管理。Web 服务重启时,正在跑的训练进程会变成孤儿进程继续跑,但数据库里的状态可能对不上。解决办法是 Web 服务启动时做一次状态修复:把所有running状态但进程已不存在的任务标记为failed。

7.3 磁盘空间与日志清理

训练是个吃磁盘的活。每个任务的权重文件、日志、验证图加起来可能几百 MB,跑几十个任务磁盘就满了。所以要有清理策略。

我的策略是:权重文件保留最近 10 个任务的,更早的自动删除;日志表每个任务只留最近 2000 行;验证图保留全部(因为体积小)。清理逻辑放在一个定时任务里,每天凌晨跑一次。

def cleanup_old_tasks(): # 找出超过 10 个的已完成任务 old_tasks = get_completed_tasks(limit=10, offset=10) for task in old_tasks: delete_weights(task["output_dir"]) trim_logs(task["id"], keep=2000)

注意:删除权重前一定要确认任务不是"最佳模型"或者用户标记为保留的。我吃过亏,自动清理把用户想留的模型删了,后来加了个keep字段,用户标记保留的不参与清理。

8. 常见问题与排查技巧实录

8.1 数据库锁问题的根治

database is locked是 SQLite 最常见的报错。根本原因是并发写入冲突。解决办法分三步:

第一步,开启 WAL 模式。WAL(Write-Ahead Logging)允许读写并发,写操作不阻塞读操作:

conn.execute("PRAGMA journal_mode=WAL")

第二步,设置 busy_timeout。当数据库被锁时,等待指定毫秒再重试,而不是立即报错:

conn.execute("PRAGMA busy_timeout=10000")

第三步,控制写入频率。训练进程写指标不要每个 batch 都写,每个 epoch 写一次就够。Web 服务的写操作尽量合并,比如状态更新和进度更新合并成一次。

这三步做完,database is locked基本就绝迹了。我实测下来,单机场景下即使有多个任务在跑,也没再出现过锁问题。

8.2 训练进程僵死与超时处理

训练进程可能因为各种原因僵死:数据加载卡住、CUDA 报错后没退出、死循环。僵死的进程占着显存不放,后续任务全跑不了。

我的处理方案是心跳检测。训练进程每个 epoch 往数据库写一次进度,Web 服务定期检查:如果一个running任务超过 30 分钟没有更新进度,就判定为僵死,杀掉进程并标记为failed。

def check_stuck_tasks(): stuck = get_tasks_no_update(minutes=30) for task in stuck: if is_process_alive(task["pid"]): kill_process(task["pid"]) update_task_status(task["id"], "failed", error_msg="训练超时,进程已终止")

30 分钟这个阈值要根据实际情况调。如果数据集很大,一个 epoch 可能要跑很久,阈值就要放大。我的做法是让用户创建任务时可选"超时时间",默认 30 分钟,大任务可以设长一点。

8.3 显存不足的预防与恢复

显存不足(CUDA out of memory)是训练最常见的失败原因。预防措施有三条:

  • 限制并发数。单卡默认只跑一个任务,除非显存特别大。
  • 合理设置 batch size。创建任务时根据模型大小给个建议值,比如 yolov8n 建议 32,yolov8x 建议 8。
  • 训练前检查显存。起训练进程前先查一下当前显存占用,不够就排队等待。

恢复方面,如果训练因为 OOM 失败,数据库里要记录清晰的错误信息,前端展示给用户,并建议降低 batch size 或换小模型。我还在任务详情页加了个"一键复制配置"按钮,用户改个 batch size 就能重新提交,不用重新填一遍参数。

8.4 常见问题速查表

问题现象可能原因排查方法解决方案
database is locked并发写入冲突查看日志中的报错频率开 WAL、设 busy_timeout、降写入频率
训练进程僵死数据加载卡住或 CUDA 错误检查进度是否长时间不更新心跳检测加超时杀进程
CUDA out of memorybatch size 过大或并发过多查看显存占用降 batch size、限制并发
进度不更新日志解析失败查看原始日志格式放宽正则、降级为只存日志
服务重启后任务状态错乱孤儿进程检查进程是否存在启动时做状态修复
磁盘写满权重文件堆积查看磁盘占用定时清理旧任务权重

这张表是我踩坑踩出来的,基本覆盖了 90% 的常见问题。遇到新问题先查表,查不到再深入排查。

9. 一些实操中的个人体会

这个项目从想法到落地大概花了两周,中间返工了两次。第一次返工是因为一开始想用 Celery 做任务队列,结果发现单机场景下 Celery 的复杂度完全没必要,一个简单的后台循环就够了。第二次返工是前端一开始想用 WebSocket,写了一半发现轮询更简单,又改回来。

最大的体会是:单机项目的核心不是技术多先进,而是把简单的事情做扎实。FastAPI 和 SQLite 都是成熟得不能再成熟的技术,但把它们组合好、把边界处理好、把异常兜住,才是真正花时间的地方。比如状态机的严格校验、进程的心跳检测、数据库的锁处理,这些细节决定了项目能不能稳定跑起来。

另一个体会是日志和错误信息要足够详细。训练失败时,用户最想知道的是"为什么失败"。如果只返回一个"训练失败",用户还得去翻服务器日志,体验很差。我的做法是把训练进程的 stderr 也捕获下来,存进数据库,前端直接展示。这样用户自己就能判断是数据问题还是参数问题。

最后分享一个小技巧:训练脚本里加一个--dry-run参数,只校验配置和数据,不真正训练。用户提交任务前可以先 dry-run 一下,确认没问题再正式跑。这个功能帮我省了很多次"跑了一半发现数据路径错了"的尴尬。

后续如果要扩展,我觉得有两个方向值得做。一是多机支持,把训练进程调度到其他机器上,Web 服务只做管理。这需要引入消息队列和远程执行,复杂度会上去,但能突破单机的算力天花板。二是模型版本管理,把每次训练的权重、配置、指标关联起来,支持模型回滚和 A/B 对比。这个对长期迭代的团队很有价值。不过这些都是后话,单机版本先把基础打牢,后面扩展才有底气。

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

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

立即咨询