用Python Tkinter打造屏幕边缘小组件:实时监控GPU、配额与会议倒计时
上周我坐在工位前连续三次切出去看编码助手的剩余配额、又跑回去盯 GPU 显存、再翻一眼下午会议的倒计时整个人像在四个页面间跳踢踏舞。于是我花了一个小时写了个极其不起眼的小脚本把这几样东西全钉在屏幕最右边编码助手额度、GPU 占用、最近一场会议还剩多久、预印本论文有没有更新。效果出奇地好——不用再惦记着去查抬眼就能看到。这篇文章就把这个小工具从需求到实现拆开讲清楚适合所有整天跟终端、训练、会议打交道的开发者和研究人员。1. 先想清楚要“钉”什么四类信息的获取思路做工具之前最怕的是“什么都想放进去”结果界面塞满了反而一个字都不想看。我先列出自己真正会反复去查的四件事再为每一件找最轻量的取数方式。这不只是写代码更是对注意力的筛选。1.1 编码助手额度从配额接口到本地缓存编码助手这类工具的额度通常指订阅套餐里剩下的可用次数或 token 数量。大多数服务都会提供一个查询当前账号状态的接口返回已用数量、总额度、重置时间这几个字段。实现思路很简单用requests请求对应接口解析 JSON 里的数字然后再画到屏幕上。我实际用的模块长这样# sources/quota.py import os, time, requests QUOTA_URL os.environ.get(QUOTA_API_URL, ) QUOTA_KEY os.environ.get(QUOTA_API_KEY, ) def fetch_quota(): if not QUOTA_URL or not QUOTA_KEY: return {used: 0, limit: 0, reset_at: } r requests.get(QUOTA_URL, headers{Authorization: fBearer {QUOTA_KEY}}, timeout5) r.raise_for_status() data r.json() return { used: int(data.get(used, 0)), limit: int(data.get(total, 0)), reset_at: data.get(reset_at, ), }注意几个容易踩的点API Key 千万别写进代码里用环境变量或者本地配置文件接口频率也有限制额度并不会每秒变化完全没必要每次打开窗口都实时请求。我的做法是把返回结果存到本地 JSON 文件做个缓存10 分钟刷新一次就足够。要是服务连不上就直接读缓存屏幕上还能显示昨天的旧数据不至于一片空白。1.2 GPU 占用命令轮询与轻量采集训练模型或者跑推理任务时我最关心两件事GPU 是不是真的在干活以及显存还剩多少。最直接的方式就是调显卡驱动自带的命令行工具一步就能拿到所有 GPU 的利用率、显存占用和温度。不同厂商的命令名略有差异但参数结构差不多这里用gpu-smi指代这一类命令。# sources/gpu.py import subprocess, shlex def get_gpu_info(): cmd gpu-smi --query-gpuutilization.gpu,memory.used,memory.total --formatcsv,noheader,nounits try: out subprocess.check_output(shlex.split(cmd), timeout5).decode().strip() except Exception: return None lines out.splitlines() result [] for line in lines[:2]: # 只看前两张卡 parts [int(x) for x in line.replace(,, ).split()] result.append(tuple(parts)) return result返回结果里每个元组对应一张卡格式是利用率百分比已用显存 MB总显存 MB。这个命令每执行一次会启动一个外部进程有小开销但不算重。唯一要注意的是刷新间隔后面踩坑部分我会单独讲。1.3 会议 DDL读本地清单做倒计时额度、GPU 都有相对标准的接口会议 DDL 反而是最没有统一答案的。有人用企业日历有人用待办清单我只是想要一个“最近一场会议还剩多久”的数字。最省事的办法是维护一个本地 JSON 文件手动或者用脚本往里面塞会议事件。# sources/ddl.py import json, datetime def load_events(pathdata/events.json): with open(path, r, encodingutf-8) as f: return json.load(f) def calc_countdown(event): deadline datetime.datetime.fromisoformat(event[deadline]) delta deadline - datetime.datetime.now() if delta.total_seconds() 0: return 已到期 hours, rem divmod(int(delta.total_seconds()), 3600) mins rem // 60 return f{hours}小时{mins}分后事件清单的格式很简单[ {title: 周报评审, deadline: 2025-06-05 14:00}, {title: 产品对焦, deadline: 2025-06-05 16:30} ]读进来之后按截止时间排序最紧急的排在最上面。如果你用的是日历服务完全可以把导出的 ICS 文件解析成同款 JSON思路是一样的。1.4 预印本论文用 RSS 订阅标题流论文预印本平台几乎每天都有新文章打开主页很容易被淹没在几百个标题里。相比手动刷新RSS 订阅是最轻量、也最不容易漏的方式每个子分类都提供聚合种子里面是最新发表的论文标题、作者、链接和摘要。用feedparser解析 RSS 2.0只保留前几条就够了。# sources/paper.py import feedparser PAPER_FEED https://example.org/rss?categorycs.AI def get_latest_papers(max_items5): feed feedparser.parse(PAPER_FEED) items [] for entry in feed.entries[:max_items]: items.append({ title: entry.get(title, ).strip(), url: entry.get(link, ), published: entry.get(published, ), }) return items订阅地址常被反爬限制需要在请求头里加上正常的 User-Agent或者改用镜像地址。我后面会写到一个缓存策略论文更新频率不高半小时拉一次就够了千万别做成每秒钟刷一下。2. 核心方案选型为什么我选了 Python 无边框置顶窗数据源摸清楚以后真正困难的是“显示”这部分。做屏幕边缘小组件可选的技术路线很多我犹豫了半天最后还是回到最朴素的方案上。2.1 候选方案对比桌面窗口 vs 前端壳 vs Python GUI把几种主流方案摆在一起比较会很清楚方案优点缺点适合场景C 原生窗口系统集成度极高、占用极小开发周期长字符串和布局代码繁琐想做成品级小工具Electron / Tauri前端布局灵活、跨平台内存占用高、启动速度慢需要复杂交互和华丽界面Python Tkinter开发速度快、数据源生态一致样式朴素需调系统 API 补能力个人工具、快速验证我最终选了 Python Tkinter。理由很直接额度、GPU、DDL、论文这四个数据源模块已经全是 Python 了再用 Python 做 GUI 就不需要引入第二门语言。Tkinter 是标准库自带生成一个无边框置顶窗口只需要几行代码虽然样子朴素一点但对我来说“在屏幕边上挂个数字条”完全够用。2.2 窗口核心特征置顶、半透明、边缘停靠“钉在屏幕边上”听起来很形象落到窗口属性上无非四点去掉标题栏、永远置顶、半透明、贴在显示器边缘。Tkinter 里前三个都很简单root tk.Tk() root.overrideredirect(True) # 去掉标题栏 root.attributes(-topmost, True) # 常驻最前 root.attributes(-alpha, 0.88) # 半透明 # 计算屏幕右边缘坐标 screen_w root.winfo_screenwidth() root.geometry(f280x600{screen_w - 300}80)这段代码会把一个 280x600 的窗口放在主屏右侧距离右边缘 20 像素。overrideredirect(True)会同时去掉边框和最小化按钮配合topmost就能一直浮在别的窗口上面。鼠标穿透是另一个需求。Tkinter 本身没有直接的穿透开关需要调用桌面系统的窗口 API给窗口加上透明穿透样式。我用的是这样的代码import ctypes def enable_click_through(hwnd): style ctypes.windll.user32.GetWindowLongW(hwnd, -20) ctypes.windll.user32.SetWindowLongW(hwnd, -20, style | 0x20 | 0x80000)但这里有个很实际的矛盾如果窗口完全穿透鼠标那当你看到一期论文标题想点开链接时就永远点不到它。所以我最后的折中是默认不穿透把窗口放在屏幕最右边缘一块不算太窄的区域宽度只有 280px。正常工作时鼠标很少滑过去偶尔要点击论文链接时又还能点反而比一味追求穿透更好用。2.3 刷新机制轮询 vs 推送四类数据的变化频率差异巨大GPU 占用率变化按秒计会议 DDL 按分钟变化额度和论文几乎按小时变。如果统一用同一个刷新频率要么浪费请求要么数据滞后严重。推送显然会更优雅但个人工具没必要引入消息队列或 WebSocket。轮询配合差异化周期已经足够def schedule(self): self.root.after(5000, self.update_gpu) # 5 秒 self.root.after(60000, self.update_ddl) # 60 秒 self.root.after(600000, self.update_quota) # 10 分钟 self.root.after(1800000, self.update_papers) # 30 分钟after是 Tkinter 的定时器能在主线程里安排下一次调用。CPU 任务都很短直接放在主线程没问题。如果以后要解析整篇论文摘要超过 1 秒的耗时任务再考虑丢进线程池。3. 一步一步把“钉子”做出来方案定下来以后剩下的就是动手。下面是我整理出来的最小可运行版本包含完整目录结构、核心代码和最后的自启配置。3.1 项目结构与环境准备先把依赖控制在最小范围requests、feedparser其余全靠标准库。项目目录这样组织screen-tack/ ├─ config.yaml # 配置订阅地址、文件路径 ├─ data/ │ ├─ events.json # 会议 DDL 清单 │ └─ quota_cache.json # 额度缓存 ├─ sources/ │ ├─ gpu.py │ ├─ quota.py │ ├─ ddl.py │ └─ paper.py ├─ ui.py # 窗口和渲染 └─ main.py # 入口配置文件不喜欢 YAML 的话可以直接放config.py省掉一个扩展包。对我来说 YAML 看着清楚就保留了。3.2 数据源模块把四个采集函数装进一个入口四个数据源已经各自写成函数现在要做的是把它们统一收集到一个collect_all()里方便 UI 层一次拿全# main.py import sources.gpu as gpu import sources.quota as quota import sources.ddl as ddl import sources.paper as paper def collect_all(config): data {} data[quota] quota.fetch_quota() data[gpu] gpu.get_gpu_info() data[ddl] ddl.calc_countdown(ddl.load_events(config[events_path])[0]) papers paper.get_latest_papers(config[paper_feed]) data[paper] papers[0][title] if papers else 暂无更新 return data这里每个子模块都做了异常兜底返回None或空字符串时UI 层显示“--”。这么设计特别重要屏幕上的小工具绝不能因为某个数据源挂掉就整体崩溃。比如网络断了额度模块抛异常不能影响 GPU 的显示。3.3 渲染与布局怎么把信息排得清爽窗口要窄信息却不能瞎挤。我的做法是把竖条分成四个区域每块之间留一点空白用不同字体大小区分主次。额度做成一个简易进度条GPU 用数值加显存显示DDL 用大号字体强调紧迫感论文只放一行截断的标题。# ui.py import tkinter as tk class TickerUI: def __init__(self, root): self.root root self.labels {} self.build_widgets() def build_widgets(self): rows [quota, gpu, ddl, paper] for i, key in enumerate(rows): frame tk.Frame(self.root, bg#2b2b2b) frame.pack(fillx, padx12, pady6) title tk.Label(frame, textkey.upper(), fg#888888, bg#2b2b2b, font(Helvetica, 8)) title.pack(anchorw) value tk.Label(frame, text--, fg#eeeeee, bg#2b2b2b, font(Helvetica, 10), anchorw, justifyleft) value.pack(fillx) self.labels[key] value刷新的时候只需要逐个更新label.config(text...)。想做得更精细可以用 Canvas 画进度条但先跑起来比画得好看更重要。颜色我统一用了深灰背景加浅灰文字不会在桌面上显得突兀长时间看也不刺眼。3.4 集成与自启从命令行到开机启动开发阶段用python main.py跑就行稳定后建议打包成单文件pyinstaller --onefile --noconsole --name screen-tack main.py桌面系统里让程序开机自启通常是把可执行文件路径写进注册表。这一步很多人忽略一个细节写绝对路径要小心用户名变了之后失效。我提供一种相对通用的做法先把 exe 复制到用户数据目录下再注册New-ItemProperty -Path HKCU:\Software\Microsoft\Windows\CurrentVersion\Run -Name ScreenTack -Value $env:APPDATA\ScreenTack\screen-tack.exe -PropertyType String更好的做法是在 Python 脚本里用一个自启函数完成每次启动时检查注册表项是否存在不存在就自动补上。这样重装或迁移机器后第一次运行就能恢复自启。4. 踩坑实录这些坑我帮你趟过了把四个数据源拼起来只花了一个多小时真正花时间的反而是各种奇怪的边缘状况。这一节基本是我在真实使用中被反复摩擦后总结出来的建议直接收藏。4.1 置顶窗口被全屏应用遮挡一开始我以为设了topmost就高枕无忧结果打开全屏演示工具或播放视频时这个钉子还是会被盖住。原因是那些程序使用了更高优先级的窗口层级普通置顶压不过它。琢磨了一阵后我做了个“开关式置顶”设定一个快捷键按下时强制再执行一次attributes(-topmost, True)并把窗口拉到前面。平时允许它被全屏内容遮挡不打扰工作需要看的时候一键呼出。这种设计比死磕“永远不被盖住”务实得多。4.2 鼠标穿透之后没法点击怎么办这个问题在前面选型时提过实际操作时会更纠结。我把窗口设成穿透时确实不挡任何操作了但论文链接永远点不到取消穿透鼠标滑过时又可能误触尤其屏幕右边缘正好是滚动条附近。我的最终方案是一个双模式切换默认窗口穿透但在窗口顶部留了一条 8px 宽的“手柄”区域鼠标移动到那里时窗口取消穿透并短暂成为最顶层移出后恢复穿透。这样兼顾了“不挡事”和“可交互”虽然代码多写了二十行但用起来舒服得多。4.3 高频轮询 GPU 导致驱动不稳定这是我踩得最深的坑。最开始新增评测后我把 GPU 刷新间隔设成 1 秒跑了小半天显卡驱动居然出现了“显示驱动程序已停止响应”的提示。后来查了下频繁从外部命令查询 GPU 状态会和系统显卡管理模块产生冲突尤其在某些老型号的卡上更容易触发。把间隔调到 5 秒之后问题就消失了。如果你实在想要更实时的数据可以用 Python 绑定的显卡管理库直接在进程内取数少一层外部进程调用可以把间隔缩到 3 秒左右但 1 秒仍然不建议。4.4 论文 RSS 解析乱码与断连论文订阅源有个很阴的问题网络偶尔不好时会返回半截不完整的数据或者 SSL 证书校验失败feedparser.parse不会报错但解析出来的标题全是乱码。我第一次遇到时屏幕上显示了一长串不知所云的字符还以为是字体渲染坏了。现在我在解析前强制用UTF-8编码重新包装请求内容同时把最近一次成功的论文数据存成 JSON 缓存。一旦解析结果异常或者请求超时直接退回缓存显示保证界面永远有内容可看。4.5 多显示器边缘定位既然要“钉在屏幕边上”定位就不该只看主屏。winfo_screenwidth()返回的是主显示器宽度如果你像我一样用双屏主屏右边缘和副屏右边缘根本不是一回事。如果只算主屏窗口就可能跑到副屏中间的奇怪位置。我改用桌面系统 API 枚举所有显示器的坐标再根据显示器编号算边缘位置。大致思路是拿到目标显示器的工作区矩形(left, top, right, bottom)然后把窗口的坐标设成right - width - 20, top 80。代码稍长但换屏后依然能准确贴边。5. 还能怎么玩后续扩展与个性化“屏幕钉子”这个形态只是一个起点。用了一周后我已经发现了很多值得加进去的东西这里挑三个最实用的方向说说。5.1 做成 Web 小组件如果不想依赖桌面 GUI可以把采集逻辑改成本地 Web 服务用 Flask 或 FastAPI 暴露一个 JSON 接口再让浏览器开一个小窗固定在侧边。好处是前端样式随便写抛弃 Tkinter 的朴素坏处是浏览器本身占用内存不小而且多了一个服务进程要维护。我的建议是如果你已经有常驻的本地服务顺手加几个路由非常划算如果只是为这个小工具单独开一个服务那还是 Tkinter 更轻量。5.2 记录历史趋势曲线把每次采集到的 GPU 占用率写进 SQLite表结构就三列时间戳、显卡编号、利用率。积累一两天后就能画出“训练任务到底哪段时间在满负荷跑、哪段时间在空闲”的曲线比单纯看百分比直观得多。画图可以用pyqtgraph实时刷新流畅或者直接输出 PNG 在按钮点击时弹窗打开。这个思路同样适用于额度每月重置那天可以看到额度消耗速度是否在加快。5.3 告警与通知单纯显示还是需要人去“看”更进一步的形态是“不打扰但关键时刻出现”。我加了几条规则额度低于 10% 时弹通知GPU 利用率在预期结束时间之后降到 0说明训练可能停了会议还有 30 分钟时窗口背景短暂变色。进一步还能在 DDL 前 5 分钟强制把窗口拉到屏幕中央闪两下这就是把监控工具从“展示器”变成了“提醒器”。这个工具最值钱的地方不是代码量而是它重新分配了我对注意力的使用方式。以前我每隔几分钟就下意识切出去看一眼额度、再跑回来盯显存零碎时间全耗在“确认”这个动作上。现在这些信息像便利贴一样贴在屏幕边缘抬眼就有抬手就能点到论文链接整个人反而安静了很多可以专注手头真正的工作了。