Contents

从勘智云平台拆解:如何自建一套端到端AI模型训练与部署平台

一台AIoT芯片背后的云平台

嘉楠科技的勘智(Kendryte)系列芯片——K210、K230、K230D——在AIoT领域颇有存在感。但芯片本身只是最后一环,真正让"AI落地"跑通的,是它背后的云训练平台:用户上传图片,在线标注,一键训练,拿到的模型直接部署到开发板上。

这套流程看起来简单,实际上串联了数据标注、模型训练、模型量化、边缘部署四大环节。本文以勘智云平台为蓝本,拆解它的架构设计,并给出一套基于开源工具链的自建方案——让你也能搭一个"上传数据→拿到模型→部署到边缘设备"的平台。

勘智云平台做了什么

先用一句话概括:它是一个面向边缘AI芯片的MLOps平台,把从数据到部署的全流程搬到云端,让不写代码的用户也能训练CV模型。

平台的核心流程分四步:

1
2
3
4
创建数据集 → 上传/标注数据 → 配置训练参数 → 下载部署包
     │              │               │              │
     ▼              ▼               ▼              ▼
  选择任务类型    在线标注工具     GPU集群训练    KModel+部署脚本

七种CV任务类型

勘智云支持七种计算机视觉任务,覆盖了边缘设备最常见的需求:

任务 输入 输出 典型场景
图像分类 图片 类别+分数 垃圾分类、缺陷检测
图像检测 图片 位置框+类别+分数 人脸检测、安防监控
语义分割 图片 像素级标签图 自动驾驶可行驶区域
OCR检测 图片 文本区域位置 车牌定位、表单提取
OCR识别 图片 文本内容 车牌识别、文档数字化
度量学习 图片 特征向量 商品比对、人脸比对
多标签分类 图片 多个类别标签 场景识别(可同时有山+水+天)

小贴士:度量学习是七种任务里最容易被忽视的。它不直接分类,而是学一个"特征提取器"——把图片变成向量,再通过向量距离比较相似度。好处是新增类别不需要重新训练,只要往特征库里加新样本就行。这在实际业务中非常实用。

平台的硬件底气

平台页面标注了关键参数:

  • 算力:112 TFLOPS
  • GPU:8卡 V100 服务器
  • 单任务上限:24小时
  • 图片上限:1000张
  • 并发限制:每位用户同时仅1个任务

这些限制说明平台定位是试用级——面向学习和小规模验证,不是生产级训练平台。官方文档也直言:“复杂场景或严格的商业场景请使用开源代码完成训练。”

但这不影响我们学习它的架构设计。正因为它做了取舍,才更值得参考。

架构拆解:四个核心模块

一个完整的"数据到部署"平台,本质上需要四个模块协同工作。下面逐一拆解。

模块一:数据标注系统

勘智云内置了在线标注工具,支持分类标注、检测框标注、分割多边形标注、OCR文本框标注等。标注结果按不同格式组织:

  • 分类任务:文件夹按类别命名,简单直接
  • 检测任务:Pascal VOC格式(图片+同名XML)
  • 分割任务:LabelMe格式(图片+同名JSON,含points数组)
  • OCR检测:自定义格式(x1,y1,x2,y2,x3,y3,x4,y4,text)

如果不想从零开发标注工具,开源方案有两个主流选择:

CVAT(Computer Vision Annotation Tool):

  • 源头:Intel开发,现由cvat-ai社区维护
  • GitHub:github.com/cvat-ai/cvat
  • 支持任务:检测、分割、分类、跟踪
  • 部署方式:Docker Compose一键启动
  • 标注格式:支持导出COCO、VOC、YOLO、LabelMe等

Label Studio

  • 源头:Heartex公司开发
  • GitHub:github.com/HumanSignal/label-studio
  • 支持任务:图片、音频、视频、文本(多模态)
  • 部署方式:pip安装或Docker
  • 标注格式:可自定义模板,灵活度极高

下面用Docker快速搭建CVAT:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 克隆CVAT仓库
git clone https://github.com/cvat-ai/cvat.git
cd cvat

# 使用docker-compose启动
docker compose up -d

# 创建超级用户(进入容器执行)
docker exec -it cvat_server bash -c \
  'python3 ~/manage.py createsuperuser'

# 访问 http://localhost:8080

CVAT启动后,标注流程是:创建Task → 上传图片 → 选择标注类型 → 在画布上标注 → 导出标注数据。

为什么选CVAT而不是自己写? 标注工具的核心难点不是画框,而是交互体验——缩放、拖拽、多边形顶点编辑、快捷键、多人协作。CVAT经过多年迭代,这些细节已经打磨得很好。自己写至少3个月才能达到可用状态,没必要重复造轮子。

模块二:训练任务调度系统

勘智云的训练流程是:用户提交训练任务 → 进入队列 → 等待GPU空闲 → 执行训练 → 训练完成后邮件通知。

这里涉及三个关键技术点:

0. 开源方案推荐(不用从零写调度系统)

在自己动手写 Redis 队列之前,先看看有没有现成的开源项目可以直接用:

ClearML(推荐首选):

  • GitHub:github.com/allegroai/clearml
  • 能力:实验追踪 + 任务队列 + 模型注册表 + Web UI,开箱即用
  • 适用:需要完整的"提交任务→排队→执行→追踪→存模型"流程
  • 优势:Python SDK 一行代码接入现有训练脚本,自带 Web 界面看任务状态
  • 部署:Docker Compose 一键启动,自带 API Server + Web UI + Agent

MLflow

  • GitHub:github.com/mlflow/mlflow
  • 能力:实验追踪 + 模型注册表 + 模型 Serving
  • 适用:已有训练脚本,只需要加追踪和模型版本管理
  • 区别:MLflow 偏"记录实验结果",不做任务排队和 GPU 调度。需要自己配队列

Polyaxon

  • GitHub:github.com/polyaxon/polyaxon
  • 能力:K8s 原生的 ML 实验平台,支持 GPU 调度、实验追踪、流水线编排
  • 适用:已经上了 K8s 的团队,需要多节点 GPU 调度
  • 区别:比 ClearML 重,但多节点能力更强

怎么选? MVP 阶段用 ClearML——它把任务队列、GPU 调度、实验追踪、模型存储全做了,Docker 部署。你只需要写训练脚本,不用自己写队列逻辑。等规模上来了(多节点、多 GPU),再考虑迁移到 Polyaxon 或自建 K8s 调度。下面的 Redis 代码适合理解原理,但生产环境优先用现成的。

1. 任务队列与GPU调度

勘智云限制"每位用户同时仅1个任务",说明它的调度策略是按用户排队。生产环境通常用Redis实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import redis
import json

class TrainingQueue:
    """训练任务队列,基于Redis实现"""
    
    def __init__(self, redis_url="redis://localhost:6379"):
        self.r = redis.from_url(redis_url)
        self.queue_key = "training:queue"
        self.running_key = "training:running"
    
    def submit(self, task_id: str, config: dict, user_id: str):
        """提交训练任务到队列
        
        Args:
            task_id: 任务唯一ID
            config: 训练配置(模型类型、超参数等)
            user_id: 提交用户
        """
        # 检查用户是否已有任务在运行
        running_tasks = self.r.hgetall(self.running_key)
        for tid, data in running_tasks.items():
            task = json.loads(data)
            if task["user_id"] == user_id:
                return {"error": "每个用户同时只能运行1个任务"}
        
        # 入队
        self.r.rpush(self.queue_key, json.dumps({
            "task_id": task_id,
            "config": config,
            "user_id": user_id,
            "status": "queued"
        }))
        return {"task_id": task_id, "status": "queued"}
    
    def fetch_next(self, gpu_id: str) -> dict | None:
        """GPU空闲时拉取下一个任务"""
        task_data = self.r.lpop(self.queue_key)
        if task_data is None:
            return None
        task = json.loads(task_data)
        task["status"] = "running"
        task["gpu_id"] = gpu_id
        self.r.hset(self.running_key, task["task_id"], 
                     json.dumps(task))
        return task
    
    def complete(self, task_id: str, result: dict):
        """任务完成,从运行列表移除"""
        self.r.hdel(self.running_key, task_id)
        self.r.set(
            f"training:result:{task_id}",
            json.dumps(result),
            ex=86400  # 结果保留24小时
        )

