跳转到内容

GPUPlane 系统设计文档

版本:v0.1-draft · 2026-08-17 本文档是 GPUPlane 的工程落地设计。产品定位与功能边界见 02-product-design.md,选型依据见 01-product-research.md


  1. 替代个人 GPU 工作流中的 SSH + tmux 训练管理方式;
  2. 不改变用户既有 PyTorch 工作流(python train.py 永远可独立运行);
  3. 训练进程与平台故障隔离:平台任何组件失效不影响训练;
  4. 人(Web/CLI)与 AI Agent(MCP)共享同一控制面;
  5. 从单机单卡平滑扩展到少量异构节点,但不引入集群调度复杂度。
约束 决策
基础设施最小化 FastAPI + SQLite + Agent + React,无 Kafka/Redis/Prometheus
单写者原则 SQLite 只允许 Server 单进程写库;Agent 一律走网络上报
单 worker 约束 uvicorn 单 worker 运行(内存态 pub/sub 与多 worker 不兼容);如需扩容再引入外部 pub/sub
首要平台 Linux macOS 支持开发调试;Windows 不考虑(NVML 进程级数据在 WDDM 下不可用)
Best-effort telemetry SDK/Agent 所有上报失败只落本地缓冲,绝不向训练进程抛异常

┌─────────────────────────── 控制端(可与 GPU 主机同机) ───────────────────────────┐
│ gpuctl-server (uvicorn 单 worker, FastAPI) │
│ ├── REST API /api/v1/... │
│ ├── Agent Gateway /api/v1/agent/ws (WebSocket, agent 主动外连) │
│ ├── UI Stream /api/v1/jobs/{id}/logs/stream (SSE) + /ws (WS) │
│ ├── Scheduler (asyncio task, 2s tick) │
│ ├── Event Detector (规则引擎, 消费 metrics/logs) │
│ ├── Rollup Worker (metrics 降采样与 retention) │
│ └── SQLite (WAL) ~/.gpuctl/server/gpuctl.db │
│ │
│ gpuctl-mcp (独立进程, streamable HTTP / stdio) —— Core Service 的 Agent 适配器 │
│ web/ (React SPA, 构建后由 gpuctl-server 静态托管) │
└───────────────────────────────────┬─────────────────────────────────────────────┘
│ WebSocket 长连接 (agent → server, NAT 友好)
┌───────────────────────────────────▼────────────────── GPU 主机 ──────────────────┐
│ gpuctl-agent (asyncio 常驻进程, systemd user service) │
│ ├── NodeMonitor NVML + psutil, 2s 采集 │
│ ├── Runner ProcessRunner (一等公民) / DockerRunner (v0.2) │
│ ├── LogCollector stdout/stderr → 本地文件 + WS 批量上报 │
│ ├── TBAdapter tail TensorBoard event 文件 │
│ ├── CheckpointWatcher 输出目录监视 (防抖 + 指纹) │
│ └── SpoolBuffer 断线时 jsonl 本地缓冲, 重连回放 │
│ │
│ 用户训练进程 python train.py (+ 可选 gpuctl-sdk) │
└──────────────────────────────────────────────────────────────────────────────────┘
  • server 与 agent 可同机(个人最常见:一台 5090 工作站 = server + agent + web);
  • 也可分离(server 在常开的 NAS/小主机,agent 在 GPU 机);agent 主动外连,GPU 主机无需入站端口;
  • 远端访问走 Tailscale/内网 + Bearer Token,不暴露公网(见 §13 安全)。

全部选型于 2026-08-17 经官方文档/PyPI/GitHub 验证,详细调研记录见 01 号文档 §5。

