vLLM推理服务假死排查-多模态缓存幽灵Key导致死循环

契机

生产环境中的vLLM推理服务突然假死:进程存活、显存正常占用、健康检查端点返回正常,但所有推理请求全部超时。GPU-util为0,CPU拉到100,极端环境出现,特指高并发

环境说明

  • 推理框架:vLLM-v0.23.0

  • 模型:多模态大模型

  • 驱动:Driver Version: 575.57.08

  • cuda:release 12.9, V12.9.86

  • 启动参数

    复制代码
    VLLM_LOGGING_LEVEL=DEBUG \
    PYTHONUNBUFFERED=1 \
    CUDA_VISIBLE_DEVICES=x \
    nohup /home/conda/envs/vllm/bin/vllm serve /home/models/xxxx \
      --served-model-name xxxx \
      --host 0.0.0.0 \
      --port 20100 \
      --trust-remote-code \
      --enable-prefix-caching \
      --gpu-memory-utilization 0.9 \
      --uvicorn-log-level debug \
      --log-error-stack \
      --enable-log-requests \
      --enable-log-outputs \
      --enable-request-id-headers \
      --max-log-len 2000 \
      --enable-logging-iteration-details \
      > vllm_debug.log 2>&1 &
  • 运行时长:服务已稳定运行数天(约 100 万次迭代),突发假死

这里多说一嘴,由于我是12.9的驱动所以装vllm相当吃力,生产环境又不敢升级驱动,如果你刚好也是12.x驱动,一定要求https://vllm.ai/找安装方法,并且安装过程需要把加速源全部禁用,安装过程中观察下载连接和地址,一定不要从镜像下载!

pip install vllm --extra-index-url https://wheels.vllm.ai/0.24.0/cu129 --extra-index-url https://download.pytorch.org/whl/cu129 --index-strategy unsafe-best-match

故障现象

检测项 状态 备注
进程 PID 存活 EngineCore
GPU 显存 正常 正常
GPU 利用率 0% 异常
CPU 利用率 136% EngineCore 进程,异常高
/health 端点 200 OK 正常返回
/v1/models 端点 200 OK 模型列表正常
/v1/chat/completions 超时 15s+ 无响应

诊断过程

外部观测

yaml 复制代码
# 1. GPU状态
nvidia-smi
# GPU : 显存 xxxxMiB / 利用率 0%

# 2. 进程CPU占用
top -H -p 3791245
# EngineCore 子进程 CPU 136%,远超正常值

# 3. 健康检查
curl -s http://0.0.0.0:20100/health
# 返回 OK

# 4. 推理请求
curl -s --max-time 15 http://0.0.0.0:20100/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{...}'
# 超时,无响应

服务处于假死状态------http层存活但调度引擎冻结

debug日志分析

yaml 复制代码
# 5. 查找最后一次正常推理
grep -a "generation tokens" debug.log | tail -20
# 最后一次有实际生成吞吐:00:30:42,之后全部归零

# 6. 查找EngineCore最后一条日志
grep -a "EngineCore" debug.log | tail -10
# 最后一条:00:30:33 "EngineCore waiting for work"
# 之后 EngineCore 再无任何输出

EngineCore在00:30:33之后完全静默,主循环停止运转

Metrics端点

yaml 复制代码
# 7. 查看vLLM内部指标
curl -s http://0.0.0.0:30000/metrics | grep -E "running|waiting|queue"
# vllm:num_requests_running: 0
# vllm:num_requests_waiting: 0

请求已被APIServer接收(日志中可见 "Added request"),但EngineCore的调度器显示 Running: 0, Waiting: 0 ------ 请求在到达调度器之前就消失了。

py-spy抓栈

yaml 复制代码
# 8. 安装 py-spy(不依赖 ptrace,通过 /proc 读取)
pip install py-spy

# 9. 抓取 EngineCore 进程的 Python 调用栈
py-spy dump --pid 3794291 --locals