小贴士:上面的代码用了Redis列表(RPUSH/LPOP)实现FIFO队列。如果需要优先级队列,可以用Redis的ZSET(有序集合),以优先级分数排序。如果任务量更大,可以上Celery或KubeFlow Pipelines。

2. 训练脚本封装

勘智云针对每种任务类型封装了训练脚本。但写训练循环是重复劳动,先看现成的训练框架:

开源项目 GitHub 覆盖任务 特点
OpenMMLab系列 github.com/open-mmlab 分类(MMClassification)/检测(MMDetection)/分割(MMSegmentation) 全套CV训练流水线,配置文件驱动,不用写训练代码
Ultralytics YOLO github.com/ultralytics/ultralytics 目标检测、分割、姿态估计 一行命令训练:yolo train model=yolov8n.pt data=coco.yaml
PyTorch Lightning github.com/Lightning-AI/pytorch-lightning 通用PyTorch训练 把训练循环、验证、日志、checkpoint全封装好
Detectron2 github.com/facebookresearch/detectron2 检测、分割、姿态估计 Meta出品,预置大量模型和配置,适合研究型用户
** segmentation_models_pytorch** github.com/qubvel/segmentation_models.pytorch 图像分割 专注分割,几十种骨干网络+解码器组合

怎么选? 如果只做目标检测,Ultralytics YOLO 是最省事的——一行命令完成训练,自动输出模型。如果要做分类+检测+分割全任务类型平台,用 OpenMMLab 系列——三个库覆盖三种任务,配置文件统一,适合标准化。下面代码展示的是原理(自己写训练循环),实际生产环境用上面这些框架能省掉80%的训练代码。

以图像分类为例,核心逻辑用PyTorch实现:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from torchvision import models, transforms
from torchvision.datasets import ImageFolder

def train_classification(data_dir: str, 
                          epochs: int = 50,
                          lr: float = 0.001,
                          num_classes: int = 10):
    """图像分类训练函数
    
    对应勘智云"图像分类"任务:
    - 数据格式:data_dir下按类别文件夹组织
    - 模型:ResNet18,适合K210/K230算力
    - 输出:PyTorch .pt模型文件
    
    Args:
        data_dir: 数据集目录,子文件夹名即类别名
        epochs: 训练轮数
        lr: 学习率
        num_classes: 类别数
    """
    # 数据预处理
    transform = transforms.Compose([
        transforms.Resize((224, 224)),
        transforms.ToTensor(),
        transforms.Normalize(
            mean=[0.485, 0.456, 0.406],
            std=[0.229, 0.224, 0.225]
        )
    ])
    
    # 加载数据集(ImageFolder自动按文件夹名分类)
    train_set = ImageFolder(
        f"{data_dir}/train", transform=transform
    )
    val_set = ImageFolder(
        f"{data_dir}/val", transform=transform
    )
    train_loader = DataLoader(
        train_set, batch_size=32, shuffle=True
    )
    val_loader = DataLoader(
        val_set, batch_size=32, shuffle=False
    )
    
    # 模型选择:ResNet18(轻量,适合边缘设备)
    model = models.resnet18(
        weights=models.ResNet18_Weights.DEFAULT
    )
    model.fc = nn.Linear(model.fc.in_features, num_classes)
    model = model.cuda()
    
    # 训练配置
    criterion = nn.CrossEntropyLoss()
    optimizer = torch.optim.Adam(
        model.parameters(), lr=lr
    )
    scheduler = torch.optim.lr_scheduler.StepLR(
        optimizer, step_size=10, gamma=0.5
    )
    
    # 训练循环
    best_acc = 0.0
    for epoch in range(epochs):
        # 训练阶段
        model.train()
        for images, labels in train_loader:
            images, labels = images.cuda(), labels.cuda()
            outputs = model(images)
            loss = criterion(outputs, labels)
            
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
        
        # 验证阶段
        model.eval()
        correct, total = 0, 0
        with torch.no_grad():
            for images, labels in val_loader:
                images, labels = images.cuda(), labels.cuda()
                outputs = model(images)
                _, predicted = outputs.max(1)
                total += labels.size(0)
                correct += (predicted == labels).sum().item()
        
        acc = correct / total
        if acc > best_acc:
            best_acc = acc
            torch.save(
                model.state_dict(),
                "best_model.pt"
            )
        
        scheduler.step()
        print(f"Epoch {epoch+1}/{epochs} "
              f"Val Acc: {acc:.4f} Best: {best_acc:.4f}")
    
    return best_acc

3. 训练日志与Loss曲线

勘智云在训练详情页展示实时日志和Loss曲线。这个功能的技术实现是:训练脚本通过WebSocket推送日志到前端,前端用ECharts绘制曲线。简化版代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# 训练脚本中,每个epoch结束后推送loss
import websockets
import asyncio
import json

async def push_metrics(task_id: str, epoch: int, 
                        loss: float, acc: float):
    """通过WebSocket推送训练指标到前端"""
    async with websockets.connect(
        f"ws://localhost:8765/ws/{task_id}"
    ) as ws:
        await ws.send(json.dumps({
            "type": "metrics",
            "epoch": epoch,
            "loss": loss,
            "acc": acc,
            "timestamp": int(time.time())
        }))

# 在训练循环中调用
# await push_metrics(task_id, epoch, loss_val, acc_val)

模块三:模型量化与转换

这是勘智云平台最关键的差异化环节。通用训练平台(如Google Colab)只给你一个.pt.onnx文件,但勘智云直接输出.kmodel——这是K210/K230芯片专用的模型格式。

开源方案推荐(量化转换不用自己写)

模型量化是门槛最高的环节,涉及算子融合、校准、精度对齐等复杂操作。好在有多种成熟的开源工具覆盖不同硬件目标:

开源项目 GitHub 量化目标硬件 适用场景
ONNX Runtime github.com/microsoft/onnxruntime CPU/GPU通用 跨平台推理+量化,最通用
TensorRT NVIDIA官方 NVIDIA GPU Jetson Nano/AGX等NVIDIA设备,推理最快
OpenVINO github.com/openvinotoolkit/openvino Intel CPU/iGPU/VPU 树莓派、Intel NUC、Movidius
TFLite Converter github.com/tensorflow/tensorflow Android/MCU 手机、ESP32-S3、微控制器
NCNN github.com/Tencent/ncnn ARM移动端 Android/iOS应用内推理
Brevitas github.com/Xilinx/brevitas 通用(PyTorch原生量化) 量化感知训练(QAT),精度更高
Paddle-Lite github.com/PaddlePaddle/Paddle-Lite 多硬件 百度的边缘推理框架,支持ARM/GPU/NPU

怎么选? 看你的目标硬件。如果只面向一种硬件,直接用对应的工具链(比如Jetson就用TensorRT,树莓派就用OpenVINO)。如果想做"一次训练到处部署"的多硬件平台,以ONNX为中间格式,再分别转成各目标格式——这是最通用的路径。下面代码演示的就是这条 ONNX → TFLite 的转换链路。

量化的本质是把浮点模型转成定点模型,减少计算量和存储空间:

1
2
3
4
5
训练好的模型          量化过程               部署模型
┌──────────┐      ┌──────────────┐      ┌──────────┐
│ float32  │ ───→ │ 量化到int8   │ ───→ │ .kmodel  │
│ ~45MB    │      │ 精度损失<2%  │      │ ~11MB    │
└──────────┘      └──────────────┘      └──────────┘

如果不针对K210/K230,而是针对通用边缘设备,转换链路是这样的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# 完整的模型转换链路:PyTorch → ONNX → TFLite → 边缘设备

import torch

