news 2026/10/6 15:07:28

FastAPI GPU推理并发控制:避免显存溢出与OOM实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
FastAPI GPU推理并发控制:避免显存溢出与OOM实战

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_tensors="pt").to("cuda") outputs = model(**inputs) result = processor.decode(outputs[0], skip_special_tokens=True)

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 请求堆积的连锁反应

显存溢出只是第一张倒下的多米诺骨牌。一旦某个请求 OOM,PyTorch 会抛出异常。如果这个异常没有被捕获,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 result

asyncio.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*2=32>24)。如果预算是 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 是 4096,num_layers 是 32。如果输入长度 512,batch 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,输出 512,KV 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(), timeout=30) except asyncio.TimeoutError: raise HTTPException(status_code=503, detail="服务繁忙,请稍后重试")

注意wait_for超时后,信号量的acquire可能还在等待。如果不处理,会导致信号量泄漏。正确的做法是用asyncio.timeout上下文管理器(Python 3.11+)或者在超时后手动取消:

acquire_task = asyncio.create_task(gpu_semaphore.acquire()) done, pending = await asyncio.wait([acquire_task], timeout=30) if pending: acquire_task.cancel() raise HTTPException(status_code=503, 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), timeout=60) except asyncio.TimeoutError: torch.cuda.empty_cache() raise HTTPException(status_code=504, 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_usage=True,会在 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_CONF=expandable_segments:True环境变量,让分配器支持可扩展段,减少碎片。

最后这个环境变量在 PyTorch 2.1 之后支持,实测能明显降低碎片化导致的 OOM。设置方法是在启动命令前加:

PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True uvicorn main:app --workers 1

7. 一套可复用的并发控制代码骨架

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(), timeout=timeout ) except asyncio.TimeoutError: raise HTTPException( status_code=503, detail=f"服务繁忙,当前排队 {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_gb=total, safety_factor=0.8) yield torch.cuda.empty_cache() app = FastAPI(lifespan=lifespan)

这段代码在启动时读取真实显存,乘以安全系数,然后创建控制器。acquire方法会等待直到显存预算够用,超时则返回 503。

7.2 推理接口的完整包装

@app.post("/infer") async def infer(request: InferRequest): need_gb = estimate_memory(request) await controller.acquire(need_gb, timeout=30.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_code=500, 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 进程可以随时重启更新代码,推理进程保持模型常驻。这样既保证了灵活性,又避免了频繁加载模型带来的显存波动。这个架构一开始搭起来麻烦一点,但后期维护会轻松很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 15:06:52

嵌入式以太网接口详解:MAC与PHY之间的MII/RMII/RGMII/SGMII选型指南

1. 先把底层的分工搞清楚&#xff1a;MAC和PHY到底各管哪一段很多嵌入式工程师第一次接触以太网时&#xff0c;最困惑的不是那堆协议栈代码&#xff0c;而是硬件上那一堆引脚到底谁管谁。我以前带过好几个新人&#xff0c;给他们扔一块带网口的核心板&#xff0c;问“MAC在哪、…

作者头像 李华
网站建设 2026/10/6 15:06:49

从CUDA到昇腾CANN 7.0:算子迁移与性能调优实战

作为一个在CUDA上摸爬滚打多年的老开发者&#xff0c;我一直觉得异构计算的世界里&#xff0c;NVIDIA就是默认选项。直到去年底接到一个新项目&#xff0c;目标平台是昇腾310P&#xff0c;我才意识到自己那套CUDA经验并不总能直接平移过去。当时拿到CANN 7.0的安装包&#xff0…

作者头像 李华
网站建设 2026/10/6 15:06:02

本地部署个人知识库:Ollama+FAISS+Python实现离线RAG问答

1. 为什么我要自己搭一个知识库 先说结论&#xff1a;我搭这套东西的起因特别朴素——受够了。受够了收藏夹里躺着几百篇“稍后再读”结果再也没打开过&#xff0c;受够了每次写方案都要重新翻聊天记录找半年前同事发的那份参数表&#xff0c;更受够了把公司内部文档传到各种在…

作者头像 李华
网站建设 2026/10/6 15:05:57

湖景农家菜招牌菜怎么选?从湖鲜土灶到时令蟹季的完整拆解

很多人第一次搜“湖景农家菜一般有哪些招牌菜”&#xff0c;背后其实有两层诉求&#xff1a;一是想吃上地道的农家土菜&#xff0c;二是想坐在能看见湖的地方慢慢吃。湖景农家菜的招牌菜&#xff0c;通常不是某一道固定菜&#xff0c;而是一套围绕“湖鲜土灶时令”组合出来的菜…

作者头像 李华
网站建设 2026/10/6 15:05:17

本地优先云端兜底:Dify+Ollama+DeepSeek私有AI中台实战

1. 为什么我决定不再把核心业务逻辑交给云端 API 1.1 从一次线上事故说起 去年冬天的一个凌晨&#xff0c;我负责的一个内部知识问答系统突然大面积超时。排查了半小时才发现&#xff0c;是上游大模型 API 的调用配额在高峰期被限流了。那一刻我意识到一个问题&#xff1a; 当…

作者头像 李华
网站建设 2026/10/6 15:03:40

伺服编码器差分接线实战:高创CDHD2与雷赛L8EC避坑指南

伺服调试现场最让人抓狂的场景之一&#xff0c;就是电机使能后原地抖动、飞车&#xff0c;或者上位机报位置偏差过大&#xff0c;而排查了半天最后发现——问题出在编码器那六根差分线上。A/A-、B/B-、Z/Z-&#xff0c;看着简单&#xff0c;接错一根或者屏蔽层处理不当&#xf…

作者头像 李华