
在实际项目里API 账单和 token 消耗通常是月底才知道但真正需要优化成本时已经晚了。一个常驻在 Mac 菜单栏上的扩展让 LLM usage 数据随时可见是很多开发者的刚需。所谓 panel / pill / nub分别对应展开后的详情面板、菜单栏里的胶囊指示器、以及可以拖到屏幕边缘的小圆点。这篇文章从零实现一个 macOS 菜单栏扩展用 SwiftUI 的 MenuBarExtra 搭建入口用一个可替换的 UsageProvider 拉取用量数据通过定时刷新把总 token、花费和剩余额度展示在菜单栏胶囊里。读完这篇文章你会知道 LLM usage 数据模型怎么设计Provider 怎么抽象SwiftUI 菜单栏怎么接常见坑怎么排查以及如何用 NSStatusItem 或 NSPanel 升级成更原生的形态。1. 先梳理需求LLM Usage 和 Mac 上的三种呈现形态1.1 用量数据到底指哪些字段LLM usage 不是单纯一个 token 数字。不同供应商返回的字段差别很大但在做展示端时可以统一成几个核心概念概念英文常见字段说明输入 token 数prompt_tokens / input_tokens请求里发送给模型的文本 token 数输出 token 数completion_tokens / output_tokens模型生成的文本 token 数总 token 数total_tokens输入和输出的总和费用cost / amount按 token 量和模型单价计算出的金额币种currencyUSD、CNY 等剩余额度remaining_credit / balance账户剩余可用余额统计周期period_start / period_end当前账单周期或查询时间范围按模型拆分models / model_usage每个模型各自的请求数、token 数和费用请求次数requests / n_requests一段时间内调用次数做限额观察时需要如果只是显示一个“总 token”对日常开发不够。最实用的菜单栏形态是胶囊显示费用或总 token点击展开后面板里展示完整的模型拆解和周期信息。1.2 panel / pill / nub 的差异和适用场景这三种叫法来自不同 UI 形态Panel展开后的详情面板通常是一个 popover 或 NSPanel。适合展示表格、统计图、模型列表。Pill菜单栏里的胶囊状小标签紧凑显示一个核心数字比如$12.34。Nub悬浮在屏幕边缘的小圆点或小把手可以拖动适合做常驻监控浮窗。这篇文章先实现 pill panel 的组合菜单栏显示胶囊点击后弹出详情面板。nub 作为进阶方向会在后面用 NSPanel 的思路说明。1.3 技术选型MenuBarExtra、NSStatusItem、NSPanelmacOS 上实现菜单栏扩展主要有三条路方案最低系统优点缺点适用场景SwiftUI MenuBarExtramacOS 13声明式、写法简单、自带 popover自定义菜单栏 View 的能力受限快速实现菜单栏入口和详情面板AppKit NSStatusItemmacOS 10.0完全控制按钮、图标、事件代码多需要处理生命周期需要自定义胶囊背景、点击手势的菜单栏工具NSPanel 悬浮窗口macOS 10.0可以实现屏幕任意位置悬浮要处理拖拽、层级、焦点nub 形态的常驻监控浮窗对一个不需要复杂交互的用量展示工具先用 MenuBarExtra 足够。2. 环境准备与工程骨架2.1 环境要求项目要求macOS13.0 或更高版本Xcode14.3 或更高版本Swift5.8 或更高外部依赖无网络可以访问你的 LLM Usage 查询接口示例代码全部使用系统自带框架不需要引入 CocoaPods 或 Swift Package。2.2 创建 macOS App 项目在 Xcode 中按以下步骤操作选择 File New Project。选择 macOS App。Interface 选 SwiftUI。Lifecycle 选 SwiftUI App。Language 选 Swift。取消勾选 Use Core Data、Include Tests。Deployment Target 设置为 macOS 13.0 或更高。创建完成后项目里默认会有一个ContentView.swift。这个项目只需要菜单栏不需要主窗口所以后面可以把默认窗口删掉。2.3 App Sandbox 和网络权限macOS App 默认开启 App Sandbox。如果使用 URLSession 发送网络请求必须开启 outgoing network 权限。打开 target 的 Signing Capabilities点击 App Sandbox确保 Network 下面的 Outgoing Connections 已勾选。也可以直接修改 entitlement 文件?xml version1.0 encodingUTF-8? !DOCTYPE plist PUBLIC -//Apple//DTD PLIST 1.0//EN http://www.apple.com/DTDs/PropertyList-1.0.dtd plist version1.0 dict keycom.apple.security.app-sandbox/key true/ keycom.apple.security.network.client/key true/ /dict /plist如果漏掉com.apple.security.network.client运行时会看到类似The network connection was lost或请求立即失败的错误。这个权限是网络请求最常见的坑。2.4 工程文件与职责代码按职责拆成几个文件不要全部塞进 App 入口文件职责LLMUsageApp.swiftApp 入口创建菜单栏入口和 ProviderUsageModels.swift用量数据模型UsageProvider.swift数据源协议、HTTP 实现、Mock 实现UsageStore.swift状态管理、自动刷新、错误状态UsagePanelView.swift菜单栏胶囊和详情面板 UI3. 定义用量模型先让数据层稳定3.1 领域模型在写网络请求之前先定义 UI 真正需要的领域模型。这样 Provider 返回什么、界面展示什么都会被统一约束不会因为上游字段变化导致 UI 到处改。import Foundation struct LLMUsage: Codable, Equatable { var totalTokens: Int var inputTokens: Int var outputTokens: Int var cost: Double var currency: String var remainingCredit: Double? var periodStart: String var periodEnd: String var models: [String: ModelUsage] } struct ModelUsage: Codable, Equatable { var requests: Int var tokens: Int var cost: Double }这里把periodStart和periodEnd直接定义为字符串而不是Date。原因很简单不同供应商返回的日期格式差异很大iOS 17 和 macOS 14 之后虽然有ISO8601FormatStyle但为了兼容较多接口先保留字符串展示时再格式化最稳妥。3.2 上游返回结构示例下面是一个兼容多数“网关聚合统计接口”的 JSON 示例。字段名按 snake_case 给出因为很多供应商实际接口也使用 snake_case{ data: { total_tokens: 1234567, input_tokens: 800000, output_tokens: 434567, cost: 12.34, currency: USD, remaining_credit: 87.66, period_start: 2026-05-01T00:00:00Z, period_end: 2026-05-31T23:59:59Z, models: [ { name: gpt-4o-mini, requests: 1200, tokens: 900000, cost: 5.20 }, { name: gpt-4o, requests: 300, tokens: 334567, cost: 7.14 } ] } }这里的数据是示例不是某个供应商的真实接口。实际项目中以你使用的服务商文档为准。3.3 用 Decodable 适配上游字段为兼容 snake_case可以定义RemoteUsageResponse并通过CodingKeys做字段映射。struct RemoteUsageResponse: Decodable { let data: RemoteUsageData } struct RemoteUsageData: Decodable { let totalTokens: Int let inputTokens: Int let outputTokens: Int let cost: Double let currency: String let remainingCredit: Double? let periodStart: String let periodEnd: String let models: [RemoteModelUsage]? enum CodingKeys: String, CodingKey { case totalTokens total_tokens case inputTokens input_tokens case outputTokens output_tokens case cost, currency case remainingCredit remaining_credit case periodStart period_start case periodEnd period_end case models } } struct RemoteModelUsage: Decodable { let name: String let requests: Int let tokens: Int let cost: Double }然后写一个toDomain()方法把远程结构转换成 UI 使用的LLMUsageextension RemoteUsageResponse { func toDomain() - LLMUsage { var modelMap: [String: ModelUsage] [:] for item in data.models ?? [] { modelMap[item.name] ModelUsage( requests: item.requests, tokens: item.tokens, cost: item.cost ) } return LLMUsage