feat: 更新后端平台模块、数据库、Compute引擎及多项配置文档

- 更新 backend 平台 API、platform_store、session 数据库模块
- 新增 backend SQL 初始化脚本
- 更新 compute 引擎适配器及 README
- 更新 Docker 部署配置(app/compute)
- 更新前端入口、环境类型声明及 README
- 新增 docs/menu-functional-requirements.md 菜单功能需求文档
- 更新多项项目文档

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wuyongtao
2026-07-21 10:55:44 +08:00
parent bccd3bf448
commit a72b8f1e4b
24 changed files with 571 additions and 630 deletions

View File

@@ -2,7 +2,7 @@
YG_FT 是一个面向企业治理场景的模型微调平台,覆盖用户中心、多租户、项目隔离、数据集管理、模型管理、训练任务、评测、推理、审批流、审计留存、算力调度和训练引擎适配。 YG_FT 是一个面向企业治理场景的模型微调平台,覆盖用户中心、多租户、项目隔离、数据集管理、模型管理、训练任务、评测、推理、审批流、审计留存、算力调度和训练引擎适配。
当前前端已有基础页面,后端与算力平台已按多人协作开发方式建立工程骨架,并开始实现第一版可演示闭环能力。第一版实现不是临时代码,工程模块、配置项、接口标签和数据库对象均按后续正式开发可延续的方式命名 当前前端已有基础页面,后端与算力平台已按多人协作开发方式建立工程骨架,并开始实现正式系统主链路能力。当前代码和 SQL 均作为后续生产演进基线维护,不再以一次性演示或静态 Mock 为开发准则
## 总体架构 ## 总体架构
@@ -38,12 +38,12 @@ YG_FT/
| 训练引擎 | 当前固定接入 LLaMA-Factory预留其他训练平台适配标准 | `compute/engines/` | | 训练引擎 | 当前固定接入 LLaMA-Factory预留其他训练平台适配标准 | `compute/engines/` |
| 数据层 | PostgreSQL、Redis、本地文件存储、日志归档 | `docs/postgres-schema.sql` | | 数据层 | PostgreSQL、Redis、本地文件存储、日志归档 | `docs/postgres-schema.sql` |
## 第一版能力 ## 当前开发基线
- 使用 FastAPI 提供统一 API 响应结构 `{ code, message, data }` - 使用 FastAPI 提供统一 API 响应结构 `{ code, message, data }`
- 本地运行阶段使用 SQLite 初始化样例用户、模型、数据集、算力节点、GPU微调任务。 - 本地运行阶段统一使用 PostgreSQL后端启动时会在 PG 中初始化当前运行表和系统内置账号;模型、数据集、算力节点、GPU微调任务等业务数据必须通过页面、接口或正式导入流程产生
- 支持登录、模型管理、数据集管理、微调任务创建/启动/停止/进度轮询。 - 支持登录、模型管理、数据集管理、微调任务创建/启动/停止/进度轮询。
- 支持训练日志、loss 指标、checkpoint 和训练产物的模拟生成 - 支持训练日志、loss 指标、checkpoint 和训练产物接口;真实训练执行器接入前,联调状态机必须通过显式环境变量开启
- 支持多算力节点、GPU、任务队列、资源副本和资源同步状态接口。 - 支持多算力节点、GPU、任务队列、资源副本和资源同步状态接口。
- 前端新增 `/compute` 算力节点页面展示节点地址、权重、标签、启用状态、GPU、队列和资源副本。 - 前端新增 `/compute` 算力节点页面展示节点地址、权重、标签、启用状态、GPU、队列和资源副本。
- `compute/engines/llama_factory/adapter.py` 提供 LLaMA-Factory 参数校验、命令生成和日志解析基础能力。 - `compute/engines/llama_factory/adapter.py` 提供 LLaMA-Factory 参数校验、命令生成和日志解析基础能力。
@@ -69,12 +69,14 @@ GET /modelTF/fine-tune
GET /modelTF/compute/nodes GET /modelTF/compute/nodes
``` ```
本地运行时默认数据库路径 本地运行时默认 PostgreSQL 连接
```text ```text
LOCAL_DB_PATH=./runtime/platform.db DATABASE_URL=postgresql+psycopg://yg_ft:change_me@localhost:15432/yg_ft
``` ```
本地启动前需要确保 PostgreSQL 已监听 `localhost:15432`,并已创建 `yg_ft` 数据库和 `yg_ft` 用户。后端启动后会自动创建当前运行表并写入内置管理员账号,运行数据统一写入 PostgreSQL。
## 前端启动 ## 前端启动
```bash ```bash
@@ -92,7 +94,7 @@ cd compute
uvicorn api.main:app --reload --port 19100 uvicorn api.main:app --reload --port 19100
``` ```
第一版默认 `COMPUTE_MODE=simulator`后续真实 GPU 接入时,在算力服务器上部署 Compute API、Agent、File Gateway 和 LLaMA-Factory应用平台通过 `compute_nodes.api_base_url``compute_nodes.file_gateway_url` 主动轮询。 默认 `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`
## 日志 ## 日志
@@ -112,10 +114,11 @@ logs/error-YYYY-MM-DD.log
## 主要文档 ## 主要文档
- `docs/platform-architecture-requirements.md`:平台需求、功能模块、页面补全建议。 - `docs/platform-architecture-requirements.md`:平台需求、功能模块、页面补全建议。
- `docs/menu-functional-requirements.md`:当前菜单、二级路由、规划菜单、功能需求、接口和数据库映射。
- `docs/backend-api-design.md`FastAPI 接口分组、参数定义、权限说明。 - `docs/backend-api-design.md`FastAPI 接口分组、参数定义、权限说明。
- `docs/postgres-schema.sql`PostgreSQL 数据库脚本,包含权限、用户中心、多租户、审批、审计等模型。 - `docs/postgres-schema.sql`PostgreSQL 数据库脚本,包含权限、用户中心、多租户、审批、审计等模型。
- `docs/system-development-plan.md`多人协作开发计划按前端、后端、DB、部署拆分。 - `docs/system-development-plan.md`多人协作开发计划按前端、后端、DB、部署拆分。
- `docs/first-version-development-plan.md`第一版可演示闭环开发计划覆盖前端、后端、DB、Compute Simulator、GPU 和 LLaMA-Factory 适配。 - `docs/first-version-development-plan.md`当前系统主链路开发计划覆盖前端、后端、DB、Compute API、GPU 和 LLaMA-Factory 适配。
- `docs/backend-logging.md`:后端日志模块使用说明。 - `docs/backend-logging.md`:后端日志模块使用说明。
- `docs/deployment-plan.md`:后期部署方案,覆盖单机算力服务器部署与应用/算力分离部署。 - `docs/deployment-plan.md`:后期部署方案,覆盖单机算力服务器部署与应用/算力分离部署。
- `docker/README.md`Docker 部署入口,包含应用服务器和算力服务器两套 Compose 使用方式。 - `docker/README.md`Docker 部署入口,包含应用服务器和算力服务器两套 Compose 使用方式。

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
import uuid
from fastapi import APIRouter, Body, File, HTTPException, Query, UploadFile from fastapi import APIRouter, Body, File, HTTPException, Query, UploadFile
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse
@@ -147,7 +148,7 @@ async def delete_model(model_id: str) -> dict[str, Any]:
@router.post("/model-manage/merge") @router.post("/model-manage/merge")
async def merge_model(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: async def merge_model(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
return ok({"job_id": "merge-sample-job", "status": "queued", **payload}) return ok({"job_id": f"merge_{uuid.uuid4().hex[:12]}", "status": "queued", **payload})
@router.get("/dataset-manage/preview/{file_id}") @router.get("/dataset-manage/preview/{file_id}")
@@ -289,8 +290,11 @@ async def fine_tune_list() -> dict[str, Any]:
@router.post("/fine-tune") @router.post("/fine-tune")
async def create_fine_tune(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: async def create_fine_tune(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
try:
task = get_platform_store().create_task(payload) task = get_platform_store().create_task(payload)
return ok({"id": task["id"]}) return ok({"id": task["id"]})
except ValueError as exc:
raise fail(400, str(exc))
@router.post("/fine-tune/start") @router.post("/fine-tune/start")
@@ -361,7 +365,10 @@ async def compute_nodes() -> dict[str, Any]:
@router.post("/compute/nodes") @router.post("/compute/nodes")
async def create_compute_node(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: async def create_compute_node(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
return ok({"id": "node_sample_new", **payload, "status": "created"}) try:
return ok(get_platform_store().create_compute_node(payload))
except KeyError as exc:
raise fail(400, f"missing field: {exc}")
@router.put("/compute/nodes/{node_id}") @router.put("/compute/nodes/{node_id}")

View File

@@ -16,8 +16,8 @@ class Settings:
app_env: str = os.getenv("APP_ENV", "local") app_env: str = os.getenv("APP_ENV", "local")
route_prefix: str = os.getenv("MODELTF_ROUTE_PREFIX", "/modelTF") route_prefix: str = os.getenv("MODELTF_ROUTE_PREFIX", "/modelTF")
app_mode: str = os.getenv("APP_MODE", "local") app_mode: str = os.getenv("APP_MODE", "local")
local_db_path: str = os.getenv("LOCAL_DB_PATH", "./runtime/platform.db") database_url: str = os.getenv("DATABASE_URL", "postgresql+psycopg://yg_ft:change_me@localhost:15432/yg_ft")
compute_mode: str = os.getenv("COMPUTE_MODE", "simulator") compute_mode: str = os.getenv("COMPUTE_MODE", "real")
compute_status_sync_mode: str = os.getenv("COMPUTE_STATUS_SYNC_MODE", "polling") 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_poll_interval_seconds: int = _int_env("COMPUTE_POLL_INTERVAL_SECONDS", 3)
log_level: str = os.getenv("LOG_LEVEL", "INFO") log_level: str = os.getenv("LOG_LEVEL", "INFO")

View File

@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import json import json
import hashlib
import hmac
import math import math
import sqlite3 import secrets
import time import time
import uuid import uuid
from contextlib import contextmanager from contextlib import contextmanager
@@ -10,6 +12,8 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Iterator from typing import Any, Iterator
import psycopg
from app.core.config import get_settings from app.core.config import get_settings
@@ -53,175 +57,133 @@ def new_id(prefix: str) -> str:
return f"{prefix}_{uuid.uuid4().hex[:12]}" return f"{prefix}_{uuid.uuid4().hex[:12]}"
class PlatformStore: PASSWORD_HASH_ITERATIONS = 390_000
"""Small SQLite-backed store for the first runnable platform version.
The production model is PostgreSQL. This store mirrors the API-facing subset
needed by the first system iteration so developers can run the app without def hash_password(password: str) -> str:
provisioning enterprise infrastructure first. salt = secrets.token_hex(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), PASSWORD_HASH_ITERATIONS)
return f"pbkdf2_sha256${PASSWORD_HASH_ITERATIONS}${salt}${digest.hex()}"
def verify_password(password: str, stored: str) -> tuple[bool, bool]:
if not stored.startswith("pbkdf2_sha256$"):
return hmac.compare_digest(password, stored), True
try:
_, iterations, salt, expected = stored.split("$", 3)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), int(iterations)).hex()
return hmac.compare_digest(digest, expected), False
except ValueError:
return False, False
def _psycopg_url(database_url: str) -> str:
return database_url.replace("postgresql+psycopg://", "postgresql://")
def _pg_sql(sql: str) -> str:
return sql.replace("?", "%s")
class PgRow(dict):
def __init__(self, columns: list[str], values: tuple[Any, ...]) -> None:
super().__init__(zip(columns, values))
self._values = values
def __getitem__(self, key: str | int) -> Any:
if isinstance(key, int):
return self._values[key]
return super().__getitem__(key)
class PgCursor:
def __init__(self, cursor: psycopg.Cursor[Any]) -> None:
self.cursor = cursor
def execute(self, sql: str, params: tuple[Any, ...] | list[Any] | None = None) -> "PgCursor":
self.cursor.execute(_pg_sql(sql), params)
return self
def fetchone(self) -> PgRow | None:
row = self.cursor.fetchone()
if row is None:
return None
return PgRow(self._columns(), tuple(row))
def fetchall(self) -> list[PgRow]:
columns = self._columns()
return [PgRow(columns, tuple(row)) for row in self.cursor.fetchall()]
def _columns(self) -> list[str]:
return [col.name for col in self.cursor.description or []]
class PgConnection:
def __init__(self, conn: psycopg.Connection[Any]) -> None:
self.conn = conn
def execute(self, sql: str, params: tuple[Any, ...] | list[Any] | None = None) -> PgCursor:
cursor = PgCursor(self.conn.cursor())
return cursor.execute(sql, params)
def executemany(self, sql: str, params_seq: list[tuple[Any, ...]] | list[list[Any]]) -> None:
with self.conn.cursor() as cursor:
cursor.executemany(_pg_sql(sql), params_seq)
def executescript(self, sql: str) -> None:
with self.conn.cursor() as cursor:
for statement in sql.split(";"):
statement = statement.strip()
if statement:
cursor.execute(statement)
def commit(self) -> None:
self.conn.commit()
def rollback(self) -> None:
self.conn.rollback()
def close(self) -> None:
self.conn.close()
class PlatformStore:
"""PostgreSQL-backed store for the first runnable platform version.
This store mirrors the API-facing subset needed by the first system
iteration while using the same PostgreSQL dependency as later production
development.
""" """
def __init__(self, db_path: str | None = None) -> None: def __init__(self, database_url: str | None = None) -> None:
settings = get_settings() settings = get_settings()
raw_path = db_path or settings.local_db_path self.database_url = _psycopg_url(database_url or settings.database_url)
self.db_path = Path(raw_path)
if not self.db_path.is_absolute():
self.db_path = Path.cwd() / self.db_path
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self.ensure_schema() self.ensure_schema()
self.ensure_seed_data() self.ensure_seed_data()
@contextmanager @contextmanager
def connect(self) -> Iterator[sqlite3.Connection]: def connect(self) -> Iterator["PgConnection"]:
conn = sqlite3.connect(self.db_path) raw_conn = psycopg.connect(self.database_url)
conn.row_factory = sqlite3.Row conn = PgConnection(raw_conn)
try: try:
yield conn yield conn
conn.commit() conn.commit()
except Exception:
conn.rollback()
raise
finally: finally:
conn.close() conn.close()
def ensure_schema(self) -> None: def ensure_schema(self) -> None:
schema_path = Path(__file__).with_name("sql") / "001_platform_runtime.sql"
with self.connect() as conn: with self.connect() as conn:
conn.executescript( conn.executescript(schema_path.read_text(encoding="utf-8"))
""" columns = conn.execute(
CREATE TABLE IF NOT EXISTS users ( "SELECT column_name FROM information_schema.columns WHERE table_name='users'"
id TEXT PRIMARY KEY, ).fetchall()
username TEXT NOT NULL UNIQUE, column_names = {row["column_name"] for row in columns}
password TEXT NOT NULL, if "password" in column_names and "password_hash" not in column_names:
display_name TEXT NOT NULL, conn.execute("ALTER TABLE users RENAME COLUMN password TO password_hash")
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,
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,
FOREIGN KEY(dataset_id) REFERENCES datasets(id) ON DELETE CASCADE
);
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,
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,
gpu_index INTEGER NOT NULL,
uuid TEXT NOT NULL,
name TEXT NOT NULL,
memory_total_gb REAL NOT NULL,
power_limit_w REAL NOT NULL,
base_temperature INTEGER NOT NULL,
FOREIGN KEY(node_id) REFERENCES compute_nodes(id) ON DELETE CASCADE
);
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,
gpus TEXT NOT NULL,
sync_job_id TEXT
);
CREATE TABLE IF NOT EXISTS resource_replicas (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
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 INDEX IF NOT EXISTS idx_fine_tune_status ON fine_tune_tasks(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 INDEX IF NOT EXISTS idx_replicas_resource ON resource_replicas(resource_type, resource_id);
"""
)
def ensure_seed_data(self) -> None: def ensure_seed_data(self) -> None:
with self.connect() as conn: with self.connect() as conn:
@@ -245,334 +207,10 @@ class PlatformStore:
conn.executemany( conn.executemany(
""" """
INSERT INTO users INSERT INTO users
(id, username, password, display_name, role, status, permissions, create_time, protected) (id, username, password_hash, display_name, role, status, permissions, create_time, protected)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
[(u[0], u[1], u[2], u[3], u[4], u[5], json_dumps(u[6]), now, u[7]) for u in users], [(u[0], u[1], hash_password(u[2]), u[3], u[4], u[5], json_dumps(u[6]), now, u[7]) for u in users],
)
models = [
(
"m_qwen25_7b",
"Qwen2.5-7B-Instruct",
"LLM",
"training",
"local",
"Sample base model for SFT and LoRA training.",
"/models/Qwen2.5-7B-Instruct",
),
(
"m_llama31_8b",
"Llama-3.1-8B-Instruct",
"LLM",
"training",
"local",
"Reserved base model path for LLaMA-Factory dry-run.",
"/models/Llama-3.1-8B-Instruct",
),
]
conn.executemany(
"""
INSERT INTO models
(id, name, type, purpose, model_source, description, path, create_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
[(*m, now) for m in models],
)
self._insert_dataset(
conn,
"ds_finance_sft",
"finance-sft-sample",
"train",
"Financial QA SFT samples for sample training.",
"finance-sft-sample.jsonl",
"\n".join(
[
'{"instruction":"Summarize revenue growth.","input":"Revenue grew from 10M to 13M.","output":"Revenue increased 30% year over year."}',
'{"instruction":"Classify risk.","input":"Customer has overdue payment for 90 days.","output":"High credit risk."}',
'{"instruction":"Draft an analyst note.","input":"Gross margin improved by 4 points.","output":"Margin expansion indicates stronger operating leverage."}',
]
),
now,
)
self._insert_dataset(
conn,
"ds_customer_service",
"customer-service-sample",
"train",
"Customer-service instruction tuning samples.",
"customer-service-sample.jsonl",
"\n".join(
[
'{"instruction":"Respond politely.","input":"My package is late.","output":"I am sorry for the delay. I can help check the latest shipment status."}',
'{"instruction":"Escalate request.","input":"I need a refund for a defective item.","output":"I will create a refund case and share the next steps."}',
]
),
now,
)
node_rows = [
(
"node_01",
"gpu-node-01",
"GPU Node 01",
"http://gpu-node-01:19100",
"http://gpu-node-01:19101",
1,
"online",
100,
["A800", "80GB", "llama_factory"],
4,
0,
2,
"/data/yg-ft",
"/models",
"/data/yg-ft/training-logs",
now,
{"mode": "simulator", "heartbeat": "ok"},
),
(
"node_02",
"gpu-node-02",
"GPU Node 02",
"http://gpu-node-02:19100",
"http://gpu-node-02:19101",
1,
"online",
60,
["4090", "24GB", "llama_factory"],
4,
0,
1,
"/data/yg-ft",
"/models",
"/data/yg-ft/training-logs",
now,
{"mode": "simulator", "heartbeat": "ok"},
),
]
conn.executemany(
"""
INSERT INTO compute_nodes
(id, code, name, api_base_url, file_gateway_url, enabled, scheduler_status,
scheduler_weight, tags, gpu_count, current_running_jobs, max_parallel_jobs,
data_root, model_root, log_root, last_health_check_at, health_detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
r[0],
r[1],
r[2],
r[3],
r[4],
r[5],
r[6],
r[7],
json_dumps(r[8]),
r[9],
r[10],
r[11],
r[12],
r[13],
r[14],
r[15],
json_dumps(r[16]),
)
for r in node_rows
],
)
for node_id, model_name, mem in [
("node_01", "NVIDIA A800-SXM4-80GB", 80),
("node_02", "NVIDIA GeForce RTX 4090", 24),
]:
for idx in range(4):
conn.execute(
"""
INSERT INTO gpus
(id, node_id, gpu_index, uuid, name, memory_total_gb, power_limit_w, base_temperature)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
f"{node_id}_gpu_{idx}",
node_id,
idx,
f"GPU-{node_id.upper()}-{idx}",
model_name,
mem,
300 if mem >= 80 else 450,
35 + idx,
),
)
self._insert_task(
conn,
task_id="ft_pending_sample",
name="pending-sft-sample",
status="pending",
progress=0,
create_time=now,
start_time=None,
completed_at=None,
node_id=None,
gpus=[],
process_id=None,
)
started = datetime.now(timezone.utc).timestamp() - 22
self._insert_task(
conn,
task_id="ft_running_sample",
name="running-sft-sample",
status="running",
progress=45,
create_time=now,
start_time=datetime.fromtimestamp(started, timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
completed_at=None,
node_id="node_01",
gpus=[0, 1],
process_id=42001,
)
self._insert_task(
conn,
task_id="ft_completed_sample",
name="completed-sft-sample",
status="completed",
progress=100,
create_time=now,
start_time=now,
completed_at=now,
node_id="node_01",
gpus=[2],
process_id=42002,
)
conn.execute(
"""
INSERT INTO trained_models
(id, name, train_methods, base_model_path, create_time, merged, merging, merged_path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"tm_completed_sample",
"completed-sft-sample-lora",
json_dumps([{"name": "lora"}]),
"/models/Qwen2.5-7B-Instruct",
now,
0,
0,
"/data/yg-ft/outputs/completed-sft-sample/adapter",
),
)
def _insert_dataset(
self,
conn: sqlite3.Connection,
dataset_id: str,
name: str,
dataset_type: str,
description: str,
file_name: str,
content: str,
now: str,
) -> None:
lines = [line for line in content.splitlines() if line.strip()]
size = f"{max(1, len(content.encode('utf-8')) // 1024)} KB"
file_id = f"{dataset_id}_file_1"
version_id = f"{file_id}_v1"
conn.execute(
"""
INSERT INTO datasets
(id, name, type, storage_type, source, size, count, description, create_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(dataset_id, name, dataset_type, "local", "upload", size, len(lines), description, now),
)
conn.execute(
"""
INSERT INTO dataset_files
(id, dataset_id, name, size, content, active_version_id, versions, create_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
file_id,
dataset_id,
file_name,
size,
content,
version_id,
json_dumps([{"id": version_id, "version": 1, "create_time": now, "description": "initial seed"}]),
now,
),
)
def _insert_task(
self,
conn: sqlite3.Connection,
task_id: str,
name: str,
status: str,
progress: int,
create_time: str,
start_time: str | None,
completed_at: str | None,
node_id: str | None,
gpus: list[int],
process_id: int | None,
) -> None:
payload = {
"id": task_id,
"name": name,
"description": "Seeded sample fine-tune task.",
"status": status,
"train_type": "SFT",
"train_method": "lora",
"template": "qwen",
"base_model": "m_qwen25_7b",
"train_dataset_id": "ds_finance_sft",
"auto_merge": False,
"output_model_name": f"{name}-lora",
"gpus": gpus,
"batch_size": 2,
"learning_rate": 0.0002,
"n_epochs": 3,
"save_steps": 50,
"lr_scheduler_type": "cosine",
"max_length": 2048,
"warmup_ratio": 0.03,
"weight_decay": 0.01,
"lora_alpha": 16,
"lora_dropout": 0.05,
"lora_rank": 8,
"quantization_bit": 4,
"export_quantized": False,
"quant_method": "bnb",
"quant_bits": 4,
"quant_group_size": 128,
"export_format": "safetensors",
"progress": progress,
"process_id": process_id,
"train_duration": self._duration(start_time, completed_at),
"create_time": create_time,
}
conn.execute(
"""
INSERT INTO fine_tune_tasks
(id, name, payload, status, progress, process_id, create_time, start_time, completed_at, compute_node_id, gpus)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
task_id,
name,
json_dumps(payload),
status,
progress,
process_id,
create_time,
start_time,
completed_at,
node_id,
json_dumps(gpus),
),
) )
def _duration(self, start_time: str | None, end_time: str | None = None) -> str: def _duration(self, start_time: str | None, end_time: str | None = None) -> str:
@@ -590,6 +228,9 @@ class PlatformStore:
return f"{sec}s" return f"{sec}s"
def refresh_runtime_state(self) -> None: def refresh_runtime_state(self) -> None:
if get_settings().compute_mode != "simulator":
return
with self.connect() as conn: with self.connect() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT * FROM fine_tune_tasks WHERE status IN ('syncing','queued','running')" "SELECT * FROM fine_tune_tasks WHERE status IN ('syncing','queued','running')"
@@ -644,7 +285,7 @@ class PlatformStore:
(status, progress, completed_at, row["id"]), (status, progress, completed_at, row["id"]),
) )
def _ensure_trained_model(self, conn: sqlite3.Connection, task: dict[str, Any]) -> None: def _ensure_trained_model(self, conn: PgConnection, task: dict[str, Any]) -> None:
name = task.get("output_model_name") or f"{task['name']}-lora" name = task.get("output_model_name") or f"{task['name']}-lora"
exists = conn.execute("SELECT id FROM trained_models WHERE name=?", (name,)).fetchone() exists = conn.execute("SELECT id FROM trained_models WHERE name=?", (name,)).fetchone()
if exists: if exists:
@@ -676,9 +317,18 @@ class PlatformStore:
def login(self, username: str, password: str) -> dict[str, Any] | None: def login(self, username: str, password: str) -> dict[str, Any] | None:
with self.connect() as conn: with self.connect() as conn:
row = conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone() row = conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
if not row or row["password"] != password or row["status"] != "active": if not row or row["status"] != "active":
return None
matched, legacy_plaintext = verify_password(password, row["password_hash"])
if not matched:
return None return None
last_login = utcnow() last_login = utcnow()
if legacy_plaintext:
conn.execute(
"UPDATE users SET password_hash=?, last_login=? WHERE id=?",
(hash_password(password), last_login, row["id"]),
)
else:
conn.execute("UPDATE users SET last_login=? WHERE id=?", (last_login, row["id"])) conn.execute("UPDATE users SET last_login=? WHERE id=?", (last_login, row["id"]))
data = self._user(row) data = self._user(row)
data["last_login"] = last_login data["last_login"] = last_login
@@ -691,13 +341,13 @@ class PlatformStore:
conn.execute( conn.execute(
""" """
INSERT INTO users INSERT INTO users
(id, username, password, display_name, role, status, permissions, create_time, protected) (id, username, password_hash, display_name, role, status, permissions, create_time, protected)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
""", """,
( (
user_id, user_id,
payload["username"], payload["username"],
payload.get("password", "platform123"), hash_password(payload.get("password", "platform123")),
payload.get("display_name") or payload["username"], payload.get("display_name") or payload["username"],
payload.get("role", "viewer"), payload.get("role", "viewer"),
payload.get("status", "active"), payload.get("status", "active"),
@@ -732,7 +382,7 @@ class PlatformStore:
raise ValueError("protected user cannot be deleted") raise ValueError("protected user cannot be deleted")
conn.execute("DELETE FROM users WHERE id=?", (user_id,)) conn.execute("DELETE FROM users WHERE id=?", (user_id,))
def _user(self, row: sqlite3.Row) -> dict[str, Any]: def _user(self, row: PgRow) -> dict[str, Any]:
return { return {
"id": row["id"], "id": row["id"],
"username": row["username"], "username": row["username"],
@@ -843,7 +493,7 @@ class PlatformStore:
raise KeyError(dataset_id) raise KeyError(dataset_id)
return self._dataset(conn, row) return self._dataset(conn, row)
def _dataset(self, conn: sqlite3.Connection, row: sqlite3.Row) -> dict[str, Any]: def _dataset(self, conn: PgConnection, row: PgRow) -> dict[str, Any]:
files = conn.execute( files = conn.execute(
"SELECT id, name, size, active_version_id, create_time FROM dataset_files WHERE dataset_id=? ORDER BY create_time", "SELECT id, name, size, active_version_id, create_time FROM dataset_files WHERE dataset_id=? ORDER BY create_time",
(row["id"],), (row["id"],),
@@ -884,9 +534,6 @@ class PlatformStore:
utcnow(), utcnow(),
), ),
) )
if not payload.get("files"):
content = '{"instruction":"Sample instruction","input":"Sample input","output":"Sample output"}'
self.add_dataset_file(conn, dataset_id, "sample.jsonl", content)
return self._dataset(conn, conn.execute("SELECT * FROM datasets WHERE id=?", (dataset_id,)).fetchone()) return self._dataset(conn, conn.execute("SELECT * FROM datasets WHERE id=?", (dataset_id,)).fetchone())
def update_dataset(self, dataset_id: str, payload: dict[str, Any]) -> dict[str, Any]: def update_dataset(self, dataset_id: str, payload: dict[str, Any]) -> dict[str, Any]:
@@ -918,7 +565,7 @@ class PlatformStore:
conn.execute("DELETE FROM dataset_files WHERE dataset_id=?", (dataset_id,)) conn.execute("DELETE FROM dataset_files WHERE dataset_id=?", (dataset_id,))
conn.execute("DELETE FROM datasets WHERE id=?", (dataset_id,)) conn.execute("DELETE FROM datasets WHERE id=?", (dataset_id,))
def add_dataset_file(self, conn: sqlite3.Connection, dataset_id: str, name: str, content: str) -> dict[str, Any]: def add_dataset_file(self, conn: PgConnection, dataset_id: str, name: str, content: str) -> dict[str, Any]:
now = utcnow() now = utcnow()
file_id = new_id("file") file_id = new_id("file")
version_id = f"{file_id}_v1" version_id = f"{file_id}_v1"
@@ -947,7 +594,7 @@ class PlatformStore:
) )
return {"id": file_id, "name": name, "size": size} return {"id": file_id, "name": name, "size": size}
def dataset_file(self, file_id: str) -> sqlite3.Row: def dataset_file(self, file_id: str) -> PgRow:
with self.connect() as conn: with self.connect() as conn:
row = conn.execute("SELECT * FROM dataset_files WHERE id=?", (file_id,)).fetchone() row = conn.execute("SELECT * FROM dataset_files WHERE id=?", (file_id,)).fetchone()
if not row: if not row:
@@ -1008,7 +655,7 @@ class PlatformStore:
raise KeyError(task_id) raise KeyError(task_id)
return self._task(row) return self._task(row)
def _task(self, row: sqlite3.Row) -> dict[str, Any]: def _task(self, row: PgRow) -> dict[str, Any]:
payload = json_loads(row["payload"], {}) payload = json_loads(row["payload"], {})
payload.update( payload.update(
{ {
@@ -1028,6 +675,12 @@ class PlatformStore:
def create_task(self, payload: dict[str, Any]) -> dict[str, Any]: def create_task(self, payload: dict[str, Any]) -> dict[str, Any]:
task_id = str(payload.get("task_id") or payload.get("id") or new_id("ft")) task_id = str(payload.get("task_id") or payload.get("id") or new_id("ft"))
name = payload.get("name") or f"fine-tune-{task_id[-6:]}" name = payload.get("name") or f"fine-tune-{task_id[-6:]}"
base_model = payload.get("base_model") or payload.get("base_model_id")
train_dataset_id = payload.get("train_dataset_id")
if not base_model:
raise ValueError("base_model or base_model_id is required")
if not train_dataset_id:
raise ValueError("train_dataset_id is required")
now = utcnow() now = utcnow()
task = { task = {
"id": task_id, "id": task_id,
@@ -1037,8 +690,8 @@ class PlatformStore:
"train_type": payload.get("train_type", "SFT"), "train_type": payload.get("train_type", "SFT"),
"train_method": payload.get("train_method", "lora"), "train_method": payload.get("train_method", "lora"),
"template": payload.get("template", "qwen"), "template": payload.get("template", "qwen"),
"base_model": payload.get("base_model") or payload.get("base_model_id") or "m_qwen25_7b", "base_model": base_model,
"train_dataset_id": payload.get("train_dataset_id") or "ds_finance_sft", "train_dataset_id": train_dataset_id,
"auto_merge": bool(payload.get("auto_merge", False)), "auto_merge": bool(payload.get("auto_merge", False)),
"output_model_name": payload.get("output_model_name") or f"{name}-lora", "output_model_name": payload.get("output_model_name") or f"{name}-lora",
"gpus": payload.get("gpus") or [], "gpus": payload.get("gpus") or [],
@@ -1173,7 +826,7 @@ class PlatformStore:
"pending": "waiting for start", "pending": "waiting for start",
"syncing": "syncing model and dataset to compute node", "syncing": "syncing model and dataset to compute node",
"queued": "waiting for GPU slot", "queued": "waiting for GPU slot",
"running": "training with LLaMA-Factory simulator", "running": "training with LLaMA-Factory",
"completed": "training completed", "completed": "training completed",
"failed": "training stopped", "failed": "training stopped",
} }
@@ -1183,7 +836,7 @@ class PlatformStore:
"status": status, "status": status,
"progress": progress, "progress": progress,
"step": labels.get(status, status), "step": labels.get(status, status),
"speed": "42.5 samples/s" if status == "running" else "--", "speed": task.get("train_speed") or "--",
"eta": eta, "eta": eta,
} }
@@ -1234,6 +887,40 @@ class PlatformStore:
) )
return next(n for n in self.compute_nodes() if n["id"] == node_id) return next(n for n in self.compute_nodes() if n["id"] == node_id)
def create_compute_node(self, payload: dict[str, Any]) -> dict[str, Any]:
node_id = payload.get("id") or new_id("node")
now = utcnow()
tags = payload.get("tags") or []
with self.connect() as conn:
conn.execute(
"""
INSERT INTO compute_nodes
(id, code, name, api_base_url, file_gateway_url, enabled, scheduler_status,
scheduler_weight, tags, gpu_count, current_running_jobs, max_parallel_jobs,
data_root, model_root, log_root, last_health_check_at, health_detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
""",
(
node_id,
payload["code"],
payload.get("name") or payload["code"],
payload["api_base_url"],
payload.get("file_gateway_url") or payload["api_base_url"],
1 if payload.get("enabled", True) else 0,
payload.get("scheduler_status", "offline"),
int(payload.get("scheduler_weight", 100)),
json_dumps(tags),
int(payload.get("gpu_count", 0)),
int(payload.get("max_parallel_jobs", 1)),
payload.get("data_root", "/data/yg-ft"),
payload.get("model_root", "/models"),
payload.get("log_root", "/data/yg-ft/training-logs"),
now,
json_dumps(payload.get("health_detail") or {"status": "registered"}),
),
)
return next(node for node in self.compute_nodes() if node["id"] == node_id)
def gpus(self) -> list[dict[str, Any]]: def gpus(self) -> list[dict[str, Any]]:
self.refresh_runtime_state() self.refresh_runtime_state()
with self.connect() as conn: with self.connect() as conn:
@@ -1367,30 +1054,7 @@ class PlatformStore:
"SELECT * FROM resource_replicas WHERE node_id=? ORDER BY create_time DESC", "SELECT * FROM resource_replicas WHERE node_id=? ORDER BY create_time DESC",
(node_id,), (node_id,),
).fetchall() ).fetchall()
if rows:
return [dict(row) for row in rows] return [dict(row) for row in rows]
return [
{
"id": f"rep_{node_id}_model_qwen",
"node_id": node_id,
"resource_type": "model",
"resource_id": "m_qwen25_7b",
"local_path": "/models/Qwen2.5-7B-Instruct",
"status": "available",
"sync_status": "completed",
"create_time": utcnow(),
},
{
"id": f"rep_{node_id}_dataset_finance",
"node_id": node_id,
"resource_type": "dataset",
"resource_id": "ds_finance_sft",
"local_path": "/data/yg-ft/datasets/finance-sft-sample.jsonl",
"status": "available",
"sync_status": "completed",
"create_time": utcnow(),
},
]
def sync_job(self, sync_id: str) -> dict[str, Any]: def sync_job(self, sync_id: str) -> dict[str, Any]:
self.refresh_runtime_state() self.refresh_runtime_state()
@@ -1423,7 +1087,7 @@ class PlatformStore:
progress = int(task.get("progress", 0) or 0) progress = int(task.get("progress", 0) or 0)
points = max(1, min(80, progress)) points = max(1, min(80, progress))
lines = [ lines = [
f"[INFO] task={task['name']} engine=llama_factory mode=simulator status={task['status']}", f"[INFO] task={task['name']} engine=llama_factory status={task['status']}",
f"[INFO] base_model={task.get('base_model')} dataset={task.get('train_dataset_id')} gpus={task.get('gpus', [])}", f"[INFO] base_model={task.get('base_model')} dataset={task.get('train_dataset_id')} gpus={task.get('gpus', [])}",
"[INFO] command=llamafactory-cli train --stage sft --finetuning_type lora --do_train true", "[INFO] command=llamafactory-cli train --stage sft --finetuning_type lora --do_train true",
] ]

View File

@@ -8,13 +8,12 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./runtime/platform.db") DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+psycopg://yg_ft:change_me@localhost:15432/yg_ft")
engine = create_engine( engine = create_engine(
DATABASE_URL, DATABASE_URL,
pool_pre_ping=True, pool_pre_ping=True,
future=True, future=True,
connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {},
) )
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False, future=True) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False, future=True)

View File

@@ -0,0 +1,133 @@
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,
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
);
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
);
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 INDEX IF NOT EXISTS idx_fine_tune_status ON fine_tune_tasks(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 INDEX IF NOT EXISTS idx_replicas_resource ON resource_replicas(resource_type, resource_id);

View File

@@ -2,14 +2,14 @@
name = "yg-ft-backend" name = "yg-ft-backend"
version = "0.1.0" version = "0.1.0"
description = "Backend service for the model fine-tuning platform" description = "Backend service for the model fine-tuning platform"
requires-python = ">=3.11" requires-python = ">=3.12"
dependencies = [ dependencies = [
"fastapi>=0.111.0", "fastapi>=0.111.0",
"uvicorn[standard]>=0.30.0", "uvicorn[standard]>=0.30.0",
"python-multipart>=0.0.9", "python-multipart>=0.0.9",
"pydantic>=2.7.0", "pydantic>=2.7.0",
"sqlalchemy>=2.0.30", "sqlalchemy>=2.0.30",
"asyncpg>=0.29.0", "psycopg[binary]>=3.2.1",
"alembic>=1.13.1", "alembic>=1.13.1",
"redis>=5.0.4", "redis>=5.0.4",
"httpx>=0.27.0", "httpx>=0.27.0",
@@ -26,7 +26,7 @@ dev = [
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
target-version = "py311" target-version = "py312"
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]

View File

@@ -3,7 +3,7 @@ uvicorn[standard]>=0.30.0
python-multipart>=0.0.9 python-multipart>=0.0.9
pydantic>=2.7.0 pydantic>=2.7.0
sqlalchemy>=2.0.30 sqlalchemy>=2.0.30
asyncpg>=0.29.0 psycopg[binary]>=3.2.1
alembic>=1.13.1 alembic>=1.13.1
redis>=5.0.4 redis>=5.0.4
httpx>=0.27.0 httpx>=0.27.0

View File

@@ -14,7 +14,7 @@ compute/
tests/ tests/
``` ```
## 第一版职责 ## 开发职责
- GPU 发现、状态上报、锁定和释放。 - GPU 发现、状态上报、锁定和释放。
- 本地磁盘工作区管理。 - 本地磁盘工作区管理。
@@ -22,3 +22,8 @@ compute/
- LLaMA-Factory 命令生成、日志解析、产物收集。 - LLaMA-Factory 命令生成、日志解析、产物收集。
- 分片上传、短时下载、离线导入。 - 分片上传、短时下载、离线导入。
- 通过服务间 token 接受应用平台调用。 - 通过服务间 token 接受应用平台调用。
## 运行模式
- 默认 `COMPUTE_EXECUTION_MODE=real`Compute API 只暴露健康检查和接口契约;真实训练执行器完成前,创建作业会返回未实现错误。
- 仅隔离联调时可设置 `COMPUTE_EXECUTION_MODE=simulator`,启用内存状态机和合成 GPU/日志数据。该模式不得作为生产运行路径。

View File

@@ -22,7 +22,12 @@ def create_app() -> FastAPI:
def host_id() -> str: def host_id() -> str:
return os.getenv("COMPUTE_HOST_ID", "gpu-node-01") 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 job_status(job: dict[str, Any]) -> dict[str, Any]: def job_status(job: dict[str, Any]) -> dict[str, Any]:
if execution_mode() != "simulator":
return job
elapsed = max(0, int(now() - job["created_at"])) elapsed = max(0, int(now() - job["created_at"]))
if job["status"] not in {"stopped", "failed", "completed"}: if job["status"] not in {"stopped", "failed", "completed"}:
if elapsed < 5: if elapsed < 5:
@@ -41,7 +46,7 @@ def create_app() -> FastAPI:
progress = int(job.get("progress", 0) or 0) progress = int(job.get("progress", 0) or 0)
points = max(1, min(80, progress)) points = max(1, min(80, progress))
lines = [ lines = [
f"[INFO] compute_host_id={host_id()} job_id={job['id']} engine=llama_factory mode=simulator", f"[INFO] compute_host_id={host_id()} job_id={job['id']} engine=llama_factory",
f"[INFO] command={' '.join(job['command'])}", f"[INFO] command={' '.join(job['command'])}",
] ]
for step in range(1, points + 1): for step in range(1, points + 1):
@@ -70,6 +75,8 @@ def create_app() -> FastAPI:
return "\n".join(lines) return "\n".join(lines)
def gpu_resources() -> list[dict[str, Any]]: def gpu_resources() -> list[dict[str, Any]]:
if execution_mode() != "simulator":
return []
active_jobs = [job_status(job) for job in jobs.values() if job["status"] in {"queued", "running"}] active_jobs = [job_status(job) for job in jobs.values() if job["status"] in {"queued", "running"}]
gpus: list[dict[str, Any]] = [] gpus: list[dict[str, Any]] = []
for idx in range(4): for idx in range(4):
@@ -121,6 +128,7 @@ def create_app() -> FastAPI:
"data_root_exists": data_root.exists(), "data_root_exists": data_root.exists(),
"llama_factory_home": str(llama_factory_home), "llama_factory_home": str(llama_factory_home),
"llama_factory_home_exists": llama_factory_home.exists(), "llama_factory_home_exists": llama_factory_home.exists(),
"execution_mode": execution_mode(),
} }
@app.get(f"{route_prefix}/v1/compute/jobs") @app.get(f"{route_prefix}/v1/compute/jobs")
@@ -137,6 +145,11 @@ def create_app() -> FastAPI:
command = build_command(payload, os.getenv("LLAMA_FACTORY_HOME", "/app/LLaMA-Factory")) command = build_command(payload, os.getenv("LLAMA_FACTORY_HOME", "/app/LLaMA-Factory"))
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) raise HTTPException(status_code=400, detail=str(exc))
if execution_mode() != "simulator":
raise HTTPException(
status_code=501,
detail="real compute executor is not implemented yet; set COMPUTE_EXECUTION_MODE=simulator only for isolated development",
)
job_id = str(payload.get("id") or f"job_{int(now() * 1000)}") job_id = str(payload.get("id") or f"job_{int(now() * 1000)}")
job = { job = {
"id": job_id, "id": job_id,

View File

@@ -35,7 +35,7 @@ def build_command(config: dict[str, Any], llama_factory_home: str = "/app/LLaMA-
model_path = config.get("base_model") or config.get("model_name_or_path") model_path = config.get("base_model") or config.get("model_name_or_path")
dataset = config.get("dataset") or config.get("dataset_dir") dataset = config.get("dataset") or config.get("dataset_dir")
output_dir = config.get("output_dir") or f"/data/yg-ft/outputs/{config.get('name', 'sample-job')}" output_dir = config.get("output_dir") or f"/data/yg-ft/outputs/{config.get('name', 'training-job')}"
command = [ command = [
"llamafactory-cli", "llamafactory-cli",
"train", "train",

View File

@@ -69,6 +69,12 @@ $images | ForEach-Object { docker pull $_ }
- `COMPUTE_API_PORT=19100` - `COMPUTE_API_PORT=19100`
- `FILE_GATEWAY_PORT=19101` - `FILE_GATEWAY_PORT=19101`
## 运行模式
- 应用侧默认 `COMPUTE_MODE=real`,任务状态必须由真实算力同步逻辑更新。
- 算力侧默认 `COMPUTE_EXECUTION_MODE=real`,真实执行器未完成前不会伪造训练作业。
- 仅隔离联调时可显式设置 `COMPUTE_MODE=simulator``COMPUTE_EXECUTION_MODE=simulator`,该模式不得用于测试环境、生产环境或生产升级基线。
## 应用服务器部署 ## 应用服务器部署
应用服务器包含前端 Nginx、Backend API、PostgreSQL、Redis。 应用服务器包含前端 Nginx、Backend API、PostgreSQL、Redis。
@@ -112,7 +118,7 @@ http://<app-server-ip>:16801
如果使用企业统一 PostgreSQL/Redis修改 `docker/app/.env` 如果使用企业统一 PostgreSQL/Redis修改 `docker/app/.env`
```env ```env
DATABASE_URL=postgresql+asyncpg://<user>:<password>@<postgres-host>:15432/<db> DATABASE_URL=postgresql+psycopg://<user>:<password>@<postgres-host>:15432/<db>
REDIS_URL=redis://<redis-host>:16379/0 REDIS_URL=redis://<redis-host>:16379/0
USE_BUILTIN_POSTGRES=false USE_BUILTIN_POSTGRES=false
USE_BUILTIN_REDIS=false USE_BUILTIN_REDIS=false

View File

@@ -14,7 +14,7 @@ REDIS_PORT=16379
POSTGRES_DB=yg_ft POSTGRES_DB=yg_ft
POSTGRES_USER=yg_ft POSTGRES_USER=yg_ft
POSTGRES_PASSWORD=change_me POSTGRES_PASSWORD=change_me
DATABASE_URL=postgresql+asyncpg://yg_ft:change_me@postgres:5432/yg_ft DATABASE_URL=postgresql+psycopg://yg_ft:change_me@postgres:5432/yg_ft
REDIS_URL=redis://redis:6379/0 REDIS_URL=redis://redis:6379/0
@@ -38,6 +38,7 @@ COMPUTE_SERVICE_TOKEN=change_me
FILE_GATEWAY_BASE_URL=http://compute-api:9101 FILE_GATEWAY_BASE_URL=http://compute-api:9101
# The application side polls Compute API for job state to avoid opening reverse network access. # The application side polls Compute API for job state to avoid opening reverse network access.
COMPUTE_MODE=real
COMPUTE_STATUS_SYNC_MODE=polling COMPUTE_STATUS_SYNC_MODE=polling
COMPUTE_POLL_INTERVAL_SECONDS=10 COMPUTE_POLL_INTERVAL_SECONDS=10
COMPUTE_POLL_BATCH_SIZE=100 COMPUTE_POLL_BATCH_SIZE=100

View File

@@ -32,7 +32,7 @@ services:
APP_ENV: ${APP_ENV:-prod} APP_ENV: ${APP_ENV:-prod}
APP_NAME: ${APP_NAME:-YG Fine-Tune Platform API} APP_NAME: ${APP_NAME:-YG Fine-Tune Platform API}
MODELTF_ROUTE_PREFIX: ${MODELTF_ROUTE_PREFIX:-/modelTF} MODELTF_ROUTE_PREFIX: ${MODELTF_ROUTE_PREFIX:-/modelTF}
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://yg_ft:change_me@postgres:5432/yg_ft} DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://yg_ft:change_me@postgres:5432/yg_ft}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
USE_BUILTIN_POSTGRES: ${USE_BUILTIN_POSTGRES:-true} USE_BUILTIN_POSTGRES: ${USE_BUILTIN_POSTGRES:-true}
USE_BUILTIN_REDIS: ${USE_BUILTIN_REDIS:-true} USE_BUILTIN_REDIS: ${USE_BUILTIN_REDIS:-true}
@@ -45,6 +45,7 @@ services:
COMPUTE_API_BASE_URL: ${COMPUTE_API_BASE_URL:-http://compute-api:9100} COMPUTE_API_BASE_URL: ${COMPUTE_API_BASE_URL:-http://compute-api:9100}
COMPUTE_SERVICE_TOKEN: ${COMPUTE_SERVICE_TOKEN:-change_me} COMPUTE_SERVICE_TOKEN: ${COMPUTE_SERVICE_TOKEN:-change_me}
FILE_GATEWAY_BASE_URL: ${FILE_GATEWAY_BASE_URL:-http://compute-api:9101} 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_STATUS_SYNC_MODE: ${COMPUTE_STATUS_SYNC_MODE:-polling}
COMPUTE_POLL_INTERVAL_SECONDS: ${COMPUTE_POLL_INTERVAL_SECONDS:-10} COMPUTE_POLL_INTERVAL_SECONDS: ${COMPUTE_POLL_INTERVAL_SECONDS:-10}
COMPUTE_POLL_BATCH_SIZE: ${COMPUTE_POLL_BATCH_SIZE:-100} COMPUTE_POLL_BATCH_SIZE: ${COMPUTE_POLL_BATCH_SIZE:-100}

View File

@@ -1,5 +1,6 @@
COMPUTE_ENV=prod COMPUTE_ENV=prod
COMPUTE_HOST_ID=gpu-node-01 COMPUTE_HOST_ID=gpu-node-01
COMPUTE_EXECUTION_MODE=real
MODELTF_ROUTE_PREFIX=/modelTF MODELTF_ROUTE_PREFIX=/modelTF
# Five-digit host ports exposed outside the compute server. # Five-digit host ports exposed outside the compute server.
COMPUTE_API_PORT=19100 COMPUTE_API_PORT=19100

View File

@@ -8,6 +8,7 @@ services:
environment: environment:
COMPUTE_ENV: ${COMPUTE_ENV:-prod} COMPUTE_ENV: ${COMPUTE_ENV:-prod}
COMPUTE_HOST_ID: ${COMPUTE_HOST_ID:-gpu-node-01} COMPUTE_HOST_ID: ${COMPUTE_HOST_ID:-gpu-node-01}
COMPUTE_EXECUTION_MODE: ${COMPUTE_EXECUTION_MODE:-real}
MODELTF_ROUTE_PREFIX: ${MODELTF_ROUTE_PREFIX:-/modelTF} MODELTF_ROUTE_PREFIX: ${MODELTF_ROUTE_PREFIX:-/modelTF}
COMPUTE_SERVICE_TOKEN: ${COMPUTE_SERVICE_TOKEN:-change_me} COMPUTE_SERVICE_TOKEN: ${COMPUTE_SERVICE_TOKEN:-change_me}
ENABLE_APP_CALLBACK: ${ENABLE_APP_CALLBACK:-false} ENABLE_APP_CALLBACK: ${ENABLE_APP_CALLBACK:-false}

View File

@@ -1,6 +1,8 @@
# 模型微调平台后端接口设计 # 模型微调平台后端接口设计
> 后端建议使用 FastAPI统一挂载 `/modelTF` 前缀。本文根据当前 Vue 前端路由、API 模块、mock 数据和页面交互反推接口,并补充完整微调平台必须具备的用户中心、权限控制、审计、异步任务、文件版本与监控能力。 > 后端建议使用 FastAPI统一挂载 `/modelTF` 前缀。本文根据当前 Vue 前端路由、API 模块和页面交互契约梳理接口,并补充完整微调平台必须具备的用户中心、权限控制、审计、异步任务、文件版本与监控能力。前端 Mock 仅作为隔离开发辅助,不作为接口设计准则。
菜单、二级路由、规划菜单、接口和数据库的总览映射见 `docs/menu-functional-requirements.md`。后续新增接口时,必须同步标注对应页面/功能模块。
## 1. 通用约定 ## 1. 通用约定
@@ -88,7 +90,7 @@ page=1&page_size=20&keyword=xxx&sort=-created_at
} }
``` ```
说明:前端当前登录接口已经要求返回 `user`mock 里只返回 token正式后端必须返回完整用户信息。 说明:前端当前登录接口已经要求返回 `user`,正式后端必须返回完整用户信息。
### 2.2 当前用户 ### 2.2 当前用户
@@ -158,7 +160,7 @@ page=1&page_size=20&keyword=xxx&sort=-created_at
} }
``` ```
说明:首页当前完全使用前端 mock后端提供聚合接口,避免前端拼多接口导致加载慢。 说明:首页需要由后端提供正式聚合接口,避免前端拼多接口导致加载慢。
### 3.2 健康指标 ### 3.2 健康指标
@@ -318,7 +320,7 @@ page=1&page_size=20&keyword=xxx&sort=-created_at
## 6. 数据处理 ## 6. 数据处理
当前数据处理页面为本地模拟,正式后端建议实现以下接口。 当前数据处理页面需要后端正式承接上传、切片、生成、编辑和发布流程,建议实现以下接口。
### 6.1 任务列表与详情 ### 6.1 任务列表与详情
@@ -678,7 +680,7 @@ page=1&page_size=20&keyword=xxx&sort=-created_at
## 11. 后端开发需要补齐的关键点 ## 11. 后端开发需要补齐的关键点
1. 前端路由已有 `user-settings``user-create``user-permission``permission-denied`,但当前仓库缺少对应 Vue 文件;后端仍应先实现用户中心和权限接口。 1. 前端路由已有 `user-settings``user-create``user-permission``permission-denied`,但当前仓库缺少对应 Vue 文件;后端仍应先实现用户中心和权限接口。
2. 数据处理主流程目前全在浏览器本地模拟,后端需要正式实现上传、切片、LLM 生成、结果编辑、发布数据集。 2. 数据处理主流程需要后端正式实现上传、切片、LLM 生成、结果编辑、发布数据集。
3. 训练、评测、数据处理、模型加载都不应同步阻塞 HTTP建议接 Celery/RQ/Arq 或 FastAPI BackgroundTasks + 独立 worker。 3. 训练、评测、数据处理、模型加载都不应同步阻塞 HTTP建议接 Celery/RQ/Arq 或 FastAPI BackgroundTasks + 独立 worker。
4. 文件内容不要全部入库;数据库保存元数据、版本、校验和、对象存储路径,内容放本地 NAS/MinIO。 4. 文件内容不要全部入库;数据库保存元数据、版本、校验和、对象存储路径,内容放本地 NAS/MinIO。
5. API Key、外部数据源密码必须加密存储接口只回显脱敏。 5. API Key、外部数据源密码必须加密存储接口只回显脱敏。

View File

@@ -63,7 +63,7 @@
### 4.1 适用场景 ### 4.1 适用场景
- PoC、试点环境、演示环境。 - 开发联调、单机试运行、资源受限的早期上线环境。
- 小团队共用一台单机多 GPU 服务器。 - 小团队共用一台单机多 GPU 服务器。
- 网络隔离要求不高,部署资源有限。 - 网络隔离要求不高,部署资源有限。
@@ -272,7 +272,7 @@ compute/engines/openrlhf/
```env ```env
APP_ENV=prod APP_ENV=prod
MODELTF_ROUTE_PREFIX=/modelTF MODELTF_ROUTE_PREFIX=/modelTF
DATABASE_URL=postgresql+asyncpg://yg_ft:***@postgres:5432/yg_ft DATABASE_URL=postgresql+psycopg://yg_ft:***@postgres:5432/yg_ft
REDIS_URL=redis://redis:6379/0 REDIS_URL=redis://redis:6379/0
LOG_DIR=/opt/yg-ft/logs/backend LOG_DIR=/opt/yg-ft/logs/backend
COMPUTE_API_BASE_URL=https://compute.internal:19100 COMPUTE_API_BASE_URL=https://compute.internal:19100
@@ -327,7 +327,7 @@ CUDA_VISIBLE_DEVICES=0,1,2,3
- 后端 `GET /modelTF/health` 正常。 - 后端 `GET /modelTF/health` 正常。
- Compute API 健康检查正常。 - Compute API 健康检查正常。
- Compute Agent 能识别 GPU、显存、CUDA 版本。 - Compute Agent 能识别 GPU、显存、CUDA 版本。
- LLaMA-Factory 能在命令行完成最小训练样例 - LLaMA-Factory 能在命令行完成最小训练作业
- 应用平台能提交训练任务到 Compute API。 - 应用平台能提交训练任务到 Compute API。
- 任务状态能从算力平台同步回应用平台。 - 任务状态能从算力平台同步回应用平台。
- 数据集上传、离线导入、产物下载路径权限正确。 - 数据集上传、离线导入、产物下载路径权限正确。

View File

@@ -1,10 +1,10 @@
# 第一版系统开发计划 # 当前系统主链路开发计划
> 说明:本计划虽然以“可演示闭环”为阶段目标,但实现内容按正式系统第一版开发,不在工程模块、配置项、接口标签、数据库对象中使用临时代码命名。初始化数据仅作为样例种子数据,后续可在此基础上继续扩展为生产能力 > 说明:本计划描述当前正在开发的系统能力。代码、接口和 SQL 均按后续生产演进基线维护,不以一次性演示、静态 Mock 或样例数据作为开发准则。联调辅助能力必须显式配置启用,并不得成为默认运行路径
## 1. 阶段目标 ## 1. 阶段目标
第一版系统需要在没有完整生产环境、没有真实 GPU 或没有 LLaMA-Factory 运行环境时,仍然可以跑通模型微调平台的主链路: 当前阶段需要完成模型微调平台的主链路工程基础
```text ```text
登录 登录
@@ -12,24 +12,26 @@
-> 数据集管理 -> 数据集管理
-> 创建微调任务 -> 创建微调任务
-> 调度算力节点与 GPU -> 调度算力节点与 GPU
-> 模拟资源副本检查同步 -> 检查同步模型/数据集资源
-> 启动训练任务 -> 启动训练任务
-> 查看任务状态、GPU 占用、训练日志、loss 曲线 -> 轮询任务状态、GPU 占用、训练日志、loss 曲线
-> 训练完成后登记训练产物 -> 训练完成后登记训练产物
``` ```
该阶段不是一次性展示代码,而是后续系统继续开发的基础版本。算力、训练框架、资源同步、任务状态机均按真实平台边界设计,当前仅以 `simulator` 模式补齐无 GPU 环境下的可运行能力 该阶段是正式系统的第一批可运行能力,不再初始化业务样例数据。系统只允许初始化内置管理员/运维账号模型、数据集、算力节点、GPU、训练任务和资源副本必须通过页面、接口、算力 Agent 扫描或正式导入流程产生
## 2. 运行模式 ## 2. 运行模式
| 模式 | 说明 | 当前状态 | | 模式 | 说明 | 当前要求 |
| --- | --- | --- | | --- | --- | --- |
| `simulator` | 模拟 GPU、训练进程、日志、指标、checkpoint 和训练产物 | 第一版默认实现 | | `real` | 面向真实部署,等待 Compute API、Agent、File Gateway 和 LLaMA-Factory 执行器回写状态 | 默认模式 |
| `real` | 预留真实 `nvidia-smi`、LLaMA-Factory 进程启动、真实日志解析和产物扫描 | 后续接入 | | `simulator` | 仅用于隔离联调,无真实 GPU 时临时推进任务状态、GPU 状态和训练日志 | 必须显式开启,不得用于生产基线 |
第一版实现必须保留 `real` 模式边界,不允许把训练命令直接写死在应用平台后端。训练相关能力统一沉淀在 `compute/engines/` 适配层 后端默认 `COMPUTE_MODE=real`。在该模式下,任务状态不再按时间自动推进,必须由后续真实算力同步逻辑更新。算力服务默认 `COMPUTE_EXECUTION_MODE=real`,真实训练执行器未完成前,创建训练作业会返回明确的未实现错误,避免误认为已经完成生产训练能力
## 3. 第一批开发范围 训练相关能力必须沉淀在 `compute/engines/` 适配层,不允许在应用平台后端直接拼接或执行训练命令。
## 3. 当前开发范围
### 3.1 应用平台后端 ### 3.1 应用平台后端
@@ -38,24 +40,27 @@
```text ```text
backend/app/ backend/app/
api/v1/endpoints/platform.py api/v1/endpoints/platform.py
db/platform_store.py core/
db/session.py db/
``` ```
覆盖能力: 建立能力:
- 统一 API 响应结构 `{ code, message, data }` - 统一 API 响应结构 `{ code, message, data }`
- 初始化样例用户、模型、数据集、算力节点、GPU 和微调任务 - PostgreSQL 运行表初始化,当前执行脚本位于 `backend/app/db/sql/001_platform_runtime.sql`
- 登录、当前用户、用户列表与权限 - 内置管理员账号初始化,业务数据不再自动写入样例记录
- 模型管理、训练产物列表、权重合并任务占位 - 登录、当前用户、用户列表与权限页面接口
- 模型管理、训练产物列表、权重合并任务入口。
- 数据集管理、文件上传、预览、版本管理和下载。 - 数据集管理、文件上传、预览、版本管理和下载。
- 微调任务创建、启动、停止、删除、进度查询、checkpoint 查询。 - 微调任务创建、启动、停止、删除、进度查询、checkpoint 查询。
- 系统健康指标、系统信息、训练日志、系统日志。 - 系统健康指标、系统信息、训练日志、系统日志接口
- 算力节点、GPU、队列、资源副本、资源同步任务接口。 - 算力节点、GPU、队列、资源副本、资源同步任务接口。
开发约定 待继续开发:
- 当前本地可运行存储使用 SQLite配置项为 `LOCAL_DB_PATH` - 接入正式 ORM/Repository/Service 分层和 Alembic 迁移
- 生产数据库仍以 PostgreSQL 为目标,正式表结构以 `docs/postgres-schema.sql` 为准 - 将任务状态更新改为应用侧定时轮询 Compute API/File Gateway 后落库
- `backend/app/db/session.py` 已提供 SQLAlchemy session 入口,后续替换正式 ORM 时从此处收口 - 完成项目/模型/数据集级权限隔离校验
- 完成审批流、审计留存、配额、资源申请和多租户上下文。
- 增加正式异常码、接口鉴权、中间件、幂等控制和分页规范。
### 3.2 算力平台服务 ### 3.2 算力平台服务
@@ -64,72 +69,77 @@ backend/app/
```text ```text
compute/ compute/
api/main.py api/main.py
engines/llama_factory/adapter.py agent/
engines/llama_factory/
file_gateway/
``` ```
覆盖能力: 建立能力:
- `/modelTF/health``/modelTF/v1/compute/health` 节点健康检查。 - `/modelTF/health``/modelTF/v1/compute/health` 节点健康检查。
- `/modelTF/compute/resources/gpus` GPU 状态模拟 - LLaMA-Factory 参数校验、命令生成和训练日志指标解析
- `/modelTF/compute/jobs` 创建、查询、停止训练任务 - Compute API 作业、GPU、文件网关接口壳
- `/modelTF/compute/jobs/{id}/logs` 拉取训练日志与解析指标 - 显式 `simulator` 模式下的内存状态机,用于隔离联调
- `/modelTF/compute/files/upload``/modelTF/compute/files/{id}/download` 文件网关占位。
- LLaMA-Factory 参数校验、命令生成、日志指标解析。
设计约定 待继续开发
- 多算力节点阶段,每台单机多 GPU 服务器都部署一套 Compute API、Agent、File Gateway 和训练框架 - 真实 GPU 发现:接入 `nvidia-smi` 或 NVML
- 应用平台通过 `compute_nodes` 中维护的 `api_base_url``file_gateway_url` 主动轮询算力节点 - GPU 锁定与释放:一张 GPU 同一时间只分配给一个训练或推理任务
- 算力节点之间默认不互访,资源副本和同步状态由应用平台统一编排 - LLaMA-Factory 真实执行器:生成 YAML/命令、启动进程、停止进程、采集 PID
- 训练日志采集:读取宿主机挂载日志文件,解析 loss、learning rate、epoch 等指标。
- Checkpoint/adapter/merged model 扫描与产物登记。
- File Gateway本地磁盘文件上传、下载、校验、导入和跨节点资源同步。
### 3.3 前端页面 ### 3.3 前端页面
已接入页面: 已接入页面:
- `/login`:登录接口。 - `/login`:登录接口。
- `/model-manage`:模型列表模型来源。 - `/model-manage`:模型列表模型来源、训练产物
- `/dataset``/dataset/:id/preview`:数据集列表、预览版本。 - `/dataset``/dataset/:id/preview`:数据集列表、预览版本。
- `/fine-tune``/fine-tune/create`:微调任务创建、启动、状态轮询。 - `/fine-tune``/fine-tune/create`:微调任务创建、启动、状态轮询。
- `/training-log/:id`:训练日志和 loss 曲线。 - `/training-log/:id`:训练日志和 loss 曲线。
- `/hardware`:平台 GPU 与系统性能。 - `/hardware`:平台 GPU 与系统性能。
- `/compute`算力节点、GPU、队列、资源副本。 - `/compute`算力节点、GPU、队列、资源副本。
新增前端能力: 前端 Mock 默认关闭。仅在隔离前端开发时可设置 `VITE_ENABLE_MOCK=true`,真实联调和后续生产演进均以 `/modelTF` 后端接口为准。
- `frontend/src/api/modules/compute.ts`:算力节点 API 包装。
- `frontend/src/views/compute/ComputeNodesView.vue`节点、GPU、队列、资源副本四个视图。 待继续开发:
- 权限枚举增加 `compute`,菜单增加“算力节点”入口 - 补齐多租户、项目管理、审批中心、审计中心、配额管理页面
- 完成算力节点管理表单,包括节点地址、权重、标签、启用状态和健康检查结果。
- 完成模型/数据集导入页面,支持本地路径扫描和归属项目选择。
- 推理服务页面需接入真实后端任务接口,移除页面内本地假对话路径。
### 3.4 数据库 ### 3.4 数据库
第一版运行时使用本地 SQLite 以降低开发环境门槛,但数据库模型仍按 PostgreSQL 正式设计推进。 当前运行 SQL
核心表范围: ```text
- 用户、租户、项目、角色、权限。 backend/app/db/sql/001_platform_runtime.sql
- 模型、训练产物、数据集、数据集文件、存储对象。 ```
- 微调任务、训练指标、checkpoint、审计日志。
- 算力节点、GPU 设备、算力任务、GPU 分配。
- 资源副本、资源同步任务。
优化方向 架构目标 SQL
- 任务状态、租户/项目隔离字段、资源副本定位字段需要建立索引。
- 审计日志、训练指标、日志归档后续建议按时间分区。 ```text
- 大文件只落本地磁盘或对象存储,数据库保存元数据和版本关系。 docs/postgres-schema.sql
```
当前运行 SQL 用于支持已开发接口落库;架构目标 SQL 包含用户中心、多租户、项目隔离、审批、审计、配额、评测等完整模型。后续需要通过 Alembic 将二者收敛为统一迁移体系,生产升级只走迁移脚本,不依赖手工改表。
## 4. 验收标准 ## 4. 验收标准
第一版完成后应满足: - 启动后端必须连接 PostgreSQL不允许回退到 SQLite。
- 前端不启用静态 Mock 时,可以通过 FastAPI 获取主要页面数据。 - 后端启动只初始化系统内置账号不初始化模型、数据集、算力节点、GPU、训练任务等业务样例数据。
- 登录后可以看到模型、数据集、微调任务和算力节点 - 前端默认请求真实 `/modelTF` 接口,除非显式设置 `VITE_ENABLE_MOCK=true`
- 创建微调任务后可以启动任务,任务状态自动流转 - 默认 `real` 模式下任务状态自动伪造完成,必须等待真实算力同步
- GPU 状态会随任务进入 `syncing``queued``running` 发生变化 - 显式 `simulator` 模式只能用于隔离联调,部署文档必须标注不得用于生产
- 训练日志持续生成,日志详情页能解析 loss、grad norm、learning rate、epoch - 登录后可以进入主界面,并可通过页面/API 创建真实业务记录
- 任务完成后训练产物出现在模型管理的训练产物列表中 - 代码、接口路由、配置项、数据库表名不得使用 `demo` 命名
- 算力节点页面可展示节点地址、权重、标签、启用状态、队列和资源副本。
- 工程命名、配置和接口分组均可作为后续正式开发基础。
## 5. 后续开发计划 ## 5. 后续开发计划
| 阶段 | 重点 | 说明 | | 阶段 | 重点 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| 第二阶段 | PostgreSQL ORM 与 Alembic | 将 `platform_store.py` 的能力迁移为正式 ORM、迁移脚本和 repository/service 分层 | | 阶段 1 | 数据库迁移体系 | 将当前运行 SQL 与架构 SQL 收敛到 Alembic 迁移 |
| 第三阶段 | 真实 Compute Agent | 接入 `nvidia-smi`、GPU 锁定、进程管理、训练日志文件采集 | | 阶段 2 | 后端领域分层 | 拆分用户、模型、数据集、训练、算力、审计等模块 |
| 第四阶段 | LLaMA-Factory 真实训练 | 生成 YAML/命令、启动训练进程、停止任务、扫描 checkpoint 和 adapter | | 阶段 3 | 真实 Compute Agent | GPU 发现、资源锁定、进程管理、日志采集 |
| 第五阶段 | 企业治理 | 多租户、项目/模型/数据集隔离、审批流、审计留存、配额和资源申请 | | 阶段 4 | LLaMA-Factory 训练执行 | YAML/命令生成、进程启动/停止、checkpoint 和 adapter 扫描 |
| 第六阶段 | 多节点调度 | 基于 `compute_nodes`、资源副本和节点标签实现自动/手动调度策略 | | 阶段 5 | 企业治理 | 多租户、项目隔离、审批流、审计留存、配额和资源申请 |
| 阶段 6 | 多算力节点调度 | 基于 `compute_nodes`、标签、权重、资源副本和节点健康实现调度策略 |

View 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 只能显式开启用于隔离联调。

View File

@@ -1038,7 +1038,7 @@ YG_FT/
## 16. 页面模块开发工作包 ## 16. 页面模块开发工作包
本节用于多人并行开发时认领任务。每个工作包都标明对应页面、前端内容、后端接口、DB 表和部署/算力依赖。 本节用于多人并行开发时认领任务。每个工作包都标明对应页面、前端内容、后端接口、DB 表和部署/算力依赖。当前菜单、二级路由、规划菜单和接口/数据库映射总览见 `docs/menu-functional-requirements.md`
### 16.1 基础入口与用户权限 ### 16.1 基础入口与用户权限

View File

@@ -26,14 +26,13 @@ npm run dev
后端 API 默认通过 Vite 代理转发到 `http://localhost:17861`(见 `vite.config.ts`)。 后端 API 默认通过 Vite 代理转发到 `http://localhost:17861`(见 `vite.config.ts`)。
开发环境默认启用前端 Mock。如需联调真实后端使用 开发环境默认联调真实后端接口。如需进行隔离前端开发,可显式启用 Mock
```bash ```bash
VITE_ENABLE_MOCK=false npm run dev VITE_ENABLE_MOCK=true npm run dev
``` ```
生产构建默认不包含 Mock仅在演示构建中可显式设置 真实联调、测试环境和生产环境不应启用 Mock。
`VITE_ENABLE_MOCK=true`
## 构建 ## 构建

View File

@@ -1,7 +1,7 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv { interface ImportMetaEnv {
/** 是否启用前端 Mock开发环境默认开启,生产环境默认关闭。 */ /** 是否启用前端 Mock默认关闭,仅限隔离前端开发时显式开启。 */
readonly VITE_ENABLE_MOCK?: 'true' | 'false' readonly VITE_ENABLE_MOCK?: 'true' | 'false'
} }

View File

@@ -17,10 +17,8 @@ app.use(createPinia())
app.use(router) app.use(router)
async function bootstrap() { async function bootstrap() {
// 开发环境默认使用 Mock生产环境只有显式开启时才加载整套 Mock 数据 // Mock 仅允许显式开启。开发环境默认联调真实后端契约
// 这样真实部署不会被前端适配器截断请求,也不会把 Mock 数据打进首屏包。
const shouldEnableMock = import.meta.env.VITE_ENABLE_MOCK === 'true' const shouldEnableMock = import.meta.env.VITE_ENABLE_MOCK === 'true'
|| (import.meta.env.DEV && import.meta.env.VITE_ENABLE_MOCK !== 'false')
if (shouldEnableMock) { if (shouldEnableMock) {
const { installMockAdapter } = await import('@/mock/adapter') const { installMockAdapter } = await import('@/mock/adapter')