我在一台 128G 统一内存的机器上同时管 5 个模型一个 70B 的对话主力、一个 14B 的轻量问答、一个 7B 视觉理解模型外加一个 embedding 和一个 reranker。刚开始我挺乐观的——128G 嘛就算 70B 原始权重接近 140GB量化一下也完全放得下。可真到落地时才发现“塞下”和“管好”完全是两码事。模型之间的内存竞争、推理请求并发、热加载热卸载、量化精度不一致任何一个环节没处理好整台机器要么慢成幻灯片要么直接被系统杀掉进程。最后我写了一个模型管理器来处理整套流程统一注册模型、动态加载卸载、按内存水位自动驱逐、给推理请求加闸门。前后踩了七个坑有几个查了一整天才定位。这篇就把设计思路、关键实现和踩坑记录完整拆出来给想在统一内存机器上同时跑多个模型的朋友做个参考。1. 项目背景为什么 128G 统一内存也需要一个管理器1.1 核心需求5 个模型同时在线先交代一下我这边要跑什么。我的场景是一个本地的多模型服务不同请求走不同模型对话主力是 70B 模型回答质量最高但最重轻量问答走 14B 模型响应快适合短问题图片理解走 7B 视觉模型文档入库和检索用 embedding 模型生成向量召回结果再用 reranker 重排一遍保证精度。这些模型的服务对象不同但都要求“在线”——也就是随时来请求都能立刻响应不能每次现场加载。70B 模型冷启动要十几秒谁敢让用户等十几秒所以必须让至少大部分模型常驻内存。问题来了5 个模型全部用 FP1670B 一个就要约 140GB直接超预算。就算量化70B 的 Q4 权重也要 40GB 左右加上 KV Cache、推理临时缓冲、系统本身的开销128G 并没有想象中那么宽裕。更重要的是不仅要“放得下”还要在多个模型同时被请求时保证每个都不卡顿、不互相拖垮——这就需要一个真正管事的调度层。1.2 统一内存架构的特点共享、动态、不透明我们说的统一内存通俗讲就是 CPU 和 GPU 共用同一块物理内存池不存在传统显卡那种“显存 24G、内存 64G”的割裂。好处很明显一个池子里的资源可以动态分配给任何模型不会出现显存不够但内存富余的尴尬。但这个池子也有让人头疼的一面。它太动态了系统自己会拿大量内存做文件缓存会压缩不活跃页面甚至会把冷数据换到磁盘。你盯着“空闲内存”看数字可能很小但实际上系统随时能挤出空间反过来内存压力一旦上来整个机器所有进程一起变慢。这种“不透明”让资源规划变得很麻烦你没法用传统方式精确控制“这块就是模型的那块是系统的”。所以要管好 5 个模型核心不是“抢内存”而是“在动态水位下做有纪律的分配”。这也是我写管理器的最初动机。1.3 设计目标不重启、不卡顿、可恢复我给这个管理器定了三个目标。第一不重启。任何模型的加载、卸载、替换都不能要求重启整个服务所有操作要在线完成。第二不卡顿。内存水位要始终控制在安全区宁可提前卸载冷模型也不能让系统进入 swap 地狱。所谓 swap 地狱就是内存不够时系统把内存页写到 SSD推理时再读回来——SSD 带宽和内存带宽差两个数量级一旦开始换页所有模型一起龟速。第三可恢复。模型进程被杀、管理器自己被系统清理都要能自动拉起来而不是整个服务瘫掉。这三个目标直接决定了架构选型下一节展开。2. 管理器怎么设计架构、预算与三个核心机制2.1 架构选型一主进程 每模型一子进程我最开始想省事在一个 Python 进程里用 transformers 把 5 个模型全 load 进来托管在同一个进程里。跑了一天后放弃了原因有三个。第一个是内存释放不干净。进程内 del 模型对象、跑 gc.collect()内存压力纹丝不动。框架内部的缓存、Metal/GPU 的 buffer、中间变量都不是你一句话能清掉的。第二个是崩溃连带。一个模型推理时报错如果不小心把异常炸到主进程5 个模型一起断开。第三个是版本冲突。5 个模型来自不同项目依赖的 transformers、tokenizer 版本不一样硬塞一个进程迟早出幺蛾子。最终采用的架构很简单主进程只管调度和转发每个模型跑在独立的子进程里子进程之间完全隔离。子进程的好处是卸载 kill 进程内核会回收它映射的所有内存页干净利落崩溃 只死一个模型主进程检测到后按需重新拉起。主进程用 FastAPI 暴露三个接口加载模型、卸载模型、查状态。请求转发则由各模型各自的本地服务端口承接主进程不碰推理本身。2.2 先把账算清楚五模型内存预算表写管理器之前我干的第一件事不是写代码而是做了一张内存预算表。这一步非常重要帮你把所有“隐形开销”提前暴露出来而不是等系统 OOM 了才后知后觉。模型量化位宽权重估算上下文配额KV/开销预留总预算chat-70b对话主力Q4_K_M约 40 GB4096约 12 GB52 GBchat-14b轻量问答Q5_K_S约 10 GB4096约 6 GB16 GBvision-7b图像理解Q4_K_M约 5 GB2048约 4 GB9 GBembed-0.5b向量化FP16约 1 GB0不留上下文约 0.5 GB1.5 GBrerank-0.4b重排FP16约 1 GB0约 0.5 GB1.5 GB合计 80GB 出头给系统留 15% 到 20% 的余量128G 的机器能装下但已经不算宽裕。注意 KV 那列很多人只算权重结果推理时上下文一长内存直接爆掉。后面坑四我会专门讲 KV Cache这里先记住一个原则预算表里必须给 KV 留位置。2.3 核心机制一内存压力监控讲完预算说第一个核心机制怎么判断当前到底能不能再加载一个模型。我犯过的错误是看 psutil 的 virtual_memory().free这在统一内存架构上非常不准。因为系统文件缓存会把 free 压得很低看起来“快满了”其实还有很多可回收空间反过来内存真正吃紧时free 这个值反而不一定难看因为系统已经在疯狂压缩和换页了。正确做法是看系统级的内存压力百分比。macOS 上可以直接读 memory_pressure 命令# memwatch.py —— 读取内存压力百分比0~100 import subprocess import re def read_pressure() - int: try: out subprocess.run( [memory_pressure, -Q], capture_outputTrue, textTrue, timeout2 ).stdout m re.search(rfree percentage:\s*(\d), out) return 100 - int(m.group(1)) if m else -1 except Exception: return -1Linux 上也简单读 /proc/meminfo 里 MemTotal 和 MemAvailable 就能算。关键是确定阈值我调试下来的经验值是这样70% 以下安全区随便加载70% 到 85%警戒区不再主动加载新模型只允许按需拉起85% 以上驱逐区立刻触发 LRU 卸载直到回到 75% 以下。这里我还做了个小优化对内存压力曲线做滑动窗口平均取最近 10 秒的均值做判断而不是用瞬时值。因为模型加载或推理的瞬时抖动很常见直接拿瞬时值触发驱逐会造成“刚加载就被杀”的抖动循环。2.4 核心机制二LRU 驱逐与冷启动补偿内存不够时驱逐谁我用 LRU最近最少使用但不能是朴素 LRU否则会出现颠簸一个模型刚被卸载下一个请求又把它拉起来来回折腾。我加了冷却时间同一个模型被驱逐后 5 分钟内不再作为候选。核心逻辑长这样# 带冷却时间的 LRU 驱逐 COOLDOWN 300 # 秒5 分钟内不重复驱逐同一模型 async def evict_lru() - str | None: victims [ name for name, proc in RUNNING.items() if time.time() - proc[last_used] COOLDOWN ] if not victims: return None victim min(victims, keylambda n: RUNNING[n][last_used]) kill_and_wait(victim) return victim另一个补偿机制是热备。70B 模型冷启动要 15 到 20 秒绝对不能频繁拉。我的做法是给它更长的闲置容忍时间比如 5 分钟没有请求才允许回收平时就算内存紧也优先杀 7B、14B 这些启动快的把 70B 留到最后。embedding 模型因为体积小且高频使用我干脆设成“永不驱逐”常驻保底。2.5 核心机制三请求级并发限流内存管理只是第一步5 个模型同时在线后新一轮问题变成了并发调度。统一内存里 CPU 和 GPU 共用带宽模型越多同时推理时互相抢带宽的情况越严重。所以我在管理器里加了一层请求闸门embedding 和 reranker 这类轻量模型不限并发LLM 类模型全局最多同时推理 2 个同一个模型实例最多并发 1 个。超出部分的请求进队列等待。这层闸门不是为了避免 CPU 过载而是为了保护内存带宽。带宽一旦被抢满所有模型的 token 生成速度一起下降拖累的是全局体验。具体表现和数据我在坑三里细说。3. 七个坑逐个复盘3.1 坑一空闲内存是“移动靶”别用 free 判断可用空间我第一个坑就是栽在看内存的方式上。最初管理器接的是 psutil 的 free 值结果连续出现同一个诡异现象明明显示还有 30GB 空闲加载一个 20GB 的模型却直接把整机拖到卡顿。原因就是前面说的统一内存架构下系统文件缓存会大量占据内存free 值偏低是常态这部分其实是可以随时回收的但反过来当系统开始压缩内存页或写 swap 时free 值又看似还好真实压力已经很高了。我盯着一个“假指标”做了几天的错误决策。症状很直观内存压力到 80% 以上时模型生成速度从 45 tok/s 掉到 8 tok/sCPU 占用飙到 60% 以上——因为系统忙着压缩和解压内存页根本没空专心推理。之后我彻底放弃 free改读内存压力百分比并且把加载的硬水位压在 75%才终于稳定。提示统一内存机器上看内存只看“内存压力”这个综合指标别单看 free 或 used。3.2 坑二加载模型时的瞬时峰值内存接近稳态的两倍第二个坑出现在加载过程这也是我早期最想摔键盘的一次。70B 的 Q4_K_M 权重约 40GB稳态占用 40GB 没什么好怕的。但加载不是“把文件塞进内存”这么简单它包含读磁盘 → 反序列化 → 转精度 → 建计算图 → 分配 KV Cache每一步都可能产生中间拷贝。我第一次用 transformers safetensors 加载 70B峰值直接冲到了 80GB 以上系统开始疯狂写 swap加载过程变成将近二十分钟的卡顿地狱。后来换成 GGUF mmap 方案情况立刻不一样模型文件通过内存映射进入地址空间按页按需读入不需要一次性把整个文件复制到内存加载峰值降到 45GB 上下启动时间反而更快。用 llama.cpp 系的服务llama-server 或 Ollama 后端加载 GGUF 就是走这条路径。这个坑给我的教训是三条大模型必须走内存映射加载两个大模型不要同时“热加载”加载过程的峰值内存要提前在预算表里留足。3.3 坑三统一内存带宽共享多模型并发互相拖慢内存压力倒是管住了但第三个坑等在了推理阶段。统一内存的带宽是共享的听起来很富余——比如我这台机器标称 800GB/s 左右——但模型推理本质上是“把权重从头到尾读一遍”的活带宽消耗和模型总量直接成正比。多个模型同时推理时带宽被瓜分每个模型都会变慢。实测数据很直观单个 7B 模型满载推理时约 45 tok/s两个 7B 模型同时满载单个速度掉到 28 tok/s如果此时第三个模型也来凑热闹整体会进一步恶化。这还没算内存换页一旦触发 swapSSD 带宽和内存带宽差两个数量级那就不叫慢了叫“冻住”。解法就是 2.5 节说的并发闸门。设计上我把模型分两类embedding 和 reranker 这类单次推理极短、权重读取量小的模型对带宽影响微乎其微可以放开并发真正的 LLM 推理要严格限流全局并发控制在 2 以内。3.4 坑四KV Cache 是隐形占用上下文越长越失控第四个坑最隐蔽也是很多人跑多模型时翻车最深的地方KV Cache。权重大小是显性占用一目了然KV Cache 则不同它跟上下文长度成正比推理时一路膨胀。公式是这样的KV Cache 大小 2 × 层数 × 注意力头数 × 每头维度 × 精度字节数 × 序列长度以 70B 模型为例约 80 层、64 个头、每头 128 维、FP16 精度每增加 1 个 tokenKV Cache 就增加约 2.6MB。4096 上下文就是 10.7GB——这几乎是模型权重的四分之一了。5 个模型都开 4K 上下文光 KV 部分就要 20GB 以上而且是推理时必须真实占据内存的不能懒加载。我的处理方式是按角色区分上下文配额对话模型给 4096视觉模型给 2048embedding 和 reranker 根本不留上下文用完即弃。另外长对话做滚动截断超过配额就把最老的轮次压缩成摘要而不是无限吞 token。提示给模型分配内存时别忘了 KV Cache 这个“随上下文增长”的变量预算表里必须单独留一列。3.5 坑五动态卸载的“惯性”内存不还、进程悬挂第五个坑来自卸载也就是我早期“进程内管理模型”埋下的雷。当时我用 del model gc.collect() 清理模型结果 RSS 根本降不下去。框架内部的缓存、GPU/Metal 的计算 buffer、算子库的常量池全都不受 Python 垃圾回收控制。页面看起来“模型已卸载”内存压力却纹丝不动。后来我彻底放弃进程内卸载改成子进程架构后杀进程确实干净但新问题出现了杀掉模型进程后容器的端口释放需要时间如果立刻拉起一个新实例可能报“端口被占用”偶尔还会遇到杀完的子进程变僵尸不回收占着 PID。我的处理是两步kill 后轮询等待进程真正退出超过 5 秒直接 kill -9端口绑定改成可配置范围拉起新实例时跳过仍在 TIME_WAIT 的端口。此外卸载后留下 30 秒的“软状态”期间来的请求走排队而不是立刻触发重新加载。3.6 坑六量化混用导致精度漂移与兼容性爆炸第六个坑是量化策略不一致造成的非常典型5 个模型来自不同项目惯用的量化格式五花八门。一开始我没统一chat-70b 用 Q4_K_Mchat-14b 用 Q5_K_Sembedding 直接 FP16。表面看都加载得很好实际用起来问题一堆。最明显的是 embedding 和 reranker它们属于精度敏感模型一旦打上 int4召回率肉眼可见地掉检索结果直接变差。另一个问题是量化版本兼容性。GGUF 的量化方式在不同 llama.cpp 版本之间有差异旧格式新加载器可能会崩。我遇到过最头疼的一次模型加载到一半报“unsupported quantization type”查了半天是模型文件是在旧版工具下量化的而推理服务用了新版内核两者不认账。我的统一策略很简单生成类模型统一 Q4_K_M 或 Q5_K_Sembedding/reranker 一律 FP16llama.cpp 相关组件版本固定在一个 tag 上不追新。模型文件的版本管理也顺手做了目录化——这个我放到最后的小习惯里讲。3.7 坑七管理器自己被拖死全家桶一起归西最后一个坑也是最讽刺的管理器成了系统里最容易被牺牲的那个进程。我刚开始把监控做得很“豪华”每秒轮询一次系统内存、逐进程计算 RSS、写详细日志、再把指标写入时序库。结果管理器进程自身吃掉几百 MB 内存不说频繁扫进程表还引发放大开销。更要命的是当内存真正吃紧系统更倾向优先杀掉“看起来不务正业”的管理器而不是正在推理的模型进程。管理器一死所有调度逻辑消失5 个模型变成孤儿进程服务全线瘫痪。后来我把管理器改成极简看门狗模式只做三件事——轮询内存压力间隔 10 秒、处理加载卸载请求、转发状态查询其他一律不干。日志只记加载/卸载事件和推理耗时不记录每秒 token 数这种高频指标。再配一个外层守护启动脚本检测主进程退出5 秒内重新拉起。我再加了一个滑动窗口对内存压力做 10 秒平均再触发动作避免瞬时抖动导致“刚卸载又加载”的颠簸。这一套改完管理器本身的占用降到 100MB 以内稳定性才真正上来。4. 常见问题速查与排查实录4.1 快速定位内存去向的命令清单踩完坑之后我整理了平时的排查命令遇到问题先跑一轮基本能定位 80% 的内存异常。# macOS memory_pressure -Q # 看系统内存压力百分比 vm_stat # 看 page 统计压缩、换页情况 footprint pid # 看某个进程的真实内存占用 ps -eo pid,rss,comm | sort -k2 -rn # 按 RSS 排序找大头进程 # Linux如果你的统一内存机器是 Linux 工作站/服务器 free -h cat /proc/meminfo # 注意 MemAvailable 而不是 MemFree /usr/bin/time -v command # 测某个启动命令的最大 RSS重点看两个信号一是内存压力百分比二是 Swap 使用是否在增长。如果压力不高但 Swap 在涨通常是瞬时峰值内存导致如果压力一直高位但 Swap 没涨说明系统全靠压缩硬撑CPU 会被拖累一样要处理。4.2 七个坑的“一句话版”速查表坑位典型症状一句话解法坑一看 free 很充足加载后整机卡顿改看内存压力百分比加载水位压在 75% 以内坑二加载大模型时卡死或 OOM用 GGUF 内存映射加载错开两个大模型的加载时机坑三多模型同时推理速度集体下降加全局并发闸门LLM 全局并发不超过 2坑四上下文越长内存迅速膨胀按角色限制上下文配额KV 在预算表单独列一行坑五卸载后内存不降、端口不释放子进程隔离kill 后轮询退出跳过 TIME_WAIT 端口坑六检索精度下降、模型加载报错精度敏感模型 FP16生成模型统一量化版本固定坑七管理器挂了所有模型失联管理器极简化外层看门狗自动拉起4.3 给打算照做的朋友的安全起步配置如果你也想在统一内存机器上跑多模型这是我认为最稳妥的起步配置内存水位75% 触发自动卸载85% 强制卸载95% 只保 embedding闲置回收大模型 5 分钟无请求回收中等模型 3 分钟embedding 永不驱逐启动顺序先小后大embedding 常驻70B 这类大家伙最后加载并发控制LLM 全局 2单实例 1embedding/reranker 不限日志策略只记加载/卸载事件、请求耗时、驱逐原因不记高频指标。这套参数我用了两周多没有再崩过你可以拿它当默认值再根据自己的模型体积微调。核心思路是宁可多驱逐几次轻量模型也别让大模型反复横跳。5. 复盘之外给你留的落地建议5.1 照着这套配置起步的参考模板如果现在让我从零再搭一次我会按这个顺序干活先填内存预算表再定子进程隔离架构然后才写调度逻辑。预算表是第一步把每个模型的权重、KV、峰值加载临时开销都列出来你才知道 128G 到底能放几个、哪些必须常驻、哪些按需拉起。没有这张表后续所有调度策略都是拍脑袋。子进程隔离是第二步这决定了你后面卸载、崩溃恢复的复杂度是“简单”还是“地狱”。调度算法的优先级反而被我排到最后。理由很简单模型不多时朴素的“冷却 LRU 请求队列”已经足够真正让你翻车的往往是基础架构没搭对而不是调度算法不够高级。5.2 最后分享两个实用小习惯第一个习惯是模型文件版本化。我用的是weights/模型名/量化位宽/日期这样的目录结构每次重新量化或升级基础模型都放到新目录配置里显式指路径。这个习惯帮我躲过了坑六里一大半的兼容性问题——模型文件乱放、新旧混用是量化事故的高发源头。第二个习惯是给驱逐动作打日志。每次自动卸载都要记下原因、目标模型、当时的内存压力以及后续是否有人请求这个模型。如果日志显示某个模型频繁被“卸载后立刻请求”说明你的冷启动时间预估过松或内存预算过紧需要调整配额而不是继续加大冷却时间。这个反馈循环比任何监控面板都管用。现在这套管理器在我机器上跑了两周多5 个模型常驻 3 个、按需拉起 2 个没再出过崩的情况。回头看看坑大部分不是深不可测的算法问题而是几个基础决定没做对看错指标、选错架构、算了账。个人最想分享的心得就是一句别迷信那一排花哨的调度策略先把“什么时候加载、什么时候卸载、最多同时跑几个推理”这三个问题想清楚你的 128G 就已经够用了。