#Thread-2持有GIL,在popitem中死循环。由于CPython的GIL机制,该线程阻塞了EngineCore所有其他Python线程
Thread 3821499 (active+gil): "Thread-2 (process_input_sockets)"
    popitem (vllm/utils/cache.py:196)      !!卡在这里!!
    __setitem__ (cachetools/__init__.py:82)
    __setitem__ (cachetools/__init__.py:294)
    get_and_update_item (vllm/multimodal/cache.py:662)
    get_and_update_features (vllm/multimodal/cache.py:607)
    preprocess_add_request (vllm/v1/engine/core.py:829)
    process_input_sockets (vllm/v1/engine/core.py:1528)

strace确认

yaml 复制代码
# 10. strace跟踪EngineCore子进程
strace -p 3794291 -f -e trace=futex -c
# 3 秒内仅 8 次 futex 调用(微秒级)
# 其余时间全部在用户态Python代码中空转

确认纯用户态 CPU 消耗,没有系统调用阻塞,是Python死循环

源码分析

yaml 复制代码
# cachetools/__init__.py:82
class Cache:
    def __setitem__(self, key, value):
        ...
        while self.__currsize + size > self.maxsize:
            self.popitem()     # ← 调用 vllm 重写的 popitem
            
            
# vllm/utils/cache.py:196
def popitem(self, remove_pinned=False):
    lru_key = next(key for key in self.order if key not in self.pinned_items)
    value = self.pop(lru_key)  # ← pop 一个不在实际缓存中的 key
    return (lru_key, value)
    # 问题:pop() 对不存在的 key 返回 None,currsize 没有减少
    # 回到 cachetools 的 while 循环:currsize + size > maxsize 仍为真
    # → 再次 popitem → 再次失败 → 死循环

刚好去搜索日志中的异常

yaml 复制代码
grep -a "AssertionError\|Expected a cached" debug.log

#找到报错日志
(EngineCore pid=3794291) ERROR 06-29 00:28:41 [v1/engine/core.py:1616]
AssertionError: Expected a cached item for mm_hash='a874ce...4739d0'

