用了这么多年的Mac,还是没搞清楚环境变量的加载机制
用 Homebrew 起个服务,想配置环境变量怎么就这么难呢

磁盘满了
前些天,我在本地用 ollama 分别起了 bge-m3 和 qwen3.5 模型来做测试,测试没什么问题后,就将 ollama 作为一个服务在后台启动运行了,结果转天发现磁盘竟然跑满了。
众所周知,Mac 电脑的硬盘是非常贵的,所以我当年买的是较低的 512G,然后用了也就一年多,可用空间就常常在 10G 上下浮动了,于是又买了个外接的 SSD。(涨价前买的,现在价格翻了一倍不止)
那么本地跑大模型的话,自然要把那些动辄几个 G 甚至十几个 G 的模型文件存到 SSD 上,对 ollama 来说,就是配置下环境变量 OLLAMA_MODELS,指向我的 SSD 地址。测试的时候我也验证过,这个环境变量是生效的。
不过当磁盘跑满后,我尝试清理磁盘时发现,bge-m3 和 qwen3.5 的模型文件,还是下载到了默认的~/.ollama/models里。一番探查后,我不得不承认,用了这么多年的 Mac,我还是没搞清楚环境变量的加载机制。
哪里不同
如今 Mac 的 shell 默认都是 zsh,用于配置环境变量的常用的有三个文件,~/.zshenv、~/.zprofile和~/.zshrc。这三个文件的区别与今天的问题无关,我就不说了。重要的是为什么我在~/.zprofile中配置了OLLAMA_MODELS,且测试时也生效了,却在实际使用时又失效了呢?
差别在于启动方式。
测试时,我是在终端使用ollama run bge-m3命令启动的,而正式使用时,为了长期运行避免对终端的依赖,我是使用brew services start ollama将 ollama 作为后台服务启动的。正是这个启动方式的差异导致了我配置的环境变量失效。
那为什么不同的启动方式会导致环境变量失效呢?也许有人会说这就是 Mac 系统的规则,在 shell 中启动的程序,可以使用~/.zshenv、~/.zprofile和~/.zshrc中设置的环境变量,但通过后台服务启动的,或通过点击图标启动的图形化界面的程序就无法使用这些环境变量。
不过我们不妨再进一步,探究下为什么会有这样的规则。其实这也是 Mac 系统一个“老大难”问题了,很多人对此都有困扰。(很早以前 Mac 上确实有一个全局的环境变量机制,不过后来被废弃了,然后也没再做新的全局机制,不知这是为什么。)
环境变量来自哪里
想要探究背后根本的原因,我们先来看看这所谓的环境变量是从哪里来的。
延续 Windows 中环境变量的机制,我一直以为所谓的环境变量就是一个全局变量。尽管前些年也经历了从 bash 切换到 zsh 后,配置环境变量的文件也跟着变了,我还是没有意识到平常使用的环境变量只不过是 zsh 的环境变量而已。
当 zsh 启动的时候,它会读取~/.zshenv、~/.zprofile和~/.zshrc,于是它们中配置的变量,才成为了 zsh 的环境变量。
而图形化界面的程序,包括作为服务在后台运行的程序,当进程启动时并不会读取那三个配置文件,所以其中的变量配置对这两类程序并不起作用。
那我们在 shell 中启动的进程为什么可以读取到 zsh 的环境变量呢,它们也加载了那三个配置文件吗?不是的。这里就涉及到了一个所有 Unix 操作系统启动进程的统一规则,当父进程创建子进程时,子进程会从父进程那里复制一份环境变量给自己。
所以,在 shell 中执行各种命令启动的进程,它们的父进程是 zsh,所以它们就能从父进程 zsh 中复制一份环境变量,也就是加载过三个配置文件的环境变量。
而图形化界面的程序,以及后台启动的进程,它们的父进程不是 zsh 而是 launchd,所以它们的环境变量复制自 launchd,也就跟 zsh 的三个配置文件无关了。
这个从父进程复制环境变量的机制,最早是在1979年发布的 Unix Version 7 中被使用的,后来于1988年被纳入到 POSIX 标准中,这个标准是所有类 Unix 操作系统的开发规范。而我们现在使用的 Mac 的操作系统 MacOS 本身就是经过官方认证的 UNIX 系统,自然也就使用了这种机制。
举例说明
那么回到我遇到的问题,来看看不同启动方式下,ollama 是如何加载环境变量的。
终端命令行启动
当我在 Warp 中执行ollama run bge-m3命令时,ollama 的进程链是这样的:
launchd → Warp.app → zsh → ollama
前面提到过 launchd 了,具体来说它是 Mac 系统启动后的第一个进程,PID 为1,也就是除 kernel_task(PID 0) 外所有进程的祖先进程。我的终端使用的是 Warp,也是个图形化软件,所以父进程是 launchd,然后 Warp 启动了 zsh,也就是此时 zsh 读取了它的三个配置文件,使其拥有了我们配置的环境变量。
当我执行 ollama 命令时,ollama 进程便是 zsh 的子进程,因此从 zsh 中复制了环境变量,也就相当于是,三个文件的配置对这个 ollama 进程是生效的。
后台启动
而当我执行brew services start ollama时,它并不是直接启动 ollama,而是生成一个 plist 文件,然后调用 launchctl 把这个 plist 加载到 launchd 中。然后 launchd 就会按照 plist 里的配置来启动 ollama 进程了。于是此时 ollama 是 launchd 的子进程:
launchd → ollama
所以此时的 ollama 与 zsh 无关,zsh 的三个配置文件中的设置变量也就对 ollama 无效了。所以此时 ollama 再去下载模型,就会保存到它的默认路径~/.ollama/models里了。
既然问题搞清楚了,那我们就来看看如何解决这个问题,如何为后台启动的 ollama 来配置环境变量。
为 launchd 配置环境变量
刚才提到,执行brew services start ollama时,其实是生成了一个 plist 文件。这个 plist 文件是 Mac 系统常用的一种数据文件,通常是 XML 格式的。在当前这个场景下,生成的 plist 文件你可以认为是 launchd 服务的一个启动参数。
所以我们可以在 plist 中为 ollama 设置环境变量,这样当 launchd 启动时,或我们主动加载 plist 的时候,plist 中设置的环境变量就可以在加载到 launchd 中了,这样由它启动的子进程,例如 ollama,就可以继承到这个环境变量了。
在 plist 中添加环境变量
如果你让 AI 来处理这个问题,它很可能就是像上面说的那样给你处理了,在 ~/Library/LaunchAgents/homebrew.mxcl.ollama.plist 中添加一个环境变量 OLLAMA_MODELS:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>EnvironmentVariables</key>
<dict>
<key>OLLAMA_FLASH_ATTENTION</key>
<string>1</string>
<key>OLLAMA_KV_CACHE_TYPE</key>
<string>q8_0</string>
<key>OLLAMA_MODELS</key>
<string>/Volumes/SSD/ollama</string>
</dict>
<key>KeepAlive</key>
<true/>
<key>Label</key>
<string>homebrew.mxcl.ollama</string>
<key>LimitLoadToSessionType</key>
<array>
<string>Aqua</string>
<string>Background</string>
<string>LoginWindow</string>
<string>StandardIO</string>
<string>System</string>
</array>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/opt/ollama/bin/ollama</string>
<string>serve</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StandardErrorPath</key>
<string>/opt/homebrew/var/log/ollama.log</string>
<key>StandardOutPath</key>
<string>/opt/homebrew/var/log/ollama.log</string>
<key>WorkingDirectory</key>
<string>/opt/homebrew/var</string>
</dict>
</plist>
这样做确实能达到我们的目的,但也会留下一个隐患。因为这个 plist 文件只是你 ollama 程序中 plist 文件的一个副本,你每次执行brew services restart ollama时,这个 plist 文件都会被重新复制过来,覆盖掉之前的,所以你的环境变量很容易丢失。
当然,你也可以修改 ollama 程序中原始的 plist 文件,/opt/homebrew/Cellar/ollama/0.31.1/homebrew.mxcl.ollama.plist,不过这个文件是放置在一个具体的 ollama 版本下的,例如我的是0.31.1。这样一来,只要升级了 ollama,那么系统用的就会是新版的 plist,你的环境变量还是会丢失。
所以改 plist 虽然有用,但并不是一个可以持续有效的方法。
自定义 plist 设置环境变量
还有一种方案,就是自定义一个专门设置环境变量的 plist,例如写一个com.mymac.env.plist,把所有想设置的环境变量都写在里面,这样系统启动时,只要 launchd 进程一启动,就会自动设置这些环境变量了。
不过这个方法也有问题,那就是 launchd 在启动时,加载 plist 的顺序是不确定的,也就是竞态问题。有可能它是先加载了 ollama 的 plist,然后才加载你自定义的 plist,这样 ollama 启动的时候,你的环境变量还没设置呢,环境变量还是会失效。
自定义 plist 管理 ollama
既然改 plist 会被覆盖,自定义 plist 又无法保证加载顺序,那就不改 plist,同时只留一个自定义的 plist。也就是你自定义一个 plist,例如com.mymac.ollama.plist, 来启动 ollama,内容还是我上面的那段 xml。
同时执行brew services stop ollama并删除~/Library/LaunchAgents/homebrew.mxcl.ollama.plist,再加载你自定义的 plist:
launchctl load ~/Library/LaunchAgents/com.mymac.ollama.plist
这样就是使用你自定义的 plist 来管理 ollama 服务了,也无所谓前面说的覆盖问题了。
但这样还是有一个隐患,如果你又执行了brew services restart ollama,那你删除的 plist 就又会复制回来,又成了一个没有环境变量的 ollama 服务了,也会和你自定义的 plist 形成竞态。而且这样也没法使用 brew services 的命令提供的能力来管理 ollama 了。
重启兜底
咱再来一个不完美的方案。前面说自定义一个 plist 来设置环境变量,会和启动 ollama 的 plist 形成竞态,那么可以加一个重启的动作作为保险。也就是在设置完环境变量之后,重启一次 ollama,这样不管哪一个 plist 先加载,都能保证最后运行的 ollama 可以读到环境变量。这个 plist 可以这样写:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.mymac.ollama-envfix</string>
<key>ProgramArguments</key>
<array>
<string>/bin/zsh</string>
<string>-c</string>
<string>launchctl setenv OLLAMA_MODELS "/Volumes/SSD/ollama" && brew services restart ollama</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>WatchPaths</key>
<array>
<string>/opt/homebrew/opt/ollama/homebrew.mxcl.ollama.plist</string>
</array>
</dict>
</plist>
这里除了有设置环境变量后重启 ollama,还监控了 ollama 的 plist,如果这个 plist 发生改变,说明它很可能重启了 ollama,也就是变成了没有环境变量的 ollama,那么就会触发执行上面这个 plist,也就是设置完环境变量,再重启下,又变成了有环境变量的 ollama 了。
这样问题也很明显,总是有一定概率,出现一小段时间,ollama 是处于没有环境变量的状态的。尽管是个转瞬即逝的窗口,但还是有可能因为环境变量不生效,而产生一些预期外的操作的。
指定 plist
最后再说一个方案,但很可惜,仍然不是完美的方案。那就是指定一个自定义的 plist。我们可以通过--file参数来指定一个加了环境变量的 plist:
brew services start ollama --file=~/my-configs/ollama.plist
这个指定的 plist 不会被 launchd 自动加载,所以不会产生竞态,也不会被任何操作覆盖,算是比较好的解决了问题。
但美中不足的就是,你很难保证你每次启动 ollama 时,都一定能加上这个参数。不管因为什么原因,只要你没加,就又变回了没有环境变量的 ollama 了。
技术洁癖
老话说得好,我的需求很简单,我就是想只在一个地方设置一次环境变量,然后能全局生效。这算是一种技术洁癖吧。结果这还没说统一 shell 和 launchd 呢,光是用 Homebrew 来启动 ollama 服务时,想毫无后顾之忧地配置一个环境变量就这么难。
我觉得这锅得 Homebrew 来背,毕竟是它升级时会覆盖 plist,才引出来这堆问题,它应该再提供一个能外挂自定义配置的机制,问题就能解决了。可我又一想,我都能想到的法子,人家肯定早就想到了,然后一查,至少去年是有人提过一个 discussion 的,目前还是开放状态。
https://github.com/orgs/Homebrew/discussions/6196
不过在 Homebrew 眼中,这个问题可能优先级并不高,因为它只是 Mac 上的问题,在 Linux 中有 systemctl edit 命令可以实现我的要求。这么一看又像是苹果的锅了,你作为操作系统,也没个统一设置环境变量的地方。以前有,还被你废弃了。
我目前是改了 plist,暂时先解决模型下载到本机的问题,后面的长期方案还没想好用哪种,毕竟都不完美。不过在这个过程中,还是有不少收获的,学到了关于 Mac 系统的进程启动和环境变量加载的很多知识,不只是这篇文章写到的,或许可以单独写一篇文章吧。