给 DGX Spark 写了个零依赖实时监控面板:1 个 Python 文件 + 1 个 HTML 文件
-
给 DGX Spark 写了个零依赖实时监控面板:1 个 Python 文件 + 1 个 HTML 文件
起因:在 DGX Spark 上跑大模型时,想随时看清 CPU / GPU / 统一内存 / 磁盘 / 网络 / 推理吞吐的真实状态,
以及推理引擎(推理服务)到底跑得怎么样。官方 Dashboard 看不了推理细节,nvtop/btop又看不到
LLM 侧的指标,索性自己写了一个。约束:零第三方依赖——后端只用 Python 标准库,前端单文件 HTML + 原生 JS,不引任何 CDN。
拷过去python3 monitor.py就能跑。[整体效果]

目录
它有什么
17 个面板,全部可拖动、可缩放、可换位置,布局存在浏览器本地,导出一段 JSON 就能在别的机器上复现。
顶部 9 张 KPI 卡带迷你走势线;中间 6 张曲线图支持鼠标悬停出十字线和数值气泡,Y 轴单位会在
B/s → KB/s → MB/s之间自动切换。[LLM 运维区]

上排是 推理引擎(running/waiting、解码速度、TTFT、TPOT、KV Cache 分池占用、MTP 接受率)、
容器 llm(CPU / 内存 / net / block IO)、一键测速(发一次真实对话,按usage计数算吞吐)。下排是硬件明细:CPU 每核负载(20 核 GB10)、温度传感器 / 风扇(带过热状态染色)、
磁盘 I/O、网络接口(含错误/丢包计数)。[硬件表格区]

再往下是 进程 Top、引擎指标明细表、当前生效配置(直接读容器里的环境变量,一眼看到
TF_SOLO_ROWS/TF_SPILL_GIB这些实际生效的参数)、容器日志尾部。[进程 / 引擎指标 / 生效配置]

交互细节
鼠标悬停在曲线上,十字线定位到具体时刻,气泡给出该时刻的各序列数值:
[悬停交互]

拖卡片标题栏换位置,拖右下角的小三角改大小。布局可以导出成 JSON:
[布局导出]