结论:

  1. touch() 方法(cache.py#L120-L124)对不存在的 key 将其写入内部 LRU 顺序链表 __order,但不写入实际缓存数据 _Cache__data
  2. get_and_update_item()(cache.py#L660)在 assert 失败时,setitem 未被执行,但 touch() 已写入的幽灵 key 残留在 __order 中
  3. 后续任意请求触发缓存写入 → setitem 进入驱逐循环
  4. popitem() 从 __order 取出幽灵 key → pop() 找不到实际数据 → currsize 不减 → 死循环

验证复盘

yaml 复制代码
# vLLM GitHub Issue #43941
# 标题: "Bug: Infinite loop in EngineCore during multimodal cache eviction"
# 状态: 已关闭,合入 PR #43595
# 修复版本: v0.24.0

完全匹配的已知 Bug,与我们的 py-spy 堆栈、日志、时间线一致

复制代码
时间线:
  00:28:41  多模态缓存 AssertionError 触发
             └→ touch() 已将幽灵 key 写入 __order
             └→ assert 失败,__setitem__ 未执行,幽灵 key 残留
  00:28:41  引擎继续处理其他请求(幽灵 key 暂不影响)
  00:30:33  某请求触发缓存写入 → __setitem__ 进入驱逐循环
             └→ popitem() 取出幽灵 key
             └→ pop() 返回 None,currsize 未减少
             └→ while 循环条件永真 → 死循环
             └→ Thread-2 持有 GIL,EngineCore 完全冻结
  00:30:33+ 所有后续请求均失败,客户端超时
位置 代码 问题
cache.py#L120-L124 touch() 的 except 分支 将不存在的 key 写入 __order,产生幽灵 key
cache.py#L196-L208 popitem() 驱逐逻辑 未检查 key 是否真实存在于缓存中
cache.py#L658-L664 get_and_update_item() touch()__setitem__ 之间非原子,异常导致不一致

修复方案

本着不升级vllm的方案,我直接去改conda环境中包的源码

touch-不存在的key不操作

复制代码
# 原代码(L120-L124)
def touch(self, key: _K) -> None:
    try:
        self._LRUCache__order.move_to_end(key)
    except KeyError:
        self._LRUCache__order[key] = None    # ← 幽灵 key 来源

# 修复后
def touch(self, key: _K) -> None:
    if key in self:
        self._LRUCache__order.move_to_end(key)

popitem---跳过并清理幽灵key

复制代码
# 原代码(L196-L208)
def popitem(self, remove_pinned: bool = False):
    if not remove_pinned:
        lru_key = next(
            (key for key in self.order if key not in self.pinned_items),
            ALL_PINNED_SENTINEL,
        )
        if lru_key is ALL_PINNED_SENTINEL:
            raise RuntimeError(...)
    else:
        lru_key = next(iter(self.order))
    value = self.pop(cast(_K, lru_key))
    return (lru_key, value)

# 修复后
def popitem(self, remove_pinned: bool = False):
    while True:
        if not remove_pinned:
            lru_key = next(
                (key for key in self.order if key not in self.pinned_items),
                ALL_PINNED_SENTINEL,
            )
            if lru_key is ALL_PINNED_SENTINEL:
                raise RuntimeError(...)
        else:
            lru_key = next(iter(self.order))

        if lru_key in self:            # ← 仅驱逐真实存在的数据
            value = self.pop(cast(_K, lru_key))
            return (lru_key, value)

        self._LRUCache__order.pop(lru_key, None)  # ← 清理幽灵 key

两层防御:改动1从源头杜绝幽灵key产生,改动2确保即使有其他路径产生幽灵key,驱逐时也不会死循环

对应上游PR:vllm-project/vllm#43595,已合入v24

复现路径

  • vLLM v0.23.0 或更早版本
  • 使用多模态模型(视觉-语言模型),启用prefix caching
  • 在缓存接近容量上限时,发送一条触发了多模态hash不存在于缓存的请求
yaml 复制代码
# 1. 启动 vLLM 服务(使用 v0.23.0)
vllm serve <多模态模型路径> \
  --host 0.0.0.0 --port 20100 \
  --enable-prefix-caching \
  --gpu-memory-utilization 0.9

# 2. 发送大量多模态请求填满缓存
for i in $(seq 1 500); do
  curl -s http://0.0.0.0:20100/v1/chat/completions \
    -H "Content-Type: application/json" \
    -d '{"model":"...","messages":[{"role":"user","content":[{"type":"text","text":"describe"},{"type":"video_url","video_url":"<video_path>"}]}]}' &
done

# 3. 发送触发幽灵 key 的请求
#    需满足:mm_hash 在 cache 的 __order 中但不在 _Cache__data 中
#    实际场景中较难精确控制,高并发下概率触发

# 4. 验证假死
curl -s --max-time 10 http://0.0.0.0:20100/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"...","messages":[{"role":"user","content":"hello"}]}'
# 预期:超时

# 5. 验证根因
py-spy dump --pid <EngineCore_pid> --locals
# 预期:Thread-2 卡在 vllm/utils/cache.py popitem()

单元测试如下

yaml 复制代码
# 模拟 touch() 产生的幽灵 key
from vllm.utils.cache import LRUCache

cache = LRUCache(capacity=100, getsizeof=lambda x: 10)
cache["real_key"] = "real_value"

# 模拟 touch() 对不存在的 key 写入 __order
cache._LRUCache__order["ghost_key"] = None

# 填满缓存触发驱逐
for i in range(9):
    cache[f"key_{i}"] = f"value_{i}"

# 此时再次 setitem 会触发 popitem()
# 如果 popitem 没有防御性检查,将死循环
try:
    cache["new_key"] = "new_value"
except Exception:
    pass  # 在修复前会死循环

总结