# 第一步:PyTorch模型导出为ONNX
def export_to_onnx(model, input_shape=(1, 3, 224, 224),
                    output_path="model.onnx"):
    """将PyTorch模型导出为ONNX格式
    
    ONNX是模型转换的"中间语言",
    几乎所有推理框架都能读ONNX。
    """
    model.eval()
    dummy = torch.randn(*input_shape)
    torch.onnx.export(
        model, dummy, output_path,
        export_params=True,
        opset_version=14,
        do_constant_folding=True,
        input_names=["input"],
        output_names=["output"],
        dynamic_axes={
            "input": {0: "batch"},
            "output": {0: "batch"}
        }
    )
    print(f"ONNX模型已保存: {output_path}")

# 第二步:ONNX转换为TFLite(量化)
def onnx_to_tflite_int8(onnx_path="model.onnx",
                          output_path="model_int8.tflite",
                          rep_data_dir="data/train"):
    """ONNX转TFLite并执行int8量化
    
    需要提供代表性数据集(rep_data)来校准量化参数。
    这和勘智云的KModel转换原理一致:
    都是用校准数据确定每层的量化scale和zero_point。
    """
    import onnx
    from onnx_tf.backend import prepare
    import tensorflow as tf
    
    # ONNX → TF
    onnx_model = onnx.load(onnx_path)
    tf_rep = prepare(onnx_model)
    tf_rep.export_graph("model_tf")
    
    # 收集校准数据
    rep_data = []
    for img_path in glob.glob(
        f"{rep_data_dir}/**/*.jpg", recursive=True
    )[:100]:  # 100张足够校准
        img = preprocess_image(img_path)  # resize+normalize
        rep_data.append(img)
    
    # TF → TFLite (int8量化)
    converter = tf.lite.TFLiteConverter.from_saved_model("model_tf")
    converter.optimizations = [tf.lite.Optimize.DEFAULT]
    converter.representative_dataset = lambda: rep_data
    converter.target_spec.supported_ops = [
        tf.lite.OpsSet.TFLITE_BUILTINS_INT8
    ]
    converter.inference_input_type = tf.uint8
    converter.inference_output_type = tf.uint8
    
    tflite_model = converter.convert()
    with open(output_path, "wb") as f:
        f.write(tflite_model)
    print(f"量化TFLite模型已保存: {output_path}")
    print(f"模型大小: {len(tflite_model)/1024/1024:.1f}MB")

为什么量化这么重要? 以ResNet18为例:float32模型45MB,int8量化后约11MB。K210的SRAM只有8MB,不量化根本塞不进去。量化是边缘AI部署的必经之路,不是可选项。

模块四:部署包生成与分发

勘智云训练完成后,用户拿到一个部署包,里面包含:

  • *.kmodel — 量化后的模型文件
  • deploy_config.json — 部署配置(阈值、分辨率等参数)
  • mp_deployment_source/ — MicroPython部署脚本
  • README.pdf — 部署文档

开源方案推荐(打包分发不用自己写)

开源项目 GitHub 能力 适用场景
BentoML github.com/bentoml/BentoML 模型+依赖+推理代码打包成一个"Bento" 把训练好的模型打包成可分发的服务
MLflow Model Registry github.com/mlflow/mlflow 模型版本管理 + 阶段管理(staging/production) 管理多个版本的模型,支持回滚
TorchServe github.com/pytorch/serve PyTorch官方模型服务框架 对接PyTorch模型,提供REST/gRPC推理API
MinIO github.com/minio/minio S3兼容对象存储 存储和分发模型文件,支持预签名URL下载
ONNX Runtime Web github.com/microsoft/onnxruntime 浏览器端推理 直接在网页里跑模型,不需要服务器

怎么选? 如果目标是"用户下载模型文件,自己部署到设备"(勘智云模式),用 MinIO 做存储 + BentoML 做打包。如果目标是"平台直接提供推理API"(云端推理模式),用 TorchServe 或 BentoML 部署为在线服务。下面代码演示的是勘智云模式——打包成ZIP发给用户。

这个"训练完成→打包→邮件发送"的流程,实现逻辑如下:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
import zipfile
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
from pathlib import Path
import json

def generate_deployment_package(
    task_id: str,
    model_path: str,
    task_type: str,
    labels: list,
    output_dir: str = "/tmp/deploy"
) -> str:
    """生成部署包(ZIP格式)
    
    模拟勘智云的部署包结构:
    ├── deploy_config.json
    ├── model.kmodel
    ├── mp_deployment_source/
    │   ├── infer.py
    │   └── __init__.py
    └── README.txt
    """
    deploy_dir = Path(output_dir) / task_id
    deploy_dir.mkdir(parents=True, exist_ok=True)
    
    # 1. 拷贝模型文件
    import shutil
    shutil.copy(model_path, deploy_dir / "model.kmodel")
    
    # 2. 生成部署配置
    deploy_config = {
        "task_type": task_type,
        "model_file": "model.kmodel",
        "labels": labels,
        "thresholds": {
            "confidence": 0.5,
            "nms": 0.45
        },
        "input_size": [224, 224],
        "mean": [0.485, 0.456, 0.406],
        "std": [0.229, 0.224, 0.225]
    }
    with open(deploy_dir / "deploy_config.json", "w") as f:
        json.dump(deploy_config, f, indent=2, ensure_ascii=False)
    
    # 3. 生成MicroPython推理脚本
    infer_code = '''# MicroPython推理脚本
import json
import sensor
import image
import KPU  # K230内置的推理库

# 加载配置
with open("deploy_config.json") as f:
    config = json.load(f)

# 加载模型
task = kpu.load(config["model_file"])
kpu.init_yolo2(task, config["thresholds"]["confidence"], 
               config["thresholds"]["nms"])

# 摄像头初始化
sensor.reset()
sensor.set_pixformat(sensor.RGB565)
sensor.set_framesize(sensor.QVGA)
sensor.run(1)

while True:
    img = sensor.snapshot()
    # 预处理
    img = img.resize(224, 224)
    # 推理
    results = kpu.yolo2_run(task, img)
    for obj in results:
        # 在画面上绘制结果
        img.draw_rectangle(obj.x(), obj.y(), 
                          obj.w(), obj.h())
    print(results)
'''
    mp_dir = deploy_dir / "mp_deployment_source"
    mp_dir.mkdir(exist_ok=True)
    (mp_dir / "infer.py").write_text(infer_code)
    
    # 4. 打包ZIP
    zip_path = f"{output_dir}/{task_id}_deploy.zip"
    with zipfile.ZipFile(zip_path, "w", 
                          zipfile.ZIP_DEFLATED) as zf:
        for file in deploy_dir.rglob("*"):
            if file.is_file():
                zf.write(file, 
                         file.relative_to(deploy_dir))
    
    return zip_path


def send_deployment_email(
    to_email: str, 
    task_id: str,
    zip_path: str
):
    """训练完成后发送部署包到用户邮箱"""
    msg = MIMEMultipart()
    msg["Subject"] = f"训练任务 {task_id} 已完成"
    msg["From"] = "[email protected]"
    msg["To"] = to_email
    
    body = f"""
    您的训练任务 {task_id} 已完成!
    
    部署包已作为附件发送,解压后请阅读README.txt进行部署。
    
    部署步骤:
    1. 将 model.kmodel 复制到开发板
    2. 将 deploy_config.json 复制到开发板
    3. 运行 mp_deployment_source/infer.py
    4. 如效果不佳,调整 deploy_config.json 中的阈值
    """
    msg.attach(MIMEText(body, "plain", "utf-8"))
    
    with open(zip_path, "rb") as f:
        part = MIMEBase("application", "zip")
        part.set_payload(f.read())
        encoders.encode_base64(part)
        part.add_header(
            "Content-Disposition",
            f"attachment; filename={task_id}_deploy.zip"
        )
        msg.attach(part)
    
    # 发送邮件(实际环境用SMTP配置)
    with smtplib.SMTP("smtp.example.com", 587) as server:
        server.starttls()
        server.login("user", "password")
        server.send_message(msg)
    
    print(f"部署包已发送至 {to_email}")

