128GB 统一内存,听起来足够把所有模型都塞进内存一次性跑起来——这是我年初从 32GB 升级到 128GB 时的第一想法。结果显而易见:如果光靠“塞进去”就能解决多模型问题,我也不至于专门写一个管理器,还在调试它的时候接连踩了七个坑。
简单交代下背景:我日常要在本机跑 5 个不同量级和用途的模型,做对比测试和混合推理。模型文件加起来其实不到 128GB,理论上是能常驻的,但实际跑起来反复出现内存不足、加载极慢、进程被杀、模型切换后行为异常。在试过 Ollama 自带的并发加载参数、LM Studio 的按需加载、llama.cpp server 的手动管理之后,我决定自己写一个针对统一内存的模型管理器。这篇文章就讲讲管理器的设计思路和排查过程中遇到的完整事故链,每个坑我都尽量还原当时的判断过程和最终定位,希望对同样在 128G 统一内存上折腾多模型的读者有帮助。
1. 128G也扛不住“全部加载”:我为什么动手写管理器
1.1 五个模型同时驻留的真实账单
先说我的模型清单:一个 7B 的聊天模型,一个 13B 的通用模型,一个 8B 的嵌入模型,一个 3B 的轻量分类模型,外加一个 32B 的高质量生成模型。如果全用 4bit 量化,7B 大约 4.5GB,13B 大约 8GB,8B 大约 5GB,3B 大约 2GB,32B 大约 19GB,光是模型权重加起来差不多 38.5GB,对 128GB 内存来说看起来非常宽裕。
但真正跑起来完全是另一回事。每个模型在加载后,除了权重,还有三块隐藏开销:KV Cache、计算图中间缓冲、运行时上下文。KV Cache 的大小和上下文长度直接挂钩,我测试时把上下文开到 16K,7B 模型的 KV Cache 大约 2~3GB,32B 模型的 KV Cache 能到 10GB 以上。计算图缓冲在推理峰值时又占掉 2~4GB。这些加起来,五个模型同时驻留的瞬时峰值经常冲到 80~90GB。
更关键的是,统一内存不只是给模型用的。操作系统文件缓存、我开着的一堆终端和浏览器、进程间的通信缓冲,全都从同一块 128GB 里扣。当模型峰值叠加系统常态占用,内存压力就上去了,系统开始压缩内存、交换到磁盘,整个 Mac 直接进入“幻灯片模式”。
1.2 现成工具的问题出在哪
动手之前,我把现成的方案都试了一遍,各有各的别扭。
Ollama 是生态最完整的,但它默认一次只加载一个模型,OLLAMA_MAX_LOADED_MODELS能调大,可它不管你自己的内存预算,调成 5 之后真就把五个模型全部加载起来,任由系统在内存边缘摩擦。OLLAMA_KEEP_ALIVE可以控制模型驻留时间,但这是按“分钟”的粗粒度控制,不是按“当前内存压力”的动态控制。
LM Studio 更偏向单模型体验,切换模型时要手动操作,没有程序化接口去精确控制内存占用。llama.cpp 自带 server 可以跑一个模型暴露 OpenAI 兼容 API,但一个进程只服务一个模型,多模型就得多个进程,端口、上下文、并发参数各管各的。
我想要的东西其实很朴素:一个进程统一管理 5 个模型的生命周期,能告诉我当前真实内存账单,能按内存压力自动卸载不活跃模型,还能把 API 请求路由到正确后端。市面工具给不了,只能自己写。
2. 管理器的骨架:统一网关、模型进程池、内存记账
2.1 整体架构设计
我最后实现的结构分三层:
- 统一 API 网关:对外提供 OpenAI 兼容的
/v1/chat/completions和/v1/embeddings接口,收到请求后根据模型名路由到后端进程。 - 模型进程池:每个模型对应一个后端进程(Ollama 或 llama.cpp server),进程常驻但模型可以卸载。进程池独立于网关的好处是,单个模型的崩溃不会拖垮整个管理面。
- 内存记账与调度器:核心组件。定期采集系统内存压力和每个进程的内存占用,按策略决定加载、保持、卸载哪个模型。
这三层里最核心的不是路由,而是记账器。因为统一内存是 CPU、GPU 共享的,你不能只看一个进程的 RSS 就觉得万事大吉,它由两部分组成:模型权重占用的驻留内存和GPU 上下文/缓存占用的统一内存页。macOS 上没有 Linux 那种 cgroup 可以直接看,我用的方案是组合psutil的 RSS 和task_info的 footprint,再做加权估算。
2.2 记账逻辑的代码骨架
我估算每个模型真实内存占用用的公式是:
def model_memory_estimate(model_config, ctx_length, batch_size=1): weights_gb = model_config["weights_gb"] kv_cache_gb = (model_config["layers"] * model_config["kv_heads"] * model_config["head_dim"] * 2 * ctx_length * 2 # key和value,乘2字节 / 1024 ** 3) compute_gb = model_config["compute_buf_gb"] overhead_gb = 1.5 # 运行时上下文、日志、python子系统 total = weights_gb + kv_cache_gb + compute_gb + overhead_gb return round(total * 1.15, 2) # 再留15%安全垫以 7B 模型为例,weights_gb = 4.5,kv_cache_gb = 2.5,compute_buf_gb = 2.0,总估值大约 10GB。我拿它在真机上对比过,误差在 ±15% 内,够用。
记账器不直接看总内存,而是追踪每个进程的内存增长速度。加载模型时进程 RSS 会快速爬升,爬升稳定后说明模型加载完成。这个事件被我用来触发调度器决策——如果内存吃紧,就把最久未用的模型卸载,而不是等到系统真的要杀进程才被动反应。
2.3 调度策略
调度器采用“冷热分离”:一个模型如果最近 15 分钟内有请求,标记为热模型,常驻;超过 15 分钟没请求,标记为温模型,可以保留但优先级低;超过 30 分钟且内存压力超过阈值,标记为冷模型,卸载。
这套策略救了我很多次,但也正是它埋下了后面好几个坑。我当时太信任“估算公式”和“静态策略”,忽视了真实系统里那些不按计算书出牌的现象。
3. 踩坑实录:我在真机上碰到的七个问题
3.1 坑一:内存估值表全对,KV Cache是那个看不见的第三只手
第一次跑管理器,我信心满满地看模型加载完的内存数据,结果 7B 模型加载后进程 footprint 高达 11.8GB,比我表格里的 10GB 预估多了将近 2GB。反复确认模型是 4bit 量化,权重文件也确实是 4.5GB,那多出来的 7GB 从哪来的?
排查到最后才发现,问题出在上下文长度的默认值上。我用 llama.cpp server 加载时没显式指定--ctx-size,它默认按模型训练上下文走,我的 7B 模型配置里写的是 32K 的 max_position_embeddings,于是系统在加载阶段就把 32K 的 KV Cache 全部预分配了。KV Cache 是随上下文长度线性增长但不是从 0 开始分配的,很多推理框架会预分配一整块。
教训:任何管理器的内存估算都必须把“加载时默认上下文长度”纳入配置,并在启动命令里显式指定--ctx-size,而不是依赖后端的默认值。我后来给每个模型配了独立的 ctx 参数,7B 模型固定 16K,内存立刻降了 1/3。
3.2 坑二:可用内存曲线的“假宽敞”:统一内存也会被文件缓存挤兑
修复坑一后,我盯着监控面板,五个模型全部加载,内存占用约 78GB,系统可用内存显示还有 30 多 GB,看起来非常安全。结果我一跑批量推理,系统直接开始 swap,鼠标都开始飘。
根源在于 macOS 的统一内存机制里有一个隐藏玩家:文件缓存。我批量推理时反复读取模型文件、数据集文件,系统把这些文件页缓存进内存,而统一内存这块蛋糕是共享的。系统监控面板的“可用内存”会包含可回收的文件缓存,但“可回收”和“可立即回收”完全是两回事——当大量缓存页处于活跃状态时,系统要先写回脏页才能释放。
排查过程花了很长时间。我先以为是推理进程的 bug,后来通过vm_stat看到Pages occupied by compressor和文件缓存相关指标在推理峰值时异常升高,才确认是缓存挤兑。
修正方案是在调度器里增加一个“系统内存压力水位”,不只看可用内存的绝对值,还用memory_pressure工具做二次确认。当内存压力达到黄色级别时,即使可用内存还有 20GB,也先把冷模型卸载掉。统一内存再大,也不是只给模型用的,这一点在 128GB 机器上尤其容易麻痹人。
3.3 坑三:模型卸载了,显存才不叫显存——Metal资源残留
调度器第一次执行“自动卸载”时,我以为就是让后端进程退出、释放内存这么简单。然后我看到了诡异的现象:模型进程明明已经被杀掉,系统内存却没有降下来,反而在之后的一段时间里持续爬升。
我追查后发现,llama.cpp 在加载模型时会把-ngl(GPU layers)指定的层放到 GPU 侧,这些层的 buffer 归 Metal 管理。当进程终止时,Metal 的资源清理是异步的,如果上一个进程还有一个资源引用没被系统回收,新进程又开始申请 GPU 缓冲区,内存压力会叠加。
更隐蔽的是,如果我用 kill 命令强杀 llama.cpp server,Metal 缓冲区不会立刻释放,而是等下一个 Metal 任务发起时才回收。这在多个模型进程间反复切换时会被放大——看起来像内存泄漏,实际上是一堆待回收的异步资源排队。
修正方法分两步:第一,优雅关闭后端进程,通过 HTTP 接口发卸载请求而不是直接 kill;第二,进程退出后加一个 3 秒的“资源回收等待窗口”,确认 footprint 降下来后才允许新模型加载。统一内存的分配和释放不是瞬间的,调度器要考虑异步回收时间,否则内存账本是假的。
3.4 坑四:并发推理撞上统一内存带宽天花板
五个模型稳定运行后,我开始测试多路并发推理。两个模型同时生成时,两者的吞吐量都掉到单模型时的 60% 左右;三个并发更惨,跌到 30% 出头。一开始我以为是 CPU 资源竞争,看了一圈 CPU 占用率不到 50%,完全不像是核心数不够。
后来用powermetrics看内存带宽占用率,峰值已经超过 80%。这才是真凶:128GB 统一内存虽然是“内存”,但它的物理带宽是固定的(我这台机器标称 800GB/s),CPU 和 GPU 共享同一条内存总线。当多个模型同时做推理时,所有进程都在疯狂读写权重矩阵,每个 token 生成都需要把整个模型权重从内存流式读一遍,多个模型同时流式读,带宽瞬间就被吃满。
这个坑的本质是:统一内存的容量优势弥补不了带宽瓶颈。128GB 能装下 5 个模型,但 5 个模型同时读权重就 5 条数据流,哪怕每个都只读一遍,聚合带宽也是极限的 5 倍压力。
修正策略:调度器增加“并发推理闸门”——同一时刻只允许一个高吞吐模型(如 32B)或两个低吞吐模型并发,其余请求排队。这是我从数据库连接池的灵感里想到的,效果立竿见影,单个请求的延迟恢复正常,总吞吐反而因为避免了带宽争抢而更高。
3.5 坑五:量化格式的“模型尺寸幻觉”
我的 5 个模型来源不同,量化格式也不一样:两个是 GGUF 的 Q4_K_M,一个是 MLX 4bit,还有一个是 AWS 转的 GPTQ 风格,另一个是 fp16 原始权重。加载时我犯了个低级错误——直接拿文件大小去估内存,结果 fp16 的模型加载后占的内存是文件大小的 1.4 倍,而 Q4_K_M 模型反而比文件大 1.6 倍。
原因在于两种格式的内存布局差异。GGUF 的量化权重是按 block 打包的,加载时需要反量化成中间格式才能交给 MLX 或 Metal 计算,这个过程会生成临时缓冲区;而 fp16 原始权重加载时几乎不用转换,但 activation 计算产生的中间 buffer 更大。
更要命的是,不同量化格式对 KV Cache 的精度要求不同。MLX 4bit 跑 16K 上下文时缓存用的是更高精度类型,KV Cache 比同等上下文的 GGUF 版本大了 20%。这些差异光看模型卡片的“大小”完全看不出来。
修正方案:我不再相信模型卡片的文件大小,而是每换一个模型,先用一个固定 prompt 跑 10 到 20 个 token 的推理,记录内存差值,得出“实测加载系数”,存入模型配置。这样记账器的数据就从“估算”变成了“标定”,之后再没出现因为格式差异导致的误判。
3.6 坑六:上下文长度是内存里的定时炸弹
这是我踩得最冤的一个坑。调度器运行良好,某天我收到一个 20K 上下文的长文档总结请求,发给了 13B 模型。结果模型加载时没出问题,但推理跑到第 12K token 时直接 OOM 崩溃,进程被杀。
原因是13B 模型的管理配置里写的是ctx_size: 8192,但请求的上下文长度超过了配置。框架在这种情况下不会拒绝请求,而是尝试动态扩展上下文,扩展的内存申请发生在推理中途,直接触发统一内存的硬上限。最气人的是崩溃前 10 分钟我才手动卸载了一个冷模型腾出内存,那个模型按我的调度策略本来会一直驻留到内存压力出现为止。
这个坑给我的启发很大:上下文长度不能只配置一个静态值,还要在网关层做长度检查。请求的max_tokens + input_tokens超过模型配置的ctx_size时,网关直接返回 400,而不是放进去让后端硬扛。同时给每个模型配置一个“安全上下文余量”——比如配置 16K,实际上只允许用到 14K,留 2K 给生成 token 和框架 overhead。这样“运行一半炸掉”的问题彻底绝迹。
3.7 坑七:换来换去,冷启动延迟毁掉了“管理器体验”
前面六个坑都填平后,管理器终于稳定运行,但我发现它的使用体验一点都不好——因为调度器太勤奋了。只要内存压力一高,它就把冷模型卸载,结果我下次要切换到刚卸载的模型时,重新加载要等 30 到 90 秒,这比直接用 Ollama 不带管理器还慢,用户体感跟“卡死”没区别。
根源在于我的调度策略只考虑了内存,没考虑切换成本。一个 32B 模型从磁盘加载到统一内存,按 NVMe 速度大概要 15 秒,再加上量化反初始化、KV Cache 分配、Metal 编译缓存预热,总时间非常长。如果这个模型每小时被调用一次,我每次都为省内存把它卸载,反而每小时浪费 30 秒加载时间,纯亏。
修正方案是给调度器加一个“切换成本感知”:把模型分三类——常驻型(每 15 分钟内至少被调用一次,无条件保留)、按需型(可以卸载,但卸载后 5 分钟内的再次调用触发热加载并优先腾挪其他内存)、批处理型(只在定时任务里调用,从不常驻)。同时把内存压力阈值调低,不再因为微小的内存波动就急着卸载温模型。
4. 踩完七个坑之后的修正方案与实测数据
4.1 修正后的调度策略
最终跑通的管理器,核心策略可以浓缩成三点:
- 先标定,再估算。每个模型加载后跑一次固定推理,把实测内存占用写入配置文件,后续调度全用实测值。
- 上下文长度在网关层硬检查。请求进入管理器时先校验 token 总量,超过模型配置上限直接拒绝,绝不放进后端。
- 内存压力、带宽、切换成本三者加权评分。调度器不再只看单一指标,而是用一个公式算每个模型的“驻留收益分”:驻留收益 = 调用频率占比 × 带宽需求系数 − 内存成本 − 切换惩罚系数。收益分最高的组合被保留。
4.2 实测数据复盘
这是修正后连续运行一周的数据:
| 配置项 | 优化前 | 优化后 |
|---|---|---|
| 模型常驻数量 | 5(固定) | 3~4(动态) |
| 峰值内存占用 | 88GB | 74GB |
| 内存压力告警次数 | 每天 7~12 次 | 每天 0~2 次 |
| 模型冷启动平均延迟 | 45 秒 | 18 秒(仅按需型) |
| 并发推理时单请求延迟 | 波动 40%~200% | 稳定在 ±15% |
| 因 OOM 被杀进程数 | 每周 5~8 次 | 0 |
最明显的变化是内存压力告警基本消失,且不再有 OOM 杀进程。代价是偶尔需要在按需模型上等 20 秒加载,但结合网关排队策略,这在可接受范围内。
4.3 调度器的实际运行日志片段
贴一段真实运行日志的样子,方便你理解调度决策:
[10:02:31] [schedule] memory_pressure=45% (safe), bandwidth_load=22% [10:02:31] [schedule] keep hot models: 7B-chat (last_used=30s), 3B-classify (last_used=50s) [10:02:31] [schedule] idle 13B-general = 17min > 15min threshold, unload in 0.5s [10:02:36] [schedule] unload 13B-general complete, recovered 9.8GB, effective_free=61GB [10:05:14] [gateway] request model=13B-general, tokens=3422, ctx_limit=8192, pass [10:05:14] [schedule] reload 13B-general, estimated_load_time=28s, priority=1 [10:05:42] [schedule] 13B-general loaded, footprint=10.2GB, mark hot for 900s从这段日志能看到,调度器和网关是路由一体的,请求来时如果模型已卸载,会走“优先加载 + 请求挂起等待”的路径。这个挂起机制很重要,否则客户端会收到连接重置。
5. 给想省内存的同行:几个可以少走弯路的建议
5.1 先记账,再谈调度
我的最大教训是:调度策略写得再花哨,如果内存数据不准,一切都是空中楼阁。在你动手设计调度算法之前,先把每个模型在不同上下文下的真实内存占用统计清楚,做成一份表格。我在修完坑一和坑五之后才意识到,最早的失败不是策略问题,而是记账口径从一开始就有偏差。
记账的建议:加载前后各采一次系统 global memory 数据,取差值;然后空跑一次推理,再取一次差值。这两个差值分别代表“模型静态占用”和“推理峰值增量”,把它们都存下来。后续任何调优都以这份实测数据为基准,而不是模型卡片的宣传体积。
5.2 别把所有模型都塞进同一量化格式
很多人拿到新模型后习惯用一贯的量化脚本一顿操作,但不同模型的敏感度差别很大。7B 模型 Q4_K_M 的质量损失几乎不可感知,32B 模型 Q4_K_M 在某些任务上就有明显降智,更别提嵌入模型和分类模型——它们对量化误差的容忍度极低,我最后把嵌入模型换回了 fp16,内存多花 4GB,但检索质量提升非常明显。
在统一内存容量够大的前提下,走“混合精度”路线更科学:生成类模型用 4bit,嵌入和分类模型用 8bit 或 fp16,重模型用 5bit 或 6bit。总内存占用增加不了太多,效果差异每天都能感受出来。
5.3 先用好现成工具的 keep_alive,再决定要不要自己写管理器
最后一句真心话:不是所有人都需要自己写管理器。如果你的场景是“同一时间主要用一个大模型,偶尔切到另一个”,Ollama 的OLLAMA_KEEP_ALIVE=30m配合手动切换就足够好,折腾管理器完全是浪费时间。
只有当你和我的需求一致——多个模型高频交叉使用、需要程序化控制生命周期、对内存占用变化敏感——才值得考虑自己动手。而且真写起来,建议先从记账器开始,不要一上来就铺开架构。我就是因为一开始就规划了三层架构,结果七个坑里有一半是架构的前提条件不成立导致的返工。先把内存账算明白,再谈调度,你会发现剩下的路比想象中顺很多。