复制这段 JSON,粘到另一台机器上点「应用」,就是一模一样的排布。
架构
monitor.py ~34 KB Python 3 标准库,无第三方依赖 ├─ 采样线程 1 Hz 写环形缓冲(deque(maxlen=...)) ├─ HTTP 服务 ThreadingHTTPServer └─ API /api/snapshot 当前快照(含 host/cpu/memory/gpu/sensors/disk/network/processes/engine) /api/series 历史序列,?window=60|300|900|1800|3600 /api/stats snapshot + 容器 stats + uptime /api/config 容器镜像/状态/环境变量(docker inspect) /api/logs 容器日志尾部 ?tail=200 /api/probe 发一次真实对话,测 TTFT / decode 速度 /api/key 设置 API Key /api/healthz index.html ~49 KB 单文件:HTML + CSS + JS,无 CDN为什么服务端采样? 因为磁盘/网络的速率本质上就是「两次采样之间的差值」,没法在 HTTP 请求发生的
那一刻凭空算出来。所以有一个后台线程固定 1 Hz 采样写进deque,前端只是来取。
值得抄的 6 个实现细节
1. 速率只能用 delta 算,不能在请求时重新采样
这是最容易踩的坑。直觉写法是「收到请求 → 读一次
/proc/diskstats→ 和上次比」,但**「上次」存在哪**
是问题:如果请求比采样线程更频繁,就会拿两个几乎同时刻的采样点作差,差值被摊薄成一个极小的时间窗,
算出来的速率要么是 0,要么抖得没法看。正确做法是:后台线程独占采样,HTTP 只返回缓存的结果。
def disk_info(prev, dt): """prev 是上一次采样的 /proc/diskstats 快照,dt 是两次采样之间的时间差。""" for name, v in cur.items(): old = (prev or {}).get(name) if old and dt > 0: d["read_bps"] = round((v[2] - old[2]) * 512 / dt, 1) # 扇区数 × 512 = 字节 d["write_bps"] = round((v[6] - old[6]) * 512 / dt, 1) d["read_iops"] = round((v[0] - old[0]) / dt, 2)后台循环里
dt用真实经过时间:def sample_once(): now = time.time() dt = max(0.5, now - STATE["prev"].get("ts", now - SAMPLE_INTERVAL)) STATE["prev"]["ts"] = now2. 统一内存架构下,
nvidia-smi的显存字段是[N/A]GB10 的 CPU 和 GPU 共享同一块内存(标称 128 GB,系统可见约 122 GiB),
nvidia-smi对显存总量/占用直接返回[N/A]:$ nvidia-smi --query-gpu=memory.total,memory.used --format=csv [N/A], [N/A]所以面板里的「统一内存」读的是
/proc/meminfo,而不是显存字段;GPU 面板只取利用率/温度/功耗。
另外power.limit、fan.speed在这台机器上也是[N/A],别指望它们。顺带一个有用的取法:逐进程显存是可以拿到的(
--query-compute-apps),把它求和就得到
「模型常驻显存」:rc, so, _se = run(["nvidia-smi", "--query-compute-apps=pid,used_gpu_memory,process_name", "--format=csv,noheader,nounits"]) out["model_gpu_mib"] = round(sum(p["mem_mib"] for p in procs), 1)3. 用 12 栅格显式定位,而不是
auto-fit最初用
grid-template-columns: repeat(auto-fit, minmax(330px, 1fr)),结果卡片宽度随窗口变化,
而且没法留空位、没法叠放。改成显式坐标后什么都解决了:.grid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr)); grid-auto-rows:44px;gap:10px} .card{grid-column:span var(--w,3);grid-row:span var(--h,5)}每张卡片存
{x, y, w, h}(单位是栅格格数),JS 直接写:el.style.gridColumn = (c.x + 1) + " / span " + c.w; el.style.gridRow = (c.y + 1) + " / span " + c.h;显式坐标才能表达「第二行右边留空」和「磁盘 I/O 叠在网络接口下面」这种排布——自动排布是做不到的。
4. 卡片被压住时「就近挪开」,而不是跳到页面另一头
拖动时如果落点压住了别的卡片,需要把被压的挪走。朴素的实现是「从左上角扫描找第一个空位」,
结果卡片会莫名其妙飞到页面最上面。这里改成收集所有空位,再按离原位的距离排序:function findSpot(w, h, exceptId, prefer){ const free = []; for (let y = 0; y < 400; y++) for (let x = 0; x <= GRID.cols - w; x++){ const cand = {x, y, w, h}; let ok = true; for (const [id, c] of Object.entries(state)){ if (id === exceptId) continue; if (overlaps(cand, c)){ ok = false; break; } } if (ok) free.push({x, y}); } if (!free.length) return {x: 0, y: 0}; if (prefer) // 就近安置:离原位越近越优先 free.sort((a, b) => (Math.abs(a.x - prefer.x) + Math.abs(a.y - prefer.y)) - (Math.abs(b.x - prefer.x) + Math.abs(b.y - prefer.y))); return free[0]; }5. canvas 曲线:nice tick + 自动单位 + 悬停十字线
不用图表库,纯 canvas 画。三点值得注意:
Y 轴刻度取「好看」的步长,否则会出现
7.4993这种刻度:niceStep(range){ const raw = range / 4, mag = Math.pow(10, Math.floor(Math.log10(raw) || 0)); const n = raw / mag; return (n <= 1 ? 1 : n <= 2 ? 2 : n <= 2.5 ? 2.5 : n <= 5 ? 5 : 10) * mag; }单位自动切换:网络速率可能是 500 B/s 也可能是 200 MB/s,固定单位必然有一头显示成
0.00 MB/s或123456789 B/s。所以按量级挑单位:if (this.opts.autoUnit){ const m = Math.max(Math.abs(vmin), Math.abs(vmax)); if (m >= 1048576){ this._scale = 1048576; this._unit = "MB/s"; } else if (m >= 1024) { this._scale = 1024; this._unit = "KB/s"; } else { this._scale = 1; this._unit = "B/s"; } }悬停十字线:鼠标位置换算成时间,吸附到最近的采样点:
onMove(e){ const g = this._geo; const x = e.clientX - this.cv.getBoundingClientRect().left; this.hover = (x - g.padL) / g.pw; this.draw(); // 重画时画十字线 const t = g.t0 + (g.t1 - g.t0) * this.hover; for (const s of this.series){ const p = this.nearest(s.data, t); // 线性找最近点 ... } }6. 布局持久化 + JSON 导出/导入
位置存
localStorage,再提供导出/导入,这样「我摆好的版面」可以固化成一段文本:const LAYOUT_KEY = "sparkmon_layout_v3"; function readLayout(){ return {layout: JSON.parse(JSON.stringify(state)), win: WIN}; } function saveLayout(){ localStorage.setItem(LAYOUT_KEY, JSON.stringify(readLayout())); }导出的 JSON 长这样,一眼就能看出每张卡片的格子坐标:
{ "layout": { "card-cpu": { "x": 0, "y": 0, "w": 2, "h": 5 }, "card-engine": { "x": 0, "y": 5, "w": 6, "h": 5 }, "card-logs": { "x": 0, "y": 24, "w": 12, "h": 6 } }, "win": 300 }
部署
# 1. 放到机器上 scp -r dgx-monitor <user>@<SPARK_IP>:~/ # 2. 一键安装(注册 systemd 服务 + 自检) cd dgx-monitor && bash install.shinstall.sh做的事:把文件装到~/dgx/monitor/、用sed替换 systemd 单元里的
User=/WorkingDirectory=/MONITOR_PORT=、daemon-reload、enable --now,
最后 curl 一下首页确认真的起来了。自检脚本检查这几项,任何一项不过会明确指出:
$ bash verify.sh PASS 服务 spark-monitor active PASS 首页 200(48958 字节) PASS API /api/stats 200 PASS 新版前端已生效(找到栅格卡片) PASS 快照字段齐全(gpu/sensors/disk/network) PASS /api/config 200 PASS /api/logs 200 PASS /api/healthz 200 PASS llm :8888 /health ok也可以不用 systemd,直接前台跑:
MONITOR_PORT=8890 python3 monitor.py
踩坑清单(实战)
这几条都是真踩过的,写出来省得别人再踩一遍。
1. 一个没定义的函数,能让半张页面变成空白。
前端调了setBar()但从没定义过它,ReferenceError直接把loadSnapshot()从中间打断,
结果 GPU、传感器、磁盘、网络、进程、容器、引擎表全部没渲染,而页面看起来只是「没数据」。
教训:前端的渲染要分段,别让一个异常连坐后面所有模块;排查时先看 console 报错。2. 同名函数声明会互相覆盖。
布局管理里有个function render()画栅格,数据渲染里也有function render(s)画面板——
JS 里函数声明会被提升,后定义的那个赢,于是数据渲染被静默顶掉,页面全空但不报错。
后来把布局那个改名paintLayout()才解决。3. 轮询打爆后端日志。
监控自己每秒去拉推理引擎的/metrics,但没带 API Key,结果后端日志里刷了 1000 多条 401。
加了 API Key 还不够,又加了失败退避:if resp.status == 401: self._engine_blocked_until = now + 300.0 # 401 就安静 5 分钟4. 单位约定不一致,数值会离谱 1000 倍。
Prometheus 的time_to_first_token_seconds是秒,但另一套采集里换算成了毫秒,
两边混用就会看到「TTFT 均值 105952.685 s」。贴指标前先确认单位,表头也要写清楚。5. Y 轴标签被裁掉。
padL: 48放不下87.81 MB/s,首字符被切掉变成7.81 MB/s,看起来像数据错了。
标签宽度要按「最长刻度 + 单位」算。6.
install.sh用enable --now不会重启已在跑的服务。
enable --now对已运行的服务是 no-op,改完代码重新安装,线上跑的还是老版本。
正确做法是systemctl --user enable之后显式restart。7. systemd 单元里的
User=是写死的。
别人拿去用就会因为用户名不同而起不来。安装脚本里用sed替换成当前用户。
文件清单
dgx-monitor/ ├── monitor.py 后端(标准库,零依赖) ~34 KB ├── index.html 前端(单文件,无 CDN) ~49 KB ├── spark-monitor.service systemd 单元模板 ├── install.sh 一键安装 ├── verify.sh 部署后自检 └── layout.test.js 布局引擎单元测试layout.test.js是给布局逻辑写的单元测试(纯逻辑,Node 里跑,浏览器无关),
校验默认版面不重叠、关键排位、碰撞推挤、就近安置、持久化往返:$ node layout.test.js == 1. 默认版面(17 张卡片)自身不重叠 == PASS 全部卡片互不重叠 PASS 状态里有 17/17 张卡片 ... == 5. findSpot 返回真正空的位子 == PASS 空位 {"x":0,"y":30} 无冲突 PASS 带 prefer 取位不更远:{"x":8,"y":30}(距离 21 ≤ 扫描序 29) 全部通过 ✅怎么拿到源码
源码就上面这 7 个文件,没有构建步骤、没有依赖清单,全量 84 KB。几种给法:
- 贴到 gist / 仓库:只有 7 个文件,直接建个仓库传上去最省事
- 打包成 tar.gz:
tar czf dgx-monitor.tar.gz dgx-monitor/,随帖附上 - 从帖子直接抄:本文已经把最关键的 6 段实现贴出来了,照着写也能复现个七七八八
如果你想给别人的是「换个样式 / 换数据源」的版本,改
index.html里的CHARTS/DEFAULT_LAYOUT
两个常量就够了,数据源在monitor.py的sample_once()里。源码里的占位符
发布版把和具体环境绑定的部分换成了占位符,拿到后改这几处即可:
占位符 在哪 改成 <ENGINE_PREFIX>monitor.py(Prometheus 指标前缀,约 20 处)你引擎的前缀,例如 vllm:<model-name>monitor.py模型名;留空则自动从 /v1/models发现<user>spark-monitor.service不用手改, install.sh会自动替换成当前用户名
已知限制
- 面板不绑定任何推理后端。引擎相关的部分靠读
/metrics(推理服务 / vLLM 都行),
没起容器时那几块是空的,其余面板照常工作。 - API Key 只存在于内存或容器环境变量里,面板本身不落盘、不外传。
- 布局存在浏览器 localStorage,换浏览器要靠「布局…」里的 JSON 搬运。
- 采样精度受限于 1 Hz:突发写入(几百 MB 的 dd)能抓到,持续小流量会被平均掉。
- 只做了 Linux/aarch64,其它平台的采集函数(
/proc、hwmon)需要改。
本文配图与源码均已做脱敏处理:模型名、容器镜像 tag、引擎名、用户名、主机名、IP、API Key
均已替换为占位符或打码;面板本身也从不显示 API Key 的值。
dgx-monitor-post.zip -
我的建议是安装一个副屏。大模型主机不需远程访问主机。让它24小时工作即可。有没有故障扫一样副屏就知道了。副屏的功耗一般是3W左右。可以忽略不计。
-
我的建议是安装一个副屏。大模型主机不需远程访问主机。让它24小时工作即可。有没有故障扫一样副屏就知道了。副屏的功耗一般是3W左右。可以忽略不计。
-
我有帖子。抓包 在 Linux重写下就可以。难度不大。
拼夕夕 45元一个。 -
当然也有大些的价格要贵一些。好的100多。
-
版主审定:本帖设为优秀,作者 +3 积分奖励,继续加油!