文章目录
项目信息
- 项目:Henry 模型管家 / Henry Model Manager
- 作者:Henry
- 当前版本:
3.3- 技术组成:SwiftUI、AppKit、Foundation、launchctl、IOHID、LM Studio API
- 项目性质:个人 macOS 工具,源码目前未公开
- 本文作者:Henry
我平时同时使用三种模型入口:
- Codex:写代码、做多步任务;
- dsh(DeepSeek Harness):在浏览器里使用 DeepSeek 模型;
- LM Studio:在本机加载 Qwen、DeepSeek 等本地模型;
- 还有一个通过 cc-switch 接入的云端聚合平台。
听起来只是“选一个模型”而已。
但实际上,每次切换都要同时处理 ~/.codex/config.toml、~/.dsh/settings.yaml、LM Studio 的加载状态、dsh 的服务进程,以及不同模型对推理参数和上下文长度的要求。
只要有一个环节没同步,结果就是:
- Codex 指向了一个已经不存在的 provider;
- dsh 还在使用旧模型;
- LM Studio 里重复加载了两个实例,白占十几 GB 内存;
- 切到云端之后,本地模型还在后台吃显存;
- 切换完成,但当前会话没有生效;
- dsh 进程被直接 kill 后,又被 launchd 的
KeepAlive拉起来,造成端口冲突。
于是就有了 Henry 模型管家。
它不是另一个聊天客户端,而是一个模型编排层:把多个工具、多个模型和多个后台服务之间的状态,收拢到一个原生 macOS 界面里。
一、最初只是一个菜单栏切换器
最早的需求非常简单:在菜单栏里点一下,就能在三个模型之间切换。
当时的选项是:
- Qwen3-Coder 30B,本地 LM Studio;
- DeepSeek R1 32B,本地 LM Studio;
- DeepSeek V4 Flash,云端 cc-switch 代理。
切换器会修改 Codex 的 config.toml,在本地模型之间切换时预载 64K 上下文,切换到云端时卸载全部本地模型。
这个版本很快暴露了问题。
第一,它只管理 Codex,不管理 dsh。第二,它会直接操作 LM Studio,遇到重复加载时会产生 :2 实例。第三,它不知道 dsh 的服务状态,更不知道 Codex 和 dsh 是否正在使用同一个本地模型。
更重要的是,它只是“改配置”,并没有真正理解“模型正在被谁使用”。
二、从“切换配置”到“引用式内存管理”
本地模型最贵的资源不是磁盘,而是内存。
一个几十 GB 的模型加载到 LM Studio 后,所有入口都去连接它,看起来很方便;但只要有两个入口使用不同模型,就需要同时保留多个模型。若某个入口切换走了,旧模型还留在内存里,就变成了孤儿占用。
新的逻辑把本地模型当作一种被引用的资源:
- Codex 当前使用哪个本地模型;
- dsh 当前使用哪个本地模型;
- 某个入口是否主动“释放”了本地模型;
- 这些引用合起来,决定 LM Studio 应该加载哪些模型;
- 没有任何入口引用的模型,自动卸载。
这和操作系统的引用计数很像。
它带来的直接变化是:Codex 和 dsh 可以同时使用不同的模型,也可以共享同一个本地模型;当最后一个使用者离开后,模型才被卸载。
“把模型当作资源来管理”,比“切换时先卸载所有模型再加载一个”更准确,也更省内存。
三、三种模型,三个完全不同的世界
模型管家里的三个选项,并不只是三个名字。
Qwen3-Coder 本地模式
Codex 走 lmstudio-local provider,模型通过 LM Studio 加载,推理强度设为 none,并预载 64K 上下文。
为什么要把 reasoning effort 设为 none?
因为 Codex 的复杂任务会生成 reasoning 或 function_call 条目,而 LM Studio 的某类 Responses 接口并不接受这两种输入。表现出来就是:
Invalid type for 'input'
这不是模型本身的问题,而是协议层不兼容。
DeepSeek R1 本地模式
同样是 LM Studio,但 dsh 和 Codex 分别使用自己的配置。dsh 读 settings.yaml,Codex 读 config.toml。
两个工具看起来都在“用本地模型”,但它们对模型名、baseURL、vision 能力和上下文的期望并不完全一样。
DeepSeek V4 Flash 云端模式
Codex 切换到 custom provider,dsh 使用 cc-switch 的代理地址,LM Studio 卸载全部本地模型。
云端模式下,本地模型不应该继续占用内存。这个判断不能交给用户记忆,而应该由模型管家自动完成。
四、切换是一次小型事务
一次完整的模型切换,至少要完成这些动作:
- 备份 Codex 和 dsh 的配置;
- 写入新的 provider、model 和 reasoning effort;
- 确保
lmstudio-localprovider 块存在; - 按引用关系同步 LM Studio 的加载状态;
- 必要时重启 dsh;
- 记录本次切换历史;
- 更新界面状态并发送通知。
任何一步失败,都可能留下一个“看起来切换了,实际上没有”的状态。
所以模型管家里的切换不是改一行配置,而是一次小型事务。
切换前会备份:
~/.codex/config.toml.bak.hm
~/.dsh/settings.yaml.bak.hm
历史记录存在:
~/Library/Application Support/HenryModelManager/history.json
历史不只是日志。它让用户可以在切错模型之后,快速回到上一次状态。
五、dsh 不能直接 kill
这是这次开发里最有意思的一个坑。
dsh 的 Web 服务由 launchd 托管,配置在:
~/Library/LaunchAgents/com.henry.dsh.web.plist
并且启用了 KeepAlive。
这意味着:如果直接 kill 进程,launchd 会立刻把它拉起来。更糟的是,如果重启逻辑里同时做了 kill 和手动启动,旧进程还没完全退出,新进程已经尝试绑定同一个端口,于是出现端口冲突。
正确的做法是让 launchd 来管理生命周期:
# 启动已加载的服务
launchctl kickstart gui/$UID/com.henry.dsh.web
# 重新启动
launchctl kickstart -k gui/$UID/com.henry.dsh.web
# 停止,并阻止 KeepAlive 继续拉起
launchctl bootout gui/$UID/com.henry.dsh.web
# 重新加载
launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.henry.dsh.web.plist
界面上的“启动 dsh / 停止 dsh”看起来只是两个按钮,背后其实是在区分“服务是否已加载”和“进程是否正在运行”。
如果服务已经加载,启动用 kickstart;如果服务被 bootout 过,就要重新 bootstrap。停止不能只杀进程,而要卸载服务,否则 KeepAlive 会继续把它拉回来。
这让我又一次确认:在 macOS 上,进程不是靠 PID 管理的,而是靠 launchd 管理的。
六、温度读取和 Mach 端口泄漏
模型管家还显示 SoC 和电池温度。
Apple Silicon 上 CPU 和 GPU 在同一个晶粒上,SoC 温度对判断模型推理时的热状态很有参考价值。读取温度使用的是 IOHID 的私有 C 接口,Swift 没有直接桥接,需要手动声明符号。
但真正危险的地方不是读取,而是资源管理。
某些 IOHID 对象背后持有 Mach 端口。如果每次刷新都新建一个 event system client,却不释放,Mach 端口会持续泄漏,最终影响整个系统。
所以温度读取不能每次都重新创建:
- 应该缓存 event system client;
- 需要明确释放创建出来的 Mach 对象;
- 读取失败时返回 nil,界面隐藏温度行,而不是让整个应用崩溃。
这类系统编程问题在普通前端项目里很少遇到。它提醒我:能读到数据,不等于读数据的方式是安全的。
七、Keychain 也可能阻塞窗口
DeepSeek 余额监控是后来加入的功能。
用户填入 API Key,Keychain 保存,不写明文文件。余额低于阈值时弹通知。听起来很直接,但第一版有一个非常隐蔽的问题:应用启动时就去读取 Keychain,触发了 macOS 的钥匙串授权弹窗。
弹窗挡住了主窗口,用户看到的是“应用启动后白屏”。
修复方式不是跳过 Keychain,而是把读取时机推迟到真正进入余额页时。这样启动流程不被权限弹窗打断,用户也只在需要余额时才看到授权请求。
这是一个典型的 macOS 应用问题:权限请求不是弹出来就完了,它会改变应用的状态和启动顺序。
八、界面上的三个小问题
模型管家当前有三个主卡片:Codex、dsh、LM Studio。
之前它们看起来不统一:
- LM Studio 卡片少一行内容,整体更矮;
- 三张卡片虽然
maxWidth一样,但没有统一高度; - 没有菜单的卡片用了
.disabled(true),结果整张卡片被 SwiftUI 自动变灰。
修复并不复杂:
- 给卡片加上
maxHeight: .infinity和Spacer,强制同一行等高; - 只有真正有菜单时才套 Button;
- 没有操作菜单的卡片直接渲染普通视图,不再用
disabled制造灰色状态。
这些细节很小,但 macOS 原生应用的质感,恰恰来自这些地方。
九、现在它是什么
当前版本 3.3 包含:
- 总览:Codex / dsh / LM Studio 实时状态;
- 模型切换:本地与云端模型卡片;
- 应用范围:单独应用到 Codex、dsh,或同时应用到两者;
- 引用式内存管理:只保留仍被使用的本地模型;
- dsh 服务控制:启动、停止、重启都走 launchd;
- 状态监控:模型加载、内存占用、服务状态;
- 余额监控:DeepSeek API Key 存 Keychain,低余额提醒;
- 历史记录:每次切换可回退;
- 温度监控:SoC 与电池温度;
- 设置:联动范围、通知、开机自启。
整个项目约 2600 行 Swift,代码量不大,但它处理的是一组非常真实的状态同步问题。
十、写在最后
做这个工具之前,我以为“切换模型”只是一个下拉框。
做完之后才发现,它其实包含:
- 配置文件格式;
- provider 协议差异;
- 本地模型加载与卸载;
- 引用计数式内存管理;
- launchd 服务生命周期;
- Keychain 权限;
- Mach 端口资源释放;
- SwiftUI 状态布局;
- 失败后的回退和历史记录。
模型管家不是一个“更漂亮的模型选择器”。它更像一个本地 AI 工具的调度层,把原本需要手工改配置、开终端、检查进程的工作,收敛成几个明确的操作。
这也是我做个人工具最喜欢的一点:需求不是市场调研出来的,而是每次自己被配置文件和后台进程折腾之后,慢慢长出来的。
项目信息: Henry 模型管家 / Henry Model Manager。项目作者与本文作者均为 Henry。当前版本 3.3,源码目前未公开。
评论 0
留下想法或补充。评论经审核后会显示在这里。
正在读取评论…
成为第一个留下想法的人。