尧图精选

统一内存大模型部署:128G 机器上多模型管理器的七个坑与解法

🕒 发布时间:2026/10/1 4:11:20 📁 来源:尧图网络
手头这台 M 芯片机器到手之后我对它的第一判断是显存这个老概念终于可以放下了。128G 统一内存摆在那CPU 和 GPU 共用同一块内存池意味着我可以把过去在两张 24G 显卡上怎么都凑不齐的大模型一次性全塞进去。当时我盘算得很美——语言对话、图像识别、向量嵌入、语音转写、代码补全五个模型一起常驻互不干扰随叫随到。结果开工之后才发现把五个模型“塞进”内存只是第一步真正难的是让它们在一个管理器下协同工作不互相抢资源、不悄悄崩溃、不把整机拖到降频。这篇文章就是把我写这个多模型管理器时踩过的七个坑连同最终能跑的方案一起复盘出来。整个过程实测了两周多数据都是真实跑出来的对打算在统一内存设备上搞“多模型常驻”的朋友应该能省不少时间。1. 为什么我非要写这个管理器1.1 128G 统一内存到底意味着什么统一内存和传统独显的“显存”完全是两套逻辑。传统显卡显卡有自己的显存CPU 访问要拷贝数据过 PCIe 总线显存满了就玩完内存再大也补不上。统一内存则是 CPU 和 GPU 共用物理内存你在 M 系芯片上看到的 128GGPU 是实实在在能用到的不需要来回搬运。但这个“实在”是有代价的GPU 能申请到的内存并不是全部的 128G系统本身、CPU 进程、GPU 上下文都要分走一部分。而且 macOS 对 GPU 内存还有一个加密转换的额外开销就是所谓“crypto tax”实际可用要比标称缩水一些。我在项目里实测下来128G 机器上能稳定给 GPU 挥霍的大概在 110-115G 之间这已经是很乐观的数字了。这意味着什么一个 70B 参数的模型只做 4bit 量化花掉 40G 左右一个 13B 视觉模型做 8bit 量化17G加上 embedding、语音这些小模型五个全上确实能装进 128G。但装得下和跑得动是两回事这才是我写管理器的初衷——不是简单加载模型而是把五个模型的资源预算、生命周期、请求路由都统一管起来。1.2 管理器的目标不是玩具是生产力最开始我也偷懒过直接用 Python 的 transformers 库在一个进程里加载五个模型。表面看代码很短但一跑就露馅Python 的 GIL 让多线程推理互相卡一个模型 OOM 直接带走整个进程不同框架的模型llama.cpp、MPS、ONNX还很难在一个进程里和平共处。所以我的目标是写一个进程级的管理器而不是模型级的管理器。五个模型各自独立跑在独立的服务进程里管理器负责模型注册与发现哪个模型在哪个端口请求路由按请求类型转发到正确的模型冷热切换常用的模型常驻不常用的按需加载资源监控实时看内存压力、带宽占用、温度故障自愈进程崩溃后自动拉起这事的性质有点像把一个散户的多个基金账户集中到一个 App 里看但比那麻烦得多因为模型不只是“持仓”还要响应请求、消耗资源。下面我会先从整体设计说起再逐个复盘踩过的坑。2. 管理器怎么设计技术方案复盘2.1 方案选型进程隔离优于多模型共进程这个选择其实是踩了几天坑之后被迫认的。单进程方案最吸引人的地方是代码简单——ModelFactory.from_pretrained()一个接一个加载就好推理时直接调用对象方法。但问题会在模型数量超过三个后集中爆发内存共享导致“全有或全无”任何一个模型触发了 OOM整个进程崩溃五个模型全部陪葬。GIL 让并发请求沦为串行Python 的线程只能在任意时刻跑一个 Python 字节码模型推理是 C 扩展、通常能释放 GIL但调度、预处理、后处理这些 Python 代码还是会被锁住。框架之间的内存管理互不买账llama.cpp 的内存池、PyTorch MPS 的缓存、ONNX Runtime 的 arena三大块复杂得让人想去调池。所以我改成“每个模型一个独立进程”的方案管理器通过 HTTP 和它们通信。每个模型进程用 llama.cpp 自带的 server 或 PyTorch 的 FastAPI 包装一层模型崩溃了只影响自己管理器拉起即可。代价是多了一层网络通信开销但实测每个请求多 3-5ms对推理任务来说可以忽略。2.2 模型注册、请求路由与冷热切换机制管理器本身是一个 Python 写的轻量服务核心是一个注册表加一个状态机。注册表用 JSON 定义长这样{ models: { chat: { command: llama-server -m chat-q4.gguf --port 10001, memory: 45000, hot: true, route: /v1/chat/completions }, vision: { command: python vision_server.py --port 10002, memory: 17000, hot: true, route: /v1/vision/detect }, embedding: { command: python embed_server.py --port 10003, memory: 8000, hot: true, route: /v1/embeddings }, speech: { command: python speech_server.py --port 10004, memory: 5000, hot: false, route: /v1/audio/transcribe }, code: { command: llama-server -m code-q4.gguf --port 10005, memory: 26000, hot: true, route: /v1/code/complete } } }hot字段决定这个模型是否常驻内存。冷启动的模型比如语音平时不占内存有请求时管理器先算一下当前内存压力足够就拉起进程不够就先去 LRU 列表里挑一个最久没用的热模型“冻结”保存推理会话后关进程腾出内存再启动。切换过程平均 5-10 秒对语音转写这类非实时场景完全能接受。路由倒是简单每来一个请求管理器按 URL 路径判断转发到哪个端口。真正的难点在下文的七个坑里每一个都让这玩意差点直接返工。2.3 内存预算表先把账算清开工之前我列了一张内存预算表。128G 减去系统占用按可用 115G 规划模型用途量化模型体量预留上下文预估占用chat-70B通用对话/工具调用Q4_K_M40G8K45Gvision-13B图像识别/目标检测Q8_015G2K18Gembedding向量检索FP167G—8Gspeech-1B语音转写Q8_01.5G—5Gcode-34B代码生成/补全Q4_K_M20G4K26G五张加起来 102G留 13G 给系统抖动和临时请求缓冲。当时我觉得这预算表很富余结果第七个坑就是被这张表骗了。预算表的逻辑本身没错错在“预留上下文”那列——按常驻预留算上下文窗口对内存的吞噬远比账面上恐怖。这部分我放到坑四细说。3. 七个坑按踩的顺序逐个说3.1 坑一内存显示还剩 30G照样被 OOM 杀掉现象很诡异加载第四个模型时vm_stat看系统内存还剩 30 多 G但新模型进程刚启动就被系统杀掉日志里写着killed: 9SIGKILL没有任何 OOM 的关键词。我当时第一反应是系统 bug查了两天才想明白——问题出在 GPU 内存和 CPU 内存是两个独立审批口。macOS 统一内存虽然物理上是一块但 GPU 侧的分配窗口有独立的水位线。你用torch.mps或llama.cpp的 GPU 后端时GPU 上下文有自己的内存限额CPU 侧看着还剩 30GGPU 侧的窗口却已经饱和了。我在一次批量推理时遇到的崩溃点刚好在 GPU 窗口的边缘。排查方法分享一下。先看内存压力memory_pressure -w 90实时监控系统内存压力再看 swapvm_stat | grep swap如果 page-outs 数字在快速上涨说明系统正在把非活跃页面往交换区赶内存已经“虚胖”了。解决办法有三个层次给每个模型的进程设置 MPS 内存水位上限我用的是PYTORCH_MPS_HIGH_WATERMARK_RATIO0.75给系统留出缓冲。在管理器里做“预算门卫”任何模型申请内存之前先加 10% 余量再判断能否启动。把 GPU 窗口和 CPU 内存分开监控不能只看top的空闲内存。这个坑对我最大的教训是统一内存不是“显存内存双重保险”而是“一块内存两套记账系统”预算必须按两边都宽松来。3.2 坑二mmap 加载模式的“假占用”推理时反而更慢我用 llama.cpp 的--mmap选项加载模型发现一个熟悉的现象ps看进程的 RSS实际驻留内存显示模型占了 40G系统内存压力却很轻我一度以为这是“懒加载”的胜利。结果一跑推理第一个 token 生成居然花了几十秒来回几次都这样。原因在于--mmap是操作系统层面的文件映射页面并不会在加载时全部进物理内存而是当你访问模型参数时才真正换入。这看起来省内存但对大语言模型的推理是灾难生成每个 token 都要访问模型几乎所有参数每次都触发大量 page fault 和磁盘 I/O就像把一本百科全书按页从硬盘里抽出来读——虽然书架上有空间但你每次读书都要去取。对单模型跑服务--mmap问题不大因为一个模型的页面换入后自然就被 LRU 缓存了。但五个模型同时 mmap地址空间看着很大物理内存的页面却被系统在几个模型之间不断换入换出性能瞬间崩。解决方案很粗暴但有效--mlock。这个参数会强制把模型页面锁定在物理内存里加载时全量换入加载慢一点70B 模型大概多等 20 秒但推理时再也不会触发系统级的换页。实际测试加了--mlock之后首个 token 生成时间从 18 秒降到 1.2 秒差别是质的。提示mlock锁的内存不计入可用内存所以预算表里必须按模型全量大小预留物理内存不能指望“反正 mmap 了先放着”。3.3 坑三量化一刀切视觉模型先炸了为了塞下五个模型我最初给所有 model 都用 Q4_K_M 量化。语言模型跑起来问题不大量化之后对话质量下降在可接受范围。但视觉模型用 Q4 之后目标检测的 mAP 掉了 15 个百分点人脸识别干脆结束。我一开始以为是推理代码的问题换了 FP16 加载才发现——就是量化精度的事。原因在于视觉模型的注意力层对数值精度极其敏感Q4 的量化粒度每32个权重共享一个scale会造成 attention map 的显著偏差。语言模型有很强的冗余结构能扛住量化噪声视觉特征提取提取的链路则没有那么强的容错。修正方案是“分模型设精度”直接改注册表模型量化理由chat-70BQ4_K_M对话任务量化损失可接受内存收益大code-34BQ4_K_M代码生成对数值敏感度中等Q4 够用vision-13BQ8_0视觉特征必须保精度embeddingFP16向量质量直接影响检索不能省speech-1BQ8_0语音识别对动态范围敏感代价是内存预算表从 102G 涨到 108G还在安全线内但精度保住了。这个坑让我明白了“量化必须按任务分策略不能一把尺子量所有模型”。3.4 坑四上下文长度吃内存比模型本尊还凶内存预算表里那几个“预留上下文”数字是我照着大模型服务的习惯拍的——反正 8K 上下文对聊天够用了。结果一部署内存监控直接傻眼五个模型的常驻内存总和远超我按模型体量算的账系统开始频繁 swap。后来我补算了 KV cache 的公式才明白账错在哪。KV cache 大小按公式算KV字节数 2K和V两组 × 层数 × 注意力头数 × 头维度 × 上下文长度 × 每元素字节数拿 chat-70B 来说假设 80 层、64 头、头维度 1288K 上下文下的 KV cache 接近 20G。也就是说一个 70B Q4 模型本身占 40G加 8K KV cache 直接让内存占用变 60G。五个模型的 KV cache 加一起直接超出了我之前预算的“余量”。关键问题是我把 KV cache 按“常驻预留”挂在每个模型进程上了但大部分时间这些 cache 是空闲的。解决思路是“按需分配请求完成即释放”——用 llama.cpp 的配置把 KV cache 做成动态分配按当前请求的实际上下文来开而不是进程一启动就预留满。实测动态 KV cache 后同样五个模型常驻占用从“模型体量最大上下文”变成“模型体量平均上下文”内存压力立刻缓解了一半。这里玩家的教训是做内存预算时KV cache 必须按平均工作负载算不能按最大配置算。尤其多模型常驻KV cache 是潜在的大头不看公式就分配一定会翻车。3.5 坑五带宽是隐形天花板五个模型一起跑就堵车这是我踩的最大的一个坑也是统一内存架构最被忽视的短板。现象单独跑任何一个模型生成速度都正常。五个模型同时有请求每个模型的速度直接掉 40% 以上。一开始以为 CPU/GPU 资源争抢用powermetrics一看CPU 占用并不高问题出在内存带宽。统一内存的带宽是共享的M 系列高端芯片理论峰值可能在 800GB/s 左右但五个模型同时推理每个模型都要频繁读写自己的几百GB参数和 KV cache带宽瞬间被瓜分到每份不到 200GB/s。从计算角度说prefill 阶段是计算密集矩阵乘法多decode 阶段是带宽密集每个 token 都要把所有参数读一遍。五个模型的 decode 同时发生时总线就像早高峰的环路谁都走不快。解决这个问题不能靠加大内存得靠“错峰”和“限流”。我改了管理器的调度逻辑给每个模型设置最大并发数防止多个请求同时打到一个模型上。对高带宽敏感的模型chat、code设置推理优先级vision 和 speech 的批量请求错开执行。把并发请求做排队管理器内部维护一个简单的令牌桶对每个模型每秒允许发起的 AI 推理请求数设上限。实测效果很明显限制并发数之后单请求的延迟从之前的 1.9 秒回落到 1.0 秒左右总吞吐反而更高了因为系统不再把时间浪费在带宽争抢上。注意powermetrics需要 root能实时看 CPU/GPU/ANEU 的功耗和频率如果发现模型推理的同时 GPU 使用率不高但系统温度迅速上升多半就是带宽瓶颈而非算力瓶颈。3.6 坑六热模型切换时会话上下文说丢就丢管理器实现冷热切换后我遇到一个让人头皮发麻的问题当 chat 模型被 LRU 踢出去切回另一个冷模型再切回来时用户之前的对话上下文全没了。聊天记录的 history 我明明有保存为什么模型“失忆”排查发现问题出在 KV cache 和系统 prompt 的缓存上。模型服务进程被杀掉时KV cache 全部释放了。而我保存的 history 只是用户和助手之间的文本消息重新加载模型后我需要把 history 重新拼成 prompt 再交给模型。理论上这没问题——只要模型能正确处理长 history。但实测发现把 20 轮对话 history 重新拼进 prompt模型能有效使用的上下文肯定会下降而且每次切换后第一次回答会特别慢因为要重新“读”一遍整个历史。如果 history 很长比如 10K tokens这部分耗时比正常生成还高。解决办法是分级缓存热模型不杀进程只把事件处理完再腾内存。用“优雅下线”有请求就排队等它完成没有就冻结而不是直接 kill。对冷切换的模型把 KV cache 持久化到磁盘用 llama.cpp 的 cache 导出功能切回来时直接热加载 KV cache不用重新跑 prompt。这个方案在单模型上是有效的但我发现多模型场景下 KV cache 持久化非常占磁盘——70B 模型的 8K KV cache 导出来也要十几个 G。最终我在磁盘和速度之间做了妥协只有 chat 模型做 KV cache 持久化其他模型改成“无状态加载”反正它们的请求通常不需要跨轮会话。3.7 坑七连续跑两周内存悄悄涨了 8 个 G这是最后一根稻草也是最有“系统性”的坑。部署完两周我例行检查发现系统总内存占用从 108G 慢慢爬到了 116G再高点就要触发 swap 了。光看单个进程每个模型的内存浮动都不大我一度以为又是哪个模型在偷偷涨。用leaks检查每个进程的内存泄漏再配tracemalloc需要PYTHONMALLOCmalloc环境变量定位 Python 侧泄露最终锁定在管理器自己的一个历史记录数组上。我在模型切换时把每次请求的详细信息都压进一个 list包括请求体、响应体、时间戳和状态码理论上只保留最近 500 条但实际上由于并发回调里有个append后忘了按条件截断这个列表越积越长。模型进程反而清清爽爽是我自己的管理器代码在拖后腿。查出来之后修很简单像这类“日志型”数据不应该存在内存 list 里应该直接落 SQLite 或者用轮转日志。管理器重启过一次后内存占用回到 107G 的水平连续运行一周都稳定在 107-109G 区间。这个坑给所有人提个醒做多进程管理时管理进程本身也是资源消费者。内存监控不能只看推理进程管理调度器、路由表、日志缓存这些“软性内存”在长稳运行时会一步步蚕食你的预算空间。4. 实测结果与调优参数4.1 管理后的实测数据踩完七个坑管理器最终稳定运行了两周多。我记录了五种模型的综合数据供参考均为 M 系列高端芯片实测模型量化平均首 token 延迟平均生成速度并发上限chat-70BQ4_K_M1.1s18 token/s4code-34BQ4_K_M0.8s32 token/s3vision-13BQ8_00.5s单图—2embeddingFP160.1s1200 条/min8speech-1BQ8_00.7s60s 音频/3s1五个模型同时常驻系统内存稳定在 106-109Gswap 为 0性能波动控制在 5% 以内。体感最明显的是不再需要为了切一个模型去停止一整个服务了。4.2 值得长期使用的配置清单最终的生产配置有几个关键项llama.cpp 统一加--mlock不做 mmap 懒加载。每个模型进程的PYTORCH_MPS_HIGH_WATERMARK_RATIO0.75给 GPU 窗口留缓冲。管理器里每模型并发数chat 4code 3vision 2embedding 8speech 1。KV cache 改成动态分配不常驻预留最大上下文。冷热切换策略chat/code 常驻vision/embedding 常驻speech 冷启动。每 6 小时跑一次内存压力检查超过 95% 就自动冻结最久未用的热模型。管理器自身的内存占用控制也很重要日志和请求记录直接落盘不在内存里攒。5. 最后说几句心里话踩了一趟七坑之后我最大的体会是统一内存的出现确实把显存焦虑打掉了一大半但它带来的不是“随便装随便跑”的自由而是“为什么能装却跑不快”的新考题。统一内存放宽的是容量限制并没有放宽带宽共享和系统记账的复杂度。尤其是做多模型常驻这件事预算表只是起点真正的考验来自 KV cache 的账外消耗、GPU 窗口和 CPU 内存两套记账体系、以及带宽争抢在并发峰值期的连锁反应。管理器只是一个“壳”真正让它稳定跑起来的是这些看不见的边界条件都被摸清了。如果你也要在 128G 统一内存机器上跑多模型服务我建议先别急着写代码把上面这张预算表和七个坑的通病看一遍至少能给你省出两三天排查时间。这个管理器目前还在我机器上昼夜跑着后面我打算把冷热切换策略再优化一轮让那几个冷模型连启动都省掉——直接内存映射预热这也算是对统一内存物理极限的进一步试探了。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →