如何在一张 GPU 上服务 100 个微调模型
100 个 7B 模型的微调变体,如果都把适配器合并进权重,要占 1.5TB。如果它们共享一个基座模型,只要 19.3GB。
差别来自服务栈往 GPU 显存里装了什么。一个 rank-8 的 LoRA 适配器大约 40MB 的任务专属权重;把它合并进基座,就产生另一份 15.2GB 的模型副本。
「一个变体一个 endpoint」还会造出 100 个独立的扩缩容池:每个池有自己的 worker、自己的冷启动、自己的空转容量。
把适配器分开存,布局就变了:一个 15.2GB 的基座模型,加上大约 4GB 的适配器,在一张 80GB 的卡上还能剩下约 60GB 给 KV cache。而每个请求依然会命中它自己的微调变体——vLLM 加载被请求的那个适配器,所有变体复用同一份基座权重。
省显存是显而易见的收益,但在生产里更重要的是 worker 复用。共享 endpoint 把所有请求导向同一个池;分开的 endpoint 则可能出现「一个模型热着,另一个请求却还在等新 worker 起来」。
这篇文章用同一份流量把两种布局都跑了一遍。
四种部署形态
第一种,合并后每个变体一个 endpoint。合并会把适配器的改动写进基座,服务引擎不再需要单独加载和应用适配器。这份简洁的代价是显存:每个微调变体都是一份 15.2GB 的模型副本,三个变体就是 45.6GB——尽管每一个最初都只是 40MB 的适配器。这个布局下每个变体还各自拥有 endpoint 和 worker:没有 worker 就绪时,请求必须等 Runpod 启动 GPU worker 并加载模型。RunPod 在一次测试里量到过这个启动延迟:32B 模型、两张 H200,324 秒;四处配置改动把这段降到约 91 秒,而模型一旦跑起来速度不变。分开的 endpoint 还让 worker 之间无法互相帮忙:一个持有抽取模型的空转 worker,没法处理 SQL 模型的请求,SQL 请求会再起一个 worker,而第一块 GPU 继续闲着。合并仍然有意义的场合是:不同变体需要不同的基座模型、不同的 GPU,或者需要完全隔离。当所有适配器共享同一个基座时,分开的副本基本只是在重复显存和 worker。
第二种,不合并、启动时注册。基座只加载一次,worker 在旁边加载那些更小的适配器。每个请求调用自己需要的适配器,vLLM 把它应用到共享基座上再生成。于是抽取、SQL、路由的请求可以共用同一块 GPU,而不必加载三份完整模型。每个 worker 都加载同一份基座和同一套适配器,所以任何 worker 都能处理任何变体——worker 池随流量增长,而不是随微调数量增长。再加一个变体,需要的是约 40MB 的适配器显存,而不是又一份 15.2GB 的模型。两个现实约束:所有适配器必须来自同一个基座;它们也占用显存,留给活跃请求的空间会变少。
第三种,不合并、请求时解析。启动时注册适合目录小、很少变化的场景。更大的目录里可能有几百个客户适配器,而同一时刻只有少数几个收到请求。请求时加载让那些适配器留在存储里,而不是在 worker 启动时全部载入。当请求需要某个没加载的适配器,vLLM 会去取它并加进正在运行的 worker;后续请求可以复用,不必重启 endpoint,新适配器也能加进来而不用重新部署。代价是第一次请求某个未加载的适配器会更慢——它要等适配器被取回并拷进显存。适配器频繁变化、或者整份目录塞不进显存时,用请求时加载;目录小而稳定时,启动时全量加载更简单,也避免首次请求的延迟。
第四种,托管按租户。服务商存着适配器、在 API 后面提供服务,通常按 token 而不是按 GPU 小时计价。部署布局由服务商控制,你能量、能调的东西有限。这里不展开——后面讨论的前提是你想自己控制 GPU。
实验:在 Runpod 上做共享基座
这个实验要验证的是:一个共享 endpoint 是否比三个各自独立的 endpoint 起更少的 worker、更少的冷启动。
我们训练三个适配器——抽取、SQL 生成、请求路由——然后按两种方式部署:一个 endpoint 加载基座和全部三个适配器;三个 endpoint 各加载基座和一个适配器。两种布局收到同样的请求,然后比较请求等待时长、启动的 worker 数量、各自消耗的 GPU 时间。
实验用 1.5B 的 Qwen 模型控制成本(前面举的 7B 例子,共享基座的机制是一样的)。流程是:建 Runpod 与 Hugging Face 的 key,用 Runpod 命令行客户端开一个 Pod(一张 RTX 4090、30GB 工作卷),在 Pod 里装训练相关的包,生成三份各 600 行的小数据集(抽取返回 JSON、SQL 返回查询、路由返回支持队列),训三个 LoRA(rank 16、覆盖七个投影模块,每个 adapter_model.safetensors 约 70MB——里面没有另一份几个 GB 的 Qwen,这就是「适配器确实被分开保存」的第一个检查),把三个适配器传上 Hugging Face,然后回到本地部署 Serverless endpoint。
部署时几个关键开关:ENABLE_LORA 打开适配器支持,MAX_LORAS=3 让三个适配器的请求能共处一个 batch,LORA_MODULES 注册后面会出现在每个请求 model 字段里的名字。endpoint 从零 worker 起步、最多扩到三个,这样它能在五秒空闲后回到零——我们用这一点测冷启动。
部署完先确认适配器真的加载了:请求返回的模型列表里应该有基座加上 extract、sql、route 三项。如果只有基座,就停在这里——后面所有测量都会测错东西。
然后给三个适配器各发一个请求,URL 不变,只改 model 字段。我们的热态测试里,适配器之间的差异落在基座模型的正常波动范围内:这个布局省下了显存,没有带来可见的请求时开销。
冷启动测量
上一步是 worker 已经在跑时的请求。这一步在 endpoint 回到零 worker 之后开始。
先确认 workers 状态全为零(包括 throttled),然后跑测量脚本。它会打印两个值:delayTime 覆盖 worker 接手任务之前的一切,包括 worker 启动和等待 GPU 容量;executionTime 覆盖被接手之后的请求。
在我们的运行里,99.7% 的测量时间发生在推理之前;取件延迟大约是执行时间的 307 倍。也就是说,一个半秒作答的模型,在 endpoint 缩容到零之后,可能要将近三分钟才触达用户。
重复三次(每次都等 endpoint 回到零):三次干净的试次分别等了 409、245、214 秒,中位数 245 秒,而执行时间始终在半秒附近。这个离散度不舒服但有用——大部分不确定性来自启动 worker,而不是跑模型。
这些数字也远长于「24GB 卡上的 1.5B 模型」应有的水平,原因在配置里:什么都没缓存。每次冷启动都要拉 vLLM 的 worker 镜像,再从 Hugging Face 下载基座和三个适配器,然后引擎才开始热身。Runpod 自己那篇讲配置的文章把一个大得多的模型从 324 秒降到 91 秒,且没碰代码;把模型烤进镜像、或缓存在网络卷上、并保持 FlashBoot 开启,能消掉我们这里测到的大部分延迟。我们故意保留默认配置,是为了让两种布局可比——请把 245 秒当成未优化基线,而不是平台属性。
如果 Runpod 报告某次是 throttled worker,那次试次必须丢掉:请求在等 GPU 容量,delayTime 就不再只代表 worker 启动。
共享 vs 三个独立
现在建三个各加载一个适配器的 endpoint。每个 endpoint 用同样的基座、GPU、worker 上限和五秒空闲超时,只有 LORA_MODULES 不同;MAX_LORAS 变成 1。
两种布局就位后,用同样的五分钟、每秒一个请求打过去,轮换抽取、SQL、路由。对每个任务记录 delayTime,同时记录 Runpod 显示的最高 worker 数,以及这次运行计费的 worker 时间。速率要压得足够低,让热 worker 跟得上——如果请求在后面排队,很长的 delayTime 看起来会像冷启动,哪怕根本没有 worker 在启动。
我们的运行结果里有个反直觉的点:分开的布局用的计费 worker 秒更少,但不是因为它的 worker 空转掉了。每三秒一个请求的速率下,热 worker 永远到不了五秒的空闲超时;分开的 endpoint 在五分钟里大部分时间都在冷启动,请求堵在启动后面,完成的请求少得多。它在这段短运行里更便宜,是因为它完成的请求更少,而且让大部分请求等了两分多钟。
共享的 worker 一直活着,因为合并后的流量持续打到同一个池——这就是内存账看不到的运营优势。
把测量变成成本估算
用你当前的 Runpod GPU 费率,别抄这篇文章里的价格(费率取决于 GPU 型号和 worker 模式)。对 flex worker,月度成本按「启动 + 空闲 + 忙碌」三段分别算:请求执行在两种布局里应该差不多(模型生成的 token 一样),但启动和空闲是按 endpoint 付的,所以三个独立 endpoint 会在同一个流量周期里把这份开销付三遍。对常驻 worker,就是一个常驻 worker 的月价乘以 endpoint 数量:一个共享 endpoint 只需要一个热底,三个独立 endpoint 需要三个——如果每个变体都必须立即响应的话。稀疏流量下,启动占账单的比例会大得多。
结论
一个 LoRA 微调在合并之前都很小。合并会为每个变体造一份完整的模型副本,而独立的 endpoint 又让每份副本拥有自己的 worker 池。当模型需要不同硬件或需要完全隔离时,这笔交易是合理的;当所有适配器来自同一个基座时,它只是在浪费显存和 worker。
对同一个基座上的适配器家族,我的默认做法是保持分开:加载一个基座、挂上适配器、让所有请求共用同一个 worker 池。目录本身仍然吃显存,所有适配器也必须匹配基座,但新增一个变体不再意味着新增一整个模型和一个 endpoint。
这个实验还给了我们一个讨论 serverless 延迟的更好方式:把等待时间和执行时间分开记录。一个半秒跑完的模型,如果 endpoint 每次醒来都要拉镜像、下权重,用户感受到的仍然是慢——这就是为什么冷启动配置值得和部署布局一样被认真对待。