完整训练流程:端到端 Walkthrough

上面四个模块是独立的零件,但用户看到的是一条流水线:上传数据 → 配置训练 → 等待完成 → 下载部署包。下面把整条链路从头到尾串起来,每一步都有可执行的代码。

这一节是整篇文章的核心。 前面的模块拆解是"每个零件长什么样",这一节是"怎么把零件组装成一台能跑的机器"。建议跟着代码从头看到尾。

第一步:用户上传数据集

用户在前端选择一个 zip 压缩包上传。后端接收文件、解压、校验格式、注册到数据集管理表。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
# api/dataset.py — 数据集上传接口
from fastapi import APIRouter, UploadFile, File, BackgroundTasks
from pathlib import Path
import zipfile, shutil, json

router = APIRouter(prefix="/api/dataset", tags=["dataset"])

@router.post("/upload")
async def upload_dataset(
    background_tasks: BackgroundTasks,
    file: UploadFile = File(...),
    task_type: str = "classification",  # classification|detection|segmentation
    user_id: str = "user_001"
):
    """上传数据集压缩包
    
    对应勘智云的"上传训练数据"步骤:
    1. 接收zip文件
    2. 后台解压并校验
    3. 注册到数据集表
    4. 返回dataset_id供后续训练使用
    """
    # 保存上传的zip文件
    upload_dir = Path(f"/data/uploads/{user_id}")
    upload_dir.mkdir(parents=True, exist_ok=True)
    zip_path = upload_dir / file.filename
    with open(zip_path, "wb") as f:
        shutil.copyfileobj(file.file, f)
    
    # 后台异步解压+校验(不阻塞响应)
    dataset_id = f"ds_{int(time.time())}"
    background_tasks.add_task(
        extract_and_validate, zip_path, dataset_id, task_type
    )
    
    return {"dataset_id": dataset_id, "status": "extracting"}


def extract_and_validate(zip_path: Path, dataset_id: str, 
                          task_type: str):
    """解压数据集并校验格式(后台任务)
    
    不同任务类型的校验规则:
    - classification: 检查子文件夹名是否为类别名,每类至少10张图
    - detection: 检查每张图片是否有同名XML标注文件
    - segmentation: 检查每张图片是否有同名JSON标注文件
    """
    dataset_dir = Path(f"/data/datasets/{dataset_id}")
    dataset_dir.mkdir(parents=True, exist_ok=True)
    
    # 解压
    with zipfile.ZipFile(zip_path, "r") as zf:
        zf.extractall(dataset_dir)
    
    # 校验
    image_exts = {".jpg", ".jpeg", ".png", ".bmp"}
    images = list(dataset_dir.rglob("*"))
    images = [f for f in images if f.suffix.lower() in image_exts]
    
    if len(images) == 0:
        update_dataset_status(dataset_id, "error", 
                              "未找到图片文件")
        return
    if len(images) > 1000:
        update_dataset_status(dataset_id, "error",
                              f"图片数量{len(images)}超过1000上限")
        return
    
    # 按任务类型校验标注文件
    if task_type == "detection":
        for img in images:
            xml_file = img.with_suffix(".xml")
            if not xml_file.exists():
                update_dataset_status(dataset_id, "error",
                    f"检测标注缺失: {img.name} 需要同名XML文件")
                return
    elif task_type == "segmentation":
        for img in images:
            json_file = img.with_suffix(".json")
            if not json_file.exists():
                update_dataset_status(dataset_id, "error",
                    f"分割标注缺失: {img.name} 需要同名JSON文件")
                return
    
    # 校验通过,统计类别
    if task_type == "classification":
        # 子文件夹名就是类别名
        classes = set(
            f.parent.name for f in images
            if f.parent != dataset_dir
        )
        class_list = sorted(classes)
    else:
        class_list = ["object"]  # 检测/分割只有一类目标
    
    # 写入数据集元信息
    meta = {
        "dataset_id": dataset_id,
        "task_type": task_type,
        "num_images": len(images),
        "classes": class_list,
        "num_classes": len(class_list),
        "path": str(dataset_dir),
        "status": "ready"
    }
    with open(f"/data/datasets/{dataset_id}/meta.json", "w") as f:
        json.dump(meta, f, indent=2, ensure_ascii=False)
    
    update_dataset_status(dataset_id, "ready", 
                         f"{len(images)}张图片, {len(class_list)}个类别")

小贴士:这里用了 FastAPI 的 BackgroundTasks 做异步解压。如果你的数据集更大(GB级别),建议改用 Celery 任务队列,因为 BackgroundTasks 会占用 Web 进程的内存。

第二步:用户配置并提交训练任务

用户在前端选择数据集、任务类型、训练参数(轮数、学习率、骨干网络等),点击"开始训练"。前端调用提交接口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# api/training.py — 训练任务提交接口
from fastapi import APIRouter
from pydantic import BaseModel

router = APIRouter(prefix="/api/training", tags=["training"])

class TrainingRequest(BaseModel):
    """训练请求参数 — 对应勘智云的训练配置页面"""
    dataset_id: str           # 上一步拿到的数据集ID
    task_type: str            # classification|detection|segmentation
    epochs: int = 50          # 训练轮数
    learning_rate: float = 0.001  # 学习率
    batch_size: int = 32      # 批大小
    backbone: str = "resnet18"  # 骨干网络
    augmentation: bool = True   # 是否开启数据增强
    user_email: str           # 训练完成后通知的邮箱

@router.post("/submit")
async def submit_training(req: TrainingRequest):
    """提交训练任务
    
    流程:校验数据集 → 检查用户配额 → 入队列 → 返回task_id
    """
    # 1. 读取数据集元信息
    meta = load_dataset_meta(req.dataset_id)
    if meta["status"] != "ready":
        return {"error": f"数据集未就绪: {meta['status']}"}
    
    # 2. 检查用户是否已有任务在运行(勘智云的限制:每用户1个并发)
    queue = TrainingQueue()
    check = queue.submit(
        task_id=f"task_{int(time.time())}",
        config={
            "dataset_id": req.dataset_id,
            "task_type": req.task_type,
            "epochs": req.epochs,
            "learning_rate": req.learning_rate,
            "batch_size": req.batch_size,
            "backbone": req.backbone,
            "augmentation": req.augmentation,
            "num_classes": meta["num_classes"],
            "data_path": meta["path"],
            "classes": meta["classes"],
            "user_email": req.user_email
        },
        user_id="user_001"  # 实际从JWT token解析
    )
    
    if "error" in check:
        return check
    
    return {"task_id": check["task_id"], "status": "queued"}

第三步:GPU Worker 拉取任务并启动训练

后台有一个常驻的 GPU Worker 进程,不断轮询队列。拉到任务后,它根据 task_type 选择对应的训练脚本,启动训练:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
# worker/training_worker.py — GPU训练工作进程
import time, json, subprocess, os

def gpu_worker_loop(gpu_id: int = 0):
    """GPU Worker主循环
    
    这个进程常驻运行,不停从Redis队列拉任务。
    勘智云有8块V100,意味着可以同时跑8个worker。
    每个worker绑定一块GPU。
    """
    queue = TrainingQueue()
    
    while True:
        # 尝试拉取任务
        task = queue.fetch_next(gpu_id=str(gpu_id))
        if task is None:
            time.sleep(5)  # 没有任务,等5秒再查
            continue
        
        # 有任务了,开始训练
        print(f"[GPU {gpu_id}] 拉取任务 {task['task_id']}, "
              f"类型: {task['config']['task_type']}")
        
        run_training_pipeline(task, gpu_id)


