
1. 显存溢出不是模型太大而是并发入口没设闸很多人第一次把模型挂到 FastAPI 上脑子里想的都是“接口通了就行”。本地单条请求跑得飞快uvicorn main:app --reload一开浏览器里点两下结果也正常。于是放心大胆地把服务丢给前端或者内部同事用。然后噩梦开始了三个人同时点服务卡死五个人同时点日志里蹦出torch.cuda.OutOfMemoryError十个人同时点进程直接挂掉连健康检查都返回 500。这个场景我见过太多次。问题的根子不在模型本身也不在 FastAPI 框架而在于推理服务的并发入口没有做任何限制。FastAPI 默认是异步框架请求进来之后如果你在路由函数里直接调用同步的 PyTorch 推理代码它会被丢到线程池里执行。线程池默认可以开很多线程每个线程都可能去碰 GPU。GPU 显存是全局资源不是每个线程独立拥有的。多个线程同时往 GPU 上搬数据、分配中间张量显存瞬间就被吃光。打个比方GPU 显存就像一间只有一张工作台的实验室。FastAPI 是实验室的接待员默认情况下接待员会把所有来访者都放进实验室让他们同时用那张工作台。第一个人刚把材料铺开第二个人就把材料推下去第三个人直接踩在材料上。结果就是实验室爆炸。正确的做法是接待员手里只有一把钥匙一次只放一个人进去做完实验出来再把钥匙交给下一个人。这就是并发控制的核心把 GPU 推理变成串行或者有限并行的操作而不是无限制并行。听起来简单但实际落地时有很多细节要处理。比如怎么限制用信号量还是队列限制到多少合适请求排队时客户端怎么知道自己的位置超时了怎么办服务重启后排队状态怎么恢复这些问题不解决光加一个asyncio.Semaphore(1)只能算半成品。这篇文章我会从实际项目出发把 FastAPI 上做 GPU 推理并发控制的完整思路拆开讲。包括为什么不能直接裸跑、几种控制方案的取舍、显存估算的方法、排队与超时的设计、以及我在真实环境里踩过的坑。目标很明确让你看完之后能自己搭一个请求再多也不会把显存打爆的推理服务。2. 为什么裸跑 FastAPI 加 GPU 推理一定会出事2.1 异步框架与同步推理的错配FastAPI 的核心卖点是异步。路由函数用async def定义时它运行在事件循环里用普通def定义时FastAPI 会把它丢到anyio的线程池里执行。很多人写推理接口时因为 PyTorch 的推理代码是同步阻塞的所以顺手写成普通def。这看起来没问题但实际上线程池的默认容量是 40。也就是说理论上可以有 40 个线程同时执行你的推理函数。每个线程都会执行类似这样的代码with torch.no_grad(): inputs processor(images, return_tensorspt).to(cuda) outputs model(**inputs) result processor.decode(outputs[0], skip_special_tokensTrue)to(cuda)这一步会把输入张量复制到显存。model(**inputs)会在显存里分配中间激活值。如果模型是 7B 参数量的半精度模型光权重就占大约 14GB。再加上输入、中间激活、KV Cache单次推理峰值可能到 16GB 甚至更高。一张 24GB 的卡跑一个请求没问题跑两个就悬了跑三个必炸。线程池不会管你显存够不够它只管有没有空闲线程。所以请求一多线程池里的线程各自为战显存分配器来不及回收OOM 就来了。2.2 显存分配器的“缓存”特性让问题更隐蔽PyTorch 的 CUDA 显存分配器有一个特点它不会在张量释放后立刻把显存还给系统而是保留在缓存池里方便下次分配时快速复用。这个设计本身是为了性能但在并发场景下会放大问题。假设第一个请求跑完显存里还留着 10GB 的缓存。第二个请求进来需要 12GB分配器发现缓存不够会尝试向系统申请更多显存。如果系统显存已经被第一个请求的缓存占着申请就可能失败。更糟糕的是多个线程同时申请时分配器内部的锁竞争会导致分配变慢请求堆积最终雪崩。我实测过一个 7B 模型单请求峰值显存 15.8GB。在 24GB 卡上两个并发请求的峰值加起来是 31.6GB直接超过物理显存。但如果你在第一个请求结束后手动调用torch.cuda.empty_cache()显存会降回 14GB 左右。问题是在线程池并发的情况下你根本不知道什么时候该调用empty_cache调用太频繁会拖慢推理速度调用不及时又会 OOM。2.3 请求堆积的连锁反应显存溢出只是第一张倒下的多米诺骨牌。一旦某个请求 OOMPyTorch 会抛出异常。如果这个异常没有被捕获FastAPI 会返回 500。但更严重的是OOM 发生后CUDA 上下文可能处于不确定状态后续请求即使显存够也可能因为上下文损坏而失败。我遇到过最诡异的情况是OOM 之后服务没有崩但所有后续请求都返回空结果。查了半天才发现CUDA 上下文被污染了模型的前向传播静默失败输出全是 NaN。这种问题比直接崩溃更难排查因为日志里没有明显的错误只有业务侧反馈“结果不对”。所以并发控制不只是为了防止 OOM更是为了保证推理结果的正确性和服务的稳定性。3. 几种并发控制方案的取舍与实测对比3.1 全局信号量最简单但不够灵活最直接的做法是在应用启动时创建一个全局信号量import asyncio gpu_semaphore asyncio.Semaphore(1) app.post(/infer) async def infer(request: InferRequest): async with gpu_semaphore: result await run_inference(request) return resultasyncio.Semaphore(1)保证同一时刻只有一个协程能进入临界区。注意这里必须用async def路由并且run_inference如果是同步阻塞的需要用run_in_executor包一层否则会阻塞事件循环。这个方案的好处是代码量极少五分钟就能加上。缺点是粒度太粗所有请求都排在一个队列里不管模型大小、不管请求类型。如果有的请求只需要 2GB 显存有的需要 16GB统一限制为 1 会导致小请求也被大请求堵住吞吐量上不去。我实测下来在单模型、请求类型单一的场景下信号量方案足够用。但如果你的服务要同时支持多个模型或者有轻重不同的推理任务就需要更细粒度的控制。3.2 显存感知的动态准入按实际占用决定放行更聪明的做法是维护一个显存预算每个请求进来时先估算自己需要多少显存然后检查当前已用显存加上新请求的估算值是否超过阈值。如果没超过就放行超过了就排队等待。估算显存的方法有几种静态估算模型权重占用 固定输入尺寸的激活值。这个可以在服务启动时用一次空跑测出来。动态探测用torch.cuda.memory_allocated()和torch.cuda.memory_reserved()实时读取。历史统计记录每个接口过去 N 次推理的峰值显存取 P95 作为估算值。我一般用静态估算加安全系数。比如启动时跑一次推理记录torch.cuda.max_memory_allocated()然后乘以 1.3 作为单请求预算。假设预算是 16GB卡是 24GB那么最多放行 1 个请求因为 16*23224。如果预算是 8GB就可以放行 2 个。这个方案需要你维护一个计数器记录当前正在执行的请求数和它们的预估显存总和。可以用asyncio.Condition来实现等待和通知class GPUBudget: def __init__(self, total_gb: float): self.total total_gb self.used 0.0 self.condition asyncio.Condition() async def acquire(self, need_gb: float): async with self.condition: while self.used need_gb self.total: await self.condition.wait() self.used need_gb async def release(self, need_gb: float): async with self.condition: self.used - need_gb self.condition.notify_all()这个方案比信号量灵活但引入了估算误差的风险。如果估算偏低实际运行时还是可能 OOM。所以安全系数不能省而且最好加一个兜底捕获 OOM 异常后临时降低准入阈值等显存回收后再恢复。3.3 独立推理进程加队列隔离性最好如果你的服务对稳定性要求极高可以考虑把推理逻辑拆到独立进程里FastAPI 只负责接收请求、把任务丢进队列、然后等待结果。推理进程从队列里取任务串行执行执行完把结果放回另一个队列。这种架构的好处是推理进程崩溃不会影响 FastAPI 主进程重启即可恢复。队列天然实现了背压请求太多时队列会堆积但不会打爆显存。可以独立监控推理进程的显存和 GPU 利用率。缺点是架构复杂了需要处理进程间通信、序列化、超时、进程重启后的状态恢复。我一般用multiprocessing.Queue或者 Redis 作为队列。如果团队已经有 Redis用 Redis 更稳因为进程重启后队列还在。实测下来独立进程方案在长时间运行的服务里最稳。FastAPI 主进程可以随时重启更新代码推理进程保持运行模型不用重新加载。但开发阶段用信号量就够了没必要一上来就上分布式队列。3.4 方案对比与选型建议方案实现复杂度显存利用率稳定性适用场景全局信号量低低中单模型、请求单一、快速上线显存感知准入中高中高多模型、请求异构、追求吞吐独立进程加队列高中高生产环境、长稳运行、多实例选型时先问自己三个问题模型有几个请求类型是否一致服务能不能接受偶尔重启如果答案是一个模型、请求一致、可以重启信号量就够了。如果模型多、请求差异大上显存感知。如果是核心生产服务独立进程加队列。4. 显存预算怎么算才不拍脑袋4.1 模型权重的显存占用模型权重的显存占用是可以精确计算的。公式很简单权重显存 参数量 × 每个参数的字节数FP32 每个参数 4 字节FP16 是 2 字节INT8 是 1 字节INT4 是 0.5 字节。一个 7B 参数的模型FP16 权重占 7 × 10^9 × 2 14GB。13B 模型 FP16 占 26GB一张 24GB 卡放不下必须用量化或者多卡。但实际占用会比理论值高一点因为还有模型缓冲区、CUDA 上下文、cuDNN 工作空间等开销。我一般会在理论值上加 1GB 到 2GB 的固定开销。所以 7B FP16 模型我按 15GB 到 16GB 来估算。4.2 激活值和 KV Cache 的估算激活值的大小取决于输入序列长度和 batch size。对于 Transformer 模型激活值大致和batch_size × seq_len × hidden_size × num_layers成正比。精确计算很复杂但可以用经验公式激活值 ≈ batch_size × seq_len × hidden_size × num_layers × 2 字节 × 系数系数通常在 2 到 4 之间取决于具体的注意力实现。对于 7B 模型hidden_size 是 4096num_layers 是 32。如果输入长度 512batch size 1激活值大约是 1 × 512 × 4096 × 32 × 2 × 3 ≈ 400MB。这个量级不算大但如果输入长度到 4096就会涨到 3.2GB。KV Cache 是另一个大头。对于自回归生成KV Cache 的大小是KV Cache 2 × batch_size × seq_len × num_layers × hidden_size × 2 字节同样 7B 模型输入 512输出 512KV Cache 大约是 2 × 1 × 1024 × 32 × 4096 × 2 ≈ 536MB。如果并发多个请求每个请求都有自己的 KV Cache这部分会线性增长。4.3 实测峰值显存的方法理论估算只能给你一个大概范围真正靠谱的是实测。方法很简单在服务启动后用一条典型请求跑一次推理在推理前后记录显存import torch torch.cuda.reset_peak_memory_stats() # 执行一次推理 run_inference(sample_input) peak torch.cuda.max_memory_allocated() / 1024**3 print(f峰值显存: {peak:.2f} GB)max_memory_allocated返回的是峰值分配量比memory_allocated更能反映真实需求。我建议用 P95 请求的输入长度来测而不是平均长度因为长请求才是 OOM 的元凶。测出来之后用这个公式决定最大并发数最大并发数 floor((总显存 - 系统预留) / 单请求峰值)系统预留一般留 2GB 到 3GB给 CUDA 上下文、显示输出如果有、其他进程用。比如 24GB 卡预留 3GB单请求峰值 16GB那么最大并发就是 floor(21/16) 1。如果单请求峰值 8GB最大并发就是 floor(21/8) 2。4.4 留足安全边际别把卡跑满我见过有人把并发数算到刚好卡满显存比如 24GB 卡单请求 12GB就设并发 2。结果跑了一段时间后开始随机 OOM。原因是显存碎片化第一个请求释放后显存里留下一些不连续的空洞第二个请求需要一块连续显存时分配失败。所以实际设置时我会在计算结果上再打八折。比如算出最大并发 2实际就设 1。算出 4实际设 3。宁可让请求多排一会儿队也不要让服务崩掉。排队最多是延迟高崩掉就是全盘不可用。另外如果用的是共享 GPU 或者云上的虚拟化 GPU实际可用显存可能比标称值少。这种情况下更要保守最好在启动时用torch.cuda.get_device_properties(0).total_memory读一下真实值而不是硬编码 24GB。5. 排队、超时与客户端体验的平衡5.1 请求排队时客户端在等什么加了并发控制之后请求不会立刻执行而是进入等待状态。客户端看到的是响应时间变长。如果等待时间太长客户端可能超时用户可能重复点击导致更多请求涌入。所以排队策略要和超时策略配合。我的做法是在请求进入时记录时间戳。在等待信号量时设置一个最大等待时间比如 30 秒。如果超过 30 秒还没拿到执行权直接返回 503并带上Retry-After头。如果拿到了执行权但推理本身超过 60 秒也要中断并返回超时。这样客户端知道服务是忙而不是挂了可以根据Retry-After决定什么时候重试。5.2 用 asyncio.wait_for 控制等待上限asyncio.Semaphore的acquire方法本身不支持超时但可以用asyncio.wait_for包一层try: await asyncio.wait_for(gpu_semaphore.acquire(), timeout30) except asyncio.TimeoutError: raise HTTPException(status_code503, detail服务繁忙请稍后重试)注意wait_for超时后信号量的acquire可能还在等待。如果不处理会导致信号量泄漏。正确的做法是用asyncio.timeout上下文管理器Python 3.11或者在超时后手动取消acquire_task asyncio.create_task(gpu_semaphore.acquire()) done, pending await asyncio.wait([acquire_task], timeout30) if pending: acquire_task.cancel() raise HTTPException(status_code503, detail服务繁忙)这个细节很容易被忽略但一旦信号量泄漏服务就会永久卡死所有请求都拿不到执行权。5.3 返回排队位置让客户端心里有数如果排队时间可能比较长可以在响应头里返回当前排队位置queue_position gpu_semaphore._value # 注意这是内部属性仅作示意 response.headers[X-Queue-Position] str(queue_position)不过_value是内部属性不建议直接依赖。更稳妥的做法是自己维护一个计数器在请求进入时递增拿到执行权时递减。这样客户端可以显示“前面还有 3 个请求”用户体验会好很多。5.4 超时后的显存回收如果推理超时被中断显存可能没有正确释放。特别是用torch.no_grad()时如果中途抛出异常中间张量可能还挂在计算图上。所以超时处理里要加显存清理try: result await asyncio.wait_for(run_inference(request), timeout60) except asyncio.TimeoutError: torch.cuda.empty_cache() raise HTTPException(status_code504, detail推理超时)empty_cache()会释放未使用的缓存显存但不能释放还在被引用的张量。所以更重要的是确保推理函数内部用try/finally清理中间变量或者把推理逻辑放在独立函数里函数返回后局部变量自动释放。6. 生产环境里那些文档不会写的坑6.1 uvicorn 多 worker 导致信号量失效这是最经典的坑。uvicorn main:app --workers 4会启动 4 个独立进程每个进程有自己的 Python 解释器和自己的全局信号量。你在代码里写的gpu_semaphore asyncio.Semaphore(1)在 4 个 worker 里就是 4 个独立的信号量每个都允许 1 个请求执行。结果就是 4 个请求同时跑显存照样爆。解决方案有两个要么只用 1 个 worker要么把并发控制放到进程外比如用 Redis 做分布式信号量。如果推理是 GPU 密集型的1 个 worker 通常就够了因为 GPU 本身就是瓶颈多 worker 只会增加上下文切换开销。我一般建议 GPU 推理服务用--workers 1然后通过异步和队列来提高吞吐。6.2 模型加载时的显存峰值被忽略很多人只关注推理时的显存忽略了模型加载时的峰值。从磁盘加载权重到 CPU再搬到 GPU这个过程中显存占用可能比推理时还高。特别是用from_pretrained加载时如果没设置low_cpu_mem_usageTrue会在 CPU 上复制一份完整权重再复制到 GPU峰值可能是模型大小的两倍。所以加载模型时最好在服务启动阶段完成并且加载完后调用一次torch.cuda.empty_cache()。如果模型加载和推理在同一个进程里加载时的峰值会占用显存导致后续推理可用显存变少。我遇到过加载完模型后可用显存比预期少了 2GB 的情况就是因为加载过程中的临时缓冲区没有完全释放。6.3 日志里的 OOM 堆栈会骗人PyTorch 的 OOM 报错堆栈通常指向最后一次显存分配的位置但不一定是真正的原因。比如报错说在attention层分配失败但实际原因是前面某个请求没有释放显存导致整体显存不足。如果只看堆栈会以为是模型结构问题其实是并发控制问题。排查 OOM 时我建议在推理前后打印显存快照def log_gpu_memory(tag: str): allocated torch.cuda.memory_allocated() / 1024**3 reserved torch.cuda.memory_reserved() / 1024**3 print(f[{tag}] allocated{allocated:.2f}GB reserved{reserved:.2f}GB)在请求进入、拿到执行权、推理完成、释放执行权这几个点都打上日志。这样 OOM 发生时你能看到是哪个阶段显存涨上去了而不是只看堆栈瞎猜。6.4 客户端重试放大并发如果客户端在超时后自动重试而服务端没有做幂等或者去重重试的请求会和原请求叠加导致并发数翻倍。我见过一个场景客户端超时时间设了 10 秒服务端排队 15 秒结果客户端每 10 秒重试一次队列里堆了几十个重复请求显存直接爆掉。解决办法是在服务端加请求去重比如用请求 ID 做幂等键相同 ID 的请求如果还在队列里直接返回“处理中”。或者调整客户端超时时间让它大于服务端的最大排队时间加推理时间。这个需要前后端一起配合不能只改一边。6.5 显存碎片化导致“明明够却分配失败”显存碎片化是另一个隐蔽的坑。假设总显存 24GB已用 10GB剩余 14GB。但剩余显存是分散的最大的连续块只有 8GB。这时一个需要 10GB 连续显存的请求就会失败尽管总剩余显存够。缓解碎片化的方法有尽量用固定尺寸的输入避免动态 shape 导致反复分配不同大小的块。在请求间隙调用torch.cuda.empty_cache()让缓存池整理一下。用PYTORCH_CUDA_ALLOC_CONFexpandable_segments:True环境变量让分配器支持可扩展段减少碎片。最后这个环境变量在 PyTorch 2.1 之后支持实测能明显降低碎片化导致的 OOM。设置方法是在启动命令前加PYTORCH_CUDA_ALLOC_CONFexpandable_segments:True uvicorn main:app --workers 17. 一套可复用的并发控制代码骨架7.1 应用启动时初始化显存预算import asyncio import torch from fastapi import FastAPI, HTTPException from contextlib import asynccontextmanager class GPUConcurrencyController: def __init__(self, total_gb: float, safety_factor: float 0.8): self.total_gb total_gb * safety_factor self.used_gb 0.0 self.condition asyncio.Condition() self.queue_count 0 async def acquire(self, need_gb: float, timeout: float 30.0): self.queue_count 1 try: async with self.condition: while self.used_gb need_gb self.total_gb: try: await asyncio.wait_for( self.condition.wait(), timeouttimeout ) except asyncio.TimeoutError: raise HTTPException( status_code503, detailf服务繁忙当前排队 {self.queue_count} 个请求 ) self.used_gb need_gb finally: self.queue_count - 1 async def release(self, need_gb: float): async with self.condition: self.used_gb - need_gb self.condition.notify_all() controller None asynccontextmanager async def lifespan(app: FastAPI): global controller total torch.cuda.get_device_properties(0).total_memory / 1024**3 controller GPUConcurrencyController(total_gbtotal, safety_factor0.8) yield torch.cuda.empty_cache() app FastAPI(lifespanlifespan)这段代码在启动时读取真实显存乘以安全系数然后创建控制器。acquire方法会等待直到显存预算够用超时则返回 503。7.2 推理接口的完整包装app.post(/infer) async def infer(request: InferRequest): need_gb estimate_memory(request) await controller.acquire(need_gb, timeout30.0) try: result await asyncio.get_event_loop().run_in_executor( None, run_inference, request ) return {result: result} except torch.cuda.OutOfMemoryError: torch.cuda.empty_cache() raise HTTPException(status_code500, detail显存不足请重试) finally: await controller.release(need_gb)注意run_in_executor把同步推理放到线程池里执行避免阻塞事件循环。finally里释放显存预算保证即使推理失败也能让出名额。7.3 显存估算函数的实现def estimate_memory(request: InferRequest) - float: base 16.0 # 模型权重和固定开销 seq_len len(request.text) dynamic seq_len * 0.002 # 每 token 约 2MB return base dynamic这个估算函数需要根据你的模型和输入类型调整。关键是base要实测dynamic部分要留足余量。如果输入是图像按分辨率估算如果是音频按时长估算。7.4 监控与动态调整在生产环境里我建议加一个后台任务定期检查显存使用情况如果发现实际使用远低于预算可以适当提高并发如果频繁 OOM就降低并发。这个逻辑可以用一个简单的反馈控制async def monitor_gpu(): while True: await asyncio.sleep(60) actual torch.cuda.memory_allocated() / 1024**3 if actual controller.total_gb * 0.9: controller.total_gb * 0.9 elif actual controller.total_gb * 0.5: controller.total_gb min( controller.total_gb * 1.1, torch.cuda.get_device_properties(0).total_memory / 1024**3 * 0.8 )这个逻辑不是必须的但在负载波动大的场景下很有用。注意调整时要平滑不要频繁大幅变动否则会导致并发数震荡。8. 压测验证怎么确认并发控制真的生效8.1 用 locust 或 wrk 模拟并发写完并发控制后一定要压测。我一般用 locust因为它能模拟逐步增加的并发用户并且可以自定义请求体。测试脚本大概这样from locust import HttpUser, task, between class InferUser(HttpUser): wait_time between(0.1, 0.5) task def infer(self): self.client.post(/infer, json{text: 测试输入 * 50})启动 locust 后逐步增加用户数观察响应时间和错误率。如果并发控制生效响应时间会随着用户数增加而上升但错误率应该保持为零除了超时返回的 503。如果错误率飙升说明并发控制没起作用或者显存估算不准。8.2 观察显存曲线确认没有尖峰压测时用nvidia-smi -l 1或者watch -n 1 nvidia-smi观察显存变化。正常的曲线应该是阶梯状请求进来时显存上升推理完成后下降但不会超过设定的阈值。如果看到显存突然冲到 100% 然后服务崩溃说明并发控制有漏洞。我还会在代码里记录每次推理的峰值显存压测后统计 P50、P95、P99。如果 P99 接近总显存说明安全边际不够需要降低并发数或者优化模型。8.3 故意触发 OOM 看恢复能力压测不仅要测正常情况还要测异常恢复。我会故意把并发数调大让它 OOM然后观察服务是否能自动恢复。好的并发控制应该在 OOM 后捕获异常、清理显存、降低准入阈值然后继续服务后续请求。如果 OOM 后服务直接挂掉或者所有后续请求都失败说明异常处理不完善。测试方法是把controller.total_gb临时设成一个很小的值然后发请求看是否返回 503 而不是 500。然后再恢复正常值看服务是否自动恢复。8.4 长时间稳定性测试最后跑一个 24 小时的长稳测试。用固定并发持续发请求观察显存是否缓慢增长内存泄漏、响应时间是否逐渐变长、错误率是否随时间上升。我遇到过一个问题服务跑几个小时后开始变慢查下来是日志文件太大导致磁盘 IO 瓶颈。所以长稳测试也能发现推理之外的问题。长稳测试期间建议开启torch.cuda.memory_summary()的定期输出看看显存分配器的状态。如果 reserved 显存持续增长不下降可能存在碎片化或者泄漏。9. 一些实战中的个人体会并发控制这件事说起来简单做起来全是细节。我最大的体会是不要相信任何“理论上够用”的估算一定要实测。理论计算告诉你 24GB 卡能跑两个 8GB 的请求但实际跑起来可能因为碎片化、CUDA 上下文开销、临时缓冲区等原因第二个请求就 OOM。所以安全系数不是保守是必要。另一个体会是排队比崩溃好但排队也要有上限。无限排队会导致请求越积越多最终内存爆掉或者客户端全部超时。所以一定要设最大排队长度和最大等待时间超过就拒绝。拒绝虽然不友好但比整个服务挂掉友好得多。还有一点监控比控制更重要。你不可能预知所有异常情况但只要有完善的监控就能在问题发生时快速定位。我一般会在服务里暴露一个/metrics接口返回当前显存使用、排队长度、推理延迟等指标然后用 Prometheus 抓取。这样即使半夜服务出问题也能从监控面板上看到原因。最后如果你的服务要长期运行建议把推理进程和 Web 进程分开。Web 进程可以随时重启更新代码推理进程保持模型常驻。这样既保证了灵活性又避免了频繁加载模型带来的显存波动。这个架构一开始搭起来麻烦一点但后期维护会轻松很多。