前阵子接了一个内部工具需求:让工程师在 Mac 上随时看到当前 LLM 账号的 Token 消耗和预估花费。最初想到的是浏览器扩展,但实际用下来发现体验不够直接——浏览器不开、页面不打开,就看不到数据。最后改成了一款常驻菜单栏的小组件:一个小胶囊显示用量数字,点击后弹出详细面板,既不影响工作流,又能随时掌握成本。
这篇文章把从 0 到 1 的实现过程整理成完整教程,涉及 SwiftUI 的MenuBarExtra、NSStatusItem、网络请求、本地缓存、设置面板等知识点。不管你是 macOS 开发初学者,还是已经在做 LLM 工具链的开发者,都可以照着这篇文章做出一个属于自己的“Mac 菜单栏 LLM 用量监视器”。
1. 为什么要在 Mac 菜单栏显示 LLM 用量
1.1 场景与痛点
随着日常开发和业务中越来越多地接入大语言模型 API,Token 消耗和费用已经变成实际需要关注的问题。调用次数多了之后,账单往往不是立刻可见的,等到月底看到费用数字,才意识到某些场景的调用量远超预期。
这种场景下,最理想的状态是:用一个非常轻量、不会打断当前工作的小窗口,实时展示当前账号的 Token 使用量、调用次数、预估花费等信息。用户扫一眼就能知道“今天用量是否正常”“是不是某个定时任务跑超了”。
浏览器扩展的问题在于依赖浏览器常开,而且很多浏览器扩展只能拿到页面级数据,无法直接展示系统级状态。命令行工具虽然强大,但需要主动打开终端去执行,不够直观。于是“菜单栏扩展”成为最优解:它占用面积极小,不会遮挡代码编辑器,却能像一个小仪表盘一样常驻在屏幕顶部。
1.2 方案选型:浏览器扩展还是菜单栏扩展
先理清一个概念:这里说的 extension 不是 VS Code 扩展,也不是 Chrome 扩展,而是 macOS 系统上的菜单栏扩展组件。它的本质是一个常驻状态栏的 App 或 Agent,最常见的实现方式是 SwiftUI 的MenuBarExtra,或者 AppKit 的NSStatusItem。
对比浏览器扩展,菜单栏扩展的主要优势是:
- 不依赖浏览器:即使浏览器没有启动,菜单栏组件也能工作。
- 系统级常驻:开机启动后自动出现在菜单栏,无需手动打开。
- 信息密度可控:菜单栏里显示一句话摘要,点击后才展开详细数据。
- 与系统交互更自然:支持快捷键、支持
Settings场景、支持通知提醒。
典型的应用场景包括 API 成本监控、Token 剩余量提醒、服务健康状态展示、定时任务执行结果提示等。本文实现的组件,本质上就是一个“LLM 用量仪表盘”。
1.3 本文要实现的最终形态
项目标题里的 panel / pill / nub,分别对应三种 UI 形态:
- pill:菜单栏上的小胶囊按钮,显示类似
LLM 12.3K这样的简短文字,表示当前 Token 总量。 - panel:点击菜单栏按钮后弹出的详细面板,展示输入 Token、输出 Token、预估花费、刷新时间等数据。
- nub:菜单栏上的小圆点指示器,可以理解为连接状态或数据状态灯。
最终效果是:菜单栏出现一个小胶囊,打开后是一个面板,面板中展示详细的 LLM 用量信息。整体交互非常简单,但背后涉及的数据链路、缓存策略、网络请求、配置管理,都是实际工程中需要认真处理的环节。
2. 环境准备与项目初始化
2.1 开发环境与版本说明
本文示例基于 macOS 13 及以上系统版本。如果系统版本较低,SwiftUI 的MenuBarExtraAPI 可能不可用,建议改用 AppKit 的NSStatusItem方案实现,后文会单独说明。
开发工具方面,需要准备 Xcode 和 Swift。项目本身不依赖第三方包,使用系统自带的 SwiftUI、AppKit、Foundation 框架即可。
版本需要根据你的项目实际情况调整。如果当前 Xcode 版本较新,Swift 编译器的并发检查会更严格,本文代码采用了相对保守的写法,尽量兼容不同版本。
2.2 创建 SwiftUI 项目
打开 Xcode,选择File -> New -> Project,选择macOS -> App,界面语言选择 Swift,生命周期选择 SwiftUI App。
项目名称可以取为LLMBuddy,这样后续代码中的 module 名称和目录结构都一致。创建完成后,默认生成的文件结构如下:
LLMBuddy/ ├── LLMBuddyApp.swift ├── ContentView.swift └── Assets.xcassets本文的示例会在根目录下继续增加 Models、Services、Views 等目录,方便代码分层。
2.3 开启网络权限与沙盒配置
如果项目启用了 App Sandbox,需要在 Xcode 的Signing & Capabilities中开启Outgoing Connections (Client),否则网络请求会被沙盒拦截,出现“请求失败”或“无网络权限”的错误。
如果目标是从 App Store 分发,沙盒是必选项;如果只是自用或企业内部工具,也需要在开发阶段确认网络权限已开启。开启方式如下:
- 选中 target
- 选择
Signing & Capabilities - 点击
+ Capability - 添加
App Sandbox - 勾选
Network -> Outgoing Connections (Client)
如果用量查询接口是 HTTP 明文地址,还需要在Info.plist中配置 App Transport Security 例外。实际生产环境建议统一使用 HTTPS。
3. 核心原理拆解:菜单栏组件的三种形态
3.1 panel:点开后的用量详情面板
panel 是用户点击菜单栏按钮后看到的弹层,通常是一个小窗口或者 Popover。在 SwiftUI 中,可以通过MenuBarExtra的menuBarExtraStyle(.window)实现。
MenuBarExtra可以理解成一个专门为菜单栏开发的 SwiftUI Scene。它自带两种展示样式:
.menu:点击后展示一个菜单,适合操作型入口。.window:点击后展示一个自定义视图,适合内容型面板。
对于显示用量数据这种场景,应该选择.window,因为 panel 中需要展示多行数据、状态文字,以及可能的操作按钮,单一的菜单项不够灵活。
在实现中,要把弹层视图和菜单栏按钮视图分开。菜单栏按钮是一个很薄的视图,只负责显示摘要;点击后的 panel 是一个内容更丰富的视图,负责承载详细信息。
3.2 pill:菜单栏上的胶囊按钮
pill 是菜单栏上的按钮外观形态。macOS 菜单栏高度有限,通常不会像浏览器扩展那样展示大块内容,而是用一个小胶囊来承载最基本的信息。
一个典型的菜单栏 pill 由以下部分组成:
- 图标:系统 SF Symbol 或者自定义小图标。
- 简短文本:例如
12.3K、$2.34、LLM。 - 状态点:一个绿色、黄色或红色的小圆点,表示数据是否新鲜。
可以用 SwiftUI 的Capsule形状配合背景色实现胶囊效果。
需要特别注意的是,菜单栏的空间非常有限,文本不能太长。如果用量数字过大,建议格式化显示,例如 12345 显示为12.3K。如果同时显示费用和 Token,可以只保留一个核心指标,其余放在点击后的 panel 中。
3.3 nub:状态指示灯与数据入口
nub 是组件中的“小凸块”或“状态点”,它的作用不只是装饰,而是快速传达状态信息。
可以在菜单栏胶囊中嵌入一个小圆点,当数据成功拉取时显示绿色,当请求失败或数据过期时显示黄色或红色。用户不需要点击面板,只看状态点颜色就能判断“数据是否正常”。
这种状态指示机制在运维工具中很常用。菜单栏组件空间有限,颜色是传递状态的最经济方式之一。
下面这个简单的 SwiftUI 视图可以把图标、状态点、文字组合成一个菜单栏按钮:
// 文件路径:LLMBuddy/Views/StatusBarLabel.swift import SwiftUI struct StatusBarLabel: View { let usage: LLMUsage? let hasError: Bool var body: some View { HStack(spacing: 5) { Circle() .fill(color) .frame(width: 7, height: 7) if let usage = usage { Text(usage.displayText) } else { Text("LLM") } } .font(.system(size: 11, weight: .semibold, design: .rounded)) .padding(.horizontal, 8) .padding(.vertical, 3) .background(Capsule().fill(Color.accentColor.opacity(0.12))) .overlay( Capsule().stroke(Color.accentColor.opacity(0.25), lineWidth: 0.5) ) .clipShape(Capsule()) .fixedSize() } private var color: Color { if hasError { return .orange } return usage == nil ? .gray : .green } }这段代码中,Circle就是 nub,Capsule包裹整体形成 pill,点击后的详细内容则是 panel。三者组合起来,就完成了项目标题中描述的交互形态。
4. 完整实战:实现 LLM 用量菜单栏小部件
下面进入核心实操环节。我们会逐步实现一个可以运行的 macOS 菜单栏 LLM 用量监视器。
4.1 项目结构
在刚才创建的 Xcode 工程中,继续增加以下文件:
LLMBuddy/ ├── LLMBuddyApp.swift ├── Models/ │ ├── LLMUsage.swift │ └── LLMProviderConfig.swift ├── Services/ │ ├── LLMUsageService.swift │ └── UsageCache.swift ├── Views/ │ ├── StatusBarLabel.swift │ ├── MenuBarView.swift │ └── SettingsView.swift └── Info.plist这个结构把数据模型、网络服务、缓存、视图分离,后续即使接入不同的 LLM Provider,也只需要替换 Model 和 Service,UI 层不受影响。
4.2 数据模型与配置读取
先定义LLMUsage数据模型,它表示一次用量查询的结果。为了让代码更通用,这里采用了一个假设的 JSON 结构,实际项目需要根据自己的服务端或第三方平台的返回格式调整字段。
// 文件路径:LLMBuddy/Models/LLMUsage.swift import Foundation struct LLMUsage: Codable, Equatable { var inputTokens: Int var outputTokens: Int var totalTokens: Int var costUSD: Double var updatedAt: Date var displayText: String { if totalTokens >= 1000 { return String(format: "%.1fK", Double(totalTokens) / 1000.0) } return "\(totalTokens)" } } struct LLMUsageResponse: Codable { let inputTokens: Int let outputTokens: Int let totalTokens: Int let costUsd: Double? func toModel() -> LLMUsage { LLMUsage( inputTokens: inputTokens, outputTokens: outputTokens, totalTokens: totalTokens, costUSD: costUsd ?? 0, updatedAt: Date() ) } }这里定义了两个类型。LLMUsageResponse是网络层返回的原始结构,配合JSONDecoder的keyDecodingStrategy可以直接解析 snake_case 字段;LLMUsage是界面展示和缓存使用的统一模型。
接下来定义配置读取。API Key、接口地址、刷新间隔这些配置,简单场景下可以先存到UserDefaults,后面的最佳实践部分会说明更安全的做法。
// 文件路径:LLMBuddy/Models/LLMProviderConfig.swift import Foundation struct LLMProviderConfig { var apiKey: String var endpoint: URL var refreshInterval: TimeInterval static let defaultEndpoint = URL(string: "https://api.example.com/v1/usage")! static func loadFromUserDefaults() -> LLMProviderConfig { let defaults = UserDefaults.standard let apiKey = defaults.string(forKey: "apiKey") ?? "" let endpointString = defaults.string(forKey: "endpoint") ?? "" let endpoint = URL(string: endpointString) ?? defaultEndpoint let rawInterval = defaults.double(forKey: "refreshInterval") let refreshInterval = rawInterval >= 60 ? rawInterval : 300 return LLMProviderConfig( apiKey: apiKey, endpoint: endpoint, refreshInterval: refreshInterval ) } }注意,这里的api.example.com只是示例地址,你需要替换成实际的用量查询接口。不同平台的接口路径、鉴权方式、返回结构不一样,这一层正是为了屏蔽差异而存在的。
4.3 网络请求与用量刷新
核心类是LLMUsageService,它负责:
- 读取配置。
- 发起网络请求。
- 将结果写入缓存。
- 定时刷新。
- 向 SwiftUI 视图发布状态变化。
// 文件路径:LLMBuddy/Services/LLMUsageService.swift import Foundation import Combine final class LLMUsageService: ObservableObject { @Published var usage: LLMUsage? @Published var isRefreshing = false @Published var lastError: String? private var timer: Timer? private let usageCache = UsageCache() init() { if let cached = usageCache.load() { self.usage = cached } let config = LLMProviderConfig.loadFromUserDefaults() startAutoRefresh(interval: config.refreshInterval) } func startAutoRefresh(interval: TimeInterval = 300) { refresh() timer?.invalidate() let newTimer = Timer(timeInterval: interval, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in self?.refresh() } } RunLoop.main.add(newTimer, forMode: .common) timer = newTimer } func refresh() { guard !isRefreshing else { return } isRefreshing = true lastError = nil Task { @MainActor [weak self] in guard let self else { return } do { let usage = try await self.fetchUsage() self.usage = usage self.usageCache.save(usage) } catch { self.lastError = error.localizedDescription } self.isRefreshing = false } } private func fetchUsage() async throws -> LLMUsage { let config = LLMProviderConfig.loadFromUserDefaults() guard !config.apiKey.isEmpty else { throw LLMUsageError.missingAPIKey } var request = URLRequest(url: config.endpoint) request.httpMethod = "GET" request.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 15 let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw LLMUsageError.serverError } let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase let result = try decoder.decode(LLMUsageResponse.self, from: data) return result.toModel() } } enum LLMUsageError: LocalizedError { case missingAPIKey case serverError case invalidResponse var errorDescription: String? { switch self { case .missingAPIKey: return "请在设置中填写 API Key" case .serverError: return "服务端返回异常状态码" case .invalidResponse: return "响应格式不正确" } } }这段代码中有几个关键点:
@Published属性用于向 SwiftUI 视图发布变化。startAutoRefresh用一个Timer定时触发刷新,RunLoop.main.add时指定了.common模式,避免菜单栏被按住时定时器暂停。fetchUsage使用async/await发起请求,返回值是统一的LLMUsage模型。- 解析 JSON 时使用
.convertFromSnakeCase,这样服务端返回input_tokens时会自动映射为inputTokens。
4.4 实现菜单栏视图与弹出面板
菜单栏入口使用 SwiftUI 的MenuBarExtra,它同时承担 pill 和 panel 的职责。
// 文件路径:LLMBuddy/LLMBuddyApp.swift import SwiftUI @main struct LLMBuddyApp: App { @StateObject private var service = LLMUsageService() var body: some Scene { MenuBarExtra { MenuBarView() .environmentObject(service) } label: { StatusBarLabel( usage: service.usage, hasError: service.lastError != nil ) } .menuBarExtraStyle(.window) Settings { SettingsView() .environmentObject(service) } } }这里的关键配置是.menuBarExtraStyle(.window),它让点击菜单栏按钮后显示一个自定义窗口,也就是 panel。
接下来是MenuBarView,也就是点击菜单栏按钮后展示的详细面板:
// 文件路径:LLMBuddy/Views/MenuBarView.swift import SwiftUI import AppKit struct MenuBarView: View { @EnvironmentObject private var service: LLMUsageService var body: some View { VStack(alignment: .leading, spacing: 12) { Text("LLM 用量") .font(.headline) if let usage = service.usage { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { GridRow { Text("Token 总量") Text(usage.totalTokens.formatted()) .gridColumnAlignment(.trailing) } GridRow { Text("输入 Token") Text(usage.inputTokens.formatted()) .gridColumnAlignment(.trailing) } GridRow { Text("输出 Token") Text(usage.outputTokens.formatted()) .gridColumnAlignment(.trailing) } if usage.costUSD > 0 { GridRow { Text("预估花费") Text(String(format: "$%.4f", usage.costUSD)) .gridColumnAlignment(.trailing) } } GridRow { Text("更新时间") Text(usage.updatedAt.formatted(date: .omitted, time: .standard)) .gridColumnAlignment(.trailing) } } .font(.system(.body, design: .monospaced)) } else { Text("暂无数据,请检查设置或网络") .foregroundStyle(.secondary) } if let error = service.lastError { Text(error) .font(.caption) .foregroundStyle(.red) } HStack { Spacer() Button("退出") { NSApplication.shared.terminate(nil) } } } .padding(16) .frame(width: 320) } }Grid是 macOS 13 引入的布局组件,非常适合这种对齐要求高的数据展示。如果项目最低版本是 macOS 12,可以用HStack或LazyVGrid替代。
4.5 设置面板
设置面板主要负责配置 API Key、接口地址和刷新间隔。这里先用@AppStorage简化,实际上线前建议改成 Keychain 保存密钥。
// 文件路径:LLMBuddy/Views/SettingsView.swift import SwiftUI struct SettingsView: View { @AppStorage("apiKey") private var apiKey = "" @AppStorage("endpoint") private var endpoint = "https://api.example.com/v1/usage" @AppStorage("refreshInterval") private var refreshInterval = 300.0 @EnvironmentObject private var service: LLMUsageService var body: some View { Form { Section("LLM 服务配置") { TextField("API Key", text: $apiKey) TextField("用量查询接口", text: $endpoint) Stepper(value: $refreshInterval, in: 60...3600, step: 60) { Text("自动刷新间隔:\(Int(refreshInterval)) 秒") } } Section("说明") { Text("当前为演示版本,API Key 存储在 UserDefaults。正式使用请改为 Keychain 保存。") } Section { Button("立即刷新") { service.refresh() } } } .formStyle(.grouped) .padding(20) .frame(width: 460) } }设置面板中的字段和LLMProviderConfig.loadFromUserDefaults()中的 key 一一对应。这样修改完成后,点击立即刷新,LLMUsageService会重新读取配置并拉取数据。
4.6 运行与验证
完成以上代码后,在 Xcode 中直接点击 Run。预期效果如下:
- 菜单栏出现一个小胶囊,左侧有一个小圆点,右侧显示
LLM或缓存中的用量摘要。 - 首次启动且没有配置 API Key 时,点击胶囊后弹出面板,显示“暂无数据”以及“请在设置中填写 API Key”。
- 通过
Cmd + ,打开设置面板,填写 API Key 和用量查询接口地址,点击立即刷新。 - 请求成功后,菜单栏文本更新为类似
12.3K的数字,面板中显示 Token 明细和更新时间。
如果网络请求失败,可以在 Xcode Console 中查看lastError的打印内容,或者直接看面板中的红色错误提示。
5. 常见问题与排查思路
菜单栏组件看似简单,实际运行时仍然会遇到不少问题。下面整理了几个高频场景。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 菜单栏没有图标 | 项目没有运行到MenuBarExtra场景,或 Xcode 缓存异常 | 确认 target 正确;清理 DerivedData 后重新运行 |
| 点击图标没反应 | menuBarExtraStyle设置不合适,或面板视图崩溃 | 改为.window或.menu测试;检查面板视图是否有异常代码 |
| 一直显示“暂无数据” | API Key 未配置、接口地址不对、响应格式不匹配 | 先到设置面板确认配置,再用 curl 测试接口 |
| 网络请求失败 | 沙盒未开启网络权限,或 ATS 拦截 HTTP 地址 | 开启Outgoing Connections;生产环境使用 HTTPS |
| 数据长时间不更新 | Timer被系统调度延后,或刷新逻辑在主线程被阻塞 | 将 Timer 加入.common模式;确认请求没有超时返回 |
| API Key 明文暴露 | 使用UserDefaults存储密钥 | 改用 Keychain 保存,至少也应对密钥做混淆处理 |
| 菜单栏文案太长被截断 | 胶囊内容超出菜单栏可用空间 | 使用抽象数字格式,例如12.3K,减少文本宽度 |
如果遇到报错,可以参考下面的排查顺序:
- 查看 Console 日志中是否有
LLMUsageError相关输出。 - 用 curl 直接请求用量接口,确认接口本身可访问,并观察返回的 JSON 字段。
- 检查 Xcode 的沙盒配置是否开启了网络客户端权限。
- 在
MenuBarView中临时加一行Text(service.lastError ?? ""),方便看到真实错误信息。 - 如果接口返回的字段和模型不匹配,优先检查
JSONDecoder的keyDecodingStrategy字段名映射。
6. 最佳实践与工程建议
6.1 密钥管理:从 UserDefaults 到 Keychain
作为演示,前文使用了@AppStorage保存 API Key。这个做法开发调试没有问题,但真实项目中非常危险。UserDefaults是明文存储,任何能读取应用沙盒数据的进程或脚本都有可能拿到密钥。
macOS 环境下,推荐使用 Keychain 保存密钥。最简单的方式是使用系统自带的Security框架,封装一个 KeychainStore。核心思路是:
- 写入时使用
SecItemAdd或SecItemUpdate。 - 读取时使用
SecItemCopyMatching。 - 删除时使用
SecItemDelete。
虽然代码上比UserDefaults复杂一些,但这是保护 API Key 的底线。如果团队成员较多,还应该考虑对 Key 做访问控制,例如设置为“仅在当前用户登录后可用”。
如果暂时不想引入 Keychain,最低限度也要避免在日志中打印 API Key。很多请求失败日志会把URLRequest或httpBody打出,密钥很容易随日志泄露。
6.2 缓存、刷新频率与系统调度
菜单栏组件的一个重要使用场景是“开机即显示”。如果每次启动都先发起网络请求,用户会看到一段时间的空白状态。更好的做法是:
- 启动时先从本地缓存读取上一次的用量数据,立即展示。
- 然后异步发起网络请求,成功后更新缓存和界面。
- 请求失败时保留旧数据,并显示一条淡化的错误提示。
缓存文件可以放在Application Support目录下。为了保证缓存的数据结构稳定,建议在UsageCache中单独管理编码和解码逻辑。
刷新频率也要克制。LLM 用量不是实时计费数据,通常 5 到 15 分钟刷新一次足够。不要设置成每秒请求一次,否则既浪费服务端资源,也容易触发风控限制。另外,macOS 在低电量模式下可能会延迟 Timer,这是系统行为,不要认为程序有 Bug。