我在一台 128G 统一内存的机器上同时管 5 个模型:一个 70B 的对话主力、一个 14B 的轻量问答、一个 7B 视觉理解模型,外加一个 embedding 和一个 reranker。
刚开始我挺乐观的——128G 嘛,就算 70B 原始权重接近 140GB,量化一下也完全放得下。可真到落地时才发现,“塞下”和“管好”完全是两码事。模型之间的内存竞争、推理请求并发、热加载热卸载、量化精度不一致,任何一个环节没处理好,整台机器要么慢成幻灯片,要么直接被系统杀掉进程。
最后我写了一个模型管理器来处理整套流程:统一注册模型、动态加载卸载、按内存水位自动驱逐、给推理请求加闸门。前后踩了七个坑,有几个查了一整天才定位。这篇就把设计思路、关键实现和踩坑记录完整拆出来,给想在统一内存机器上同时跑多个模型的朋友做个参考。
1. 项目背景:为什么 128G 统一内存也需要一个管理器
1.1 核心需求:5 个模型同时在线
先交代一下我这边要跑什么。我的场景是一个本地的多模型服务,不同请求走不同模型:
- 对话主力是 70B 模型,回答质量最高,但最重;
- 轻量问答走 14B 模型,响应快,适合短问题;
- 图片理解走 7B 视觉模型;
- 文档入库和检索用 embedding 模型生成向量;
- 召回结果再用 reranker 重排一遍,保证精度。
这些模型的服务对象不同,但都要求“在线”——也就是随时来请求都能立刻响应,不能每次现场加载。70B 模型冷启动要十几秒,谁敢让用户等十几秒?所以必须让至少大部分模型常驻内存。
问题来了:5 个模型全部用 FP16,70B 一个就要约 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 GB | 4096 | 约 12 GB | 52 GB |
| chat-14b(轻量问答) | Q5_K_S | 约 10 GB | 4096 | 约 6 GB | 16 GB |
| vision-7b(图像理解) | Q4_K_M | 约 5 GB | 2048 | 约 4 GB | 9 GB |
| embed-0.5b(向量化) | FP16 | 约 1 GB | 0(不留上下文) | 约 0.5 GB | 1.5 GB |
| rerank-0.4b(重排) | FP16 | 约 1 GB | 0 | 约 0.5 GB | 1.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_output=True, text=True, timeout=2 ).stdout m = re.search(r"free 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, key=lambda 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/s,CPU 占用飙到 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;如果此时第三个模型也来凑热闹,整体会进一步恶化。这还没算内存换页,一旦触发 swap,SSD 带宽和内存带宽差两个数量级,那就不叫慢了,叫“冻住”。
解法就是 2.5 节说的并发闸门。设计上我把模型分两类:embedding 和 reranker 这类单次推理极短、权重读取量小的模型,对带宽影响微乎其微,可以放开并发;真正的 LLM 推理要严格限流,全局并发控制在 2 以内。
3.4 坑四:KV Cache 是隐形占用,上下文越长越失控
第四个坑最隐蔽,也是很多人跑多模型时翻车最深的地方:KV Cache。
权重大小是显性占用,一目了然;KV Cache 则不同,它跟上下文长度成正比,推理时一路膨胀。公式是这样的:
KV Cache 大小 = 2 × 层数 × 注意力头数 × 每头维度 × 精度字节数 × 序列长度
以 70B 模型为例,约 80 层、64 个头、每头 128 维、FP16 精度,每增加 1 个 token,KV Cache 就增加约 2.6MB。4096 上下文就是 10.7GB——这几乎是模型权重的四分之一了。5 个模型都开 4K 上下文,光 KV 部分就要 20GB 以上,而且是推理时必须真实占据内存的,不能懒加载。
我的处理方式是按角色区分上下文配额:对话模型给 4096,视觉模型给 2048,embedding 和 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_M,chat-14b 用 Q5_K_S,embedding 直接 FP16。表面看都加载得很好,实际用起来问题一堆。最明显的是 embedding 和 reranker,它们属于精度敏感模型,一旦打上 int4,召回率肉眼可见地掉,检索结果直接变差。
另一个问题是量化版本兼容性。GGUF 的量化方式在不同 llama.cpp 版本之间有差异,旧格式新加载器可能会崩。我遇到过最头疼的一次:模型加载到一半报“unsupported quantization type”,查了半天是模型文件是在旧版工具下量化的,而推理服务用了新版内核,两者不认账。
我的统一策略很简单:生成类模型统一 Q4_K_M 或 Q5_K_S;embedding/reranker 一律 FP16;llama.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,单实例 1,embedding/reranker 不限;
- 日志策略:只记加载/卸载事件、请求耗时、驱逐原因,不记高频指标。
这套参数我用了两周多没有再崩过,你可以拿它当默认值,再根据自己的模型体积微调。核心思路是:宁可多驱逐几次轻量模型,也别让大模型反复横跳。
5. 复盘之外:给你留的落地建议
5.1 照着这套配置起步的参考模板
如果现在让我从零再搭一次,我会按这个顺序干活:先填内存预算表,再定子进程隔离架构,然后才写调度逻辑。
预算表是第一步,把每个模型的权重、KV、峰值加载临时开销都列出来,你才知道 128G 到底能放几个、哪些必须常驻、哪些按需拉起。没有这张表,后续所有调度策略都是拍脑袋。子进程隔离是第二步,这决定了你后面卸载、崩溃恢复的复杂度是“简单”还是“地狱”。
调度算法的优先级反而被我排到最后。理由很简单:模型不多时,朴素的“冷却 LRU + 请求队列”已经足够,真正让你翻车的往往是基础架构没搭对,而不是调度算法不够高级。
5.2 最后分享两个实用小习惯
第一个习惯是模型文件版本化。我用的是weights/模型名/量化位宽/日期这样的目录结构,每次重新量化或升级基础模型都放到新目录,配置里显式指路径。这个习惯帮我躲过了坑六里一大半的兼容性问题——模型文件乱放、新旧混用,是量化事故的高发源头。
第二个习惯是给驱逐动作打日志。每次自动卸载都要记下原因、目标模型、当时的内存压力,以及后续是否有人请求这个模型。如果日志显示某个模型频繁被“卸载后立刻请求”,说明你的冷启动时间预估过松或内存预算过紧,需要调整配额,而不是继续加大冷却时间。这个反馈循环,比任何监控面板都管用。
现在这套管理器在我机器上跑了两周多,5 个模型常驻 3 个、按需拉起 2 个,没再出过崩的情况。回头看看,坑大部分不是深不可测的算法问题,而是几个基础决定没做对:看错指标、选错架构、算了账。个人最想分享的心得就是一句:别迷信那一排花哨的调度策略,先把“什么时候加载、什么时候卸载、最多同时跑几个推理”这三个问题想清楚,你的 128G 就已经够用了。