SwiftUI构建macOS原生Gemini客户端:上下文管理与成本控制实战
月初看到 Gemini API 账单的时候说实话我肉疼了一下。作为一个把 Gemini 当日常生产力工具的人网页版用起来总差点意思上下文老丢、没法跟系统工作流打通、每次开个新标签页又要重复聊一遍。所以那几天我干脆坐下来用 SwiftUI 写了这个 macOS 原生客户端本身就是 Gemini API 的桌面壳重点解决了上下文管理、系统集成和成本控制的问题。写完之后我直接开源了目前仓库已经放出来Star 涨得比我想象中快。这篇文章就是这次项目的完整复盘从为什么要写原生客户端到流式对话、Token 预算、Keychain 存储这些核心模块怎么实现再到开源之后遇到的那些坑。如果你正在考虑给自己做一个桌面端 AI 工具或者单纯想看看 SwiftUI 做这类应用能到什么程度这篇应该对你有用。1. 项目缘起为什么嫌弃网页版非要自己造轮子1.1 网页版的三大痛点上下文、集成、成本网页版 Gemini 作为聊天工具本身没大毛病但真要往“日常生产工具”的方向用问题就出来了。最明显的痛点是上下文管理浏览器是状态隔离的聊完一个话题关掉标签页下次再开等于重新开始。API 调用是按 Token 计费的网页版你又没法控制它每次带多少历史上下文很多时候成本都烧在了重复的自我介绍和旧话重提上。第二个痛点是没法跟系统集成。我想要的是全局快捷键呼出、直接从剪贴板粘贴图片给模型识别、把回复以 Markdown 格式直接存成文件这些网页版都很难做或者说做得很别扭。浏览器安全模型限制了很多本地能力的调用比如读写剪贴板需要授权、文件系统访问要用户手动允许体验根本谈不上顺畅。第三个痛点是成本可见性问题。网页版不会告诉你这个回答花了多少 Token、输入输出各占多少。但自己走 API 就完全不一样每个请求花多少钱是一笔一笔算得清的这时候你才会发现原来某个功能吃掉了账单的大头。1.2 “回本”到底怎么算标题说“终于要回本了”其实我的原意不是靠开源赚钱而是通过客户端把 API 的使用效率提上来让每一美元花的都更值。我自己订阅 Gemini API 是按量付费的一个月正常使用下来大概十几到几十美元。如果能把上下文裁剪得好一点、把重复请求消灭掉、把缓存做好整体 Token 消耗能降下来至少三成到一半这个钱就等于省下来了。另外一方面把客户端开源出去之后社区能帮你提出很多没想到的需求和 bug项目本身的质量在往上走相当于用别人的使用反馈来帮你完善自己的工具。五美元的 API 费用叠加长期的使用价值这个“回本”逻辑是成立的。我给出的建议是任何想折腾这类工具的人都应该把“成本效率”作为第一设计目标而不是先堆功能。1.3 技术方案调研其实试过 Electron但放弃了刚立项的时候我其实先试了 Electron 套壳把网页端打包成 macOS 应用。做起来确实快打包完内存直接干到几百 MB 那是家常便饭而且跟系统的集成还是受限快捷键、菜单栏、Touch Bar 这些都要靠额外桥接。用下来总觉得这是个“装在 App 壳里的网页”不是原生体验。后来我也看了看 Tauri内存占用确实比 Electron 好看但 macOS 能力穿透还是要走 Rust 侧写起来不如 Swift 直接。最后我选定了 SwiftUI 加原生 Swift Concurrency理由很简单内存占用优势明显、系统 API 调用直接、Combine 和 async/await 在处理流式数据时特别顺手。2. 功能拆解一个“称手”的客户端应该有哪些能力2.1 流式对话边想边打印不让人干等跟模型对话最不能接受的就是点了发送后一直转圈。网页版的流式体验已经很成熟原生客户端自然也不能落后。核心做法是直接使用 URLSession 的 async 流式接口一行一行读服务端返回的数据每当拿到一个数据块就即时更新消息气泡真正做到“字都还没打完就出来了”。这块实现里有几个细节一个是行缓冲因为网络层的 fragment 并不一定在换行符边界必须自己实现一个按换行符拆分的缓冲区避免把一条 JSON 数据拆成两半解析导致报错。另一个是中断处理用户中途点了停止必须把当前连接 cancel 掉同时把已收到的内容正常保存到会话历史中而不是直接把整条消息丢弃。这个操作我一开始没做对后来连续两次把已经回答了一多半的内容丢掉了才反应过来中断与保存要分开处理。比较让我意外的还有一点Swift Concurrency 在处理这种流式场景时几乎没遇到背压问题async/await 的挂起与恢复非常自然性能开销也很小。不得不说 Swift 在这套新并发模型上确实成熟了不少已经是我写这类工具的首选。2.2 上下文管理让 Token 花在刀刃上网页版每次对话带多少历史你控制不了客户端就可以自己做主。我的做法是维护一个会话内的消息数组发送新请求前先用一个自定义 Token 预估算器算一下当前消息列表的 Token 总量超过预算就自动丢弃最早的一部分历史消息只保留最近的消息和当前问题。这个预估算器用的是字符数除以 4 的粗略估计法因为绝大多数常见文本一个 Token 大约是 4 个英文字符。中文稍微有点特殊但按经验值来看用一个中文字符折算 0.7 到 1 个 Token 也基本够用误差在可接受范围内。精确的 Tokenizer 代价太高桌面端做实时预算控制其实不必要。裁剪策略上我留了一个可配置的“最大历史轮数”参数默认 20 轮。如果 20 轮的 Token 总和超过模型最大输入限制的一定比例就优先丢弃系统提示之外的最早对话而不是直接把整个对话清空。这样用户的长期记忆可以保持而短期焦点也不会被挤爆。这一套逻辑做完之后我明显感觉到同样的问题列表消耗的 Token 比网页版少接近一半。2.3 系统级集成快捷键、菜单栏、剪贴板原生客户端比网页版多出来的价值就是可以真正“住”在系统里。我实现了全局快捷键呼出主窗口用的 Carbon API 注册热键这个 API 虽然老但好在稳定唯一要注意的是快捷键冲突一旦发现注册失败就自动回退到菜单栏点击呼出。菜单栏图标也是标配本质上是运行一个无窗口的后台代理点击图标可以弹出快速提问框。快速提问框是我自己实现的一个 NSPanel不用的时候隐藏起来按下快捷键直接唤起输入完了回车就发送。这样整个体验可以用“无缝”来形容你在写代码突然想起来一个问题按一下快捷键输入回车回复就躺在剪贴板里了。图片识别这块我也接上了粘贴一张截图到输入框客户端自动转成 Base64 传到 Gemini 的视觉模型里识别。最开始走了弯路想在客户端做图片压缩后来发现 Gemini 的视觉模型对普通分辨率截图识别得很好没必要做压缩步骤反而要多写一堆代码。2.4 Markdown 渲染与代码块体验AI 回复里最常见的诉求就是代码块能正确着色。我用的是 SwiftMarkdown 库做解析然后自己写了一个轻量渲染器覆盖代码块的着色逻辑。SwiftMarkdown 本身不支持代码高亮所以我把代码块单独提取出来用 Highlightr 做高亮渲染其余文本保持普通 Markdown 样式。这里遇到一个比较棘手的 Scheherazade 兼容性问题。代码块里如果有 Tab 字符或者不规则缩进高亮渲染经常会出错我的解决方法是先把 Tab 替换成四个空格然后交给 Highlightr 处理之后再处理语言标签的映射。最终效果就是代码高亮的颜色与 Xcode 的默认配色非常接近视觉疲劳度很低。表格和数学公式我是顺带支持的Markdown 表格用 SwiftMarkdown 的表格解析能力转换成了原生 NSTableView 来显示Latex 公式则用 ASCIIMathML 的兼容层做了渲染。严格说这块还有不少边角情况没处理好但日常文档阅读已经完全够用。3. 关键代码实操从网络层到本地存储3.1 网络层设计URLSession 流式读取网络层是整个客户端最核心的部分我直接用了 URLSession 的 bytes 方法let request URLRequest(url: endpointURL) request.httpMethod POST request.setValue(application/json, forHTTPHeaderField: Content-Type) request.setValue(Bearer \(apiKey), forHTTPHeaderField: Authorization) request.httpBody try JSONSerialization.data(withJSONObject: payload) let (bytes, response) try await session.bytes(for: request) guard let httpResponse response as? HTTPURLResponse, httpResponse.statusCode 200 else { throw APIError.invalidResponse } var buffer for try await line in bytes.lines { buffer line if buffer.hasSuffix(\n) { // 处理完整的一行 SSE 数据 parseAndHandleEvent(buffer) buffer } }需要特别说明的是bytes.lines这个接口在 macOS 上处理流式数据时行分隔符有点坑。默认它会按\n来拆分但 Gemini 流式响应里的数据块可能跨行所以必须自己维护一个缓冲区等拿到了完整的换行符号再执行解析。否则一个 JSON 片段被拆成几行JSONSerialization 直接报错是小事更大的问题是响应内容会丢失。另一个坑是 SSE 里的“心跳空行”。Gemini 返回的流里偶尔会出现只有一行空格的占位数据如果直接当成事件传给解析器会收到 nil。我的做法是在解析前先 trim 一下空白字符为空就直接跳过确保消息不被误处理。3.2 API 请求体构造与模型切换Gemini 的 API 请求结构不算复杂一个generateContent接口加上一个contents数组即可。系统提示直接用systemInstruction字段传版本上需要注意不同模型对systemInstruction的支持度不一样Flash 系列支持得比较好Pro 系列早期版本似乎对它的响应不太稳定。模型切换时实际上只改一个 URL 路径上的模型名不需要动整个网络层。我在客户端里做成了下拉选项让用户直接在 Flash、Pro、Vision 等模型间切换。对于希望节省成本的人我建议日常对话全用 Flash只有在长文档总结、复杂代码生成这一类任务上才切到 Pro这能让你的月度账单直接少一个量级。图片输入的形式是inlineData加mimeType和 Base64 数据。在 Swift 里直接把图片转成 Data再用base64EncodedString()就行了。我对超过 10MB 的图片做了降采样把最长边限定到 2048 像素这样识别的准确度和上传速度都更平衡。3.3 Token 估算器代码参考Token 估算是成本控制的核心我写了一个很轻量的估算函数func estimateTokenCount(for text: String) - Int { let trimmed text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return 0 } let charCount trimmed.count // 简易估算英文按 4 字符/token中文近似按 0.8 字符/token let tokenEstimate charCount / 4 // 中文字符单独加权 let chineseChars trimmed.reduce(0) { $0 ($1.isASCII ? 0 : 1) } return tokenEstimate Int(Double(chineseChars) * 0.75) }这个函数虽然简单但实测精度可以控制在 ±15% 左右。对桌面客户端来说完全够用而且它的计算速度极快比加载一个完整的 Tokenizer 库要省几个数量级。我把这个估算结果用来动态调整历史保留数比如当前对话接近模型输入上限时就自动截断最早的消息。预算控制这块建议做两层全局预算比如设置每个会话最多消耗 8000 Token单请求预算比如单次请求不超过 6000 Token。超预算的请求直接拒绝并提示用户裁剪上下文而不是等 API 返回 400 错误再处理。防止用户在一次提问中无意间浪费大量 Token比如错误地粘贴了一整本书让模型总结结果瞬间刷爆账单。3.4 Keychain 存储与本地持久化API Key 是敏感数据绝对不能硬编码在工程里也不能存到 UserDefaults。我用的是 Keychain Services API通过 Security.framework 直接写入 macOS 的钥匙串。这段代码最需要注意的地方是kSecAttrAccessible要设置成kSecAttrAccessibleAfterFirstUnlock保证开机后在首次解锁后能正常读取而不会因为系统重启而临时抽风。func saveAPIKey(_ key: String) { let query: [String: Any] [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: gemini_api_key, kSecValueData as String: Data(key.utf8), kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock ] SecItemDelete(query as CFDictionary) SecItemAdd(query as CFDictionary, nil) }读取的时候注意返回值有可能是 nil需要先检查状态码再以 Data 形式取回并转成 String。第一次实现我忘掉处理密钥为空的情况导致设置没保存重启后一直提示找不到 Key排查了半天。如果你也给自己的工具写过类似逻辑牢记读 Keychain 一定不能假设密钥存在必须做兜底。聊天历史我用的是 JSON 文件存储路径在 Application Support 目录下按会话 ID 分文件存放。选 JSON 而不是 SQLite 的原因很简单聊天记录通常是顺序读写JSON 足够应付不用承担 SQLite 的依赖复杂度。后面如果会话数量涨到几千条再迁移到 SwiftData 也不迟。4. 成本控制实战把账单降下来的具体手段4.1 模型选型与调用频率控制成本大头永远是模型调用本身Flash 的定价大概是 Pro 的几十分之一日常问答、代码补全、文本改写这类任务用 Flash 完全够打。所以我在客户端默认设置就是 Flash在所有需要“聪明回答”的场景里再手动切换到 Pro。这个策略让月度 API 消耗明显回落账单数字大概砍半。除了选模型调用频率控制同样关键。我加了一个冷却期逻辑同一会话内两次请求之间至少间隔 1.5 秒防止用户连点导致并发请求翻倍。严格说这不是硬限制更像是一个簧片让用户体验不被打断同时又避免无脑请求刷量。如果你也是按量付费用户我强烈建议加一层这样的节流。4.2 缓存与内容复用缓存策略在整本项目中意外地好用。我把常见 Prompt 和回复做了一层模糊匹配的 KV 缓存同一个问题在短时间内重复提问时直接读取本地缓存完全不需要调用 API。实现上用了一个很朴素的思路把用户输入做一次 normalize去掉多余空格、转小写然后取前 50 个字符作为 key命中就直接返回记录。缓存命中对问答场景帮助不小。比如你在测试客户端的 Markdown 渲染效果同一个问题反复发了好几遍如果每次都消耗 Token那纯粹就是烧钱。有了缓存层连续测试同一类问题基本零成本。需要注意缓存过期时间我一般设定 15 分钟或同一会话内有效防止出现数据过时被用户误认为模型没更新。4.3 后台预取与批量提示还有一种隐藏的费用杀手是重复渲染。比如你想让模型帮你格式化一段代码结果客户端在每次渲染时都重新调用一次 API这就是灾难。我的做法是把渲染过程完全本地化只有当用户手动点击“重新生成”时才发起新的网络请求其他任何 UI 刷新都不会触发 API 调用。后台预取是我在后期加上去的功能。系统空闲时客户端会默默分析当前会话内容如果发现有一些可能接着追问的点就预先请求一次很短的回答。这个功能虽然额外消耗少量 Token但整体体验提升明显让人感觉 AI 在“主动思考”。建议预算充足时开启预算紧张时关掉。5. 开源发布与踩坑反馈5.1 打包、签名与公证对 macOS 开发者来说直接把 App 发给别人不是双击就能跑这么简单。没有签名的应用在别人的机器上会被 Gatekeeper 拦截用户得去系统设置里手动允许运行体验很差。所以我的项目从一开始就配置了 Developer ID 证书和 Notary 公证签名完成后上传 Apple 的公证服务等审核通过后再发布。公证的流程说复杂也复杂说简单其实也就是几个命令。我用的是一个脚本把 release 模式构建、签名、上传公证、stapler 装订几个步骤串起来保证分发出去的是一个干净的、可以正常 Gatekeeper 通过的二进制包。这一步如果省了开源项目会少掉一大批潜在测试用户毕竟不是每个人都愿意折腾右键打开。5.2 开源协议选择与社区问题开源协议我选了 MIT原因很简单最宽松别人拿去做商业项目也不会被限制社区参与门槛最低。对我来说这个项目最大的价值是让更多人用上而不是通过 License 去限制什么。没必要为了“防止被商用”而选择更严格的协议又没有实际意义。开源之后收到不少 issue最多的几类集中在系统版本兼容性、API Key 存储位置、流式输出偶尔断开。API Key 存储这块部分用户觉得 Keychain 太“神秘”也有用户担心安全问题。后来我在 README 里补充了完整的设置流程截图并明确说明 Keychain 是 macOS 的安全隔离存储应用之间无法互相读取质疑的声音才少了。5.3 常见问题速查表问题原因解决办法流式输出中途停止网络层缓冲拆分错误升级到最新版确认行缓冲逻辑正确全局快捷键不生效与其他软件冲突在设置里更换快捷键组合API Key 保存后重启丢失未设置kSecAttrAccessibleAfterFirstUnlock重新保存 Key更新到最新版繁体中文识别乱码字符集编码异常统一走 UTF-8不要用系统默认编码高亮代码块显示异常Tab 字符干扰替换为四个空格后重渲染挂载到菜单栏后没有图标模板图片未加载指定isTemplate为 true 的图片资源即可这些坑我自己一个不落都踩过。有些问题只在特定系统型号上出现比如在 Apple Silicon 上编译的时候内存布局不同导致的高亮渲染差异这类问题只能在用户反馈中慢慢暴露。开源的好处就在这里你一个人测试覆盖面太小社区帮你把各种环境都试了一遍。6. 最后再分享一个实用小技巧写完这个客户端之后我实际使用中最满意的一个细节不是界面而是配合快捷指令实现“一键收集灵感”。我把客户端注册了一个自定义 URL Scheme叫gemini://ask?text...这样任何应用里只要配置一条快捷指令就能把选中的文字直接发到客户端提问。比如你在 Safari 里读文章发现不懂的概念选中文字快捷键触发快捷指令引擎马上在客户端里给出解释整个过程不用切窗口、不用敲字跟系统原生工作流完美咬合。另外一个建议是给自己的会话做分类标签比如“代码调试”“写作素材”“日常生活”每次新会话前先选好分类这样历史记录的可检索性大幅提升。我用了一段时间后几乎把 AI 当成了一个长期积累的第二大脑跟网页版那种“用完就忘”的感觉完全不同。这个项目的源码已经在 GitHub 开源Stars 数量在我写这篇文章时还在涨。我个人做这个项目的核心收获是理解了“工具成本”的本质不在价格标签而在于使用效率。如果你也想搭一个自己的 AI 客户端不妨先从最小的功能集开始把流式对话跑通再加按钮再加快捷键一步步来。当你每次按下快捷键都能秒唤起一个懂上下文的助手时你也会发现当初那笔 API 订阅费是真的值。