事情得从我把 70B 对话大模型、BGE 向量模型、Whisper 语音转写、FLUX 出图模型和一个 7B 代码模型,同时塞进同一台 128G 统一内存的 Mac 开始说起。听起来内存管够,但真正跑起来才发现,统一内存的“大容量”和传统显存的“私有容量”完全不是一回事。为了把这五个模型安排明白,我手写了一个本地模型管理器,结果前前后后踩了七个大坑,每一个坑都值得单独拿出来说。
这篇就当作一个实战记录:你会看到我为什么写这个管理器,统一内存场景下内存和带宽该怎么算账,以及那七个坑的完整排查链路和最终修复方案。不管你是想在 Mac 上本地跑多模型服务,还是只是被“128G 统一内存”的宣传吸引了,这篇应该能帮你少走不少弯路。
1. 为什么我在 M 系列 Mac 上写了一个本地模型管理器
1.1 一个把五个模型同时常驻的工作场景
我的日常任务基本可以拆成五块:长文写作和对话要靠一个大模型,RAG 检索需要 embedding 模型,会议录音要转文字,写代码时需要一个响应快的代码模型,偶尔还要出图。于是我把它们排成了一桌:
| 模型 | 用途 | 运行框架 | 稳定时物理占用 |
|---|---|---|---|
| 70B Quant 主力模型 | 长文生成、对话 | MLX | 38~42G |
| 7B 代码模型 | 代码补全、工具调用 | llama.cpp | 约 5G |
| BGE-M3 | RAG 向量化 | PyTorch MPS | 约 2.5G |
| Whisper large-v3 | 音频转写 | whisper.cpp | 约 3G |
| FLUX.1-dev | 图像生成 | ComfyUI / PyTorch | 约 12G,峰值更高 |
每个模型单独跑都没问题,但五份服务一起开,我很快就发现三件事:第一,系统内存明明还剩不少,却开始频繁压缩页面,UI 卡顿肉眼可见;第二,每个服务各自为政,谁也不知道别人占了多少内存,更不知道什么时候能释放;第三,一旦某个模型的内存缓存异常膨胀,我完全没法精准定位是谁干的。
所以我决定写一个管理器,统一管理这些模型的进程生命周期、内存占用和请求调度。这个管理器不是要替代 Ollama 这种现成工具,而是要解决一个 Ollama 管不了的问题:多种异构推理框架、多个不同用途的模型,在同一块统一内存上共存时,谁来排优先级、谁来记账、谁来控制驱逐。
1.2 统一内存的“容量大”和“带宽窄”是两回事
M 系列芯片上的统一内存,本质是 CPU 和 GPU 共享同一个物理内存池。好处很直接:没有显存和内存的物理边界,模型权重想放多少放多少。但坏处很多人没意识到——所有计算单元共享同一条内存带宽。
打个比方:传统 CPU+独显的机器像两个独立仓库,各进各的货;统一内存是一个超大仓库,存储容量巨大,但所有搬运都走同一条传送带。大模型推理恰恰是典型的“搬运密集型”任务,每生成一个 token,都要把模型权重从头到尾读一遍。
一个 40G 的模型,在 400GB/s 的理论带宽下,单次读取权重需要约 0.1 秒,所以单模型解码速度上限就是 10 token/s 左右。五个模型一起跑,传送带是唯一的,总吞吐不会变大,只会互相抢。这就是我后来踩第三个坑的理论基础。
1.3 管理器到底帮我管什么
我最终把管理器的职责收敛成三块。
内存核算:搞清楚每个模型进程真正占了多少物理内存,而不是看ps的 RSS 自欺欺人。这需要引入 macOS 的 footprint 指标,后面专门讲。
状态调度:每个模型进程在“加载中、空闲、推理中、待驱逐、已退出”之间切换,由管理器统一决定何时加载、何时驱逐,而不是让每个服务自己乱来。
请求路由:所有外部调用走管理器的统一网关,由网关把请求转发到正确的模型进程,同时控制并发,避免多个大模型同时推理时互相踩踏带宽。
架构上我选了“管理器 + 多子进程”的方式:管理器是一个 Python 3.11 写的 asyncio 服务,子进程是各推理框架自己的服务进程。管理器和子进程之间通过 HTTP 通信,状态记录在 SQLite 里。这个选择本身也踩了不少坑,后面会细说。
2. 管理器设计:多进程隔离、内存盘点与租约调度
2.1 为什么不用一个进程把所有模型都 load 进来
第一个设计决策就是:绝对不把所有模型塞进同一个 Python 进程。
理由很实际。MLX、PyTorch MPS、llama.cpp、ComfyUI 的 Python 依赖完全不是一套生态。硬塞进一个进程,光是 libomp、protobuf、OpenMP 线程库的符号冲突就能让你从早折腾到晚。就算装成功,任何一个框架的缓存池污染,都会把其他模型的显存分配拖下水。多个进程隔离后,ComfyUI 崩了,我的 RAG 服务还活着,这个收益在长跑中非常值钱。
代价也要坦白说:每个推理框架各自维护一套内存缓存池,统一内存里确实会多出一些不可共享的缓存冗余。但相对于管理复杂度,这个代价完全可接受。
2.2 内存盘点:从 RSS 到 footprint 的账本
传统 Linux 服务器上大家习惯了看 RSS,但在 macOS 上直接看 RSS 会吃大亏。ps的 RSS 是“进程映射的所有物理页”,它包含文件映射的页缓存,而这些页在内存压力下是可以被系统回收的。尤其是 llama.cpp 默认用 mmap 方式加载 GGUF,虚拟地址空间很大,RSS 看起来虚高,实际物理压力却没那么大。
我最终采用的核算命令是这些:
# 系统级别内存压力,输出 level 为 ok / warning / critical memory_pressure -Q # swap 用量 sysctl vm.swapusage # 单个进程的物理内存占用,看 phys_footprint 字段 footprint -p 12345 # 传统方法,只用于快速对照 ps -o pid,rss,vsz,comm -p 12345footprint输出的phys_footprint才是 macOS 用来判断进程真实物理内存占用的指标,它会把页缓存、压缩内存这些因素尽量排除掉。后面踩第二个坑时,我就是靠它救回来的。
2.3 调度策略:LRU 驱逐 + 租约锁定
管理器最关键的部分是调度。最开始我幼稚地写了个 LRU:谁最久没用就驱逐谁。这个方案在只有一个服务说话的时候没问题,但多服务并发时直接爆雷——我驱逐了一个看起来“空闲”的模型,结果它的推理请求还在路上,进程被 kill,请求直接全挂。这就是第五个坑。
后来我改成“租约”模型:每个请求从网关注册一个租约,推理结束后释放。只有租约数为 0,且空闲时间超过阈值的进程,才允许被驱逐。数据库里每个模型进程的状态就长这样:
CREATE TABLE model_registry ( name TEXT PRIMARY KEY, proc_pid INTEGER, status TEXT, peak_footprint INTEGER, idle_since REAL, lease_count INTEGER DEFAULT 0, start_command TEXT );驱逐时先发 SIGTERM,给进程 10 秒自己清理,超时再 SIGKILL。这套逻辑最终扛住了多请求并发,也是整个管理器从“玩具”走向“可用”的关键一步。
3. 坑一:MPS 缓存不释放,内存明明够却被 OOM
3.1 现象
RAG 服务和 FLUX 出图服务跑了一阵之后,PyTorch 进程直接抛 OutOfMemory。但诡异的是,系统内存明明还有三四十 G 空闲,vm_stat显示的内存压力也没到 warning 级别。我一度以为是模型加载太大,后来发现根本不是容量不够,是 PyTorch 的 MPS 后端在骗我。
3.2 根因
PyTorch 的 MPS 后端有一个 caching allocator,Tensor 被释放后,GPU 内存不会立刻还给系统池,而是留在自己的缓存池里备用。问题在于,某些版本下这个缓存池只扩不缩,内存碎片越积越多。系统层面明明还有大把物理内存,但 MPS 进程自己认为“可分配空间已经用完”,于是抛 OOM。
本质上就是两层账本对不上:系统记账系统,框架记账框架。我只看系统空闲,PyTorch 只看自己的缓存池。
3.3 解决
修复分两步。
第一步,设置 MPS 后端的水位阈值,让缓存池达到上限后主动收缩:
export PYTORCH_MPS_HIGH_WATERMARK_RATIO=0.4 export PYTORCH_MPS_LOW_WATERMARK_RATIO=0.2HIGH_WATERMARK_RATIO控制缓存池能涨到多高,LOW_WATERMARK_RATIO控制降到多低。默认是 1.0,也就是几乎不主动收缩,调整后缓存会激进得多。
第二步,在每次推理循环后主动清理:
import torch torch.mps.synchronize() torch.mps.empty_cache()对于 embedding 这种高频小请求服务,我后来还在管理器里加了周期性重启策略,彻底把缓存池重新归零。这个坑给我的教训是:想在统一内存上管多模型,不仅要看系统空闲内存,还要看每个框架自己的内存账本。
4. 坑二:llama.cpp 的 mmap 让 RSS 虚高,内存统计被带偏
4.1 现象
管理器上线第二天,调度器疯了。它看到 llama.cpp 的 7B 代码模型进程 RSS 高达 5G,加上 70B 模型的 40G,还有 FLUX 的 12G,觉得内存要爆,于是开始疯狂驱逐模型。但实际系统内存压力一直正常,memory_pressure -Q输出的始终是 ok。我一度怀疑是 macOS 的统计工具坏了,后来才明白,是我的统计维度用错了。
4.2 根因分析
llama.cpp 默认通过 mmap 加载 GGUF,意思是把文件直接映射进进程地址空间。这些映射页会算进 RSS,但它们本质上是文件页缓存,系统在内存压力下完全可以回收。更麻烦的是,如果两个 llama.cpp 进程加载同一个 GGUF,物理页还能共享,RSS 却会各自加一遍,导致总账看起来莫名其妙。
如果用 RSS 总和来决定驱逐策略,结果一定是“假想内存不够”,在最不该驱逐的时候把模型杀掉了。
4.3 修正方案
我做了三件事。
第一,所有模型进程启动时尽量用 footprint 的 phys_footprint 作为内存核算基准,不再信任 RSS 总和。
第二,在核算公式里增加固定缓冲:
模型真实占用 ≈ footprint 输出的 phys_footprint × 1.05多出来的 5% 是给框架缓存和碎片留的余量。
第三,针对 llama.cpp,我明确区分两种情况:用--no-mmap启动时,模型权重会拷贝进匿名内存,RSS 可信但加载慢;保持 mmap 时,RSS 可能虚高但系统可回收。管理器内部对每个模型记账时,会记录它启动参数里的 mmap 模式,再决定按 RSS 还是 footprint 核算。
下面是同一个 7B 模型在两种模式下的对照,当时给我留下很深印象:
| 统计方式 | mmap 模式 RSS | no-mmap 模式 RSS | footprint phys_footprint |
|---|---|---|---|
| 数值 | 5.2G | 5.1G | 4.8G |
| 系统内存压力变化 | 无明显压力 | 略微上升 | 参考 |
| 能否自行回收 | 可以 | 不可以 | 准确反映保留量 |
5. 坑三:统一内存带宽共享,五个模型并行推理互相踩踏
5.1 现象
管理器把所有服务都拉起来后,并发控制还没写,我天真地以为 128G 内存足够大,五个模型同时推理毫无压力。结果实测下来:主力模型单跑有 12 token/s,同时让 FLUX 出一张图,主力模型直接掉到 4 token/s;再叠加一个 Whisper 转写,主力模型掉到 2.3 token/s,FLUX 出图也肉眼可见地变慢。每个模型都没“死”,但综合吞吐低得离谱。
5.2 根因
这就是 1.2 节说的带宽问题。大模型 decode 属于带宽密集任务,每个 token 都要把模型权重读一遍。五个模型并行推理,相当于五路请求同时抢一条内存总线,谁的权重大,谁被拖得越惨。
传统显存架构下,GPU 有自己的私有显存带宽,CPU 内存带宽再紧张,也不至于影响模型推理。但统一内存完全没有这个隔离,带宽就是公共水管。
5.3 解决方案
管理器里加了一个“推理互斥阀”:在同一时刻,只允许一个重量级模型(权重大于 30G)做推理。其他模型请求排队等待。轻量任务比如 embedding,因为权重小、读取量低,直接放行不影响大局。
调度核心长这样:
import asyncio global_infer_lock = asyncio.Lock() async def run_inference(model_name: str, weight_gb: float, request): if weight_gb >= 30: async with global_infer_lock: return await do_request(model_name, request) else: return await do_request(model_name, request)加了这个互斥阀之后,主力模型重新回到了 11~12 token/s,FLUX 出图排队时稍慢但整体吞吐和响应时间都可接受。这个设计的核心思想是:容量可以共享,带宽必须排队。
6. 坑四:macOS 的 App Nap 和内存压缩把模型冻住了
6.1 现象
管理器本身是个后台服务,没有窗口。Mac 用着用着,我发现某些模型首次请求巨慢:原来一个 embedding 请求 300ms,闲置 10 分钟后变成 8 秒;Whisper 闲置后第一个转写请求甚至花了接近一分钟。CPU 在狂转,GPU 却没什么动静。我当时第一反应是网络或网关出问题了,但单独 curl 子进程端口,响应照样慢。
6.2 根因
这里有两个 macOS 机制在捣乱。
一是 App Nap。macOS 对后台无 UI 进程会降低定时器频率、限制 IO 和 GPU 调用频率。对于模型服务这种“平时很闲、来活必须秒回”的程序,App Nap 简直是灾难。
二是内存压缩。macOS 在内存压力下不会直接把非活跃页面清掉,而是压缩它们。模型权重被压缩后,一旦需要推理,就要现场解压。70B 模型的权重解压,CPU 直接被打满几分钟。
这两个机制叠加,就是“模型明明还活着,却像被冻住了一样”。
6.3 修复
修复的关键是让管理器告诉 macOS:我是重要的后台计算任务,不要 App Nap 我。
我在管理器主进程和所有子进程初始化时,通过 PyObjC 绑定 NSProcessInfo 的活动 token:
from Foundation import NSProcessInfo info = NSProcessInfo.processInfo() token = info.beginActivityWithOptions( NSActivityUserInitiated | NSActivityLatencyCritical, reason="keep model services hot" ) # 进程结束前记得释放 # info.endActivity(token)同时,对 70B 这种主力模型,我用--mlock把权重锁定在物理内存,防止它被系统换出或压缩。代价是这部分内存不能被系统回收,但换来的是响应时间的稳定。
这个坑还有一条血泪经验:不要等活动来了才发现内存已经被压缩,要主动监控memory_pressure -Q和 swap 用量,一旦指标越线,立刻启动驱逐流程。
7. 坑五到坑七:驱逐杀进程、swap 拖垮推理、冷启动风暴
7.1 坑五:LRU 驱逐杀掉了还在推理的进程
这个坑前面提过。第一版 LRU 驱逐只看“上次请求结束时间”,结果一个模型进程 idle 超过 60 秒,但它某个长请求的响应还挂在网上等着返回。管理器一杀,调用方直接收到连接重置。
修复就是租约机制。每个活跃请求对应一个 lease,请求结束才释放。驱逐前必须满足两个条件:lease_count == 0且idle_since超过阈值。驱逐时先 SIGTERM,等 10 秒,再检查进程是否退出,超时了才 SIGKILL。
这套机制上线后,我再没遇到过“杀错进程”的事。
7.2 坑六:swap 之后推理速度崩了
某天机器跑了很重的多任务,我看了眼sysctl vm.swapusage,swapused 已经 20G。那时主力模型响应从 10 token/s 直接掉到 0.5 token/s,基本属于不可用状态。
排查下来,是系统内存不够时,macOS 把不活跃模型的权重换到了磁盘。一旦推理请求过来,要把几十 G 的权重从磁盘读回内存,而我这台机器的内部盘虽然很快,但几十 GB 的读回也要好几分钟。这段时间里,模型就像卡死了一样。
修复策略是内存水位管理:
- 物理内存使用率到 75% 时,管理器开始驱逐“可驱逐”的模型。
- 到 90% 时,立刻驱逐所有非活跃租约模型。
- 轮询监控
vm.swapusage,只要 swapused 超过 10G,就强制把最小的模型驱逐。
运行稳定后,我把内存预算压到物理内存的 85% 以内,swap 基本没再出现过。
7.3 坑七:冷启动加载风暴
管理器重启之后,我写了个“一次性拉起全部模型”的启动逻辑,结果五个模型同时开始读磁盘里的权重文件。SSD 并发读取被打满,系统 UI 卡死,模型加载时间反而比单独加载更慢。更讽刺的是,加载完第一个模型后想推理,却发现后面几个模型还在排队读盘,前几个模型又因为吃内存被系统压缩了。
修复也简单:加载队列化,同一时间只允许一个模型加载,按优先级排队。比如主力模型优先,embedding 第二,FLUX 最后。同时把每个模型的冷启动耗时记到 SQLite 里,后续调度时做预热预估,如果预测到马上会用到某个模型,就提前开始加载,而不是等到请求到了才冷启动。
7.4 三个坑其实是联动的
驱逐、swap、冷启动是相互影响的。加载新模型前,必须等被驱逐进程真正退出、内存水位降到安全线以下再开始。不然就会一边驱逐旧模型,一边加载新模型,SSD 在读、内存压力不减,最终又一次滑入 swap 灾难。
所以我后来把所有动作串成一个状态机,不允许同时出现“驱逐中 + 加载中”两个动作。哪怕调用方排队多等几秒,也比内存状态混乱好得多。
8. 最终修复对照表与一套可复制的监控脚本
8.1 七个坑修复对照表
每个坑的具体修复方案,我总结成了这张表,贴在这里给有需要的人直接抄作业:
| 坑 | 根因 | 修复 | 检测命令 |
|---|---|---|---|
| MPS 缓存不释放 | 框架缓存池只扩不缩 | 设置水位环境变量 + empty_cache | 观察 PyTorch OOM 与系统内存压力 |
| RSS 虚高 | mmap 页缓存计入 RSS | 改用 footprint 核算 | footprint -p PID |
| 带宽共享 | 多模型并行抢占带宽 | 推理互斥阀 + 重量级串行 | powermetrics gpu_power |
| App Nap 冻结 | 系统限制后台进程 IO/GPU | 绑定 NSProcessInfo 活动 token | 观察闲置后首请求延迟 |
| 驱逐误杀 | 只看了空闲时间没看活跃请求 | 租约机制 + SIGTERM 等待 | 请求日志与进程退出日志 |
| swap 拖垮 | 权重被换到磁盘 | 75% 水位驱逐 + mlock | sysctl vm.swapusage |
| 冷启动风暴 | 并发加载打满 SSD | 加载串行化 + 预热预估 | 观察加载耗时与 IO 压力 |
8.2 我日常用的监控脚本
管理器跑起来后,我每天会用这个脚本扫一遍内存账本:
#!/bin/bash while true; do echo "== memory pressure ==" memory_pressure -Q echo "== swap usage ==" sysctl vm.swapusage for pid in $(pgrep -f "llama-server|mlx_lm|whisper|comfy|embed"); do echo "PID $pid:" footprint -p $pid 2>/dev/null | grep -E "phys_footprint|compressed" done sleep 5 done这个脚本的价值不在于好看,而在于把每个模型进程的真实物理占用、压缩内存和系统压力拉到同一个屏幕上。出问题时,第一眼就能判断到底是容量不够、带宽争抢,还是框架缓存异常。
8.3 请把“内存账本”当成项目的最高优先级
我最大的感受是:在统一内存上做多模型管理,网络路由和调度策略都不是最难的,最难的是账本一致。
系统有一套账,PyTorch 有一套账,mlx 有一套账,llama.cpp 因为 mmap 又有一套账。如果管理器自己的账本也是乱的,后面所有调度决策都是错的。所以我的建议是,动手写调度逻辑之前,先把footprint和memory_pressure这几个命令的读数机制彻底搞清楚,否则后面每个坑都会重复踩。
8.4 还能继续扩展的方向
这个管理器目前已经能稳定跑我日常的五模型组合。后续我打算把统一网关做成 OpenAI 兼容 API,这样本地跑着的若干模型可以无缝给 OpenWebUI、Claude Code 这类前端调用。还可以按任务语义做路由,比如代码问题自动走 7B 代码模型,长文生成自动走 70B 主力模型,embedding 只负责 RAG 检索。
另外,针对“128G 统一内存”这种环境,我觉得未来还能加一个智能预热模块,根据历史请求规律预测下一个要唤醒的模型,提前把它从磁盘加载进内存。这样才能真正把统一内存的容量优势发挥出来,而不是在冷启动上浪费时间。
最后说一个我的个人判断:128G 统一内存的容量确实很诱人,但它不是“128G 显存”的替代品。它是一块需要精细管理的内存,带宽有限、压缩机制会捣乱、框架缓存又各有脾气。如果只是插上五六个服务随便跑,跑步半小时,修坑两星期是常有的事。上面这七个坑是我真实踩出来的,希望能帮你绕开它们。