‹ 返回首页技术

一个 macOS 原生模型编排工具:如何联动 Codex、DeepSeek Harness、LM Studio 和云端聚合平台,并处理配置、内存、launchd、权限与状态同步。

2026年10月18日·10 分钟阅读·阅读 …

文章目录
  1. 一、最初只是一个菜单栏切换器
  2. 二、从“切换配置”到“引用式内存管理”
  3. 三、三种模型,三个完全不同的世界
  4. Qwen3-Coder 本地模式
  5. DeepSeek R1 本地模式
  6. DeepSeek V4 Flash 云端模式
  7. 四、切换是一次小型事务
  8. 五、dsh 不能直接 kill
  9. 六、温度读取和 Mach 端口泄漏
  10. 七、Keychain 也可能阻塞窗口
  11. 八、界面上的三个小问题
  12. 九、现在它是什么
  13. 十、写在最后

项目信息

  • 项目: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 卸载全部本地模型。

云端模式下,本地模型不应该继续占用内存。这个判断不能交给用户记忆,而应该由模型管家自动完成。

四、切换是一次小型事务

一次完整的模型切换,至少要完成这些动作:

  1. 备份 Codex 和 dsh 的配置;
  2. 写入新的 provider、model 和 reasoning effort;
  3. 确保 lmstudio-local provider 块存在;
  4. 按引用关系同步 LM Studio 的加载状态;
  5. 必要时重启 dsh;
  6. 记录本次切换历史;
  7. 更新界面状态并发送通知。

任何一步失败,都可能留下一个“看起来切换了,实际上没有”的状态。

所以模型管家里的切换不是改一行配置,而是一次小型事务。

切换前会备份:

~/.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,源码目前未公开。

H
Henry

记录技术实践、设计观察与生活里的慢思考。

关于作者
DISCUSSION

评论 0

留下想法或补充。评论经审核后会显示在这里。

正在读取评论…