Compare commits
26 Commits
server
...
a6868ec2e5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6868ec2e5 | ||
|
|
6cd1e46e86 | ||
|
|
836343b29e | ||
|
|
1e438164c1 | ||
|
|
f4864fafd0 | ||
|
|
9798b34717 | ||
|
|
284995d79c | ||
|
|
e18a367abb | ||
|
|
817d13c8f7 | ||
|
|
a72b8f1e4b | ||
|
|
bccd3bf448 | ||
|
|
a67ca2c19c | ||
|
|
2c1e08a271 | ||
|
|
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
|
||||||
24
.gitignore
vendored
24
.gitignore
vendored
@@ -12,6 +12,10 @@ __pycache__/
|
|||||||
build/
|
build/
|
||||||
develop-eggs/
|
develop-eggs/
|
||||||
dist/
|
dist/
|
||||||
|
!frontend/dist/
|
||||||
|
!frontend/dist/**
|
||||||
|
node_modules/
|
||||||
|
*.tsbuildinfo
|
||||||
downloads/
|
downloads/
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
@@ -37,6 +41,15 @@ MANIFEST
|
|||||||
pip-log.txt
|
pip-log.txt
|
||||||
pip-delete-this-directory.txt
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Runtime data and logs
|
||||||
|
runtime/
|
||||||
|
backend/runtime/
|
||||||
|
logs/
|
||||||
|
backend/logs/
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
# Unit test / coverage reports
|
# Unit test / coverage reports
|
||||||
htmlcov/
|
htmlcov/
|
||||||
.tox/
|
.tox/
|
||||||
@@ -174,3 +187,14 @@ cython_debug/
|
|||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
docker/llamafactory-latest.tar.gz
|
||||||
|
# Compute data - 保留目录结构和 README,忽略子目录内容(日志、模型、数据集等)
|
||||||
|
!docker/compute/data/yg-ft/logs/
|
||||||
|
docker/compute/data/yg-ft/datasets/*
|
||||||
|
docker/compute/data/yg-ft/models/*
|
||||||
|
docker/compute/data/yg-ft/outputs/*
|
||||||
|
docker/compute/data/yg-ft/logs/**
|
||||||
|
!docker/compute/data/yg-ft/logs/compute/
|
||||||
|
!docker/compute/data/yg-ft/logs/training/
|
||||||
|
!docker/compute/data/yg-ft/**/.gitkeep
|
||||||
|
!docker/compute/data/yg-ft/**/README.md
|
||||||
|
|||||||
235
README.md
235
README.md
@@ -1,133 +1,162 @@
|
|||||||
# YG_FT
|
# YG_FT 模型微调平台
|
||||||
|
|
||||||
远光微调平台 - 面向大语言模型的微调、评测、推理与对比一体化前端。
|
YG_FT 是一个面向企业治理场景的模型微调平台,覆盖用户中心、多租户、项目隔离、数据集管理、模型管理、训练任务、评测、推理、审批流、审计留存、算力调度和训练引擎适配。
|
||||||
|
|
||||||
## 技术栈
|
当前前端已有基础页面,后端与算力平台已按多人协作开发方式建立工程骨架,并开始实现正式系统主链路能力。当前代码和 SQL 均作为后续生产演进基线维护,不再以一次性演示或静态 Mock 为开发准则。
|
||||||
|
|
||||||
| 类别 | 技术 | 版本 |
|
## 总体架构
|
||||||
|------|------|------|
|
|
||||||
| 框架 | 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 |
|
|
||||||
|
|
||||||
**项目版本**:1.0.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/ # 容器化配置
|
||||||
|
```
|
||||||
|
|
||||||
## 环境要求
|
## 平台分层
|
||||||
|
|
||||||
- **Node.js** >= 18(推荐 20 LTS)
|
| 层级 | 职责 | 主要目录 |
|
||||||
- **npm** >= 9
|
| --- | --- | --- |
|
||||||
- 后端服务运行于 `http://localhost:7861`(前端通过代理转发,见下文)
|
| 前端控制台 | 用户操作入口、任务看板、项目/模型/数据集/训练/审批/审计页面 | `frontend/` |
|
||||||
|
| 应用平台后端 | 用户中心、多租户、RBAC/ABAC、项目隔离、元数据、审批流、审计、API 编排 | `backend/` |
|
||||||
|
| 算力平台 | GPU 发现、资源锁定、训练进程管理、日志采集、产物归档、任务状态同步 | `compute/` |
|
||||||
|
| 训练引擎 | 当前固定接入 LLaMA-Factory,预留其他训练平台适配标准 | `compute/engines/` |
|
||||||
|
| 数据层 | PostgreSQL、Redis、本地文件存储、日志归档 | `docs/postgres-schema.sql` |
|
||||||
|
|
||||||
## 快速开始
|
## 当前开发基线
|
||||||
|
|
||||||
### 1. 安装依赖
|
- 使用 FastAPI 提供统一 API 响应结构 `{ code, message, data }`。
|
||||||
|
- 本地运行阶段统一使用 PostgreSQL,后端启动时会在 PG 中初始化当前运行表和系统内置账号;模型、数据集、算力节点、GPU、微调任务等业务数据必须通过页面、接口或正式导入流程产生。
|
||||||
|
- 支持登录、模型管理、数据集管理、微调任务创建/启动/停止/进度轮询。
|
||||||
|
- 支持训练日志、loss 指标、checkpoint 和训练产物接口;真实训练执行器接入前,联调状态机必须通过显式环境变量开启。
|
||||||
|
- 支持多算力节点、GPU、任务队列、资源副本和资源同步状态接口。
|
||||||
|
- 前端新增 `/compute` 算力节点页面,展示节点地址、权重、标签、启用状态、GPU、队列和资源副本。
|
||||||
|
- `compute/engines/llama_factory/adapter.py` 提供 LLaMA-Factory 参数校验、命令生成和日志解析基础能力。
|
||||||
|
|
||||||
|
## 后端启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn app.main:app --reload --port 17861
|
||||||
|
```
|
||||||
|
|
||||||
|
默认接口前缀为 `/modelTF`,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /modelTF/health
|
||||||
|
POST /modelTF/login
|
||||||
|
GET /modelTF/model-manage
|
||||||
|
GET /modelTF/dataset-manage
|
||||||
|
GET /modelTF/fine-tune
|
||||||
|
GET /modelTF/compute/nodes
|
||||||
|
```
|
||||||
|
|
||||||
|
本地运行时默认 PostgreSQL 连接:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DATABASE_URL=postgresql+psycopg://yg_ft:change_me@localhost:15432/yg_ft
|
||||||
|
```
|
||||||
|
|
||||||
|
本地启动前需要确保 PostgreSQL 已监听 `localhost:15432`,并已创建 `yg_ft` 数据库和 `yg_ft` 用户。后端启动后会自动创建当前运行表并写入内置管理员账号,运行数据统一写入 PostgreSQL。
|
||||||
|
|
||||||
|
开发阶段内置登录账号:
|
||||||
|
|
||||||
|
| 角色 | 账号 | 密码 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 超级管理员 | `admin` | `admin123` | 拥有当前全部页面权限 |
|
||||||
|
| 操作员 | `operator` | `operator123` | 拥有业务操作相关页面权限 |
|
||||||
|
|
||||||
|
以上账号仅用于本地开发和联调。生产环境初始化后应立即修改密码,或改为企业统一身份认证/管理员初始化流程。
|
||||||
|
|
||||||
|
## 前端启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 启动开发服务器
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
开发服务器默认运行在 `http://localhost:6801`。
|
前端开发服务默认运行在 `http://localhost:16801`,并通过 Vite proxy 将 `/modelTF` 转发到 `http://localhost:17861`。
|
||||||
|
|
||||||
### 3. 构建生产包
|
## 算力服务启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build # 类型检查 + 生产构建,产物输出到 dist/
|
cd compute
|
||||||
npm run preview # 本地预览构建产物
|
uvicorn api.main:app --reload --port 19100
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. 类型检查
|
默认 `COMPUTE_MODE=real`。真实 GPU 接入时,在每台算力服务器上部署 Compute API、Agent、File Gateway 和 LLaMA-Factory,应用平台通过 `compute_nodes.api_base_url` 和 `compute_nodes.file_gateway_url` 主动轮询。仅在隔离联调环境可显式设置 `COMPUTE_MODE=simulator` 或 `COMPUTE_EXECUTION_MODE=simulator`。
|
||||||
|
|
||||||
|
## 日志
|
||||||
|
|
||||||
|
后端日志模块位于 `backend/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 天。
|
||||||
|
|
||||||
|
## 主要文档
|
||||||
|
|
||||||
|
- `docs/platform-architecture-requirements.md`:平台需求、功能模块、页面补全建议。
|
||||||
|
- `docs/menu-functional-requirements.md`:当前菜单、二级路由、规划菜单、功能需求、接口和数据库映射。
|
||||||
|
- `docs/backend-api-design.md`:FastAPI 接口分组、参数定义、权限说明。
|
||||||
|
- `docs/postgres-schema.sql`:PostgreSQL 数据库脚本,包含权限、用户中心、多租户、审批、审计等模型。
|
||||||
|
- `docs/system-development-plan.md`:多人协作开发计划,按前端、后端、DB、部署拆分。
|
||||||
|
- `docs/team-development-plan.md`:3-4 人并行开发分工计划,按人员边界标注页面、接口、数据库和交付节奏。
|
||||||
|
- `docs/first-version-development-plan.md`:当前系统主链路开发计划,覆盖前端、后端、DB、Compute API、GPU 和 LLaMA-Factory 适配。
|
||||||
|
- `docs/backend-logging.md`:后端日志模块使用说明。
|
||||||
|
- `docs/deployment-plan.md`:后期部署方案,覆盖单机算力服务器部署与应用/算力分离部署。
|
||||||
|
- `docker/README.md`:Docker 部署入口,包含应用服务器和算力服务器两套 Compose 使用方式。
|
||||||
|
|
||||||
|
## Docker 部署入口
|
||||||
|
|
||||||
|
应用服务器:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run type-check
|
cd docker/app
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
## 测试
|
算力服务器:
|
||||||
|
|
||||||
内置基于 Playwright 的 UI 回归脚本,首次运行前需安装浏览器:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx playwright install chromium
|
cd docker/compute
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
执行已注册的回归脚本:
|
两套 Compose 均采用代码外挂方式运行,镜像只包含运行时环境和第三方依赖。项目根目录不再保留 `Dockerfile` 和 `docker-compose.yml`,部署时统一进入 `docker/app` 或 `docker/compute` 目录执行。
|
||||||
|
|
||||||
```bash
|
## 后续开发原则
|
||||||
npm run test:data-process-wizard # 数据处理向导
|
|
||||||
npm run test:model-manage # 模型管理
|
|
||||||
npm run test:training-log-layout # 训练日志布局
|
|
||||||
npm run test:page-surface # 页面表层级
|
|
||||||
```
|
|
||||||
|
|
||||||
其余脚本可直接运行:
|
- 接口实现优先遵循 `docs/backend-api-design.md`。
|
||||||
|
- 数据库实现优先遵循 `docs/postgres-schema.sql`,后续通过 Alembic 迁移管理变更。
|
||||||
```bash
|
- 前端页面与后端接口、数据库表之间的映射以文档中的“对应页面/功能模块”为准。
|
||||||
node scripts/regression-back-navigation.mjs # 返回导航
|
- 训练引擎适配必须通过 `compute/engines/` 下的标准接口,不在应用平台后端直接拼接训练命令。
|
||||||
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 与权重合并 |
|
|
||||||
| 数据集 | 数据集管理与预览 |
|
|
||||||
| 数据处理 | 数据处理任务向导 |
|
|
||||||
| 工具 | 辅助工具集 |
|
|
||||||
| 系统 | 硬件监控、日志、训练日志 |
|
|
||||||
|
|||||||
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/ # 对前端暴露的 接口路由
|
||||||
|
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 /modelTF/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."""
|
||||||
14
backend/app/api/v1/endpoints/health.py
Normal file
14
backend/app/api/v1/endpoints/health.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.db.platform_store import get_platform_store
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health_check() -> dict[str, object]:
|
||||||
|
logger.info("health check requested")
|
||||||
|
return {"code": 0, "message": "ok", "data": get_platform_store().health_metrics()}
|
||||||
|
|
||||||
1091
backend/app/api/v1/endpoints/platform.py
Normal file
1091
backend/app/api/v1/endpoints/platform.py
Normal file
File diff suppressed because it is too large
Load Diff
9
backend/app/api/v1/router.py
Normal file
9
backend/app/api/v1/router.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.v1.endpoints.platform import router as platform_router
|
||||||
|
from app.api.v1.endpoints.health import router as health_router
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(health_router, tags=["health"])
|
||||||
|
api_router.include_router(platform_router, tags=["platform"])
|
||||||
|
|
||||||
1
backend/app/core/__init__.py
Normal file
1
backend/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Core infrastructure modules."""
|
||||||
59
backend/app/core/config.py
Normal file
59
backend/app/core/config.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_env(name: str, default: list[str]) -> list[str]:
|
||||||
|
raw = os.getenv(name)
|
||||||
|
if raw is None or raw.strip() == "":
|
||||||
|
return default
|
||||||
|
return [item.strip() for item in raw.split(",") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
@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")
|
||||||
|
route_prefix: str = os.getenv("MODELTF_ROUTE_PREFIX", "/modelTF")
|
||||||
|
app_mode: str = os.getenv("APP_MODE", "local")
|
||||||
|
database_url: str = os.getenv("DATABASE_URL", "postgresql+psycopg://yg_ft:change_me@localhost:15432/yg_ft")
|
||||||
|
cors_allow_origins: list[str] = None # type: ignore[assignment]
|
||||||
|
compute_mode: str = os.getenv("COMPUTE_MODE", "real")
|
||||||
|
compute_status_sync_mode: str = os.getenv("COMPUTE_STATUS_SYNC_MODE", "polling")
|
||||||
|
compute_poll_interval_seconds: int = _int_env("COMPUTE_POLL_INTERVAL_SECONDS", 3)
|
||||||
|
compute_request_timeout_seconds: int = _int_env("COMPUTE_REQUEST_TIMEOUT_SECONDS", 5)
|
||||||
|
compute_service_token: str = os.getenv("COMPUTE_SERVICE_TOKEN", "")
|
||||||
|
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)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"cors_allow_origins",
|
||||||
|
_list_env(
|
||||||
|
"CORS_ALLOW_ORIGINS",
|
||||||
|
[
|
||||||
|
"http://localhost:16801",
|
||||||
|
"http://127.0.0.1:16801",
|
||||||
|
"http://localhost:17861",
|
||||||
|
"http://127.0.0.1:17861",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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."""
|
||||||
1673
backend/app/db/platform_store.py
Normal file
1673
backend/app/db/platform_store.py
Normal file
File diff suppressed because it is too large
Load Diff
40
backend/app/db/session.py
Normal file
40
backend/app/db/session.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+psycopg://yg_ft:change_me@localhost:15432/yg_ft")
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
DATABASE_URL,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
future=True,
|
||||||
|
)
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False, future=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Generator[Session, None, None]:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def session_scope() -> Generator[Session, None, None]:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
171
backend/app/db/sql/001_platform_runtime.sql
Normal file
171
backend/app/db/sql/001_platform_runtime.sql
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
permissions TEXT NOT NULL,
|
||||||
|
create_time TEXT NOT NULL,
|
||||||
|
last_login TEXT,
|
||||||
|
protected INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS models (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
purpose TEXT NOT NULL,
|
||||||
|
model_source TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
path TEXT,
|
||||||
|
api_url TEXT,
|
||||||
|
api_key TEXT,
|
||||||
|
online_model_name TEXT,
|
||||||
|
create_time TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS trained_models (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
train_methods TEXT NOT NULL,
|
||||||
|
base_model_path TEXT,
|
||||||
|
create_time TEXT NOT NULL,
|
||||||
|
merged INTEGER NOT NULL DEFAULT 0,
|
||||||
|
merging INTEGER NOT NULL DEFAULT 0,
|
||||||
|
merged_path TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS datasets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
storage_type TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
task_id TEXT,
|
||||||
|
size TEXT,
|
||||||
|
count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
description TEXT,
|
||||||
|
create_time TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dataset_files (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
dataset_id TEXT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
size TEXT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
active_version_id TEXT NOT NULL,
|
||||||
|
versions TEXT NOT NULL,
|
||||||
|
create_time TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS compute_nodes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
code TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
api_base_url TEXT NOT NULL,
|
||||||
|
file_gateway_url TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
scheduler_status TEXT NOT NULL,
|
||||||
|
scheduler_weight INTEGER NOT NULL DEFAULT 100,
|
||||||
|
tags TEXT NOT NULL,
|
||||||
|
gpu_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
current_running_jobs INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_parallel_jobs INTEGER NOT NULL DEFAULT 2,
|
||||||
|
data_root TEXT NOT NULL,
|
||||||
|
model_root TEXT NOT NULL,
|
||||||
|
log_root TEXT NOT NULL,
|
||||||
|
api_version TEXT NOT NULL DEFAULT 'v1',
|
||||||
|
capabilities TEXT NOT NULL DEFAULT '[]',
|
||||||
|
description TEXT,
|
||||||
|
last_health_check_at TEXT,
|
||||||
|
health_detail TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS gpus (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
node_id TEXT NOT NULL REFERENCES compute_nodes(id) ON DELETE CASCADE,
|
||||||
|
gpu_index INTEGER NOT NULL,
|
||||||
|
uuid TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
memory_total_gb DOUBLE PRECISION NOT NULL,
|
||||||
|
power_limit_w DOUBLE PRECISION NOT NULL,
|
||||||
|
base_temperature INTEGER NOT NULL,
|
||||||
|
last_seen_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fine_tune_tasks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
progress INTEGER NOT NULL DEFAULT 0,
|
||||||
|
process_id INTEGER,
|
||||||
|
create_time TEXT NOT NULL,
|
||||||
|
start_time TEXT,
|
||||||
|
completed_at TEXT,
|
||||||
|
compute_node_id TEXT REFERENCES compute_nodes(id) ON DELETE SET NULL,
|
||||||
|
gpus TEXT NOT NULL,
|
||||||
|
sync_job_id TEXT,
|
||||||
|
compute_job_id TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS resource_replicas (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
node_id TEXT NOT NULL REFERENCES compute_nodes(id) ON DELETE CASCADE,
|
||||||
|
resource_type TEXT NOT NULL,
|
||||||
|
resource_id TEXT NOT NULL,
|
||||||
|
local_path TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
sync_status TEXT NOT NULL,
|
||||||
|
create_time TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS resource_sync_jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
target_node_id TEXT NOT NULL,
|
||||||
|
resources TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
progress INTEGER NOT NULL DEFAULT 0,
|
||||||
|
create_time TEXT NOT NULL,
|
||||||
|
completed_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS eval_tasks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
create_time TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS eval_dimensions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_default INTEGER NOT NULL DEFAULT 0,
|
||||||
|
create_time TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS compare_tasks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
create_time TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fine_tune_status ON fine_tune_tasks(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fine_tune_compute_job ON fine_tune_tasks(compute_job_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fine_tune_compute_node_status ON fine_tune_tasks(compute_node_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dataset_files_dataset ON dataset_files(dataset_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gpus_node ON gpus(node_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_gpus_node_index ON gpus(node_id, gpu_index);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_replicas_resource ON resource_replicas(resource_type, resource_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_replicas_node_resource ON resource_replicas(node_id, resource_type, resource_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_jobs_node_status ON resource_sync_jobs(target_node_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_eval_tasks_status ON eval_tasks(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_eval_dimensions_active ON eval_dimensions(is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compare_tasks_status ON compare_tasks(status);
|
||||||
43
backend/app/main.py
Normal file
43
backend/app/main.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import asyncio
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
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
|
||||||
|
from app.workers.compute_poller import run_compute_poller
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
settings = get_settings()
|
||||||
|
configure_logging(settings)
|
||||||
|
|
||||||
|
app = FastAPI(title=settings.app_name)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_allow_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
setup_request_logging(app)
|
||||||
|
app.include_router(api_router, prefix=settings.route_prefix)
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def start_workers() -> None:
|
||||||
|
app.state.compute_poller_task = asyncio.create_task(run_compute_poller())
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def stop_workers() -> None:
|
||||||
|
task = getattr(app.state, "compute_poller_task", None)
|
||||||
|
if task:
|
||||||
|
task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
||||||
|
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."""
|
||||||
206
backend/app/modules/compute_gateway/client.py
Normal file
206
backend/app/modules/compute_gateway/client.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
def _join_url(base_url: str, path: str) -> str:
|
||||||
|
return urljoin(base_url.rstrip("/") + "/", path.lstrip("/"))
|
||||||
|
|
||||||
|
|
||||||
|
def _unwrap_items(payload: Any) -> list[dict[str, Any]]:
|
||||||
|
if isinstance(payload, list):
|
||||||
|
return [item for item in payload if isinstance(item, dict)]
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
data = payload.get("data")
|
||||||
|
if isinstance(data, dict) and isinstance(data.get("items"), list):
|
||||||
|
return [item for item in data["items"] if isinstance(item, dict)]
|
||||||
|
if isinstance(payload.get("items"), list):
|
||||||
|
return [item for item in payload["items"] if isinstance(item, dict)]
|
||||||
|
if isinstance(data, list):
|
||||||
|
return [item for item in data if isinstance(item, dict)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _unwrap_dict(payload: Any) -> dict[str, Any]:
|
||||||
|
if isinstance(payload, dict) and isinstance(payload.get("data"), dict):
|
||||||
|
return payload["data"]
|
||||||
|
return payload if isinstance(payload, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
class ComputeNodeClient:
|
||||||
|
"""Application-side client for one compute node.
|
||||||
|
|
||||||
|
The client accepts both current YG Compute API responses and common
|
||||||
|
wrapper shapes such as `{code,message,data}` to make future engine/node
|
||||||
|
adapters less brittle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, api_base_url: str, token: str | None = None, timeout: float | None = None) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
self.api_base_url = api_base_url.rstrip("/")
|
||||||
|
self.token = token or settings.compute_service_token
|
||||||
|
self.timeout = timeout or settings.compute_request_timeout_seconds
|
||||||
|
self.route_prefix = settings.route_prefix.rstrip("/") or "/modelTF"
|
||||||
|
|
||||||
|
def headers(self) -> dict[str, str]:
|
||||||
|
if not self.token:
|
||||||
|
return {}
|
||||||
|
return {"X-Compute-Token": self.token}
|
||||||
|
|
||||||
|
async def test_connection(self) -> dict[str, Any]:
|
||||||
|
started = time.perf_counter()
|
||||||
|
health = await self.health()
|
||||||
|
gpus = await self.gpus()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"latency_ms": int((time.perf_counter() - started) * 1000),
|
||||||
|
"health": health,
|
||||||
|
"gpus": gpus,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def health(self) -> dict[str, Any]:
|
||||||
|
paths = [f"{self.route_prefix}/v1/compute/health", f"{self.route_prefix}/health", "/health"]
|
||||||
|
last_error = ""
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout, headers=self.headers()) as client:
|
||||||
|
for path in paths:
|
||||||
|
try:
|
||||||
|
response = await client.get(_join_url(self.api_base_url, path))
|
||||||
|
response.raise_for_status()
|
||||||
|
return _unwrap_dict(response.json())
|
||||||
|
except Exception as exc: # noqa: BLE001 - keep endpoint compatibility fallback broad
|
||||||
|
last_error = str(exc)
|
||||||
|
raise RuntimeError(last_error or "compute health check failed")
|
||||||
|
|
||||||
|
async def gpus(self) -> list[dict[str, Any]]:
|
||||||
|
paths = [
|
||||||
|
f"{self.route_prefix}/compute/resources/gpus",
|
||||||
|
f"{self.route_prefix}/v1/compute/resources/gpus",
|
||||||
|
"/compute/resources/gpus",
|
||||||
|
]
|
||||||
|
last_error = ""
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout, headers=self.headers()) as client:
|
||||||
|
for path in paths:
|
||||||
|
try:
|
||||||
|
response = await client.get(_join_url(self.api_base_url, path))
|
||||||
|
response.raise_for_status()
|
||||||
|
return _unwrap_items(response.json())
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
last_error = str(exc)
|
||||||
|
raise RuntimeError(last_error or "compute gpu discovery failed")
|
||||||
|
|
||||||
|
async def create_job(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout, headers=self.headers()) as client:
|
||||||
|
response = await client.post(_join_url(self.api_base_url, f"{self.route_prefix}/compute/jobs"), json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
return _unwrap_dict(response.json())
|
||||||
|
|
||||||
|
async def preview_job(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout, headers=self.headers()) as client:
|
||||||
|
response = await client.post(
|
||||||
|
_join_url(self.api_base_url, f"{self.route_prefix}/compute/jobs/preview"),
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return _unwrap_dict(response.json())
|
||||||
|
|
||||||
|
async def validate_job(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout, headers=self.headers()) as client:
|
||||||
|
response = await client.post(
|
||||||
|
_join_url(self.api_base_url, f"{self.route_prefix}/compute/jobs/validate"),
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return _unwrap_dict(response.json())
|
||||||
|
|
||||||
|
async def check_paths(self, paths: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout, headers=self.headers()) as client:
|
||||||
|
response = await client.post(
|
||||||
|
_join_url(self.api_base_url, f"{self.route_prefix}/compute/files/check-paths"),
|
||||||
|
json={"paths": paths},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return _unwrap_dict(response.json())
|
||||||
|
|
||||||
|
async def list_files(
|
||||||
|
self,
|
||||||
|
root: str = "data",
|
||||||
|
relative_path: str = "",
|
||||||
|
directories_only: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout, headers=self.headers()) as client:
|
||||||
|
response = await client.get(
|
||||||
|
_join_url(self.api_base_url, f"{self.route_prefix}/compute/files/list"),
|
||||||
|
params={"root": root, "relative_path": relative_path, "directories_only": directories_only},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return _unwrap_dict(response.json())
|
||||||
|
|
||||||
|
async def get_job(self, job_id: str) -> dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout, headers=self.headers()) as client:
|
||||||
|
response = await client.get(_join_url(self.api_base_url, f"{self.route_prefix}/compute/jobs/{job_id}"))
|
||||||
|
response.raise_for_status()
|
||||||
|
return _unwrap_dict(response.json())
|
||||||
|
|
||||||
|
async def stop_job(self, job_id: str) -> dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout, headers=self.headers()) as client:
|
||||||
|
response = await client.post(_join_url(self.api_base_url, f"{self.route_prefix}/compute/jobs/{job_id}/stop"))
|
||||||
|
response.raise_for_status()
|
||||||
|
return _unwrap_dict(response.json())
|
||||||
|
|
||||||
|
async def job_logs(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
tail_lines: int | None = None,
|
||||||
|
offset: int | None = None,
|
||||||
|
limit: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
params = {
|
||||||
|
key: value
|
||||||
|
for key, value in {"tail_lines": tail_lines, "offset": offset, "limit": limit}.items()
|
||||||
|
if value is not None
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout, headers=self.headers()) as client:
|
||||||
|
response = await client.get(
|
||||||
|
_join_url(self.api_base_url, f"{self.route_prefix}/compute/jobs/{job_id}/logs"),
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return _unwrap_dict(response.json())
|
||||||
|
|
||||||
|
async def import_local_file(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout, headers=self.headers()) as client:
|
||||||
|
response = await client.post(
|
||||||
|
_join_url(self.api_base_url, f"{self.route_prefix}/compute/files/import-local"),
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return _unwrap_dict(response.json())
|
||||||
|
|
||||||
|
async def upload_file(
|
||||||
|
self,
|
||||||
|
filename: str,
|
||||||
|
content: bytes,
|
||||||
|
target_relative_path: str,
|
||||||
|
resource_type: str | None = None,
|
||||||
|
resource_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
data = {
|
||||||
|
"target_relative_path": target_relative_path,
|
||||||
|
"resource_type": resource_type or "",
|
||||||
|
"resource_id": resource_id or "",
|
||||||
|
}
|
||||||
|
files = {"file": (filename, content)}
|
||||||
|
async with httpx.AsyncClient(timeout=max(self.timeout, 60), headers=self.headers()) as client:
|
||||||
|
response = await client.post(
|
||||||
|
_join_url(self.api_base_url, f"{self.route_prefix}/compute/files/upload"),
|
||||||
|
data=data,
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return _unwrap_dict(response.json())
|
||||||
27
backend/app/modules/compute_gateway/sync.py
Normal file
27
backend/app/modules/compute_gateway/sync.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.db.platform_store import get_platform_store
|
||||||
|
from app.modules.compute_gateway.client import ComputeNodeClient
|
||||||
|
|
||||||
|
|
||||||
|
def _node_for_task(task: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
return next((node for node in get_platform_store().compute_nodes() if node["id"] == task.get("compute_node_id")), None)
|
||||||
|
|
||||||
|
|
||||||
|
async def poll_compute_jobs_once() -> dict[str, Any]:
|
||||||
|
store = get_platform_store()
|
||||||
|
synced: list[dict[str, Any]] = []
|
||||||
|
failed: list[dict[str, str]] = []
|
||||||
|
for task in store.running_compute_tasks():
|
||||||
|
node = _node_for_task(task)
|
||||||
|
if not node:
|
||||||
|
failed.append({"task_id": task["id"], "error": "compute node not found"})
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
job = await ComputeNodeClient(node["api_base_url"]).get_job(task["compute_job_id"])
|
||||||
|
synced.append(store.apply_compute_job(task["id"], job))
|
||||||
|
except Exception as exc: # noqa: BLE001 - keep polling other jobs
|
||||||
|
failed.append({"task_id": task["id"], "error": str(exc)})
|
||||||
|
return {"synced": len(synced), "failed": failed, "items": synced}
|
||||||
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."""
|
||||||
31
backend/app/workers/compute_poller.py
Normal file
31
backend/app/workers/compute_poller.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.modules.compute_gateway.sync import poll_compute_jobs_once
|
||||||
|
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_compute_poller() -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
if settings.compute_mode == "simulator" or settings.compute_status_sync_mode != "polling":
|
||||||
|
logger.info("compute poller disabled", extra={"compute_mode": settings.compute_mode})
|
||||||
|
return
|
||||||
|
|
||||||
|
interval = max(3, settings.compute_poll_interval_seconds)
|
||||||
|
logger.info("compute poller started", extra={"interval_seconds": interval})
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
result = await poll_compute_jobs_once()
|
||||||
|
if result["synced"] or result["failed"]:
|
||||||
|
logger.info("compute jobs polled", extra={"result": result})
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("compute poller stopped")
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001 - keep background polling alive
|
||||||
|
logger.exception("compute poller failed", extra={"error": str(exc)})
|
||||||
|
await asyncio.sleep(interval)
|
||||||
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.12"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.111.0",
|
||||||
|
"uvicorn[standard]>=0.30.0",
|
||||||
|
"python-multipart>=0.0.9",
|
||||||
|
"pydantic>=2.7.0",
|
||||||
|
"sqlalchemy>=2.0.30",
|
||||||
|
"psycopg[binary]>=3.2.1",
|
||||||
|
"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 = "py312"
|
||||||
|
|
||||||
|
[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
|
||||||
|
psycopg[binary]>=3.2.1
|
||||||
|
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
|
||||||
81
compute/README.md
Normal file
81
compute/README.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Compute Platform
|
||||||
|
|
||||||
|
算力平台与应用平台分开部署,本目录用于后续实现单机多 GPU 调度、文件网关和训练引擎适配。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
compute/
|
||||||
|
api/ # 只允许应用平台访问的内部 Compute API
|
||||||
|
agent/ # 单机 Agent,负责 GPU、进程、工作区管理
|
||||||
|
engines/
|
||||||
|
llama_factory/ # LLaMA-Factory 训练引擎适配器
|
||||||
|
file_gateway/ # 本地磁盘上传、下载、预览、离线导入
|
||||||
|
tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 开发职责
|
||||||
|
|
||||||
|
- GPU 发现、状态上报、锁定和释放。
|
||||||
|
- 本地磁盘工作区管理。
|
||||||
|
- 创建、停止、查询训练/评测/推理/合并任务。
|
||||||
|
- LLaMA-Factory 命令生成、日志解析、产物收集。
|
||||||
|
- 分片上传、短时下载、离线导入。
|
||||||
|
- 通过服务间 token 接受应用平台调用。
|
||||||
|
|
||||||
|
## 运行模式
|
||||||
|
|
||||||
|
- 默认 `COMPUTE_EXECUTION_MODE=real`,Compute API 会通过 `compute.agent.process_manager.ProcessManager` 启动真实 `llamafactory-cli train` 子进程,并将日志写入 `TRAINING_LOG_ROOT`。
|
||||||
|
- 真实模式下 GPU 发现优先使用宿主机 `nvidia-smi`。如果部署环境暂时无法调用 `nvidia-smi`,可通过 `COMPUTE_GPU_COUNT`、`COMPUTE_GPU_NAME`、`COMPUTE_GPU_MEMORY_GB`、`COMPUTE_GPU_POWER_LIMIT_W` 声明兼容 GPU 清单,便于应用侧先完成节点登记和联调。
|
||||||
|
- 仅隔离联调时可设置 `COMPUTE_EXECUTION_MODE=simulator`,启用内存状态机和合成 GPU/日志数据。该模式不得作为生产运行路径。
|
||||||
|
- 服务间鉴权默认开启:设置 `COMPUTE_AUTH_ENABLED=true` 和一致的 `COMPUTE_SERVICE_TOKEN`,应用侧会通过 `X-Compute-Token` 调用 Compute API。
|
||||||
|
- 真实训练作业会登记到 `TRAINING_LOG_ROOT/compute-jobs.json`。Compute API 重启后会恢复作业索引,继续提供状态、停止和日志查询。
|
||||||
|
- 同一算力节点内按 GPU ID 做轻量锁定;已有运行中作业占用的 GPU 不允许再次提交,避免同机多 GPU 场景下误复用。
|
||||||
|
|
||||||
|
真实执行前提:
|
||||||
|
|
||||||
|
- 镜像或宿主机环境中 `llamafactory-cli` 可执行。
|
||||||
|
- `LLAMA_FACTORY_HOME` 指向 LLaMA-Factory 工作目录。
|
||||||
|
- 基座模型路径和数据集名称/目录已经在算力服务器本地可访问。
|
||||||
|
- 应用侧训练任务中的 GPU、模型、数据集配置能映射到当前节点本地路径。
|
||||||
|
|
||||||
|
## 应用侧接入
|
||||||
|
|
||||||
|
应用平台通过“算力节点”页面维护每台 GPU 服务器的 `Compute API` 和 `File Gateway` 地址。点击连接测试时,Backend API 会主动调用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /modelTF/v1/compute/health
|
||||||
|
GET /modelTF/compute/resources/gpus
|
||||||
|
```
|
||||||
|
|
||||||
|
连接成功后,应用侧会同步节点健康信息、能力标签和 GPU 清单到 PostgreSQL。多节点阶段仍按“每台算力服务器 = 单机多 GPU 节点”管理,每台服务器都部署 Compute API、Agent、File Gateway 契约和 LLaMA-Factory。
|
||||||
|
|
||||||
|
训练闭环:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Frontend 创建/启动训练
|
||||||
|
-> Backend API 选择 compute_nodes 节点
|
||||||
|
-> Backend API POST /modelTF/compute/jobs 到目标 Compute API
|
||||||
|
-> Compute API 启动 llamafactory-cli 子进程
|
||||||
|
-> Backend Worker 定时 GET /modelTF/compute/jobs/{id}
|
||||||
|
-> Backend API 同步 fine_tune_tasks 状态、进度、PID、日志路径和产物索引
|
||||||
|
```
|
||||||
|
|
||||||
|
## 当前接口能力
|
||||||
|
|
||||||
|
日志接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /modelTF/compute/jobs/{job_id}/logs?tail_lines=200
|
||||||
|
GET /modelTF/compute/jobs/{job_id}/logs?offset=0&limit=500
|
||||||
|
```
|
||||||
|
|
||||||
|
返回 `content`、`metrics`、`total_lines`、`offset`、`limit`、`has_more`、`next_offset`,用于前端增量刷新和日志平台采集。
|
||||||
|
|
||||||
|
文件导入:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /modelTF/compute/files/import-local
|
||||||
|
```
|
||||||
|
|
||||||
|
该接口用于应用侧调度前把算力服务器本地可访问的模型/数据集路径导入到 `YG_FT_DATA_ROOT` 内部。目标路径会校验不能逃逸出 `YG_FT_DATA_ROOT`,源路径必须已存在于算力服务器本地或挂载目录。
|
||||||
1
compute/__init__.py
Normal file
1
compute/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Compute platform package."""
|
||||||
1
compute/agent/__init__.py
Normal file
1
compute/agent/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Compute agent package."""
|
||||||
246
compute/agent/process_manager.py
Normal file
246
compute/agent/process_manager.py
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import contextlib
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
TERMINAL_STATUSES = {"completed", "failed", "stopped"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ManagedProcess:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
command: list[str]
|
||||||
|
work_dir: str
|
||||||
|
log_path: Path
|
||||||
|
output_dir: str
|
||||||
|
gpus: list[int]
|
||||||
|
process: subprocess.Popen[Any] | None
|
||||||
|
created_at: float
|
||||||
|
pid: int | None = None
|
||||||
|
status: str = "running"
|
||||||
|
progress: int = 5
|
||||||
|
artifacts: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessManager:
|
||||||
|
def __init__(self, log_root: str) -> None:
|
||||||
|
self.log_root = Path(log_root)
|
||||||
|
self.log_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.registry_path = self.log_root / "compute-jobs.json"
|
||||||
|
self.jobs: dict[str, ManagedProcess] = {}
|
||||||
|
self._load_registry()
|
||||||
|
|
||||||
|
def create_job(self, payload: dict[str, Any], command: list[str], work_dir: str) -> dict[str, Any]:
|
||||||
|
job_id = str(payload.get("id") or f"job_{int(time.time() * 1000)}")
|
||||||
|
if job_id in self.jobs and self.jobs[job_id].status not in TERMINAL_STATUSES:
|
||||||
|
raise ValueError(f"job {job_id} is already running")
|
||||||
|
|
||||||
|
output_dir = str(payload.get("output_dir") or f"/data/yg-ft/outputs/{payload.get('name', job_id)}")
|
||||||
|
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
log_path = self.log_root / f"{job_id}.log"
|
||||||
|
env = os.environ.copy()
|
||||||
|
gpus = [int(item) for item in payload.get("gpus") or []]
|
||||||
|
locked = self.locked_gpus()
|
||||||
|
conflict = sorted(set(gpus).intersection(locked))
|
||||||
|
if conflict:
|
||||||
|
raise ValueError(f"gpu already locked: {conflict}")
|
||||||
|
if gpus:
|
||||||
|
env["CUDA_VISIBLE_DEVICES"] = ",".join(str(item) for item in gpus)
|
||||||
|
env.update({str(k): str(v) for k, v in payload.get("env", {}).items()})
|
||||||
|
|
||||||
|
cwd = work_dir if Path(work_dir).exists() else None
|
||||||
|
with log_path.open("ab") as log_file:
|
||||||
|
log_file.write(f"[INFO] starting job_id={job_id} command={' '.join(command)}\n".encode("utf-8"))
|
||||||
|
process = subprocess.Popen(
|
||||||
|
command,
|
||||||
|
cwd=cwd,
|
||||||
|
env=env,
|
||||||
|
stdout=log_file,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
managed = ManagedProcess(
|
||||||
|
id=job_id,
|
||||||
|
name=str(payload.get("name") or job_id),
|
||||||
|
command=command,
|
||||||
|
work_dir=work_dir,
|
||||||
|
log_path=log_path,
|
||||||
|
output_dir=output_dir,
|
||||||
|
gpus=gpus,
|
||||||
|
process=process,
|
||||||
|
created_at=time.time(),
|
||||||
|
pid=process.pid,
|
||||||
|
progress=10,
|
||||||
|
)
|
||||||
|
self.jobs[job_id] = managed
|
||||||
|
data = self.serialize(managed)
|
||||||
|
self._save_registry()
|
||||||
|
return data
|
||||||
|
|
||||||
|
def get_job(self, job_id: str) -> dict[str, Any] | None:
|
||||||
|
job = self.jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return None
|
||||||
|
return self.serialize(job)
|
||||||
|
|
||||||
|
def list_jobs(self) -> list[dict[str, Any]]:
|
||||||
|
return [self.serialize(job) for job in self.jobs.values()]
|
||||||
|
|
||||||
|
def stop_job(self, job_id: str) -> dict[str, Any] | None:
|
||||||
|
job = self.jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return None
|
||||||
|
if job.status not in TERMINAL_STATUSES:
|
||||||
|
try:
|
||||||
|
if job.process is not None and os.name == "nt":
|
||||||
|
job.process.terminate()
|
||||||
|
elif job.pid is not None:
|
||||||
|
os.kill(job.pid, signal.SIGTERM)
|
||||||
|
if job.process is not None:
|
||||||
|
job.process.wait(timeout=10)
|
||||||
|
except Exception:
|
||||||
|
if job.process is not None:
|
||||||
|
job.process.kill()
|
||||||
|
elif job.pid is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
os.kill(job.pid, signal.SIGKILL)
|
||||||
|
job.status = "stopped"
|
||||||
|
job.progress = min(job.progress, 99)
|
||||||
|
data = self.serialize(job)
|
||||||
|
self._save_registry()
|
||||||
|
return data
|
||||||
|
|
||||||
|
def logs(self, job_id: str) -> str:
|
||||||
|
job = self.jobs.get(job_id)
|
||||||
|
if not job or not job.log_path.exists():
|
||||||
|
return ""
|
||||||
|
return job.log_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
def serialize(self, job: ManagedProcess) -> dict[str, Any]:
|
||||||
|
code = job.process.poll() if job.process is not None else None
|
||||||
|
if job.status not in TERMINAL_STATUSES:
|
||||||
|
if job.process is None and job.pid is not None and not self._pid_alive(job.pid):
|
||||||
|
job.status = "failed"
|
||||||
|
job.progress = min(job.progress, 99)
|
||||||
|
code = -1
|
||||||
|
elif code is None:
|
||||||
|
job.status = "running"
|
||||||
|
elapsed = max(0, int(time.time() - job.created_at))
|
||||||
|
job.progress = min(95, max(job.progress, 10 + elapsed // 6))
|
||||||
|
elif code == 0:
|
||||||
|
job.status = "completed"
|
||||||
|
job.progress = 100
|
||||||
|
job.artifacts = self._collect_artifacts(job.output_dir)
|
||||||
|
else:
|
||||||
|
job.status = "failed"
|
||||||
|
job.progress = min(job.progress, 99)
|
||||||
|
self._save_registry()
|
||||||
|
return {
|
||||||
|
"id": job.id,
|
||||||
|
"name": job.name,
|
||||||
|
"status": job.status,
|
||||||
|
"progress": job.progress,
|
||||||
|
"pid": job.pid,
|
||||||
|
"gpus": job.gpus,
|
||||||
|
"created_at": job.created_at,
|
||||||
|
"command": job.command,
|
||||||
|
"work_dir": job.work_dir,
|
||||||
|
"output_dir": job.output_dir,
|
||||||
|
"log_file": str(job.log_path),
|
||||||
|
"artifacts": job.artifacts,
|
||||||
|
"return_code": code,
|
||||||
|
}
|
||||||
|
|
||||||
|
def locked_gpus(self) -> set[int]:
|
||||||
|
locked: set[int] = set()
|
||||||
|
for job in self.jobs.values():
|
||||||
|
status = self.serialize(job)["status"]
|
||||||
|
if status in {"queued", "running"}:
|
||||||
|
locked.update(job.gpus)
|
||||||
|
return locked
|
||||||
|
|
||||||
|
def _collect_artifacts(self, output_dir: str) -> list[dict[str, Any]]:
|
||||||
|
root = Path(output_dir)
|
||||||
|
if not root.exists():
|
||||||
|
return []
|
||||||
|
artifacts: list[dict[str, Any]] = []
|
||||||
|
for path in root.rglob("*"):
|
||||||
|
if path.is_file():
|
||||||
|
artifacts.append(
|
||||||
|
{
|
||||||
|
"path": str(path),
|
||||||
|
"name": path.name,
|
||||||
|
"size": path.stat().st_size,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return artifacts[:200]
|
||||||
|
|
||||||
|
def _save_registry(self) -> None:
|
||||||
|
items = []
|
||||||
|
for job in self.jobs.values():
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": job.id,
|
||||||
|
"name": job.name,
|
||||||
|
"command": job.command,
|
||||||
|
"work_dir": job.work_dir,
|
||||||
|
"log_path": str(job.log_path),
|
||||||
|
"output_dir": job.output_dir,
|
||||||
|
"gpus": job.gpus,
|
||||||
|
"pid": job.pid,
|
||||||
|
"created_at": job.created_at,
|
||||||
|
"status": job.status,
|
||||||
|
"progress": job.progress,
|
||||||
|
"artifacts": job.artifacts,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.registry_path.write_text(json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
def _load_registry(self) -> None:
|
||||||
|
if not self.registry_path.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
items = json.loads(self.registry_path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return
|
||||||
|
for item in items if isinstance(items, list) else []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
pid = item.get("pid")
|
||||||
|
status = item.get("status", "failed")
|
||||||
|
if status not in TERMINAL_STATUSES and pid and not self._pid_alive(int(pid)):
|
||||||
|
status = "failed"
|
||||||
|
job = ManagedProcess(
|
||||||
|
id=str(item["id"]),
|
||||||
|
name=str(item.get("name") or item["id"]),
|
||||||
|
command=[str(part) for part in item.get("command") or []],
|
||||||
|
work_dir=str(item.get("work_dir") or ""),
|
||||||
|
log_path=Path(item.get("log_path") or self.log_root / f"{item['id']}.log"),
|
||||||
|
output_dir=str(item.get("output_dir") or ""),
|
||||||
|
gpus=[int(gpu) for gpu in item.get("gpus") or []],
|
||||||
|
process=None,
|
||||||
|
pid=int(pid) if pid else None,
|
||||||
|
created_at=float(item.get("created_at") or time.time()),
|
||||||
|
status=status,
|
||||||
|
progress=int(item.get("progress") or 0),
|
||||||
|
artifacts=item.get("artifacts") or [],
|
||||||
|
)
|
||||||
|
self.jobs[job.id] = job
|
||||||
|
|
||||||
|
def _pid_alive(self, pid: int) -> bool:
|
||||||
|
if pid <= 0:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
1
compute/api/__init__.py
Normal file
1
compute/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Compute API package."""
|
||||||
629
compute/api/main.py
Normal file
629
compute/api/main.py
Normal file
@@ -0,0 +1,629 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import math
|
||||||
|
import hashlib
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
|
||||||
|
from compute.agent.process_manager import ProcessManager
|
||||||
|
from compute.engines.llama_factory.adapter import build_command, parse_log_line
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
app = FastAPI(title="YG Fine-Tune Compute API")
|
||||||
|
jobs: dict[str, dict[str, Any]] = {}
|
||||||
|
route_prefix = os.getenv("MODELTF_ROUTE_PREFIX", "/modelTF").rstrip("/") or "/modelTF"
|
||||||
|
process_manager = ProcessManager(os.getenv("TRAINING_LOG_ROOT", "/opt/yg-ft/logs/training"))
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def compute_token_auth(request: Request, call_next):
|
||||||
|
token = os.getenv("COMPUTE_SERVICE_TOKEN", "")
|
||||||
|
auth_enabled = os.getenv("COMPUTE_AUTH_ENABLED", "true").lower() == "true"
|
||||||
|
public_paths = {f"{route_prefix}/health", "/health"}
|
||||||
|
if auth_enabled and token and request.url.path not in public_paths:
|
||||||
|
header_token = request.headers.get("x-compute-token", "")
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
bearer_token = auth_header.removeprefix("Bearer ").strip() if auth_header.startswith("Bearer ") else ""
|
||||||
|
if header_token != token and bearer_token != token:
|
||||||
|
return JSONResponse({"detail": "invalid compute service token"}, status_code=401)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
def now() -> float:
|
||||||
|
return time.time()
|
||||||
|
|
||||||
|
def host_id() -> str:
|
||||||
|
return os.getenv("COMPUTE_HOST_ID", "gpu-node-01")
|
||||||
|
|
||||||
|
def execution_mode() -> str:
|
||||||
|
return os.getenv("COMPUTE_EXECUTION_MODE", os.getenv("COMPUTE_MODE", "real")).lower()
|
||||||
|
|
||||||
|
def _int_env(name: str, default: int) -> int:
|
||||||
|
raw = os.getenv(name)
|
||||||
|
if raw is None or raw == "":
|
||||||
|
return default
|
||||||
|
return int(raw)
|
||||||
|
|
||||||
|
def _float_env(name: str, default: float) -> float:
|
||||||
|
raw = os.getenv(name)
|
||||||
|
if raw is None or raw == "":
|
||||||
|
return default
|
||||||
|
return float(raw)
|
||||||
|
|
||||||
|
def _path_inside(root: Path, candidate: Path) -> bool:
|
||||||
|
try:
|
||||||
|
candidate.resolve().relative_to(root.resolve())
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _llama_factory_version() -> str:
|
||||||
|
for command in (["llamafactory-cli", "version"], ["llamafactory-cli", "--version"]):
|
||||||
|
try:
|
||||||
|
result = subprocess.run(command, capture_output=True, text=True, timeout=5)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
output = (result.stdout or result.stderr).strip()
|
||||||
|
if result.returncode == 0 and output:
|
||||||
|
return output.splitlines()[0][:120]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _slice_log_content(
|
||||||
|
content: str,
|
||||||
|
tail_lines: int | None = None,
|
||||||
|
offset: int | None = None,
|
||||||
|
limit: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
lines = content.splitlines()
|
||||||
|
total = len(lines)
|
||||||
|
if offset is not None or limit is not None:
|
||||||
|
start = max(0, offset or 0)
|
||||||
|
end = start + limit if limit else total
|
||||||
|
selected = lines[start:end]
|
||||||
|
else:
|
||||||
|
tail = tail_lines or 200
|
||||||
|
start = max(0, total - tail)
|
||||||
|
selected = lines[start:]
|
||||||
|
next_offset = start + len(selected)
|
||||||
|
return {
|
||||||
|
"content": "\n".join(selected),
|
||||||
|
"total_lines": total,
|
||||||
|
"offset": start,
|
||||||
|
"limit": len(selected),
|
||||||
|
"has_more": next_offset < total,
|
||||||
|
"next_offset": next_offset if next_offset < total else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _safe_float(value: Any, default: float = 0) -> float:
|
||||||
|
try:
|
||||||
|
return float(str(value).replace("[N/A]", "").strip() or default)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
def job_status(job: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if execution_mode() != "simulator":
|
||||||
|
return job
|
||||||
|
elapsed = max(0, int(now() - job["created_at"]))
|
||||||
|
if job["status"] not in {"stopped", "failed", "completed"}:
|
||||||
|
if elapsed < 5:
|
||||||
|
job["status"] = "queued"
|
||||||
|
job["progress"] = 12 + elapsed * 3
|
||||||
|
elif elapsed < 60:
|
||||||
|
job["status"] = "running"
|
||||||
|
job["progress"] = min(96, 25 + int((elapsed - 5) / 55 * 70))
|
||||||
|
else:
|
||||||
|
job["status"] = "completed"
|
||||||
|
job["progress"] = 100
|
||||||
|
job["logs"] = generate_logs(job)
|
||||||
|
return job
|
||||||
|
|
||||||
|
def generate_logs(job: dict[str, Any]) -> str:
|
||||||
|
progress = int(job.get("progress", 0) or 0)
|
||||||
|
points = max(1, min(80, progress))
|
||||||
|
lines = [
|
||||||
|
f"[INFO] compute_host_id={host_id()} job_id={job['id']} engine=llama_factory",
|
||||||
|
f"[INFO] command={' '.join(job['command'])}",
|
||||||
|
]
|
||||||
|
for step in range(1, points + 1):
|
||||||
|
if step % 4 != 0 and step != points:
|
||||||
|
continue
|
||||||
|
loss = max(0.11, 2.5 * math.exp(-step / 40))
|
||||||
|
grad_norm = 0.4 + (step % 5) * 0.04
|
||||||
|
lr = 0.0002 * max(0.05, 1 - step / 100)
|
||||||
|
epoch = round(step / points * 3, 4)
|
||||||
|
lines.append(
|
||||||
|
"{"
|
||||||
|
f"'loss': {loss:.4f}, 'grad_norm': {grad_norm:.4f}, "
|
||||||
|
f"'learning_rate': {lr:.8f}, 'epoch': {epoch:.4f}"
|
||||||
|
"}"
|
||||||
|
)
|
||||||
|
if job.get("status") == "completed":
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"***** train metrics *****",
|
||||||
|
"epoch = 3",
|
||||||
|
"train_loss = 0.1181",
|
||||||
|
"train_runtime = 1m 0s",
|
||||||
|
"***** train metrics end *****",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def real_gpu_resources() -> list[dict[str, Any]]:
|
||||||
|
query = (
|
||||||
|
"index,uuid,name,memory.total,memory.used,utilization.gpu,"
|
||||||
|
"temperature.gpu,power.draw,power.limit"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["nvidia-smi", f"--query-gpu={query}", "--format=csv,noheader,nounits"],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return fallback_gpu_resources()
|
||||||
|
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
parts = [part.strip() for part in line.split(",")]
|
||||||
|
if len(parts) < 9:
|
||||||
|
continue
|
||||||
|
idx, uuid, name, mem_total, mem_used, util, temp, power, power_limit = parts[:9]
|
||||||
|
total_gb = round(_safe_float(mem_total) / 1024, 2)
|
||||||
|
used_gb = round(_safe_float(mem_used) / 1024, 2)
|
||||||
|
memory_percent = round(used_gb / total_gb * 100, 1) if total_gb else 0
|
||||||
|
gpu_percent = int(_safe_float(util))
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": int(idx),
|
||||||
|
"gpu_index": int(idx),
|
||||||
|
"uuid": uuid,
|
||||||
|
"name": name,
|
||||||
|
"status": "busy" if gpu_percent >= 5 or used_gb > 1 else "idle",
|
||||||
|
"gpu_percent": gpu_percent,
|
||||||
|
"memory_used_gb": used_gb,
|
||||||
|
"memory_total_gb": total_gb,
|
||||||
|
"memory_percent": memory_percent,
|
||||||
|
"temperature": int(_safe_float(temp)),
|
||||||
|
"power_w": round(_safe_float(power), 1),
|
||||||
|
"power_limit_w": round(_safe_float(power_limit), 1),
|
||||||
|
"processes": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
def fallback_gpu_resources() -> list[dict[str, Any]]:
|
||||||
|
count = _int_env("COMPUTE_GPU_COUNT", 0)
|
||||||
|
if count <= 0:
|
||||||
|
return []
|
||||||
|
name = os.getenv("COMPUTE_GPU_NAME", "Configured GPU")
|
||||||
|
memory_total = _float_env("COMPUTE_GPU_MEMORY_GB", 80.0)
|
||||||
|
power_limit = _float_env("COMPUTE_GPU_POWER_LIMIT_W", 300.0)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": idx,
|
||||||
|
"gpu_index": idx,
|
||||||
|
"uuid": f"GPU-{host_id().upper()}-{idx}",
|
||||||
|
"name": name,
|
||||||
|
"status": "idle",
|
||||||
|
"gpu_percent": 0,
|
||||||
|
"memory_used_gb": 0,
|
||||||
|
"memory_total_gb": memory_total,
|
||||||
|
"memory_percent": 0,
|
||||||
|
"temperature": _int_env("COMPUTE_GPU_BASE_TEMPERATURE", 35),
|
||||||
|
"power_w": 0,
|
||||||
|
"power_limit_w": power_limit,
|
||||||
|
"processes": [],
|
||||||
|
}
|
||||||
|
for idx in range(count)
|
||||||
|
]
|
||||||
|
|
||||||
|
def gpu_resources() -> list[dict[str, Any]]:
|
||||||
|
if execution_mode() != "simulator":
|
||||||
|
return real_gpu_resources()
|
||||||
|
active_jobs = [job_status(job) for job in jobs.values() if job["status"] in {"queued", "running"}]
|
||||||
|
gpus: list[dict[str, Any]] = []
|
||||||
|
for idx in range(4):
|
||||||
|
task = next((job for job in active_jobs if idx in job.get("gpus", [])), None)
|
||||||
|
busy = task is not None and task["status"] == "running"
|
||||||
|
reserved = task is not None and task["status"] == "queued"
|
||||||
|
gpus.append(
|
||||||
|
{
|
||||||
|
"id": idx,
|
||||||
|
"uuid": f"GPU-{host_id().upper()}-{idx}",
|
||||||
|
"name": os.getenv("COMPUTE_GPU_NAME", "NVIDIA A800-SXM4-80GB"),
|
||||||
|
"status": "busy" if busy else "reserved" if reserved else "idle",
|
||||||
|
"gpu_percent": 88 if busy else 25 if reserved else 4,
|
||||||
|
"memory_used_gb": 58 if busy else 12 if reserved else 2,
|
||||||
|
"memory_total_gb": 80,
|
||||||
|
"temperature": 61 if busy else 45 if reserved else 36,
|
||||||
|
"power_w": 215 if busy else 80 if reserved else 25,
|
||||||
|
"power_limit_w": 300,
|
||||||
|
"processes": [
|
||||||
|
{
|
||||||
|
"pid": task["pid"],
|
||||||
|
"name": "llamafactory-cli",
|
||||||
|
"task_name": task["name"],
|
||||||
|
"memory_used_gb": 58 if busy else 12,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if task
|
||||||
|
else [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return gpus
|
||||||
|
|
||||||
|
def _check_path_item(item: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
path = Path(str(item.get("path") or ""))
|
||||||
|
exists = path.exists()
|
||||||
|
expected_type = str(item.get("type") or "any")
|
||||||
|
ok = exists
|
||||||
|
if exists and expected_type == "dir":
|
||||||
|
ok = path.is_dir()
|
||||||
|
if exists and expected_type == "file":
|
||||||
|
ok = path.is_file()
|
||||||
|
return {
|
||||||
|
"name": item.get("name") or "",
|
||||||
|
"path": str(path),
|
||||||
|
"type": expected_type,
|
||||||
|
"required": bool(item.get("required", True)),
|
||||||
|
"exists": exists,
|
||||||
|
"is_dir": path.is_dir() if exists else False,
|
||||||
|
"is_file": path.is_file() if exists else False,
|
||||||
|
"ok": ok or not item.get("required", True),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _job_preview(payload: dict[str, Any], check_paths: bool) -> dict[str, Any]:
|
||||||
|
warnings: list[str] = []
|
||||||
|
try:
|
||||||
|
command = build_command(payload, os.getenv("LLAMA_FACTORY_HOME", "/app/LLaMA-Factory"))
|
||||||
|
except ValueError as exc:
|
||||||
|
return {
|
||||||
|
"valid": False,
|
||||||
|
"errors": [part.strip() for part in str(exc).split(";") if part.strip()],
|
||||||
|
"warnings": warnings,
|
||||||
|
"engine": str(payload.get("engine") or payload.get("training_engine") or "llama_factory"),
|
||||||
|
"command": [],
|
||||||
|
"command_text": "",
|
||||||
|
"work_dir": os.getenv("LLAMA_FACTORY_HOME", "/app/LLaMA-Factory"),
|
||||||
|
"env": {},
|
||||||
|
"path_checks": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
engine = str(payload.get("engine") or payload.get("training_engine") or "llama_factory")
|
||||||
|
path_checks: list[dict[str, Any]] = []
|
||||||
|
if check_paths and engine != "smoke":
|
||||||
|
path_checks = [
|
||||||
|
_check_path_item(
|
||||||
|
{
|
||||||
|
"name": "model_name_or_path",
|
||||||
|
"path": payload.get("model_name_or_path") or payload.get("base_model") or "",
|
||||||
|
"type": "any",
|
||||||
|
"required": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if payload.get("dataset_dir"):
|
||||||
|
path_checks.append(
|
||||||
|
_check_path_item(
|
||||||
|
{
|
||||||
|
"name": "dataset_dir",
|
||||||
|
"path": payload.get("dataset_dir"),
|
||||||
|
"type": "dir",
|
||||||
|
"required": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
output_dir = Path(str(payload.get("output_dir") or "/data/yg-ft/outputs/training-job"))
|
||||||
|
path_checks.append(
|
||||||
|
_check_path_item(
|
||||||
|
{
|
||||||
|
"name": "output_parent",
|
||||||
|
"path": str(output_dir.parent),
|
||||||
|
"type": "dir",
|
||||||
|
"required": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
errors.extend(
|
||||||
|
[f"{item['name']} path not available: {item['path']}" for item in path_checks if not item["ok"] and item["required"]]
|
||||||
|
)
|
||||||
|
if shutil.which(command.command[0]) is None:
|
||||||
|
errors.append(f"training command not found: {command.command[0]}")
|
||||||
|
if not Path(command.work_dir).exists():
|
||||||
|
errors.append(f"llama_factory_home not found: {command.work_dir}")
|
||||||
|
elif engine == "smoke":
|
||||||
|
warnings.append("smoke engine skips model and dataset path checks")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": not errors,
|
||||||
|
"errors": errors,
|
||||||
|
"warnings": warnings,
|
||||||
|
"engine": engine,
|
||||||
|
"command": command.command,
|
||||||
|
"command_text": " ".join(command.command),
|
||||||
|
"work_dir": command.work_dir,
|
||||||
|
"env": command.env,
|
||||||
|
"path_checks": path_checks,
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get(f"{route_prefix}/health")
|
||||||
|
async def health_check() -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"compute_host_id": os.getenv("COMPUTE_HOST_ID", "unknown"),
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check_root() -> dict[str, str]:
|
||||||
|
return await health_check()
|
||||||
|
|
||||||
|
@app.get(f"{route_prefix}/v1/compute/health")
|
||||||
|
async def compute_health_check() -> dict[str, Any]:
|
||||||
|
data_root = Path(os.getenv("YG_FT_DATA_ROOT", "/data/yg-ft"))
|
||||||
|
dataset_root = Path(os.getenv("YG_FT_DATASET_ROOT", str(data_root / "datasets")))
|
||||||
|
output_root = Path(os.getenv("YG_FT_OUTPUT_ROOT", str(data_root / "outputs")))
|
||||||
|
llama_factory_home = Path(os.getenv("LLAMA_FACTORY_HOME", "/app/LLaMA-Factory"))
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"api_version": "v1",
|
||||||
|
"compute_host_id": os.getenv("COMPUTE_HOST_ID", "unknown"),
|
||||||
|
"app_callback_enabled": os.getenv("ENABLE_APP_CALLBACK", "false").lower() == "true",
|
||||||
|
"data_root": str(data_root),
|
||||||
|
"data_root_exists": data_root.exists(),
|
||||||
|
"model_root": os.getenv("YG_FT_MODEL_ROOT", str(data_root / "models")),
|
||||||
|
"dataset_root": str(dataset_root),
|
||||||
|
"dataset_root_exists": dataset_root.exists(),
|
||||||
|
"output_root": str(output_root),
|
||||||
|
"output_root_exists": output_root.exists(),
|
||||||
|
"log_root": os.getenv("TRAINING_LOG_ROOT", "/opt/yg-ft/logs/training"),
|
||||||
|
"llama_factory_home": str(llama_factory_home),
|
||||||
|
"llama_factory_home_exists": llama_factory_home.exists(),
|
||||||
|
"llama_factory_version": os.getenv("LLAMA_FACTORY_VERSION", ""),
|
||||||
|
"execution_mode": execution_mode(),
|
||||||
|
"gpu_count": _int_env("COMPUTE_GPU_COUNT", 0),
|
||||||
|
"gpu_discovery_endpoint": f"{route_prefix}/compute/resources/gpus",
|
||||||
|
"capabilities": ["gpu_discovery", "llama_factory", "file_gateway", "job_polling"],
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get(f"{route_prefix}/v1/compute/jobs")
|
||||||
|
async def list_jobs_alias() -> dict[str, list[dict[str, Any]]]:
|
||||||
|
items = process_manager.list_jobs() if execution_mode() != "simulator" else [job_status(job) for job in jobs.values()]
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
@app.get(f"{route_prefix}/compute/resources/gpus")
|
||||||
|
async def list_gpus() -> dict[str, Any]:
|
||||||
|
return {"items": gpu_resources(), "compute_host_id": host_id()}
|
||||||
|
|
||||||
|
@app.get(f"{route_prefix}/v1/compute/resources/gpus")
|
||||||
|
async def list_gpus_v1() -> dict[str, Any]:
|
||||||
|
return {"items": gpu_resources(), "compute_host_id": host_id()}
|
||||||
|
|
||||||
|
@app.post(f"{route_prefix}/compute/jobs/preview")
|
||||||
|
async def preview_job(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return _job_preview(payload, check_paths=False)
|
||||||
|
|
||||||
|
@app.post(f"{route_prefix}/compute/jobs/validate")
|
||||||
|
async def validate_job(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return _job_preview(payload, check_paths=True)
|
||||||
|
|
||||||
|
@app.post(f"{route_prefix}/v1/compute/jobs/preview")
|
||||||
|
async def preview_job_v1(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await preview_job(payload)
|
||||||
|
|
||||||
|
@app.post(f"{route_prefix}/v1/compute/jobs/validate")
|
||||||
|
async def validate_job_v1(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await validate_job(payload)
|
||||||
|
|
||||||
|
@app.post(f"{route_prefix}/compute/files/check-paths")
|
||||||
|
async def check_paths(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
items = [_check_path_item(item) for item in payload.get("paths", []) if isinstance(item, dict)]
|
||||||
|
return {"valid": all(item["ok"] for item in items), "items": items}
|
||||||
|
|
||||||
|
@app.get(f"{route_prefix}/compute/files/list")
|
||||||
|
async def list_files(
|
||||||
|
root: str = Query(default="data"),
|
||||||
|
relative_path: str = Query(default=""),
|
||||||
|
directories_only: bool = Query(default=False),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
roots = {
|
||||||
|
"data": Path(os.getenv("YG_FT_DATA_ROOT", "/data/yg-ft")),
|
||||||
|
"models": Path(os.getenv("YG_FT_MODEL_ROOT", os.getenv("YG_FT_DATA_ROOT", "/data/yg-ft") + "/models")),
|
||||||
|
"datasets": Path(os.getenv("YG_FT_DATASET_ROOT", os.getenv("YG_FT_DATA_ROOT", "/data/yg-ft") + "/datasets")),
|
||||||
|
"outputs": Path(os.getenv("YG_FT_OUTPUT_ROOT", os.getenv("YG_FT_DATA_ROOT", "/data/yg-ft") + "/outputs")),
|
||||||
|
}
|
||||||
|
base = roots.get(root)
|
||||||
|
if base is None:
|
||||||
|
raise HTTPException(status_code=400, detail="invalid root")
|
||||||
|
target = (base / relative_path.lstrip("/\\")).resolve()
|
||||||
|
if not _path_inside(base, target):
|
||||||
|
raise HTTPException(status_code=400, detail="path must stay inside selected root")
|
||||||
|
if not target.exists():
|
||||||
|
return {"root": root, "base_path": str(base), "relative_path": relative_path, "items": []}
|
||||||
|
items = []
|
||||||
|
for child in sorted(target.iterdir(), key=lambda path: (not path.is_dir(), path.name.lower())):
|
||||||
|
if directories_only and not child.is_dir():
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"name": child.name,
|
||||||
|
"path": str(child),
|
||||||
|
"relative_path": str(child.relative_to(base)).replace("\\", "/"),
|
||||||
|
"type": "directory" if child.is_dir() else "file",
|
||||||
|
"byte_size": child.stat().st_size if child.is_file() else 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"root": root, "base_path": str(base), "relative_path": relative_path, "items": items}
|
||||||
|
|
||||||
|
@app.post(f"{route_prefix}/compute/jobs")
|
||||||
|
async def create_job(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
command = build_command(payload, os.getenv("LLAMA_FACTORY_HOME", "/app/LLaMA-Factory"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
job_id = str(payload.get("id") or f"job_{int(now() * 1000)}")
|
||||||
|
if execution_mode() != "simulator":
|
||||||
|
try:
|
||||||
|
return process_manager.create_job({**payload, "id": job_id}, command.command, command.work_dir)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=f"training command not found: {exc.filename}")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc))
|
||||||
|
job = {
|
||||||
|
"id": job_id,
|
||||||
|
"name": payload.get("name", job_id),
|
||||||
|
"status": "queued",
|
||||||
|
"progress": 10,
|
||||||
|
"pid": int(52000 + now() % 10000),
|
||||||
|
"gpus": payload.get("gpus") or [0],
|
||||||
|
"created_at": now(),
|
||||||
|
"command": command.command,
|
||||||
|
"work_dir": command.work_dir,
|
||||||
|
"artifacts": [],
|
||||||
|
"logs": "",
|
||||||
|
}
|
||||||
|
jobs[job_id] = job
|
||||||
|
return job_status(job)
|
||||||
|
|
||||||
|
@app.get(f"{route_prefix}/compute/jobs")
|
||||||
|
async def list_jobs() -> dict[str, Any]:
|
||||||
|
items = process_manager.list_jobs() if execution_mode() != "simulator" else [job_status(job) for job in jobs.values()]
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
@app.get(f"{route_prefix}/compute/jobs/{{job_id}}")
|
||||||
|
async def get_job(job_id: str) -> dict[str, Any]:
|
||||||
|
job = jobs.get(job_id)
|
||||||
|
if execution_mode() != "simulator":
|
||||||
|
job = process_manager.get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="job not found")
|
||||||
|
return job
|
||||||
|
job = jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="job not found")
|
||||||
|
return job_status(job)
|
||||||
|
|
||||||
|
@app.post(f"{route_prefix}/compute/jobs/{{job_id}}/stop")
|
||||||
|
async def stop_job(job_id: str) -> dict[str, Any]:
|
||||||
|
if execution_mode() != "simulator":
|
||||||
|
job = process_manager.stop_job(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="job not found")
|
||||||
|
return job
|
||||||
|
job = jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="job not found")
|
||||||
|
job["status"] = "stopped"
|
||||||
|
job["progress"] = min(job.get("progress", 0), 99)
|
||||||
|
return job
|
||||||
|
|
||||||
|
@app.get(f"{route_prefix}/compute/jobs/{{job_id}}/logs")
|
||||||
|
async def job_logs(
|
||||||
|
job_id: str,
|
||||||
|
tail_lines: int | None = Query(default=200, ge=1, le=5000),
|
||||||
|
offset: int | None = Query(default=None, ge=0),
|
||||||
|
limit: int | None = Query(default=None, ge=1, le=5000),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if execution_mode() != "simulator":
|
||||||
|
job = process_manager.get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="job not found")
|
||||||
|
content = process_manager.logs(job_id)
|
||||||
|
else:
|
||||||
|
job = jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="job not found")
|
||||||
|
job = job_status(job)
|
||||||
|
content = job["logs"]
|
||||||
|
window = _slice_log_content(content, tail_lines, offset, limit)
|
||||||
|
metrics = [parse_log_line(line) for line in window["content"].splitlines()]
|
||||||
|
return {"job_id": job_id, **window, "metrics": [m for m in metrics if m]}
|
||||||
|
|
||||||
|
@app.post(f"{route_prefix}/compute/files/upload")
|
||||||
|
async def upload_file(
|
||||||
|
file: UploadFile | None = File(default=None),
|
||||||
|
target_relative_path: str | None = Form(default=None),
|
||||||
|
resource_type: str | None = Form(default=None),
|
||||||
|
resource_id: str | None = Form(default=None),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
file_id = f"file_{int(now() * 1000)}"
|
||||||
|
data_root = Path(os.getenv("YG_FT_DATA_ROOT", "/data/yg-ft"))
|
||||||
|
data_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
filename = Path(file.filename if file else file_id).name
|
||||||
|
if target_relative_path:
|
||||||
|
target = (data_root / target_relative_path.lstrip("/\\")).resolve()
|
||||||
|
if not _path_inside(data_root, target):
|
||||||
|
raise HTTPException(status_code=400, detail="target path must stay inside YG_FT_DATA_ROOT")
|
||||||
|
else:
|
||||||
|
target = data_root / "uploads" / f"{file_id}_{filename}"
|
||||||
|
if file:
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with target.open("wb") as output:
|
||||||
|
while chunk := await file.read(1024 * 1024):
|
||||||
|
output.write(chunk)
|
||||||
|
else:
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_text("", encoding="utf-8")
|
||||||
|
return {
|
||||||
|
"id": file_id,
|
||||||
|
"resource_type": resource_type,
|
||||||
|
"resource_id": resource_id,
|
||||||
|
"status": "available",
|
||||||
|
"local_path": str(target),
|
||||||
|
"byte_size": target.stat().st_size,
|
||||||
|
"checksum_sha256": hashlib.sha256(target.read_bytes()).hexdigest() if target.is_file() else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.post(f"{route_prefix}/compute/files/import-local")
|
||||||
|
async def import_local_file(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
source = Path(str(payload.get("source_path") or ""))
|
||||||
|
if not source.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="source path not found")
|
||||||
|
data_root = Path(os.getenv("YG_FT_DATA_ROOT", "/data/yg-ft"))
|
||||||
|
data_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
relative = str(payload.get("target_relative_path") or f"imports/{source.name}").lstrip("/\\")
|
||||||
|
target = (data_root / relative).resolve()
|
||||||
|
if not _path_inside(data_root, target):
|
||||||
|
raise HTTPException(status_code=400, detail="target path must stay inside YG_FT_DATA_ROOT")
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if source.is_dir():
|
||||||
|
if target.exists():
|
||||||
|
shutil.rmtree(target)
|
||||||
|
shutil.copytree(source, target)
|
||||||
|
byte_size = sum(path.stat().st_size for path in target.rglob("*") if path.is_file())
|
||||||
|
checksum = ""
|
||||||
|
else:
|
||||||
|
shutil.copy2(source, target)
|
||||||
|
byte_size = target.stat().st_size
|
||||||
|
checksum = hashlib.sha256(target.read_bytes()).hexdigest()
|
||||||
|
return {
|
||||||
|
"id": str(payload.get("id") or f"file_{int(now() * 1000)}"),
|
||||||
|
"resource_type": payload.get("resource_type"),
|
||||||
|
"resource_id": payload.get("resource_id"),
|
||||||
|
"status": "available",
|
||||||
|
"local_path": str(target),
|
||||||
|
"byte_size": byte_size,
|
||||||
|
"checksum_sha256": checksum,
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get(f"{route_prefix}/compute/files/{{file_id}}/download")
|
||||||
|
async def download_file(file_id: str) -> FileResponse:
|
||||||
|
upload_root = Path(os.getenv("YG_FT_DATA_ROOT", "/data/yg-ft")) / "uploads"
|
||||||
|
matches = list(upload_root.glob(f"{file_id}_*"))
|
||||||
|
if not matches:
|
||||||
|
raise HTTPException(status_code=404, detail="file not found")
|
||||||
|
return FileResponse(matches[0])
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
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."""
|
||||||
130
compute/engines/llama_factory/adapter.py
Normal file
130
compute/engines/llama_factory/adapter.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LlamaFactoryCommand:
|
||||||
|
command: list[str]
|
||||||
|
work_dir: str
|
||||||
|
env: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config(config: dict[str, Any]) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
if not config.get("base_model") and not config.get("model_name_or_path"):
|
||||||
|
errors.append("base_model or model_name_or_path is required")
|
||||||
|
if not config.get("dataset") and not config.get("dataset_dir"):
|
||||||
|
errors.append("dataset or dataset_dir is required")
|
||||||
|
try:
|
||||||
|
learning_rate = float(config.get("learning_rate", 0.0002))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
learning_rate = 0
|
||||||
|
if learning_rate <= 0:
|
||||||
|
errors.append("learning_rate must be greater than zero")
|
||||||
|
try:
|
||||||
|
epochs = int(config.get("n_epochs", config.get("num_train_epochs", 1)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
epochs = 0
|
||||||
|
if epochs <= 0:
|
||||||
|
errors.append("n_epochs must be greater than zero")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_arg(config: dict[str, Any], command: list[str], option: str, *keys: str) -> None:
|
||||||
|
for key in keys:
|
||||||
|
value = config.get(key)
|
||||||
|
if value is not None and value != "":
|
||||||
|
command.extend([option, str(value)])
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def build_command(config: dict[str, Any], llama_factory_home: str = "/app/LLaMA-Factory") -> LlamaFactoryCommand:
|
||||||
|
errors = validate_config(config)
|
||||||
|
if errors:
|
||||||
|
raise ValueError("; ".join(errors))
|
||||||
|
|
||||||
|
engine = str(config.get("engine") or config.get("training_engine") or "llama_factory")
|
||||||
|
if engine == "smoke":
|
||||||
|
output_dir = config.get("output_dir") or f"/data/yg-ft/outputs/{config.get('name', 'training-smoke')}"
|
||||||
|
script = (
|
||||||
|
"import json, os, time; "
|
||||||
|
f"out={str(output_dir)!r}; "
|
||||||
|
"os.makedirs(out, exist_ok=True); "
|
||||||
|
"print('[INFO] smoke training started', flush=True); "
|
||||||
|
"\nfor step in range(1, 7):\n"
|
||||||
|
" loss=round(1.8/(step+1), 4)\n"
|
||||||
|
" lr=round(0.0002*(1-step/10), 8)\n"
|
||||||
|
" print({'loss': loss, 'grad_norm': round(0.4 + step*0.03, 4), 'learning_rate': lr, 'epoch': round(step/6, 4)}, flush=True)\n"
|
||||||
|
" time.sleep(0.4)\n"
|
||||||
|
"\nopen(os.path.join(out, 'adapter_config.json'), 'w', encoding='utf-8').write(json.dumps({'engine':'smoke','status':'completed'})); "
|
||||||
|
"print('***** train metrics *****', flush=True); "
|
||||||
|
"print('train_loss = 0.12', flush=True); "
|
||||||
|
"print('***** train metrics end *****', flush=True)"
|
||||||
|
)
|
||||||
|
return LlamaFactoryCommand(command=["python", "-u", "-c", script], work_dir="/app", env={})
|
||||||
|
|
||||||
|
model_path = config.get("base_model") or config.get("model_name_or_path")
|
||||||
|
dataset = config.get("dataset") or config.get("dataset_name")
|
||||||
|
dataset_dir = config.get("dataset_dir")
|
||||||
|
output_dir = config.get("output_dir") or f"/data/yg-ft/outputs/{config.get('name', 'training-job')}"
|
||||||
|
command = [
|
||||||
|
"llamafactory-cli",
|
||||||
|
"train",
|
||||||
|
"--stage",
|
||||||
|
str(config.get("stage", "sft")).lower(),
|
||||||
|
"--do_train",
|
||||||
|
"true",
|
||||||
|
"--model_name_or_path",
|
||||||
|
str(model_path),
|
||||||
|
"--dataset",
|
||||||
|
str(dataset or "default"),
|
||||||
|
"--template",
|
||||||
|
str(config.get("template", "qwen")),
|
||||||
|
"--finetuning_type",
|
||||||
|
str(config.get("train_method", config.get("finetuning_type", "lora"))),
|
||||||
|
"--output_dir",
|
||||||
|
str(output_dir),
|
||||||
|
"--per_device_train_batch_size",
|
||||||
|
str(config.get("batch_size", 2)),
|
||||||
|
"--learning_rate",
|
||||||
|
str(config.get("learning_rate", 0.0002)),
|
||||||
|
"--num_train_epochs",
|
||||||
|
str(config.get("n_epochs", 3)),
|
||||||
|
"--save_steps",
|
||||||
|
str(config.get("save_steps", 50)),
|
||||||
|
"--logging_steps",
|
||||||
|
str(config.get("logging_steps", 10)),
|
||||||
|
"--overwrite_output_dir",
|
||||||
|
"true",
|
||||||
|
"--plot_loss",
|
||||||
|
"true",
|
||||||
|
]
|
||||||
|
if dataset_dir:
|
||||||
|
command.extend(["--dataset_dir", str(dataset_dir)])
|
||||||
|
_optional_arg(config, command, "--cutoff_len", "max_length", "cutoff_len")
|
||||||
|
_optional_arg(config, command, "--lr_scheduler_type", "lr_scheduler_type")
|
||||||
|
_optional_arg(config, command, "--warmup_ratio", "warmup_ratio")
|
||||||
|
_optional_arg(config, command, "--weight_decay", "weight_decay")
|
||||||
|
_optional_arg(config, command, "--lora_rank", "lora_rank", "rank")
|
||||||
|
_optional_arg(config, command, "--lora_alpha", "lora_alpha")
|
||||||
|
_optional_arg(config, command, "--lora_dropout", "lora_dropout")
|
||||||
|
quantization_bit = int(config.get("quantization_bit", 0) or 0)
|
||||||
|
if quantization_bit in {4, 8}:
|
||||||
|
command.extend(["--quantization_bit", str(quantization_bit)])
|
||||||
|
return LlamaFactoryCommand(command=command, work_dir=str(Path(llama_factory_home)), env={})
|
||||||
|
|
||||||
|
|
||||||
|
def parse_log_line(line: str) -> dict[str, float] | None:
|
||||||
|
if "loss" not in line or "learning_rate" not in line:
|
||||||
|
return None
|
||||||
|
result: dict[str, float] = {}
|
||||||
|
for key in ["loss", "grad_norm", "learning_rate", "epoch"]:
|
||||||
|
match = re.search(rf"['\"]?{key}['\"]?\s*:\s*([-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)", line)
|
||||||
|
if match:
|
||||||
|
result[key] = float(match.group(1))
|
||||||
|
return result or None
|
||||||
|
|
||||||
1
compute/file_gateway/__init__.py
Normal file
1
compute/file_gateway/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Local file gateway package."""
|
||||||
6
compute/requirements.txt
Normal file
6
compute/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
fastapi>=0.111.0
|
||||||
|
uvicorn[standard]>=0.30.0
|
||||||
|
python-multipart>=0.0.9
|
||||||
|
pydantic>=2.7.0
|
||||||
|
python-dotenv>=1.0.1
|
||||||
|
httpx>=0.27.0
|
||||||
1
compute/tests/__init__.py
Normal file
1
compute/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Compute platform tests package."""
|
||||||
299
docker/README.md
Normal file
299
docker/README.md
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
# Docker 部署说明
|
||||||
|
|
||||||
|
本目录按应用服务器和算力服务器拆分 Dockerfile 与 Docker Compose 文件。Compose 文件不包含 `build:`,不会在 `docker compose up` 时自动构建业务镜像。所有业务镜像需要先通过手动 `docker build` 构建,再由 Compose 启动。
|
||||||
|
|
||||||
|
## 基础镜像
|
||||||
|
|
||||||
|
| 镜像 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `python:3.12-slim` | 应用后端基础镜像,后端运行环境要求 Python 3.12 及以上 |
|
||||||
|
| `nginx:1.27-alpine` | 前端静态资源与 `/modelTF` 反向代理运行镜像 |
|
||||||
|
| `hiyouga/llamafactory:latest` | 算力服务基础镜像,基于 LLaMA-Factory 官方镜像扩展 Compute API |
|
||||||
|
| `postgres:16-alpine` | 开发阶段内置 PostgreSQL |
|
||||||
|
| `redis:7-alpine` | 开发阶段内置 Redis |
|
||||||
|
|
||||||
|
一键拉取基础镜像:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull python:3.12-slim && \
|
||||||
|
docker pull nginx:1.27-alpine && \
|
||||||
|
docker pull hiyouga/llamafactory:latest && \
|
||||||
|
docker pull postgres:16-alpine && \
|
||||||
|
docker pull redis:7-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$images = @(
|
||||||
|
"python:3.12-slim",
|
||||||
|
"nginx:1.27-alpine",
|
||||||
|
"hiyouga/llamafactory:latest",
|
||||||
|
"postgres:16-alpine",
|
||||||
|
"redis:7-alpine"
|
||||||
|
)
|
||||||
|
$images | ForEach-Object { docker pull $_ }
|
||||||
|
```
|
||||||
|
|
||||||
|
如果部署环境不能访问外网,需要提前在可联网环境执行上述拉取命令,再用 `docker save` / `docker load` 导出导入。
|
||||||
|
|
||||||
|
## 业务镜像
|
||||||
|
|
||||||
|
| 镜像 | Dockerfile | 构建命令 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `yg-ft-backend-api:latest` | `docker/app/Dockerfile.backend` | `docker build -f docker/app/Dockerfile.backend -t yg-ft-backend-api:latest .` |
|
||||||
|
| `yg-ft-frontend-runtime:latest` | `docker/app/Dockerfile.frontend` | `docker build -f docker/app/Dockerfile.frontend -t yg-ft-frontend-runtime:latest .` |
|
||||||
|
| `yg-ft-compute-api:latest` | `docker/compute/Dockerfile.compute` | `docker build -f docker/compute/Dockerfile.compute -t yg-ft-compute-api:latest .` |
|
||||||
|
|
||||||
|
## 对外端口
|
||||||
|
|
||||||
|
所有宿主机对外端口统一使用 5 位端口。容器内部端口保持镜像默认端口,便于容器内服务和健康检查稳定。
|
||||||
|
|
||||||
|
| 服务 | 宿主机对外端口 | 容器内部端口 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 前端 Nginx | `16801` | `80` | 前端页面入口 |
|
||||||
|
| 后端 API | `17861` | `8000` | FastAPI 服务 |
|
||||||
|
| PostgreSQL | `15432` | `5432` | 开发阶段内置数据库 |
|
||||||
|
| Redis | `16379` | `6379` | 开发阶段内置缓存 |
|
||||||
|
| Compute API | `19100` | `9100` | 算力服务器 API |
|
||||||
|
| File Gateway | `19101` | `9100` | 当前由 Compute API 暴露文件网关契约,后续可拆为独立服务 |
|
||||||
|
|
||||||
|
注意:`8000` 是后端容器内部端口,不作为宿主机对外访问端口。宿主机或浏览器应访问 `http://<app-server-ip>:17861/modelTF/health`;前端 Nginx 容器在 Docker 网络内部访问 `http://backend-api:8000/modelTF/...`。
|
||||||
|
|
||||||
|
对应配置文件:
|
||||||
|
|
||||||
|
- `docker/app/.env.example`
|
||||||
|
- `FRONTEND_PORT=16801`
|
||||||
|
- `BACKEND_API_PORT=17861`
|
||||||
|
- `POSTGRES_PORT=15432`
|
||||||
|
- `REDIS_PORT=16379`
|
||||||
|
- `docker/compute/.env.example`
|
||||||
|
- `COMPUTE_API_PORT=19100`
|
||||||
|
- `FILE_GATEWAY_PORT=19101`
|
||||||
|
|
||||||
|
## 运行模式
|
||||||
|
|
||||||
|
- 应用侧默认 `COMPUTE_MODE=real`,任务状态必须由真实算力同步逻辑更新。
|
||||||
|
- 算力侧默认 `COMPUTE_EXECUTION_MODE=real`,真实执行器未完成前不会伪造训练作业。
|
||||||
|
- 仅隔离联调时可显式设置 `COMPUTE_MODE=simulator` 或 `COMPUTE_EXECUTION_MODE=simulator`,该模式不得用于测试环境、生产环境或生产升级基线。
|
||||||
|
|
||||||
|
## 应用服务器部署
|
||||||
|
|
||||||
|
应用服务器包含前端 Nginx、Backend API、PostgreSQL、Redis。
|
||||||
|
|
||||||
|
当前 Compose 内置 PostgreSQL 使用 `backend/app/db/sql/001_platform_runtime.sql` 初始化运行库。`docs/postgres-schema.sql` 是完整目标架构设计,不应直接挂载为当前运行库初始化脚本,否则会与当前后端代码的运行表结构不兼容。
|
||||||
|
|
||||||
|
首次部署:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <repo-root>
|
||||||
|
|
||||||
|
# 1. 使用当前 Windows/宿主机 npm 构建前端静态产物
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 2. 手动构建业务镜像
|
||||||
|
docker build -f docker/app/Dockerfile.backend -t yg-ft-backend-api:latest .
|
||||||
|
docker build -f docker/app/Dockerfile.frontend -t yg-ft-frontend-runtime:latest .
|
||||||
|
|
||||||
|
# 3. 启动应用服务
|
||||||
|
cd docker/app
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
后端镜像构建过程中会执行依赖导入自检,确认 `fastapi`、`uvicorn`、`psycopg`、`sqlalchemy`、`redis` 等运行依赖已安装。构建后也可以手动检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm yg-ft-backend-api:latest python -c "import psycopg; print(psycopg.__version__)"
|
||||||
|
```
|
||||||
|
|
||||||
|
默认访问地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://<app-server-ip>:16801
|
||||||
|
```
|
||||||
|
|
||||||
|
应用侧代码和数据外挂:
|
||||||
|
|
||||||
|
```text
|
||||||
|
../../backend -> /app
|
||||||
|
../../frontend/dist -> /usr/share/nginx/html
|
||||||
|
../../runtime/app/logs/backend -> /opt/yg-ft/logs/backend
|
||||||
|
../../runtime/app/data -> /data/yg-ft
|
||||||
|
```
|
||||||
|
|
||||||
|
前端容器启动前必须确保 `../../frontend/dist/index.html` 已存在。若前端 Nginx 日志出现 `directory index of "/usr/share/nginx/html/" is forbidden` 或 `rewrite or internal redirection cycle while internally redirecting to "/index.html"`,通常表示当前执行 `docker compose` 的项目目录下没有构建好的 `frontend/dist`,或挂载路径不是同一份代码目录。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在执行 docker compose 的同一份代码目录中检查
|
||||||
|
cd <repo-root>/frontend
|
||||||
|
npm run build
|
||||||
|
test -f dist/index.html && ls -lh dist/index.html
|
||||||
|
|
||||||
|
cd ../docker/app
|
||||||
|
docker compose up -d --force-recreate frontend
|
||||||
|
docker compose logs --tail=80 frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
如果使用 Windows npm 构建、WSL 中运行 Docker Compose,需要确认 Windows 路径和 WSL 路径指向同一份仓库。例如在 `D:\...\YG_FT\frontend` 构建不会自动生成 `/mnt/d/wuyongtao/Code/YG_FT/frontend/dist` 下的产物,除非二者本就是同一个目录。
|
||||||
|
|
||||||
|
如果使用企业统一 PostgreSQL/Redis,修改 `docker/app/.env`:
|
||||||
|
|
||||||
|
如果前端 Nginx 日志出现 `open() "/usr/share/nginx/html/modelTF/login" failed` 或 `open() "/usr/share/nginx/html/login" failed`,说明当前容器没有加载项目的 Nginx 代理配置,`/modelTF/*` 被当成静态文件查找。处理方式:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <repo-root>/docker/app
|
||||||
|
docker compose up -d --force-recreate frontend
|
||||||
|
docker compose exec frontend nginx -T | grep -n "location.*modelTF" -A12
|
||||||
|
```
|
||||||
|
|
||||||
|
正常配置中应存在 `location ^~ /modelTF/`,并代理到 `BACKEND_PROXY_PASS`,默认是 `http://backend-api:8000`。
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgresql+psycopg://<user>:<password>@<postgres-host>:15432/<db>
|
||||||
|
REDIS_URL=redis://<redis-host>:16379/0
|
||||||
|
USE_BUILTIN_POSTGRES=false
|
||||||
|
USE_BUILTIN_REDIS=false
|
||||||
|
```
|
||||||
|
|
||||||
|
生产环境如完全使用外部基础设施,可以删除或注释 Compose 中的 `postgres`、`redis` 服务及 `backend-api.depends_on` 中对应依赖。
|
||||||
|
|
||||||
|
## 算力服务器部署
|
||||||
|
|
||||||
|
算力服务器包含 Compute API、后续 Compute Agent、File Gateway、GPU runtime、本地训练数据目录和 LLaMA-Factory。`Dockerfile.compute` 基于 LLaMA-Factory 官方镜像:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM hiyouga/llamafactory:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
部署前需要安装:
|
||||||
|
|
||||||
|
- NVIDIA Driver
|
||||||
|
- NVIDIA Container Toolkit
|
||||||
|
- Docker Engine 和 Docker Compose Plugin
|
||||||
|
- 本地训练数据目录,默认 `/data/yg-ft`
|
||||||
|
|
||||||
|
首次部署:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <repo-root>
|
||||||
|
|
||||||
|
# 手动构建算力业务镜像
|
||||||
|
docker build -f docker/compute/Dockerfile.compute -t yg-ft-compute-api:latest .
|
||||||
|
|
||||||
|
# 启动算力服务
|
||||||
|
cd docker/compute
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
健康检查:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET http://<compute-server-ip>:19100/modelTF/health
|
||||||
|
GET http://<compute-server-ip>:19100/modelTF/v1/compute/health
|
||||||
|
```
|
||||||
|
|
||||||
|
算力侧代码和数据外挂:
|
||||||
|
|
||||||
|
```text
|
||||||
|
../../compute -> /app/compute
|
||||||
|
${YG_FT_DATA_ROOT_HOST} -> /data/yg-ft
|
||||||
|
${YG_FT_MODEL_ROOT_HOST} -> /data/yg-ft/models
|
||||||
|
${YG_FT_DATASET_ROOT_HOST} -> /data/yg-ft/datasets
|
||||||
|
${YG_FT_OUTPUT_ROOT_HOST} -> /data/yg-ft/outputs
|
||||||
|
${COMPUTE_LOG_ROOT_HOST} -> /opt/yg-ft/logs/compute
|
||||||
|
${TRAINING_LOG_ROOT_HOST} -> /opt/yg-ft/logs/training
|
||||||
|
```
|
||||||
|
|
||||||
|
算力服务器启动前必须先在宿主机创建持久化目录,基座模型、训练数据、训练产物和训练日志都应落在宿主机磁盘上,不能只写入容器层。推荐默认目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <repo-root>/docker/compute
|
||||||
|
mkdir -p data/yg-ft/models \
|
||||||
|
data/yg-ft/datasets \
|
||||||
|
data/yg-ft/outputs \
|
||||||
|
data/yg-ft/logs/compute \
|
||||||
|
data/yg-ft/logs/training
|
||||||
|
```
|
||||||
|
|
||||||
|
默认 `docker/compute/.env.example` 使用 `./data/yg-ft`,该相对路径以 `docker/compute/docker-compose.yml` 所在目录为基准,因此实际宿主机目录是 `<repo-root>/docker/compute/data/yg-ft`。如企业环境模型盘、数据盘、产物盘分盘挂载,可在 `docker/compute/.env` 中分别调整 `YG_FT_MODEL_ROOT_HOST`、`YG_FT_DATASET_ROOT_HOST`、`YG_FT_OUTPUT_ROOT_HOST`、`COMPUTE_LOG_ROOT_HOST`、`TRAINING_LOG_ROOT_HOST`,容器内路径建议保持 `/data/yg-ft/models`、`/data/yg-ft/datasets`、`/data/yg-ft/outputs`,避免训练参数和节点配置复杂化。
|
||||||
|
|
||||||
|
页面上传数据集时,文件先进入 Backend API,再由 Backend API 调用目标算力节点的 `POST /modelTF/compute/files/upload`,写入容器内 `/data/yg-ft/datasets/{dataset_id}/`。在默认开发配置下,宿主机可在 `<repo-root>/docker/compute/data/yg-ft/datasets/{dataset_id}/` 看到对应文件。仅创建 bind mount 不会自动让应用侧上传文件出现在算力目录,必须通过这条 File Gateway 链路同步。
|
||||||
|
|
||||||
|
## 应用与算力分离部署
|
||||||
|
|
||||||
|
应用服务器只需要主动访问算力服务器,不要求算力服务器回调应用服务器。
|
||||||
|
|
||||||
|
在 `docker/app/.env` 中配置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
COMPUTE_API_BASE_URL=http://<compute-server-ip>:19100
|
||||||
|
FILE_GATEWAY_BASE_URL=http://<compute-server-ip>:19101
|
||||||
|
COMPUTE_SERVICE_TOKEN=change_me
|
||||||
|
COMPUTE_STATUS_SYNC_MODE=polling
|
||||||
|
COMPUTE_POLL_INTERVAL_SECONDS=3
|
||||||
|
COMPUTE_POLL_BATCH_SIZE=100
|
||||||
|
```
|
||||||
|
|
||||||
|
交互链路:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Frontend
|
||||||
|
-> Backend API
|
||||||
|
-> Compute API
|
||||||
|
-> Compute Agent / LLaMA-Factory
|
||||||
|
-> 本地数据目录 / 模型目录 / 训练产物
|
||||||
|
<- Backend Worker 定时轮询 Compute API
|
||||||
|
```
|
||||||
|
|
||||||
|
算力服务默认开启服务间鉴权。`docker/compute/.env` 中保持 `COMPUTE_AUTH_ENABLED=true`,并确保 `COMPUTE_SERVICE_TOKEN` 与 `docker/app/.env` 一致;健康检查路径仍可用于容器探活。
|
||||||
|
|
||||||
|
## 多算力节点部署
|
||||||
|
|
||||||
|
多算力节点仍按“单机多 GPU 节点”部署。每台 GPU 服务器都独立部署一套 `docker/compute`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gpu-node-01: docker/compute + /data/yg-ft + 19100/19101
|
||||||
|
gpu-node-02: docker/compute + /data/yg-ft + 19100/19101
|
||||||
|
gpu-node-03: docker/compute + /data/yg-ft + 19100/19101
|
||||||
|
```
|
||||||
|
|
||||||
|
节点之间默认不互访。应用平台主动访问每个节点的 Compute API/File Gateway,并通过 `compute_nodes`、`resource_replicas`、`resource_sync_jobs` 统一调度和同步。
|
||||||
|
|
||||||
|
节点地址、权重、标签、启用状态和本地路径在前端“算力节点”页面动态维护。新增或编辑节点后,点击“测试”会由 Backend API 主动访问该节点的 `GET /modelTF/v1/compute/health` 和 `GET /modelTF/compute/resources/gpus`,并把健康信息与 GPU 清单同步到 PostgreSQL。
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
重新构建应用镜像:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f docker/app/Dockerfile.backend -t yg-ft-backend-api:latest .
|
||||||
|
docker build -f docker/app/Dockerfile.frontend -t yg-ft-frontend-runtime:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
重新构建算力镜像:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f docker/compute/Dockerfile.compute -t yg-ft-compute-api:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
启动服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docker/app
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
cd ../compute
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
查看服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
46
docker/app/.env.example
Normal file
46
docker/app/.env.example
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
APP_ENV=prod
|
||||||
|
APP_NAME=YG Fine-Tune Platform API
|
||||||
|
MODELTF_ROUTE_PREFIX=/modelTF
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:16801,http://127.0.0.1:16801
|
||||||
|
|
||||||
|
FRONTEND_IMAGE=yg-ft-frontend-runtime:latest
|
||||||
|
BACKEND_API_IMAGE=yg-ft-backend-api:latest
|
||||||
|
|
||||||
|
# Five-digit host ports exposed outside the application server.
|
||||||
|
FRONTEND_PORT=16801
|
||||||
|
BACKEND_API_PORT=17861
|
||||||
|
POSTGRES_PORT=15432
|
||||||
|
REDIS_PORT=16379
|
||||||
|
|
||||||
|
POSTGRES_DB=yg_ft
|
||||||
|
POSTGRES_USER=yg_ft
|
||||||
|
POSTGRES_PASSWORD=change_me
|
||||||
|
DATABASE_URL=postgresql+psycopg://yg_ft:change_me@postgres:5432/yg_ft
|
||||||
|
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
|
# Development uses the built-in PostgreSQL/Redis services in docker-compose.yml.
|
||||||
|
# For enterprise infrastructure, replace DATABASE_URL/REDIS_URL and remove or disable those services.
|
||||||
|
USE_BUILTIN_POSTGRES=true
|
||||||
|
USE_BUILTIN_REDIS=true
|
||||||
|
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_DIR=/opt/yg-ft/logs/backend
|
||||||
|
LOG_FILE_PREFIX=backend
|
||||||
|
LOG_ERROR_FILE_PREFIX=error
|
||||||
|
LOG_MAX_BYTES=20971520
|
||||||
|
LOG_RETENTION_DAYS=10
|
||||||
|
|
||||||
|
BACKEND_PROXY_PASS=http://backend-api:8000
|
||||||
|
|
||||||
|
# Split deployment: set these to the compute server address, for example http://10.10.20.31:19100.
|
||||||
|
COMPUTE_API_BASE_URL=http://compute-api:9100
|
||||||
|
COMPUTE_SERVICE_TOKEN=change_me
|
||||||
|
FILE_GATEWAY_BASE_URL=http://compute-api:9101
|
||||||
|
|
||||||
|
# The application side polls Compute API for job state to avoid opening reverse network access.
|
||||||
|
COMPUTE_MODE=real
|
||||||
|
COMPUTE_STATUS_SYNC_MODE=polling
|
||||||
|
COMPUTE_POLL_INTERVAL_SECONDS=3
|
||||||
|
COMPUTE_POLL_BATCH_SIZE=100
|
||||||
|
COMPUTE_REQUEST_TIMEOUT_SECONDS=5
|
||||||
21
docker/app/Dockerfile.backend
Normal file
21
docker/app/Dockerfile.backend
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY backend/requirements.txt /tmp/requirements.txt
|
||||||
|
RUN pip install --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||||
|
&& pip install -r /tmp/requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||||
|
&& rm -f /tmp/requirements.txt
|
||||||
|
|
||||||
|
RUN python -c "import fastapi, uvicorn, psycopg, sqlalchemy, redis, jwt, passlib, httpx, alembic; print('backend dependency check ok')"
|
||||||
|
|
||||||
|
RUN mkdir -p /opt/yg-ft/logs/backend /data/yg-ft \
|
||||||
|
&& chmod -R 0775 /opt/yg-ft /data/yg-ft
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
9
docker/app/Dockerfile.frontend
Normal file
9
docker/app/Dockerfile.frontend
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
RUN mkdir -p /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||||
|
CMD test -f /usr/share/nginx/html/index.html && wget -qO- http://127.0.0.1/index.html >/dev/null || exit 1
|
||||||
126
docker/app/docker-compose.yml
Normal file
126
docker/app/docker-compose.yml
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
image: ${FRONTEND_IMAGE:-yg-ft-frontend-runtime:latest}
|
||||||
|
container_name: yg-ft-frontend
|
||||||
|
depends_on:
|
||||||
|
backend-api:
|
||||||
|
condition: service_started
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT:-16801}:80"
|
||||||
|
environment:
|
||||||
|
BACKEND_PROXY_PASS: ${BACKEND_PROXY_PASS:-http://backend-api:8000}
|
||||||
|
volumes:
|
||||||
|
- ../../frontend/dist:/usr/share/nginx/html:ro
|
||||||
|
- ../nginx.conf.template:/etc/nginx/templates/default.conf.template:ro
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
if [ ! -f /usr/share/nginx/html/index.html ]; then
|
||||||
|
echo "frontend dist is missing: build frontend first and ensure ../../frontend/dist is mounted";
|
||||||
|
ls -la /usr/share/nginx/html;
|
||||||
|
exit 1;
|
||||||
|
fi;
|
||||||
|
envsubst '$$BACKEND_PROXY_PASS' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf;
|
||||||
|
nginx -t;
|
||||||
|
nginx -g 'daemon off;'
|
||||||
|
networks:
|
||||||
|
- yg-ft-app
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend-api:
|
||||||
|
image: ${BACKEND_API_IMAGE:-yg-ft-backend-api:latest}
|
||||||
|
container_name: yg-ft-backend-api
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_API_PORT:-17861}:8000"
|
||||||
|
environment:
|
||||||
|
APP_ENV: ${APP_ENV:-prod}
|
||||||
|
APP_NAME: ${APP_NAME:-YG Fine-Tune Platform API}
|
||||||
|
MODELTF_ROUTE_PREFIX: ${MODELTF_ROUTE_PREFIX:-/modelTF}
|
||||||
|
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-http://localhost:16801,http://127.0.0.1:16801}
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://yg_ft:change_me@postgres:5432/yg_ft}
|
||||||
|
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
|
||||||
|
USE_BUILTIN_POSTGRES: ${USE_BUILTIN_POSTGRES:-true}
|
||||||
|
USE_BUILTIN_REDIS: ${USE_BUILTIN_REDIS:-true}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
LOG_DIR: ${LOG_DIR:-/opt/yg-ft/logs/backend}
|
||||||
|
LOG_FILE_PREFIX: ${LOG_FILE_PREFIX:-backend}
|
||||||
|
LOG_ERROR_FILE_PREFIX: ${LOG_ERROR_FILE_PREFIX:-error}
|
||||||
|
LOG_MAX_BYTES: ${LOG_MAX_BYTES:-20971520}
|
||||||
|
LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-10}
|
||||||
|
COMPUTE_API_BASE_URL: ${COMPUTE_API_BASE_URL:-http://compute-api:9100}
|
||||||
|
COMPUTE_SERVICE_TOKEN: ${COMPUTE_SERVICE_TOKEN:-change_me}
|
||||||
|
FILE_GATEWAY_BASE_URL: ${FILE_GATEWAY_BASE_URL:-http://compute-api:9101}
|
||||||
|
COMPUTE_MODE: ${COMPUTE_MODE:-real}
|
||||||
|
COMPUTE_STATUS_SYNC_MODE: ${COMPUTE_STATUS_SYNC_MODE:-polling}
|
||||||
|
COMPUTE_POLL_INTERVAL_SECONDS: ${COMPUTE_POLL_INTERVAL_SECONDS:-3}
|
||||||
|
COMPUTE_POLL_BATCH_SIZE: ${COMPUTE_POLL_BATCH_SIZE:-100}
|
||||||
|
COMPUTE_REQUEST_TIMEOUT_SECONDS: ${COMPUTE_REQUEST_TIMEOUT_SECONDS:-5}
|
||||||
|
PYTHONPATH: /app
|
||||||
|
volumes:
|
||||||
|
- ../../backend:/app:ro
|
||||||
|
- ../../runtime/app/logs/backend:/opt/yg-ft/logs/backend
|
||||||
|
- ../../runtime/app/data:/data/yg-ft
|
||||||
|
networks:
|
||||||
|
- yg-ft-app
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/modelTF/health', timeout=3).read()\""]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: yg-ft-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-yg_ft}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-yg_ft}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change_me}
|
||||||
|
PGDATA: /var/lib/postgresql/data/pgdata
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ../../backend/app/db/sql/001_platform_runtime.sql:/docker-entrypoint-initdb.d/001-platform-runtime.sql:ro
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-15432}:5432"
|
||||||
|
networks:
|
||||||
|
- yg-ft-app
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: yg-ft-redis
|
||||||
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
ports:
|
||||||
|
- "${REDIS_PORT:-16379}:6379"
|
||||||
|
networks:
|
||||||
|
- yg-ft-app
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
yg-ft-app:
|
||||||
|
name: yg-ft-app
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
44
docker/compute/.env.example
Normal file
44
docker/compute/.env.example
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
COMPUTE_ENV=prod
|
||||||
|
COMPUTE_HOST_ID=gpu-node-01
|
||||||
|
COMPUTE_EXECUTION_MODE=real
|
||||||
|
MODELTF_ROUTE_PREFIX=/modelTF
|
||||||
|
# Five-digit host ports exposed outside the compute server.
|
||||||
|
COMPUTE_API_PORT=19100
|
||||||
|
FILE_GATEWAY_PORT=19101
|
||||||
|
COMPUTE_API_IMAGE=yg-ft-compute-api:latest
|
||||||
|
|
||||||
|
# The application server actively polls Compute API; compute server does not need reverse access.
|
||||||
|
COMPUTE_AUTH_ENABLED=true
|
||||||
|
COMPUTE_SERVICE_TOKEN=change_me
|
||||||
|
ENABLE_APP_CALLBACK=false
|
||||||
|
|
||||||
|
# LLaMA-Factory is provided by the official hiyouga/llamafactory base image.
|
||||||
|
LLAMA_FACTORY_HOME=/app/LLaMA-Factory
|
||||||
|
|
||||||
|
# Persistent host directories on the compute server.
|
||||||
|
# Create these directories before starting docker compose. They are mounted into
|
||||||
|
# the container so base models, datasets, training outputs and logs survive
|
||||||
|
# container recreation or image upgrades.
|
||||||
|
YG_FT_DATA_ROOT=/data/yg-ft
|
||||||
|
YG_FT_DATA_ROOT_HOST=./data/yg-ft
|
||||||
|
YG_FT_MODEL_ROOT=/data/yg-ft/models
|
||||||
|
YG_FT_MODEL_ROOT_HOST=./data/yg-ft/models
|
||||||
|
YG_FT_DATASET_ROOT=/data/yg-ft/datasets
|
||||||
|
YG_FT_DATASET_ROOT_HOST=./data/yg-ft/datasets
|
||||||
|
YG_FT_OUTPUT_ROOT=/data/yg-ft/outputs
|
||||||
|
YG_FT_OUTPUT_ROOT_HOST=./data/yg-ft/outputs
|
||||||
|
TRAINING_LOG_ROOT=/opt/yg-ft/logs/training
|
||||||
|
TRAINING_LOG_ROOT_HOST=./data/yg-ft/logs/training
|
||||||
|
COMPUTE_LOG_ROOT_HOST=./data/yg-ft/logs/compute
|
||||||
|
|
||||||
|
# Optional fallback used when nvidia-smi is unavailable.
|
||||||
|
# Leave COMPUTE_GPU_COUNT=0 on real GPU servers with working NVIDIA runtime.
|
||||||
|
COMPUTE_GPU_COUNT=0
|
||||||
|
COMPUTE_GPU_NAME=NVIDIA A800-SXM4-80GB
|
||||||
|
COMPUTE_GPU_MEMORY_GB=80
|
||||||
|
COMPUTE_GPU_POWER_LIMIT_W=300
|
||||||
|
|
||||||
|
LOG_DIR=/opt/yg-ft/logs/compute
|
||||||
|
CUDA_VISIBLE_DEVICES=all
|
||||||
|
NVIDIA_VISIBLE_DEVICES=all
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||||
27
docker/compute/Dockerfile.compute
Normal file
27
docker/compute/Dockerfile.compute
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
FROM hiyouga/llamafactory:latest
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends tini \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY compute/requirements.txt /tmp/requirements.txt
|
||||||
|
RUN pip install --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||||
|
&& pip install -r /tmp/requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||||
|
&& rm -f /tmp/requirements.txt
|
||||||
|
|
||||||
|
RUN mkdir -p /opt/yg-ft/logs/compute /opt/yg-ft/logs/training /data/yg-ft /app/LLaMA-Factory \
|
||||||
|
&& chmod -R 0775 /opt/yg-ft /data/yg-ft /app/LLaMA-Factory
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||||
|
|
||||||
|
EXPOSE 9100
|
||||||
|
|
||||||
|
CMD ["uvicorn", "compute.api.main:app", "--host", "0.0.0.0", "--port", "9100"]
|
||||||
38
docker/compute/data/yg-ft/README.md
Normal file
38
docker/compute/data/yg-ft/README.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# YG-FT Compute 数据目录说明
|
||||||
|
|
||||||
|
本目录挂载到 `yg-ft-compute-api` 容器的 `/data/yg-ft`,用于持久化存储训练相关的数据。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
data/yg-ft/
|
||||||
|
├── datasets/ # 数据集存储目录
|
||||||
|
├── models/ # 模型文件存储目录
|
||||||
|
├── outputs/ # 训练/推理输出结果目录
|
||||||
|
└── logs/
|
||||||
|
├── compute/ # 计算服务运行日志
|
||||||
|
└── training/ # 训练任务执行日志
|
||||||
|
```
|
||||||
|
|
||||||
|
## 各目录说明
|
||||||
|
|
||||||
|
### datasets/
|
||||||
|
训练和评估所使用的数据集文件,包括 JSON、JSONL、CSV 等格式。数据集由用户上传或通过平台创建,供 LLaMA-Factory 等训练引擎读取。
|
||||||
|
|
||||||
|
### models/
|
||||||
|
存放模型文件,包括:
|
||||||
|
- 预训练基座模型(如 LLaMA、Qwen 等)
|
||||||
|
- 微调后的自定义模型权重
|
||||||
|
- 合并后的部署模型
|
||||||
|
|
||||||
|
### outputs/
|
||||||
|
训练任务和推理任务的输出结果,包括:
|
||||||
|
- 训练过程中的 checkpoint 文件
|
||||||
|
- 评估结果和指标报告
|
||||||
|
- 推理生成的结果文本
|
||||||
|
|
||||||
|
### logs/compute/
|
||||||
|
计算服务(compute-api)的运行时日志,用于排查服务启动、GPU 调度、健康检查等问题。
|
||||||
|
|
||||||
|
### logs/training/
|
||||||
|
各训练任务的执行日志,记录训练过程状态、报错信息等,便于追踪单个任务的运行情况。
|
||||||
0
docker/compute/data/yg-ft/datasets/.gitkeep
Normal file
0
docker/compute/data/yg-ft/datasets/.gitkeep
Normal file
0
docker/compute/data/yg-ft/logs/compute/.gitkeep
Normal file
0
docker/compute/data/yg-ft/logs/compute/.gitkeep
Normal file
0
docker/compute/data/yg-ft/logs/training/.gitkeep
Normal file
0
docker/compute/data/yg-ft/logs/training/.gitkeep
Normal file
0
docker/compute/data/yg-ft/models/.gitkeep
Normal file
0
docker/compute/data/yg-ft/models/.gitkeep
Normal file
0
docker/compute/data/yg-ft/outputs/.gitkeep
Normal file
0
docker/compute/data/yg-ft/outputs/.gitkeep
Normal file
52
docker/compute/docker-compose.yml
Normal file
52
docker/compute/docker-compose.yml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
services:
|
||||||
|
compute-api:
|
||||||
|
image: ${COMPUTE_API_IMAGE:-yg-ft-compute-api:latest}
|
||||||
|
container_name: yg-ft-compute-api
|
||||||
|
gpus: all
|
||||||
|
ports:
|
||||||
|
- "${COMPUTE_API_PORT:-19100}:9100"
|
||||||
|
- "${FILE_GATEWAY_PORT:-19101}:9100"
|
||||||
|
environment:
|
||||||
|
COMPUTE_ENV: ${COMPUTE_ENV:-prod}
|
||||||
|
COMPUTE_HOST_ID: ${COMPUTE_HOST_ID:-gpu-node-01}
|
||||||
|
COMPUTE_EXECUTION_MODE: ${COMPUTE_EXECUTION_MODE:-real}
|
||||||
|
MODELTF_ROUTE_PREFIX: ${MODELTF_ROUTE_PREFIX:-/modelTF}
|
||||||
|
COMPUTE_AUTH_ENABLED: ${COMPUTE_AUTH_ENABLED:-true}
|
||||||
|
COMPUTE_SERVICE_TOKEN: ${COMPUTE_SERVICE_TOKEN:-change_me}
|
||||||
|
ENABLE_APP_CALLBACK: ${ENABLE_APP_CALLBACK:-false}
|
||||||
|
LLAMA_FACTORY_HOME: ${LLAMA_FACTORY_HOME:-/app/LLaMA-Factory}
|
||||||
|
YG_FT_DATA_ROOT: ${YG_FT_DATA_ROOT:-/data/yg-ft}
|
||||||
|
YG_FT_MODEL_ROOT: ${YG_FT_MODEL_ROOT:-/data/yg-ft/models}
|
||||||
|
YG_FT_DATASET_ROOT: ${YG_FT_DATASET_ROOT:-/data/yg-ft/datasets}
|
||||||
|
YG_FT_OUTPUT_ROOT: ${YG_FT_OUTPUT_ROOT:-/data/yg-ft/outputs}
|
||||||
|
TRAINING_LOG_ROOT: ${TRAINING_LOG_ROOT:-/opt/yg-ft/logs/training}
|
||||||
|
COMPUTE_GPU_COUNT: ${COMPUTE_GPU_COUNT:-0}
|
||||||
|
COMPUTE_GPU_NAME: ${COMPUTE_GPU_NAME:-NVIDIA A800-SXM4-80GB}
|
||||||
|
COMPUTE_GPU_MEMORY_GB: ${COMPUTE_GPU_MEMORY_GB:-80}
|
||||||
|
COMPUTE_GPU_POWER_LIMIT_W: ${COMPUTE_GPU_POWER_LIMIT_W:-300}
|
||||||
|
LOG_DIR: ${LOG_DIR:-/opt/yg-ft/logs/compute}
|
||||||
|
CUDA_VISIBLE_DEVICES: ${CUDA_VISIBLE_DEVICES:-all}
|
||||||
|
NVIDIA_VISIBLE_DEVICES: ${NVIDIA_VISIBLE_DEVICES:-all}
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES: ${NVIDIA_DRIVER_CAPABILITIES:-compute,utility}
|
||||||
|
PYTHONPATH: /app
|
||||||
|
volumes:
|
||||||
|
- ../../compute:/app/compute:ro
|
||||||
|
- ${YG_FT_DATA_ROOT_HOST:-./data/yg-ft}:${YG_FT_DATA_ROOT:-/data/yg-ft}
|
||||||
|
- ${YG_FT_MODEL_ROOT_HOST:-./data/yg-ft/models}:${YG_FT_MODEL_ROOT:-/data/yg-ft/models}
|
||||||
|
- ${YG_FT_DATASET_ROOT_HOST:-./data/yg-ft/datasets}:${YG_FT_DATASET_ROOT:-/data/yg-ft/datasets}
|
||||||
|
- ${YG_FT_OUTPUT_ROOT_HOST:-./data/yg-ft/outputs}:${YG_FT_OUTPUT_ROOT:-/data/yg-ft/outputs}
|
||||||
|
- ${COMPUTE_LOG_ROOT_HOST:-./data/yg-ft/logs/compute}:${LOG_DIR:-/opt/yg-ft/logs/compute}
|
||||||
|
- ${TRAINING_LOG_ROOT_HOST:-./data/yg-ft/logs/training}:${TRAINING_LOG_ROOT:-/opt/yg-ft/logs/training}
|
||||||
|
networks:
|
||||||
|
- yg-ft-compute
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:9100/modelTF/health', timeout=3).read()\""]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
yg-ft-compute:
|
||||||
|
name: yg-ft-compute
|
||||||
41
docker/nginx.conf.template
Normal file
41
docker/nginx.conf.template
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
client_max_body_size 200m;
|
||||||
|
|
||||||
|
location ^~ /modelTF/ {
|
||||||
|
proxy_pass ${BACKEND_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 = /modelTF {
|
||||||
|
proxy_pass ${BACKEND_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";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
1332
docs/backend-api-design.md
Normal file
1332
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=/modelTF/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` 贯穿链路。
|
||||||
431
docs/deployment-plan.md
Normal file
431
docs/deployment-plan.md
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
# 模型微调平台后期部署方案
|
||||||
|
|
||||||
|
本文档对应页面/功能模块:系统设置、算力资源、训练任务、任务详情、模型管理、数据集管理、审批中心、审计中心、运维监控。
|
||||||
|
|
||||||
|
## 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 适用场景
|
||||||
|
|
||||||
|
- 开发联调、单机试运行、资源受限的早期上线环境。
|
||||||
|
- 小团队共用一台单机多 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 | 17861 | 仅 Nginx、本机 |
|
||||||
|
| Compute API | 19100 | 仅 Backend API、本机 |
|
||||||
|
| File Gateway | 19101 | 仅 Backend API、本机 |
|
||||||
|
| PostgreSQL | 15432 | 本机或内网 |
|
||||||
|
| Redis | 16379 | 本机或内网 |
|
||||||
|
|
||||||
|
## 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["算力服务器本地磁盘"]
|
||||||
|
B -- "定时轮询任务状态/指标/产物索引" --> C
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 风险
|
||||||
|
|
||||||
|
- 文件传输链路比单机部署复杂。
|
||||||
|
- 需要处理跨服务器网络失败、轮询延迟、任务状态对账。
|
||||||
|
- 需要明确模型、数据集、产物在应用侧和算力侧的索引关系。
|
||||||
|
|
||||||
|
### 5.7 多算力节点部署约定
|
||||||
|
|
||||||
|
多算力节点阶段仍然按“单机多 GPU 节点”部署,每台 GPU 服务器都是一个独立算力节点。每个参与调度的节点都必须部署:
|
||||||
|
|
||||||
|
- Compute API。
|
||||||
|
- Compute Agent。
|
||||||
|
- File Gateway。
|
||||||
|
- LLaMA-Factory 宿主机目录和训练依赖。
|
||||||
|
- CUDA、NVIDIA Driver、NCCL、PyTorch。
|
||||||
|
- 本地数据盘 `/data/yg-ft`。
|
||||||
|
- 本地日志和训练产物目录。
|
||||||
|
|
||||||
|
网络策略保持单向:
|
||||||
|
|
||||||
|
```text
|
||||||
|
应用服务器 -> 算力节点 A Compute API/File Gateway
|
||||||
|
应用服务器 -> 算力节点 B Compute API/File Gateway
|
||||||
|
应用服务器 -> 算力节点 C Compute API/File Gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
默认不要求:
|
||||||
|
|
||||||
|
```text
|
||||||
|
算力节点 -> 应用服务器
|
||||||
|
算力节点 A -> 算力节点 B
|
||||||
|
```
|
||||||
|
|
||||||
|
多节点任务调度由应用平台统一完成。应用平台从 `compute_nodes` 读取节点地址、权重、标签、启用状态、维护状态和健康检查结果;从 `resource_replicas` 判断目标节点是否已有所需数据集/模型副本;缺失时创建 `resource_sync_jobs`,通过目标节点 File Gateway 同步资源。
|
||||||
|
|
||||||
|
当前实现已支持在 `/compute` 算力节点页面新增和编辑节点。运维人员维护 `Compute API` 地址、`File Gateway` 地址、权重、标签、启用状态、最大并发和本地路径后,点击连接测试会由应用后端主动访问目标节点健康检查和 GPU 清单接口,并将 `health_detail`、`gpu_count`、`gpu_devices/gpus` 同步到 PostgreSQL。真实 GPU 服务器优先通过 `nvidia-smi` 发现 GPU;特殊环境可用 `COMPUTE_GPU_COUNT` 等环境变量声明兼容清单。
|
||||||
|
|
||||||
|
训练运行闭环:
|
||||||
|
|
||||||
|
- 前端启动训练后,Backend API 按 `compute_nodes` 的启用状态、调度状态、权重和并行任务数选择节点。
|
||||||
|
- Backend API 向目标节点 `POST /modelTF/compute/jobs` 提交 LLaMA-Factory 训练作业,并在 `fine_tune_tasks.compute_job_id` 记录算力任务 ID。
|
||||||
|
- Compute API 在真实模式下启动 `llamafactory-cli train` 子进程,训练日志写入 `TRAINING_LOG_ROOT/{job_id}.log`。
|
||||||
|
- Backend API 启动后会运行应用侧轮询 worker,按 `COMPUTE_POLL_INTERVAL_SECONDS` 主动查询目标节点 `GET /modelTF/compute/jobs/{id}`,同步任务状态、进度、PID、输出目录、日志路径和产物索引。
|
||||||
|
- 停止训练时,Backend API 优先调用目标节点 `POST /modelTF/compute/jobs/{id}/stop`,再回写应用任务状态。
|
||||||
|
- 失败或停止任务可以通过 `POST /modelTF/compute/jobs/{id}/retry` 重试;重试会清空旧运行态,重新调度节点并创建 Compute Job。
|
||||||
|
- 训练日志通过 `GET /modelTF/compute/jobs/{id}/logs` 读取,支持 `tail_lines`、`offset`、`limit`,用于训练详情页、训练日志页和日志平台采集。
|
||||||
|
- Compute API 使用 `COMPUTE_SERVICE_TOKEN` 做服务间鉴权,应用侧请求携带 `X-Compute-Token`;健康检查接口保持可公开探活。
|
||||||
|
- Compute API 会把本机训练作业登记到 `TRAINING_LOG_ROOT/compute-jobs.json`,服务重启后可恢复任务索引并继续暴露状态和日志。
|
||||||
|
|
||||||
|
调度策略:
|
||||||
|
|
||||||
|
- 默认自动调度,按节点健康、标签、GPU 空闲、队列长度、节点权重和资源副本命中率排序。
|
||||||
|
- 支持管理员/高级用户手动指定节点或 GPU。
|
||||||
|
- `disabled` 节点不参与调度。
|
||||||
|
- `draining` 节点不接收新任务,但允许已有任务跑完。
|
||||||
|
- `maintenance/offline` 节点只允许查看和清理,不允许提交训练任务。
|
||||||
|
|
||||||
|
## 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
|
||||||
|
MODELTF_ROUTE_PREFIX=/modelTF
|
||||||
|
DATABASE_URL=postgresql+psycopg://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:19100
|
||||||
|
COMPUTE_SERVICE_TOKEN=***
|
||||||
|
FILE_GATEWAY_BASE_URL=https://compute.internal:19101
|
||||||
|
COMPUTE_STATUS_SYNC_MODE=polling
|
||||||
|
COMPUTE_POLL_INTERVAL_SECONDS=3
|
||||||
|
COMPUTE_POLL_BATCH_SIZE=100
|
||||||
|
```
|
||||||
|
|
||||||
|
算力平台:
|
||||||
|
|
||||||
|
```env
|
||||||
|
COMPUTE_ENV=prod
|
||||||
|
COMPUTE_HOST_ID=gpu-node-01
|
||||||
|
COMPUTE_API_PORT=19100
|
||||||
|
FILE_GATEWAY_PORT=19101
|
||||||
|
COMPUTE_AUTH_ENABLED=true
|
||||||
|
COMPUTE_SERVICE_TOKEN=***
|
||||||
|
ENABLE_APP_CALLBACK=false
|
||||||
|
LLAMA_FACTORY_HOME=/app/LLaMA-Factory
|
||||||
|
YG_FT_DATA_ROOT=/data/yg-ft
|
||||||
|
YG_FT_DATA_ROOT_HOST=./data/yg-ft
|
||||||
|
YG_FT_MODEL_ROOT=/data/yg-ft/models
|
||||||
|
YG_FT_MODEL_ROOT_HOST=./data/yg-ft/models
|
||||||
|
YG_FT_DATASET_ROOT=/data/yg-ft/datasets
|
||||||
|
YG_FT_DATASET_ROOT_HOST=./data/yg-ft/datasets
|
||||||
|
YG_FT_OUTPUT_ROOT=/data/yg-ft/outputs
|
||||||
|
YG_FT_OUTPUT_ROOT_HOST=./data/yg-ft/outputs
|
||||||
|
TRAINING_LOG_ROOT=/opt/yg-ft/logs/training
|
||||||
|
TRAINING_LOG_ROOT_HOST=./data/yg-ft/logs/training
|
||||||
|
COMPUTE_LOG_ROOT_HOST=./data/yg-ft/logs/compute
|
||||||
|
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 已初始化当前运行脚本 `backend/app/db/sql/001_platform_runtime.sql`;`docs/postgres-schema.sql` 作为目标架构设计,后续通过迁移体系逐步收敛。
|
||||||
|
- Redis 可连通。
|
||||||
|
- 后端 `GET /modelTF/health` 正常。
|
||||||
|
- Compute API 健康检查正常。
|
||||||
|
- Compute Agent 能识别 GPU、显存、CUDA 版本。
|
||||||
|
- LLaMA-Factory 能在命令行完成最小训练作业。
|
||||||
|
- 应用平台能提交训练任务到 Compute API。
|
||||||
|
- 任务状态能从算力平台同步回应用平台。
|
||||||
|
- 数据集上传、离线导入、产物下载路径权限正确。
|
||||||
|
- 后端 JSON 日志可被日志平台解析。
|
||||||
|
- ERROR 日志能触发告警。
|
||||||
|
- 日志、数据集、模型、产物所在磁盘容量有监控和告警。
|
||||||
|
|
||||||
|
## 11. Docker Compose 文件规划
|
||||||
|
|
||||||
|
当前项目按应用服务器和算力服务器拆分了两套 Docker 部署文件,均采用代码外挂方式运行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docker/
|
||||||
|
app/
|
||||||
|
Dockerfile.backend # Backend API 运行时镜像,代码通过 volume 挂载到 /app
|
||||||
|
Dockerfile.frontend # Nginx 前端运行时镜像,frontend/dist 通过 volume 挂载
|
||||||
|
docker-compose.yml # 应用服务器:frontend、backend-api、postgres、redis
|
||||||
|
.env.example
|
||||||
|
compute/
|
||||||
|
Dockerfile.compute # CUDA + Python + Compute API 运行时镜像
|
||||||
|
docker-compose.yml # 算力服务器:compute-api,预留 agent/file gateway 拆分
|
||||||
|
.env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
项目根目录不再保留 `Dockerfile` 和 `docker-compose.yml`,避免与拆分部署入口混淆。
|
||||||
|
|
||||||
|
应用服务器启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docker/app
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
算力服务器启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docker/compute
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
应用服务器与算力服务器独立部署时,需要在 `docker/app/.env` 中配置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
COMPUTE_API_BASE_URL=http://<compute-server-ip>:19100
|
||||||
|
FILE_GATEWAY_BASE_URL=http://<compute-server-ip>:19101
|
||||||
|
COMPUTE_SERVICE_TOKEN=change_me
|
||||||
|
```
|
||||||
|
|
||||||
|
这些地址在当前 Docker 阶段通过环境变量动态配置。后续多算力节点阶段建议升级为数据库配置,由应用平台从 `compute_nodes` 表读取节点地址、权重、标签、健康状态和启用状态,并在“算力节点管理”页面维护。
|
||||||
|
|
||||||
|
多节点后,每台算力服务器各自进入 `docker/compute` 启动一套算力服务,并在应用平台中登记为一条 `compute_nodes` 记录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gpu-node-01 -> http://10.10.20.31:19100 / http://10.10.20.31:19101
|
||||||
|
gpu-node-02 -> http://10.10.20.32:19100 / http://10.10.20.32:19101
|
||||||
|
gpu-node-03 -> http://10.10.20.33:19100 / http://10.10.20.33:19101
|
||||||
|
```
|
||||||
|
|
||||||
|
算力服务器需要在 `docker/compute/.env` 中配置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ENABLE_APP_CALLBACK=false
|
||||||
|
COMPUTE_SERVICE_TOKEN=change_me
|
||||||
|
YG_FT_DATA_ROOT_HOST=./data/yg-ft
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. 仍需确认的问题
|
||||||
|
|
||||||
|
- 生产环境是否已有统一 ELK/OpenSearch、Filebeat/Vector 标准配置。
|
||||||
|
- PostgreSQL/Redis 开发阶段采用项目自带部署;生产阶段是否切换企业统一基础设施,以及对应 SLA 仍需确认。
|
||||||
|
- 是否需要 PostgreSQL 主备、备份恢复、审计日志长期归档的明确 SLA。
|
||||||
|
- 大文件上传是否需要断点续传、限速、病毒扫描或 DLP 检测。
|
||||||
|
- 应用服务器与算力服务器默认只开通应用侧主动访问算力侧;如后续需要实时回调,再单独评估双向网络策略。
|
||||||
|
- 多算力节点已按单机多 GPU 节点扩展设计;仍需确认是否需要节点组、租户绑定节点、同步限速和资源副本清理审批。
|
||||||
145
docs/first-version-development-plan.md
Normal file
145
docs/first-version-development-plan.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# 当前系统主链路开发计划
|
||||||
|
|
||||||
|
> 说明:本计划描述当前正在开发的系统能力。代码、接口和 SQL 均按后续生产演进基线维护,不以一次性演示、静态 Mock 或样例数据作为开发准则。联调辅助能力必须显式配置启用,并不得成为默认运行路径。
|
||||||
|
|
||||||
|
## 1. 阶段目标
|
||||||
|
|
||||||
|
当前阶段需要完成模型微调平台的主链路工程基础:
|
||||||
|
|
||||||
|
```text
|
||||||
|
登录
|
||||||
|
-> 模型管理
|
||||||
|
-> 数据集管理
|
||||||
|
-> 创建微调任务
|
||||||
|
-> 调度算力节点与 GPU
|
||||||
|
-> 检查并同步模型/数据集资源
|
||||||
|
-> 启动训练任务
|
||||||
|
-> 轮询任务状态、GPU 占用、训练日志、loss 曲线
|
||||||
|
-> 训练完成后登记训练产物
|
||||||
|
```
|
||||||
|
|
||||||
|
该阶段是正式系统的第一批可运行能力,不再初始化业务样例数据。系统只允许初始化内置管理员/运维账号,模型、数据集、算力节点、GPU、训练任务和资源副本必须通过页面、接口、算力 Agent 扫描或正式导入流程产生。
|
||||||
|
|
||||||
|
## 2. 运行模式
|
||||||
|
|
||||||
|
| 模式 | 说明 | 当前要求 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `real` | 面向真实部署,等待 Compute API、Agent、File Gateway 和 LLaMA-Factory 执行器回写状态 | 默认模式 |
|
||||||
|
| `simulator` | 仅用于隔离联调,无真实 GPU 时临时推进任务状态、GPU 状态和训练日志 | 必须显式开启,不得用于生产基线 |
|
||||||
|
|
||||||
|
后端默认 `COMPUTE_MODE=real`。在该模式下,任务状态不再按时间自动推进,必须由后续真实算力同步逻辑更新。算力服务默认 `COMPUTE_EXECUTION_MODE=real`,真实训练执行器未完成前,创建训练作业会返回明确的未实现错误,避免误认为已经完成生产训练能力。
|
||||||
|
|
||||||
|
训练相关能力必须沉淀在 `compute/engines/` 适配层,不允许在应用平台后端直接拼接或执行训练命令。
|
||||||
|
|
||||||
|
## 3. 当前开发范围
|
||||||
|
|
||||||
|
### 3.1 应用平台后端
|
||||||
|
|
||||||
|
对应目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/app/
|
||||||
|
api/v1/endpoints/platform.py
|
||||||
|
core/
|
||||||
|
db/
|
||||||
|
```
|
||||||
|
|
||||||
|
已建立能力:
|
||||||
|
- 统一 API 响应结构 `{ code, message, data }`。
|
||||||
|
- PostgreSQL 运行表初始化,当前执行脚本位于 `backend/app/db/sql/001_platform_runtime.sql`。
|
||||||
|
- 内置管理员账号初始化,业务数据不再自动写入样例记录。
|
||||||
|
- 登录、当前用户、用户列表与权限页面接口。
|
||||||
|
- 模型管理、训练产物列表、权重合并任务入口。
|
||||||
|
- 数据集管理、文件上传、预览、版本管理和下载。
|
||||||
|
- 微调任务创建、启动、停止、删除、进度查询、checkpoint 查询。
|
||||||
|
- 系统健康指标、系统信息、训练日志、系统日志接口。
|
||||||
|
- 算力节点、GPU、队列、资源副本、资源同步任务接口。
|
||||||
|
|
||||||
|
待继续开发:
|
||||||
|
- 接入正式 ORM/Repository/Service 分层和 Alembic 迁移。
|
||||||
|
- 将任务状态更新改为应用侧定时轮询 Compute API/File Gateway 后落库。
|
||||||
|
- 完成项目/模型/数据集级权限隔离校验。
|
||||||
|
- 完成审批流、审计留存、配额、资源申请和多租户上下文。
|
||||||
|
- 增加正式异常码、接口鉴权、中间件、幂等控制和分页规范。
|
||||||
|
|
||||||
|
### 3.2 算力平台服务
|
||||||
|
|
||||||
|
对应目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
compute/
|
||||||
|
api/main.py
|
||||||
|
agent/
|
||||||
|
engines/llama_factory/
|
||||||
|
file_gateway/
|
||||||
|
```
|
||||||
|
|
||||||
|
已建立能力:
|
||||||
|
- `/modelTF/health` 与 `/modelTF/v1/compute/health` 节点健康检查。
|
||||||
|
- LLaMA-Factory 参数校验、命令生成和训练日志指标解析。
|
||||||
|
- Compute API 作业、GPU、文件网关接口壳。
|
||||||
|
- 显式 `simulator` 模式下的内存状态机,用于隔离联调。
|
||||||
|
|
||||||
|
待继续开发:
|
||||||
|
- 真实 GPU 发现:接入 `nvidia-smi` 或 NVML。
|
||||||
|
- GPU 锁定与释放:一张 GPU 同一时间只分配给一个训练或推理任务。
|
||||||
|
- LLaMA-Factory 真实执行器:生成 YAML/命令、启动进程、停止进程、采集 PID。
|
||||||
|
- 训练日志采集:读取宿主机挂载日志文件,解析 loss、learning rate、epoch 等指标。
|
||||||
|
- Checkpoint/adapter/merged model 扫描与产物登记。
|
||||||
|
- File Gateway:本地磁盘文件上传、下载、校验、导入和跨节点资源同步。
|
||||||
|
|
||||||
|
### 3.3 前端页面
|
||||||
|
|
||||||
|
已接入页面:
|
||||||
|
- `/login`:登录接口。
|
||||||
|
- `/model-manage`:模型列表、模型来源、训练产物。
|
||||||
|
- `/dataset`、`/dataset/:id/preview`:数据集列表、预览、版本。
|
||||||
|
- `/fine-tune`、`/fine-tune/create`:微调任务创建、启动、状态轮询。
|
||||||
|
- `/training-log/:id`:训练日志和 loss 曲线。
|
||||||
|
- `/hardware`:平台 GPU 与系统性能。
|
||||||
|
- `/compute`:算力节点、GPU、队列、资源副本。
|
||||||
|
|
||||||
|
前端 Mock 默认关闭。仅在隔离前端开发时可设置 `VITE_ENABLE_MOCK=true`,真实联调和后续生产演进均以 `/modelTF` 后端接口为准。
|
||||||
|
|
||||||
|
待继续开发:
|
||||||
|
- 补齐多租户、项目管理、审批中心、审计中心、配额管理页面。
|
||||||
|
- 完成算力节点管理表单,包括节点地址、权重、标签、启用状态和健康检查结果。
|
||||||
|
- 完成模型/数据集导入页面,支持本地路径扫描和归属项目选择。
|
||||||
|
- 推理服务页面需接入真实后端任务接口,移除页面内本地假对话路径。
|
||||||
|
|
||||||
|
### 3.4 数据库
|
||||||
|
|
||||||
|
当前运行 SQL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/app/db/sql/001_platform_runtime.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
架构目标 SQL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/postgres-schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
当前运行 SQL 用于支持已开发接口落库;架构目标 SQL 包含用户中心、多租户、项目隔离、审批、审计、配额、评测等完整模型。后续需要通过 Alembic 将二者收敛为统一迁移体系,生产升级只走迁移脚本,不依赖手工改表。
|
||||||
|
|
||||||
|
## 4. 验收标准
|
||||||
|
|
||||||
|
- 启动后端必须连接 PostgreSQL,不允许回退到 SQLite。
|
||||||
|
- 后端启动只初始化系统内置账号,不初始化模型、数据集、算力节点、GPU、训练任务等业务样例数据。
|
||||||
|
- 前端默认请求真实 `/modelTF` 接口,除非显式设置 `VITE_ENABLE_MOCK=true`。
|
||||||
|
- 默认 `real` 模式下任务状态不自动伪造完成,必须等待真实算力同步。
|
||||||
|
- 显式 `simulator` 模式只能用于隔离联调,部署文档必须标注不得用于生产。
|
||||||
|
- 登录后可以进入主界面,并可通过页面/API 创建真实业务记录。
|
||||||
|
- 代码、接口路由、配置项、数据库表名不得使用 `demo` 命名。
|
||||||
|
|
||||||
|
## 5. 后续开发计划
|
||||||
|
|
||||||
|
| 阶段 | 重点 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 阶段 1 | 数据库迁移体系 | 将当前运行 SQL 与架构 SQL 收敛到 Alembic 迁移 |
|
||||||
|
| 阶段 2 | 后端领域分层 | 拆分用户、模型、数据集、训练、算力、审计等模块 |
|
||||||
|
| 阶段 3 | 真实 Compute Agent | GPU 发现、资源锁定、进程管理、日志采集 |
|
||||||
|
| 阶段 4 | LLaMA-Factory 训练执行 | YAML/命令生成、进程启动/停止、checkpoint 和 adapter 扫描 |
|
||||||
|
| 阶段 5 | 企业治理 | 多租户、项目隔离、审批流、审计留存、配额和资源申请 |
|
||||||
|
| 阶段 6 | 多算力节点调度 | 基于 `compute_nodes`、标签、权重、资源副本和节点健康实现调度策略 |
|
||||||
98
docs/menu-functional-requirements.md
Normal file
98
docs/menu-functional-requirements.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# 菜单与功能需求总览
|
||||||
|
|
||||||
|
> 本文根据当前前端侧边栏、路由、需求文档、接口文档、部署文档和 SQL 脚本整理。当前代码和 SQL 均按正式系统开发基线维护;Mock、Simulator 只能作为显式联调能力,不作为默认开发准则。
|
||||||
|
|
||||||
|
## 1. 菜单分层
|
||||||
|
|
||||||
|
### 1.1 当前侧边栏菜单
|
||||||
|
|
||||||
|
| 一级分组 | 菜单 | 路由 | 权限码 | 当前状态 | 主要功能 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 服务看板 | 服务看板 | `/dashboard` | `dashboard` | 已有页面,接口需继续完善 | 总览指标、服务状态、训练统计、最近任务、健康入口 |
|
||||||
|
| 模型服务 | 模型训练 | `/fine-tune` | `fine-tune` | 已接入主链路 | 训练任务列表、创建训练、启动/停止、进度、训练日志、checkpoint |
|
||||||
|
| 模型服务 | 模型评测 | `/model-eval` | `model-eval` | 前端页面已有,后端待完整实现 | 评测任务、评测维度、样本评分、综合结果 |
|
||||||
|
| 模型服务 | 模型推理 | `/model-inference` | `model-inference` | 前端页面已有,后端待完整实现 | 推理任务、模型加载、单模型对话、模型对比入口 |
|
||||||
|
| 模型服务 | 模型管理 | `/model-manage` | `model-manage` | 已接入主链路 | 基座模型登记、本地/API 模型、训练产物、权重合并、模型导出 |
|
||||||
|
| 数据治理 | 数据集管理 | `/dataset` | `dataset` | 已接入主链路 | 数据集列表、上传、预览、在线编辑、版本、下载、删除审批入口 |
|
||||||
|
| 数据治理 | 数据处理 | `/data-process` | `data-process` | 前端页面已有,后端待完整实现 | 文档上传、切片预览、LLM 生成、结果编辑、发布数据集 |
|
||||||
|
| 其他工具 | 数据类型转换 | `/data-convert` | `data-convert` | 前端页面已有,后端待实现 | JSON/JSONL/Markdown 等格式转换任务 |
|
||||||
|
| 算力资源 | 算力节点 | `/compute` | `compute` | 已接入节点管理接口 | 节点地址、权重、标签、启用状态、GPU、队列、资源副本 |
|
||||||
|
| 系统设置 | 用户设置 | `/user-settings` | `user-settings` | 已接入基础用户接口 | 用户列表、创建用户、启停、页面权限 |
|
||||||
|
| 系统设置 | 平台性能 | `/hardware` | `hardware` | 已有接口,需接真实采集 | CPU、内存、磁盘、GPU、进程、网络监控 |
|
||||||
|
| 系统设置 | 查看日志 | `/logs` | `logs` | 已有接口,需接真实日志文件 | 后端日志、error 日志、训练日志索引、日志内容查看 |
|
||||||
|
|
||||||
|
### 1.2 当前二级和隐藏路由
|
||||||
|
|
||||||
|
| 页面 | 路由 | 归属菜单 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 登录 | `/login` | 独立入口 | 登录后进入主界面 |
|
||||||
|
| 使用文档 | `/guide` | 独立入口 | 当前系统使用说明 |
|
||||||
|
| 创建训练任务 | `/fine-tune/create` | 模型训练 | 训练参数、模型/数据集/GPU 选择 |
|
||||||
|
| 训练日志 | `/training-log/:id` | 模型训练 | 日志、指标、checkpoint、任务概览 |
|
||||||
|
| 新建评测 | `/model-eval/create` | 模型评测 | 模型、数据集、维度、GPU 选择 |
|
||||||
|
| 评测详情 | `/model-eval/:id` | 模型评测 | 维度汇总、样本结果、人工复核预留 |
|
||||||
|
| 评测维度创建/编辑 | `/model-eval/dimension/create`、`/model-eval/dimension/:id/edit` | 模型评测 | 评测规则、Prompt、评分器配置 |
|
||||||
|
| 新建推理 | `/model-inference/create` | 模型推理 | 推理任务和模型加载配置 |
|
||||||
|
| 模型对话 | `/model-inference/chat/:id` | 模型推理 | 单模型对话 |
|
||||||
|
| 模型对比 | `/model-compare/chat/:id`、`/model-compare/result` | 模型推理 | 多模型对比和结果页 |
|
||||||
|
| 添加/编辑模型 | `/model-manage/create`、`/model-manage/:id/edit` | 模型管理 | 模型登记、用途、来源、路径/API 配置 |
|
||||||
|
| 合并权重 | `/model-manage/merge` | 模型管理 | LoRA/Adapter 合并任务 |
|
||||||
|
| 数据处理创建/详情 | `/data-process/create`、`/data-process/:id` | 数据处理 | 数据处理向导和任务详情 |
|
||||||
|
| 数据集创建/编辑/预览 | `/dataset/create`、`/dataset/:id/edit`、`/dataset/:id/preview` | 数据集管理 | 数据集元数据、文件、版本与内容 |
|
||||||
|
| 自定义工具 | `/tools`、`/tools/create`、`/tools/:id/edit` | 规划入口 | 路由存在,当前侧边栏未展示,后续可归入“其他工具” |
|
||||||
|
| 算力子页 | `/compute/gpus`、`/compute/queue`、`/compute/nodes` | 算力节点 | 当前可作为页签或深链 |
|
||||||
|
| 创建用户/权限设置 | `/user-settings/create`、`/user-settings/:id/permission` | 用户设置 | 用户创建和页面权限 |
|
||||||
|
| 无权限页 | `/permission-denied` | 系统页 | 路由守卫无权限跳转 |
|
||||||
|
|
||||||
|
### 1.3 企业治理待补菜单
|
||||||
|
|
||||||
|
| 建议菜单分组 | 菜单 | 建议路由 | 优先级 | 必要性 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 组织与项目 | 租户管理 | `/tenants`、`/tenants/:id` | P0 | 多租户隔离、配额、留存策略入口 |
|
||||||
|
| 组织与项目 | 项目空间 | `/projects`、`/projects/:id`、`/projects/:id/members` | P0 | 项目级模型/数据集/任务隔离 |
|
||||||
|
| 组织与项目 | 资源授权 | `/projects/:id/permissions` 或资源详情弹窗 | P0 | 模型/数据集/任务级 ACL |
|
||||||
|
| 治理中心 | 审批中心 | `/approvals`、`/approvals/:id` | P0 | 删除、发布、导出、停止他人任务等高风险动作 |
|
||||||
|
| 治理中心 | 审批设置 | `/approval-settings` | P1 | 审批模板、审批人规则、超时策略 |
|
||||||
|
| 治理中心 | 审计中心 | `/audit-logs`、`/login-logs`、`/download-logs` | P1 | 操作审计、登录审计、下载审计、导出 |
|
||||||
|
| 运维中心 | 存储管理 | `/storage` | P1 | 本地磁盘占用、临时文件、checkpoint 清理、留存 |
|
||||||
|
| 运维中心 | 训练引擎管理 | `/training-engines` | P2 | LLaMA-Factory 和后续引擎能力 schema、健康检查 |
|
||||||
|
| 模型服务 | 模型服务治理 | `/model-services`、`/model-services/:id` | P1 | 测试/生产服务发布、调用统计、下线审批 |
|
||||||
|
|
||||||
|
## 2. 菜单对应接口和数据库
|
||||||
|
|
||||||
|
| 菜单/模块 | 主要接口 | 当前运行 SQL | 目标 SQL |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 登录、用户设置 | `/modelTF/login`、`/modelTF/me`、`/modelTF/users` | `users` | `users`、`login_sessions`、`permissions`、`role_permissions`、`user_permission_overrides` |
|
||||||
|
| 服务看板 | `/modelTF/dashboard/overview`、`/modelTF/health` | 复用模型/数据集/任务/算力表 | `system_metric_snapshots`、`web_logs`、各业务表聚合 |
|
||||||
|
| 模型管理 | `/modelTF/model-manage`、`/modelTF/model-manage/trained-models`、`/modelTF/model-manage/merge` | `models`、`trained_models` | `models`、`trained_models`、`storage_objects`、`local_import_jobs`、`resource_acl` |
|
||||||
|
| 数据集管理 | `/modelTF/dataset-manage`、`/modelTF/dataset-manage/upload/{id}`、`/preview`、`/versions` | `datasets`、`dataset_files` | `datasets`、`dataset_files`、`dataset_file_versions`、`dataset_records`、`storage_objects` |
|
||||||
|
| 模型训练 | `/modelTF/fine-tune`、`/start`、`/progress`、`/checkpoints` | `fine_tune_tasks`、`trained_models` | `fine_tune_tasks`、`fine_tune_metrics`、`fine_tune_checkpoints`、`compute_jobs`、`gpu_allocations` |
|
||||||
|
| 训练日志 | `/modelTF/training-log-files`、`/modelTF/training-log-content` | 由任务表生成索引 | 日志文件元数据、`fine_tune_metrics`、`audit_logs` |
|
||||||
|
| 算力节点 | `/modelTF/compute/nodes`、`/compute/gpus`、`/compute/queue`、`/compute/nodes/{id}/replicas` | `compute_nodes`、`gpus`、`resource_replicas`、`resource_sync_jobs` | `compute_nodes`、`gpu_devices`、`compute_node_engines`、`compute_jobs`、`resource_replicas`、`resource_sync_jobs` |
|
||||||
|
| 平台性能 | `/modelTF/system-info`、`/modelTF/compute/gpus` | `gpus`、任务表 | `system_metric_snapshots`、`gpu_devices`、`compute_jobs` |
|
||||||
|
| 查看日志 | `/modelTF/log-files`、`/modelTF/log-content`、`/modelTF/web-log` | 文件日志 | `web_logs`、`audit_logs`,大日志进入日志平台 |
|
||||||
|
| 模型评测 | `/modelTF/model-eval`、`/modelTF/dimension` | 当前运行 SQL 未覆盖 | `eval_tasks`、`eval_dimensions`、`eval_sample_results`、`eval_dimension_summaries` |
|
||||||
|
| 模型推理/对比 | `/modelTF/model-compare`、`/modelTF/model-chat/*` | 当前运行 SQL 未覆盖 | `inference_tasks`、`inference_task_models`、`chat_sessions`、`chat_messages` |
|
||||||
|
| 数据处理 | `/modelTF/data-process/*` | 当前运行 SQL 未覆盖 | `data_process_tasks`、`data_process_source_files`、`data_process_preview_items`、`data_process_results` |
|
||||||
|
| 数据转换/自定义工具 | `/modelTF/data-convert/jobs`、`/modelTF/tools` | 当前运行 SQL 未覆盖 | `data_convert_jobs`、`custom_tools` |
|
||||||
|
| 租户/项目/资源授权 | `/modelTF/tenants`、`/modelTF/projects`、`/modelTF/resources/{type}/{id}/acl` | 当前运行 SQL 未覆盖 | `tenants`、`tenant_users`、`projects`、`project_members`、`resource_acl` |
|
||||||
|
| 审批/审计/留存/配额 | `/modelTF/approvals`、`/modelTF/audit-logs`、`/modelTF/retention-policies`、`/modelTF/quotas/usage` | 当前运行 SQL 未覆盖 | `approval_templates`、`approval_instances`、`approval_steps`、`audit_logs`、`retention_policies`、`quotas`、`quota_usage` |
|
||||||
|
|
||||||
|
## 3. 文档和脚本检查结论
|
||||||
|
|
||||||
|
| 对象 | 当前结论 | 本次补充 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 需求文档 | `docs/platform-architecture-requirements.md` 和 `docs/system-development-plan.md` 已覆盖多租户、项目隔离、审批、审计、多算力节点、应用/算力分离部署;缺少一份按当前菜单组织的总览 | 新增本文作为菜单和功能需求总览 |
|
||||||
|
| 接口文档 | `docs/backend-api-design.md` 已统一 `/modelTF`,并已有页面/接口映射;需要明确引用菜单总览,避免开发只看接口不看页面入口 | 在接口文档增加菜单总览引用 |
|
||||||
|
| 开发计划 | `docs/system-development-plan.md` 已按工作包列出页面、接口和 DB;需要把本文作为任务认领入口 | 在开发计划增加菜单总览引用 |
|
||||||
|
| 部署文档 | `docs/deployment-plan.md`、`docker/README.md` 已覆盖应用/算力分离、单机多 GPU、本地磁盘、真实模式默认、Docker 拆分 | 暂无新增部署配置要求 |
|
||||||
|
| 目标 SQL | `docs/postgres-schema.sql` 覆盖完整目标模型,包含用户、权限、多租户、项目、审批、审计、模型、数据集、训练、评测、推理、算力、存储、导入、服务治理 | 暂不需要新增目标表 |
|
||||||
|
| 当前运行 SQL | `backend/app/db/sql/001_platform_runtime.sql` 只覆盖已接入运行接口的最小表集 | 后续每实现一个 P0/P1 菜单模块,应同步补运行 SQL 或迁移脚本;不能再以样例数据补功能 |
|
||||||
|
|
||||||
|
## 4. 后续补充原则
|
||||||
|
|
||||||
|
- 新增侧边栏菜单时,必须同步补齐:路由、权限码、接口文档、DB 表/迁移、审计动作、部署依赖。
|
||||||
|
- 新增后端接口时,必须在 `docs/backend-api-design.md` 标注对应页面/功能模块。
|
||||||
|
- 新增表结构时,目标模型写入 `docs/postgres-schema.sql`,当前可执行落库写入 `backend/app/db/sql/` 或 Alembic 迁移。
|
||||||
|
- 与训练、评测、推理、数据处理相关的异步任务必须落库,不能依赖前端本地状态。
|
||||||
|
- 与算力相关的功能默认走真实模式;Simulator 只能显式开启用于隔离联调。
|
||||||
772
docs/platform-architecture-requirements.md
Normal file
772
docs/platform-architecture-requirements.md
Normal file
@@ -0,0 +1,772 @@
|
|||||||
|
# 模型训练平台架构与功能需求设计
|
||||||
|
|
||||||
|
> 本文基于当前前端页面、已有接口/数据库设计,以及用户补充的 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 证书。
|
||||||
|
- 算力平台不信任前端用户身份,只信任应用平台下发的租户、项目、任务和资源上下文。
|
||||||
|
- 第一阶段不默认启用算力侧回调,应用平台通过定时轮询 Compute API 同步任务状态;如未来启用回调,必须带签名,避免伪造状态。
|
||||||
|
|
||||||
|
核心通信接口:
|
||||||
|
|
||||||
|
| 方向 | 接口 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 应用 -> 算力 | `POST /modelTF/compute/jobs` | 创建训练/评测/数据处理/推理任务 |
|
||||||
|
| 应用 -> 算力 | `POST /modelTF/compute/jobs/{id}/stop` | 停止任务 |
|
||||||
|
| 应用 -> 算力 | `GET /modelTF/compute/jobs/{id}` | 查询任务状态 |
|
||||||
|
| 应用 -> 算力 | `GET /modelTF/compute/jobs/{id}/logs` | 拉取日志 |
|
||||||
|
| 应用 -> 算力 | `GET /modelTF/compute/resources/gpus` | 查询 GPU 状态 |
|
||||||
|
| 应用 -> 算力 | `POST /modelTF/compute/files/upload` | 上传文件到算力本地磁盘 |
|
||||||
|
| 应用 -> 算力 | `GET /modelTF/compute/files/{object_id}/download` | 下载文件 |
|
||||||
|
| 应用 -> 算力 | `GET /modelTF/compute/jobs?status=running` | 定时轮询任务状态、指标、产物索引 |
|
||||||
|
|
||||||
|
## 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 资源调度
|
||||||
|
|
||||||
|
第一版可以先以单个算力节点跑通主链路,但数据模型、接口和页面需要按多算力节点预留。多节点阶段仍然采用“每台 GPU 服务器 = 一个单机多 GPU 节点”的模式,不引入 Kubernetes。
|
||||||
|
|
||||||
|
### 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`。
|
||||||
|
- 高优任务是否可抢占低优任务,需要审批或管理员权限。
|
||||||
|
|
||||||
|
### 4.4 多算力节点升级策略
|
||||||
|
|
||||||
|
多算力节点阶段的推荐决策:
|
||||||
|
|
||||||
|
- 每个可执行训练任务的 GPU 节点都部署 `Compute API`、`Compute Agent`、`File Gateway`、LLaMA-Factory、CUDA/PyTorch 训练环境和本地数据盘。
|
||||||
|
- 应用服务器可以主动访问所有算力节点的 `Compute API/File Gateway`。
|
||||||
|
- 算力节点之间默认不互相访问,不做节点间点对点同步;所有调度、状态同步和资源分发由应用平台统一编排。
|
||||||
|
- 长期坚持每台算力服务器本地磁盘,因此需要 `resource_replicas` 记录数据集、基座模型、checkpoint、adapter、导出模型在哪些节点已有本地副本。
|
||||||
|
- 调度前必须检查目标节点是否已有模型和数据集副本;缺失时由应用平台通过目标节点 File Gateway 创建同步任务,完成后再启动训练。
|
||||||
|
- 调度支持自动和手动两种模式:普通用户默认自动调度,管理员/高级用户可手动指定节点、GPU、标签或节点组。
|
||||||
|
|
||||||
|
多节点自动调度建议:
|
||||||
|
|
||||||
|
1. 过滤 `enabled = true` 且 `scheduler_status = online` 的节点。
|
||||||
|
2. 按训练引擎、GPU 型号、显存、节点标签、租户/项目配额过滤。
|
||||||
|
3. 优先选择已存在所需模型/数据集副本的节点,减少跨节点复制。
|
||||||
|
4. 同等条件下按空闲 GPU、队列长度、节点权重和最近健康检查排序。
|
||||||
|
5. `draining` 节点不接收新任务,但允许已有任务完成。
|
||||||
|
|
||||||
|
## 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 状态。
|
||||||
|
- 算力节点新增/编辑、连接测试、启用/禁用、维护模式。
|
||||||
|
- 节点权重、标签、训练引擎版本、LLaMA-Factory 健康状态。
|
||||||
|
- 节点本地资源副本:数据集、模型、checkpoint、adapter 和导出模型缓存。
|
||||||
|
- 训练引擎健康状态。
|
||||||
|
|
||||||
|
### 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 /modelTF/tenants`
|
||||||
|
- `POST /modelTF/tenants`
|
||||||
|
- `GET /modelTF/tenants/{id}`
|
||||||
|
- `PUT /modelTF/tenants/{id}`
|
||||||
|
- `PUT /modelTF/tenants/{id}/quota`
|
||||||
|
- `PUT /modelTF/tenants/{id}/retention-policy`
|
||||||
|
|
||||||
|
项目:
|
||||||
|
|
||||||
|
- `GET /modelTF/projects`
|
||||||
|
- `POST /modelTF/projects`
|
||||||
|
- `GET /modelTF/projects/{id}`
|
||||||
|
- `PUT /modelTF/projects/{id}`
|
||||||
|
- `POST /modelTF/projects/{id}/archive`
|
||||||
|
- `GET /modelTF/projects/{id}/members`
|
||||||
|
- `POST /modelTF/projects/{id}/members`
|
||||||
|
- `PUT /modelTF/projects/{id}/members/{user_id}`
|
||||||
|
- `DELETE /modelTF/projects/{id}/members/{user_id}`
|
||||||
|
|
||||||
|
资源授权:
|
||||||
|
|
||||||
|
- `GET /modelTF/resources/{resource_type}/{resource_id}/acl`
|
||||||
|
- `PUT /modelTF/resources/{resource_type}/{resource_id}/acl`
|
||||||
|
- `POST /modelTF/resources/{resource_type}/{resource_id}/share`
|
||||||
|
|
||||||
|
审批:
|
||||||
|
|
||||||
|
- `GET /modelTF/approvals`
|
||||||
|
- `POST /modelTF/approvals`
|
||||||
|
- `GET /modelTF/approvals/{id}`
|
||||||
|
- `POST /modelTF/approvals/{id}/approve`
|
||||||
|
- `POST /modelTF/approvals/{id}/reject`
|
||||||
|
- `POST /modelTF/approvals/{id}/cancel`
|
||||||
|
|
||||||
|
算力:
|
||||||
|
|
||||||
|
- `GET /modelTF/compute/nodes`
|
||||||
|
- `GET /modelTF/compute/gpus`
|
||||||
|
- `GET /modelTF/compute/queue`
|
||||||
|
- `POST /modelTF/compute/jobs/{id}/retry`
|
||||||
|
- `POST /modelTF/compute/jobs/{id}/priority`
|
||||||
|
|
||||||
|
训练引擎:
|
||||||
|
|
||||||
|
- `GET /modelTF/training-engines`
|
||||||
|
- `GET /modelTF/training-engines/{id}`
|
||||||
|
- `POST /modelTF/training-engines/{id}/health-check`
|
||||||
|
- `GET /modelTF/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. 多算力节点是否需要节点组、租户绑定节点或项目绑定节点策略?
|
||||||
|
14. 跨节点资源同步是否需要限速、同步窗口和管理员审批?
|
||||||
|
|
||||||
|
## 13. 推荐决策补充
|
||||||
|
|
||||||
|
`system-development-plan.md` 已对上述问题给出第一版建议,需求设计以以下决策为准。
|
||||||
|
|
||||||
|
1. 本地磁盘主存储放在算力服务器,应用服务器只保留上传临时文件。临时文件默认保留 24 小时,成功转发到算力文件网关后可立即进入清理队列。
|
||||||
|
2. 第一版不支持 MIG、GPU 分片和多任务共享同一张 GPU。一张 GPU 同一时间只分配给一个训练任务或一个推理服务。GPU 数据模型预留 `partition_type`、`parent_gpu_uuid`、`memory_total_mb`,便于后续扩展 MIG。
|
||||||
|
3. 第一版不做自动抢占。支持任务优先级和排队;停止他人任务需要审批或平台管理员权限。
|
||||||
|
4. 多算力节点仍按“单机多 GPU 节点”管理,每个节点独立部署算力服务和 LLaMA-Factory;节点之间不互相访问,由应用平台统一调度和资源同步。
|
||||||
|
5. 多节点调度默认自动选择节点,同时支持管理员/高级用户手动指定节点;调度优先考虑节点健康、标签、权重、空闲 GPU、队列长度和资源副本是否已存在。
|
||||||
|
6. 第一版必须支持离线导入已有模型和数据集目录。导入由算力 Agent 扫描、校验、登记,并归属指定租户和项目。
|
||||||
|
7. 模型发布区分测试服务和生产服务。测试服务项目内可启动并默认限流;生产服务必须审批。
|
||||||
|
8. 数据脱敏和数据质量评分作为数据处理模块的一等能力进入第一期,先实现规则版脱敏、格式校验、重复率、完整性、长度分布等指标。
|
||||||
|
9. 人工评测/复核作为第二期功能,但第一期需在数据库和页面入口预留人工复核状态与修订字段。
|
||||||
|
10. 第一版支持从 checkpoint 手动恢复训练,不做自动失败续训。失败任务可选择 checkpoint 重试。
|
||||||
|
11. 第一版必须支持 checkpoint 自动清理策略:默认保留最近 3 个、最优 2 个;已发布模型关联 checkpoint 不自动删除;失败任务 checkpoint 默认保留 14 天。
|
||||||
|
12. 第一版提供内部 API,第二期再开放面向其他系统的标准 API、API Key、限流和 Webhook。
|
||||||
|
13. 第一版使用本地账号,预留 OIDC/LDAP 字段和认证 provider 抽象;第二期接入企业统一身份认证。
|
||||||
|
14. 第一版做 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 查询 |
|
||||||
1513
docs/postgres-schema.sql
Normal file
1513
docs/postgres-schema.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -431,9 +431,9 @@ Expected: 三项检查全部 PASS。
|
|||||||
|
|
||||||
- [ ] **Step 3: 启动页面并逐步验证四步交互**
|
- [ ] **Step 3: 启动页面并逐步验证四步交互**
|
||||||
|
|
||||||
Run: `cd frontend && npm run dev -- --host 0.0.0.0 --port 6801`
|
Run: `cd frontend && npm run dev -- --host 0.0.0.0 --port 16801`
|
||||||
|
|
||||||
Browser checks at `http://localhost:6801/data-process/create`:
|
Browser checks at `http://localhost:16801/data-process/create`:
|
||||||
|
|
||||||
1. 第一步上传文本并选择非结构化数据。
|
1. 第一步上传文本并选择非结构化数据。
|
||||||
2. 第二步点击至少三个右侧切片,确认左侧滚动目标和高亮范围变化。
|
2. 第二步点击至少三个右侧切片,确认左侧滚动目标和高亮范围变化。
|
||||||
|
|||||||
1118
docs/system-development-plan.md
Normal file
1118
docs/system-development-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
208
docs/team-development-plan.md
Normal file
208
docs/team-development-plan.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# 多人并行开发分工计划
|
||||||
|
|
||||||
|
> 本文基于 `docs/menu-functional-requirements.md`、`docs/backend-api-design.md`、`docs/postgres-schema.sql`、`docs/system-development-plan.md` 和当前前端路由整理,用于 3-4 人并行开发。开发口径以正式系统演进为准,不以临时演示或静态 Mock 作为交付标准。
|
||||||
|
|
||||||
|
## 1. 分工原则
|
||||||
|
|
||||||
|
- 每位开发尽量独立负责一组页面、后端模块、数据库表和联调脚本,避免多人同时改同一个业务文件。
|
||||||
|
- 公共接口契约先在 `docs/backend-api-design.md` 更新,再进入代码实现。
|
||||||
|
- 前端 API 模块按业务域维护:`system.ts`、`model.ts`、`dataset.ts`、`fineTune.ts`、`compute.ts`、`eval.ts`、`log.ts`。
|
||||||
|
- 后端业务代码按 `backend/app/modules/<domain>/` 拆分,路由统一挂载 `/modelTF`。
|
||||||
|
- 数据库按迁移脚本推进,目标模型以 `docs/postgres-schema.sql` 为准,当前运行库脚本以 `backend/app/db/sql/` 为准。
|
||||||
|
- 所有写操作必须预留审计;涉及删除、导出、发布、停止他人任务等高风险动作必须预留审批入口。
|
||||||
|
|
||||||
|
## 2. 4 人开发拆分
|
||||||
|
|
||||||
|
### A. 平台基础与企业治理
|
||||||
|
|
||||||
|
负责人边界:
|
||||||
|
|
||||||
|
- 前端目录:`frontend/src/views/login/`、`frontend/src/views/system/`,后续新增 `tenants`、`projects`、`approvals`、`audit` 页面目录。
|
||||||
|
- 前端 API:`frontend/src/api/modules/system.ts`、`frontend/src/api/modules/log.ts`。
|
||||||
|
- 后端模块:`auth`、`tenant`、`project`、`approval`、`audit`、`retention`、`system`。
|
||||||
|
- 数据库表:`users`、`permissions`、`roles`、`role_permissions`、`user_permission_overrides`、`tenants`、`tenant_users`、`projects`、`project_members`、`resource_acl`、`approval_templates`、`approval_instances`、`approval_steps`、`audit_logs`、`retention_policies`。
|
||||||
|
|
||||||
|
对应页面和功能:
|
||||||
|
|
||||||
|
| 页面/模块 | 路由 | 功能点 | 接口 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 登录 | `/login` | 登录、Token 写入、登录失败提示、会话恢复 | `POST /modelTF/login`、`GET /modelTF/me` |
|
||||||
|
| 用户设置 | `/user-settings`、`/user-settings/create`、`/user-settings/:id/permission` | 用户列表、创建、启停、重置密码、页面权限 | `/modelTF/users/*`、`/modelTF/permissions` |
|
||||||
|
| 平台性能 | `/hardware` | 系统资源、进程、GPU 摘要 | `/modelTF/system-info`、`/modelTF/compute/gpus` |
|
||||||
|
| 查看日志 | `/logs`、`/training-log/:id` | 后端日志、error 日志、训练日志索引和内容 | `/modelTF/log-files`、`/modelTF/log-content`、`/modelTF/training-log-*` |
|
||||||
|
| 租户管理 | `/tenants`、`/tenants/:id` | 租户、配额、留存策略 | `/modelTF/tenants/*`、`/modelTF/retention-policies/*` |
|
||||||
|
| 项目空间 | `/projects`、`/projects/:id`、`/projects/:id/members` | 项目、成员、项目角色 | `/modelTF/projects/*` |
|
||||||
|
| 资源授权 | `/resources/:type/:id/acl` 或弹窗 | 模型/数据集/任务 ACL | `/modelTF/resources/{type}/{id}/acl` |
|
||||||
|
| 审批中心 | `/approvals`、`/approval-settings` | 审批待办、审批历史、审批模板 | `/modelTF/approvals/*`、`/modelTF/approval-templates/*` |
|
||||||
|
| 审计中心 | `/audit-logs` | 操作审计、登录审计、导出 | `/modelTF/audit-logs` |
|
||||||
|
|
||||||
|
开发计划:
|
||||||
|
|
||||||
|
| 阶段 | 交付内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 第 1 周 | 完成登录、当前用户、用户列表、权限码、日志查询接口;完善当前运行 SQL。 |
|
||||||
|
| 第 2 周 | 完成租户、项目、项目成员、资源 ACL 后端和基础页面。 |
|
||||||
|
| 第 3 周 | 完成审批实例、审批模板、审计日志查询和导出。 |
|
||||||
|
| 第 4 周 | 接入其他模块写操作审计和审批拦截,补充权限测试。 |
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- 所有业务列表按租户、项目、资源 ACL 过滤。
|
||||||
|
- 普通用户无法访问未授权项目、模型和数据集。
|
||||||
|
- 高风险动作有审批或管理员旁路规则。
|
||||||
|
- 日志文件和审计日志可按时间、用户、动作、资源筛选。
|
||||||
|
|
||||||
|
### B. 模型资产、训练与 LLaMA-Factory 任务
|
||||||
|
|
||||||
|
负责人边界:
|
||||||
|
|
||||||
|
- 前端目录:`frontend/src/views/model/`、`frontend/src/views/fine-tune/`。
|
||||||
|
- 前端 API:`frontend/src/api/modules/model.ts`、`frontend/src/api/modules/fineTune.ts`。
|
||||||
|
- 后端模块:`model`、`fine_tune`、`engine_registry`、`compute_gateway` 中的训练编排部分。
|
||||||
|
- 数据库表:`models`、`trained_models`、`fine_tune_tasks`、`fine_tune_metrics`、`fine_tune_checkpoints`、`training_engines`、`training_engine_capabilities`、`compute_jobs`、`gpu_allocations`。
|
||||||
|
|
||||||
|
对应页面和功能:
|
||||||
|
|
||||||
|
| 页面/模块 | 路由 | 功能点 | 接口 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 模型管理 | `/model-manage` | 基座模型、API 模型、训练产物列表、筛选、删除审批入口 | `/modelTF/model-manage`、`/modelTF/model-manage/trained-models` |
|
||||||
|
| 添加/编辑模型 | `/model-manage/create`、`/model-manage/:id/edit` | 模型登记、本地路径/API 配置、能力标签 | `/modelTF/model-manage`、`/modelTF/model-manage/{id}` |
|
||||||
|
| 合并权重 | `/model-manage/merge` | LoRA/Adapter 合并、产物登记 | `/modelTF/model-manage/merge` |
|
||||||
|
| 模型训练 | `/fine-tune` | 训练任务列表、状态、启动、停止、删除审批入口 | `/modelTF/fine-tune`、`/modelTF/fine-tune/{id}/start`、`/stop` |
|
||||||
|
| 创建训练任务 | `/fine-tune/create` | 选择模型、数据集、超参、GPU、节点策略 | `/modelTF/fine-tune`、`/modelTF/model-manage`、`/modelTF/dataset-manage`、`/modelTF/compute/*` |
|
||||||
|
| 训练日志 | `/training-log/:id` | 实时日志、loss 曲线、checkpoint、产物 | `/modelTF/fine-tune/{id}/progress`、`/metrics`、`/checkpoints`、`/modelTF/training-log-*` |
|
||||||
|
|
||||||
|
开发计划:
|
||||||
|
|
||||||
|
| 阶段 | 交付内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 第 1 周 | 完成模型 CRUD、训练任务 CRUD、训练参数校验和接口联调。 |
|
||||||
|
| 第 2 周 | 完成训练启动、停止、状态轮询、日志和指标落库。 |
|
||||||
|
| 第 3 周 | 完成 LLaMA-Factory 参数映射、checkpoint 列表、训练产物登记。 |
|
||||||
|
| 第 4 周 | 完成权重合并、失败恢复、权限隔离和审计接入。 |
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- 训练任务不能绕过项目、模型、数据集权限。
|
||||||
|
- 训练任务状态以应用侧轮询 Compute API 为主。
|
||||||
|
- 训练命令只能由训练引擎适配层生成,不在页面或应用 API 中拼命令。
|
||||||
|
- checkpoint、日志、产物均可追溯到任务、节点、GPU 和项目。
|
||||||
|
|
||||||
|
### C. 数据集、数据处理、评测与推理
|
||||||
|
|
||||||
|
负责人边界:
|
||||||
|
|
||||||
|
- 前端目录:`frontend/src/views/dataset/`、`frontend/src/views/data-process/`、`frontend/src/views/data-convert/`、`frontend/src/views/eval/`、`frontend/src/views/inference/`、`frontend/src/views/compare/`、`frontend/src/views/tools/`。
|
||||||
|
- 前端 API:`dataset.ts`、`eval.ts`、`compare.ts`,必要时新增 `dataProcess.ts`、`dataConvert.ts`、`inference.ts`。
|
||||||
|
- 后端模块:`dataset`、`data_process`、`eval`、`inference`、`file_gateway` 中的数据资产登记部分。
|
||||||
|
- 数据库表:`datasets`、`dataset_files`、`dataset_file_versions`、`dataset_records`、`data_process_tasks`、`data_process_source_files`、`data_process_preview_items`、`data_process_results`、`data_convert_jobs`、`eval_tasks`、`eval_dimensions`、`eval_sample_results`、`inference_tasks`、`chat_sessions`、`chat_messages`、`custom_tools`。
|
||||||
|
|
||||||
|
对应页面和功能:
|
||||||
|
|
||||||
|
| 页面/模块 | 路由 | 功能点 | 接口 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 数据集管理 | `/dataset` | 数据集列表、搜索、版本、下载、删除审批入口 | `/modelTF/dataset-manage` |
|
||||||
|
| 数据集创建/编辑 | `/dataset/create`、`/dataset/:id/edit` | 元数据、文件上传、格式识别、项目归属 | `/modelTF/dataset-manage`、`/upload/{id}` |
|
||||||
|
| 数据集预览 | `/dataset/:id/preview` | 分页预览、在线编辑、版本对比 | `/modelTF/dataset-manage/{id}/preview`、`/versions` |
|
||||||
|
| 数据处理 | `/data-process`、`/data-process/create`、`/data-process/:id` | 文档上传、切片、脱敏、质量评分、发布数据集 | `/modelTF/data-process/*` |
|
||||||
|
| 数据类型转换 | `/data-convert` | JSON/JSONL/Markdown 转换任务 | `/modelTF/data-convert/jobs/*` |
|
||||||
|
| 模型评测 | `/model-eval`、`/model-eval/create`、`/model-eval/:id` | 评测任务、维度、样本级结果、人工复核预留 | `/modelTF/model-eval/*`、`/modelTF/dimension/*` |
|
||||||
|
| 模型推理/对比 | `/model-inference/*`、`/model-compare/*` | 模型加载、对话、对比、结果沉淀 | `/modelTF/model-chat/*`、`/modelTF/model-compare/*` |
|
||||||
|
| 自定义工具 | `/tools`、`/tools/create`、`/tools/:id/edit` | 工具登记、参数 schema、启停 | `/modelTF/tools/*` |
|
||||||
|
|
||||||
|
开发计划:
|
||||||
|
|
||||||
|
| 阶段 | 交付内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 第 1 周 | 完成数据集 CRUD、上传、预览、版本接口和页面联调。 |
|
||||||
|
| 第 2 周 | 完成数据处理任务、切片预览、质量评分和发布数据集。 |
|
||||||
|
| 第 3 周 | 完成评测任务、评测维度、样本结果查询。 |
|
||||||
|
| 第 4 周 | 完成推理会话、模型对比、数据转换、自定义工具基础能力。 |
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- 数据文件必须登记存储对象、checksum、版本和项目归属。
|
||||||
|
- 数据处理产物发布为数据集时保留来源链路。
|
||||||
|
- 评测和推理必须记录使用的模型版本、数据集版本和参数快照。
|
||||||
|
- 下载、删除、导出等动作必须接入审计和审批策略。
|
||||||
|
|
||||||
|
### D. 算力平台、部署与运维
|
||||||
|
|
||||||
|
负责人边界:
|
||||||
|
|
||||||
|
- 前端目录:`frontend/src/views/compute/`,协助 `system/HardwareView.vue`。
|
||||||
|
- 前端 API:`frontend/src/api/modules/compute.ts`。
|
||||||
|
- 后端模块:`compute_gateway`、`engine_registry`、`file_gateway`、`system` 中的资源采集部分。
|
||||||
|
- 算力目录:`compute/api/`、`compute/agent/`、`compute/engines/llama_factory/`、`compute/file_gateway/`。
|
||||||
|
- 部署目录:`docker/app/`、`docker/compute/`、`docker/README.md`、`docs/deployment-plan.md`。
|
||||||
|
- 数据库表:`compute_nodes`、`gpu_devices`、`compute_node_engines`、`compute_jobs`、`gpu_allocations`、`resource_replicas`、`resource_sync_jobs`、`system_metric_snapshots`、`storage_objects`。
|
||||||
|
|
||||||
|
对应页面和功能:
|
||||||
|
|
||||||
|
| 页面/模块 | 路由 | 功能点 | 接口 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 算力节点 | `/compute`、`/compute?tab=nodes` | 节点地址、File Gateway 地址、权重、标签、启用状态、连接测试 | `/modelTF/compute/nodes/*` |
|
||||||
|
| GPU 资源 | `/compute?tab=gpus`、`/hardware` | GPU 显存、利用率、温度、分配状态、节点归属 | `/modelTF/compute/gpus`、`/modelTF/system-info` |
|
||||||
|
| 任务队列 | `/compute?tab=queue` | 队列、优先级、占用 GPU、任务状态 | `/modelTF/compute/queue` |
|
||||||
|
| 资源副本 | `/compute` 节点详情 | 模型/数据集在算力节点上的同步状态 | `/modelTF/compute/nodes/{id}/replicas` |
|
||||||
|
| 文件网关 | 无独立页面,供模型/数据/训练调用 | 上传、下载、离线导入、产物归档 | 应用侧 `/modelTF/*` 编排,算力侧内部 File Gateway API |
|
||||||
|
| 部署运维 | 文档和 Compose | 应用/算力分离部署、端口、镜像、日志、健康检查 | Docker Compose、健康检查接口 |
|
||||||
|
|
||||||
|
开发计划:
|
||||||
|
|
||||||
|
| 阶段 | 交付内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 第 1 周 | 完成 Compute API 健康检查、GPU 发现、节点登记和连接测试。 |
|
||||||
|
| 第 2 周 | 完成任务状态查询、应用侧轮询、资源副本状态同步。 |
|
||||||
|
| 第 3 周 | 完成 LLaMA-Factory 容器/宿主机路径适配、日志采集、训练进程管理。 |
|
||||||
|
| 第 4 周 | 完成应用/算力两套 Docker Compose、部署文档、故障排查脚本。 |
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- 多算力节点阶段仍按“每台算力服务器 = 单机多 GPU 节点”设计。
|
||||||
|
- 每台算力服务器都部署 Compute API、Agent、File Gateway 和 LLaMA-Factory。
|
||||||
|
- 应用服务器只需主动访问所有算力节点,不要求算力节点反向访问应用服务器。
|
||||||
|
- 节点地址、权重、标签、启用状态必须可动态维护。
|
||||||
|
|
||||||
|
## 3. 3 人开发合并方案
|
||||||
|
|
||||||
|
如果团队只有 3 人,建议合并为:
|
||||||
|
|
||||||
|
| 开发人员 | 合并内容 | 不建议合并的原因 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A | 平台基础与企业治理 | 该部分是所有模块的权限和隔离底座,不宜再叠加训练或数据主链路。 |
|
||||||
|
| B | 模型资产、训练与 LLaMA-Factory 任务 | 模型和训练强耦合,适合一人端到端打通。 |
|
||||||
|
| C | 数据集、数据处理、评测、推理、算力部署协同 | 数据链路和评测推理使用相同数据/模型资产;算力底层可先由 C 搭骨架,后续扩人拆出 D。 |
|
||||||
|
|
||||||
|
若进入真实 GPU 联调阶段,必须优先把 D 独立出来,否则训练问题、部署问题和业务问题会混在一起,排障效率会明显下降。
|
||||||
|
|
||||||
|
## 4. 公共契约和协作节奏
|
||||||
|
|
||||||
|
公共契约负责人建议由 A 兼任,所有人遵守:
|
||||||
|
|
||||||
|
| 契约 | 文件 | 变更规则 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 路由前缀 | `backend/app/core/config.py`、`backend/app/api/v1/router.py`、接口文档 | 统一 `/modelTF`,不得新增 `/api` 前缀 |
|
||||||
|
| 响应结构 | `frontend/src/api/request.ts`、后端 schema | 统一 `{ code, message, data }` |
|
||||||
|
| 权限码 | `frontend/src/types/index.ts`、`permissions` 表、接口文档 | 新菜单先登记权限码再开发 |
|
||||||
|
| 项目隔离 | `project_id`、`tenant_id`、`resource_acl` | 所有模型、数据集、任务必须带项目归属 |
|
||||||
|
| 审计动作 | `audit_logs`、后端审计中间件/服务 | 写操作默认审计 |
|
||||||
|
| 异步状态 | 任务表、`compute_jobs` | 统一 `pending/running/completed/failed/stopped` |
|
||||||
|
| 文件存储 | `storage_objects`、File Gateway | 不暴露宿主机绝对路径给前端 |
|
||||||
|
|
||||||
|
建议节奏:
|
||||||
|
|
||||||
|
- 每周一上午同步接口契约和数据库迁移计划。
|
||||||
|
- 每天下午固定一次跨模块联调窗口,优先处理阻塞其他人的接口。
|
||||||
|
- 每个模块 PR 必须包含页面入口、接口说明、SQL/迁移、最小验证步骤。
|
||||||
|
- 公共文件如 `frontend/src/types/index.ts`、`backend/app/core/*`、`docs/backend-api-design.md` 由对应 owner 统一合并,其他人通过小 PR 提交变更。
|
||||||
|
|
||||||
|
## 5. 里程碑
|
||||||
|
|
||||||
|
| 里程碑 | 目标 | 必须完成 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| M1 基础可用 | 用户登录、模型/数据集/训练主链路可运行 | A 登录权限,B 模型训练,C 数据集,D 单节点 GPU 状态 |
|
||||||
|
| M2 企业隔离 | 多租户、项目、资源 ACL 接入主链路 | 所有资源按租户/项目过滤,审计落库 |
|
||||||
|
| M3 训练闭环 | LLaMA-Factory 真实训练、日志、checkpoint、产物登记 | 应用侧轮询 Compute API,训练产物可在模型管理查看 |
|
||||||
|
| M4 治理闭环 | 审批、审计、留存、导出、部署文档完善 | 高风险动作审批,审计可检索,应用/算力分离部署可复现 |
|
||||||
|
|
||||||
2
frontend/.gitignore
vendored
2
frontend/.gitignore
vendored
@@ -1,5 +1,7 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
!dist/
|
||||||
|
!dist/**
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,17 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
开发服务器默认运行在 `http://localhost:6801`。
|
开发服务器默认运行在 `http://localhost:16801`。
|
||||||
|
|
||||||
后端 API 默认通过 Vite 代理转发到 `http://localhost:7861`(见 `vite.config.ts`)。
|
后端 API 默认通过 Vite 代理转发到 `http://localhost:17861`(见 `vite.config.ts`)。
|
||||||
|
|
||||||
|
开发环境默认联调真实后端接口。如需进行隔离前端开发,可显式启用 Mock:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_ENABLE_MOCK=true npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
真实联调、测试环境和生产环境不应启用 Mock。
|
||||||
|
|
||||||
## 构建
|
## 构建
|
||||||
|
|
||||||
|
|||||||
1
frontend/dist/assets/AppConfirmDialog-DssSJ2jv.js
vendored
Normal file
1
frontend/dist/assets/AppConfirmDialog-DssSJ2jv.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{d as C,bf as g,D as E,H as O,o as _,e as B,s as D,Z as $,w as A,c as I,aL as K,q as a,n as v,aa as r,g as q,bg as M,y as u,z as N,P as k}from"./index-Ds9AETjS.js";import{_ as R}from"./_plugin-vue_export-helper-DlAUqK2U.js";const V={class:"app-confirm-header"},z={class:"app-confirm-heading"},H={class:"app-confirm-icon","aria-hidden":"true"},L={class:"app-confirm-body"},P={class:"app-confirm-actions"},S=C({__name:"AppConfirmDialog",setup(j,{expose:x}){const c=u(!1),m=u(),p=u(),d=`app-confirm-title-${g()}`,y=`app-confirm-message-${g()}`,n=N({title:"请确认操作",message:"",confirmText:"确定",cancelText:"取消",tone:"warning",closeOnOverlay:!1});let o=null,i=null;function s(t){c.value=!1;const e=o;o=null,e==null||e(t)}function h(t){return o&&s(!1),Object.assign(n,{confirmText:"确定",cancelText:"取消",tone:"warning",closeOnOverlay:!1,...t}),c.value=!0,new Promise(e=>{o=e})}function w(){n.closeOnOverlay&&s(!1)}function T(t){var b;if(t.key==="Escape"){t.preventDefault(),s(!1);return}if(t.key!=="Tab")return;const e=Array.from(((b=m.value)==null?void 0:b.querySelectorAll("button:not([disabled])"))??[]),l=e[0],f=e[e.length-1];!l||!f||(t.shiftKey&&document.activeElement===l?(t.preventDefault(),f.focus()):!t.shiftKey&&document.activeElement===f&&(t.preventDefault(),l.focus()))}return E(c,async t=>{var e;if(t){i=document.activeElement instanceof HTMLElement?document.activeElement:null,await k(),(e=p.value)==null||e.focus();return}await k(),i==null||i.focus(),i=null}),O(()=>{o==null||o(!1),o=null}),x({open:h}),(t,e)=>(_(),B(M,{to:"body"},[D($,{name:"app-confirm"},{default:A(()=>[c.value?(_(),I("div",{key:0,class:"app-confirm-overlay",onMousedown:K(w,["self"])},[a("section",{ref_key:"dialogRef",ref:m,class:v(["app-confirm-dialog",`is-${n.tone}`]),role:"alertdialog","aria-modal":!0,"aria-labelledby":d,"aria-describedby":y,onKeydown:T},[a("header",V,[a("div",z,[a("span",H,[a("i",{class:v(n.tone==="primary"?"fa fa-question-circle":"fa fa-exclamation-triangle")},null,2)]),a("h2",{id:d},r(n.title),1)]),a("button",{class:"app-confirm-close",type:"button","aria-label":"关闭确认弹窗",onClick:e[0]||(e[0]=l=>s(!1))},[...e[3]||(e[3]=[a("i",{class:"fa fa-times","aria-hidden":"true"},null,-1)])])]),a("div",L,[a("p",{id:y},r(n.message),1)]),a("footer",P,[a("button",{ref_key:"cancelButtonRef",ref:p,class:"app-confirm-button is-cancel",type:"button",onClick:e[1]||(e[1]=l=>s(!1))},r(n.cancelText),513),a("button",{class:"app-confirm-button is-confirm",type:"button",onClick:e[2]||(e[2]=l=>s(!0))},r(n.confirmText),1)])],34)],32)):q("",!0)]),_:1})]))}}),G=R(S,[["__scopeId","data-v-398df98e"]]);export{G as A};
|
||||||
1
frontend/dist/assets/AppConfirmDialog-zFD3Iwu_.css
vendored
Normal file
1
frontend/dist/assets/AppConfirmDialog-zFD3Iwu_.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.app-confirm-overlay[data-v-398df98e]{position:fixed;z-index:2000;top:0;right:0;bottom:0;left:0;display:grid;place-items:center;padding:20px;box-sizing:border-box;background:#0f172a70}.app-confirm-dialog[data-v-398df98e]{position:relative;width:min(480px,100%);overflow:hidden;background:#fff;border:1px solid #dfe3ea;border-radius:8px;box-shadow:0 12px 28px #0f172a29}.app-confirm-header[data-v-398df98e]{display:flex;min-height:52px;align-items:center;justify-content:space-between;gap:16px;padding:0 10px 0 20px;border-bottom:1px solid #e7eaf0}.app-confirm-heading[data-v-398df98e]{display:flex;min-width:0;align-items:center;gap:10px}.app-confirm-heading h2[data-v-398df98e]{margin:0;overflow:hidden;color:#273142;font-size:15px;font-weight:650;line-height:1.4;text-overflow:ellipsis;white-space:nowrap}.app-confirm-close[data-v-398df98e]{display:inline-grid;width:32px;height:32px;flex:0 0 32px;place-items:center;padding:0;color:#7b8495;background:transparent;border:0;border-radius:4px;cursor:pointer;transition:color .18s ease,background-color .18s ease}.app-confirm-close[data-v-398df98e]:hover{color:#273142;background:#f2f4f7}.app-confirm-close[data-v-398df98e]:focus-visible{outline:2px solid rgba(91,80,242,.45);outline-offset:1px}.app-confirm-icon[data-v-398df98e]{display:inline-grid;width:28px;height:28px;flex:0 0 28px;place-items:center;color:#a15c07;background:#fff8e6;border:1px solid #f3dfad;border-radius:6px;font-size:13px}.app-confirm-dialog.is-danger .app-confirm-icon[data-v-398df98e]{color:#c43232;background:#fff1f1;border-color:#f2c7c7}.app-confirm-dialog.is-primary .app-confirm-icon[data-v-398df98e]{color:#4f46e5;background:#f3f2ff;border-color:#d9d6ff}.app-confirm-body[data-v-398df98e]{padding:16px 20px 18px}.app-confirm-body p[data-v-398df98e]{margin:0;color:#5f6878;font-size:13px;line-height:1.7}.app-confirm-actions[data-v-398df98e]{display:flex;justify-content:flex-end;gap:8px;padding:10px 14px;background:#f8f9fb;border-top:1px solid #e7eaf0}.app-confirm-button[data-v-398df98e]{height:34px;min-width:72px;padding:0 13px;color:#344054;font-size:13px;font-weight:500;background:#fff;border:1px solid #cfd5df;border-radius:4px;cursor:pointer;transition:border-color .18s ease,background-color .18s ease,color .18s ease}.app-confirm-button[data-v-398df98e]:hover{color:#273142;background:#f2f4f7;border-color:#b9c1cd}.app-confirm-button[data-v-398df98e]:focus-visible{outline:2px solid rgba(91,80,242,.45);outline-offset:1px}.app-confirm-button.is-confirm[data-v-398df98e]{color:#fff;background:#a15c07;border-color:#a15c07}.app-confirm-button.is-confirm[data-v-398df98e]:hover{background:#844b06;border-color:#844b06}.app-confirm-dialog.is-danger .app-confirm-button.is-confirm[data-v-398df98e]{background:#c43232;border-color:#c43232}.app-confirm-dialog.is-danger .app-confirm-button.is-confirm[data-v-398df98e]:hover{background:#a92828;border-color:#a92828}.app-confirm-dialog.is-primary .app-confirm-button.is-confirm[data-v-398df98e]{background:#4f46e5;border-color:#4f46e5}.app-confirm-dialog.is-primary .app-confirm-button.is-confirm[data-v-398df98e]:hover{background:#4338ca;border-color:#4338ca}.app-confirm-enter-active[data-v-398df98e],.app-confirm-leave-active[data-v-398df98e]{transition:opacity .18s ease}.app-confirm-enter-active .app-confirm-dialog[data-v-398df98e],.app-confirm-leave-active .app-confirm-dialog[data-v-398df98e]{transition:opacity .18s ease,transform .18s ease}.app-confirm-enter-from[data-v-398df98e],.app-confirm-leave-to[data-v-398df98e]{opacity:0}.app-confirm-enter-from .app-confirm-dialog[data-v-398df98e],.app-confirm-leave-to .app-confirm-dialog[data-v-398df98e]{opacity:0;transform:translateY(4px)}@media(max-width:520px){.app-confirm-overlay[data-v-398df98e]{padding:12px}.app-confirm-actions[data-v-398df98e]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.app-confirm-button[data-v-398df98e]{height:auto;min-width:0;min-height:44px}}@media(prefers-reduced-motion:reduce){.app-confirm-enter-active[data-v-398df98e],.app-confirm-leave-active[data-v-398df98e],.app-confirm-enter-active .app-confirm-dialog[data-v-398df98e],.app-confirm-leave-active .app-confirm-dialog[data-v-398df98e]{transition:none}}
|
||||||
1
frontend/dist/assets/CompareChatView-BVdMCKbe.js
vendored
Normal file
1
frontend/dist/assets/CompareChatView-BVdMCKbe.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{a as N,E as B}from"./el-form-item-Bq0n_p5a.js";import{E as K}from"./index-h5FkzAP_.js";import{E as M}from"./index-BNsCyG4A.js";import{E as h}from"./index-ByhS8A1d.js";import{E as I}from"./el-divider-a924dciw.js";import{E as R}from"./el-slider-mZJHOhL2.js";import{d as F,G as z,e as V,w as l,ac as D,y as L,o as f,s as a,x as p,q as $,c as k,ad as j,aa as E,M as A,g as G,f as J,v as O,z as H,j as _,A as P}from"./index-Ds9AETjS.js";import"./el-popper-D1tByNLb.js";import"./el-tooltip-l0sNRNKZ.js";import"./el-input-number-Bg2C0S8w.js";/* empty css */import{P as Q}from"./PageCard-CV3p14-x.js";import{u as W}from"./usePolling-Bx2QCw4O.js";import{a as X}from"./compare-fmy9bbtE.js";import{_ as Y}from"./_plugin-vue_export-helper-DlAUqK2U.js";import"./castArray-BWI4UxBy.js";import"./_baseClone-CZxxnGCv.js";import"./raf-zQ00nuMI.js";import"./index-B0if-AHo.js";import"./index-BSbCLbaz.js";import"./debounce-D6mJVDYC.js";import"./toNumber-DrayciwT.js";import"./clamp-CyhADbdq.js";import"./index-sWKLi7bH.js";import"./index-2LOSO4Pw.js";import"./el-card-D86TXgMv.js";const Z={class:"model-list"},tt={key:0,class:"empty-hint"},et=F({__name:"CompareChatView",setup(ot){const b=D(),y=O(),x=b.params.id,n=L(null),e=H({systemPrompt:"",question:"",temperature:.7,topP:.9,topK:40,maxTokens:2048}),m=_(()=>{var s;if(!((s=n.value)!=null&&s.load_status))return[];try{return(typeof n.value.load_status=="string"?JSON.parse(n.value.load_status):n.value.load_status).loaded_models||[]}catch{return[]}}),T=_(()=>m.value.length>0&&m.value.every(s=>s.status==="ready"||s.status==="running")),g=_(()=>m.value.some(s=>s.status==="starting"));async function c(){try{n.value=await X(x)}catch{}}function C(){var u,i;if(!e.question.trim()){P.warning("请输入问题");return}if(g.value){P.warning("模型仍在启动中,请稍候");return}const s=new URLSearchParams({taskId:x,taskName:((u=n.value)==null?void 0:u.model_name)||((i=n.value)==null?void 0:i.name)||"",question:e.question,systemPrompt:e.systemPrompt,temperature:String(e.temperature),topP:String(e.topP),topK:String(e.topK),maxTokens:String(e.maxTokens)}),t=y.resolve(`/model-compare/result?${s.toString()}`).href;window.open(t,"_blank")}const{start:S}=W(c,5e3,{immediate:!1});return z(async()=>{await c(),S()}),(s,t)=>{const u=I,i=h,v=K,r=N,d=R,w=M,q=B;return f(),V(Q,{title:"模型对比配置"},{default:l(()=>[a(u,{"content-position":"left"},{default:l(()=>[...t[7]||(t[7]=[p("已启动模型",-1)])]),_:1}),$("div",Z,[(f(!0),k(A,null,j(m.value,(o,U)=>(f(),V(i,{key:U,type:o.status==="ready"||o.status==="running"?"success":o.status==="starting"?"warning":"danger",size:"large"},{default:l(()=>[p(E(o.model_name)+" ("+E(o.status)+") ",1)]),_:2},1032,["type"]))),128)),m.value.length?G("",!0):(f(),k("span",tt,"暂无已启动模型"))]),a(u,{"content-position":"left"},{default:l(()=>[...t[8]||(t[8]=[p("对话配置",-1)])]),_:1}),a(q,{"label-width":"120px",style:{"max-width":"700px"}},{default:l(()=>[a(r,{label:"系统提示词"},{default:l(()=>[a(v,{modelValue:e.systemPrompt,"onUpdate:modelValue":t[0]||(t[0]=o=>e.systemPrompt=o),type:"textarea",rows:3,placeholder:"可选"},null,8,["modelValue"])]),_:1}),a(r,{label:"问题"},{default:l(()=>[a(v,{modelValue:e.question,"onUpdate:modelValue":t[1]||(t[1]=o=>e.question=o),type:"textarea",rows:4,placeholder:"请输入要对比的问题"},null,8,["modelValue"])]),_:1}),a(r,{label:"Temperature"},{default:l(()=>[a(d,{modelValue:e.temperature,"onUpdate:modelValue":t[2]||(t[2]=o=>e.temperature=o),min:0,max:2,step:.1,"show-input":"",style:{"max-width":"500px"}},null,8,["modelValue"])]),_:1}),a(r,{label:"Top-p"},{default:l(()=>[a(d,{modelValue:e.topP,"onUpdate:modelValue":t[3]||(t[3]=o=>e.topP=o),min:0,max:1,step:.05,"show-input":"",style:{"max-width":"500px"}},null,8,["modelValue"])]),_:1}),a(r,{label:"Top-k"},{default:l(()=>[a(d,{modelValue:e.topK,"onUpdate:modelValue":t[4]||(t[4]=o=>e.topK=o),min:1,max:100,step:1,"show-input":"",style:{"max-width":"500px"}},null,8,["modelValue"])]),_:1}),a(r,{label:"Max Tokens"},{default:l(()=>[a(d,{modelValue:e.maxTokens,"onUpdate:modelValue":t[5]||(t[5]=o=>e.maxTokens=o),min:256,max:4096,step:128,"show-input":"",style:{"max-width":"500px"}},null,8,["modelValue"])]),_:1}),a(r,null,{default:l(()=>[a(w,{type:"primary",disabled:!T.value||g.value,onClick:C},{default:l(()=>[...t[9]||(t[9]=[p(" 开始对比 ",-1)])]),_:1},8,["disabled"]),a(w,{onClick:t[6]||(t[6]=o=>J(y).back())},{default:l(()=>[...t[10]||(t[10]=[p("返回",-1)])]),_:1})]),_:1})]),_:1})]),_:1})}}}),qt=Y(et,[["__scopeId","data-v-5da55d96"]]);export{qt as default};
|
||||||
1
frontend/dist/assets/CompareChatView-wm3b2ZD9.css
vendored
Normal file
1
frontend/dist/assets/CompareChatView-wm3b2ZD9.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.model-list[data-v-5da55d96]{display:flex;flex-wrap:wrap;gap:12px;margin-bottom:12px}.empty-hint[data-v-5da55d96]{color:#909399}
|
||||||
1
frontend/dist/assets/CompareResultView-DOPqATtj.js
vendored
Normal file
1
frontend/dist/assets/CompareResultView-DOPqATtj.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{E as D}from"./el-alert-CoSdHkeK.js";import{E as $}from"./index-BNsCyG4A.js";import{E as z}from"./index-ByhS8A1d.js";import{E as F}from"./el-card-D86TXgMv.js";import{d as O,G as U,H as J,c as d,q as i,aa as c,f as v,s as N,w as m,M as K,ad as A,ac as G,y as g,o as r,x as p,e as y,g as H}from"./index-Ds9AETjS.js";/* empty css */import{_ as L}from"./MarkdownView.vue_vue_type_style_index_0_lang-CNORZ6uG.js";import{a as W,c as j}from"./compare-fmy9bbtE.js";import{_ as Q}from"./_plugin-vue_export-helper-DlAUqK2U.js";import"./vnode-CBiWHFw7.js";const X={class:"compare-result"},Y={class:"result-header"},Z={class:"header-actions"},tt={class:"result-grid"},et={class:"card-header"},st={class:"model-name"},ot={key:0,class:"error-text"},at={key:1,class:"streaming-text"},nt={key:3,class:"loading-text"},lt={key:4,class:"result-stats"},rt=O({__name:"CompareResultView",setup(it){const u=G(),q=u.query.taskId,k=decodeURIComponent(u.query.question||""),h=decodeURIComponent(u.query.systemPrompt||""),E=Number(u.query.temperature||.7),P=Number(u.query.topP||.9),I=Number(u.query.topK||40),b=Number(u.query.maxTokens||2048),w=u.query.taskName,l=g([]),C=g(!1),x=g([]),f=new Set;async function B(){if(!C.value){C.value=!0;try{const s=await W(q);let t=[];s.load_status&&(t=(typeof s.load_status=="string"?JSON.parse(s.load_status):s.load_status).loaded_models||[]),x.value=t,l.value=t.map(o=>({name:o.model_name||"模型",content:"",displayContent:"",isTyping:!1,status:"loading"})),await Promise.all(t.map((o,e)=>S(o,e)))}catch{}}}async function S(s,t){const o=Date.now();try{const e=await V(j({port:s.port,model_name:s.model_name,messages:[...h?[{role:"system",content:h}]:[],{role:"user",content:k}],temperature:E,top_p:P,top_k:I,max_tokens:b}),3e5),a=(e==null?void 0:e.response)||(e==null?void 0:e.content)||(e==null?void 0:e.data)||JSON.stringify(e),_=(Date.now()-o)/1e3;l.value[t].content=a,l.value[t].status="done",l.value[t].stats={totalTime:_,charsPerSec:_>0?Number((a.length/_).toFixed(1)):0},M(t,a)}catch(e){l.value[t].content="推理失败: "+(e.message||""),l.value[t].status="error"}}async function V(s,t){let o=null;try{return await Promise.race([s,new Promise((e,a)=>{o=setTimeout(()=>a(new Error("推理超时")),t)})])}finally{o&&clearTimeout(o)}}function M(s,t){let o=0;l.value[s].isTyping=!0;const e=Math.max(2,Math.ceil(t.length/30)),a=setInterval(()=>{o+=e,l.value[s].displayContent=t.slice(0,o),o>=t.length&&(clearInterval(a),f.delete(a),l.value[s].displayContent=t,l.value[s].isTyping=!1)},50);f.add(a)}return U(B),J(()=>{f.forEach(clearInterval),f.clear()}),(s,t)=>{const o=$,e=D,a=z,_=F;return r(),d("div",X,[i("div",Y,[i("h2",null,"对比结果"+c(v(w)?` - ${v(w)}`:""),1),i("div",Z,[N(o,{onClick:t[0]||(t[0]=n=>s.$router.push("/model-inference"))},{default:m(()=>[...t[1]||(t[1]=[p("返回列表",-1)])]),_:1})])]),N(e,{type:"info",closable:!1,"show-icon":"",class:"question-box"},{title:m(()=>[t[2]||(t[2]=i("strong",null,"问题:",-1)),p(c(v(k)),1)]),_:1}),i("div",tt,[(r(!0),d(K,null,A(l.value,(n,R)=>(r(),y(_,{key:R,shadow:"hover",class:"result-card"},{header:m(()=>[i("div",et,[i("span",st,c(n.name),1),n.status==="loading"?(r(),y(a,{key:0,type:"warning",size:"small"},{default:m(()=>[...t[3]||(t[3]=[p("生成中...",-1)])]),_:1})):n.status==="done"?(r(),y(a,{key:1,type:"success",size:"small"},{default:m(()=>[...t[4]||(t[4]=[p("完成",-1)])]),_:1})):(r(),y(a,{key:2,type:"danger",size:"small"},{default:m(()=>[...t[5]||(t[5]=[p("失败",-1)])]),_:1}))])]),default:m(()=>{var T;return[n.status==="error"?(r(),d("div",ot,c(n.content),1)):n.isTyping?(r(),d("div",at,c(n.displayContent),1)):n.displayContent?(r(),y(L,{key:2,content:n.displayContent},null,8,["content"])):(r(),d("div",nt,[...t[6]||(t[6]=[i("i",{class:"fa fa-spinner fa-spin"},null,-1),p(" 正在生成回答... ",-1)])])),n.stats?(r(),d("div",lt,[i("span",null,"耗时 "+c((T=n.stats.totalTime)==null?void 0:T.toFixed(1))+"s",1),i("span",null,"速度 "+c(n.stats.charsPerSec)+" 字/秒",1)])):H("",!0)]}),_:2},1024))),128))])])}}}),kt=Q(rt,[["__scopeId","data-v-4c4e03d7"]]);export{kt as default};
|
||||||
1
frontend/dist/assets/CompareResultView-DlLosvn8.css
vendored
Normal file
1
frontend/dist/assets/CompareResultView-DlLosvn8.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.compare-result[data-v-4c4e03d7]{max-width:1200px;margin:0 auto}.result-header[data-v-4c4e03d7]{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.result-header h2[data-v-4c4e03d7]{font-size:18px;font-weight:500;margin:0}.question-box[data-v-4c4e03d7]{margin-bottom:20px}.result-grid[data-v-4c4e03d7]{display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:16px}.result-card .card-header[data-v-4c4e03d7]{display:flex;align-items:center;justify-content:space-between}.result-card .card-header .model-name[data-v-4c4e03d7]{font-weight:500;color:#303133}.result-card .loading-text[data-v-4c4e03d7],.result-card .error-text[data-v-4c4e03d7]{color:#909399;min-height:80px;display:flex;align-items:center;justify-content:center}.result-card .error-text[data-v-4c4e03d7]{color:#f56c6c}.result-card .streaming-text[data-v-4c4e03d7]{min-height:80px;line-height:1.7;white-space:pre-wrap;word-break:break-word}.result-card .result-stats[data-v-4c4e03d7]{display:flex;gap:16px;margin-top:12px;padding-top:12px;border-top:1px solid #ebeef5;font-size:12px;color:#909399}
|
||||||
1
frontend/dist/assets/ComputeNodesView-BGkUyXxx.js
vendored
Normal file
1
frontend/dist/assets/ComputeNodesView-BGkUyXxx.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/ComputeNodesView-Czj9vH6H.css
vendored
Normal file
1
frontend/dist/assets/ComputeNodesView-Czj9vH6H.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.compute-page[data-v-5ebf5cb5]{display:flex;flex-direction:column;gap:16px;min-height:0;height:100%;padding:24px;background:#fff}.compute-header[data-v-5ebf5cb5]{display:flex;justify-content:space-between;gap:16px;align-items:flex-start}.compute-header h1[data-v-5ebf5cb5]{margin:0;font-size:24px;font-weight:650;color:#111827}.compute-header p[data-v-5ebf5cb5]{margin:8px 0 0;color:#64748b}.header-actions[data-v-5ebf5cb5]{display:flex;align-items:center;gap:12px}.last-updated[data-v-5ebf5cb5],.muted[data-v-5ebf5cb5]{color:#64748b;font-size:12px}.last-updated[data-v-5ebf5cb5]{display:inline-block;min-width:92px;text-align:right}.summary-grid[data-v-5ebf5cb5]{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.summary-tile[data-v-5ebf5cb5]{border:1px solid #e5e7eb;border-radius:8px;padding:14px 16px;background:#f8fafc}.summary-tile span[data-v-5ebf5cb5]{display:block;color:#64748b;font-size:12px}.summary-tile strong[data-v-5ebf5cb5]{display:block;margin-top:8px;color:#111827;font-size:24px}.compute-tabs[data-v-5ebf5cb5]{flex:1;min-height:0}.compute-tabs[data-v-5ebf5cb5] .el-tabs__content{height:calc(100% - 56px)}.compute-tabs[data-v-5ebf5cb5] .el-tab-pane{height:100%}.mono[data-v-5ebf5cb5]{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px}.replica-toolbar[data-v-5ebf5cb5]{display:flex;gap:12px;align-items:center;margin-bottom:12px}.replica-toolbar .el-select[data-v-5ebf5cb5]{width:280px}.node-form-grid[data-v-5ebf5cb5]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:12px}.node-form-grid[data-v-5ebf5cb5] .el-input-number,.node-form-grid[data-v-5ebf5cb5] .el-select{width:100%}@media(max-width:960px){.compute-header[data-v-5ebf5cb5],.header-actions[data-v-5ebf5cb5],.replica-toolbar[data-v-5ebf5cb5]{flex-direction:column;align-items:stretch}.summary-grid[data-v-5ebf5cb5]{grid-template-columns:repeat(2,minmax(0,1fr))}.node-form-grid[data-v-5ebf5cb5]{grid-template-columns:1fr}}
|
||||||
1
frontend/dist/assets/DashboardView-BMPZwafh.css
vendored
Normal file
1
frontend/dist/assets/DashboardView-BMPZwafh.css
vendored
Normal file
File diff suppressed because one or more lines are too long
2
frontend/dist/assets/DashboardView-NO-FdQqU.js
vendored
Normal file
2
frontend/dist/assets/DashboardView-NO-FdQqU.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/DataConvertView-Bog-IlBb.js
vendored
Normal file
1
frontend/dist/assets/DataConvertView-Bog-IlBb.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{a as N,E as v}from"./el-form-item-Bq0n_p5a.js";import{E as b}from"./el-popper-D1tByNLb.js";import{E as O}from"./index-h5FkzAP_.js";import{E as S}from"./index-BNsCyG4A.js";import{E as g,a as E}from"./el-select-BD70by95.js";import{d as J,e as V,w as e,o as w,q as t,s as l,x as i,z as x,A as C}from"./index-Ds9AETjS.js";import"./el-tooltip-l0sNRNKZ.js";import"./el-scrollbar-CF42LNzg.js";/* empty css */import{P as U}from"./PageCard-CV3p14-x.js";import{_ as y}from"./_plugin-vue_export-helper-DlAUqK2U.js";import"./castArray-BWI4UxBy.js";import"./_baseClone-CZxxnGCv.js";import"./index-BSbCLbaz.js";import"./index-sWKLi7bH.js";import"./raf-zQ00nuMI.js";import"./index-B0if-AHo.js";import"./vnode-CBiWHFw7.js";import"./index-CQlF36Lk.js";import"./index-ByhS8A1d.js";import"./scroll-DVmMv0d9.js";import"./clamp-CyhADbdq.js";import"./toNumber-DrayciwT.js";import"./_baseIteratee-82gB5Yov.js";import"./el-card-D86TXgMv.js";const F={class:"converter-panel"},L={class:"form-row"},T={class:"panel-footer"},B={class:"actions"},k=J({__name:"DataConvertView",setup(I){const a=x({outputName:"converted-data",encoding:"UTF-8"});function p(){C.info("当前仅完成界面设计,转换功能将在后续接入")}function d(){Object.assign(a,{outputName:"converted-data",encoding:"UTF-8"})}return(j,o)=>{const n=N,m=O,u=E,f=g,c=v,r=S,_=b;return w(),V(U,{class:"data-convert-page",title:"数据类型转换",subtitle:"将 JSON 文件转换为便于训练和评测使用的 JSONL 格式"},{default:e(()=>[t("div",F,[o[9]||(o[9]=t("div",{class:"panel-header"},[t("div",{class:"tool-icon","aria-hidden":"true"},[t("i",{class:"fa fa-exchange"})]),t("div",null,[t("h3",null,"JSON 转 JSONL"),t("p",null,"每条 JSON 数据将输出为 JSONL 文件中的一行记录")])],-1)),l(c,{class:"converter-form","label-position":"top"},{default:e(()=>[l(n,{label:"转换类型"},{default:e(()=>[...o[2]||(o[2]=[t("div",{class:"format-field","aria-label":"JSON 转 JSONL"},[t("span",null,"JSON"),t("i",{class:"fa fa-long-arrow-right","aria-hidden":"true"}),t("span",null,"JSONL")],-1)])]),_:1}),l(n,{label:"源文件",required:""},{default:e(()=>[t("button",{class:"upload-zone",type:"button",onClick:p},[...o[3]||(o[3]=[t("i",{class:"fa fa-cloud-upload","aria-hidden":"true"},null,-1),t("span",{class:"upload-content"},[t("strong",null,"点击选择或拖拽 JSON 文件到此处"),t("small",null,"仅支持 .json 格式,单文件不超过 200 MB")],-1),t("span",{class:"select-button"},"选择文件",-1)])])]),_:1}),t("div",L,[l(n,{label:"输出文件名"},{default:e(()=>[l(m,{modelValue:a.outputName,"onUpdate:modelValue":o[0]||(o[0]=s=>a.outputName=s)},{append:e(()=>[...o[4]||(o[4]=[i(".jsonl",-1)])]),_:1},8,["modelValue"])]),_:1}),l(n,{label:"字符编码"},{default:e(()=>[l(f,{modelValue:a.encoding,"onUpdate:modelValue":o[1]||(o[1]=s=>a.encoding=s),style:{width:"100%"}},{default:e(()=>[l(u,{label:"UTF-8",value:"UTF-8"})]),_:1},8,["modelValue"])]),_:1})]),o[5]||(o[5]=t("div",{class:"format-tip"},[t("i",{class:"fa fa-info-circle","aria-hidden":"true"}),t("span",null,"支持由 JSON 数组转换为 JSONL,每个数组元素输出为一行。")],-1))]),_:1}),t("div",T,[o[8]||(o[8]=t("span",{class:"prototype-label"},"当前为 UI 原型,暂不执行实际转换",-1)),t("div",B,[l(r,{onClick:d},{default:e(()=>[...o[6]||(o[6]=[i("重置",-1)])]),_:1}),l(_,{content:"转换功能将在后续开发中接入",placement:"top"},{default:e(()=>[t("span",null,[l(r,{type:"primary",disabled:""},{default:e(()=>[...o[7]||(o[7]=[i("开始转换",-1)])]),_:1})])]),_:1})])])])]),_:1})}}}),rt=y(k,[["__scopeId","data-v-fcb69543"]]);export{rt as default};
|
||||||
1
frontend/dist/assets/DataConvertView-DJuX-JIl.css
vendored
Normal file
1
frontend/dist/assets/DataConvertView-DJuX-JIl.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.converter-panel[data-v-fcb69543]{width:100%;min-height:calc(100vh - 220px);border:1px solid #e4e7ed;border-radius:8px;background:#fff;display:flex;flex-direction:column}.panel-header[data-v-fcb69543]{min-height:72px;padding:16px 20px;border-bottom:1px solid #ebeef5;background:#fafafa;display:flex;align-items:center;gap:12px;box-sizing:border-box}.panel-header .tool-icon[data-v-fcb69543]{width:38px;height:38px;flex:0 0 auto;border-radius:6px;background:var(--el-color-primary-light-9);color:var(--primary-color);display:flex;align-items:center;justify-content:center}.panel-header h3[data-v-fcb69543]{margin:0;color:#303133;font-size:15px;font-weight:600}.panel-header p[data-v-fcb69543]{margin:4px 0 0;color:#909399;font-size:12px}.converter-form[data-v-fcb69543]{flex:1;padding:22px 24px 6px}.converter-form[data-v-fcb69543] .el-form-item{margin-bottom:20px}.converter-form[data-v-fcb69543] .el-form-item__label{padding-bottom:8px;color:#606266;font-size:13px}.format-field[data-v-fcb69543]{width:100%;min-height:40px;padding:0 14px;border:1px solid #dcdfe6;border-radius:4px;background:#f5f7fa;color:#303133;display:flex;align-items:center;gap:14px;box-sizing:border-box;font-size:13px;font-weight:500}.format-field i[data-v-fcb69543]{color:#909399}.upload-zone[data-v-fcb69543]{width:100%;min-height:112px;padding:20px;border:1px dashed #b8c4d1;border-radius:6px;background:#fafcff;color:#606266;cursor:pointer;font:inherit;display:flex;align-items:center;gap:14px;text-align:left;transition:border-color .2s ease,background .2s ease}.upload-zone[data-v-fcb69543]:hover,.upload-zone[data-v-fcb69543]:focus-visible{border-color:var(--primary-color);background:var(--el-color-primary-light-9);outline:none}.upload-zone>i[data-v-fcb69543]{color:var(--primary-color);font-size:24px}.upload-zone .upload-content[data-v-fcb69543]{min-width:0;display:flex;flex:1;flex-direction:column;gap:5px}.upload-zone strong[data-v-fcb69543]{color:#303133;font-size:13px;font-weight:500}.upload-zone small[data-v-fcb69543]{color:#909399;font-size:12px}.upload-zone .select-button[data-v-fcb69543]{min-height:32px;padding:0 14px;border:1px solid #dcdfe6;border-radius:4px;background:#fff;color:#606266;display:inline-flex;align-items:center;white-space:nowrap}.form-row[data-v-fcb69543]{display:grid;grid-template-columns:minmax(0,2fr) minmax(180px,1fr);gap:16px}.format-tip[data-v-fcb69543]{min-height:38px;padding:9px 12px;border-radius:4px;background:var(--el-color-primary-light-9);color:#606266;display:flex;align-items:center;gap:8px;box-sizing:border-box;font-size:12px}.format-tip i[data-v-fcb69543]{color:var(--primary-color)}.panel-footer[data-v-fcb69543]{min-height:64px;padding:12px 24px;border-top:1px solid #ebeef5;background:#fafafa;display:flex;align-items:center;justify-content:space-between;gap:20px;box-sizing:border-box}.panel-footer .prototype-label[data-v-fcb69543]{color:#909399;font-size:12px}.panel-footer .actions[data-v-fcb69543]{display:flex;gap:10px}@media(max-width:640px){.converter-form[data-v-fcb69543]{padding:18px 16px 4px}.form-row[data-v-fcb69543]{grid-template-columns:1fr;gap:0}.upload-zone[data-v-fcb69543]{align-items:flex-start;flex-wrap:wrap}.upload-zone .select-button[data-v-fcb69543]{margin-left:38px}.panel-footer[data-v-fcb69543]{padding:12px 16px;align-items:flex-end;flex-direction:column}}
|
||||||
12
frontend/dist/assets/DataProcessCreateView-BUfBIY00.js
vendored
Normal file
12
frontend/dist/assets/DataProcessCreateView-BUfBIY00.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/DataProcessCreateView-DyU55R2b.css
vendored
Normal file
1
frontend/dist/assets/DataProcessCreateView-DyU55R2b.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/DataProcessDetailView-B1q0P6u8.css
vendored
Normal file
1
frontend/dist/assets/DataProcessDetailView-B1q0P6u8.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/DataProcessDetailView-kmoLJ2yJ.js
vendored
Normal file
1
frontend/dist/assets/DataProcessDetailView-kmoLJ2yJ.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user