我最近接了个多模态推理任务,一口气要同时跑五个模型:一个对话用的LLM、一个图像分类的ViT、一个语音识别、一个embedding向量化,外加一个RAG检索模型。手头显卡显存只有12G,根本装不下,逼得我借了一台128G统一内存的机器。结果发现,光有内存根本不够用,五个模型同时加载,内存直接爆炸,来回卸载几次之后上下文全丢了,进程还互相打架。最后实在受不了,自己写了一个模型管理器来统一调度,过程中踩了整整七个坑。这篇文章就把这些坑和背后的内存管理、进程调度、上下文保持的细节全部记录成文,希望后来者能少走弯路。
1. 项目整体设计与思路拆解
1.1 为什么需要同时管五个模型?
先说背景。我做的这个需求是典型的"多模型协同"任务:用户发来一段音频,我先用语音识别转成文字,然后丢给LLM分析意图,再从知识库里做embedding检索,最后把检索结果和上下文一起交给LLM生成答案。中途还有图像识别模型介入。这五个模型如果按顺序串行跑,延迟高到没法用;如果全部常驻内存,又会被资源限制卡死。
传统方案是买一块大显存卡,比如A100 80G,但成本太高而且难买。统一内存的设备就不一样,比如某些带128G统一内存的工作站,CPU和GPU共享同一块物理内存,模型权重可以直接放进内存,按需加载到计算单元执行。理论上有128G,五个平均7B参数的模型,用FP16实数大约14G一个,五个就是70G,加上context和中间计算,勉强够住。但如果全用FP16,内存余量很小,稍微开个长上下文的对话就爆。
所以我当时的第一反应是:不能一股脑全加载,得有个东西来控制每个模型的生命周期。有人会问,为什么不用现成的框架?我试过,大多都是"加载就常驻"或者"手动一个个启动",没有针对"多模型统一内存"这个场景做精细调度。有的工具能管单个模型,但想跨模型做资源仲裁、上下文持久化、自动换入换出,就力不从心了。于是决定自己写。
1.2 管理器到底该管什么?
一个模型管理器,表面上是"启动/停止模型",但真正难的是透明管理内存和状态。我拆解成四件事:
- 模型生命周期:何时加载、何时卸载,按需加载还是常驻。
- 上下文持久化:LLM对话历史、embedding索引等状态,不能因为卸载就丢失。
- 内存水位监控:实时统计每个模型占了多少内存,总内存剩余多少,接近阈值时自动驱逐低优先级模型。
- 进程间通信:管理器向模型发指令,模型返回结果,必须异步、带超时,不能因为模型推理慢就卡死整个系统。
整个架构我用了一个Python守护进程作为中心调度器,每个模型跑在独立的子进程里,调度器和每个模型进程之间通过HTTP端点通信。选择子进程而不是线程,是为了隔离内存:某个模型崩溃了,不会牵连其他模型。通信用HTTP是因为简单直观,每个模型内置一个轻量服务,接收加载、推理、卸载的指令。
1.3 为什么统一内存下更需要"主动管理"?
统一内存的好处是弹性,但坏处也是弹性。你一旦多个进程同时申请内存,系统不会替你安排先后优先级,只会OOM(内存溢出)或者频繁swap。管理器的核心就是做"资源仲裁",本质上和操作系统的内存管理是一样的逻辑,只不过粒度从"页面"变成了"模型"。
我用八个字概括设计原则:按需加载,优先级换出。高优先级的LLM常驻,因为它每轮对话都要用到;embedding模型虽然用得多但可以接受毫秒级唤醒,所以用惰性加载;语音识别和图像识别是低频任务,用完立刻卸载。这样把内存占用峰值压下来,五个模型才能在一台128G机器上平稳共存。
2. 统一内存与模型加载的核心细节
2.1 统一内存和传统显存到底差在哪?
很多人对统一内存的认知还停留在"内存大就是好",其实忽略了带宽和分配策略的差别。传统显卡是独立显存,容量小但带宽极高,比如RTX 4090有24G显存,带宽超过1TB/s。统一内存是把一部分物理内存共享给CPU和GPU,带宽取决于内存类型,通常低于独立显存,但容量大得多。
这意味着,在统一内存上跑模型,模型权重是放在内存里的,计算时按需搬到计算单元。如果内存总带宽是800GB/s,一个7B模型即使只有4G量化大小,也要花十几毫秒才能完整搬到GPU核心里。更关键的是,如果五个模型同时读取权重,带宽会互相抢,导致每个模型都变慢。
所以我当时给自己定了个规矩:同一时间只有一个"大模型"在做推理,其他模型只保持权重在内存里,不参与计算。这样至少保证推理峰值带宽够用。管理器里我加了一个"计算锁"——整个系统同一时刻只允许一个GPU绑定的模型执行,多出来的请求排队。实测下来这个机制最有效,既保住了响应速度,又避免带宽竞争。
2.2 内存分配策略:常驻、惰性加载与换出
光有锁还不够,还得设计什么时候加载、卸载。我参考操作系统的页面换出算法,做了一套简化版:
- 高优先级(常驻):LLM主模型。它占最大内存,而且频繁推理,不能卸载。我干脆在启动管理器时预加载,把权重锁在内存里。
- 中优先级(惰性加载):embedding模型。它轻量,只有几百MB,但调用频繁。第一次调用时加载,之后常驻。
- 低优先级(用完即走):语音识别、图像分类、RAG检索模型。这些是任务触发式使用,每次推理完直接卸载,释放给系统。
但这个策略有个坑,我一会儿会细说:卸载模型后会丢掉上下文,所以我在卸载前必须把模型内部状态序列化到磁盘。当时我为了实现这一步,给每个模型写了一个save_state和load_state接口,在卸载时调用,加载时再恢复,算是整个管理器最核心的代码。
2.3 隐藏的内存杀手:context长度和推理引擎开销
权重占用只是冰山一角。推理过程中真正吃内存的是KV cache,也就是LLM的注意力缓存。一个7B模型,即使权重只有4G,如果context长到32K,KV cache可以轻松多占好几G。五个模型如果各自开了很长的context,内存总占用会远超权重总和。
我建议每个模型在管理器里都有一个独立的"上下文预算"。LLM只给8K context,给到32K就是灾难。embedding模型没有这个负担,可以随意。语音和图像模型推理时临时分配,推理完释放。管理器在启动时读一个配置文件,限制每个模型的max_context_length,算好内存上限,超过就拒绝请求,而不是让系统去OOM。
3. 管理器实操实现过程
3.1 整体架构的落地细节
我用的技术栈很简单:Python 3.11 + psutil + FastAPI。psutil负责监控每个进程的内存和CPU,FastAPI给每个模型起一个HTTP服务。调度器本身也是一个FastAPI服务,对外暴露统一接口,比如/run/llm、/run/embedding。
每个模型进程启动时传入PID和一个端口号,调度器把端口号登记在内存表里。进程内部实现一个标准接口:/health、/infer、/load_state、/save_state。调度器和模型之间所有请求都加超时,超时就标记为"不可用",自动重启。
这样一个简单的架构就是一个小型"模型管理平台",只是没有UI,全用API。我自己主要用命令行来调试。
3.2 核心代码:模型进程控制
下面是一段简化版的关键代码,展示如何启动和停止一个子进程:
import psutil import subprocess class ModelRunner: def __init__(self, model_name, command, port): self.model_name = model_name self.command = command self.port = port self.proc = None def start(self): self.proc = subprocess.Popen( self.command + f" --port {self.port}", shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) self.wait_ready() def stop(self): if self.proc and self.proc.poll() is None: self.proc.terminate() try: self.proc.wait(timeout=5) except subprocess.TimeoutExpired: self.proc.kill() def memory_usage_mb(self): if self.proc: return psutil.Process(self.proc.pid).memory_info().rss / 1024 / 1024 return 0这里有个小技巧:terminate发SIGTERM,kill发SIGKILL。正常情况下先发SIGTERM让模型有机会保存状态,5秒内没退出再强制杀。如果不这样,模型进程会留下僵尸进程,内存不释放。
3.3 调度器怎么排优先级?
调度器维护一个优先级队列。每当收到推理请求,先查对应模型是否在内存里,如果在就直接转发;如果不在,看内存剩余量够不够,不够就释放低优先级模型,腾出空间再启动。这个逻辑用伪代码表示是:
def ensure_loaded(model_executor): while can_launch(model_executor) == False: victim = find_lowest_priority_loaded_model() if victim is None: raise OOM unload(victim) model_executor.start()这里can_launch会估算模型需要的内存,清单里每个模型都标注了max_memory_estimate。这个估算值必须比实际略大,否则一次加载就爆。我最后是根据实测数据填的,而不是只看权重大小。
3.4 基准内存测试结果
我把五个模型跑了一遍,记录实际占用情况:
| 模型 | 参数规模 | 量化版本 | 内存峰值 |
|---|---|---|---|
| LLM主模型 | 7B | int8量化 | 约7.4GB |
| embedding | 0.4B | FP16 | 约1.1GB |
| 语音识别 | 1.5B | int8 | 约2.8GB |
| 图像分类 | 0.3B | FP16 | 约1.2GB |
| RAG检索 | 0.6B | FP16 | 约1.6GB |
总计常驻权重约14.1GB,加上每个模型的运行开销,峰值时总占用拉到40GB左右,还有很大余量。这说明五个模型同时常驻是行得通的,但前提是正确管理KV cache和临时buffer。如果不做预算,开满context全都可能爆。
4. 七个坑的完整实录
4.1 坑一:模型加载后内存直接爆掉
现象:一开始我用FP16权重直接加载,加了两个模型就提示内存不足,整个系统卡死,连终端都敲不动。
原因:两个层面。一是权重本身就大,7B的FP16有14GB,五个加起来70GB,看起来128G够,但系统还要留内存给文件缓存和进程开销,实际可用可能只有110G;二是模型加载时,加载工具会先把权重从磁盘复制到内存,同时为推理临时开辟buffer,瞬时峰值可能翻倍。
解决:全部改成int8或4bit量化,权重直接减半或减到四分之一。我自己是LLM用int8,语音用int8,其他小模型保持FP16。更重要的是,加载模型前先算当前进程的总内存和模型估算值,如果加在一起超过物理内存的90%,直接拒绝加载,宁可返回错误也不拖垮系统。
提示:任何模型管理器都必须有"内存预算"这个机制,否则再大的内存都白搭。
4.2 坑二:上下文被清空,对话续不上
现象:LLM跑得好好的,为了加载embedding模型我把它卸载了,等LLM再启动,之前的对话历史全丢了。
原因:LLM的上下文都保存在进程内存里,卸载进程就意味着内存释放。我当时压根没做状态保存,以为模型权重在磁盘,重新加载回来自然就恢复,显然太天真。
解决:给每个模型写一个save_state接口,在卸载前把对话历史、kv cache(可选)序列化到磁盘。加载时读回。KV cache恢复不能一概而论,因为不同模型的cache结构不同,所以我做了两个版本:一个序列化对话历史,重开后重新传输history,缺点是慢;另一个直接序列化cache,快但只对同一模型同一量化版本有效。我最后选了"历史+重新计算"方式,因为更通用。
4.3 坑三:进程间通信卡死
现象:调度器发一个推理请求给模型,半天没响应,然后整个系统的其他请求也全部排队,像死锁了一样。
原因:模型推理本身可能耗时几十秒甚至几分钟,调度器的HTTP请求是同步等待的,模型进程处理请求时本身是单线程,新请求没法处理,两边互相等待,调度器也就卡住了。
解决:所有通信改成异步。用FastAPI自身支持异步接口,加上asyncio.timeout实现超时控制。超时后立即标记模型为"busy",把请求返回给用户,而不是一直等。后来我还给每个模型加了独立的请求队列,队列满就直接丢弃并返回错误。
4.4 坑四:内存碎片和swap频繁
现象:明明内存总量还剩不少,但系统变得特别慢,跑模型像是开了虚拟机一样。
原因:反复加载卸载模型,内存是一块块申请的,容易产生碎片。更重要的是,内存碎片导致系统频繁把不活跃页面换到磁盘做swap,统一内存机器的swap操作非常慢,每次都几GB的来回,性能彻底崩掉。
解决:两个措施。一是管理器维护固定的"临时卸载区",同一时间最多卸载一个模型,减少碎片;二是监控swap用量,一旦连续几秒swap量超过阈值,调度器主动清理低优先级模型,而非等内存不够才动作。实测下来,swappiness参数也可以调低,比如写到/proc/sys/vm/swappiness,但这不是我主推的做法。
4.5 坑五:模型格式生态不兼容
现象:不同模型用了不同推理引擎。LLM用的是llama.cpp生成的GGUF格式,embedding模型用PyTorch原生权重,语音识别用的另一种格式。调度器必须适配多种加载逻辑,代码瞬间膨胀,而且某个引擎如果升级了格式,整个管理器就要跟着改。
原因:工业界没有一个统一"模型文件格式",每个框架都有自己的保存方式。
解决:我写了一个"模型抽象层",把所有格式的加载、推理、保存封装成统一接口。接口后面是具体实现,比如GGUF模型走LlamaAdapter,PyTorch模型走Transformers。管理器只跟抽象层打交道。这个开销不小,但值得,后续加新模型都不用改调度逻辑。
4.6 坑六:CPU和GPU调度互相拖累
现象:把LLM绑在GPU上跑,同时把语音识别丢到CPU,结果两边都变慢,GPU利用率上不去,CPU利用率也低。
原因:统一内存下CPU和GPU共享同一个内存带宽。CPU密集访问内存时会抢占带宽,GPU推理也在抢带宽,互相干扰。再加上进程绑核没设置好,模型在核心间乱跳,缓存不命中,性能进一步恶化。
解决:我给每个模型都定义了device字段和CPU亲和性。指定GPU的模型,用CUDA_VISIBLE_DEVICES锁定GPU;指定CPU的模型,用taskset绑定到特定核心数。最重要的是,把计算锁机制做严:同一时间只能有一个"重模型"在推理,其他模型即使常驻,也在等待状态。这样带宽冲突被大幅缓解。
4.7 坑七:管理器崩溃后留下满地的僵尸进程
现象:有一次调度器自己因为一个异常崩溃了,等我重新启动时,发现之前启动的几个模型进程还活着,占着几百GB内存,又得一个个手动kill。
原因:管理器用subprocess.Popen启动子进程,如果父进程突然退出,子进程不会自动退出,会变成孤儿进程继续存在。
解决:最简单的办法是在启动管理器时先清理旧进程。我在调度器启动入口加了一个"进程清扫"函数:读取一个记录PID的文件,逐个检查是否存活,是就杀掉。同时,每个模型进程启动时都保存PID到文件,卸载时删除。进一步,我还加了文件锁,防止两个调度器同时管理同一批进程。
5. 避坑工具箱与经验总结
5.1 实用工具清单
如果你也要做类似的多模型管理,我建议这几个工具先备好:
psutil:Python库,监控内存、CPU、进程状态,比直接解析/proc好用多了。nvidia-smi+sysctl:检查GPU使用率和内存情况。在统一内存机器上,我主要是看free -g和vmstat。taskset:绑定进程到特定CPU核心,减少调度抖动。uvloop:如果你用Python写异步通信,把事件循环换成uvloop,性能提升不少。vector = 模型推理超时:每个模型都要设置自己的超时时间,LLM给60秒,embedding给5秒,语音给30秒,不能一刀切。
5.2 初始化配置参考
我的配置文件长这样,可以抄作业:
models: llm: command: "python run_llm.py" port: 8001 max_context_length: 4096 estimated_memory_bytes: 8GB priority: high device: gpu embedding: command: "python run_embedding.py" port: 8002 estimated_memory_bytes: 1.5GB priority: medium device: cpu asr: command: "python run_asr.py" port: 8003 estimated_memory_bytes: 3GB priority: low device: cpu这里的estimated_memory_bytes要和基准测试对齐,宁高勿低。我一开始按权重大小填,结果吃过大亏,后来改成实测值加20%缓冲。
5.3 个人体会
踩完这七个坑,我的感觉是:统一内存机器给了你"装得下"的信心,但真正难点在于"管得住"。128G听着大,但多个模型加上上下文、临时buffer,分分钟可以把它吃干净。一个负责任的模型管理器,一定要有内存预算、状态持久化、异步通信、进程清理这四板斧,缺一个都会出大事。
如果只是偶尔跑两个模型,用现成的工具确实方便。但只要模型数量上到三个以上,或者你有跨模型协同的需求,手写一个调度器是值得的。我这套管理器大概写了五百行代码,不算多,但它能让我对每一个进程的生死都有办法,再也不用担心半夜模型挂了还占着内存。
最后再分享一个小技巧:调试的时候,记得给每个模型进程写详细的日志文件,包括每次加载、推理、卸载的耗时和内存变化。很多坑都是靠日志才定位到的,尤其是内存碎片和swap问题,没有日志你根本猜不到原因。