def run_training_pipeline(task: dict, gpu_id: int):
    """完整的训练流水线 — 串起所有环节
    
    这就是勘智云"提交训练"按钮背后执行的完整流程:
    1. 启动WebSocket日志服务
    2. 运行训练脚本(根据task_type选择)
    3. 训练完成后导出ONNX
    4. 执行int8量化
    5. 生成部署包
    6. 发送邮件通知
    """
    task_id = task["task_id"]
    config = task["config"]
    
    # ---------- 阶段1:数据准备 ----------
    update_task_status(task_id, "preparing_data", 
                       "正在准备训练数据...")
    
    # 如果开启数据增强,生成增强后的数据集
    if config.get("augmentation", False):
        augmented_dir = apply_data_augmentation(
            config["data_path"],
            task_type=config["task_type"]
        )
        train_data_dir = augmented_dir
    else:
        # 自动划分训练集/验证集 (8:2)
        train_data_dir = split_train_val(
            config["data_path"], ratio=0.8
        )
    
    # ---------- 阶段2:启动训练 ----------
    update_task_status(task_id, "training",
                       f"训练中... {config['epochs']}轮")
    
    # 根据任务类型选择训练脚本
    # 训练脚本内部就是前面"模块二"里的train_classification等函数
    task_type = config["task_type"]
    
    if task_type == "classification":
        from training_scripts import train_classification
        best_acc, model_path = train_classification(
            data_dir=train_data_dir,
            epochs=config["epochs"],
            lr=config["learning_rate"],
            num_classes=config["num_classes"],
            backbone=config["backbone"],
            task_id=task_id  # 传入task_id用于推送日志
        )
    elif task_type == "detection":
        from training_scripts import train_detection
        best_map, model_path = train_detection(
            data_dir=train_data_dir,
            epochs=config["epochs"],
            lr=config["learning_rate"],
            num_classes=config["num_classes"],
            backbone=config["backbone"],
            task_id=task_id
        )
    elif task_type == "segmentation":
        from training_scripts import train_segmentation
        best_miou, model_path = train_segmentation(
            data_dir=train_data_dir,
            epochs=config["epochs"],
            lr=config["learning_rate"],
            num_classes=config["num_classes"],
            backbone=config["backbone"],
            task_id=task_id
        )
    
    # ---------- 阶段3:模型评估 ----------
    update_task_status(task_id, "evaluating",
                       "正在生成评估报告...")
    
    eval_report = evaluate_model(
        model_path=model_path,
        data_dir=config["data_path"],
        task_type=task_type,
        classes=config["classes"]
    )
    # eval_report包含:准确率/召回率/F1、混淆矩阵、PR曲线数据
    
    save_eval_report(task_id, eval_report)
    
    # ---------- 阶段4:模型转换与量化 ----------
    update_task_status(task_id, "converting",
                       "正在导出ONNX并量化...")
    
    # PyTorch → ONNX
    onnx_path = export_to_onnx(
        model=model_path,
        output_path=f"/data/output/{task_id}/model.onnx"
    )
    
    # ONNX → TFLite int8
    tflite_path = onnx_to_tflite_int8(
        onnx_path=onnx_path,
        output_path=f"/data/output/{task_id}/model_int8.tflite",
        rep_data_dir=config["data_path"]
    )
    
    # ---------- 阶段5:生成部署包 ----------
    update_task_status(task_id, "packaging",
                       "正在生成部署包...")
    
    zip_path = generate_deployment_package(
        task_id=task_id,
        model_path=tflite_path,
        task_type=task_type,
        labels=config["classes"]
    )
    
    # ---------- 阶段6:通知用户 ----------
    send_deployment_email(
        to_email=config["user_email"],
        task_id=task_id,
        zip_path=zip_path
    )
    
    # 标记任务完成
    queue.complete(task_id, {
        "status": "completed",
        "best_metric": eval_report.get("best_metric"),
        "model_path": zip_path,
        "eval_report_id": task_id
    })
    
    update_task_status(task_id, "completed",
                       f"训练完成!准确率: {eval_report.get('best_metric', 0):.2%}")
    print(f"[GPU {gpu_id}] 任务 {task_id} 完成")


def apply_data_augmentation(data_path: str, 
                             task_type: str) -> str:
    """数据增强 — 在1000张图片限制下提升效果
    
    勘智云限制1000张图片,数据增强就特别重要:
    原始1000张 → 增强5000张,相当于5倍数据量。
    
    策略:
    - 水平翻转(最安全,不改变语义)
    - 随机裁剪(模拟不同拍摄角度)
    - 色彩抖动(模拟不同光照条件)
    - 不用垂直翻转(鸟倒过来飞不合理)
    """
    from torchvision import transforms
    from PIL import Image
    import shutil
    
    aug_dir = f"{data_path}_augmented"
    shutil.copytree(data_path, aug_dir, dirs_exist_ok=True)
    
    aug_transform = transforms.Compose([
        transforms.RandomHorizontalFlip(p=0.5),
        transforms.RandomResizedCrop(224, scale=(0.8, 1.0)),
        transforms.ColorJitter(
            brightness=0.2, contrast=0.2,
            saturation=0.2, hue=0.1
        ),
    ])
    
    image_exts = {".jpg", ".jpeg", ".png"}
    for img_file in Path(data_path).rglob("*"):
        if img_file.suffix.lower() in image_exts:
            img = Image.open(img_file).convert("RGB")
            # 每张图生成3个增强版本
            for i in range(3):
                augmented = aug_transform(img)
                aug_path = (Path(aug_dir) / img_file.parent 
                           / f"{img_file.stem}_aug{i}{img_file.suffix}")
                augmented.save(aug_path)
    
    return aug_dir


def split_train_val(data_path: str, ratio: float = 0.8) -> str:
    """自动划分训练集和验证集
    
    把原始数据按8:2分成 train/ 和 val/ 两个子目录。
    训练脚本会自动从这两个目录读取数据。
    """
    import random, shutil
    random.seed(42)  # 固定随机种子,保证可复现
    
    images = list(Path(data_path).rglob("*"))
    images = [f for f in images 
              if f.suffix.lower() in {".jpg", ".jpeg", ".png"}]
    random.shuffle(images)
    
    split_idx = int(len(images) * ratio)
    train_imgs = images[:split_idx]
    val_imgs = images[split_idx:]
    
    for split_name, split_imgs in [("train", train_imgs), 
                                    ("val", val_imgs)]:
        split_dir = Path(data_path) / split_name
        split_dir.mkdir(exist_ok=True)
        for img in split_imgs:
            # 保持子目录结构(分类任务按类别文件夹)
            rel = img.relative_to(Path(data_path))
            dst = split_dir / rel
            dst.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy(img, dst)
    
    return data_path  # 返回数据根目录,训练函数自己找train/和val/


def evaluate_model(model_path: str, data_dir: str,
                   task_type: str, classes: list) -> dict:
    """模型评估 — 生成完整的评估报告
    
    不同任务类型的评估指标:
    - classification: Top-1准确率、混淆矩阵
    - detection: [email protected]、PR曲线
    - segmentation: mIoU、每类IoU
    
    这里以分类任务为例,生成混淆矩阵。
    """
    import torch
    from torchvision import transforms, models
    from torchvision.datasets import ImageFolder
    from torch.utils.data import DataLoader
    import numpy as np
    from sklearn.metrics import (confusion_matrix, 
                                  classification_report)
    
    # 加载训练好的模型
    model = models.resnet18()
    model.fc = torch.nn.Linear(model.fc.in_features, len(classes))
    model.load_state_dict(torch.load(model_path, 
                                     map_location="cpu"))
    model.eval()
    
    # 准备验证集
    transform = transforms.Compose([
        transforms.Resize((224, 224)),
        transforms.ToTensor(),
        transforms.Normalize(
            mean=[0.485, 0.456, 0.406],
            std=[0.229, 0.224, 0.225]
        )
    ])
    
    val_dir = f"{data_dir}/val"
    if not Path(val_dir).exists():
        val_dir = data_dir  # 没有划分的话用整个数据集
    
    val_set = ImageFolder(val_dir, transform=transform)
    val_loader = DataLoader(val_set, batch_size=32, shuffle=False)
    
    all_preds, all_labels = [], []
    with torch.no_grad():
        for images, labels in val_loader:
            outputs = model(images)
            _, preds = outputs.max(1)
            all_preds.extend(preds.numpy())
            all_labels.extend(labels.numpy())
    
    # 生成混淆矩阵
    cm = confusion_matrix(all_labels, all_preds)
    
    # 分类报告(精确率、召回率、F1)
    report = classification_report(
        all_labels, all_preds,
        target_names=classes,
        output_dict=True  # 返回字典格式
    )
    
    accuracy = report["accuracy"]
    
    return {
        "task_type": task_type,
        "best_metric": accuracy,
        "accuracy": accuracy,
        "confusion_matrix": cm.tolist(),
        "per_class_report": {
            cls: {
                "precision": report[cls]["precision"],
                "recall": report[cls]["recall"],
                "f1_score": report[cls]["f1-score"],
                "support": report[cls]["support"]
            }
            for cls in classes if cls in report
        },
        "macro_avg": report.get("macro avg", {}),
        "weighted_avg": report.get("weighted avg", {})
    }

