前阵子线上推理集群遇到一个特别拧巴的事:白天高峰扛不住,半夜闲得发慌,但GPU是月租的,不用也照样计费。想着把空闲节点缩掉,结果模型冷启动一次要拉权重、建CUDA graph、预热KV cache,恢复服务得好几分钟,缩完再扩回来,用户早跑了。后来换了vLLM的Sleep模式,才算是把这个结解开了——它不是杀进程,而是让推理引擎“休眠”,把权重和KV cache从显存里挪走,显存直接交还给系统,等请求来了再“唤醒”,整个周期缩短了一个数量级。这篇文章就把我在真实环境里跑Sleep模式的完整过程写出来,包括怎么开、怎么调、能省多少显存,以及那些文档里没写的坑。
这套东西适合谁?手上有GPU推理服务,但流量有明显的潮汐特征,或者同一批卡还要兼顾训练任务,想提高卡利用率的人。如果你只是单机自己玩,一卡一个模型,Sleep模式意义不大;但只要上了多副本、多租户、混合负载,这个功能基本是刚需。
1. Sleep模式到底做了什么
1.1 一个显存管理的真实痛点
在说Sleep之前,先聊聊没有它的时候我们是怎么处理“空闲显存”的。
常规做法是直接删掉工作负载。vLLM是常驻服务,删掉就彻底释放显存,但这意味着下次要用得重新拉起整套环境:读配置、初始化tokenizer、加载权重到GPU、做CUDA graph捕获、预热采样参数……我实测一个7B量级的稠密模型,冷启动基本在3到5分钟;如果模型再大点,比如70B级别加上多卡张量并行,十分钟以上是常态。
另一个做法是保留进程但限制并发,比如把--max-num-seqs调低、把--gpu-memory-utilization调低,腾出一些显存给别的任务。但这只是把显存“预留量”降了,进程占住的CUDA context和缓存池还在,释放不彻底,能腾出来的比例很有限。
Sleep模式走的是第三条路:进程和服务的完整状态都保留,但把GPU上的“重资产”腾空——模型权重、KV cache、CUDA graph、采样器状态,统统从显存清掉,该落回CPU内存的落回CPU内存。引擎还在,地址还在,路由还在,显存没了。下一个请求进来,再花短时间把所有状态装回去,继续服务。
这个思路有点像电脑的“睡眠”而不是“关机”。关机的代价是重启后所有东西要从头拉,睡眠的代价只是把内存里的数据倒腾一遍。
1.2 Sleep前后的显存占用实测对比
我在一台双卡机器上做过一次粗测,供参考。卡是常见的80GB显存级别的GPU,跑的是一个7B稠密模型,FP16权重大约14GB,KV cache上限设得比较宽,单卡总占用在60GB上下浮动。
Sleep之前的nvidia-smi大概是这个画风:
code复制GPU 0: 70GB / 80GB
GPU 1: 70GB / 80GB
Sleep之后:
code复制GPU 0: 3GB / 80GB
GPU 1: 3GB / 80GB
也就是说,显存从70GB掉到了3GB。这3GB是啥?绝大部分是CUDA context本身、cuBLAS/cuDNN的算子工作区,以及PyTorch缓存分配器保留的一小块池子。只要进程没死,这部分就清不掉,想彻底归零只有杀进程。但即便带着这3GB的底,释放比例算下来也接近96%,标题里的“90% GPU资源瞬间回收”并不是夸张的说法,只是“瞬间”这个词得打引号,实际从触发Sleep到显存真正降下来,中间有一个等待请求排空的过程,几十秒到几分钟不等。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 前置条件与启动配置
2.1 版本与依赖要求
Sleep模式不是老版本直接就能用的,先确认你的vLLM版本。这个功能在不同分支里出现过不同叫法,有的版本启动参数是--enable-sleep-mode,对应API是/v1/sleep和/v1/wake;有的分支用过--enable-pause和/v1/pause这种命名。机制差不多,但名字不一样,拿到手先做一件事——看启动参数列表:
bash复制python -m vllm.entrypoints.openai.api_server --help | grep -i sleep
如果输出里有sleep相关项,就说明这个版本支持;如果没有,大概率是版本太老,或者编译时没带这个特性,直接换新版就行,别在旧版上死磕。
另外,Sleep模式在多卡张量并行、流水线并行甚至量化模型上都应该能用,但我个人建议先在单卡、无量化的小模型上做验证,确认你的版本行为符合预期,再上大模型。不是功能不行,而是排查组合太多,一旦出问题,你少一个变量就少一个小时的定位时间。
2.2 启动时开启Sleep功能的方法
如果版本支持,启动命令大致长这样:
bash复制python -m vllm.entrypoints.openai.api_server \
--model /data/models/7b \
--tensor-parallel-size 1 \
--gpu-memory-utilization 0.9 \
--max-num-seqs 128 \
--port 8000 \
--enable-sleep-mode
有几个参数值得专门说。
--gpu-memory-utilization 0.9的意思是允许引擎最多用掉90%的显存做权重和KV cache。Sleep能不能释放得干净、Wake能不能成功,和这个值有最直接的关系。如果设得太满,比如0.98,Sleep之后残留的碎片可能反而多,Wake时加载权重可能出现显存不足;留出10%的余量,看起来浪费,实际在Sleep/Wake来回切换时是很好的缓冲。
--max-num-seqs控制的是并发序列数,它会决定KV cache预分配大小。Sleep时不光要卸载权重,这块KV cache也要释放。预分配越大,Sleep过程要搬的数据越多,Sleep的耗时也会相应增加。要是你的场景里Sleep和Wake切换频繁,适当调低这个值能明显缩短切换时间。
有些版本还需要在环境变量里显式开启,比如:
bash复制export VLLM_ENABLE_SLEEP_MODE=1
保险起见,启动日志里看到Sleep mode enabled之类的字样再接着跑。
3. 实战操作:Sleep与Wake的完整调用
3.1 API路径与请求参数
Sleep模式和普通推理共用同一个HTTP服务,不需要额外部署一个小程序来管状态。请求路径就是/v1/sleep和/v1/wake,POST方式,不需要传请求体,一个空POST就行。
有几个细节必须在动手前说明白:
第一,Sleep不是瞬时的。调用Sleep之后,vLLM会等当前正在处理的请求全部完成,然后才开始卸权重、腾显存。这是有意为之,否则前一个请求还没算完,权重先给卸了,推理结果就是错的。所以Sleep接口的响应时间不是固定的,它取决于你当前队列里积压了多少请求。
第二,处于Sleep状态时,如果有新请求进来,vLLM会直接返回503。这个行为不同版本有差异,有的版本是排队等Wake,有的是直接拒掉。当你做平台调度时,必须把503当成“当前实例不可用”的信号,而不是引擎崩溃。
第三,Wake的耗时同样不固定。小模型可能十几秒,70B级别的大模型可能到几分钟。在这个窗口内进来的请求同样会排队或返回错误,所以调用Wake的一方要有超时和重试设计。
3.2 用curl完成一次Sleep/Wake循环
最朴素的验证方式就是curl。
触发Sleep:
bash复制curl -X POST http://127.0.0.1:8000/v1/sleep
正常情况下,返回会是200或204,响应体可能是空,也可能是一段状态描述。如果当前有请求正在处理,你会感觉这个命令卡了几秒甚至几十秒才返回。
Sleep之后立刻看显存:
bash复制nvidia-smi
这时你应该能看到占用在往下掉。如果请求还在处理中,占用会先保持,跑完才降。
之后触发Wake:
bash复制curl -X POST http://127.0.0.1:8000/v1/wake
Wake会重新加载权重,这个过程比较吃CPU、内存带宽和GPU。返回成功后,再执行一个最简单的补全请求验证推理是否恢复正常:
bash复制curl http://127.0.0.1:8000/v1/completions \
-H "Content-Type: application/json" \
-d '{"model": "/data/models/7b", "prompt": "hello", "max_tokens": 8}'
这一步千万别省。Wake返回成功只代表流程走完了,不代表模型权重真的完整加载、CUDA graph重建成功,必须发一个真实请求确认。
3.3 Python代码封装调用
在实际业务里,你大概率不是手工敲curl,而是把它接进调度系统。这里给一个最小可用的封装示例。
python复制import time
import requests
from requests.exceptions import Timeout, ConnectionError
class VLLMInstance:
def __init__(self, base_url, wake_timeout=180):
self.base_url = base_url.rstrip("/")
self.wake_timeout = wake_timeout
def sleep(self, timeout=300):
try:
resp = requests.post(f"{self.base_url}/v1/sleep", timeout=timeout)
resp.raise_for_status()
return True
except (ConnectionError, Timeout, requests.HTTPError) as e:
# 连接断开不一定代表失败,也可能是Sleep过程中服务暂时无响应
# 建议结合nvidia-smi或健康检查二次确认
print(f"[warn] sleep call error: {e}")
return False
def wake(self, timeout=None):
timeout = timeout or self.wake_timeout
deadline = time.time() + timeout
while time.time() < deadline:
try:
resp = requests.post(f"{self.base_url}/v1/wake", timeout=min(30, timeout))
if resp.status_code == 200:
return True
except (ConnectionError, Timeout):
print("[warn] wake not ready, retrying...")
time.sleep(2)
return False
if __name__ == "__main__":
inst = VLLMInstance("http://127.0.0.1:8000")
inst.sleep()
time.sleep(5)
ok = inst.wake()
print("wake ok:", ok)
这里面有个细节:Wake期间服务可能短暂处于CPU加载状态,HTTP连接会超时,所以要用带重试的循环,而不是一次调用失败就报错。我实际观察到的规律是,Wake失败多数不是vLLM崩了,而是调度脚本没等够时间就发下一个请求,把排队请求全都撞上503,看起来像故障,其实是节奏问题。
4. 显存回收的真实效果与量化数据
4.1 不同模型下的释放比例
既然叫“显存大解放”,那就得把账算清楚。Sleep模式能释放多少显存,核心取决于权重和KV cache在总占用里的占比。
先说权重占比。一个推理服务占用的显存大致分四块:模型权重、激活和中间张量、KV cache、CUDA基础设施。Sleep把前两块清干净,KV cache清空,CUDA基础设施留在原地。
我整理了一个粗略的估算表格,假设80GB卡,gpu-memory-utilization=0.9,权重按FP16计算:
| 模型体量 | 权重大小 | 典型总占用 | 预计释放 | 释放后残留 |
|---|---|---|---|---|
| 7B | 约14GB | 60~70GB | 55~65GB | 3~5GB |
| 13B | 约26GB | 65~75GB | 60~70GB | 3~5GB |
| 70B(多卡) | 约140GB | 600GB+(8卡) | 85%~93% | 每卡3~5GB |
释放比例随权重占总占用比例的下降而下降。如果你开的是长上下文,KV cache所占显存远大于权重,那么Sleep能释放的比例就会打折扣。因为KV cache是动态的,Sleep之前如果没怎么跑请求,KV cache只占一点点,Sleep释放的比例自然就高;反之如果刚从满负载状态切过来,KV cache占着大几十GB,Sleep要慢慢清,释放过程也更长。
4.2 “90%”这个数字是怎么来的
标题里的90%并不是固定值,而是一个在典型配置下很容易达到的经验值。拿上面的例子来说,7B模型在80GB卡上总占用70GB,释放后剩3GB多,释放比例约96%。但如果你把显存利用率设得很低,比如--gpu-memory-utilization 0.5,总占用本来就只有40GB,释放后剩3GB,释放比例就变成约92%。所以90%这个数字,前提是你把显存利用配得足够高。
还有一个“计量单位”的坑。nvidia-smi显示的是进程占用的显存,但vLLM实际从PyTorch分配器借的显存不一定全部体现在这个数字里。Sleep之后,PyTorch的缓存池可能会持有一部分显存不归还给CUDA,这部分不归还的内存会继续显示在nvidia-smi里。如果你想验证“是否真的释放了”,不要只看nvidia-smi,更好的是再起一个测试进程,尝试用torch.cuda.empty_cache()之外的方式申请大块显存,看看能不能申请到。申请到了,说明显存真的回收到驱动层了。
4.3 多卡环境的特殊表现
多卡场景有几个和单卡不同的地方。
张量并行下,模型权重是切到多张卡上的。Sleep时要每张卡同步卸载,Wake时再同步加载。如果其中一张卡出了问题,其他卡会等着,表现就是Sleep或者Wake卡住不动。排查时要对所有卡同时执行nvidia-smi,只看一张卡是看不出全貌的。
显存是否均匀释放也需要留意。卸载完成后,每张卡的残留占用应该基本一致。如果出现某张卡残留特别高,大概率是那张卡上有其他进程占显存,或者权重分片在某张卡上有额外的临时缓冲没清干净。
多副本环境还有另一个问题:每个副本都暴露一个HTTP服务,做Sleep和Wake就要对每个副本分别调用。我见过有人漏了副本,白天一看,三个副本唤醒了两个,另一个还在睡,流量全压到那两个副本上,延迟直接飙上去。所以多副本调用一定要做成循环,并且调完统一验证状态。
5. 真实业务场景与接入策略
5.1 多租户共享GPU的排队设计
一个很典型的场景是把Sleep模式用在多租户平台里。
原来多个团队共享一台GPU服务器,一个团队想跑训练,得跟其他团队商量:你们推理服务能不能停一下?停了之后,对方下次启动要冷启动几分钟,体验很差;不停,训练放不进去,两边僵住。
Sleep模式把这个协商过程自动化了。训练任务提交前,平台检查目标节点上的推理服务是否在运行。如果在,就调用Sleep,把显存交出来;训练跑完,平台再调用Wake,把推理拉起来。整个过程对推理方是透明的,API地址没有变,只是中间有一段303状态。
我这里提到的“教育后要把训练进程”先说清楚:唤醒后,工作进程和CUDA context是干净的,相当于System的库都清了,所以并发线程池、CUDA stream钩子都要重新初始化。vLLM本身处理了大多数情况,但如果你用了自定义的prefill hook、自定义logits processor,或者往请求生命周期里挂了自定义状态,Wake之后这些自定义状态不会被恢复,要自己兜底。
5.2 推理与训练混部的时间窗口
第二个场景是推理和训练在同一天里抢时间。
我之前在某个类似“白天推理、晚上训练”的节奏里试过这么调度:
- 上午9点到晚上10点,推理高峰期,推理实例保持Wake。
- 晚上10点以后,调用Sleep,释放显存,训练任务开始占用。
- 第二天早上8点,训练任务提前结束并让出显存,调用Wake,推理服务恢复。
这个方案在纸上很顺,实际上踩了一个坑:训练任务结束的时间不是固定的。有时候训练跑得慢了,早上8点还在收尾,Wake脚本已经触发,两边同时申请显存,结果Wake加载权重时显存不够,进程OOM。
后面我加了一个协调层:Wake之前先探测显存占用,如果发现还有训练进程占着卡,就等它结束。探测方法很简单,nvidia-smi --query-compute-apps=pid,used_memory --format=csv看看还有没有可疑的进程,或者直接让训练方报告结束时间。这个协调逻辑不需要多复杂,但不能没有。
5.3 定时自动Sleep的Cron方案
再给一个简单的定时控制方案,适合流量潮汐比较规律的服务。
在调度机上加两条crontab:
bash复制# 每天晚上11点触发Sleep
0 23 * * * curl -X POST http://127.0.0.1:8000/v1/sleep
# 每天早上8点触发Wake
0 8 * * * curl -X POST http://127.0.0.1:8000/v1/wake
这个方案看起来简单,实际上需要注意两个事。
一是Wake之后要留“预热”时间。vLLM是懒加载式的,Wake后第一个请求可能要额外付一点编译成本。如果你8点整Wake,8点零1分就有大量流量进来,第一个吃螃蟹的请求大概率会超时。我建议在Wake之后加一个“幽灵请求”,发一个只生成几个token的最小请求触发CUDA graph重建,让真正的用户别撞在枪口上。
二是Cron节点和被调度的vLLM实例之间的时间一致性。如果跨机房、跨时区,或者有NTP漂移,定时就不准了。更稳的做法是把调度时间交给业务层,比如流量监控发现连续20分钟无请求,再触发Sleep。
6. 常见问题与排查避坑
6.1 API返回405或404
这两个状态码最常出现在版本混淆的情况下。
405 Method Not Allowed,多半是调用了GET或者PUT,Sleep接口只接受POST。404基本就是路径不对,常见的有/sleep、/v1/sleep、/vllm/sleep几种写法,不同版本不一样。遇到404,第一件事不是改代码,而是去翻这个版本的openapi.json,直接看注册了哪些路径。
bash复制curl http://127.0.0.1:8000/openapi.json | grep -i sleep
这个文件是运行时生成的,一定准。
6.2 Wake后请求变慢
Wake之后的前几个请求速度明显比正常慢,这个我建议直接接受,别花时间调。
慢的原因有两层:一是权重加载完成后,CUDA kernel 需要重新编译或重新捕获 CUDA graph;二是某些版本的采样器会重建随机数生成器状态。这些都是一次性开销,跑几个请求之后就恢复正常。
如果慢的不是前几个请求,而是持续慢,那就得检查是不是Wake之后没有把--gpu-memory-utilization恢复成原来的值,或者加载过程中有些优化项没有生效。另一个容易忽略的是CPU内存分配:Wake时权重要从CPU内存拷回GPU,如果机器上其他进程把内存占满了,CPU到GPU的拷贝会严重变慢。Sleep前留意一下free -g,给权重留足内存空间。
6.3 Sleep后显存没降下来
这是最常见的“虚假成功”。API返回正常,但nvidia-smi一看,显存纹丝不动。
先别怀疑功能坏了,按下面顺序排查。
第一,当前还有请求在处理。Sleep是等排空再卸的,你刚发完Sleep请求,前面还有几十个请求堵着,显存当然不会立刻降。等几分钟再看。
第二,有别的进程也在这张卡上。nvidia-smi里显示的占用是整张卡的,不全是vLLM的。执行nvidia-smi --query-compute-apps=pid,used_memory --format=csv,看看实际占显存的是不是你的vLLM进程。
第三,PyTorch缓存池没还。Sleep把模型和KV cache卸载后,PyTorch的caching allocator可能把内存留在自己的缓存池里。vLLM内部一般会调empty_cache,但如果你在启动脚本里自己包了一层逻辑干扰了它,就会卡在这一步。这种情况没有特别好的强制手段,最直接的排查办法是看进程RSS和nvidia-smi的对比,如果长时间不一致,建议直接升级vLLM版本。
6.4 Wake失败与OOM的几种典型模式
Wake时最容易出的问题就是显存不够。原因通常有两个:一是Sleep之后,有别的任务把显存占走了,Wake回填时发现地方不够;二是gpu-memory-utilization配得太满,Wake过程中临时开销的余量不足。
第一个原因要靠调度协调解决,第二个原因可以通过调参数绕开。建议把gpu-memory-utilization从0.95降到0.88到0.9之间,损失的那点KV cache容量,换来Wake稳定性的提升,非常划算。
还有一种情况是Wake超时。这个超时不在HTTP层,而在引擎内部。大模型Wake时要重新加载权重、重建KV cache pool,这个过程可能超过默认的HTTP连接超时。如果通过API网关转发,网关的超时时间也要同步调大,至少要大于你实测的Wake耗时,否则网关先断开,你以为vLLM挂了,其实它还在吭哧吭哧加载权重。
我把几个典型问题整理成一张表,方便对照定位:
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| POST /v1/sleep 返回404 | 版本不同,路径不一致 | 查openapi.json确认实际路径 |
| Sleep返回但显存未降 | 请求未排空 / 其他进程占用 / 缓存池未释放 | 等待后重查,区分CUDA context残留 |
| Wake返回但推理报错 | CUDA graph重建失败 / 权重损坏 | 检查启动日志,必要时重启进程 |
| Wake超时 | 权重过大,加载慢 / 网关超时太短 | 调大网关超时,增加客户端重试 |
| Sleep后无法Wake | 显存被其他任务占用 | 协调任务释放资源后再Wake |
| 多卡中某张卡显存未释放 | 单卡上其他进程占用 | 逐卡检查进程,清理后再Sleep |
7. 从Sleep模式延伸出去的显存治理思路
7.1 与动态LoRA加载结合
Sleep模式解决的是“整个模型是否在GPU上”的问题,但有些场景更精细:模型主体常驻,不同的租户挂不同的LoRA adapter。这时Sleep模式用得少了,更多是用LoRA的动态加载卸载来控制显存。不过两者不冲突。
我的经验是,如果租户粒度切换频繁,LoRA动态加载更合适;如果整个模型都进入低峰,Sleep更合适。二者可以串起来用:先卸载不活跃的LoRA adapter,再触发Sleep,让显存释放得更彻底。
7.2 多副本自动伸缩的辅助角色
做多副本自动伸缩时,大部分人第一反应是K8s的HPA或者自定义的扩缩容脚本。但扩缩容的粒度是“整个Pod”,来回起停Pod的成本很高,而且Pod起来了也不代表服务就能用。
Sleep模式可以作为自动伸缩的中间态:缩容时不直接删Pod,而是先Sleep,把显存交出去;扩容时不新建Pod,而是Wake已有的Sleep实例。显存模型按需竞争的效率高很多,Node上的Pod数量不变,管理面也少了一些变化。当然这需要平台层的配合,vLLM本身只提供了Sleep/Wake的接口,什么时候调用,还是由调度方决定。
我在实际项目中用这个状态机跑了两周,整体稳定。真正的收益不只是显存省了多少,而是团队再也不用为了“不要让推理服务占着卡”去写各种奇怪的清进程脚本了。Sleep是API,Wake是API,一切都变成可编排的,这才是它最大的价值。
最后分享一个细节:Sleep模式的第一版实现,对显存的释放并不是全量的。我测试时发现释放完成之后,进程占用仍有上浮,刚开始以为是泄漏,后来定位到是PyTorch的缓存池策略导致。升级版本后这个问题消失。所以如果你在生产环境遇到类似现象,不要急着优化业务代码,先确认你用的vLLM版本是不是修复了相关问题的版本。任何优化功能,都要把“版本差异”列在第一优先级的排查项里。
