Claude 会话额度和 reset 时间是很多重度使用者的共同焦虑。连续对话一长,心里总会犯嘀咕:当前会话还能撑多久,是刚过 reset 不久、额度充足,还是已经用掉大半个窗口,再问几个问题就可能被限流。Claude Pacer 解决的就是这个问题:它常驻 macOS 菜单栏,显示当前 Claude 会话的状态,并判断这个会话能否撑到下一次 reset。这篇文章以 Claude Pacer 为背景,拆解一个菜单栏状态工具的完整实现思路。文章会先讲清楚会话、额度和 reset 机制之间的关系,再对比几种菜单栏实现方案,然后用 Python + rumps 做一个最小可运行版本把核心逻辑跑通,最后补齐验证、排错和发布建议。读完以后,你可以按同样思路实现自己的版本,也可以只借助其中的状态模型,把它扩展到其他订阅制 AI 工具的额度监控场景。
1. 先理解 Claude 会话与 reset 机制,再决定菜单栏要显示什么
很多人在实现这类小工具时,第一反应是找“剩余额度”接口。但菜单栏工具真正要展示的不是一个孤立的数字,而是一个包含时间窗口、当前使用量和消耗速度的判断结果。先搞清楚这几个概念,后面写代码才不会乱。
1.1 会话、使用额度与重置窗口
在 Claude 这类服务里,“会话”通常指用户和模型之间的一段连续对话。对话会产生 token 消耗,也会占用服务器资源。为了控制资源使用,服务方会在一定时间窗口内为用户设置使用上限。这个窗口结束的那一刻,就是 reset 时间。
对于不同产品形态,限制的粒度不一样:
- 订阅套餐,常见的是固定小时窗口或滑动窗口内的使用量限制。窗口结束或额度刷新后,又可以继续使用。
- API 调用,常见限制包括每分钟请求数、每分钟 token 数,以及更长时间维度的每日用量。
- 讨论工具提到的 reset,多数场景指的是那个“到点后额度恢复”的窗口边界。
这里涉及一个容易混淆的点:会话是否“撑到 reset”,不取决于当前剩余额度,而是取决于“剩余额度在当前消耗速度下还能用多久”和“距离 reset 还有多久”之间的比较。如果剩余额度很多,但消耗速度极快,仍然可能提前触顶;如果距离 reset 只剩几分钟,即使剩余额度不多,也大概率能等到额度刷新。
可以把需要监控的数据整理成一张表:
| 数据项 | 含义 | 常见获取方式 |
|---|---|---|
| window_start | 当前窗口开始时间 | 从本地缓存或服务端响应中读取 |
| window_seconds | 窗口长度 | 套餐说明或响应头 |
| used_quota | 当前已用额度 | 日志、响应头或账户页面 |
| total_quota | 当前窗口总额度 | 套餐说明或账户页面 |
| current_speed | 当前消耗速率 | 用已用量除以已用时间估算 |
菜单栏工具的核心,就是围绕这些数据做一次非常朴素的预测。
1.2 菜单栏工具真正要解决的是“能否撑到重置”
“能否撑到 reset”这个问题,天然适合用菜单栏承载。它不需要用户主动打开网页,不需要切出编辑器,只要扫一眼菜单栏就能知道当前状态。这就是 Claude Pacer 这类工具最大的价值:把“判断额度是否够用”从一次割裂工作流的操作,变成一件几乎无感知的事情。
工具的输出可以分为三层:
- 第一层是状态结论,例如“状态安全”“可能撑不到 reset”“已接近上限”。
- 第二层是剩余时间,例如“距离 reset 还有 3 小时 25 分钟”。
- 第三层是原因和依据,例如“剩余额度 0.4,当前速度 0.15/hour”。
第一层和第二层适合放在菜单栏,因为菜单栏空间有限。第三层适合放在下拉菜单中,用户点击后才展开。实现时不要把所有信息都塞进菜单栏标题,否则长文本会把菜单栏撑得很乱。
1.3 数据来源决定实现成本
菜单栏 UI 并不难做,真正的难点是数据从哪里来。实现前必须想清楚自己的账号类型和使用方式,否则会陷入“采集器写了一半发现接口不可用”的困境。
常见数据来源有三种:
- Claude API 响应头:如果你自己调用 Claude API,限流信息通常会出现在 HTTP 响应头中,比如剩余额度、重置时间等。优点是数据准确,缺点是需要自己记录请求过程的响应头。字段名在不同版本或不同网关下不一定相同,落地前要先用一次真实请求确认。
- Claude Code 本地日志:如果你在终端里使用 Claude Code,本地目录下通常会有日志文件,里面可能包含会话、用量等信息。优点是离用户近,缺点是日志位置、格式、字段都可能随版本变化,解析代码要写得足够宽容。
- 订阅套餐账户页面:网页上登录后能看到使用量,但这类页面通常没有给自动化预留稳定接口。个人工具偶尔手写一个采集脚本可以,生产化之前要确认是否违反服务条款,并且避免高频抓取。
注意:无论选择哪种数据来源,都应该先手动确认数据的真实字段,再写解析逻辑。不要根据网上的示例直接猜测字段名,否则工具会显示“看似正常但实际错误”的额度。
明确数据来源后,工具的技术路线也就定了。
2. 菜单栏工具的总体设计与技术选型
菜单栏应用看起来只是个常驻小图标,背后涉及 UI 框架、定时任务和数据采集模块。这一节先做技术选型,再拆模块,避免边写边改。
2.1 为什么用 macOS 菜单栏承载状态提示
macOS 菜单栏由系统顶部的状态栏构成,应用可以通过 NSStatusItem 注册一个常驻入口。与窗口应用相比,菜单栏应用有几个明显优势:
- 不占 Dock 位,也不会弹出主窗口干扰用户。
- 用户可以通过菜单栏图标快速查看状态并点击交互。
- 配合 Timer 可以定时刷新,实现“常驻监控”。
当然,菜单栏应用也有缺点:菜单栏空间天然有限,不可能展示长篇信息;系统对后台常驻应用有资源管理策略;用户可能不小心把图标折叠起来。这些在实现时都要考虑到。
2.2 三种实现路线对比
对于个人状态工具,常见的实现路线有三种。以“快速跑通”为标准,推荐程度不同:
| 实现路线 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| Swift + AppKit | 系统原生,内存占用低,体验最好 | 需要 Xcode 工程,改起来重 | 计划长期使用、要上架或分发 |
| Electron / Tauri | 前端技术栈,UI 自由度高 | Electron 体积和内存大,Tauri 需要 Rust 环境 | 已有前端团队,需要复杂界面 |
| Python + rumps | 几十行代码可跑通,数据处理方便 | 依赖 Python 环境,打包不如原生方便 | 个人工具、快速原型、自己维护 |
这篇文章的主路线选 Python + rumps。原因很实际:菜单栏展示逻辑很简单,真正有复杂度的是数据采集和状态预测,Python 在这些模块上写起来更顺手。生产化时如果觉得 Python 环境太重,再重写成 Swift 也不迟。
2.3 核心模块划分与目录结构
无论用什么语言,这个工具都应该按四条职责拆分:
- collector:负责采集窗口开始时间、剩余额度等原始数据。
- predictor:负责根据原始数据计算“能否撑到 reset”。
- renderer:负责把计算结果渲染到菜单栏。
- scheduler:负责定时触发刷新。
用目录表达就是:
claude_pacer/ ├── claude_pacer.py # 入口,启动 rumps 应用 ├── collector.py # 采集数据 ├── predictor.py # 核心预测逻辑 ├── renderer.py # 菜单栏显示格式 ├── config.py # 窗口长度、阈值等配置 └── tests/ └── test_predictor.py # 核心逻辑测试对于最小版本,文件可以少一些,但模块边界必须清楚。尤其是 predictor,它只依赖传入的数据对象,不依赖任何网络请求。这样后续换数据源时,核心判断逻辑一行都不用改。
3. 最小可运行版本:Python + rumps 实现状态菜单栏
这一节从一个最小可运行版本开始。先不讲真实日志解析,先把菜单栏、状态判断和定时刷新完整跑通。
3.1 环境准备与依赖安装
在 macOS 上执行以下命令:
python3 --version python3 -m venv .venv source .venv/bin/activate pip install rumps这里有几个检查点:
- rumps 只支持 macOS,Windows 和 Linux 上无法运行菜单栏 UI。
- 如果 Python 版本过低,建议使用 Python 3.10 或更高版本。
- rumps 依赖 pyobjc,首次安装会拉取较多依赖,耐心等待即可。
安装完成后,可以运行一个最简单的示例确认环境正常:
import rumps class DemoApp(rumps.App): def __init__(self): super().__init__("Demo") DemoApp().run()运行后菜单栏出现一个名为 Demo 的图标,说明环境没问题。如果没出现,优先检查 Python 解释器路径和终端权限。
3.2 先接入一个本地 usage 数据源,避免一开始就困在登录态里
很多实现卡在第一步,不是 UI 难写,而是“不知道怎么从账号里拿到用量”。解决方式很简单:先造一个本地 mock 数据源,把整条链路跑通,再替换成真实采集器。
先用一个数据类表达会话状态:
from dataclasses import dataclass from datetime import datetime, timedelta @dataclass class SessionUsage: window_start: datetime # 当前窗口开始时间 window_seconds: int # 窗口长度,秒 used_quota: float # 已用额度 total_quota: float # 总额度 current_speed: float # 每小时消耗额度,0 表示未知再写一个 mock 加载器:
def load_usage() -> SessionUsage: # 模拟:窗口 5 小时,已经过了 2 小时,用了 35%,当前速度 0.1/hour return SessionUsage( window_start=datetime.now() - timedelta(hours=2), window_seconds=5 * 3600, used_quota=0.35, total_quota=1.0, current_speed=0.1, )这样做的好处是,核心逻辑开发完全不需要真实账号,也不需要处理登录态和日志格式。等工具跑通后,再把load_usage替换成读取 Claude Code 日志或 API 响应头的实现。
3.3 核心计算逻辑:能否撑到 reset
核心预测函数负责四件事:
- 计算已经过了多少时间。
- 计算距离 reset 还剩多少时间。
- 按当前速度估算剩余额度还能用多久。
- 综合判断当前状态。
实现如下:
def compute_status(usage: SessionUsage, now: datetime) -> dict: elapsed_seconds = max(0.0, (now - usage.window_start).total_seconds()) remaining_window = max(0.0, usage.window_seconds - elapsed_seconds) remaining_quota = max(0.0, usage.total_quota - usage.used_quota) elapsed_hours = elapsed_seconds / 3600.0 # 没有历史速度数据时,只按剩余额度比例判断 if usage.current_speed <= 0: ratio = usage.used_quota / usage.total_quota if usage.total_quota else 0 if ratio < 0.5: level = "ok" elif ratio < 0.8: level = "warn" else: level = "danger" estimated_hours_left = None else: estimated_hours_left = remaining_quota / usage.current_speed estimated_seconds_left = estimated_hours_left * 3600.0 if estimated_seconds_left >= remaining_window: level = "ok" elif estimated_seconds_left > 0: level = "warn" else: level = "danger" # 如果窗口已经结束,说明刚 reset 或即将 reset if remaining_window <= 0: level = "reset" return { "level": level, "remaining_window": remaining_window, "estimated_hours_left": estimated_hours_left, "remaining_quota": remaining_quota, "used_ratio": usage.used_quota / usage.total_quota if usage.total_quota else 0, "reset_time": usage.window_start + timedelta(seconds=usage.window_seconds), }判断逻辑的关键在于比较两个时间:剩余的窗口时间,和按当前速度估算出来的额度可用时间。如果额度可用时间比窗口剩余时间更长,说明“能撑到 reset”;如果额度可用时间更短,说明很可能在 reset 之前就把额度用完。
这里的“当前速度”不是瞬时速度,而是从窗口开始到现在的平均消耗速率。它是这个工具中最容易产生误导的参数,后面会专门讨论。
3.4 渲染菜单栏并定时刷新
菜单栏渲染代码比较简单:
import rumps class ClaudePacerApp(rumps.App): def __init__(self): super().__init__("Claude Pacer", title="计算中...") self.menu = ["刷新", "详情"] # 每 300 秒刷新一次,避免请求过于频繁 self.timer = rumps.Timer(self.refresh, 300) self.timer.start() def refresh(self, _=None): usage = load_usage() status = compute_status(usage, datetime.now()) level_text = { "ok": "OK", "warn": "WARN", "danger": "DANGER", "reset": "RESET", }[status["level"]] remaining_h = status["remaining_window"] // 3600 remaining_m = (status["remaining_window"] % 3600) // 60 text = f"{level_text} {int(remaining_h):02d}:{int(remaining_m):02d}" self.title = text ratio = status["used_ratio"] detail = ( f"已用 {ratio * 100:.0f}%\n" f"剩余额度 {status['remaining_quota']:.2f}\n" f"reset 时间 {status['reset_time']:%H:%M}" ) self.menu["详情"] = detail if __name__ == "__main__": ClaudePacerApp().run()运行方式:
python claude_pacer.py菜单栏会出现类似OK 02:13的文本。到这里,一个最小闭环已经完成:它有数据源,有状态判断,有定时刷新,也有菜单栏展示。接下来要解决的是“判断逻辑是否合理”的问题。
4. 关键参数与判断逻辑详解
代码能跑通只是第一步。window_seconds设成多少、current_speed怎么估算、阈值怎么选,这些参数直接决定工具是否可信。
4.1 三种窗口模型
“reset 时间”具体怎么算,取决于服务端采用哪种窗口模型:
| 窗口模型 | 含义 | 对工具的影响 |
|---|---|---|
| 固定窗口 | 从固定时间点起算,并按固定周期重置 | reset 时间可预测,计算简单 |
| 滑动窗口 | 从用户第一次使用起算,往后推一个完整周期 | reset 时间随第一次使用时间变化 |
| 分钟级速率 | 每分钟限制请求数或 token 数 | 短时间维度,菜单栏工具意义不大 |
订阅制套餐常见的描述是“连续 X 小时窗口”或“每 X 小时重置一次”。实现时要先确认是固定窗口还是滑动窗口,否则window_start会算错。API 场景则要先关注分钟级速率,这个通常不需要做成菜单栏,因为窗口太短。
4.2 剩余时间与消耗速度的计算
当前速度的估算,最简单的方式是:
current_speed = used_quota / elapsed_hours这个平均值在会话前期波动很大。比如刚打开对话 10 分钟就用掉 5% 额度,折合每小时 30%,但真实使用可能只是刚开始发了几条长消息。工具会因此给出偏悲观的判断。
改进方向有两个:
- 引入一个最小观察窗口,例如至少运行 30 分钟才计算速度,否则使用默认速度。
- 记录最近 N 次刷新时的额度差值,用短时间窗口的增量计算速度。
def smooth_speed(history: list[tuple[datetime, float]]) -> float: # history: [(时间, 已用额度), ...] if len(history) < 2: return 0.0 first_time, first_used = history[0] last_time, last_used = history[-1] elapsed_hours = (last_time - first_time).total_seconds() / 3600.0 if elapsed_hours <= 0: return 0.0 return (last_used - first_used) / elapsed_hours这个函数用一段观察窗口的总消耗除以总时间,比单次瞬时值稳定得多。对于个人工具,至少保留最近 6 到 10 条记录再计算,效果会更接近真实使用节奏。
4.3 状态分级与阈值选择
状态阈值没有绝对标准,但可以根据使用目的定位:
| 状态 | 判断条件 | 菜单栏表现 | 建议行为 |
|---|---|---|---|
| OK | 剩余额度可用时间 >= 窗口剩余时间 | OK 03:12 | 正常使用 |
| WARN | 额度可用时间 < 窗口剩余时间,但还有余量 | WARN 00:42 | 减少长对话,留意消耗 |
| DANGER | 剩余额度几乎耗尽,或速度预测会在 reset 前耗尽 | DANGER 00:05 | 停止低优先级对话 |
| RESET | 当前窗口已结束 | RESET 00:00 | 可以放心继续使用 |
阈值的选择要考虑自己的使用习惯。如果只是轻度使用,剩余额度 20% 都不会有紧迫感;如果是重度使用,建议在 40% 时就开始告警。不要照搬别人的阈值,而要根据自己的历史数据调整。
5. 运行验证:从启动到边界用例
写完成代码后,需要验证的不仅是“应用能启动”,还要验证状态判断在边界情况下是否正确。
5.1 启动应用并确认菜单栏状态
在终端执行:
python claude_pacer.py正常情况下,菜单栏出现类似OK 02:13的文本。这里OK表示状态,02:13表示距离 reset 还有 2 小时 13 分钟。
同时检查下拉菜单里的“详情”项,内容应该和 mock 数据对得上。如果只看到标题显示,详情为空,说明 rumps 的self.menu赋值方式有问题,或者菜单对象没有正确更新。
5.2 用边界输入验证判断逻辑
不要只依赖 mock 数据。把以下输入分别跑一遍,确认输出符合预期:
| 场景 | window_start | 已用额度 | 当前速度 | 预期状态 |
|---|---|---|---|---|
| 刚 reset | now - 1分钟 | 0.0 | 0.0 | RESET 或 OK |
| 刚使用不久 | now - 10分钟 | 0.05 | 0.0 | ratio 低,应该 OK |
| 接近窗口结束 | now - 4小时50分 | 0.9 | 0.1 | RESET 概率高 |
| 额度不足但时间充足 | now - 1小时 | 0.9 | 0.05 | 可能 WARN 或 DANGER |
| 速度过快 | now - 1小时 | 0.5 | 0.4 | DANGER |
用固定时间测试,避免依赖datetime.now():
from datetime import datetime from predictor import compute_status, SessionUsage now = datetime(2025, 1, 1, 12, 0) usage = SessionUsage( window_start=datetime(2025, 1, 1, 11, 0), window_seconds=5 * 3600, used_quota=0.5, total_quota=1.0, current_speed=0.4, ) status = compute_status(usage, now) print(status["level"]) print(status["remaining_window"]) print(status["reset_time"])如果输出符合判断逻辑,再进入真实数据接入。这一步能避免后面排查时区分不清“是显示问题还是逻辑问题”。
5.3 常见验证盲区
以下三类问题最容易漏掉:
- 只验证正常状态,不验证 reset 跨越。窗口时间一旦变成负数,title 里可能显示负时间。
- 只验证 mock 数据,不验证日志解析。真实日志里字段缺失、空值、额外空格都会让代码直接报错。
- 不验证系统时间变化。如果电脑长时间睡眠,
datetime.now()会跳变,Timer 回调可能会延迟。菜单栏工具要处理“回来时发现已经过了很久”的情况,最简单的做法是每次刷新都重新计算,而不是累加。
6. 常见问题排查:菜单栏不显示、状态不刷新、数据读不到
把问题按现象归类,排查起来会快很多。
6.1 菜单栏图标不出现
可能原因和检查顺序:
| 现象 | 常见原因 | 检查方式 |
|---|---|---|
| 终端无报错,但菜单栏无图标 | 应用启动后直接退出 | 在run()前加print("start"),确认是否执行到这里 |
| 图标出现后又消失 | rumps 初始化失败或 Timer 回调异常 | 查看终端 traceback,观察异常信息 |
| 在某个屏幕上不显示 | macOS 菜单栏在多显示器下被自动折叠 | 点击屏幕刘海旁的展开按钮查找 |
| 双击启动器无法运行 | Python 环境变量不一致 | 使用绝对路径的 Python 启动脚本 |
最直接的现象是启动后终端打印了异常。rumps 的回调异常通常会打印到终端,不要忽略它。
6.2 状态一直不刷新或时间不更新
如果定时器不刷新,通常不是系统问题,而是代码写错了。
检查点:
- rumps.Timer 对象是否被 App 持有。如果 Timer 变量在
__init__里被丢弃,调用可能不会生效。 - 回调里是否执行了阻塞操作,比如
time.sleep或同步网络请求。在菜单栏主线程里做耗时操作会卡住整个 UI。 self.title是否被赋值过。如果赋值时抛异常,刷新也会中断。
推荐把耗时的采集逻辑放到独立线程,或者至少保证采集逻辑有超时时间:
import threading def refresh_async(self): thread_usage = load_usage_async() self.refresh_ui(thread_usage)6.3 读取不到 Claude 会话数据
这是接入真实数据时最常遇到的问题。现象通常是“状态一直显示无数据”或解析直接报错。
排查顺序:
- 先确认文件是否存在、权限是否可读。
- 再确认字段名称和格式。Claude Code 的日志格式可能随版本变化,不要写死字段。
- 用单独脚本读取日志并打印前几行,确认数据是 JSON、纯文本还是其他格式。
- 解析时使用“尽量宽容”的方式:字段缺失时不报错,而是返回 None 或 0。
注意:不要根据一篇旧博客的字段名直接写死解析逻辑。日志格式、目录位置、安装方式都可能不同,最终字段要以本机实际输出为准。
6.4 应用无法开机自启或被杀
开发环境中手动运行没问题,但重启后工具不出现,这是 macOS 常驻工具最常见的痛点。两种处理方式:
- 在“系统设置 - 通用 - 登录项”中把
claude_pacer.py或打包后的应用加进去。 - 使用 launchd 配置 LaunchAgent。
launchd 的最小 plist 示例:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>Label</key> <string>com.example.claudepacer</string> <key>ProgramArguments</key> <array> <string>/Users/yourname/.venv/bin/python</string> <string>/Users/yourname/claude_pacer/claude_pacer.py</string> </array> <key>RunAtLoad</key> <true/> <key>KeepAlive</key> <false/> </dict> </plist>注意:路径必须写绝对路径,尤其要使用虚拟环境里的 Python,而不是系统自带 Python,否则 import rumps 会失败。
7. 生产化改造与最佳实践
最小版本跑通后,如果打算长期使用,应该按生产化标准再打磨一轮。
7.1 学习环境与生产环境的差异
| 维度 | 学习环境 | 生产环境 |
|---|---|---|
| 数据源 | mock 数据 | 真实日志或 API 响应头 |
| 刷新频率 | 任意 | 根据来源限制调整,避免高频请求 |
| 日志 | 写入固定目录日志,支持tail | |
| 异常处理 | 不处理 | 捕获异常并保持上次状态 |
| 启动方式 | 手动运行 | 登录项或 launchd |
| 发布形式 | 脚本 | app 或整机统一工具链 |
生产环境最关键的改造是“失败时不要误报”。如果数据读取失败,宁可显示“UNKNOWN”,也不要用上一次的旧数据冒充当前状态,否则会给用户错误安全感。
7.2 发布前检查清单
发布或长期使用前,建议逐项确认:
- [ ] mock 数据下,OK、WARN、DANGER、RESET 四种状态都观察过。
- [ ] 真实数据源字段解析和日志位置已经在本机验证。
- [ ] 状态刷新有异常兜底,不会因为日志格式变化而崩溃。
- [ ] 刷新频率不会对本地或远端造成压力。
- [ ] 系统重启后登录项能自动启动。
- [ ] Python 环境路径已写死,找不到 rumps 时有明确提示。
- [ ] 菜单栏 title 长度可控,不会把菜单栏撑得很难看。
7.3 扩展方向
Claude Pacer 的最小版本只是一个起点,后续可以按几个方向扩展:
- 在详情菜单里加入“历史用量曲线”,展示多个窗口的使用趋势。
- 设置通知,当状态从 OK 变成 WARN 时弹系统通知。
- 把预测结果写入本地 JSON,供其他工具读取。
- 用 Swift 原生重写,去掉 Python 依赖。
- 如果通过自己的 API 网关发起请求,可以在网关侧统一收集限流头,然后由菜单栏工具读取汇总数据。
如果要长期维护,建议把 collector 单独拆成一个可复用的采集脚本,这样即使 Claude Code 日志格式变化,也只改 collector,不影响 predictor 和 renderer。
菜单栏工具的核心,从来不是做出一个漂亮的图标,而是把“剩余额度、时间窗口、消耗速度”三个信息压缩成一个可靠的状态判断。先用 mock 数据跑通,再逐步替换真实数据源,是让这个工具真正可信的路径。实现完后,最有价值的练习不是继续堆功能,而是记录自己一周的真实使用数据,用这些数据调整速度估算和状态阈值,直到工具的判断和你的实际感受一致。