第四步:前端实时查看训练进度

用户提交任务后,前端通过 WebSocket 连接到训练服务,实时展示训练进度和 Loss 曲线:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# websocket/training_ws.py — WebSocket服务端
from fastapi import WebSocket, WebSocketDisconnect
import json, asyncio

class ConnectionManager:
    """管理WebSocket连接 — 每个训练任务一个频道"""
    
    def __init__(self):
        # task_id → [WebSocket连接列表]
        self.connections: dict[str, list[WebSocket]] = {}
    
    async def connect(self, websocket: WebSocket, task_id: str):
        await websocket.accept()
        if task_id not in self.connections:
            self.connections[task_id] = []
        self.connections[task_id].append(websocket)
    
    def disconnect(self, websocket: WebSocket, task_id: str):
        if task_id in self.connections:
            self.connections[task_id].remove(websocket)
    
    async def broadcast(self, task_id: str, message: dict):
        """向所有监听该任务的前端推送消息"""
        if task_id not in self.connections:
            return
        dead = []
        for ws in self.connections[task_id]:
            try:
                await ws.send_json(message)
            except Exception:
                dead.append(ws)
        for ws in dead:
            self.connections[task_id].remove(ws)

manager = ConnectionManager()

# FastAPI WebSocket路由
@router.websocket("/ws/{task_id}")
async def training_websocket(websocket: WebSocket, 
                              task_id: str):
    """训练实时日志WebSocket
    
    前端连接后能收到:
    - {"type": "status", "status": "training", "msg": "..."}
    - {"type": "metrics", "epoch": 1, "loss": 0.85, "acc": 0.72}
    - {"type": "eval", "accuracy": 0.95, "confusion_matrix": [...]}
    - {"type": "done", "download_url": "/api/download/task_xxx"}
    """
    await manager.connect(websocket, task_id)
    try:
        while True:
            # 保持连接,等待训练脚本推送的消息
            data = await websocket.receive_text()
            # 前端也可以发消息(比如取消训练)
            if data == "cancel":
                # TODO: 设置取消标志,训练脚本下次检查时退出
                pass
    except WebSocketDisconnect:
        manager.disconnect(websocket, task_id)

