【Bug已解决】[Bug]: POST /wake_up causes vLLM process to crash. 500 Internal Server Error 解决方案
【Bug已解决】[Bug] POST /wake_up causes vLLM process to crash. 500 Internal Server Error 解决方案一、现象长什么样vLLM 启用了 sleep 模式用/sleep把显存释放、模型卸载省电省卡后调用/wake_up把服务唤醒时HTTP 接口直接返回500 Internal Server Error而且更严重的是——整个 vLLM 进程跟着崩了不只是接口报错。日志里常见两种形态ERROR: Exception in ASGI application Traceback (most recent call last): ... File vllm/entrypoints/openai/api_server.py, in wake_up await engine.wake_up() RuntimeError: CUDA error: invalid argument (engine not in sleeping state)或者更迷惑的一种Process SpawnProcess-1: Traceback (most recent call last): ... File vllm/v1/executor/multiproc_executor.py, in wake_up AssertionError: worker 0 reported stateRUNNING but expected SLEEPING最坑的地方在于这个问题不是 100% 复现。冷静期长一点sleep 后等几十秒再 wake_up往往能成功但 sleep 后立刻wake_up或者 wake_up 过程中又来了一个推理请求就大概率崩。于是测试环境「没问题」压测或生产环境「时灵时不灵」极难定位。二、背景vLLM 的 sleep/wake 是一套省资源机制POST /sleep把 GPU 上的模型权重、KV 缓存等显存释放offload 到 CPU 或干脆cudaFree引擎状态置为SLEEPING。此时不占卡但接口还活着随时能被唤醒。POST /wake_up把权重重新加载回 GPU重建 KV 缓存池状态回到RUNNING。这套机制在 vLLM V1 引擎里是跨进程的API 服务器uvicorn/ASGI跑在主进程真正的引擎核心跑在SpawnProcess子进程里multiproc executor。/wake_up这个 HTTP 请求由主进程的 ASGI handler 接收然后它要跨进程通知引擎子进程去重新初始化 CUDA、重新分配显存。问题就藏在这个「跨进程 异步」的交接里。三、根因根因是wake_up 的 HTTP handler 没有等引擎真正就绪就提前返回了「成功」而真正的崩溃发生在异步的唤醒流程中途且这个中途崩溃没有被 handler 捕获于是冒泡成了 500 并把子进程带崩。拆开看有三层第一层状态机缺少「WAKING」中间态。引擎状态只有RUNNING/SLEEPING两态。当收到 wake_up 后子进程开始干活重新cudaMalloc、重新加载权重这段期间状态其实既不是 SLEEPING 也不是 RUNNING但代码里没有WAKING这个中间态。于是主进程的 handler 以为 wake_up 一发就完事了立刻回 200或还没回就遇到异常子进程还在cudaMalloc中途此时如果又来了一个/wake_up比如客户端重试或一个普通推理请求handler 看到状态「不是 SLEEPING 也不是 RUNNING」走到未定义分支直接 assert 失败。第二层唤醒失败没有回滚到 SLEEPING。子进程在重建权重时如果某一步失败最常见是cudaMalloc失败因为别的进程/碎片占着显存它既没有抛出一个能被主进程捕获的干净异常也没有把自己状态回滚成 SLEEPING而是让子进程直接抛 RuntimeError。ASGI 的 handler 没try/except包住await engine.wake_up()异常冒泡 → 500 → 子进程死 → 主进程发现 executor 没了也跟着退出。第三层wake_up 与在线请求缺乏互斥。/wake_up和/v1/completions走的是同一个 event loop。wake_up 还没完成时新请求直接进入调度器调度器发现引擎状态异常触发 assert。这本该由「状态机 请求排队」挡住但当时缺这层保护。一句话wake_up 把「发指令」当成「已完成」且崩溃没被 handler 捕获、没回滚、没和在线请求互斥于是一次唤醒失败就演变成进程级崩溃。四、最小可运行复现下面用纯 Python 的multiprocessing模拟「主进程发 wake_up 指令 → 子进程异步唤醒 → 中途失败 → 因为没有捕获导致整个进程组崩」的骨架不需要 GPU 就能跑出同样的控制流问题import multiprocessing as mp import time from enum import Enum class State(Enum): RUNNING 1 SLEEPING 2 # 注意原版没有 WAKING 态 def worker(state_q: mp.Queue, cmd_q: mp.Queue): state State.SLEEPING state_q.put(state) while True: cmd cmd_q.get() if cmd wake_up: # 模拟唤醒先干一半活然后 cudaMalloc 失败 time.sleep(0.2) # 原版失败时直接抛没有回滚、没有捕获 raise RuntimeError(cudaMalloc failed during wake_up) if cmd stop: return def main(): state_q, cmd_q mp.Queue(), mp.Queue() p mp.Process(targetworker, args(state_q, cmd_q), daemonFalse) p.start() state_q.get() try: # 主进程发 wake_up 指令以为发完就成功 cmd_q.put(wake_up) time.sleep(0.5) # 等待子进程干活 # 子进程已崩溃这里 p.is_alive() 会是 False if not p.is_alive(): print(子进程已崩整个服务不可用) except Exception as e: print(handler caught:, e) p.join() if __name__ __main__: main()跑这段会看到子进程因为未捕获的RuntimeError退出主进程侧p.is_alive()为 False——和线上「wake_up 一调进程就没了」是同一个控制流形状。五、解决方案第一层最小直接修复最省事的救火方案在 HTTP handler 里用try/except包住 wake_up 调用失败就回 503 而不是 500并且不让异常冒泡到 ASGI 顶层把进程带崩。同时保证「一次只唤醒一次」。from fastapi import HTTPException async def safe_wake_up(engine, in_flight): # 用一把锁防止并发 wake_up / wake_up 与推理打架 if not in_flight.acquire(blockingFalse): raise HTTPException(status_code409, detailwake_up already in progress) try: await engine.wake_up() return {status: awake} except Exception as e: # 关键捕获回 503绝不冒泡 raise HTTPException(status_code503, detailfwake_up failed: {e}) finally: in_flight.release()如果只是为了先让服务不崩这一层就够了wake_up 失败现在返回 503客户端可以重试进程稳稳活着。六、解决方案第二层结构性改进真正的修复是给状态机加WAKING中间态并且唤醒失败时回滚到 SLEEPING让引擎始终处于一个「明确定义」的状态from enum import Enum from dataclasses import dataclass class State(Enum): RUNNING 1 SLEEPING 2 WAKING 3 # 新增唤醒进行中 dataclass class EngineStateMachine: state: State State.SLEEPING def begin_wake_up(self) - None: assert self.state State.SLEEPING, ( fwake_up 只能在 SLEEPING 状态发起当前 {self.state} ) self.state State.WAKING def complete_wake_up(self) - None: assert self.state State.WAKING self.state State.RUNNING def rollback_to_sleep(self) - None: # 唤醒失败回滚保证状态机不卡在非法态 self.state State.SLEEPING def accept_requests(self) - bool: # 只有 RUNNING 才接推理请求WAKING 期间排队 return self.state State.RUNNING子进程侧配套def worker_safe(state: EngineStateMachine, cmd_q: mp.Queue): while True: cmd cmd_q.get() if cmd wake_up: state.begin_wake_up() # 进入 WAKING try: reload_weights_to_gpu() # 可能失败的步骤 rebuild_kv_pool() except Exception: state.rollback_to_sleep() # 失败回滚不崩 cmd_q.put(wake_up_failed) continue state.complete_wake_up() # 成功才到 RUNNING cmd_q.put(awake)主进程 handler 改成「等子进程回报awake或wake_up_failed再回响应」而不是「发完指令就回」async def wake_up_blocking(engine, result_q): await engine.wake_up() # 内部已改为阻塞直到子进程回报 # 子进程要么回报 awake要么回报 failedhandler 不会再提前返回这样 wake_up 期间新来的推理请求看到状态是WAKING调度器直接「排队」而不是 assert 崩溃。七、解决方案第三层断言 / CI 守护把「状态机合法迁移」和「失败回滚」固化成测试import pytest def test_wake_up_from_running_rejected(): sm EngineStateMachine(stateState.RUNNING) with pytest.raises(AssertionError): sm.begin_wake_up() def test_wake_up_happy_path(): sm EngineStateMachine(stateState.SLEEPING) sm.begin_wake_up() assert sm.state State.WAKING sm.complete_wake_up() assert sm.state State.RUNNING def test_wake_up_failure_rolls_back(): sm EngineStateMachine(stateState.SLEEPING) sm.begin_wake_up() # 模拟 reload 失败 sm.rollback_to_sleep() assert sm.state State.SLEEPING def test_no_requests_during_waking(): sm EngineStateMachine(stateState.WAKING) assert sm.accept_requests() is False def test_concurrent_wake_up_guarded(): import threading sm EngineStateMachine(stateState.SLEEPING) sm.begin_wake_up() # 第二个并发 wake_up 必须在 begin 阶段就 assert 失败 with pytest.raises(AssertionError): sm.begin_wake_up()再加一个端到端回归模拟 wake_up 失败断言服务进程仍然存活、且状态回到 SLEEPINGdef test_process_survives_wake_up_failure(): state_q, cmd_q mp.Queue(), mp.Queue() p mp.Process(targetworker_safe, args(EngineStateMachine(), cmd_q)) p.start() cmd_q.put(wake_up_simulate_fail) outcome cmd_q.get() # 期待 wake_up_failed assert outcome wake_up_failed assert p.is_alive() is True # 进程没崩 p.join(timeout2)八、排查清单先确认是不是 sleep 相关grep wake_up看崩溃栈是否落在wake_up/multiproc_executor。看状态机崩溃前引擎最后打印的状态是 SLEEPING 还是 RUNNING若是「都不是」的非法态基本坐实缺 WAKING 态。是否在 wake_up 刚发完就来了推理请求或第二次 wake_up加并发锁验证是否消失。看子进程是否还活着p.is_alive()。若子进程没了、主进程也跟着退是异常没捕获导致冒泡。临时救火handler 包try/except回 503或 sleep 后等几秒再 wake_up绕开竞态。长期修复加 WAKING 态 失败回滚 请求互斥排队。升级 vLLM 到合了 sleep/wake 状态机修复的版本并跑上面的回归用例确认。九、小结POST /wake_up把进程带崩表面看是「CUDA 报错」根子上是状态机设计缺陷 异常没被 handler 捕获 唤醒与在线请求缺乏互斥。最小修复是给 handler 加try/except让它优雅返回 503 而不是 500结构性修复是引入WAKING中间态、失败时回滚到SLEEPING、唤醒期间请求排队最后用 pytest 把「状态迁移合法」和「失败不崩进程」锁死。这类「异步跨进程 状态机」的坑在调度系统里很常见抓住「指令发出 ≠ 操作完成」这一条就能提前避开。
