Compare commits
14 Commits
server
...
1468834116
| Author | SHA1 | Date | |
|---|---|---|---|
| 1468834116 | |||
|
|
ba4059fe3b | ||
|
|
4050c120d5 | ||
| 156a952b47 | |||
|
|
4173b53b1b | ||
|
|
ab9e87f948 | ||
|
|
5a040366da | ||
| 8789019db2 | |||
|
|
3cb20a4a28 | ||
|
|
a6085a2612 | ||
|
|
4899bc8779 | ||
|
|
e70538e64d | ||
| 39a5390ecd | |||
| cd354f52e6 |
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
frontend/.vite
|
||||
npm-debug.log*
|
||||
docker-compose*.yml
|
||||
README.md
|
||||
design-qa.md
|
||||
docs
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY docker/nginx.conf.template /etc/nginx/templates/default.conf.template
|
||||
COPY --from=build /app/frontend/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1
|
||||
184
README.md
184
README.md
@@ -1,133 +1,107 @@
|
||||
# YG_FT
|
||||
# YG_FT 模型微调平台
|
||||
|
||||
远光微调平台 - 面向大语言模型的微调、评测、推理与对比一体化前端。
|
||||
YG_FT 是一个面向企业治理场景的完整模型微调平台,覆盖用户中心、多租户、项目隔离、数据集管理、模型管理、训练任务、评测、推理、审批流、审计留存、算力调度和训练引擎适配。当前前端已存在基础页面,后端与算力平台已按多人协作开发方式建立工程骨架。
|
||||
|
||||
## 技术栈
|
||||
## 总体架构
|
||||
|
||||
| 类别 | 技术 | 版本 |
|
||||
|------|------|------|
|
||||
| 框架 | Vue 3 | ^3.5.13 |
|
||||
| 语言 | TypeScript | ~5.7.2 |
|
||||
| 构建工具 | Vite | ^6.0.7 |
|
||||
| 路由 | Vue Router | ^4.5.0 |
|
||||
| 状态管理 | Pinia | ^2.3.0 |
|
||||
| UI 组件库 | Element Plus | ^2.9.1 |
|
||||
| HTTP 客户端 | axios | ^1.7.9 |
|
||||
| 图表 | ECharts / vue-echarts | ^6.1.0 / ^8.0.1 |
|
||||
| Markdown | marked + DOMPurify | ^15.0.5 / ^3.2.3 |
|
||||
| 编辑器 | md-editor-v3 | ^5.1.4 |
|
||||
| 工具集 | @vueuse/core | ^11.3.0 |
|
||||
| 样式 | Sass | ^1.83.0 |
|
||||
```text
|
||||
YG_FT/
|
||||
frontend/ # 前端应用,承载训练平台控制台页面
|
||||
backend/ # FastAPI 应用平台后端
|
||||
app/
|
||||
api/v1/ # 对前端暴露的 REST API
|
||||
core/ # 配置、日志、中间件、权限等基础能力
|
||||
db/ # 数据库连接、迁移、事务工具
|
||||
modules/ # 业务模块目录
|
||||
schemas/ # Pydantic 入参/出参模型
|
||||
services/ # 跨模块应用服务
|
||||
workers/ # 后台任务入口
|
||||
requirements.txt # 后端 Python 第三方依赖
|
||||
compute/ # 算力平台与训练框架适配层
|
||||
api/ # 内部 Compute API
|
||||
agent/ # 单机多 GPU 调度与进程管理
|
||||
engines/llama_factory/ # LLaMA-Factory 适配器
|
||||
file_gateway/ # 本地文件上传、下载、导入、产物管理
|
||||
docs/ # 需求、接口、数据库、开发计划和部署文档
|
||||
docker/ # Nginx 等容器化配置
|
||||
```
|
||||
|
||||
**项目版本**:1.0.0
|
||||
## 平台分层
|
||||
|
||||
## 环境要求
|
||||
| 层级 | 职责 | 主要目录 |
|
||||
| --- | --- | --- |
|
||||
| 前端控制台 | 用户操作入口、任务看板、项目/模型/数据集/训练/审批/审计页面 | `frontend/` |
|
||||
| 应用平台后端 | 用户中心、多租户、RBAC/ABAC、项目隔离、元数据、审批流、审计、API 编排 | `backend/` |
|
||||
| 算力平台 | GPU 发现、资源锁定、训练进程管理、日志采集、产物归档、任务状态回传 | `compute/` |
|
||||
| 训练引擎 | 当前固定接入 LLaMA-Factory,预留其他训练平台适配标准 | `compute/engines/` |
|
||||
| 数据层 | PostgreSQL、Redis、本地文件存储、日志归档 | `docs/postgres-schema.sql` |
|
||||
|
||||
- **Node.js** >= 18(推荐 20 LTS)
|
||||
- **npm** >= 9
|
||||
- 后端服务运行于 `http://localhost:7861`(前端通过代理转发,见下文)
|
||||
## 关键能力
|
||||
|
||||
## 快速开始
|
||||
- 多租户:租户级数据隔离、租户配置、租户成员和角色。
|
||||
- 权限控制:支持项目、模型、数据集级隔离,后续可扩展到字段级和操作级策略。
|
||||
- 审批流:覆盖数据集发布、模型发布、训练资源申请、推理服务上线等企业流程。
|
||||
- 审计留存:操作审计、安全审计、审批审计、任务审计,支持留存周期策略。
|
||||
- 训练任务:训练参数管理、单机多 GPU 调度、任务状态同步、训练日志、产物管理。
|
||||
- 引擎适配:默认 LLaMA-Factory,预留统一 Engine Adapter 接口接入其他微调框架。
|
||||
- 文件存储:当前使用本地磁盘,按租户/项目/数据集/任务分区。
|
||||
- 日志采集:后端 JSON Lines 日志,主日志和错误日志拆分,便于 ELK/日志平台采集。
|
||||
|
||||
### 1. 安装依赖
|
||||
## 后端启动
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
### 2. 启动开发服务器
|
||||
默认健康检查:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```text
|
||||
GET /api/v1/health
|
||||
```
|
||||
|
||||
开发服务器默认运行在 `http://localhost:6801`。
|
||||
## 日志
|
||||
|
||||
### 3. 构建生产包
|
||||
后端日志模块位于 `backend/app/core/logging.py`,说明文档见:
|
||||
|
||||
```bash
|
||||
npm run build # 类型检查 + 生产构建,产物输出到 dist/
|
||||
npm run preview # 本地预览构建产物
|
||||
- `docs/backend-logging.md`
|
||||
|
||||
默认输出:
|
||||
|
||||
```text
|
||||
logs/backend-YYYY-MM-DD.log
|
||||
logs/error-YYYY-MM-DD.log
|
||||
```
|
||||
|
||||
### 4. 类型检查
|
||||
日志格式为 JSON Lines,单个文件不超过 20MB,只保留最近 10 天。
|
||||
|
||||
```bash
|
||||
npm run type-check
|
||||
```
|
||||
## 主要文档
|
||||
|
||||
## 测试
|
||||
- `docs/platform-architecture-requirements.md`:平台需求、功能模块、页面补全建议。
|
||||
- `docs/backend-api-design.md`:FastAPI 接口分组、参数定义、权限说明。
|
||||
- `docs/postgres-schema.sql`:PostgreSQL 数据库脚本,包含权限、用户中心、多租户、审批、审计等模型。
|
||||
- `docs/system-development-plan.md`:多人协作开发计划,按前端、后端、DB、部署拆分。
|
||||
- `docs/backend-logging.md`:后端日志模块使用说明。
|
||||
- `docs/deployment-plan.md`:后期部署方案,覆盖单机算力服务器部署与应用/算力分离部署。
|
||||
|
||||
内置基于 Playwright 的 UI 回归脚本,首次运行前需安装浏览器:
|
||||
## 部署模式
|
||||
|
||||
```bash
|
||||
npx playwright install chromium
|
||||
```
|
||||
平台支持两种主要部署模式:
|
||||
|
||||
执行已注册的回归脚本:
|
||||
1. 所有服务部署在算力服务器:适合 PoC、内网试点、小团队单机多 GPU 使用。
|
||||
2. 应用服务和算力/训练服务独立部署:适合企业生产环境,应用平台部署在业务服务区,算力平台和 LLaMA-Factory 部署在 GPU 服务器。
|
||||
|
||||
```bash
|
||||
npm run test:data-process-wizard # 数据处理向导
|
||||
npm run test:model-manage # 模型管理
|
||||
npm run test:training-log-layout # 训练日志布局
|
||||
npm run test:page-surface # 页面表层级
|
||||
```
|
||||
生产环境建议采用第二种模式。算力平台与训练框架应部署在 GPU 算力服务器上,应用平台不直接控制 GPU 进程,而是通过内部 Compute API 调度训练任务。
|
||||
|
||||
其余脚本可直接运行:
|
||||
详细方案见 `docs/deployment-plan.md`。
|
||||
|
||||
```bash
|
||||
node scripts/regression-back-navigation.mjs # 返回导航
|
||||
node scripts/regression-fine-tune-create-ui.mjs # 调优创建 UI
|
||||
```
|
||||
## 后续开发原则
|
||||
|
||||
> 回归脚本默认连接 `http://localhost:6801`,需先启动开发服务器。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
YG-FT/
|
||||
├── frontend/ # 前端工程(Vue 3 SPA)
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # axios 封装 + 各业务模块 API
|
||||
│ │ ├── components/ # 公共组件
|
||||
│ │ ├── composables/ # 组合式函数
|
||||
│ │ ├── constants/ # 常量与映射表
|
||||
│ │ ├── layouts/ # 主布局
|
||||
│ │ ├── mock/ # Mock 数据与适配器
|
||||
│ │ ├── plugins/ # 第三方插件注册
|
||||
│ │ ├── router/ # 路由配置 + 登录守卫
|
||||
│ │ ├── stores/ # Pinia 状态
|
||||
│ │ ├── styles/ # 全局样式
|
||||
│ │ ├── types/ # TypeScript 类型定义
|
||||
│ │ └── views/ # 业务页面
|
||||
│ ├── scripts/ # UI 回归测试脚本
|
||||
│ ├── public/ # 静态资源
|
||||
│ └── vite.config.ts # Vite 构建与代理配置
|
||||
├── docs/ # 设计文档与视觉走查记录
|
||||
└── design-qa.md # 视觉走查汇总
|
||||
```
|
||||
|
||||
## 端口与代理
|
||||
|
||||
| 服务 | 地址 |
|
||||
|------|------|
|
||||
| 前端开发服务器 | `http://localhost:6801` |
|
||||
| 后端 API | `http://localhost:7861` |
|
||||
|
||||
前端统一使用 `/api` 相对路径发请求,由 Vite 开发代理转发到后端 `http://localhost:7861`(配置见 `frontend/vite.config.ts`)。
|
||||
|
||||
## 业务模块
|
||||
|
||||
| 模块 | 说明 |
|
||||
|------|------|
|
||||
| 登录 | 用户登录鉴权 |
|
||||
| 模型调优 | 微调任务创建与管理 |
|
||||
| 模型评测 | 评测任务与评测维度配置 |
|
||||
| 模型推理 | 在线推理对话 |
|
||||
| 模型对比 | 多模型对话与结果对比 |
|
||||
| 模型管理 | 模型 CRUD 与权重合并 |
|
||||
| 数据集 | 数据集管理与预览 |
|
||||
| 数据处理 | 数据处理任务向导 |
|
||||
| 工具 | 辅助工具集 |
|
||||
| 系统 | 硬件监控、日志、训练日志 |
|
||||
- 接口实现优先遵循 `docs/backend-api-design.md`。
|
||||
- 数据库实现优先遵循 `docs/postgres-schema.sql`,后续通过 Alembic 迁移管理变更。
|
||||
- 前端页面与后端接口、数据库表之间的映射以文档中的“对应页面/功能模块”为准。
|
||||
- 训练引擎适配必须通过 `compute/engines/` 下的标准接口,不在应用平台后端直接拼接训练命令。
|
||||
- 敏感信息不得写入日志,生产环境密钥通过环境变量或密钥管理系统注入。
|
||||
|
||||
9
backend/.env.example
Normal file
9
backend/.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
APP_NAME=YG Fine-Tune Platform API
|
||||
APP_ENV=local
|
||||
API_PREFIX=/api
|
||||
LOG_LEVEL=INFO
|
||||
LOG_DIR=./logs
|
||||
LOG_FILE_PREFIX=backend
|
||||
LOG_ERROR_FILE_PREFIX=error
|
||||
LOG_MAX_BYTES=20971520
|
||||
LOG_RETENTION_DAYS=10
|
||||
65
backend/README.md
Normal file
65
backend/README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Backend Service
|
||||
|
||||
后端工程使用 FastAPI,定位为模型微调平台的应用平台服务,负责用户中心、多租户、权限隔离、项目、数据集、模型、训练任务、审批、审计和算力平台编排。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
backend/
|
||||
app/
|
||||
main.py # FastAPI 应用入口
|
||||
api/v1/ # 对前端暴露的 API 路由
|
||||
core/ # 配置、日志、中间件、权限等基础能力
|
||||
db/ # 数据库连接、迁移集成、事务工具
|
||||
modules/ # 业务模块
|
||||
auth/
|
||||
tenant/
|
||||
project/
|
||||
model/
|
||||
dataset/
|
||||
data_process/
|
||||
fine_tune/
|
||||
eval/
|
||||
inference/
|
||||
approval/
|
||||
audit/
|
||||
compute_gateway/
|
||||
file_gateway/
|
||||
engine_registry/
|
||||
retention/
|
||||
system/
|
||||
schemas/ # Pydantic 入参/出参模型
|
||||
services/ # 跨模块应用服务
|
||||
workers/ # 后台任务入口
|
||||
requirements.txt # 后端第三方依赖
|
||||
logs/ # 本地开发日志目录,生产环境建议挂载到独立日志盘
|
||||
```
|
||||
|
||||
## 本地启动
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
健康检查:
|
||||
|
||||
```text
|
||||
GET /api/v1/health
|
||||
```
|
||||
|
||||
## 日志
|
||||
|
||||
日志模块位于 `app/core/logging.py`,使用说明见 `../docs/backend-logging.md`。
|
||||
|
||||
默认日志文件:
|
||||
|
||||
```text
|
||||
logs/backend-YYYY-MM-DD.log
|
||||
logs/error-YYYY-MM-DD.log
|
||||
```
|
||||
|
||||
文件日志为 JSON Lines 格式,单个文件不超过 20MB,只保存最近 10 天,错误日志按 `ERROR` 级别独立拆分,便于 ELK/日志平台采集。
|
||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Application package."""
|
||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API package."""
|
||||
1
backend/app/api/v1/__init__.py
Normal file
1
backend/app/api/v1/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Versioned API package."""
|
||||
1
backend/app/api/v1/endpoints/__init__.py
Normal file
1
backend/app/api/v1/endpoints/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API endpoint modules."""
|
||||
12
backend/app/api/v1/endpoints/health.py
Normal file
12
backend/app/api/v1/endpoints/health.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.core.logging import get_logger
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check() -> dict[str, str]:
|
||||
logger.info("health check requested")
|
||||
return {"status": "ok"}
|
||||
6
backend/app/api/v1/router.py
Normal file
6
backend/app/api/v1/router.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints.health import router as health_router
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health_router, tags=["health"])
|
||||
1
backend/app/core/__init__.py
Normal file
1
backend/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Core infrastructure modules."""
|
||||
28
backend/app/core/config.py
Normal file
28
backend/app/core/config.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
import os
|
||||
|
||||
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
raw = os.getenv(name)
|
||||
if raw is None or raw == "":
|
||||
return default
|
||||
return int(raw)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
app_name: str = os.getenv("APP_NAME", "YG Fine-Tune Platform API")
|
||||
app_env: str = os.getenv("APP_ENV", "local")
|
||||
api_prefix: str = os.getenv("API_PREFIX", "/api")
|
||||
log_level: str = os.getenv("LOG_LEVEL", "INFO")
|
||||
log_dir: str = os.getenv("LOG_DIR", "./logs")
|
||||
log_file_prefix: str = os.getenv("LOG_FILE_PREFIX", "backend")
|
||||
log_error_file_prefix: str = os.getenv("LOG_ERROR_FILE_PREFIX", "error")
|
||||
log_max_bytes: int = _int_env("LOG_MAX_BYTES", 20 * 1024 * 1024)
|
||||
log_retention_days: int = _int_env("LOG_RETENTION_DAYS", 10)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
253
backend/app/core/logging.py
Normal file
253
backend/app/core/logging.py
Normal file
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar
|
||||
from datetime import date, datetime, timedelta
|
||||
import json
|
||||
import logging
|
||||
from logging import Handler, LogRecord
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
|
||||
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
|
||||
|
||||
|
||||
class RequestIdFilter(logging.Filter):
|
||||
def filter(self, record: LogRecord) -> bool:
|
||||
record.request_id = request_id_var.get()
|
||||
return True
|
||||
|
||||
|
||||
class JsonLogFormatter(logging.Formatter):
|
||||
"""Format one JSON object per line for ELK/Filebeat collection."""
|
||||
|
||||
def format(self, record: LogRecord) -> str:
|
||||
payload: dict[str, Any] = {
|
||||
"@timestamp": datetime.fromtimestamp(record.created).astimezone().isoformat(
|
||||
timespec="milliseconds"
|
||||
),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
"module": record.module,
|
||||
"function": record.funcName,
|
||||
"file": record.pathname,
|
||||
"line": record.lineno,
|
||||
"process": record.process,
|
||||
"thread": record.thread,
|
||||
"thread_name": record.threadName,
|
||||
"request_id": getattr(record, "request_id", "-"),
|
||||
}
|
||||
if record.exc_info:
|
||||
payload["exception"] = self.formatException(record.exc_info)
|
||||
if record.stack_info:
|
||||
payload["stack"] = self.formatStack(record.stack_info)
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
class DateSizeRotatingFileHandler(Handler):
|
||||
"""Rotate log files by date and size while keeping date in every file name."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
log_dir: str | Path,
|
||||
file_prefix: str,
|
||||
max_bytes: int,
|
||||
retention_days: int,
|
||||
encoding: str = "utf-8",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.log_dir = Path(log_dir)
|
||||
self.file_prefix = file_prefix
|
||||
self.max_bytes = max_bytes
|
||||
self.retention_days = retention_days
|
||||
self.encoding = encoding
|
||||
self._current_date: date | None = None
|
||||
self._stream: Any | None = None
|
||||
self._current_path: Path | None = None
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def emit(self, record: LogRecord) -> None:
|
||||
try:
|
||||
message = self.format(record) + self.terminator
|
||||
encoded_size = len(message.encode(self.encoding))
|
||||
self._ensure_stream()
|
||||
if self._should_rotate(encoded_size):
|
||||
self._rotate_by_size()
|
||||
self._ensure_stream(force=True)
|
||||
self._stream.write(message)
|
||||
self.flush()
|
||||
self._cleanup_expired_files()
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
|
||||
@property
|
||||
def terminator(self) -> str:
|
||||
return "\n"
|
||||
|
||||
def flush(self) -> None:
|
||||
if self._stream and not self._stream.closed:
|
||||
self._stream.flush()
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
if self._stream and not self._stream.closed:
|
||||
self._stream.close()
|
||||
finally:
|
||||
self._stream = None
|
||||
super().close()
|
||||
|
||||
def _dated_path(self, target_date: date) -> Path:
|
||||
return self.log_dir / f"{self.file_prefix}-{target_date.isoformat()}.log"
|
||||
|
||||
def _ensure_stream(self, force: bool = False) -> None:
|
||||
today = date.today()
|
||||
if not force and self._stream and self._current_date == today:
|
||||
return
|
||||
|
||||
if self._stream and not self._stream.closed:
|
||||
self._stream.close()
|
||||
|
||||
self._current_date = today
|
||||
self._current_path = self._dated_path(today)
|
||||
self._stream = self._current_path.open("a", encoding=self.encoding)
|
||||
|
||||
def _should_rotate(self, incoming_size: int) -> bool:
|
||||
if not self._current_path or self.max_bytes <= 0:
|
||||
return False
|
||||
if not self._current_path.exists():
|
||||
return False
|
||||
return self._current_path.stat().st_size + incoming_size > self.max_bytes
|
||||
|
||||
def _rotate_by_size(self) -> None:
|
||||
if not self._current_path or not self._current_path.exists():
|
||||
return
|
||||
|
||||
if self._stream and not self._stream.closed:
|
||||
self._stream.close()
|
||||
self._stream = None
|
||||
|
||||
stem = self._current_path.stem
|
||||
suffix = self._current_path.suffix
|
||||
index = 1
|
||||
while True:
|
||||
rotated_path = self.log_dir / f"{stem}.{index}{suffix}"
|
||||
if not rotated_path.exists():
|
||||
self._current_path.rename(rotated_path)
|
||||
return
|
||||
index += 1
|
||||
|
||||
def _cleanup_expired_files(self) -> None:
|
||||
if self.retention_days <= 0:
|
||||
return
|
||||
|
||||
cutoff = date.today() - timedelta(days=self.retention_days - 1)
|
||||
pattern = re.compile(
|
||||
rf"^{re.escape(self.file_prefix)}-(\d{{4}}-\d{{2}}-\d{{2}})(?:\.\d+)?\.log$"
|
||||
)
|
||||
for path in self.log_dir.glob(f"{self.file_prefix}-*.log"):
|
||||
match = pattern.match(path.name)
|
||||
if not match:
|
||||
continue
|
||||
file_date = datetime.strptime(match.group(1), "%Y-%m-%d").date()
|
||||
if file_date < cutoff:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def configure_logging(settings: Settings | None = None) -> None:
|
||||
settings = settings or get_settings()
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.handlers.clear()
|
||||
root_logger.setLevel(settings.log_level.upper())
|
||||
|
||||
console_formatter = logging.Formatter(
|
||||
fmt=(
|
||||
"%(asctime)s | %(levelname)s | pid=%(process)d | %(threadName)s | "
|
||||
"request_id=%(request_id)s | %(name)s | %(pathname)s:%(lineno)d | %(message)s"
|
||||
),
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
json_formatter = JsonLogFormatter()
|
||||
request_filter = RequestIdFilter()
|
||||
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(console_formatter)
|
||||
console_handler.addFilter(request_filter)
|
||||
|
||||
file_handler = DateSizeRotatingFileHandler(
|
||||
log_dir=settings.log_dir,
|
||||
file_prefix=settings.log_file_prefix,
|
||||
max_bytes=settings.log_max_bytes,
|
||||
retention_days=settings.log_retention_days,
|
||||
)
|
||||
file_handler.setFormatter(json_formatter)
|
||||
file_handler.addFilter(request_filter)
|
||||
|
||||
error_file_handler = DateSizeRotatingFileHandler(
|
||||
log_dir=settings.log_dir,
|
||||
file_prefix=settings.log_error_file_prefix,
|
||||
max_bytes=settings.log_max_bytes,
|
||||
retention_days=settings.log_retention_days,
|
||||
)
|
||||
error_file_handler.setLevel(logging.ERROR)
|
||||
error_file_handler.setFormatter(json_formatter)
|
||||
error_file_handler.addFilter(request_filter)
|
||||
|
||||
root_logger.addHandler(console_handler)
|
||||
root_logger.addHandler(file_handler)
|
||||
root_logger.addHandler(error_file_handler)
|
||||
|
||||
for logger_name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
||||
logger = logging.getLogger(logger_name)
|
||||
logger.handlers.clear()
|
||||
logger.propagate = True
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
return logging.getLogger(name)
|
||||
|
||||
|
||||
def set_request_id(request_id: str) -> None:
|
||||
request_id_var.set(request_id)
|
||||
|
||||
|
||||
def setup_request_logging(app: FastAPI) -> None:
|
||||
logger = get_logger("app.access")
|
||||
|
||||
@app.middleware("http")
|
||||
async def request_logging_middleware(request: Request, call_next): # type: ignore[no-untyped-def]
|
||||
request_id = request.headers.get("X-Request-ID") or str(uuid4())
|
||||
token = request_id_var.set(request_id)
|
||||
started_at = time.perf_counter()
|
||||
try:
|
||||
response = await call_next(request)
|
||||
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
||||
logger.info(
|
||||
"request completed method=%s path=%s status_code=%s duration_ms=%.2f client=%s",
|
||||
request.method,
|
||||
request.url.path,
|
||||
response.status_code,
|
||||
elapsed_ms,
|
||||
request.client.host if request.client else "-",
|
||||
)
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
return response
|
||||
except Exception:
|
||||
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
||||
logger.exception(
|
||||
"request failed method=%s path=%s duration_ms=%.2f client=%s",
|
||||
request.method,
|
||||
request.url.path,
|
||||
elapsed_ms,
|
||||
request.client.host if request.client else "-",
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
request_id_var.reset(token)
|
||||
1
backend/app/db/__init__.py
Normal file
1
backend/app/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Database infrastructure package."""
|
||||
4
backend/app/db/session.py
Normal file
4
backend/app/db/session.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Database session factory placeholder.
|
||||
|
||||
Implement SQLAlchemy/SQLModel session management here when database development starts.
|
||||
"""
|
||||
18
backend/app/main.py
Normal file
18
backend/app/main.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.v1.router import api_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import configure_logging, setup_request_logging
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
configure_logging(settings)
|
||||
|
||||
app = FastAPI(title=settings.app_name)
|
||||
setup_request_logging(app)
|
||||
app.include_router(api_router, prefix=settings.api_prefix)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
15
backend/app/modules/README.md
Normal file
15
backend/app/modules/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Backend Module Convention
|
||||
|
||||
每个业务模块建议保持一致结构:
|
||||
|
||||
```text
|
||||
module_name/
|
||||
__init__.py
|
||||
router.py # FastAPI router
|
||||
schemas.py # Pydantic request/response models
|
||||
service.py # Business orchestration
|
||||
repository.py # Database access
|
||||
permissions.py # Optional resource permission checks
|
||||
```
|
||||
|
||||
模块边界以 `docs/system-development-plan.md` 的页面模块开发工作包为准。
|
||||
1
backend/app/modules/approval/__init__.py
Normal file
1
backend/app/modules/approval/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Approval workflow module."""
|
||||
1
backend/app/modules/audit/__init__.py
Normal file
1
backend/app/modules/audit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Audit log module."""
|
||||
1
backend/app/modules/auth/__init__.py
Normal file
1
backend/app/modules/auth/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Authentication and user session module."""
|
||||
1
backend/app/modules/compute_gateway/__init__.py
Normal file
1
backend/app/modules/compute_gateway/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Application-side compute platform gateway module."""
|
||||
1
backend/app/modules/data_process/__init__.py
Normal file
1
backend/app/modules/data_process/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Data processing module."""
|
||||
1
backend/app/modules/dataset/__init__.py
Normal file
1
backend/app/modules/dataset/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Dataset management module."""
|
||||
1
backend/app/modules/engine_registry/__init__.py
Normal file
1
backend/app/modules/engine_registry/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Training engine registry module."""
|
||||
1
backend/app/modules/eval/__init__.py
Normal file
1
backend/app/modules/eval/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Evaluation module."""
|
||||
1
backend/app/modules/file_gateway/__init__.py
Normal file
1
backend/app/modules/file_gateway/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Application-side file gateway module."""
|
||||
1
backend/app/modules/fine_tune/__init__.py
Normal file
1
backend/app/modules/fine_tune/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Fine-tuning task module."""
|
||||
1
backend/app/modules/inference/__init__.py
Normal file
1
backend/app/modules/inference/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Inference and compare module."""
|
||||
1
backend/app/modules/model/__init__.py
Normal file
1
backend/app/modules/model/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Model registry module."""
|
||||
1
backend/app/modules/project/__init__.py
Normal file
1
backend/app/modules/project/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Project workspace and member module."""
|
||||
1
backend/app/modules/retention/__init__.py
Normal file
1
backend/app/modules/retention/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Retention policy and cleanup module."""
|
||||
1
backend/app/modules/system/__init__.py
Normal file
1
backend/app/modules/system/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""System health, metrics and logs module."""
|
||||
1
backend/app/modules/tenant/__init__.py
Normal file
1
backend/app/modules/tenant/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tenant management module."""
|
||||
1
backend/app/schemas/__init__.py
Normal file
1
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Shared schemas package."""
|
||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Cross-module services package."""
|
||||
1
backend/app/workers/__init__.py
Normal file
1
backend/app/workers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Background workers package."""
|
||||
32
backend/pyproject.toml
Normal file
32
backend/pyproject.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
[project]
|
||||
name = "yg-ft-backend"
|
||||
version = "0.1.0"
|
||||
description = "Backend service for the model fine-tuning platform"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.111.0",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
"python-multipart>=0.0.9",
|
||||
"pydantic>=2.7.0",
|
||||
"sqlalchemy>=2.0.30",
|
||||
"asyncpg>=0.29.0",
|
||||
"alembic>=1.13.1",
|
||||
"redis>=5.0.4",
|
||||
"httpx>=0.27.0",
|
||||
"PyJWT>=2.8.0",
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"python-dotenv>=1.0.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.2.0",
|
||||
"ruff>=0.5.0",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
12
backend/requirements.txt
Normal file
12
backend/requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
fastapi>=0.111.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
python-multipart>=0.0.9
|
||||
pydantic>=2.7.0
|
||||
sqlalchemy>=2.0.30
|
||||
asyncpg>=0.29.0
|
||||
alembic>=1.13.1
|
||||
redis>=5.0.4
|
||||
httpx>=0.27.0
|
||||
PyJWT>=2.8.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
python-dotenv>=1.0.1
|
||||
24
compute/README.md
Normal file
24
compute/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Compute Platform
|
||||
|
||||
算力平台与应用平台分开部署,本目录用于后续实现单机多 GPU 调度、文件网关和训练引擎适配。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
compute/
|
||||
api/ # 只允许应用平台访问的内部 Compute API
|
||||
agent/ # 单机 Agent,负责 GPU、进程、工作区管理
|
||||
engines/
|
||||
llama_factory/ # LLaMA-Factory 训练引擎适配器
|
||||
file_gateway/ # 本地磁盘上传、下载、预览、离线导入
|
||||
tests/
|
||||
```
|
||||
|
||||
## 第一版职责
|
||||
|
||||
- GPU 发现、状态上报、锁定和释放。
|
||||
- 本地磁盘工作区管理。
|
||||
- 创建、停止、查询训练/评测/推理/合并任务。
|
||||
- LLaMA-Factory 命令生成、日志解析、产物收集。
|
||||
- 分片上传、短时下载、离线导入。
|
||||
- 通过服务间 token 接受应用平台调用。
|
||||
1
compute/agent/__init__.py
Normal file
1
compute/agent/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Compute agent package."""
|
||||
1
compute/api/__init__.py
Normal file
1
compute/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Compute API package."""
|
||||
1
compute/engines/__init__.py
Normal file
1
compute/engines/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Training engine adapters package."""
|
||||
1
compute/engines/llama_factory/__init__.py
Normal file
1
compute/engines/llama_factory/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""LLaMA-Factory engine adapter package."""
|
||||
1
compute/file_gateway/__init__.py
Normal file
1
compute/file_gateway/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Local file gateway package."""
|
||||
1
compute/tests/__init__.py
Normal file
1
compute/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Compute platform tests package."""
|
||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
yg-ft-frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: yg-ft-frontend:latest
|
||||
container_name: yg-ft-frontend
|
||||
ports:
|
||||
- "6801:80"
|
||||
environment:
|
||||
# Change this if the backend is deployed somewhere else.
|
||||
API_PROXY_PASS: "http://host.docker.internal:7861"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
30
docker/nginx.conf.template
Normal file
30
docker/nginx.conf.template
Normal file
@@ -0,0 +1,30 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
client_max_body_size 200m;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass ${API_PROXY_PASS};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
location ~* \.(?:js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
|
||||
try_files $uri =404;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
1070
docs/backend-api-design.md
Normal file
1070
docs/backend-api-design.md
Normal file
File diff suppressed because it is too large
Load Diff
109
docs/backend-logging.md
Normal file
109
docs/backend-logging.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# 后端日志模块说明
|
||||
|
||||
本文档对应页面/功能模块:全平台通用能力、系统设置、审计中心、任务详情、训练任务日志、运维监控。
|
||||
|
||||
## 设计目标
|
||||
|
||||
- 后端服务统一使用 `backend/app/core/logging.py` 初始化日志。
|
||||
- 日志文件按日期命名,单个文件超过 20MB 自动滚动。
|
||||
- 日志只保留最近 10 天,过期文件自动清理。
|
||||
- 业务日志使用 JSON Lines 格式,便于 Filebeat、Vector、Logstash、ELK、OpenSearch 等日志平台采集。
|
||||
- `ERROR` 及以上日志独立写入错误日志文件,便于告警与问题定位。
|
||||
- 日志字段必须包含代码文件、行号、函数、日志内容、请求 ID、进程和线程信息。
|
||||
|
||||
## 文件命名
|
||||
|
||||
默认日志目录由 `LOG_DIR` 控制,本地默认是 `./logs`。
|
||||
|
||||
```text
|
||||
logs/
|
||||
backend-2026-07-16.log # INFO/ERROR 等全部应用日志,JSON Lines
|
||||
backend-2026-07-16.1.log # 当天主日志超过 20MB 后滚动产生
|
||||
error-2026-07-16.log # ERROR/CRITICAL 错误日志,JSON Lines
|
||||
error-2026-07-16.1.log # 当天错误日志超过 20MB 后滚动产生
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
```env
|
||||
LOG_LEVEL=INFO
|
||||
LOG_DIR=./logs
|
||||
LOG_FILE_PREFIX=backend
|
||||
LOG_ERROR_FILE_PREFIX=error
|
||||
LOG_MAX_BYTES=20971520
|
||||
LOG_RETENTION_DAYS=10
|
||||
```
|
||||
|
||||
## JSON 字段
|
||||
|
||||
每一行都是一个完整 JSON 对象。
|
||||
|
||||
```json
|
||||
{
|
||||
"@timestamp": "2026-07-16T13:20:10.123",
|
||||
"level": "INFO",
|
||||
"logger": "app.access",
|
||||
"message": "request completed method=GET path=/api/v1/health status_code=200 duration_ms=3.12 client=127.0.0.1",
|
||||
"module": "logging",
|
||||
"function": "request_logging_middleware",
|
||||
"file": "D:\\AI\\codex-code\\YG_FT\\backend\\app\\core\\logging.py",
|
||||
"line": 169,
|
||||
"process": 1234,
|
||||
"thread": 5678,
|
||||
"thread_name": "MainThread",
|
||||
"request_id": "6f9d1c3c-8be0-4c8d-a5b2-18f9d41f9a0c"
|
||||
}
|
||||
```
|
||||
|
||||
异常日志会额外包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"exception": "Traceback ..."
|
||||
}
|
||||
```
|
||||
|
||||
## 使用方式
|
||||
|
||||
业务代码中不要直接 `print`,统一使用:
|
||||
|
||||
```python
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
logger.info("dataset uploaded dataset_id=%s", dataset_id)
|
||||
logger.warning("gpu queue is busy project_id=%s", project_id)
|
||||
logger.exception("training job failed job_id=%s", job_id)
|
||||
```
|
||||
|
||||
`logger.exception(...)` 只能在 `except` 代码块中使用,它会自动写入堆栈信息,并同时进入主日志和错误日志。
|
||||
|
||||
## FastAPI 接入
|
||||
|
||||
应用入口 `backend/app/main.py` 已完成接入:
|
||||
|
||||
```python
|
||||
settings = get_settings()
|
||||
configure_logging(settings)
|
||||
setup_request_logging(app)
|
||||
```
|
||||
|
||||
请求日志会自动生成或透传 `X-Request-ID`,并在响应头中返回同一个请求 ID,方便前端、后端、算力服务、日志平台串联排障。
|
||||
|
||||
## ELK/日志平台采集建议
|
||||
|
||||
- 采集路径:`/app/logs/*.log` 或生产环境挂载后的日志目录。
|
||||
- 解析方式:按行读取,每行作为 JSON 文档解析。
|
||||
- 索引建议:
|
||||
- 主日志:`yg-ft-backend-*`
|
||||
- 错误日志:`yg-ft-backend-error-*`
|
||||
- 推荐保留字段:`@timestamp`、`level`、`logger`、`message`、`file`、`line`、`function`、`request_id`、`tenant_id`、`project_id`、`job_id`。
|
||||
- 业务开发后续应在关键模块日志中补充 `tenant_id`、`project_id`、`job_id` 等上下文字段,便于企业审计和问题定位。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 当前日志落本地磁盘,生产环境建议把日志目录挂载到独立数据盘。
|
||||
- 日志文件保留 10 天是应用侧兜底策略,企业侧长期留存应由 ELK、对象存储或归档服务承担。
|
||||
- 敏感字段如 token、密码、密钥、原始用户数据内容不得写入日志。
|
||||
- 算力节点和应用节点分开部署时,建议两侧都采用 JSON Lines 格式,并使用统一 `request_id/job_id` 贯穿链路。
|
||||
305
docs/deployment-plan.md
Normal file
305
docs/deployment-plan.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# 模型微调平台后期部署方案
|
||||
|
||||
本文档对应页面/功能模块:系统设置、算力资源、训练任务、任务详情、模型管理、数据集管理、审批中心、审计中心、运维监控。
|
||||
|
||||
## 1. 部署目标
|
||||
|
||||
平台需要支持单机多 GPU 训练、本地磁盘文件存储、LLaMA-Factory 训练框架,并预留未来接入其他训练平台的能力。部署设计需要把“应用平台”和“算力平台”边界明确拆开:
|
||||
|
||||
- 应用平台:面向用户、权限、项目、模型、数据集、审批、审计、任务编排和 API。
|
||||
- 算力平台:面向 GPU、训练进程、训练框架、本地工作目录、训练日志和产物。
|
||||
- 训练框架:当前固定 LLaMA-Factory,后续通过 Engine Adapter 标准接入其他框架。
|
||||
|
||||
结论:算力平台和训练框架应该部署在 GPU 算力服务器上。原因是训练框架需要直接访问 GPU、CUDA、驱动、模型权重、本地数据集切片、训练工作目录和训练进程。应用平台可以与算力平台同机部署,也可以独立部署,但不建议在无 GPU 的应用服务器上直接运行 LLaMA-Factory。
|
||||
|
||||
## 2. 服务清单
|
||||
|
||||
| 服务 | 部署位置 | 职责 |
|
||||
| --- | --- | --- |
|
||||
| Nginx | 应用服务器或算力服务器 | 前端静态资源、反向代理、TLS 终止 |
|
||||
| Frontend | Nginx 静态目录 | 平台控制台 |
|
||||
| Backend API | 应用服务器 | FastAPI 接口、鉴权、元数据、审批、审计、任务编排 |
|
||||
| Backend Worker | 应用服务器 | 异步任务、状态同步、通知、审计归档 |
|
||||
| PostgreSQL | 应用服务器或独立数据库服务器 | 业务元数据、权限、审批、审计 |
|
||||
| Redis | 应用服务器或独立缓存服务器 | 队列、锁、短期状态、幂等控制 |
|
||||
| Compute API | GPU 算力服务器 | 只对应用平台开放的内部算力接口 |
|
||||
| Compute Agent | GPU 算力服务器 | GPU 发现、资源锁定、训练进程管理 |
|
||||
| File Gateway | GPU 算力服务器 | 本地文件上传、下载、离线导入、产物访问 |
|
||||
| LLaMA-Factory | GPU 算力服务器 | 实际训练、评测、合并、导出 |
|
||||
| 日志采集 Agent | 两侧服务器 | 采集应用日志、训练日志、系统日志 |
|
||||
|
||||
## 3. 目录与存储规划
|
||||
|
||||
建议生产环境把文件、日志、数据库数据分盘挂载:
|
||||
|
||||
```text
|
||||
/opt/yg-ft/
|
||||
app/ # 应用服务代码
|
||||
compute/ # 算力服务代码
|
||||
config/ # 环境配置和服务配置
|
||||
logs/
|
||||
backend/ # 后端 JSON Lines 日志
|
||||
compute/ # 算力服务日志
|
||||
training/ # 训练过程日志
|
||||
data/
|
||||
datasets/ # 数据集文件
|
||||
models/ # 基座模型、微调模型、导出模型
|
||||
jobs/ # 训练任务工作目录
|
||||
artifacts/ # 评测报告、adapter、checkpoint、导出包
|
||||
```
|
||||
|
||||
本地文件存储建议按租户、项目、资源类型分区:
|
||||
|
||||
```text
|
||||
/data/yg-ft/
|
||||
tenants/{tenant_id}/
|
||||
projects/{project_id}/
|
||||
datasets/{dataset_id}/
|
||||
models/{model_id}/
|
||||
jobs/{job_id}/
|
||||
```
|
||||
|
||||
## 4. 方案一:所有服务部署在算力服务器
|
||||
|
||||
### 4.1 适用场景
|
||||
|
||||
- PoC、试点环境、演示环境。
|
||||
- 小团队共用一台单机多 GPU 服务器。
|
||||
- 网络隔离要求不高,部署资源有限。
|
||||
|
||||
### 4.2 拓扑
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["用户浏览器"] --> N["Nginx/Frontend"]
|
||||
N --> B["Backend API"]
|
||||
B --> DB["PostgreSQL"]
|
||||
B --> R["Redis"]
|
||||
B --> C["Compute API"]
|
||||
C --> A["Compute Agent"]
|
||||
A --> L["LLaMA-Factory"]
|
||||
A --> G["GPU/CUDA"]
|
||||
A --> FS["本地磁盘文件存储"]
|
||||
```
|
||||
|
||||
### 4.3 部署方式
|
||||
|
||||
同一台 GPU 服务器部署:
|
||||
|
||||
- `frontend` 构建后由 Nginx 托管。
|
||||
- `backend-api` 使用 Uvicorn/Gunicorn 或容器运行。
|
||||
- `backend-worker` 独立进程运行。
|
||||
- `postgres` 和 `redis` 可使用 Docker Compose 或系统服务。
|
||||
- `compute-api`、`compute-agent`、`file-gateway` 与 LLaMA-Factory 在同机运行。
|
||||
- 训练产物、数据集、模型和日志都放在本地数据盘。
|
||||
|
||||
### 4.4 优点
|
||||
|
||||
- 部署简单,路径共享容易。
|
||||
- 上传数据、训练读取、产物归档都在本机完成,I/O 链路短。
|
||||
- 适合快速验证平台功能。
|
||||
|
||||
### 4.5 风险
|
||||
|
||||
- 应用服务、数据库、训练任务抢占同一台服务器资源。
|
||||
- GPU 训练高负载可能影响 API 响应。
|
||||
- 数据库与文件存储容灾能力弱。
|
||||
- 安全边界不清晰,企业生产不推荐长期使用。
|
||||
|
||||
### 4.6 端口建议
|
||||
|
||||
| 服务 | 端口 | 暴露范围 |
|
||||
| --- | --- | --- |
|
||||
| Nginx | 80/443 | 用户网段 |
|
||||
| Backend API | 8000 | 仅 Nginx、本机 |
|
||||
| Compute API | 9100 | 仅 Backend API、本机 |
|
||||
| File Gateway | 9101 | 仅 Backend API、本机 |
|
||||
| PostgreSQL | 5432 | 本机或内网 |
|
||||
| Redis | 6379 | 本机或内网 |
|
||||
|
||||
## 5. 方案二:应用服务与算力/训练服务独立部署
|
||||
|
||||
### 5.1 适用场景
|
||||
|
||||
- 企业生产环境。
|
||||
- 有独立应用服务器、数据库服务器和 GPU 算力服务器。
|
||||
- 需要清晰网络边界、权限边界和运维职责。
|
||||
- 未来可能扩展多台 GPU 服务器或多种训练框架。
|
||||
|
||||
### 5.2 拓扑
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["用户浏览器"] --> N["应用区 Nginx/Frontend"]
|
||||
N --> B["应用区 Backend API"]
|
||||
B --> DB["PostgreSQL"]
|
||||
B --> R["Redis"]
|
||||
B -- "内部 HTTPS/mTLS + 服务 Token" --> C["算力区 Compute API"]
|
||||
C --> A["Compute Agent"]
|
||||
A --> L["LLaMA-Factory"]
|
||||
A --> G["GPU/CUDA"]
|
||||
A --> FS["算力服务器本地磁盘"]
|
||||
A -- "状态回调/日志摘要" --> B
|
||||
```
|
||||
|
||||
### 5.3 部署边界
|
||||
|
||||
应用服务器部署:
|
||||
|
||||
- Nginx。
|
||||
- Frontend。
|
||||
- Backend API。
|
||||
- Backend Worker。
|
||||
- PostgreSQL 或数据库连接。
|
||||
- Redis 或队列连接。
|
||||
- 审批、审计、系统配置、用户中心等应用能力。
|
||||
|
||||
GPU 算力服务器部署:
|
||||
|
||||
- Compute API。
|
||||
- Compute Agent。
|
||||
- File Gateway。
|
||||
- LLaMA-Factory。
|
||||
- CUDA、NVIDIA Driver、NCCL、PyTorch、训练依赖。
|
||||
- 本地训练工作目录、模型目录、数据集缓存、产物目录。
|
||||
|
||||
### 5.4 互通方式
|
||||
|
||||
应用平台调用算力平台:
|
||||
|
||||
- 协议:内部 HTTPS REST,后续可扩展 gRPC。
|
||||
- 鉴权:服务间 Token,生产建议 mTLS + IP 白名单。
|
||||
- 幂等:训练任务提交使用 `Idempotency-Key` 或 `job_id`。
|
||||
- 回调:算力平台向应用平台回调任务状态、指标摘要、产物索引。
|
||||
- 拉取:应用平台也可以定时轮询 Compute API,避免回调失败导致状态丢失。
|
||||
|
||||
文件互通:
|
||||
|
||||
- 小文件:前端上传到 Backend API,再由 Backend API 转发或同步到 File Gateway。
|
||||
- 大文件:Backend API 创建上传会话,前端通过受控地址分片上传到 File Gateway。
|
||||
- 离线数据:管理员把数据放到算力服务器指定目录,应用平台登记离线导入任务。
|
||||
- 产物下载:应用平台校验权限后,向 File Gateway 申请短期下载地址。
|
||||
|
||||
状态互通:
|
||||
|
||||
- Backend API 是业务状态的最终来源。
|
||||
- Compute Agent 是训练进程状态的事实来源。
|
||||
- Worker 定时对账,把 `queued/running/succeeded/failed/cancelled` 等状态同步回业务库。
|
||||
|
||||
### 5.5 优点
|
||||
|
||||
- 应用服务稳定性不受 GPU 训练高负载直接影响。
|
||||
- 数据库和审计能力更适合纳入企业基础设施。
|
||||
- 算力节点可以逐步扩展,不影响前端和应用后端。
|
||||
- 安全边界更清晰,便于设置防火墙、堡垒机、服务账号和审计策略。
|
||||
|
||||
### 5.6 风险
|
||||
|
||||
- 文件传输链路比单机部署复杂。
|
||||
- 需要处理跨服务器网络失败、回调失败、任务状态对账。
|
||||
- 需要明确模型、数据集、产物在应用侧和算力侧的索引关系。
|
||||
|
||||
## 6. Compute API 接入标准
|
||||
|
||||
为预留其他训练平台,应用平台只依赖统一算力接口,不直接依赖 LLaMA-Factory 命令。
|
||||
|
||||
训练引擎适配器应提供:
|
||||
|
||||
- `validate_config(config)`:校验训练参数和模板。
|
||||
- `build_command(job)`:生成训练命令或执行计划。
|
||||
- `start(job)`:启动训练进程。
|
||||
- `stop(job_id)`:停止训练进程。
|
||||
- `status(job_id)`:查询训练状态。
|
||||
- `collect_metrics(job_id)`:采集 loss、learning rate、epoch、step 等指标。
|
||||
- `collect_artifacts(job_id)`:登记 checkpoint、adapter、导出模型、评测报告。
|
||||
- `parse_log(line)`:解析训练日志。
|
||||
|
||||
第一版适配器:
|
||||
|
||||
```text
|
||||
compute/engines/llama_factory/
|
||||
```
|
||||
|
||||
后续其他框架:
|
||||
|
||||
```text
|
||||
compute/engines/xtuner/
|
||||
compute/engines/deepspeed_custom/
|
||||
compute/engines/openrlhf/
|
||||
```
|
||||
|
||||
## 7. 环境变量建议
|
||||
|
||||
应用平台:
|
||||
|
||||
```env
|
||||
APP_ENV=prod
|
||||
API_PREFIX=/api
|
||||
DATABASE_URL=postgresql+asyncpg://yg_ft:***@postgres:5432/yg_ft
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
LOG_DIR=/opt/yg-ft/logs/backend
|
||||
COMPUTE_API_BASE_URL=https://compute.internal:9100
|
||||
COMPUTE_SERVICE_TOKEN=***
|
||||
FILE_GATEWAY_BASE_URL=https://compute.internal:9101
|
||||
```
|
||||
|
||||
算力平台:
|
||||
|
||||
```env
|
||||
COMPUTE_ENV=prod
|
||||
COMPUTE_HOST_ID=gpu-node-01
|
||||
COMPUTE_API_PORT=9100
|
||||
FILE_GATEWAY_PORT=9101
|
||||
APP_CALLBACK_BASE_URL=https://app.internal/api/v1/compute/callbacks
|
||||
APP_SERVICE_TOKEN=***
|
||||
LLAMA_FACTORY_HOME=/opt/LLaMA-Factory
|
||||
YG_FT_DATA_ROOT=/data/yg-ft
|
||||
LOG_DIR=/opt/yg-ft/logs/compute
|
||||
CUDA_VISIBLE_DEVICES=0,1,2,3
|
||||
```
|
||||
|
||||
## 8. 日志与监控
|
||||
|
||||
应用平台:
|
||||
|
||||
- 采集 `backend-YYYY-MM-DD.log` 和 `error-YYYY-MM-DD.log`。
|
||||
- 按 `request_id`、`tenant_id`、`project_id`、`job_id` 检索。
|
||||
- ERROR 日志触发告警。
|
||||
|
||||
算力平台:
|
||||
|
||||
- 采集 Compute API 日志、Agent 日志、训练原始日志。
|
||||
- 训练日志需要按 `job_id` 独立归档。
|
||||
- 关键指标包括 GPU 利用率、显存、磁盘容量、训练队列长度、失败率。
|
||||
|
||||
## 9. 安全要求
|
||||
|
||||
- Compute API 不对公网开放。
|
||||
- 应用平台和算力平台之间使用服务账号鉴权,生产建议 mTLS。
|
||||
- File Gateway 下载地址必须短期有效,并绑定租户、项目、资源权限。
|
||||
- 日志不得输出密码、Token、密钥、数据集原文敏感内容。
|
||||
- 审计日志留存周期按租户或企业配置执行,应用日志短期留存,长期归档交给日志平台。
|
||||
|
||||
## 10. 部署检查清单
|
||||
|
||||
- PostgreSQL 已初始化 `docs/postgres-schema.sql`。
|
||||
- Redis 可连通。
|
||||
- 后端 `GET /api/v1/health` 正常。
|
||||
- Compute API 健康检查正常。
|
||||
- Compute Agent 能识别 GPU、显存、CUDA 版本。
|
||||
- LLaMA-Factory 能在命令行完成最小训练样例。
|
||||
- 应用平台能提交训练任务到 Compute API。
|
||||
- 任务状态能从算力平台同步回应用平台。
|
||||
- 数据集上传、离线导入、产物下载路径权限正确。
|
||||
- 后端 JSON 日志可被日志平台解析。
|
||||
- ERROR 日志能触发告警。
|
||||
- 日志、数据集、模型、产物所在磁盘容量有监控和告警。
|
||||
|
||||
## 11. 仍需确认的问题
|
||||
|
||||
- 生产环境是否已有统一 ELK/OpenSearch、Filebeat/Vector 标准配置。
|
||||
- 数据库和 Redis 是否由企业基础设施统一提供,还是由项目自行部署。
|
||||
- 是否需要 PostgreSQL 主备、备份恢复、审计日志长期归档的明确 SLA。
|
||||
- 大文件上传是否需要断点续传、限速、病毒扫描或 DLP 检测。
|
||||
- 应用服务器与算力服务器之间是否允许双向访问,还是只能应用侧主动访问算力侧。
|
||||
- 是否需要未来支持多台 GPU 节点调度;如果需要,Compute API 需要提前设计节点注册和调度策略。
|
||||
744
docs/platform-architecture-requirements.md
Normal file
744
docs/platform-architecture-requirements.md
Normal file
@@ -0,0 +1,744 @@
|
||||
# 模型训练平台架构与功能需求设计
|
||||
|
||||
> 本文基于当前前端页面、已有接口/数据库设计,以及用户补充的 6 条约束进行补全。目标是把平台从“单前端原型 + 后端接口草案”扩展为企业可落地的模型训练平台方案。
|
||||
|
||||
## 1. 补充需求结论
|
||||
|
||||
### 1.1 已确认约束
|
||||
|
||||
1. 部署形态:单机多 GPU。
|
||||
2. 文件存储:本地磁盘。
|
||||
3. 训练框架:当前固定 LLaMA-Factory,但要预留其他训练平台接入标准。
|
||||
4. 权限粒度:需要到项目、模型、数据集级隔离。
|
||||
5. 企业治理:需要多租户、审批流、审计留存周期。
|
||||
6. 部署边界:算力平台与应用平台分开部署。
|
||||
|
||||
### 1.2 对整体设计的影响
|
||||
|
||||
- 不能只做页面级 RBAC,需要引入“租户 -> 项目 -> 资源 -> 成员/角色/ACL”的资源权限模型。
|
||||
- 单机多 GPU 不等于简单指定 GPU ID,需要有 GPU 资源池、锁定、排队、抢占策略和异常释放机制。
|
||||
- 本地磁盘存储与应用/算力分离存在天然冲突:文件不能只存应用服务器本地,也不能让应用直接读算力服务器目录。建议将训练文件、模型文件、日志文件统一落在算力节点本地磁盘,由算力平台提供文件网关 API;应用平台只保存元数据和访问路径。
|
||||
- LLaMA-Factory 应作为第一个训练引擎插件,而不是写死到业务流程里。后续接入其他平台时只需实现同一套训练引擎协议。
|
||||
- 审批流、审计和保留策略必须从第一版进入数据模型和接口,否则后期补会牵动大量资源表。
|
||||
|
||||
## 2. 总体架构设计
|
||||
|
||||
### 2.1 逻辑分层
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["用户浏览器"] --> APP["应用平台 Web / FastAPI"]
|
||||
APP --> DB["PostgreSQL"]
|
||||
APP --> REDIS["Redis / 任务队列"]
|
||||
APP --> CPAPI["算力平台 API"]
|
||||
CPAPI --> AGENT["算力节点 Agent"]
|
||||
AGENT --> GPU["单机多 GPU"]
|
||||
AGENT --> DISK["本地磁盘工作区"]
|
||||
AGENT --> LF["LLaMA-Factory 引擎"]
|
||||
```
|
||||
|
||||
### 2.2 应用平台职责
|
||||
|
||||
应用平台负责业务编排和企业治理,不直接执行训练命令:
|
||||
|
||||
- 用户、租户、项目、权限、审批、审计。
|
||||
- 模型、数据集、任务、评测、推理任务元数据。
|
||||
- 前端 API、OpenAPI、统一认证、统一响应。
|
||||
- 训练/评测/数据处理任务创建、状态查询、审批校验。
|
||||
- 与算力平台通信,下发任务、查询进度、拉取日志、下载产物。
|
||||
|
||||
建议部署组件:
|
||||
|
||||
- Nginx:静态前端与反向代理。
|
||||
- FastAPI:业务 API。
|
||||
- PostgreSQL:业务元数据。
|
||||
- Redis:缓存、任务队列、SSE 状态缓存、分布式锁。
|
||||
- Worker:审批通知、审计归档、周期清理、异步导出。
|
||||
|
||||
### 2.3 算力平台职责
|
||||
|
||||
算力平台负责“实际占用 GPU 和磁盘”的事情:
|
||||
|
||||
- GPU 发现、资源上报、锁定、释放。
|
||||
- 本地磁盘工作区管理。
|
||||
- 数据集文件接收、校验、解压、版本目录管理。
|
||||
- LLaMA-Factory 命令生成、执行、停止、日志采集。
|
||||
- 训练产物、LoRA adapter、merged model、checkpoint 管理。
|
||||
- 推理服务进程管理、端口分配、健康检查。
|
||||
- 系统/GPU 监控指标采集。
|
||||
|
||||
建议部署组件:
|
||||
|
||||
- Compute API:只暴露给应用平台访问。
|
||||
- Compute Agent:本机服务,具备启动/停止进程权限。
|
||||
- 本地文件网关:上传、下载、预览、断点续传、文件校验。
|
||||
- Engine Adapter:LLaMA-Factory 适配器,后续扩展其他训练引擎。
|
||||
|
||||
### 2.4 应用与算力平台通信
|
||||
|
||||
建议统一使用内部 HTTP/gRPC API,并配置服务间认证:
|
||||
|
||||
- 应用平台调用算力平台必须携带 `X-Service-Token` 或 mTLS 证书。
|
||||
- 算力平台不信任前端用户身份,只信任应用平台下发的租户、项目、任务和资源上下文。
|
||||
- 所有任务回调必须带签名,避免伪造状态。
|
||||
|
||||
核心通信接口:
|
||||
|
||||
| 方向 | 接口 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 应用 -> 算力 | `POST /compute/jobs` | 创建训练/评测/数据处理/推理任务 |
|
||||
| 应用 -> 算力 | `POST /compute/jobs/{id}/stop` | 停止任务 |
|
||||
| 应用 -> 算力 | `GET /compute/jobs/{id}` | 查询任务状态 |
|
||||
| 应用 -> 算力 | `GET /compute/jobs/{id}/logs` | 拉取日志 |
|
||||
| 应用 -> 算力 | `GET /compute/resources/gpus` | 查询 GPU 状态 |
|
||||
| 应用 -> 算力 | `POST /compute/files/upload` | 上传文件到算力本地磁盘 |
|
||||
| 应用 -> 算力 | `GET /compute/files/{object_id}/download` | 下载文件 |
|
||||
| 算力 -> 应用 | `POST /api/internal/compute-callbacks/jobs` | 回调任务状态、指标、产物 |
|
||||
|
||||
## 3. 本地磁盘存储设计
|
||||
|
||||
### 3.1 存储根目录
|
||||
|
||||
由于算力和应用分离,建议文件主存储放在算力节点本地磁盘:
|
||||
|
||||
```text
|
||||
/data/ft-platform/
|
||||
tenants/{tenant_id}/
|
||||
projects/{project_id}/
|
||||
datasets/{dataset_id}/
|
||||
models/base/{model_id}/
|
||||
models/trained/{trained_model_id}/
|
||||
jobs/{job_id}/
|
||||
input/
|
||||
output/
|
||||
logs/
|
||||
checkpoints/
|
||||
tmp/
|
||||
```
|
||||
|
||||
应用平台数据库保存:
|
||||
|
||||
- `storage_type=local`。
|
||||
- `storage_node_id`。
|
||||
- `relative_path`。
|
||||
- `checksum_sha256`。
|
||||
- `byte_size`。
|
||||
- `tenant_id/project_id/resource_id`。
|
||||
|
||||
### 3.2 文件访问原则
|
||||
|
||||
- 前端不直接访问磁盘路径。
|
||||
- 应用平台生成短时下载凭证,转发或重定向到算力文件网关。
|
||||
- 预览内容只读取前 N 行或指定区间,避免大文件撑爆 API。
|
||||
- 大文件上传必须支持分片上传、校验、断点续传。
|
||||
- 删除操作采用软删除 + 延迟清理,等待审计留存和审批结果。
|
||||
|
||||
### 3.3 磁盘配额
|
||||
|
||||
配额应分三层:
|
||||
|
||||
- 租户配额:总容量、模型容量、数据容量、日志容量。
|
||||
- 项目配额:可用容量、最大文件大小、最大任务产物保留数。
|
||||
- 用户配额:可上传文件总量、并发任务产物占用。
|
||||
|
||||
超过配额时:
|
||||
|
||||
- 禁止新建任务或上传文件。
|
||||
- 允许下载和清理。
|
||||
- 提示可清理的 checkpoint、过期日志、失败任务临时目录。
|
||||
|
||||
## 4. 单机多 GPU 资源调度
|
||||
|
||||
### 4.1 GPU 资源模型
|
||||
|
||||
每张 GPU 需要记录:
|
||||
|
||||
- `gpu_index`、`uuid`、型号、显存、驱动版本。
|
||||
- 当前利用率、显存占用、温度、功耗。
|
||||
- 当前锁定任务、进程 PID、端口。
|
||||
- 状态:`idle`、`reserved`、`running`、`draining`、`offline`、`error`。
|
||||
|
||||
### 4.2 调度策略
|
||||
|
||||
第一版建议支持三种模式:
|
||||
|
||||
- 手动指定 GPU:兼容当前前端选择 GPU 的模式。
|
||||
- 自动选择 GPU:按空闲显存、温度、任务队列选择。
|
||||
- 项目配额调度:项目最多可占用 N 张 GPU,避免单项目占满机器。
|
||||
|
||||
训练任务启动流程:
|
||||
|
||||
1. 应用平台校验权限、配额、审批状态。
|
||||
2. 生成任务,状态为 `pending`。
|
||||
3. 算力平台尝试锁定 GPU。
|
||||
4. 锁定成功后创建工作区并启动 LLaMA-Factory。
|
||||
5. Agent 持续上报进度、日志、指标。
|
||||
6. 任务完成后释放 GPU,登记模型产物。
|
||||
|
||||
### 4.3 并发与排队
|
||||
|
||||
- 同一 GPU 同时只允许一个训练任务。
|
||||
- 推理服务可与训练互斥,默认不允许混跑;如后续允许,需要显存保留策略。
|
||||
- 数据处理如果只调用 API 模型,可以不占 GPU;如果调用本地模型生成,需要占用 GPU。
|
||||
- 支持任务队列优先级:`low`、`normal`、`high`、`urgent`。
|
||||
- 高优任务是否可抢占低优任务,需要审批或管理员权限。
|
||||
|
||||
## 5. 训练引擎接入标准
|
||||
|
||||
### 5.1 引擎抽象
|
||||
|
||||
LLaMA-Factory 是第一实现,但业务系统只依赖统一训练引擎接口:
|
||||
|
||||
```text
|
||||
TrainingEngine
|
||||
validate_config(config)
|
||||
prepare_workspace(job_context)
|
||||
build_command(job_context)
|
||||
start(job_context)
|
||||
stop(job_id)
|
||||
parse_progress(log_line)
|
||||
collect_artifacts(job_id)
|
||||
export_model(job_id, export_config)
|
||||
```
|
||||
|
||||
### 5.2 引擎注册信息
|
||||
|
||||
每个训练引擎需要声明:
|
||||
|
||||
- 引擎编码:`llama_factory`。
|
||||
- 支持任务:`SFT`、`DPO`、`CPT`。
|
||||
- 支持方法:`lora`、`qlora`、`full`。
|
||||
- 支持模型模板:`qwen`、`llama3` 等。
|
||||
- 支持数据格式:Alpaca、ShareGPT、DPO pair、pretrain text。
|
||||
- 支持量化和导出格式。
|
||||
- 参数 schema。
|
||||
- 命令模板或启动方式。
|
||||
|
||||
### 5.3 LLaMA-Factory 适配要求
|
||||
|
||||
LLaMA-Factory 适配器负责:
|
||||
|
||||
- 将平台训练参数转换为 YAML/CLI 参数。
|
||||
- 根据项目工作区生成数据集配置。
|
||||
- 自动设置 `CUDA_VISIBLE_DEVICES`。
|
||||
- 解析训练日志中的 loss、epoch、learning_rate、ETA。
|
||||
- 收集 checkpoint、adapter、merged model、training_args、trainer_state。
|
||||
- 支持训练停止和失败恢复。
|
||||
|
||||
### 5.4 后续接入其他平台的标准
|
||||
|
||||
其他训练平台只要实现以下契约即可接入:
|
||||
|
||||
- 输入:模型引用、数据集引用、训练配置、资源需求、输出目录。
|
||||
- 输出:任务状态、进度、日志、指标、产物清单、失败原因。
|
||||
- 生命周期:`prepare`、`start`、`running`、`stop`、`complete`、`cleanup`。
|
||||
- 安全:不能越权访问其他租户/项目目录。
|
||||
- 可观测:必须输出结构化事件和日志。
|
||||
|
||||
## 6. 多租户与项目级隔离
|
||||
|
||||
### 6.1 租户模型
|
||||
|
||||
需要新增租户管理:
|
||||
|
||||
- 租户名称、编码、状态。
|
||||
- 租户管理员。
|
||||
- 租户资源配额:GPU 并发数、磁盘容量、最大项目数。
|
||||
- 租户审计策略、保留周期、审批策略。
|
||||
|
||||
### 6.2 项目空间
|
||||
|
||||
所有业务资源必须归属项目:
|
||||
|
||||
- 数据集。
|
||||
- 模型。
|
||||
- 训练任务。
|
||||
- 评测任务。
|
||||
- 推理任务。
|
||||
- 数据处理任务。
|
||||
- 自定义工具。
|
||||
|
||||
项目字段:
|
||||
|
||||
- 项目名称、描述、所属租户。
|
||||
- 项目管理员、成员。
|
||||
- 默认资源权限。
|
||||
- GPU/磁盘/任务并发配额。
|
||||
- 项目状态:启用、归档、禁用。
|
||||
|
||||
### 6.3 资源级权限
|
||||
|
||||
建议采用 RBAC + ACL 混合:
|
||||
|
||||
- RBAC 决定用户是否能访问模块,例如能否进入模型管理。
|
||||
- 项目角色决定用户是否能管理项目内资源。
|
||||
- 资源 ACL 处理特殊授权,例如某个数据集只给指定成员可读。
|
||||
|
||||
项目角色建议:
|
||||
|
||||
| 角色 | 权限 |
|
||||
| --- | --- |
|
||||
| Project Owner | 项目设置、成员、资源、审批策略全权限 |
|
||||
| Project Maintainer | 创建/编辑模型、数据集、任务,可发起发布和删除 |
|
||||
| Developer | 创建训练、评测、推理、数据处理任务 |
|
||||
| Reviewer | 审批、复核、查看评测结果 |
|
||||
| Viewer | 只读查看 |
|
||||
|
||||
资源权限建议:
|
||||
|
||||
- `read`:查看资源。
|
||||
- `write`:编辑元数据和内容。
|
||||
- `execute`:用于训练/评测/推理。
|
||||
- `download`:下载文件和模型。
|
||||
- `delete`:删除或申请删除。
|
||||
- `manage_acl`:管理资源授权。
|
||||
|
||||
### 6.4 数据隔离要求
|
||||
|
||||
- API 查询必须默认带 `tenant_id` 和用户可访问项目范围。
|
||||
- 数据库所有核心资源表增加 `tenant_id`、`project_id`。
|
||||
- 本地磁盘路径包含租户和项目 ID,防止路径混用。
|
||||
- 算力任务上下文必须携带租户/项目,Agent 只允许访问对应工作区。
|
||||
- 日志、审计、下载链接也必须按租户隔离。
|
||||
|
||||
## 7. 企业治理设计
|
||||
|
||||
### 7.1 审批流
|
||||
|
||||
建议第一版支持可配置审批模板:
|
||||
|
||||
| 场景 | 是否建议审批 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| 删除数据集 | 是 | 数据不可逆风险高 |
|
||||
| 删除模型 | 是 | 影响训练/推理依赖 |
|
||||
| 模型发布为可推理服务 | 是 | 影响生产资源 |
|
||||
| 停止他人训练任务 | 是或管理员直通 | 影响计算成本和他人工作 |
|
||||
| 导出模型 | 可配置 | 涉及资产外流 |
|
||||
| 下载敏感数据集 | 可配置 | 涉及数据安全 |
|
||||
| 提高 GPU 配额 | 是 | 涉及资源竞争 |
|
||||
|
||||
审批流能力:
|
||||
|
||||
- 发起审批。
|
||||
- 指定审批人/审批组。
|
||||
- 多级审批。
|
||||
- 通过、驳回、撤回、转交。
|
||||
- 审批超时提醒。
|
||||
- 审批结果回写原业务动作。
|
||||
|
||||
### 7.2 审计留存周期
|
||||
|
||||
建议支持租户级配置:
|
||||
|
||||
- 操作审计:默认 180 天,可配置 90/180/365/永久。
|
||||
- 登录审计:默认 180 天。
|
||||
- 训练日志:默认 90 天。
|
||||
- 系统监控:原始采样默认 30 天,聚合指标保留 1 年。
|
||||
- 模型产物:默认长期保留,删除需审批。
|
||||
- 临时文件:默认 7 天清理。
|
||||
- 失败任务工作区:默认 14 天清理。
|
||||
|
||||
审计不可被普通管理员物理删除,只能由系统归档任务按策略处理。
|
||||
|
||||
### 7.3 安全策略
|
||||
|
||||
需要补充:
|
||||
|
||||
- API Key 加密存储和脱敏展示。
|
||||
- 外部数据源密码加密存储或不落库。
|
||||
- 下载链接短时有效。
|
||||
- 敏感操作二次确认。
|
||||
- 审批通过后动作有效期,例如 24 小时内执行。
|
||||
- IP 白名单和服务间 token。
|
||||
- 操作审计记录 before/after 数据。
|
||||
|
||||
## 8. 需要补全的页面和功能
|
||||
|
||||
### 8.1 租户与项目页面
|
||||
|
||||
当前前端缺失,建议新增:
|
||||
|
||||
1. 租户管理页
|
||||
- 租户列表、创建、禁用、配额设置、审计策略。
|
||||
- 仅平台管理员可见。
|
||||
|
||||
2. 项目空间页
|
||||
- 项目列表、创建项目、归档项目。
|
||||
- 展示项目资源概览:模型数、数据集数、任务数、磁盘占用、GPU 使用。
|
||||
|
||||
3. 项目成员页
|
||||
- 添加/移除成员。
|
||||
- 设置项目角色。
|
||||
- 查看成员最近操作。
|
||||
|
||||
4. 项目资源权限页
|
||||
- 模型/数据集/任务级授权。
|
||||
- 支持按用户、用户组、项目角色授权。
|
||||
|
||||
### 8.2 用户中心补全
|
||||
|
||||
前端路由已有但页面文件缺失或未完成:
|
||||
|
||||
- `PermissionDeniedView.vue`
|
||||
- `UserSettingsView.vue`
|
||||
- `UserCreateView.vue`
|
||||
- `UserPermissionView.vue`
|
||||
|
||||
建议功能:
|
||||
|
||||
- 用户列表、创建、禁用、重置密码。
|
||||
- 角色管理。
|
||||
- 页面权限管理。
|
||||
- 用户所属租户/项目。
|
||||
- 用户可用 GPU/磁盘配额查看。
|
||||
- 登录记录和操作审计入口。
|
||||
|
||||
### 8.3 审批中心
|
||||
|
||||
新增页面:
|
||||
|
||||
- 我的申请。
|
||||
- 待我审批。
|
||||
- 已办审批。
|
||||
- 审批详情。
|
||||
- 审批模板配置。
|
||||
|
||||
审批详情需要展示:
|
||||
|
||||
- 申请人、申请时间、动作类型、目标资源。
|
||||
- 变更前后信息。
|
||||
- 风险提示。
|
||||
- 审批记录。
|
||||
- 通过/驳回意见。
|
||||
|
||||
### 8.4 算力资源中心
|
||||
|
||||
当前只有硬件监控页,建议扩展为算力资源中心:
|
||||
|
||||
- GPU 拓扑和状态。
|
||||
- GPU 当前任务占用。
|
||||
- GPU 锁定/释放记录。
|
||||
- 队列中的任务。
|
||||
- 资源配额:租户/项目/用户维度。
|
||||
- 算力节点 Agent 状态。
|
||||
- 训练引擎健康状态。
|
||||
|
||||
### 8.5 文件与存储管理
|
||||
|
||||
新增页面:
|
||||
|
||||
- 存储总览。
|
||||
- 租户/项目磁盘占用。
|
||||
- 大文件列表。
|
||||
- 临时文件清理。
|
||||
- Checkpoint 管理。
|
||||
- 日志保留策略。
|
||||
- 文件下载审计。
|
||||
|
||||
### 8.6 训练引擎管理
|
||||
|
||||
新增页面:
|
||||
|
||||
- 引擎列表。
|
||||
- LLaMA-Factory 版本和路径。
|
||||
- 引擎能力声明。
|
||||
- 参数 schema 管理。
|
||||
- 引擎健康检查。
|
||||
- 引擎接入文档。
|
||||
|
||||
### 8.7 任务队列与运行控制
|
||||
|
||||
新增页面:
|
||||
|
||||
- 全局任务队列。
|
||||
- 项目任务队列。
|
||||
- 任务优先级调整。
|
||||
- 任务重试。
|
||||
- 任务停止审批。
|
||||
- 失败任务诊断。
|
||||
|
||||
### 8.8 模型发布与服务治理
|
||||
|
||||
当前推理/对比页面已有基础能力,但缺少企业化发布能力:
|
||||
|
||||
- 模型发布申请。
|
||||
- 推理服务实例配置。
|
||||
- 端口、GPU、并发、超时、最大上下文限制。
|
||||
- 服务启停记录。
|
||||
- 调用统计。
|
||||
- 服务下线审批。
|
||||
|
||||
## 9. 后端模块补全
|
||||
|
||||
### 9.1 新增核心模块
|
||||
|
||||
| 模块 | 职责 |
|
||||
| --- | --- |
|
||||
| tenant | 租户管理、租户配额、租户策略 |
|
||||
| project | 项目空间、成员、项目角色 |
|
||||
| resource_acl | 模型/数据集/任务级资源授权 |
|
||||
| approval | 审批模板、审批实例、审批动作 |
|
||||
| quota | GPU、磁盘、任务并发配额 |
|
||||
| compute_gateway | 应用平台与算力平台通信 |
|
||||
| file_gateway | 本地磁盘文件上传、下载、预览 |
|
||||
| engine_registry | 训练引擎注册与能力发现 |
|
||||
| retention | 审计、日志、临时文件保留策略 |
|
||||
|
||||
### 9.2 数据模型补充
|
||||
|
||||
在前一版 SQL 基础上,应新增或调整:
|
||||
|
||||
- `tenants`
|
||||
- `tenant_users`
|
||||
- `projects`
|
||||
- `project_members`
|
||||
- `resource_acl`
|
||||
- `approval_templates`
|
||||
- `approval_instances`
|
||||
- `approval_steps`
|
||||
- `quotas`
|
||||
- `quota_usage`
|
||||
- `compute_nodes`
|
||||
- `gpu_devices`
|
||||
- `gpu_allocations`
|
||||
- `compute_jobs`
|
||||
- `training_engines`
|
||||
- `retention_policies`
|
||||
|
||||
核心资源表需要补充字段:
|
||||
|
||||
- `tenant_id`
|
||||
- `project_id`
|
||||
- `visibility`
|
||||
- `owner_id`
|
||||
- `approval_status`
|
||||
- `storage_node_id`
|
||||
|
||||
需要调整的已有表:
|
||||
|
||||
- `models`
|
||||
- `trained_models`
|
||||
- `datasets`
|
||||
- `dataset_files`
|
||||
- `data_process_tasks`
|
||||
- `fine_tune_tasks`
|
||||
- `eval_tasks`
|
||||
- `inference_tasks`
|
||||
- `custom_tools`
|
||||
- `audit_logs`
|
||||
- `storage_objects`
|
||||
|
||||
### 9.3 接口补充
|
||||
|
||||
租户:
|
||||
|
||||
- `GET /api/tenants`
|
||||
- `POST /api/tenants`
|
||||
- `GET /api/tenants/{id}`
|
||||
- `PUT /api/tenants/{id}`
|
||||
- `PUT /api/tenants/{id}/quota`
|
||||
- `PUT /api/tenants/{id}/retention-policy`
|
||||
|
||||
项目:
|
||||
|
||||
- `GET /api/projects`
|
||||
- `POST /api/projects`
|
||||
- `GET /api/projects/{id}`
|
||||
- `PUT /api/projects/{id}`
|
||||
- `POST /api/projects/{id}/archive`
|
||||
- `GET /api/projects/{id}/members`
|
||||
- `POST /api/projects/{id}/members`
|
||||
- `PUT /api/projects/{id}/members/{user_id}`
|
||||
- `DELETE /api/projects/{id}/members/{user_id}`
|
||||
|
||||
资源授权:
|
||||
|
||||
- `GET /api/resources/{resource_type}/{resource_id}/acl`
|
||||
- `PUT /api/resources/{resource_type}/{resource_id}/acl`
|
||||
- `POST /api/resources/{resource_type}/{resource_id}/share`
|
||||
|
||||
审批:
|
||||
|
||||
- `GET /api/approvals`
|
||||
- `POST /api/approvals`
|
||||
- `GET /api/approvals/{id}`
|
||||
- `POST /api/approvals/{id}/approve`
|
||||
- `POST /api/approvals/{id}/reject`
|
||||
- `POST /api/approvals/{id}/cancel`
|
||||
|
||||
算力:
|
||||
|
||||
- `GET /api/compute/nodes`
|
||||
- `GET /api/compute/gpus`
|
||||
- `GET /api/compute/queue`
|
||||
- `POST /api/compute/jobs/{id}/retry`
|
||||
- `POST /api/compute/jobs/{id}/priority`
|
||||
|
||||
训练引擎:
|
||||
|
||||
- `GET /api/training-engines`
|
||||
- `GET /api/training-engines/{id}`
|
||||
- `POST /api/training-engines/{id}/health-check`
|
||||
- `GET /api/training-engines/{id}/schema`
|
||||
|
||||
## 10. 端到端业务流程
|
||||
|
||||
### 10.1 数据集上传
|
||||
|
||||
1. 用户进入项目空间。
|
||||
2. 用户创建数据集。
|
||||
3. 应用平台校验项目写权限和磁盘配额。
|
||||
4. 前端上传文件到应用平台。
|
||||
5. 应用平台转发到算力文件网关,保存到项目目录。
|
||||
6. 算力平台返回文件元数据和 checksum。
|
||||
7. 应用平台登记数据集文件版本。
|
||||
8. 审计记录上传行为。
|
||||
|
||||
### 10.2 微调训练
|
||||
|
||||
1. 用户选择项目、模型、数据集、训练参数和 GPU。
|
||||
2. 应用平台检查模型/数据集 `execute` 权限。
|
||||
3. 检查项目 GPU 并发配额。
|
||||
4. 如果策略要求审批,先创建审批单。
|
||||
5. 审批通过后创建 compute job。
|
||||
6. 算力平台锁定 GPU,生成 LLaMA-Factory 配置,启动训练。
|
||||
7. 训练日志和指标实时回传。
|
||||
8. 完成后登记 trained model。
|
||||
9. 如启用自动合并,进入合并任务。
|
||||
10. 审计记录任务全生命周期。
|
||||
|
||||
### 10.3 模型发布
|
||||
|
||||
1. 用户选择训练产物。
|
||||
2. 提交发布申请。
|
||||
3. 审批通过后算力平台启动推理服务。
|
||||
4. 分配端口和 GPU。
|
||||
5. 应用平台登记服务实例。
|
||||
6. 前端推理页面调用服务。
|
||||
7. 监控调用量、延迟、错误率、GPU 占用。
|
||||
|
||||
## 11. 当前功能完整性评估
|
||||
|
||||
补充 6 条需求后,平台设计已经覆盖完整模型训练平台的主链路:
|
||||
|
||||
- 数据准备。
|
||||
- 数据处理。
|
||||
- 模型登记。
|
||||
- 微调训练。
|
||||
- 训练日志和指标。
|
||||
- 模型合并和导出。
|
||||
- 模型评测。
|
||||
- 推理服务。
|
||||
- 模型对比。
|
||||
- 用户、权限、租户、项目隔离。
|
||||
- 审批、审计、保留策略。
|
||||
- 算力资源调度。
|
||||
|
||||
但如果目标是企业级生产平台,还建议继续确认以下缺口。
|
||||
|
||||
## 12. 仍需确认的问题
|
||||
|
||||
1. 本地磁盘是否在算力服务器上,应用服务器是否完全不保存训练文件?如果应用服务器也要保存上传临时文件,需要确认临时文件保留周期和容量。
|
||||
2. 单机多 GPU 是否需要支持 MIG、GPU 分片或多进程共享,还是一张 GPU 同一时间只给一个任务?
|
||||
3. 是否允许训练任务抢占?高优先级任务是否能停止低优先级任务?
|
||||
4. 是否需要离线导入已有模型和已有数据集目录,还是所有文件都必须从平台上传?
|
||||
5. 模型发布是否区分“测试服务”和“生产服务”?生产发布是否必须审批?
|
||||
6. 是否需要数据集脱敏、敏感字段识别和数据质量评分作为内置流程?
|
||||
7. 是否需要人工评测/人工复核结果沉淀为新数据集?
|
||||
8. 是否需要训练任务失败后的断点续训?
|
||||
9. 是否需要 checkpoint 自动清理策略,例如只保留最近 N 个或最好 N 个?
|
||||
10. 是否需要对外提供标准 API 给其他系统调用训练、评测、推理能力?
|
||||
11. 是否需要接入企业统一身份认证,例如 LDAP、OIDC、企业微信、钉钉?
|
||||
12. 是否需要成本核算:按租户/项目统计 GPU 小时、磁盘占用、模型调用量?
|
||||
|
||||
## 13. 推荐决策补充
|
||||
|
||||
`system-development-plan.md` 已对上述问题给出第一版建议,需求设计以以下决策为准。
|
||||
|
||||
1. 本地磁盘主存储放在算力服务器,应用服务器只保留上传临时文件。临时文件默认保留 24 小时,成功转发到算力文件网关后可立即进入清理队列。
|
||||
2. 第一版不支持 MIG、GPU 分片和多任务共享同一张 GPU。一张 GPU 同一时间只分配给一个训练任务或一个推理服务。GPU 数据模型预留 `partition_type`、`parent_gpu_uuid`、`memory_total_mb`,便于后续扩展 MIG。
|
||||
3. 第一版不做自动抢占。支持任务优先级和排队;停止他人任务需要审批或平台管理员权限。
|
||||
4. 第一版必须支持离线导入已有模型和数据集目录。导入由算力 Agent 扫描、校验、登记,并归属指定租户和项目。
|
||||
5. 模型发布区分测试服务和生产服务。测试服务项目内可启动并默认限流;生产服务必须审批。
|
||||
6. 数据脱敏和数据质量评分作为数据处理模块的一等能力进入第一期,先实现规则版脱敏、格式校验、重复率、完整性、长度分布等指标。
|
||||
7. 人工评测/复核作为第二期功能,但第一期需在数据库和页面入口预留人工复核状态与修订字段。
|
||||
8. 第一版支持从 checkpoint 手动恢复训练,不做自动失败续训。失败任务可选择 checkpoint 重试。
|
||||
9. 第一版必须支持 checkpoint 自动清理策略:默认保留最近 3 个、最优 2 个;已发布模型关联 checkpoint 不自动删除;失败任务 checkpoint 默认保留 14 天。
|
||||
10. 第一版提供内部 API,第二期再开放面向其他系统的标准 API、API Key、限流和 Webhook。
|
||||
11. 第一版使用本地账号,预留 OIDC/LDAP 字段和认证 provider 抽象;第二期接入企业统一身份认证。
|
||||
12. 第一版做 GPU 小时、磁盘占用、任务时长、推理调用量等用量统计;第二期再做成本单价和账单核算。
|
||||
|
||||
以上决策需要同步反映在接口文档、数据库 SQL、前端页面和部署方案中。第一版实现不再阻塞于这些问题的反复确认,除非实际部署环境与假设明显冲突。
|
||||
|
||||
## 14. 页面功能模块映射
|
||||
|
||||
本节用于帮助前端、后端、DB 和测试人员理解需求对应到哪些页面与功能模块。页面路径以当前 Vue 路由和新增规划路由为准。
|
||||
|
||||
### 14.1 平台入口与全局能力
|
||||
|
||||
| 页面模块 | 路由/入口 | 对应需求 | 主要功能 |
|
||||
| --- | --- | --- | --- |
|
||||
| 登录页 | `/login` | 用户认证、本地账号、后续预留 OIDC/LDAP | 登录、会话创建、权限加载 |
|
||||
| 主布局 | `/` | 全局项目上下文、权限控制 | 菜单、顶部状态、项目切换器、用户信息 |
|
||||
| 无权限页 | `/permission-denied` | 页面权限和资源权限兜底 | 展示无权限原因、返回可访问页面 |
|
||||
| 服务看板 | `/dashboard` | 平台运行总览 | 服务健康、任务概览、训练统计、用户操作分布 |
|
||||
|
||||
### 14.2 租户、项目与权限治理
|
||||
|
||||
| 页面模块 | 建议路由 | 对应需求 | 主要功能 |
|
||||
| --- | --- | --- | --- |
|
||||
| 租户管理 | `/tenants`、`/tenants/:id` | 多租户、租户配额、留存策略 | 租户列表、创建/禁用租户、配额、审计留存策略 |
|
||||
| 项目空间 | `/projects`、`/projects/:id` | 项目级隔离、项目资源聚合 | 项目列表、项目概览、资源统计、项目归档 |
|
||||
| 项目成员 | `/projects/:id/members` | 项目角色 | 添加成员、移除成员、设置 owner/maintainer/developer/reviewer/viewer |
|
||||
| 资源授权 | `/projects/:id/permissions` 或资源详情弹窗 | 模型/数据集/任务级 ACL | 按用户、项目角色授权 read/write/execute/download/delete/manage_acl |
|
||||
| 用户中心 | `/user-settings`、`/user-settings/create`、`/user-settings/:id/permission` | 用户、角色、页面权限 | 用户列表、创建用户、禁用、重置密码、分配页面权限和项目 |
|
||||
|
||||
### 14.3 数据链路
|
||||
|
||||
| 页面模块 | 路由/入口 | 对应需求 | 主要功能 |
|
||||
| --- | --- | --- | --- |
|
||||
| 数据集列表 | `/dataset` | 数据集管理、项目隔离 | 列表、搜索、下载、删除审批入口 |
|
||||
| 数据集创建/编辑 | `/dataset/create`、`/dataset/:id/edit` | 上传、本地磁盘、文件网关 | 创建数据集、上传文件、离线导入、元数据编辑 |
|
||||
| 数据集预览 | `/dataset/:id/preview` | 文件版本、在线编辑、乐观锁 | 文件预览、版本切换、保存新版本、下载 |
|
||||
| 数据处理列表 | `/data-process` | 数据处理任务管理 | 任务列表、状态、输出数据集跳转、删除审批 |
|
||||
| 数据处理创建向导 | `/data-process/create` | 清洗、切片、生成、质量评分、脱敏 | 任务配置、模型选择、源文件/外部源、预览切片、生成、结果编辑、发布数据集 |
|
||||
| 数据处理详情 | `/data-process/:id` | 处理统计和结果追踪 | 运行信息、处理统计、失败原因、结果明细 |
|
||||
| 数据转换 | `/data-convert` | JSON/JSONL 转换 | 上传 JSON、转换任务、下载结果 |
|
||||
|
||||
### 14.4 模型训练链路
|
||||
|
||||
| 页面模块 | 路由/入口 | 对应需求 | 主要功能 |
|
||||
| --- | --- | --- | --- |
|
||||
| 模型管理 | `/model-manage` | 模型登记、离线导入、资源 ACL | 基座模型/API 模型列表、用途变更、授权、删除审批 |
|
||||
| 模型创建/编辑 | `/model-manage/create`、`/model-manage/:id/edit` | 本地模型/API 模型登记 | 选择本地路径、填写 API 模型信息、加密 API Key |
|
||||
| 权重合并 | `/model-manage/merge` | LoRA 合并、产物管理 | 选择训练产物、合并权重、生成 merged model |
|
||||
| 微调任务列表 | `/fine-tune` | 训练任务管理 | 任务列表、状态、进度、停止/删除审批 |
|
||||
| 微调创建 | `/fine-tune/create` | LLaMA-Factory 训练配置、GPU 调度 | 选择模型/数据集/GPU、训练参数、量化导出、提交审批或启动 |
|
||||
| 训练日志详情 | `/training-log/:id` | 日志、指标、checkpoint、恢复训练 | 日志 tail、loss 曲线、GPU 状态、checkpoint 列表、恢复/重试 |
|
||||
| 训练引擎管理 | `/training-engines` | LLaMA-Factory 插件化和后续引擎接入 | 引擎列表、能力声明、schema、健康检查 |
|
||||
|
||||
### 14.5 评测、推理和发布
|
||||
|
||||
| 页面模块 | 路由/入口 | 对应需求 | 主要功能 |
|
||||
| --- | --- | --- | --- |
|
||||
| 评测列表 | `/model-eval` | 模型评测 | 评测任务列表、状态、分数、删除 |
|
||||
| 评测创建 | `/model-eval/create` | 自动评测、LLM Judge、基础指标 | 选择模型/数据集/维度/GPU、配置指标、启动评测 |
|
||||
| 评测详情 | `/model-eval/:id` | 样本级结果、人工复核预留 | 综合评价、维度汇总、样本评分、错误类型 |
|
||||
| 评测维度 | `/model-eval/dimension/:id/edit` | 维度和评测 Prompt 管理 | 创建/编辑维度、评分范围、Prompt、启用状态 |
|
||||
| 推理列表 | `/model-inference` | 测试推理服务 | 推理任务列表、加载/卸载、服务状态 |
|
||||
| 推理创建 | `/model-inference/create` | 模型服务资源申请 | 选择模型、GPU、端口策略、并发参数 |
|
||||
| 推理对话 | `/model-inference/chat/:id` | 模型对话 | 单模型流式对话、会话记录 |
|
||||
| 模型对比 | `/model-compare/chat/:id`、`/model-compare/result` | 多模型对比 | 多模型加载、并行对话、对比结果 |
|
||||
| 模型发布治理 | `/model-services`、`/model-services/:id` | 测试/生产服务、发布审批 | 测试服务启动、生产发布申请、调用统计、下线审批 |
|
||||
|
||||
### 14.6 算力、审批、审计和运维
|
||||
|
||||
| 页面模块 | 建议路由 | 对应需求 | 主要功能 |
|
||||
| --- | --- | --- | --- |
|
||||
| 算力资源中心 | `/compute`、`/compute/gpus` | 单机多 GPU、资源锁定、队列 | GPU 卡片、任务占用、节点状态、队列、优先级 |
|
||||
| 存储管理 | `/storage` | 本地磁盘、配额、清理 | 租户/项目占用、大文件、临时文件、checkpoint 清理 |
|
||||
| 审批中心 | `/approvals`、`/approvals/pending`、`/approvals/mine`、`/approvals/:id` | 审批流 | 我的申请、待我审批、审批详情、通过/驳回/撤回 |
|
||||
| 审批模板 | `/approval-settings` | 审批策略配置 | 按动作配置审批人、超时、风险级别 |
|
||||
| 审计中心 | `/audit-logs`、`/login-logs`、`/download-logs` | 操作审计和留存 | 操作审计、登录审计、下载审计、筛选导出 |
|
||||
| 平台性能 | `/hardware` | 系统监控 | CPU、内存、磁盘、GPU、进程 |
|
||||
| 系统日志 | `/logs` | 日志查看 | 系统日志、训练日志、tail/offset 查询 |
|
||||
1386
docs/postgres-schema.sql
Normal file
1386
docs/postgres-schema.sql
Normal file
File diff suppressed because it is too large
Load Diff
1094
docs/system-development-plan.md
Normal file
1094
docs/system-development-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,15 @@ npm run dev
|
||||
|
||||
后端 API 默认通过 Vite 代理转发到 `http://localhost:7861`(见 `vite.config.ts`)。
|
||||
|
||||
开发环境默认启用前端 Mock。如需联调真实后端,使用:
|
||||
|
||||
```bash
|
||||
VITE_ENABLE_MOCK=false npm run dev
|
||||
```
|
||||
|
||||
生产构建默认不包含 Mock;仅在演示构建中可显式设置
|
||||
`VITE_ENABLE_MOCK=true`。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
|
||||
@@ -48,8 +48,8 @@ assert.match(dashboardSource, /grid-template-columns:\s*minmax\(0,\s*1\.9fr\)\s*
|
||||
assert.match(dashboardSource, /\.training-chart\s*\{[\s\S]*?height:\s*300px;[\s\S]*?min-height:\s*300px;/, '训练统计图应保持舒展、稳定的展示高度')
|
||||
assert.match(dashboardSource, /\.service-table\s*\{[\s\S]*?grid-template-rows:[^;]*repeat\(4,\s*minmax\(48px,\s*1fr\)\)/, '服务状态行应随中间区域同步拉伸')
|
||||
assert.match(dashboardSource, /\.tasks-heading\s*\{[\s\S]*?min-height:\s*32px;[\s\S]*?padding:\s*0 14px 10px;/, '训练任务标题区应适当加高')
|
||||
assert.match(dashboardSource, /\.tasks-table\s*\{[\s\S]*?th,\s*td\s*\{[\s\S]*?height:\s*42px;[\s\S]*?th\s*\{[\s\S]*?height:\s*36px;/, '训练任务表格行应适当加高')
|
||||
assert.match(dashboardSource, /@media\s*\(max-height:\s*900px\)[\s\S]*?\.tasks-heading\s*\{[\s\S]*?min-height:\s*28px;[\s\S]*?padding:\s*0 12px 8px;[\s\S]*?\.tasks-table\s*\{[\s\S]*?height:\s*36px;[\s\S]*?th\s*\{[\s\S]*?height:\s*32px;/, '低高度桌面视口也应提高训练任务占比')
|
||||
assert.match(dashboardSource, /\.tasks-table\s*\{[\s\S]*?th,\s*td\s*\{[\s\S]*?height:\s*50px;[\s\S]*?th\s*\{[\s\S]*?height:\s*38px;/, '训练任务表格正文行应保持舒展')
|
||||
assert.match(dashboardSource, /@media\s*\(max-height:\s*900px\)[\s\S]*?\.tasks-heading\s*\{[\s\S]*?min-height:\s*28px;[\s\S]*?padding:\s*0 12px 8px;[\s\S]*?\.tasks-table\s*\{[\s\S]*?height:\s*42px;[\s\S]*?th\s*\{[\s\S]*?height:\s*34px;/, '低高度桌面视口也应保留可读的训练任务行高')
|
||||
assert.match(dashboardSource, /class=["']user-stats-row["'][\s\S]*?用户操作分布[\s\S]*?登录时长排行[\s\S]*?最近登录用户/, '服务看板应展示三块用户统计卡片')
|
||||
assert.match(dashboardSource, /class=["']duration-chart["'][\s\S]*?loginDurationChartOption/, '登录时长应使用 ECharts 图表展示')
|
||||
assert.match(dashboardSource, /const loginDurationChartOption[\s\S]*?type:\s*['"]bar['"][\s\S]*?formatter:\s*['"]\{c\} 小时['"]/, '登录时长应以横向柱状图展示具体小时数')
|
||||
|
||||
@@ -181,7 +181,7 @@ assert.match(rootPageCardBlock, /box-shadow:\s*none\s*!important;/, '页面根
|
||||
const trainingLogStyle = [trainingLogSource, trainingOverviewSource]
|
||||
.flatMap((source) => parseSfc(source).descriptor.styles.map((item) => item.content))
|
||||
.join('\n')
|
||||
const businessSurfaceBlock = extractCssBlock(trainingLogStyle, '.profile-section,\n.runtime-panel')
|
||||
const businessSurfaceBlock = extractCssBlock(trainingLogStyle, '.task-overview')
|
||||
assert.match(
|
||||
businessSurfaceBlock,
|
||||
/background:\s*var\(--app-surface-bg\);/,
|
||||
|
||||
@@ -117,7 +117,8 @@ assert.match(
|
||||
assert.match(source, /import \{ getSystemInfo \} from '@\/api\/modules\/system'/, '训练概览必须复用系统 GPU 监控数据源')
|
||||
assert.match(source, /Promise\.all\(\[datasetPromise, loadLog\(currentTask\), loadGpuStatus\(\)\]\)/, 'GPU 状态必须和训练日志一起刷新')
|
||||
assert.match(source, /if \(refreshInFlight\) return/, '轮询刷新必须阻止并发重叠')
|
||||
assert.match(source, /onUnmounted\([\s\S]*?clearInterval\(timer\)/, '组件卸载时必须清理轮询定时器')
|
||||
assert.match(source, /usePolling/, '训练日志必须使用统一轮询机制')
|
||||
assert.match(source, /stopPolling\(\)/, '训练结束后必须停止轮询')
|
||||
|
||||
const overview = findElements(
|
||||
templateAst,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
// 根组件,仅承载路由出口
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-config-provider :locale="zhCn">
|
||||
<router-view />
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
@@ -17,10 +17,6 @@ const service: AxiosInstance = axios.create({
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// 安装 mock adapter(拦截所有 axios 请求返回 mock 数据,方便前端独立开发调试)
|
||||
import { installMockAdapter } from '@/mock/adapter'
|
||||
installMockAdapter(service)
|
||||
|
||||
// 请求拦截器
|
||||
service.interceptors.request.use(
|
||||
(config) => config,
|
||||
|
||||
@@ -27,6 +27,11 @@ function updateTime() {
|
||||
currentDate.value = now.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).replace(/\//g, '-')
|
||||
}
|
||||
|
||||
function openGuide() {
|
||||
const guideUrl = router.resolve({ name: 'guide' }).href
|
||||
window.open(guideUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const serverIp = ref(window.location.hostname)
|
||||
|
||||
onMounted(() => {
|
||||
@@ -103,6 +108,19 @@ onUnmounted(() => {
|
||||
<span>{{ currentTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider document-divider"></div>
|
||||
|
||||
<el-tooltip content="使用文档" placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
class="documentation-btn"
|
||||
aria-label="打开使用文档"
|
||||
@click="openGuide"
|
||||
>
|
||||
<i class="fa fa-book" aria-hidden="true" />
|
||||
</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -261,4 +279,50 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.documentation-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
font-size: 17px;
|
||||
transition: color 180ms ease, border-color 180ms ease, background-color 180ms ease, box-shadow 180ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(79, 70, 229, 0.28);
|
||||
background: #eef2ff;
|
||||
color: var(--primary-color);
|
||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 3px solid rgba(79, 70, 229, 0.2);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.header-right {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.system-info .info-item:first-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.system-info,
|
||||
.header-right > .divider:not(.document-divider) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,82 @@
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
interface PollingOptions {
|
||||
/** 启动后是否立即执行一次,默认立即执行。 */
|
||||
immediate?: boolean
|
||||
/** 页面不可见时是否暂停,默认暂停。 */
|
||||
pauseWhenHidden?: boolean
|
||||
/** 轮询函数抛错时的可选回调。 */
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询 composable
|
||||
* 封装 useIntervalFn,自动在组件卸载时清理
|
||||
* 串行轮询:上一轮完成后才安排下一轮,避免慢请求重叠和旧响应覆盖。
|
||||
* 页面进入后台时自动暂停,恢复可见后立即刷新;组件卸载时自动清理。
|
||||
*/
|
||||
export function usePolling(fn: () => void | Promise<void>, interval = 5000, immediate = true) {
|
||||
const { pause, resume } = useIntervalFn(fn, interval, { immediate })
|
||||
export function usePolling(
|
||||
fn: () => void | Promise<void>,
|
||||
interval: number | (() => number) = 5000,
|
||||
options: PollingOptions = {},
|
||||
) {
|
||||
const { immediate = true, pauseWhenHidden = true, onError } = options
|
||||
const isActive = ref(false)
|
||||
const isRunning = ref(false)
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearTimer() {
|
||||
if (!timer) return
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
clearTimer()
|
||||
if (!isActive.value) return
|
||||
if (pauseWhenHidden && document.hidden) return
|
||||
const delay = typeof interval === 'function' ? interval() : interval
|
||||
timer = setTimeout(() => void run(), Math.max(0, delay))
|
||||
}
|
||||
|
||||
async function run() {
|
||||
clearTimer()
|
||||
if (!isActive.value || isRunning.value) return
|
||||
if (pauseWhenHidden && document.hidden) return
|
||||
|
||||
isRunning.value = true
|
||||
try {
|
||||
await fn()
|
||||
} catch (error) {
|
||||
onError?.(error)
|
||||
} finally {
|
||||
isRunning.value = false
|
||||
schedule()
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
pause()
|
||||
isActive.value = false
|
||||
clearTimer()
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (immediate) fn()
|
||||
resume()
|
||||
if (isActive.value) return
|
||||
isActive.value = true
|
||||
if (immediate) void run()
|
||||
else schedule()
|
||||
}
|
||||
|
||||
return { start, stop, pause, resume }
|
||||
function handleVisibilityChange() {
|
||||
if (!pauseWhenHidden || !isActive.value) return
|
||||
if (document.hidden) clearTimer()
|
||||
else void run()
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('visibilitychange', handleVisibilityChange))
|
||||
onBeforeUnmount(() => {
|
||||
stop()
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
})
|
||||
|
||||
return { start, stop, run, isActive, isRunning }
|
||||
}
|
||||
|
||||
9
frontend/src/env.d.ts
vendored
9
frontend/src/env.d.ts
vendored
@@ -1,5 +1,14 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/** 是否启用前端 Mock;开发环境默认开启,生产环境默认关闭。 */
|
||||
readonly VITE_ENABLE_MOCK?: 'true' | 'false'
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import '@/assets/font-awesome/css/font-awesome.min.css'
|
||||
import AppSidebar from '@/components/AppSidebar.vue'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
// Font Awesome 图标
|
||||
import '@/assets/font-awesome/css/font-awesome.min.css'
|
||||
|
||||
// ECharts(按需引入)+ vue-echarts 组件
|
||||
import '@/plugins/echarts'
|
||||
import VChart from 'vue-echarts'
|
||||
// 这三个能力通过函数或指令使用,无法由模板组件扫描自动补充样式。
|
||||
import 'element-plus/es/components/message/style/css'
|
||||
import 'element-plus/es/components/message-box/style/css'
|
||||
import 'element-plus/es/components/loading/style/css'
|
||||
|
||||
import App from './App.vue'
|
||||
import service from '@/api/request'
|
||||
import router from './router'
|
||||
import './styles/index.scss'
|
||||
|
||||
@@ -19,7 +15,19 @@ const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.component('VChart', VChart)
|
||||
|
||||
async function bootstrap() {
|
||||
// 开发环境默认使用 Mock;生产环境只有显式开启时才加载整套 Mock 数据。
|
||||
// 这样真实部署不会被前端适配器截断请求,也不会把 Mock 数据打进首屏包。
|
||||
const shouldEnableMock = import.meta.env.VITE_ENABLE_MOCK === 'true'
|
||||
|| (import.meta.env.DEV && import.meta.env.VITE_ENABLE_MOCK !== 'false')
|
||||
|
||||
if (shouldEnableMock) {
|
||||
const { installMockAdapter } = await import('@/mock/adapter')
|
||||
installMockAdapter(service)
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
}
|
||||
|
||||
void bootstrap()
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
/**
|
||||
* ECharts 按需引入
|
||||
* 仅注册训练曲线所需的模块,避免引入全量包以减小体积
|
||||
* 服务看板图表按需注册。
|
||||
*/
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { BarChart, LineChart, PieChart } from 'echarts/charts'
|
||||
import { BarChart, PieChart } from 'echarts/charts'
|
||||
import {
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent,
|
||||
MarkLineComponent,
|
||||
} from 'echarts/components'
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
BarChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent,
|
||||
MarkLineComponent,
|
||||
])
|
||||
|
||||
@@ -9,6 +9,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/login/LoginView.vue'),
|
||||
meta: { title: '登录', public: true },
|
||||
},
|
||||
{
|
||||
path: '/guide',
|
||||
name: 'guide',
|
||||
component: () => import('@/views/guide/GuideView.vue'),
|
||||
meta: { title: '使用文档', skipPermission: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
|
||||
@@ -10,15 +10,24 @@ import type { ModelItem } from '@/types'
|
||||
export const useModelsStore = defineStore('models', () => {
|
||||
const list = ref<ModelItem[]>([])
|
||||
const loaded = ref(false)
|
||||
let pendingLoad: Promise<void> | null = null
|
||||
|
||||
async function load(force = false) {
|
||||
if (loaded.value && !force) return
|
||||
if (pendingLoad && !force) return pendingLoad
|
||||
|
||||
pendingLoad = (async () => {
|
||||
try {
|
||||
list.value = (await getModelList()) || []
|
||||
loaded.value = true
|
||||
} catch {
|
||||
list.value = []
|
||||
} finally {
|
||||
pendingLoad = null
|
||||
}
|
||||
})()
|
||||
|
||||
return pendingLoad
|
||||
}
|
||||
|
||||
/** 根据 id 获取模型名 */
|
||||
|
||||
@@ -10,19 +10,29 @@ import type { HealthMetrics } from '@/types'
|
||||
export const useSystemStore = defineStore('system', () => {
|
||||
const metrics = ref<HealthMetrics>({})
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let fetching = false
|
||||
|
||||
async function fetchMetrics() {
|
||||
if (fetching || document.hidden) return
|
||||
fetching = true
|
||||
try {
|
||||
metrics.value = await getHealth()
|
||||
} catch {
|
||||
// 静默失败,顶部栏非关键
|
||||
} finally {
|
||||
fetching = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (!document.hidden) void fetchMetrics()
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (timer) return
|
||||
fetchMetrics()
|
||||
void fetchMetrics()
|
||||
timer = setInterval(fetchMetrics, 30000)
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
@@ -30,6 +40,7 @@ export const useSystemStore = defineStore('system', () => {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
|
||||
return { metrics, fetchMetrics, start, stop }
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import PageCard from '@/components/PageCard.vue'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import { getCompare } from '@/api/modules/compare'
|
||||
import type { CompareTask, LoadedModel } from '@/types'
|
||||
|
||||
@@ -11,7 +12,6 @@ const router = useRouter()
|
||||
const taskId = route.params.id as string
|
||||
|
||||
const task = ref<CompareTask | null>(null)
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const form = reactive({
|
||||
systemPrompt: '',
|
||||
@@ -73,13 +73,11 @@ function handleSubmit() {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTask()
|
||||
pollTimer = setInterval(loadTask, 5000)
|
||||
})
|
||||
const { start: startPolling } = usePolling(loadTask, 5000, { immediate: false })
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
onMounted(async () => {
|
||||
await loadTask()
|
||||
startPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import MarkdownView from '@/components/MarkdownView.vue'
|
||||
import {
|
||||
getCompare,
|
||||
chatWithPort,
|
||||
batchChat,
|
||||
} from '@/api/modules/compare'
|
||||
import { getModelByName } from '@/api/modules/model'
|
||||
import type { CompareTask, LoadedModel } from '@/types'
|
||||
import { getCompare, chatWithPort } from '@/api/modules/compare'
|
||||
import type { LoadedModel } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const taskId = route.query.taskId as string
|
||||
@@ -25,6 +20,7 @@ interface ModelResult {
|
||||
name: string
|
||||
content: string
|
||||
displayContent: string
|
||||
isTyping: boolean
|
||||
status: 'loading' | 'done' | 'error'
|
||||
stats?: { charsPerSec?: number; totalTime?: number }
|
||||
}
|
||||
@@ -33,6 +29,7 @@ const results = ref<ModelResult[]>([])
|
||||
const started = ref(false)
|
||||
|
||||
const loadedModels = ref<LoadedModel[]>([])
|
||||
const typewriterTimers = new Set<ReturnType<typeof setInterval>>()
|
||||
|
||||
async function init() {
|
||||
if (started.value) return
|
||||
@@ -50,6 +47,7 @@ async function init() {
|
||||
name: m.model_name || '模型',
|
||||
content: '',
|
||||
displayContent: '',
|
||||
isTyping: false,
|
||||
status: 'loading',
|
||||
}))
|
||||
|
||||
@@ -65,7 +63,7 @@ async function inferOne(model: LoadedModel, idx: number) {
|
||||
const startTime = Date.now()
|
||||
try {
|
||||
// 尝试通过端口代理调用
|
||||
const res: any = await Promise.race([
|
||||
const res: any = await withTimeout(
|
||||
chatWithPort({
|
||||
port: model.port,
|
||||
model_name: model.model_name,
|
||||
@@ -78,8 +76,8 @@ async function inferOne(model: LoadedModel, idx: number) {
|
||||
top_k: topK,
|
||||
max_tokens: maxTokens,
|
||||
}),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('推理超时')), 300000)),
|
||||
])
|
||||
300000,
|
||||
)
|
||||
|
||||
const content = res?.response || res?.content || res?.data || JSON.stringify(res)
|
||||
const totalTime = (Date.now() - startTime) / 1000
|
||||
@@ -87,7 +85,7 @@ async function inferOne(model: LoadedModel, idx: number) {
|
||||
results.value[idx].status = 'done'
|
||||
results.value[idx].stats = {
|
||||
totalTime,
|
||||
charsPerSec: totalTime > 0 ? (content.length / totalTime).toFixed(1) as unknown as number : 0,
|
||||
charsPerSec: totalTime > 0 ? Number((content.length / totalTime).toFixed(1)) : 0,
|
||||
}
|
||||
// 模拟打字机效果
|
||||
typewriterDisplay(idx, content)
|
||||
@@ -97,22 +95,46 @@ async function inferOne(model: LoadedModel, idx: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error('推理超时')), timeoutMs)
|
||||
}),
|
||||
])
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
/** 打字机效果逐字展示 */
|
||||
function typewriterDisplay(idx: number, content: string) {
|
||||
let pos = 0
|
||||
results.value[idx].isTyping = true
|
||||
// 将更新次数控制在约 30 次,避免长回答逐字触发 Markdown 全文解析。
|
||||
const step = Math.max(2, Math.ceil(content.length / 30))
|
||||
const interval = setInterval(() => {
|
||||
pos += 2
|
||||
pos += step
|
||||
results.value[idx].displayContent = content.slice(0, pos)
|
||||
if (pos >= content.length) {
|
||||
clearInterval(interval)
|
||||
typewriterTimers.delete(interval)
|
||||
results.value[idx].displayContent = content
|
||||
results.value[idx].isTyping = false
|
||||
}
|
||||
}, 20)
|
||||
}, 50)
|
||||
typewriterTimers.add(interval)
|
||||
}
|
||||
|
||||
const allDone = computed(() => results.value.length > 0 && results.value.every((r) => r.status === 'done' || r.status === 'error'))
|
||||
|
||||
onMounted(init)
|
||||
onBeforeUnmount(() => {
|
||||
typewriterTimers.forEach(clearInterval)
|
||||
typewriterTimers.clear()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -144,6 +166,7 @@ onMounted(init)
|
||||
</template>
|
||||
|
||||
<div v-if="r.status === 'error'" class="error-text">{{ r.content }}</div>
|
||||
<div v-else-if="r.isTyping" class="streaming-text">{{ r.displayContent }}</div>
|
||||
<MarkdownView v-else-if="r.displayContent" :content="r.displayContent" />
|
||||
<div v-else class="loading-text">
|
||||
<i class="fa fa-spinner fa-spin" /> 正在生成回答...
|
||||
@@ -212,6 +235,13 @@ onMounted(init)
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.streaming-text {
|
||||
min-height: 80px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.result-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import VChart from 'vue-echarts'
|
||||
import '@/plugins/echarts'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
|
||||
type ServiceState = 'normal' | 'busy' | 'error'
|
||||
@@ -253,7 +255,7 @@ const loginDurationStats: LoginDurationStat[] = [
|
||||
|
||||
const loginDurationChartOption = computed<EChartsOption>(() => ({
|
||||
animationDuration: 500,
|
||||
grid: { top: 8, right: 40, bottom: 6, left: 8, containLabel: true },
|
||||
grid: { top: 8, right: 12, bottom: 6, left: 8, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
@@ -261,7 +263,7 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
max: Math.ceil(Math.max(...loginDurationStats.map((user) => user.duration)) / 10) * 10,
|
||||
max: Math.ceil(Math.max(...loginDurationStats.map((user) => user.duration)) * 1.15 / 10) * 10,
|
||||
splitNumber: 4,
|
||||
axisLabel: { color: '#94a3b8', fontSize: 11, formatter: '{value}h' },
|
||||
axisLine: { show: false },
|
||||
@@ -284,7 +286,7 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
|
||||
barMaxWidth: 18,
|
||||
barCategoryGap: '34%',
|
||||
itemStyle: { color: '#4f46e5', borderRadius: [0, 4, 4, 0] },
|
||||
label: { show: true, position: 'right', distance: 8, color: '#64748b', fontSize: 11, formatter: '{c} 小时' },
|
||||
label: { show: true, position: 'insideRight', distance: 6, color: '#ffffff', fontSize: 11, formatter: '{c} 小时' },
|
||||
},
|
||||
],
|
||||
}))
|
||||
@@ -798,8 +800,8 @@ function viewTask(task: DashboardTask) {
|
||||
|
||||
th,
|
||||
td {
|
||||
height: 42px;
|
||||
padding: 6px 10px;
|
||||
height: 50px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid #eef2f7;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
@@ -807,7 +809,7 @@ function viewTask(task: DashboardTask) {
|
||||
}
|
||||
|
||||
th {
|
||||
height: 36px;
|
||||
height: 38px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
@@ -925,12 +927,12 @@ function viewTask(task: DashboardTask) {
|
||||
.tasks-table {
|
||||
th,
|
||||
td {
|
||||
height: 36px;
|
||||
padding: 4px 8px;
|
||||
height: 42px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
th {
|
||||
height: 32px;
|
||||
height: 34px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ const WIZARD_STEPS = [
|
||||
{ id: 'upload', title: '上传文件', desc: '上传或接入待处理的源数据' },
|
||||
{ id: 'preview', title: '数据预览', desc: '核对源文件与预览内容' },
|
||||
{ id: 'generate', title: '开始生成', desc: '确认摘要并启动处理' },
|
||||
{ id: 'results', title: '编辑与保存', desc: '检查、修改并保存结果' },
|
||||
{ id: 'results', title: '结果编辑与保存', desc: '检查、修改并保存结果' },
|
||||
] as const satisfies ReadonlyArray<{ id: StepId; title: string; desc: string }>
|
||||
const currentStep = ref(0)
|
||||
const currentStepId = computed<StepId>(() => WIZARD_STEPS[currentStep.value]?.id ?? 'create')
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
import { MdEditor } from 'md-editor-v3'
|
||||
import 'md-editor-v3/lib/style.css'
|
||||
import { computed, defineAsyncComponent, watch } from 'vue'
|
||||
import { EVAL_METHODS, EVAL_METHOD_PROMPTS } from '@/constants/dimension'
|
||||
import { DIMENSION_TYPE_MAP } from '@/constants'
|
||||
import type { DimensionType, ModelItem } from '@/types'
|
||||
|
||||
// 编辑器体积较大,仅在用户进入包含 Prompt 的指标配置时加载。
|
||||
const MdEditor = defineAsyncComponent(async () => {
|
||||
await import('md-editor-v3/lib/style.css')
|
||||
const module = await import('md-editor-v3')
|
||||
return module.MdEditor
|
||||
})
|
||||
|
||||
export interface DimensionFormDraft {
|
||||
type: DimensionType | ''
|
||||
description: string
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DataTablePage from '@/components/DataTablePage.vue'
|
||||
import ModelStatusTag from '@/components/ModelStatusTag.vue'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import { useModelsStore } from '@/stores/models'
|
||||
import {
|
||||
getFineTuneList,
|
||||
@@ -44,14 +45,10 @@ const filteredList = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
let progressTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
dataList.value = (await getFineTuneList()) || []
|
||||
// 列表加载完成后,立即获取一次运行中任务的进度
|
||||
refreshProgress()
|
||||
} catch {
|
||||
// 拦截器已提示
|
||||
} finally {
|
||||
@@ -101,14 +98,13 @@ function formatDateTime(value?: string) {
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
modelsStore.load()
|
||||
loadData()
|
||||
progressTimer = setInterval(refreshProgress, 5000)
|
||||
})
|
||||
const { start: startProgressPolling } = usePolling(refreshProgress, 5000, { immediate: false })
|
||||
|
||||
onUnmounted(() => {
|
||||
if (progressTimer) clearInterval(progressTimer)
|
||||
onMounted(async () => {
|
||||
void modelsStore.load()
|
||||
await loadData()
|
||||
await refreshProgress()
|
||||
startProgressPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, nextTick, onMounted } from 'vue'
|
||||
import { ref, reactive, nextTick, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import MarkdownView from '@/components/MarkdownView.vue'
|
||||
@@ -34,6 +34,7 @@ const temperature = ref(0.7)
|
||||
const top_p = ref(0.95)
|
||||
const maxTokens = ref(2048)
|
||||
const contentRef = ref<HTMLElement>()
|
||||
let activeAssistant: ChatMessage | null = null
|
||||
/** 设置面板抽屉 */
|
||||
const showSettings = ref(false)
|
||||
|
||||
@@ -98,8 +99,8 @@ async function handleSend() {
|
||||
return
|
||||
}
|
||||
|
||||
// 监听流式 message 变化,同步到 assistantMsg
|
||||
const watchStop = watchMessage(assistantMsg)
|
||||
// 流式状态变化时只同步当前回复,避免固定定时器空转。
|
||||
activeAssistant = assistantMsg
|
||||
|
||||
await send({
|
||||
port: target.port,
|
||||
@@ -118,7 +119,7 @@ async function handleSend() {
|
||||
assistantMsg.isThinking = false
|
||||
assistantMsg.isStreaming = false
|
||||
assistantMsg.done = true
|
||||
watchStop()
|
||||
activeAssistant = null
|
||||
reset()
|
||||
await nextTick()
|
||||
scrollToBottom()
|
||||
@@ -149,17 +150,24 @@ async function mockReply(assistantMsg: ChatMessage, question: string) {
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
/** 轮询同步流式状态到展示消息 */
|
||||
function watchMessage(assistantMsg: ChatMessage) {
|
||||
const timer = setInterval(() => {
|
||||
assistantMsg.content = message.value.displayContent
|
||||
assistantMsg.think = message.value.thinkContent
|
||||
assistantMsg.isThinking = message.value.isThinking
|
||||
if (message.value.done) clearInterval(timer)
|
||||
watch(
|
||||
() => [
|
||||
message.value.displayContent,
|
||||
message.value.thinkContent,
|
||||
message.value.isThinking,
|
||||
message.value.done,
|
||||
] as const,
|
||||
async ([content, think, isThinking, done]) => {
|
||||
if (!activeAssistant) return
|
||||
activeAssistant.content = content
|
||||
activeAssistant.think = think
|
||||
activeAssistant.isThinking = isThinking
|
||||
activeAssistant.isStreaming = !done
|
||||
await nextTick()
|
||||
scrollToBottom()
|
||||
}, 80)
|
||||
return () => clearInterval(timer)
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
function scrollToBottom() {
|
||||
if (contentRef.value) {
|
||||
@@ -168,6 +176,7 @@ function scrollToBottom() {
|
||||
}
|
||||
|
||||
function handleNewChat() {
|
||||
activeAssistant = null
|
||||
messages.value = []
|
||||
reset()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import DataTablePage from '@/components/DataTablePage.vue'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import {
|
||||
getCompareList,
|
||||
deleteCompare,
|
||||
@@ -17,7 +18,7 @@ const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const dataList = ref<CompareTask[]>([])
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
let delayedRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function loadData(silent = false) {
|
||||
if (!silent) {
|
||||
@@ -80,7 +81,8 @@ function parseModelNames(row: any): string[] {
|
||||
async function handleLoad(row: any) {
|
||||
await loadCompare(row.id)
|
||||
ElMessage.info('正在加载模型,请稍候...')
|
||||
setTimeout(loadData, 1000)
|
||||
if (delayedRefreshTimer) clearTimeout(delayedRefreshTimer)
|
||||
delayedRefreshTimer = setTimeout(loadData, 1000)
|
||||
}
|
||||
|
||||
/** 卸载推理任务 */
|
||||
@@ -112,13 +114,15 @@ function startChat(row: any) {
|
||||
router.push(`/model-inference/chat/${row.id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
refreshTimer = setInterval(() => loadData(true), 3000)
|
||||
const { start: startPolling } = usePolling(() => loadData(true), 3000, { immediate: false })
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
startPolling()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
if (delayedRefreshTimer) clearTimeout(delayedRefreshTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ async function handleLogin() {
|
||||
position: relative;
|
||||
color: #fff;
|
||||
background-color: #121127;
|
||||
background-image: url('@/assets/login-hero-flow.png');
|
||||
background-image: url('@/assets/login-hero-flow.jpg');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import PageCard from '@/components/PageCard.vue'
|
||||
import '@/plugins/echarts-hardware'
|
||||
import { getSystemInfo } from '@/api/modules/system'
|
||||
import type { GpuInfo, SystemInfo } from '@/types'
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import PageCard from '@/components/PageCard.vue'
|
||||
import { useCountdown } from '@/composables/useCountdown'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import {
|
||||
getLogFiles,
|
||||
getLogContent,
|
||||
@@ -30,29 +31,26 @@ const fullContent = ref('')
|
||||
// 自动刷新
|
||||
const refreshInterval = ref(10)
|
||||
const { remaining, start: startCountdown, stop: stopCountdown } = useCountdown(10)
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const filteredContent = computed(() => {
|
||||
if (!keyword.value.trim()) return fullContent.value
|
||||
const filteredLog = computed(() => {
|
||||
if (!keyword.value.trim()) return { content: fullContent.value, count: 0 }
|
||||
const kw = keyword.value.toLowerCase().trim()
|
||||
return fullContent.value
|
||||
const lines = fullContent.value
|
||||
.split('\n')
|
||||
.filter((line) => line.toLowerCase().includes(kw))
|
||||
.join('\n')
|
||||
return { content: lines.join('\n'), count: lines.length }
|
||||
})
|
||||
|
||||
const matchCount = computed(() => {
|
||||
if (!keyword.value.trim()) return 0
|
||||
const kw = keyword.value.toLowerCase().trim()
|
||||
return fullContent.value.split('\n').filter((line) => line.toLowerCase().includes(kw)).length
|
||||
})
|
||||
const filteredContent = computed(() => filteredLog.value.content)
|
||||
const matchCount = computed(() => filteredLog.value.count)
|
||||
|
||||
async function loadSysFiles() {
|
||||
try {
|
||||
sysFiles.value = (await getLogFiles(sysDate.value)) || []
|
||||
if (sysFiles.value.length > 0) {
|
||||
sysSelected.value = sysFiles.value[0].file
|
||||
loadSysContent()
|
||||
const firstFile = sysFiles.value[0].file
|
||||
if (sysSelected.value === firstFile) void loadSysContent()
|
||||
else sysSelected.value = firstFile
|
||||
} else {
|
||||
sysContent.value = '该日期暂无日志文件'
|
||||
}
|
||||
@@ -76,8 +74,9 @@ async function loadTrainFiles() {
|
||||
try {
|
||||
trainFiles.value = (await getTrainingLogFiles()) || []
|
||||
if (trainFiles.value.length > 0) {
|
||||
trainSelected.value = trainFiles.value[0].file
|
||||
loadTrainContent()
|
||||
const firstFile = trainFiles.value[0].file
|
||||
if (trainSelected.value === firstFile) void loadTrainContent()
|
||||
else trainSelected.value = firstFile
|
||||
} else {
|
||||
trainContent.value = '暂无训练日志'
|
||||
}
|
||||
@@ -97,29 +96,28 @@ async function loadTrainContent() {
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
async function refresh() {
|
||||
if (activeTab.value === 'system') {
|
||||
loadSysContent()
|
||||
await loadSysContent()
|
||||
} else {
|
||||
loadTrainContent()
|
||||
await loadTrainContent()
|
||||
}
|
||||
}
|
||||
|
||||
const { start: startPolling, stop: stopPolling } = usePolling(
|
||||
refresh,
|
||||
() => refreshInterval.value * 1000,
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
function startAutoRefresh() {
|
||||
stopAutoRefresh()
|
||||
stopPolling()
|
||||
if (refreshInterval.value === 0) {
|
||||
stopCountdown()
|
||||
return
|
||||
}
|
||||
startCountdown()
|
||||
refreshTimer = setInterval(refresh, refreshInterval.value * 1000)
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
startPolling()
|
||||
}
|
||||
|
||||
watch(refreshInterval, startAutoRefresh)
|
||||
@@ -135,8 +133,6 @@ onMounted(() => {
|
||||
loadSysFiles()
|
||||
startAutoRefresh()
|
||||
})
|
||||
|
||||
onUnmounted(stopAutoRefresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import VChart from 'vue-echarts'
|
||||
import PageCard from '@/components/PageCard.vue'
|
||||
import ModelStatusTag from '@/components/ModelStatusTag.vue'
|
||||
import TrainingTaskOverview from './training-log/TrainingTaskOverview.vue'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import '@/plugins/echarts-training-log'
|
||||
import { useModelsStore } from '@/stores/models'
|
||||
import { getFineTune } from '@/api/modules/fineTune'
|
||||
import { getTrainingLogFiles, getTrainingLogContent } from '@/api/modules/log'
|
||||
@@ -55,7 +58,6 @@ const paramsExpanded = ref(false)
|
||||
const GPU_PREVIEW_LIMIT = 4
|
||||
const gpuExpanded = ref(false)
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let refreshInFlight = false
|
||||
|
||||
/** 三个曲线的 ECharts 配置(响应式,数据变化自动重绘) */
|
||||
@@ -257,14 +259,16 @@ async function refreshAll() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
modelsStore.load()
|
||||
refreshAll()
|
||||
timer = setInterval(refreshAll, 5000)
|
||||
})
|
||||
const isTerminalTask = () => ['completed', 'failed', 'stopped', 'cancelled'].includes(task.value?.status || '')
|
||||
const { start: startPolling, stop: stopPolling } = usePolling(async () => {
|
||||
await refreshAll()
|
||||
if (isTerminalTask()) stopPolling()
|
||||
}, 5000, { immediate: false })
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
onMounted(async () => {
|
||||
void modelsStore.load()
|
||||
await refreshAll()
|
||||
if (!isTerminalTask()) startPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ function formatDateTime(value?: string) {
|
||||
margin-bottom: 0;
|
||||
border: 1px solid #e4e7ed !important;
|
||||
border-radius: 8px !important;
|
||||
background: var(--app-surface-bg);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.overview-layout { width: 100%; }
|
||||
|
||||
@@ -10,10 +10,10 @@ export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver({ importStyle: false })],
|
||||
resolvers: [ElementPlusResolver({ importStyle: 'css' })],
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver({ importStyle: false })],
|
||||
resolvers: [ElementPlusResolver({ importStyle: 'css' })],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
|
||||
Reference in New Issue
Block a user