前端(Vue3)接收消息并渲染 Loss 曲线:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// 前端 TrainingMonitor.vue — 训练监控组件
import { ref, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'

const props = defineProps({ taskId: String })

const status = ref('连接中...')
const lossData = ref([])    // [{epoch, loss, acc}, ...]
const chartRef = ref(null)
let chart = null
let ws = null

onMounted(() => {
  // 初始化ECharts图表
  chart = echarts.init(chartRef.value)
  chart.setOption({
    title: { text: '训练Loss曲线' },
    xAxis: { type: 'category', data: [] },
    yAxis: [
      { type: 'value', name: 'Loss' },
      { type: 'value', name: 'Accuracy', max: 1 }
    ],
    series: [
      { name: 'Loss', type: 'line', data: [], 
        smooth: true, itemStyle: { color: '#ff6b6b' } },
      { name: 'Accuracy', type: 'line', yAxisIndex: 1, 
        data: [], smooth: true, 
        itemStyle: { color: '#4ecdc4' } }
    ]
  })
  
  // 连接WebSocket
  ws = new WebSocket(`ws://localhost:8000/ws/${props.taskId}`)
  
  ws.onmessage = (event) => {
    const msg = JSON.parse(event.data)
    
    switch (msg.type) {
      case 'status':
        status.value = msg.status + ': ' + msg.msg
        break
      case 'metrics':
        // 追加数据点
        lossData.value.push({
          epoch: msg.epoch,
          loss: msg.loss,
          acc: msg.acc
        })
        // 更新图表
        chart.setOption({
          xAxis: { 
            data: lossData.value.map(d => `Epoch ${d.epoch}`) 
          },
          series: [
            { data: lossData.value.map(d => d.loss) },
            { data: lossData.value.map(d => d.acc) }
          ]
        })
        break
      case 'eval':
        // 显示评估结果
        status.value = `评估完成:准确率 ${(msg.accuracy * 100).toFixed(1)}%`
        break
      case 'done':
        status.value = '训练完成!'
        // 显示下载按钮
        downloadUrl.value = msg.download_url
        break
    }
  }
  
  ws.onclose = () => { status.value = '连接已断开' }
})

onUnmounted(() => {
  ws?.close()
  chart?.dispose()
})

第五步:用户下载部署包

训练完成后,用户在前端点击"下载部署包",或者通过邮件链接下载:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# api/download.py — 部署包下载接口
from fastapi import APIRouter
from fastapi.responses import FileResponse

router = APIRouter(prefix="/api/download", tags=["download"])

@router.get("/{task_id}")
async def download_package(task_id: str):
    """下载训练完成的部署包
    
    返回的ZIP包含:
    ├── deploy_config.json    — 部署配置
    ├── model_int8.tflite     — 量化后的模型
    ├── mp_deployment_source/ — MicroPython推理脚本
    │   └── infer.py
    ├── eval_report.json      — 评估报告
    └── README.txt           — 部署说明
    """
    zip_path = f"/data/output/{task_id}/{task_id}_deploy.zip"
    
    if not Path(zip_path).exists():
        return {"error": "部署包不存在或任务未完成"}
    
    return FileResponse(
        zip_path,
        media_type="application/zip",
        filename=f"{task_id}_deploy.zip"
    )

全流程时序图

把上面五个步骤串起来,用户从上传到拿到模型的时间线如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
用户          前端(Vue3)        API(FastAPI)       GPU Worker       Redis队列
                                                                   
 │──上传zip───→│                                                    
               │──POST /upload──→│                                  
               │←─dataset_id─────│                                  
                                 │──异步解压+校验──→│                
                                                                   
 │──配置参数──→│                                                    
               │──POST /submit──→│                                  
                                 │──submit()──────────────────────→│
               │←──task_id────────│                                  
                                                                   
               │──WS /ws/task_id→│                                  
                                                   │←─fetch_next()─│
                                                      (拉到任务)    
                                                                   
                                                     ┌─数据准备    
                                                     ├─训练(50)  
               │←──────metrics推送(每轮)──────────────│  ├─评估       
                 (ECharts画曲线)                     ├─导出ONNX   
                                                     ├─量化TFLite 
                                                     ├─打包ZIP    
                                                     └─发邮件      
               │←────done推送────│                                  
                                                                   
 │←──邮件通知──│                                                    
                                                                   
 │──点击下载──→│──GET /download/─→│                                  
 │←──ZIP文件───────────────────────│                                  
                                                                   

为什么这套流程值得参考?因为它把一个本来需要 ML 工程师手动操作的流程(写训练脚本、跑训练、看日志、转模型、写部署代码),变成了非技术用户也能走完的5步操作。勘智云的价值不在单点技术多强,而在于把链路串通了

整体架构图

把四个模块拼到一起,完整的平台架构如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
┌─────────────────────────────────────────────────────┐
│                   Web 前端 (Vue/React)                │
│  数据集管理 │ 在线标注 │ 训练配置 │ 结果可视化      │
└────────────────────┬────────────────────────────────┘
                      │ REST API + WebSocket
┌─────────────────────┼───────────────────────────────┐
│                API 网关 (FastAPI/Nginx)               │
│         用户认证 │ 任务路由 │ 文件上传               │
└──────┬──────┬──────┼──────┬──────┬───────────────────┘
       │      │      │      │      │
       ▼      ▼      ▼      ▼      ▼
    ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───────┐
    │标 │ │数 │ │训 │ │量 │ │部署包  │
    │注 │ │据 │ │练 │ │化 │ │生成   │
    │服 │ │存 │ │调 │ │转 │ │+邮件  │
    │务 │ │储 │ │度 │ │换 │ │       │
    └───┘ └───┘ └─┬─┘ └───┘ └───────┘
           ┌───────┴───────┐
           │  GPU 训练集群  │
           │  (Docker容器)  │
           │  PyTorch + CUDA│
           └───────────────┘

自建方案:技术选型清单

如果你要搭一套类似的平台,这是完整的选型参考:

模块 勘智云方案 开源自建方案 备注
前端 自研 Vue3 + Element Plus 或React+AntDesign
后端API 自研 FastAPI (Python) 天然适合ML场景
数据标注 自研 CVAT (Docker) Intel开源,功能完善
数据存储 自建 MinIO (S3兼容) 或直接用本地NAS
任务队列 自建 Redis + Celery 简单场景Redis够用
训练框架 自研脚本 PyTorch + torchvision 主流CV框架
GPU调度 自建 Docker + NVIDIA Container 或Kubernetes
模型量化 自研(KModel) ONNX → TFLite int8 通用性更强
部署格式 .kmodel .tflite / .onnx 看目标硬件
通知 邮件 邮件 + Webhook 可加Telegram Bot
日志监控 自研 ELK / Grafana 训练日志收集

关键设计决策与权衡

1. 限制1000张图片:这是聪明的取舍

勘智云限制每次训练最多1000张图片,压缩包200MB。这不是能力不足,而是有意设计——它把平台定位为"试用+验证"而非"生产训练"。

好处是:训练快(30分钟到2小时)、成本低(V100一小时约$3)、用户能快速验证想法。真正需要大规模训练时,用户自然会迁移到自建环境。

2. 在线标注 vs 压缩包上传:两条路给不同人

平台同时提供在线标注和压缩包上传,覆盖两类用户:

  • 非开发者:在线标注,所见即所得
  • 有经验的开发者:本地标注好直接打包上传,省去重复操作

3. 输出KModel而非ONNX:生态锁定

勘智云只输出KModel格式,这是绑定K210/K230芯片的。好处是用户拿到模型就能直接用,不需要懂量化细节。坏处是模型不能迁移到其他芯片。

如果自建平台,建议同时输出TFLite和ONNX两种格式,覆盖更广泛的边缘设备。

从平台到产品:还需要补什么

勘智云覆盖了核心链路,但如果要做商业产品,还有几块需要补:

  • 数据版本管理:用DVC(Data Version Control)追踪数据集变更,保证训练可复现
  • 模型评估报告:训练完成后自动生成混淆矩阵、PR曲线,让用户判断模型质量
  • A/B测试部署:在边缘设备上支持模型热切换,对比新旧模型效果
  • 数据增强:内置数据增强流水线(翻转、裁剪、色彩抖动),对1000张图片的限制特别有用
  • 模型库:用户可以分享和复用训练好的模型,降低重复训练成本

落地实施方案:从零搭建的完整路径

前面讲了"每个模块怎么实现"和"完整流程怎么串"。但如果你今天就要动手做一套,真正要回答的问题是:买什么服务器?怎么部署?数据库怎么设计?要多少钱?分几步走?下面逐个回答。

一、基础设施选型

搭一套AI训练平台,硬件核心就三块:GPU算力、存储、网络

GPU服务器选型:

方案 配置 适用阶段 月成本估算
入门:单机 1×RTX 3090 (24GB) + 64GB RAM MVP/开发验证 租~$300,自购~$2500
标准:单机 2×V100 (32GB) + 128GB RAM 小规模商用 租~$800,自购~$8000
规模化:集群 4×A100 (40GB) + 256GB RAM 多用户并发 租~$3000,自购~$30000

关键决策:租还是买? GPU降价很快,A100两年前$10000/张,现在二手$6000。如果刚开始做,建议租云GPU(AutoDL、Lambda Cloud、Vast.ai)验证模式,模式跑通了再买。勘智云用8块V100,说明它的峰值并发是8个训练任务。

存储选型:

  • MVP阶段:直接用本地磁盘(NVMe SSD,2TB起),数据集和模型文件放本地目录。简单、零成本。
  • 规模增长后:上 MinIO(S3兼容的对象存储),数据集和模型文件统一管理,支持多节点共享。MinIO本身就是Docker一键部署,无需额外成本。
  • 不推荐:训练期间频繁读写NAS/NFS,I/O会拖慢训练。数据应该在本机SSD上,训练完了再归档到对象存储。

网络架构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
                    互联网
                ┌─────┴─────┐
                │  Nginx    │  反向代理 + SSL + 限流
                │  (443)    │
                └─────┬─────┘
          ┌───────────┼───────────┐
          │           │           │
     ┌────┴───┐ ┌────┴───┐ ┌────┴───┐
     │前端静态 │ │API服务 │ │CVAT标注│
     │(8080)  │ │(8000)  │ │(8081)  │
     └────────┘ └────┬───┘ └────────┘
          ┌──────────┼──────────┐
          │          │          │
     ┌────┴───┐ ┌───┴────┐ ┌───┴────┐
     │ Redis  │ │Postgres│ │ MinIO  │
     │ (6379) │ │(5432)  │ │(9000)  │
     └────────┘ └────────┘ └────────┘
              ┌──────┴──────┐
              │  GPU Worker  │  绑定GPU
              │  (Docker)    │  访问本地SSD
              └─────────────┘

二、服务编排方案

所有服务用 Docker Compose 统一编排,一个 docker-compose.yml 拉起整个平台。不需要 Kubernetes——单机阶段 Docker Compose 足够。

核心服务清单:

服务名 镜像 端口 职责
nginx nginx:alpine 80/443 反向代理、SSL终止、静态资源
frontend 自构建(Vue3→Nginx) 8080 Web前端
api 自构建(FastAPI+Uvicorn) 8000 REST API + WebSocket
cvat cvat/server:latest 8081 在线标注工具
redis redis:7-alpine 6379 任务队列 + 缓存
postgres postgres:16 5432 用户/任务/数据集元数据
minio minio/minio:latest 9000 对象存储(数据集/模型文件)
gpu-worker 自构建(PyTorch+CUDA) GPU训练工作进程

为什么不用Kubernetes? K8s的学习成本和运维成本远超Docker Compose。当你的GPU节点超过3台、需要自动扩缩容时再上K8s。在此之前,Docker Compose + SSH部署到多台机器是更务实的选择。

三、数据库设计

平台需要管理的核心数据有四类:用户、数据集、训练任务、训练结果。核心表结构如下:

users(用户表)

字段 类型 说明
id UUID 主键
email VARCHAR 登录邮箱
password_hash VARCHAR bcrypt哈希
plan ENUM free / pro / enterprise
quota_remaining INT 剩余训练次数
created_at TIMESTAMP 注册时间

datasets(数据集表)

字段 类型 说明
id UUID 主键
user_id UUID 外键→users
name VARCHAR 数据集名称
task_type ENUM classification/detection/segmentation
num_images INT 图片数量
num_classes INT 类别数
classes JSON 类别名称列表
storage_path VARCHAR MinIO中的对象路径
status ENUM extracting/ready/error
created_at TIMESTAMP 上传时间

training_tasks(训练任务表)

字段 类型 说明
id UUID 主键
user_id UUID 外键→users
dataset_id UUID 外键→datasets
task_type ENUM 任务类型
config JSON 训练参数(epochs/lr/backbone等)
status ENUM queued/preparing_data/training/evaluating/converting/packaging/completed/failed
gpu_id VARCHAR 分配的GPU编号
best_metric FLOAT 最佳准确率/mAP/mIoU
model_path VARCHAR 部署包在MinIO中的路径
eval_report JSON 评估报告(混淆矩阵等)
started_at TIMESTAMP 开始训练时间
completed_at TIMESTAMP 完成时间
error_msg TEXT 失败原因(如有)

设计要点configeval_report 用 JSON 字段而非拆成多列。因为训练参数会随任务类型变化(分类要 epochs,检测要 anchor_size),用 JSON 更灵活。status 用枚举类型而非字符串,防止脏数据。PostgreSQL 对 JSON 字段有原生查询支持(config->>'backbone' = 'resnet18'),不用担心灵活性损失。

四、用户体系与配额管理

勘智云免费提供训练,但限制"每用户1个并发任务"。如果做商业平台,需要一个多级配额体系:

套餐 月费 并发任务 单次图片上限 单次训练轮数 模型保留期
免费 $0 1 500 30 7天
专业 $29/月 3 5000 100 90天
企业 $199/月 10 50000 不限 永久

配额执行逻辑:

  • 并发限制:用户提交任务时检查 training_tasks 表中 user_idstatus IN ('queued','training') 的记录数
  • 图片上限:数据集上传时校验 num_images 是否超过套餐限制
  • 轮数限制:提交训练时校验 config.epochs 是否超过套餐上限
  • 保留期:定时任务每天扫描 completed_at + 保留期 < now() 的记录,删除模型文件

为什么免费套餐也要给? AI训练平台的获客成本很高——用户需要先上传数据、标注、试跑,才能判断模型效果。免费套餐让用户零成本走完整流程,体验好了才愿意付费。这是 SaaS 的经典 PLG(Product-Led Growth)策略。

五、安全设计

AI平台有几个特殊的安全风险点:

1. 文件上传安全

用户上传的图片文件可能包含恶意内容。处理策略:

  • 限制文件类型:只接受 jpg/jpeg/png/bmp,通过文件头魔数校验,不只看扩展名
  • 限制文件大小:单文件 10MB,压缩包 200MB
  • 病毒扫描:上传后过 ClamAV 扫描(Docker部署,零成本)
  • 存储隔离:用户文件存储路径用 user_id/dataset_id/ 前缀,防止目录穿越

2. GPU隔离

多个用户共享GPU服务器时,一个用户的训练进程不能影响其他用户。方案:

  • 每个训练任务在独立Docker容器中运行
  • 容器通过 --gpus '"device=0"' 绑定到特定GPU
  • 设置容器内存限制(--memory=32g),防止OOM影响宿主机
  • 设置容器CPU限制(--cpus=4),防止数据预处理阶段CPU满载

3. API认证

  • JWT Token 认证,过期时间2小时
  • 敏感操作(提交训练、删除数据集)需要二次验证
  • WebSocket 连接也需要带Token鉴权(URL参数或子协议)
  • 文件上传走预签名URL(MinIO Presigned URL),不经过API服务器转发

六、成本估算

搭一套最小可用平台(MVP)的成本:

一次性成本:

项目 费用
域名 ~$10/年
SSL证书 免费(Let’s Encrypt)
前端开发 1人×2周
后端开发 1人×3周
GPU Worker开发 1人×2周
DevOps部署 1人×1周
合计人力 约8周(1人)或2周(3人团队)

月运营成本(MVP阶段):

项目 方案 月成本
GPU服务器 租1×RTX 3090(AutoDL/腾讯云) ~$300
Web服务器 4核8G云主机 ~$40
存储 1TB NVMe + 500GB对象存储 ~$30
带宽 5Mbps(训练数据上传用) ~$20
合计 ~$390/月

关键数字:RTX 3090租用约$0.4/小时。一个分类训练任务平均跑1小时(1000张图片,50轮),GPU成本$0.4。如果免费套餐每月给用户50次训练额度,单用户GPU成本$20/月。月费$29的专业套餐就能覆盖。这意味着10个付费用户就能覆盖GPU成本

七、分阶段实施路径

不要试图一次性把所有功能做完。分三个阶段:

Phase 1 — MVP(4-6周):验证核心链路

只做图像分类,砍掉所有非核心功能:

  • ✅ 用户注册登录(JWT)
  • ✅ 数据集上传(仅zip包,不支持在线标注)
  • ✅ 训练提交(固定参数:ResNet18,50轮)
  • ✅ 训练进度WebSocket推送
  • ✅ 模型下载(PyTorch .pt文件,不做量化)
  • ❌ 在线标注(直接用CVAT的外部链接)
  • ❌ 模型量化(用户自己用ONNX转)
  • ❌ 多任务类型(只做分类)
  • ❌ 配额系统(不限制)

MVP的目标不是功能完整,而是验证有没有用户愿意用。如果MVP阶段没人用,后面的投入就省了。

Phase 2 — 完整版(8-12周):补全差异化能力

在MVP验证可行后,补上平台的核心竞争力:

  • ✅ 在线标注集成(CVAT嵌入到平台内)
  • ✅ 三种任务类型(分类 + 检测 + 分割)
  • ✅ 模型量化流水线(PyTorch → ONNX → TFLite int8)
  • ✅ 部署包生成(ZIP打包 + 推理脚本 + 部署文档)
  • ✅ 模型评估报告(混淆矩阵 + PR曲线)
  • ✅ 配额系统(免费/专业/企业套餐)
  • ✅ 邮件通知

Phase 3 — 规模化(12-20周):多节点与生态

  • ✅ 多GPU节点调度(Docker Swarm或K8s)
  • ✅ 模型库(用户可公开/私有分享训练好的模型)
  • ✅ 数据版本管理(DVC集成)
  • ✅ 模型A/B测试部署
  • ✅ 更多任务类型(OCR、姿态估计、人脸检测)
  • ✅ API开放(让开发者通过API集成训练能力)

八、竞品差异化

勘智云绑定K210/K230芯片,这是它的护城河也是它的天花板。自建平台要在两个方向上找差异:

方向A:多硬件支持

勘智云只输出KModel。你的平台可以同时输出:

  • TFLite(树莓派、Android手机、ESP32-S3)
  • ONNX Runtime(Jetson Nano、PC)
  • NCNN(移动端ARM优化)
  • WASM(浏览器端推理)

价值主张:一次训练,到处部署。 用户不需要关心目标硬件是什么芯片,平台自动输出对应格式。这比勘智云的"只能跑K210"要宽得多。

方向B:垂直行业模板

勘智云是通用CV平台。你可以做垂直行业:

  • 农业病虫害识别模板(预置100种常见病虫害分类,用户只需补充本地数据微调)
  • 工业质检模板(缺陷检测专用,预置标准流水线 + 常见缺陷类别)
  • 零售货架分析模板(商品识别 + 陈列检测 + 空货架告警)

垂直模板的价值在于降低用户准备数据的门槛。通用平台用户需要从零标注,垂直平台预置了基础模型,用户只需要补充少量数据做微调(few-shot learning)。1000张图片的限制在通用平台是缺点,在垂直模板下反而够用——因为有了预训练基础,100张就能微调出不错的效果。

总结

勘智云平台的精髓不在技术复杂度,而在于把四个独立环节串成一条流水线:数据标注→模型训练→模型量化→边缘部署。每个环节单独看都不难,难的是把它们组合成一个让非技术用户也能用的产品。

如果你打算自建类似平台,记住一个原则:标注环节用CVAT,训练环节用PyTorch,量化环节走ONNX→TFLite,调度环节用Redis+Docker。 四个开源组件撑起一套平台,投入产出比远超从零自研。

边缘AI不是未来,是正在发生的事。从一台K230开发板开始,到一套云训练平台,中间差的不是芯片,是把数据变成模型的工程化能力。