首页 / 资讯中心 / 文章详情

Codex CLI Token 用量监控:PyQt5 桌面悬浮宠物设计与实现

Codex CLI Token 用量监控:PyQt5 桌面悬浮宠物设计与实现 ★ FEATURED ARTICLE
先交代一下背景。我算是 Codex CLI 的早期重度用户从第一天开始就把日常的代码生成、重构、修 bug 全部交给它基本是把它当结对编程搭档在用。用得越深一个问题就越绕不开token 到底花了多少额度还剩多少注册送的额度什么时候见底每次去查都得先定位会话目录、翻 tokens.md 文件、再对着数字心算一遍运气不好日志文件一多光是找对路径就够折腾。后来实在受不了干脆花了两个晚上做了一个桌面悬浮宠物固定悬浮在屏幕最上层实时显示 Token 用量、额度剩余刷新在后台静默完成再也不用到处翻日志了。这篇文章就把整个项目的设计与实现从头到尾拆一遍包括数据链路怎么打通、悬浮窗怎么做、认证报错怎么排查希望能给你自己做类似小工具时提供一点参考。1. 项目定位与整体设计思路1.1 痛点拆解为什么需要专门的 Token 监控工具Codex 的 Token 记录其实写得很明确它会在会话目录里生成tokens.md每一轮对话结束后更新一次里面记录了累计输入 token、累计输出 token、累计成本。问题在于这个文件藏在~/.codex/sessions/{session_id}/下面session_id 又是一长串随机字符我每次查用量都得做三件事先用命令列目录找到最近的 session 文件再打开 tokens.md 对照时间戳确认是不是最新的最后心里换算成剩余额度。一天重复四五次之后这个动作就从“例行检查”变成了“心理负担”。做悬浮宠物之前我盘点过已有的替代方案。Codex 终端里本身会在每次请求结束后打印 usage 信息但那是临时输出滚两屏就没了。OpenAI 官网后台能看到账号维度用量但延迟高而且需要切浏览器登录等你看完数字手头的编程思路已经断了半个小时。日志文件虽然信息完整但可读性极差。这些方案都解决不了“持续性可见”的问题而桌面悬浮窗天然就是干这个的不用主动查询数据就在眼前不打断工作流瞥一眼就够。所以这个工具的定位很明确不是做一个完整的 Codex 管理面板而是做一块“仪表盘玻璃片”你不需要操作它它自己保持最新状态。核心功能只有三个实时读取 Token 用量、按预算换算剩余额度、用视觉状态提醒用户注意消耗速度。1.2 技术栈选型Electron、Tauri 还是 PyQt悬浮宠物这类桌面小工具技术栈选择直接决定开发效率和资源占用。我在立项时对比了三个方案。Electron 是生态最成熟的前端界面随便写动画效果好但内存占用起步就是一两百 MB。一个只需要显示几个数字的小挂件常驻内存这么高我觉得有点亏。Tauri 很轻量前端依旧用 Web 技术后端用 Rust但构建链路长涉及系统 WebView 的兼容问题调试成本不低。最终我选了 PyQt5原因有三个一是内存占用在 40-60 MB 左右对常驻工具非常友好二是QSystemTrayIcon的托盘支持、透明无边框窗口支持在跨平台场景下都很成熟三是我可以在 Python 里直接用pathlib处理 Codex 的本地文件不需要通过 IPC 桥接前后端。选 PyQt5 也有代价主要是动画表现力不如 Web 方案。不过我的定位本来就是“数据仪表盘”不是“游戏宠物”简单动画用像素序列帧 定时器就足够后面会细说。1.3 功能裁剪先跑通链路再谈效果这个项目的功能边界经历了两次砍切。第一次砍切发生在需求整理阶段我最初列了十几项功能包括宠物动画、语音播报、用量报表、邮件告警、多账号切换。后来冷静下来想这些功能 80% 都是锦上添花真正能解决痛点的是“实时数据显示”这 20% 的核心。第二次砍切发生在实现阶段最初做了“卡通宠物随机动作”动画结果发现 1 秒 30 帧的 PNG 序列帧在透明窗口上绘制CPU 占用明显偏高而且每次动画刷新都会干扰视觉读取数字。最后我把动画砍成只在数据刷新时做一个轻微缩放效果其他时间保持静态。我给这个工具定了一条原则任何功能如果会干扰“一眼读取 Token 用量”这个核心目标就不做。这条原则在后面的交互设计中救了命。2. 实时监控数据链路从日志到悬浮窗2.1 Codex 数据源解析tokens.md 与日志兜底首先得搞清楚数据从哪里来。Codex CLI 目前会在本地持久化会话数据目录结构大致是这样~/.codex/ ├── config.toml ├── auth.json ├── log/ │ └── codex-tui.log └── sessions/ ├── {session_id}/ │ ├── history.jsonl │ ├── tokens.md │ └── rollout.jsonl └── {session_id}/ ├── ...tokens.md是最理想的监控目标因为它是 Codex 官方为了跟踪用量主动写出的文件结构相对稳定。我的机器上它的内容大致长这样# Token usage - model: gpt-4.1 - total_input_tokens: 85932 - total_output_tokens: 12648 - total_tokens: 98580 - total_cost_usd: 0.8921不过实测发现tokens.md的字段名在不同版本 Codex 里偶尔会变例如input_tokens、cached_tokens这些字段都出现过。所以解析逻辑不能硬编码字段名得做一层模糊匹配遍历文件内容同时找total_tokens、cost、model这几个关键词找不到就尝试算两个 token 分项之和。考虑到tokens.md文件在极少数情况下会缺失或写入延迟我还加了第二层数据源Codex 的日志文件codex-tui.log。这个日志里每次 API 调用完成都会打印一条 usage 汇总包含prompt_tokens、completion_tokens、total_tokens。解析日志作为兜底方案虽然格式更乱但信息足够。2.2 文件监听还是轮询我选了朴素的时间戳方案确定数据源之后下一个问题是“什么时候去读文件”。两个典型方案一是用 watchdog 这类库监听文件系统事件文件变化立刻回调二是固定间隔轮询每次比较文件修改时间变了才读取。watchdog 方案响应快、实时性最好但有个实际麻烦它在 Linux 上需要 inotify 支持某些网络文件系统、Docker 映射目录上会失效而且 Codex 写 tokens.md 时不是原子操作可能触发多次文件变化事件你需要自己做去抖。轮询方案笨一点但胜在绝对可靠。我最后用的是轮询 时间戳缓存。默认 30 秒检查一次tokens.md的最后修改时间如果和上次记录的不一样才重新读取文件内容。为什么是 30 秒因为 Codex 单轮对话的响应时间通常大于 10 秒30 秒轮询几乎不会丢数据同时 CPU 占用可以忽略不计。实现很简单import os import time from pathlib import Path class TokenFileWatcher: def __init__(self, file_path: str, interval: int 30): self.file_path Path(file_path) self.interval interval self._last_mtime 0.0 self._last_data None def poll(self): try: mtime self.file_path.stat().st_mtime except FileNotFoundError: return self._last_data if mtime self._last_mtime: return self._last_data self._last_mtime mtime self._last_data self.file_path.read_text(encodingutf-8) return self._last_data注意这里有个小细节我刻意用st_mtime而不是st_size做判断因为 Codex 可能会原地修改文件文件大小不一定变化但修改时间一定会变。2.3 Token 用量与额度计算账要算得明白从 tokens.md 拿到原始数据后悬浮窗上要展示的其实不只是 token 数而是“花钱速度”和“剩余额度”。这需要引入成本换算和预算管理。先说成本换算。不同模型的单位价格差很多我把当前配置的模型和对应的 1M token 单价做成映射表PRICE_PER_1M_TOKENS { gpt-4.1: {input: 2.0, output: 8.0}, gpt-4o: {input: 2.5, output: 10.0}, o3-mini: {input: 1.1, output: 4.4}, }不过 Codex 会通过config.toml配置 model provider用户完全可能自定义模型名所以硬编码价格表会漏。我的做法是先从tokens.md里读当前 model 名称如果匹配不上价格表就回退到读取config.toml中的model字段再匹配不上的话只显示 token 数不显示金额。这个设计避免了“显示一个错误的金额”这种更严重的问题。预算部分浮窗需要一个“总额度”概念。Codex 本身没有提供官方的配额查询 API所以我的处理方式是让用户在配置文件的[monitor]区域里手动填写[monitor] budget_usd 50.0 warning_threshold 0.8剩余额度 预算总额 - 当前成本。当用量超过预算的 80% 时悬浮窗状态从绿色变成橙色超过 100% 就变红。这比单纯显示一个 token 数字直观得多。计算逻辑如下def calc_budget_status(total_cost: float, budget: float, threshold: float 0.8): if budget 0: return green, None remain max(0.0, budget - total_cost) used_ratio total_cost / budget state green if used_ratio 1.0: state red elif used_ratio threshold: state orange return state, remain2.4 多会话聚合别被单次会话的数字骗了Codex 一个会话目录对应一次对话但实际使用中你每天可能开好几个会话只看当前会话的 tokens.md 会严重低估消耗。所以监听起来后还得做“会话聚合”。我的做法是遍历~/.codex/sessions/下所有子目录里的tokens.md按文件修改时间过滤出过去 24 小时内更新过的会话把它们的 token 数和成本全部加总。这个聚合操作不需要高频执行放在每分钟一次的定时任务里就行因为多会话的汇总对实时性要求没那么高而且 Codex 写文件本身也有延迟。聚合逻辑还有一个注意点tokens.md记录的是“会话累计用量”不是“本次新增用量”。聚合的时候如果直接加总会把已经统计过的部分重复计算。我为了避免这个问题维护了一个本地缓存文件cache.json记录每个会话文件上一次解析时读到的 total_tokens每次解析时用当前值减掉缓存值得到新增量再把新增量加到全局累计中。这样即使 Codex 重写整个 tokens.md也不会产生重复计费。3. 悬浮宠物前端交互与实现3.1 无边框透明窗口悬浮物的形态基础桌面悬浮宠物和普通应用窗口最大的区别在于形态。普通窗口有标题栏、有边框、占据任务栏而悬浮宠物要的是“没有窗口感”——它就飘在桌面上不打扰你但随时可见。在 PyQt5 里设置这种窗口核心代码就几行from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QWidget class PetWidget(QWidget): def __init__(self): super().__init__() self.setWindowFlags( Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool | Qt.WindowDoesNotAcceptFocus ) self.setAttribute(Qt.WA_TranslucentBackground) self.setAttribute(Qt.WA_ShowWithoutActivating)逐行解释这四个 flag 的作用。FramelessWindowHint去掉系统标题栏和边框WindowStaysOnTopHint让窗口保持在所有普通窗口之上Tool让窗口不出现在任务栏和 AltTab 切换列表里WindowDoesNotAcceptFocus配合WA_ShowWithoutActivating是关键它保证悬浮窗不会抢焦点——你正在编辑器里打字的时候悬浮窗不会突然把输入焦点夺走否则这个工具就完全没有可用性了。再加上WA_TranslucentBackground窗口背景完全透明只显示你绘制的内容。这样用户看到的不是一个矩形窗口而是飘在桌面上的一个小宠物图标。3.2 数据展示与交互逻辑悬浮宠物虽然小但交互逻辑需要精心设计。我做的交互方案分三层默认态、悬浮态、点击态。默认态是这样的一个小尺寸的宠物形象右下角显示当前用量百分比数字以及一个小色点表明状态绿/橙/红一眼扫过就能判断“今天花得猛不猛”。鼠标悬停上去时宠物上方展开一个半透明卡片显示完整数据当前会话 tokens、24 小时聚合 tokens、成本金额、剩余额度、模型名称。鼠标拖拽可以移动宠物位置双击切换锁定/半透明状态。交互实现上用 Qt 的事件处理就可以搞定def mousePressEvent(self, event): if event.button() Qt.LeftButton: self._drag_offset event.globalPos() - self.frameGeometry().topLeft() def mouseMoveEvent(self, event): if event.buttons() Qt.LeftButton: self.move(event.globalPos() - self._drag_offset)双击切换半透明的逻辑比较实用默认态是不透明的方便看数字但如果你在编辑代码或者开会投屏不希望这个宠物太抢眼双击一下让它变成 30% 透明度只剩一个轮廓。悬停时透明度恢复。实现就是重写enterEvent和leaveEvent调节setWindowOpacity。3.3 系统托盘、全局快捷键与跨平台注意点悬浮窗再轻量也还是需要退出入口和配置入口。我把系统托盘做成了主力控制区。右键托盘图标会弹出菜单包含显示/隐藏悬浮宠物、立即刷新、暂停监控、打开日志、退出。左键双击托盘图标是显示/隐藏的快捷方式。全局快捷键是另一个高频需求。很多时候你并不想移动鼠标去托盘点击只想按一下快捷键立刻刷新数据。我用的方案是pynput库注册全局快捷键F8 隐藏/显示宠物F9 立即刷新数据。from pynput import keyboard def on_activate_refresh(): app.refresh() app.show_toast(Refreshed) hotkey keyboard.HotKey( keyboard.HotKey.parse(f9), on_activate_refresh ) def for_canonical(f): return lambda k: f(listener.canonical(k)) with keyboard.Listener( on_pressfor_canonical(hotkey.press), on_releasefor_canonical(hotkey.release) ) as listener: listener.join()使用pynput时有几个平台差异点需要留意。Linux Wayland 会话下全局热键权限受限我在 X11 下测试没问题Wayland 下建议改用桌面环境的快捷键绑定到启动命令。macOS 上首次运行需要授予辅助功能权限否则全局监听不生效。Windows 上基本无感。总之这个功能属于“加分项”如果平台权限搞不定完全可以砍掉不影响核心使用。还有一个细节pynput的全局监听会常驻一个后台线程如果你已经用了 Qt 的事件循环注意热键回调里不要直接操作 UI 组件要通过信号槽或者QMetaObject.invokeMethod切回主线程否则容易触发“跨线程修改 UI”的崩溃。3.4 动画适度原则像素风宠物怎么做不卡既然叫悬浮宠物形象上总要有点“宠物感”。我选择了最简单的像素风格 PNG 序列帧一帧一帧显示在一小块画布上。好处是像素风体积小、绘制快不需要在透明窗口上做复杂的矢量渲染。关键的优化是帧率和动画触发策略。我最初尝试让宠物一直循环播放呼吸动画CPU 占用直接上去一大截而且窗口透明度开启时动画刷新会明显消耗系统资源。后来改成“状态触发动画”平时完全静止数据刷新时播放一次连拍两帧的反馈动画状态从绿变橙或变红时播放一次对应的变色动画。这样既保留了“活的”感觉又把 CPU 占用压到 1% 以内。PyQt5 里播放 PNG 序列帧用QLabel逐帧设置setPixmap就行简单直接self.frames [QPixmap(fassets/frame_{i}.png) for i in range(8)] self.timer QTimer(self) self.timer.timeout.connect(self.next_frame) def play_animation(self): self._frame_idx 0 self.timer.start(50)如果不想准备序列帧也可以直接用一段 GIF但注意 PyQt5 的QMovie播放 GIF 时同样会消耗资源务必加帧间隔控制不要用QMovie默认的无脑循环。4. 常见问题与排查技巧4.1 Token 认证异常从“token exchange failed”说起使用 Codex 的过程中最常被社区提到的报错就是token exchange failed表现形式五花八门有的是登录时直接失败有的是运行中突然失效。从我实测和个人经验来看这类问题大部分不是程序 bug而是认证链路的某个环节出了问题。先说登录失败。如果你在codex login或使用客户端登录时看到sign-in could not be completed token exchange failed优先怀疑两个方向一是令牌确实过期了Codex CLI 的登录态保存在~/.codex/auth.json如果里面存的是短期访问令牌且没有刷新令牌过一段时间就续不上了处理办法是重新执行完整登录流程二是系统时间偏差JWT 令牌的过期校验对客户端时间非常敏感本地时钟如果偏了几分钟服务端所有请求都会判定令牌无效。我在一次修电脑时钟后遇到过这个问题同步时间后立刻恢复。再说运行中的 403 类报错。如果你看到token endpoint returned status 403 forbidden优先检查账号状态和当前网络出口。注意这种报错并不会自动恢复必须做诊断而不是反复重试。我的排查顺序是先看auth.json里的令牌字段是否完整再看本地网络到认证服务的连通性最后重新登录。这里也要提醒一句任何涉及网络请求异常的问题先别急着卸载重装日志里的错误码远比“感觉”靠谱。4.2 本地代理端口冲突日志里的 “local proxy failed”Codex 支持本地代理配置很多人会在本地跑一个 API 网关再指向不同的模型服务商。这种配置下日志里偶尔会出现类似cc switch local proxy failed while handling codex endpoint的报错。这通常不是 Codex 本身的问题而是本地代理服务状态异常。我的排查经验分两步。第一步确认代理进程是否存活端口是否被占用或监听地址是否改变。因为代理重启后端口可能漂移Codex 还在请求旧端口自然失败。第二步检查config.toml里model_providers的base_url是否指向正确的本地地址。如果两者都没问题再检查一下环境变量里是否有HTTP_PROXY这类全局代理残留和本地代理配置打架。这类问题定位到“配置不一致”后重启一下代理服务基本都能解决。4.3 tokens.md 桌面悬浮宠物值为 0 或不变如果你照本文思路开发遇到悬浮窗数字一直是 0 或长时间不变优先检查三件事。第一路径对不对。Codex 的会话目录可能在~/.codex/sessions但如果你设置了自定义CODEX_HOME环境变量实际路径会不一样。第二文件有没有写入权限。某些沙箱环境下 Codex 进程是只读模式tokens.md 可能一直不更新。第三解析逻辑是否匹配了字段名。不同 Codex 版本的 tokens.md 字段有细微差异我建议在解析代码里加一行调试输出把原始内容打印出来看两眼比盲猜快得多。聚合统计失效也是常见问题。如果你按我的方案做多会话聚合发现总量一直不涨可以看看cache.json是不是落在一个没有写权限的目录。权限异常时文件读写会静默失败计数器自然永远停在初始值。4.4 悬浮窗抢焦点、无法拖拽、托盘图标不显示这三个问题都属于桌面开发的老面孔了。悬浮窗抢焦点说明你没有设置WindowDoesNotAcceptFocus和WA_ShowWithoutActivating这两个属性设置后立即解决。无法拖拽多半是鼠标事件被其他控件拦截了。比如我在卡片上放了 QLabelQLabel 默认不会向上传递拖拽事件需要在卡片上重写三个鼠标方法并手动调用父窗口的响应逻辑。托盘图标不显示最常见的原因是图标文件路径用了相对路径而程序不是从项目根目录启动的。解决方法是启动时把工作目录切到项目根目录或者干脆用绝对路径。托盘还有一个值得注意的细节在部分 Linux 桌面上QSystemTrayIcon需要依赖应用剩余的菜单进程才能正常显示如果程序异常退出导致托盘残留下次启动可能看不到新图标。遇到这种情况先找到残留的旧的进程杀掉再重启程序。4.5 轮询性能问题最后提一下性能。悬浮窗类工具是常驻的性能问题直接影响日常使用体验。我的优化策略是“能不动就不动”轮询文件时先看时间戳没有变化就绝不读文件内容聚合统计放到每分钟一次而不是每 30 秒一次动画播放只在状态变化时触发。这些优化做完后工具常驻内存约 55 MBCPU 占用基本是 0%。如果还嫌高可以把刷新间隔从 30 秒放宽到 60 秒代价是数据延迟最多一分钟对于“看个大概”的需求完全足够。关于这个项目我个人在实际使用中最深的感受是很多看起来“高大上”的工具背后核心逻辑其实都很朴素难点全在细节的取舍上。比如这个悬浮宠物技术难点不在 Qt 窗口也不在文件解析而在于你能否克制住“什么功能都想加”的冲动把“一眼看到 Token 用量”这件事做到极致。后续如果有余力我可能会加一个轻量的周报统计把每天的 token 消耗做成折线图但这个想法的优先级并不高因为当前这个“瞟一眼就知道花了多少”的状态已经很大程度上消除了我对额度的焦虑。如果你也在做类似的监控工具欢迎从这个最小闭环开始改。
阅读完成 · 觉得有帮助?
咨询建站