领域 决策 备选 关键理由与坑
NVML 绑定 nvidia-ml-py(导入名仍是 pynvml DCGM(过重,否) pynvml 包已弃用,必须卸载避免 FutureWarning;RTX 50 系随驱动支持(R570+);NVML init 用单例
TB event 解析 tensorboard.backend.event_processing.EventAccumulator 周期 Reload() tbparse(离线导入好用);自写 TFRecord 增量读(高频 tail 优化) PyTorch 新版 scalar 写成 tensor summary,Scalars() 可能为空,必须读 Tensors()size_guidance={'scalars':0,'tensors':0} 关降采样;捕获 DataLossError(半写 record)下轮重试
数据库 SQLite WAL + synchronous=NORMAL,raw/rollup 双表 DuckDB/Parquet(个人规模过度设计,仅做冷归档备选) 批量 executemany + 单事务;索引 (name, ts);定期 ANALYZE + auto_vacuum=INCREMENTAL
UI 日志流 SSE 为默认EventSource 自带重连);需要交互(发信号/stdin)时 WS WebSocket uvicorn 单 worker;生成器内检查 request.is_disconnected();15s 心跳注释行;nginx 前置需 X-Accel-Buffering: no
Agent↔Server 单条 WebSocket 长连接type 字段多路复用 HTTP 批量轮询(降级模式) server 需主动下发 dispatch/cancel,故 WS;应用层心跳 20-30s(防 LB idle 断连);websockets 库锁定大版本(15.x asyncio API 有 breaking change)
断线缓冲 agent 端 append-only jsonl spool + (source, seq) 幂等回放 轮转上限(100MB/24h);回放限流去重;O_APPEND 单行原子写
进程执行 asyncio.create_subprocess_exec(..., start_new_session=True) + os.killpg 整组杀 pexpect(交互场景) 只杀单 PID 会留孤儿(dataloader worker);SIGTERM→5s→SIGKILL→await proc.wait() 收割;env 注入 PYTHONUNBUFFERED=1 避免块缓冲;PTY 仅在需要 TTY 行为时开
CLI typer + uv tool install 分发 click / argparse 重依赖(torch)不得顶层 import,子命令内延迟导入保证启动速度
MCP fastmcp 3.4.x 钉版FastMCP.from_fastapi 或直接定义 tools) 官方 mcp 2.0 MCPServer(依赖最少) mcp 1.x→2.0 硬 breaking,fastmcp 3.x 依赖 mcp 1.x,严禁同 venv 混装 fastmcp3 + mcp2;远程传输用 streamable HTTP(SSE 传输已废弃)
前端图表 uPlot(高频实时曲线,10 万点 60fps)+ ECharts 6(概览/对比,echarts/core 按需导入) Recharts(>5k 点不可用,否) 实时曲线必须窗口化 + rAF 节流批量 setData;React 19 StrictMode 注意双挂载清理
WS 重连(前端) partysocket(指数退避) react-use-websocket / 原生 EventSource(SSE 场景)
部署 systemd user service ×2(server/agent)+ loginctl enable-linger system service 不 linger 是“重启后服务没起来”的最常见原因;unit 内显式声明 PATH/CUDA 环境(user service 不继承 shell rc);uv sync --frozen --no-dev 可复现部署
ID 生成 ULID,带类型前缀(job_run_ckpt_nd_ev_ UUID 可读、可排序、URL 安全
ORM/校验 SQLAlchemy 2.x(async)+ Pydantic v2(协议模型共享包) SQLModel 协议模型在 server/agent/sdk/cli/mcp 间共享,放 gpuctl-common

Project ──< Experiment ──< Run ──< Checkpoint ──< Evaluation
│ ▲
│ │ (parent_checkpoint 自关联, resume 链)
├─< MetricPoint (raw/rollup)
├─< Event
└─< RunLogRef
Job ──< (1..N attempt) ──< Run Node ──< GPUSlot ── owner_job ──> Job
Node ──< NodeMetric (raw/rollup)
MetricDefinition (指标语义元数据)

Job 与 Run 的关系(对原方案的细化):

  • Job 是调度单位,Run 是观测单位runs.job_id + runs.attempt 关联;
  • Job 每次 dispatch(含 retry)创建新 Run(同 job_id,attempt 递增)——重试的 metrics 不混淆;
  • Run 也可以没有 Job:source = sdk | import(训练在平台外启动后自注册,或 import-run 离线导入);
  • Job 也可以不产生 Run:type = CUSTOM 的纯脚本任务只记录日志与退出码。
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
CREATE TABLE projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at INTEGER NOT NULL -- ms epoch
);
CREATE TABLE experiments (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
name TEXT NOT NULL,
description TEXT,
created_at INTEGER NOT NULL,
UNIQUE (project_id, name)
);
CREATE TABLE nodes (
id TEXT PRIMARY KEY, -- nd_...
name TEXT NOT NULL UNIQUE,
status TEXT NOT NULL, -- ONLINE/OFFLINE/UNHEALTHY (BUSY/IDLE 由 slot 推导)
agent_version TEXT,
labels TEXT, -- JSON ["5090","linux"]
hardware TEXT, -- JSON {gpus:[...], cpu, mem_mb, disk_mb, cuda, driver}
last_heartbeat_at INTEGER,
created_at INTEGER NOT NULL
);
-- 每张物理 GPU 一个调度槽;独占调度的判定依据就是 owner_job_id
CREATE TABLE gpu_slots (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL REFERENCES nodes(id),
gpu_index INTEGER NOT NULL,
model TEXT,
vram_mb INTEGER,
owner_job_id TEXT REFERENCES jobs(id), -- NULL = free
UNIQUE (node_id, gpu_index)
);
CREATE TABLE jobs (
id TEXT PRIMARY KEY, -- job_...
name TEXT NOT NULL,
type TEXT NOT NULL, -- TRAIN/EVALUATE/INFERENCE/EXPORT/CUSTOM
status TEXT NOT NULL, -- CREATED/QUEUED/DISPATCHING/RUNNING/
-- SUCCEEDED/FAILED/CANCELLED/LOST
priority INTEGER NOT NULL DEFAULT 0,
runner TEXT NOT NULL DEFAULT 'process', -- process/docker
working_dir TEXT,
command TEXT NOT NULL,
env TEXT, -- JSON
resources TEXT NOT NULL DEFAULT '{"gpu":1}', -- JSON {gpu:N, cpu_only:false}
node_selector TEXT, -- JSON {labels:[...], node:"..."}
attempt INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 1, -- retry: max_attempts>1
project_id TEXT REFERENCES projects(id),
experiment_id TEXT REFERENCES experiments(id),
node_id TEXT REFERENCES nodes(id),
created_at INTEGER NOT NULL,
started_at INTEGER, finished_at INTEGER,
exit_code INTEGER,
failure_reason TEXT, -- OOM/SIGNAL/EXIT_CODE/TIMEOUT/LOST...
cancel_requested INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_jobs_queue ON jobs(status, priority DESC, created_at);
CREATE TABLE runs (
id TEXT PRIMARY KEY, -- run_...
job_id TEXT REFERENCES jobs(id),
attempt INTEGER, -- 与 jobs.attempt 对应
node_id TEXT,
project_id TEXT REFERENCES projects(id),
experiment_id TEXT REFERENCES experiments(id),
name TEXT,
source TEXT NOT NULL, -- job/sdk/import
status TEXT NOT NULL, -- RUNNING/SUCCEEDED/FAILED/CANCELLED/LOST/IMPORTED
config TEXT, -- JSON: 超参/配置
env_snapshot TEXT, -- JSON: {git_commit, git_dirty, python, argv, cuda, key_pkgs}
created_at INTEGER NOT NULL,
started_at INTEGER, finished_at INTEGER,
summary TEXT -- JSON: RunSummary 缓存, 增量更新
);
CREATE INDEX idxruns_exp ON runs(experiment_id, created_at DESC);
CREATE TABLE metric_definitions (
name TEXT PRIMARY KEY, -- "eval/loss", "ndcg@10"
display_name TEXT,
direction TEXT, -- minimize/maximize
scope TEXT, -- train/eval/system
is_primary INTEGER NOT NULL DEFAULT 0,
unit TEXT,
created_at INTEGER NOT NULL
);
-- 指标方向/单位是全局元数据;primary 选择按 ownership hierarchy 覆盖。
-- 解析顺序 experiment → project → global metric_definitions.is_primary。
CREATE TABLE project_primary_metrics (
project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
metric_name TEXT NOT NULL REFERENCES metric_definitions(name) ON DELETE CASCADE,
created_at INTEGER NOT NULL
);
CREATE TABLE experiment_primary_metrics (
experiment_id TEXT PRIMARY KEY REFERENCES experiments(id) ON DELETE CASCADE,
metric_name TEXT NOT NULL REFERENCES metric_definitions(name) ON DELETE CASCADE,
created_at INTEGER NOT NULL
);
-- 训练/评估指标:双表法 (raw + rollup)
CREATE TABLE metrics_raw (
run_id TEXT NOT NULL REFERENCES runs(id),
ts INTEGER NOT NULL,
step INTEGER,
name TEXT NOT NULL,
value REAL NOT NULL
);
CREATE INDEX idx_metrics_raw ON metrics_raw(run_id, name, ts);
CREATE TABLE metrics_rollup (
run_id TEXT NOT NULL,
name TEXT NOT NULL,
granularity TEXT NOT NULL, -- 1m / 5m
bucket_ts INTEGER NOT NULL,
min REAL, max REAL, avg REAL, last REAL, cnt INTEGER,
PRIMARY KEY (run_id, name, granularity, bucket_ts)
);
-- 系统指标按节点存储, 与 run 解耦 (run 通过 node_id+时间窗关联)
CREATE TABLE node_metrics_raw (
node_id TEXT NOT NULL,
ts INTEGER NOT NULL,
name TEXT NOT NULL, -- gpu0.utilization/gpu0.mem_used_mb/gpu0.temp_c/
-- gpu0.power_w/cpu.utilization/mem.used_mb/disk.used_mb
value REAL NOT NULL
);
CREATE INDEX idx_node_metrics ON node_metrics_raw(node_id, name, ts);
-- node_metrics_rollup 结构同 metrics_rollup, 省略
CREATE TABLE events (
id TEXT PRIMARY KEY, -- ev_...
ts INTEGER NOT NULL,
type TEXT NOT NULL, -- RUN_STARTED/RUN_FINISHED/CHECKPOINT_CREATED/
-- LOSS_NAN/LOSS_SPIKE/OOM/GPU_UNDERUTILIZED/
-- PROCESS_EXITED/AGENT_DISCONNECTED/DISK_LOW/...
severity TEXT NOT NULL, -- info/warning/critical
run_id TEXT, job_id TEXT, node_id TEXT,
step INTEGER,
message TEXT,
context TEXT -- JSON
);
CREATE INDEX idx_events_run ON events(run_id, ts);
CREATE INDEX idx_events_type ON events(type, ts);
CREATE TABLE checkpoints (
id TEXT PRIMARY KEY, -- ckpt_...
run_id TEXT NOT NULL REFERENCES runs(id),
step INTEGER, epoch REAL,
path TEXT NOT NULL, -- agent 端绝对路径
size_bytes INTEGER,
fingerprint TEXT, -- sha1(size + mtime + 头尾各1MB) 快速指纹; 非全量 hash
parent_id TEXT REFERENCES checkpoints(id),
metadata TEXT, -- JSON
created_at INTEGER NOT NULL,
UNIQUE (run_id, path)
);
CREATE TABLE evaluations (
id TEXT PRIMARY KEY,
checkpoint_id TEXT NOT NULL REFERENCES checkpoints(id),
job_id TEXT REFERENCES jobs(id), -- 由哪个 EVALUATE job 产生
run_id TEXT REFERENCES runs(id),
suite TEXT, -- "beir"
dataset TEXT, -- "beir/nfcorpus"
dataset_version TEXT,
metrics TEXT NOT NULL, -- JSON {"ndcg@10":0.47, "mrr":0.51}
status TEXT NOT NULL, -- RUNNING/SUCCEEDED/FAILED
runtime_s REAL,
created_at INTEGER NOT NULL
);
CREATE TABLE artifacts ( -- v0.2: 非 checkpoint 产物 (图表/样例/导出模型)
id TEXT PRIMARY KEY, run_id TEXT NOT NULL, kind TEXT,
path TEXT NOT NULL, metadata TEXT, created_at INTEGER NOT NULL
);
CREATE TABLE webhook_subscriptions ( -- v0.2: 事件外推 (ntfy/Bark/自定义)
id TEXT PRIMARY KEY, url TEXT NOT NULL,
event_types TEXT NOT NULL, -- JSON 过滤
secret TEXT, enabled INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL
);
数据 粒度与保留
node_metrics_raw 采集 2s → agent 聚合上报 5s 一批;raw 保留 24h
node_metrics_rollup 1m 保留 7d;5m 保留 30d;更早 DELETE
metrics_raw(训练指标) 频率低(step 级),长期保留;>90d 的 run 可降采样到 1m rollup 后清 raw
events 长期保留(量小、语义价值高)
日志文件 ~/.gpuctl/jobs/<job-id>/ 本地保留;server 只存尾部 N 行 + 元数据,全文留在 agent 端(见 §7.3)

Rollup Worker:每 60s 执行一次 INSERT INTO ... SELECT ... GROUP BY + DELETE 过期 raw(Zabbix history/trends 同款模式),单事务执行。


单条 WebSocket:wss://<server>/api/v1/agent/ws,凭据仅通过 Authorization: Bearer <agent_token> 请求头发送,避免 URL/访问日志泄漏。agent 作 client 主动外连(穿透 NAT)。所有帧为 JSON,含 type 字段多路复用。应用层心跳 25s(防中间设备 idle 断连)。

Agent → Server:

type 载荷要点 说明
hello {node_name, agent_version, hardware, labels} 连接建立后首帧(认证在 URL token);server 回复 hello_ok 分配 node_id
heartbeat {ts, slots:[{gpu_index, owner_job_id}], disk, load} 25s;server 据此维护 nodes.status 与 LOST 判定
sys_metrics {seq, points:[{name, ts, value}]} 5s 一批
run_metrics {run_id, seq, points:[{name, ts, step, value}]} SDK/TB adapter 汇出的训练指标
log_batch {job_id, stream, seq, lines:[...]} 200 行或 1s 刷一批
event {type, severity, run_id?, step?, context} agent 本地检测到的事件(如 CHECKPOINT_CREATED)
job_status {job_id, attempt, status, exit_code?, failure_reason?} 状态变迁回执(RUNNING/SUCCEEDED/FAILED/…)
checkpoint_seen {run_id, path, step?, size, fingerprint} watcher 发现新 checkpoint
reconcile {running:[{job_id, attempt, pid}], exited:[...]} 重连后上报本地真实进程状态

Server → Agent:

type 说明
hello_ok / hello_err 注册结果
dispatch {job: {...完整 JobSpec, attempt}}
cancel {job_id} → agent 对进程组 SIGTERM→SIGKILL
ack {type, seq} 可选回执(日志/指标批量确认)
ping 保活
  • 每类上报流各自维护单调 seq;spool 落盘格式:{"stream":"log","job_id":...,"seq":...,"payload":...} 一行一条;
  • server 按 (stream, source_id, seq) 去重落库(metrics/logs 天然可重复写时以 seq 覆盖);
  • 断线期间 agent 继续采集写 spool(上限 100MB/24h 轮转);重连后限速回放(防积压打爆 server);
  • 心跳超时:3 个心跳周期(~90s)无心跳 → node 标记 OFFLINE,其 RUNNING job 标记 LOST 并产生 AGENT_DISCONNECTED 事件;agent 重连后 reconcile 对齐真实状态(进程还在 → 恢复 RUNNING;不在 → FAILED/lost-confirmed)。

CREATED → QUEUED → DISPATCHING → RUNNING → SUCCEEDED
│ ├────→ FAILED ──(retry, attempt+1)──→ QUEUED
│ ├────→ CANCELLED
│ └────→ LOST ──(reconcile)──→ RUNNING | FAILED
└─(dispatch ack 超时)──→ QUEUED (重新调度, 上限 3 次)
  • LOST 是核心状态:server 认为 RUNNING 但 agent 失联;
  • 用户 cancel:QUEUED 直接 CANCELLED;RUNNING 置 cancel_requested,经 WS cancel 下发;
  • failure_reason 由 agent 初步判定(exit code / signal / 日志 OOM 模式)+ server EventDetector 补充。
# 伪代码 —— 刻意保持 SQL 级简单
job = SELECT * FROM jobs WHERE status='QUEUED'
ORDER BY priority DESC, created_at ASC LIMIT 1
if not job: return
slots = 该 job.node_selector 匹配节点上的 free gpu_slots
if len(slots) < job.resources.gpu: return # 无资源, 等下一 tick
tx:
job.status = DISPATCHING; job.node_id = ...; job.attempt += 1
slots[i].owner_job_id = job.id
run = INSERT INTO runs(job_id, attempt, source='job', status='RUNNING' ...)
ws.send(node, dispatch(job, run_id, attempt))

关键决策(对原方案的修订):

  1. 独占单位是 GPU Slot 而非节点:多卡机按 gpu_slots 逐卡分配,dispatch 时注入 CUDA_VISIBLE_DEVICES=<分配的 gpu_index>
  2. CPU-only job 不占 GPU slotresources.gpu=0 的 EVALUATE/EXPORT 可与训练并发,每节点设 max_cpu_jobs(默认 2)防失控;
  3. 不用 GPU Utilization 判断空闲(原方案正确,保留):util=0 可能是 DataLoader/Eval 阶段;
  4. 不抢占:个人场景排队即可,抢占带来的 checkpoint/resume 复杂度不值。

gpuctl-agent
├── Config ~/.gpuctl/agent.yaml {server_url, token, node_name, labels, watch_dirs}
├── WSClient 连接管理/重连(指数退避)/spool 回放
├── NodeMonitor 2s: NVML(单例 init) + psutil → 5s 批量上报
├── RunnerManager job_id → Runner 实例; 并发上限: gpu slot 数 + max_cpu_jobs
│ ├── ProcessRunner asyncio subprocess, start_new_session, killpg, exit code
│ └── DockerRunner v0.2, docker SDK, 同等日志/退出码语义
├── LogCollector 管道 → tee: 本地文件 + WS 批量; drop-oldest 有界队列(1000 行)
├── TBAdapter watchfiles 监听 run 的 runs/ 目录 → EventAccumulator 增量 Reload
├── CkptWatcher watch_dirs 防抖扫描 → 指纹 → checkpoint_seen
└── EnvSniffer dispatch 时采集: git rev-parse HEAD / dirty / python -V / pip freeze(关键包)

本地目录约定:

~/.gpuctl/
├── agent.yaml
├── jobs/<job-id>/attempt-<n>/{stdout.log, stderr.log, runtime.json, events.jsonl}
├── spool/{metrics,logs,events}.jsonl # 断线缓冲, 100MB 轮转
└── cache/tb_offsets.json # TB 增量读取 offset

7.2 ProcessRunner 细节(一等公民)

Section titled “7.2 ProcessRunner 细节(一等公民)”
  • asyncio.create_subprocess_exec(shlex.split? → 直接 shell=False 列表, start_new_session=True, cwd=working_dir, env={...os.environ, **job.env, PYTHONUNBUFFERED:'1', CUDA_VISIBLE_DEVICES:...})
  • 整组杀os.killpg(os.getpgid(pid), SIGTERM) → 5s → SIGKILLawait proc.wait();禁止只 proc.kill()(dataloader worker 会变孤儿);
  • 非 TTY 块缓冲问题:默认注入 PYTHONUNBUFFERED=1;用户命令包 stdbuf -oL 或 PTY 模式(pty: true 时 stdout/stderr 合并,记录 stream: combined);
  • exit code → job_status;signal 退出映射 failure_reason=SIGNAL(<sig>)
  • runtime.json 记录 pid/pgid/start/end/cmd/env,供 reconcile 与人工排查。

原方案“实时发送至 Control Server + WebSocket 实时查看”修订为:

  • 全文日志只存 agent 本地文件(训练日志可上 GB,全量进 SQLite 是自找麻烦);
  • WS 实时上报 = server 内存态 pub/sub 转发给 UI SSE + **server 只持久化尾部 ring buffer(默认 2000 行)**进 jobs.log_tail
  • gpuctl logs <job> / MCP tail_logs 默认读 server tail;--follow 走实时流;--full 时 server 向 agent 拉取本地文件(agent 在线才可用,离线则明确提示);
  • UI 日志用 SSE(单向、自带重连),WS 仅用于交互式操作。

7.4 TensorBoard Adapter(Level 1 兼容层)

Section titled “7.4 TensorBoard Adapter(Level 1 兼容层)”
  • run 启动时 agent 在 working_dir 下按约定发现 event 目录(runs/, logs/, outputs/, TB_LOG_DIR env),也允许 job yaml 显式 tb_dir
  • 增量解析:EventAccumulator(path, size_guidance={'scalars':0,'tensors':0}) + 周期 Reload();捕获 DataLossError(半写 CRC)下轮重试;
  • 必读 Tensors()(PyTorch 新 SummaryWriter 把 scalar 写成 tensor summary),同时兼容 Scalars()
  • 大文件优化:cache/tb_offsets.json 记录文件 offset,自行按 record 边界增量读,避免全量 CRC;
  • step 回退(重启训练):accumulator purge 语义下,以 (step, ts) 上报、server 端允许同 step 多值(保留,不覆盖);
  • tag → metric name 直通(train/loss 等约定自然成立);首次见到的 name 自动建 metric_definitions(direction 按名称启发式:loss/error→minimize,acc/f1/ndcg/mrr→maximize,可人工改)。
  • 监视 watch_dirs(默认 ./checkpoints ./outputs ./runs)+ run config 中声明的目录;
  • 防抖:文件 mtime 稳定 10s 才上报(大 checkpoint 写入慢,避免注册半成品);识别 .tmp/.partial 后缀跳过;
  • 指纹而非全量 hash(修订点):sha1(size + mtime + 头 1MB + 尾 1MB)——GB 级文件全量 hash 太贵,指纹足以判断“是否同一文件”;需要严格校验时提供 gpuctl ckpt verify <id> 手动全量;
  • 目录形态识别:HF checkpoint-*/(读 trainer_state.json 提取 step/epoch)、单文件 *.pt/*.safetensors、DeepSpeed 分片目录;
  • 发现与注册,绝不移动/改写模型文件。

训练进程内 gpuctl-sdk 直接上报(不经 agent 管道):

  • 优先 HTTP POST /api/v1/runs/{run_id}/metrics:batch(带 sdk token);server 不可达时写本地 ~/.gpuctl/spool/sdk-<run_id>.jsonl,由 agent 下次连接时代为回放(若同机有 agent)或 SDK 后台线程自行重试;
  • SDK 内部:log() → 有界内存队列(满则 drop-oldest + 计数)→ 后台 sender 线程批量发送;atexit flush 一次(超时 2s)。

from gpuctl import run # 单例, framework-neutral
run.init(project="qwen-sft", experiment="lr-2e5", config={...}) # 可选; 无 init 时 log 也工作(lazy run)
run.log({"train/loss": loss.item(), "lr": lr}, step=step) # 绝不抛异常
run.log_event("phase", {"name": "epoch-2-start"})
run.log_checkpoint(path, step=20000) # register, 不 save; torch.save 仍是用户自己的事
run.summary() # 本地只读视图
  • 零配置发现:在平台 dispatch 的 job 里,env 注入 GPUCTL_SERVER/RUN_ID/TOKEN → SDK 自动工作;平台外裸跑时 run.init() 自注册 run(source=sdk),或完全静默降级为本地 jsonl;
  • 依赖最小化:仅 httpx(硬依赖 pydantic 都不要,避免与训练环境撞版本);
  • 显式不做 framework callback 的强绑定(Lightning/HF Trainer callback 作为 gpuctl-sdk[integrations] 可选 extras 薄封装,v0.2)。

SDK ──HTTP batch──┐
TBAdapter ──WS────┼──► Server MetricService ──► metrics_raw (批量 executemany, 单事务)
NodeMonitor ──WS──┘ │
├─► EventDetector (流式规则判定, §10)
├─► RunSummary 增量更新 (runs.summary JSON 缓存)
└─► Rollup Worker (60s tick, 降采样+retention)
  • 写入收口在 server 单进程(SQLite 单写者);
  • 查询接口按时间窗自动选择 raw 或 rollup(?window=1h → raw;7d → 1m rollup),对调用方透明;
  • metric_definitions 是 Agent 理解指标的关键:direction 驱动 get_best_checkpoint/compare 的排序逻辑;primary 使用 experiment → project → global 继承。跨实验比较只使用共同 project 选择,跨 project 比较只使用 global 默认或显式 metric,避免用不同指标静默排名。

10.1 EventDetector(server 端规则引擎,v0.2 完整 / v0.1 含最小集)

Section titled “10.1 EventDetector(server 端规则引擎,v0.2 完整 / v0.1 含最小集)”
规则 触发条件(默认阈值可配) severity
LOSS_NAN loss is NaN/Inf critical
LOSS_SPIKE 当前值 > 滚动中位数 + 6×MAD warning
OVERFITTING_SUSPECTED eval/loss 距最低点回升 >10% 且 train/loss 仍下降,持续 3 次 eval warning
GPU_UNDERUTILIZED RUNNING 中 gpu util <20% 且显存 <30%,持续 10min warning
OOM 日志匹配 CUDA out of memory / exit code 配合显存曲线 critical
DISK_LOW 输出盘剩余 <5% critical
AGENT_DISCONNECTED / JOB_LOST 心跳超时 critical
RUN_STARTED/FINISHEDCHECKPOINT_CREATEDEVALUATION_FINISHED 生命周期 info

10.2 Run Summary(runs.summary 增量缓存)

Section titled “10.2 Run Summary(runs.summary 增量缓存)”

结构即原方案 §17 的 JSON:status / progress(step,max_steps,epoch) / latest / trend(滚动窗口斜率判定 increasing/decreasing/stable) / best(按 metric_definitions.direction 计算) / health.warnings。Agent 与 UI 首屏都只读 summary,需要细节再 query_metrics

diagnose_run / compare_runs / compare_checkpoints / explain_failure / get_best_checkpoint 全部实现为 Core Service 的普通 Python 函数(规则 + 统计,非 LLM),REST 与 MCP 只是暴露层。LLM 推理留给调用方 Agent——平台负责把 10 万 metric points 压缩成它可直接消费的语义结论。这保证:结果可复现、无 LLM 成本、离线可用。


资源 端点
Nodes GET /nodes GET /nodes/{id}(含 slots 与最新系统指标快照)
Jobs POST /jobs GET /jobs?status= GET /jobs/{id} POST /jobs/{id}/cancel POST /jobs/{id}/retry
Logs GET /jobs/{id}/logs?stream=&tail= GET /jobs/{id}/logs/stream(SSE)
Projects/Experiments 标准 CRUD
Runs GET /runs GET /runs/{id} GET /runs/{id}/summary GET /runs/{id}/metrics?names=&window=&step_range=
Metrics POST /runs/{id}/metrics:batch(SDK 用) POST /runs/metrics:query(v0.4 批量查询,叠加图一次取数) GET /runs/{id}/metrics/stream(v0.4 SSE 实时推点,PubSub fan-out) GET/PUT/PATCH /metric-definitions PUT /primary-metric(global/project/experiment)
Events GET /events?run_id=&type=&severity= GET /events/stream(SSE,供 UI/Agent 长轮询替代)
Checkpoints GET /runs/{id}/checkpoints GET /checkpoints/{id}
Evaluations POST /checkpoints/{id}/evaluations(创建 EVALUATE job) GET /checkpoints/{id}/evaluations
Semantics GET /runs/{id}/diagnosis GET /runs/compare?ids= GET /checkpoints/compare?ids= GET /experiments/{id}/best-checkpoint?metric=
Agent WS /agent/ws
Webhooks (v0.2) POST/DELETE /webhooks

约定:列表默认按 created_at DESC 分页(cursor);错误统一 {"error":{"code","message","details"}};所有写端点幂等(客户端带 Idempotency-Key 或自然键唯一约束)。

协议基线:MCP 2026-07-28 规范——无状态核心(无 initialize 握手 / Mcp-Session-Id)、Tasks 扩展转正(长时操作标准模式)、旧 HTTP+SSE 传输废弃、Roots/Sampling/Logging 进入废弃期。调研细节见 01 号文档 §3.1。

  • 实现:fastmcp 3.4.x 钉版;tool 函数薄封装(MCP 进程经 REST 调用 server,保持纯适配器);
  • 传输:仅 streamable HTTP/mcp,Bearer token);本机客户端用 gpuctl mcp-stdio 桥。不做已废弃的 SSE 传输;
  • 长时任务建模(关键设计)submit_job / evaluate_checkpoint / cancel_jobTasks 扩展建模——调用立即返回 task handle(taskId/ttlMs/pollIntervalMs,内含 job_id),进度经 notifications/progress(step/epoch/loss 摘要),状态机对齐 working / input_required / completed / failed / cancelled绝不把一次数小时的训练伪装成同步 tool 调用。轮询为主、推送为辅:get_job/get_run_summary 永远可拉取关键状态(客户端渲染参差,VS Code 不显示 tasks/status 的 statusMessage),tasks/get 返回建议轮询间隔(GPU 作业 5–30s)。fastmcp 3.4 基于 SDK v1,Tasks 语义先在应用层对齐(submit 即返回 handle + get_job 查询),SDK v2 / fastmcp 4 GA 后平移到协议原生 tasks——适配层内部演进,tool 语义不变;
  • 危险操作确认cancel_job 等破坏性操作支持 input_required + tasks/update(MRTR)二次确认流程,而非另造 approval 工具;
  • 实现三个必需 headers(MCP-Protocol-VersionMcp-MethodMcp-Name)与 per-request _meta 能力声明;MCP server 无状态(状态全落 SQLite),发布时声明 io.modelcontextprotocol/tasks capability;
  • Tools v0.3(read 默认开放,write 需 token 具备 write scope):
类别 tools
观测(read) list_nodes get_node_status list_jobs get_job get_run_summary query_metrics tail_logs list_events list_checkpoints list_evaluations
控制(write) submit_job cancel_job retry_job evaluate_checkpoint
语义(read) diagnose_run compare_runs compare_checkpoints get_best_checkpoint explain_failure
  • 返回一律结构化 JSON(summary 优先、raw 可选展开),附 next_actions 提示字段(如 diagnose 返回 {"overfitting": {...}, "next_actions": ["compare_checkpoints(run_id)", "get_best_checkpoint(metric='eval/loss')"]})——引导 Agent 走正确流程而无需轮询。

11.3 事件唤醒机制(修订点:补上 Agent “按事件介入” 的落地通道)

Section titled “11.3 事件唤醒机制(修订点:补上 Agent “按事件介入” 的落地通道)”

原方案“平台持续监控,Agent 按事件介入”缺少最后一步——Agent 如何被唤醒。MCP 2026-07-28 的 subscriptions/listen 订阅流提供了协议内通道,但多数 agent harness 的 MCP 客户端并不会主动消费订阅通知,因此设计三通道兜底:

  1. Webhook 外推(v0.2):事件 → POST 到 ntfy/Bark/自定义 URL(手机推送给人);
  2. Agent harness hook:提供 gpuctl event-hook --types OOM,RUN_FINISHED -- claude -p "..." 包装命令,事件到达时拉起/唤醒本地 agent 会话;
  3. 被动拉取:Agent 会话内 list_events(severity=critical) + get_run_summary 低成本轮询(分钟级,非 30s 级);MCP 通道可用时优先用 task progress 通知替代轮询。

12. Web UI(React + Vite + Tailwind + uPlot/ECharts)

Section titled “12. Web UI(React + Vite + Tailwind + uPlot/ECharts)”

v0.4 实况:页面扩为七个——Dashboard / Runs / Run Detail / Experiments / Compare / Jobs / Nodes / Events。 图表统一走自研 ChartPanel(EMA 平滑 + crosshair 精确读数 + 拖拽缩放 + LTTB 降采样 + run 稳定配色); 实时性 = POST /runs/metrics:query 批量取数 + /runs/{id}/metrics/stream SSE 逐点推流 + 5s 水位轮询兜底, 未缩放时曲线自动跟随新数据。Experiments 重设计为 W&B 式分析工作区(多指标面板 + ★ primary 置顶 + URL 深链视图状态)。ECharts 最终未引入(uPlot 足够),下文保留最初的四页设计意图备查。

四个核心页面(与原方案一致),补充实现要点:

  1. Dashboard:节点卡片(GPU util/显存/温度/功耗,2s 刷新,uPlot sparkline)、当前 job 进度条、队列前 5;
  2. Jobs:状态分tab列表、提交对话框(yaml/表单两模式)、cancel/retry 操作;
  3. Run Detail(最重要):Overview(summary JSON 渲染)/ Metrics(uPlot 实时曲线,窗口化 + LTTB 降采样,多 run 叠加对比)/ System Metrics / Events 时间线 / Logs(SSE tail,虚拟滚动)/ Checkpoints + Evaluations 表(按 primary metric 高亮最优);
  4. Nodes:硬件信息、slot 占用、心跳时间、agent 版本。

工程要点:WS 消息进 ref 缓冲 + rAF 节流批量 setData(防高频 setState 打爆 React);ECharts 按需模块化 import;partysocket 管理重连;React 19 StrictMode 下妥善处理 effect cleanup(双挂载不双连)。


13. 安全与部署(原方案缺失,新增)

Section titled “13. 安全与部署(原方案缺失,新增)”
决策
认证 单用户多 token:server.yaml 配置 [{name, token_hash, scope: read/write/agent/sdk}];REST/WS/MCP 统一 Bearer;MCP/REST 的 write scope 才有 submit/cancel
传输 信任网络(LAN/Tailscale)可明文 HTTP;跨网必须 TLS——推荐 Tailscale 兜底,或 Caddy 自动证书反代
命令注入 job command 来自“拥有者本人”(个人产品定位),不做多租户隔离;但 Web UI 表单模式对参数做 shell 转义,yaml 模式原样传递并明示风险
数据备份 gpuctl backup 在线备份 Server SQLite;在每台 Agent 主机运行 gpuctl backup-agent 归档完整 jobs 日志/runtime 与离线 spool。分体部署必须同时保留两类备份;可选 litestream 持续复制 SQLite 到外部目录
部署 deploy/systemd/gpuplane-server.service / gpuplane-agent.service(user service + linger);升级 = git pull && uv sync --frozen --no-dev && systemctl --user restart

14. 代码结构(uv workspace monorepo)

Section titled “14. 代码结构(uv workspace monorepo)”
GPUPlane/
├── pyproject.toml # uv workspace 根
├── packages/
│ ├── common/ gpuctl-common # Pydantic 协议模型/WsMsg 类型/ID 生成/常量 (零重依赖)
│ ├── sdk/ gpuctl-sdk # 训练侧 SDK (仅 httpx)
│ ├── agent/ gpuctl-agent # NodeMonitor/Runner/TBAdapter/CkptWatcher/WSClient
│ ├── server/ gpuctl-server # FastAPI app + Scheduler + EventDetector + RollupWorker
│ ├── mcp/ gpuctl-mcp # MCP 适配器 (fastmcp 钉版)
│ └── cli/ gpuctl # typer CLI: status/node/job/run/logs/metrics/submit/import-run/mcp-stdio
├── web/ # React + Vite + Tailwind + uPlot/ECharts
├── deploy/systemd/ # unit 模板
├── docs/
├── examples/ # 示例训练项目 (mnist / hf-sft) 用于端到端验证
└── tests/ # e2e: 假 agent + 真 server + sqlite

包依赖方向:common ← sdk / agent / server / cli / mcp,server 不依赖 agent,agent 不依赖 server。CLI 顶层不 import 任何重库。

CLI 命令面(v0.1 完整,v0.2+ 标注)

Section titled “CLI 命令面(v0.1 完整,v0.2+ 标注)”
gpuctl status # 总览: 节点/GPU/当前job/队列
gpuctl node list|show <id>
gpuctl submit --name N --gpu 1 --dir D [--priority] [--retry N] -- <cmd...>
gpuctl job list|show|cancel|retry <id>
gpuctl logs <job-id> [-f] [--full] [--stream stderr]
gpuctl run list|show <id> # show 打印 RunSummary
gpuctl metrics <run-id> --names train/loss --window 6h
gpuctl checkpoints <run-id>
gpuctl import-run <dir> # v0.1: TB events + logs + ckpt 识别导入
gpuctl server serve|backup # 管理命令
gpuctl agent run # 前台运行 (systemd 托管时也由此启动)
gpuctl mcp-stdio # v0.3
gpuctl event-hook ... # v0.2

风险 影响 缓解
MCP 生态版本动荡(mcp 1→2 硬 breaking,fastmcp 3/4 交替期) 依赖冲突、升级断裂 uv.lock 钉死 fastmcp 3.4.x + mcp 1.x;MCP 放独立包,坏了不影响 server;v0.3 才接入
TB event 解析边界(tensor-scalar、半写、step 回退、大文件 CRC) Level 1 指标丢失/错乱 按 §7.4 逐条处理 + 集成测试用真实 PyTorch SummaryWriter 产物;SDK 路径作为可靠主线
单 worker 内存态 水平扩展受限 个人规模可接受;抽象 pub/sub 接口,未来可换 Redis
日志全量入库 SQLite 膨胀 §7.3:全文留 agent 端,server 只存 tail
checkpoint 半成品注册 评估到损坏模型 防抖 + 指纹 + .tmp 约定 + verify 命令
训练环境与平台依赖撞车 装 sdk 破坏训练 venv sdk 零重依赖;agent/server 永远装在自己的 venv,不进训练环境
个人产品过度设计 维护成本拖垮项目 路线图每里程碑可独立交付使用;明确不做清单(见 02 号文档)