vllm v1 源码精读(三):KV Cache 管理、Chunked Prefill 与异步架构

vllm v1 源码精读(三):KV Cache 管理、Chunked Prefill 与异步架构

本文基于 vllm ba22152(2026-07-06),源码链接均指向该 commit 的固定行号。 代码浏览入口:github.com/vllm-project/vllm/tree/ba22152

系列文章


1. 前言

上一篇把 EngineCore.step() 的调用链走了一遍:调度 → GPU forward → 采样 → 状态更新。”调度”这一步内部发生了什么,上一篇故意跳过了——这篇专门把它拆开。

这篇文章围绕三个独立但相关的机制:KV Cache 怎么管(显存分页与前缀复用)、Chunked Prefill 怎么工作(prefill 和 decode 如何混排)、HTTP serving 的异步架构(为什么要独立进程 + ZMQ)。三者共同服务于 Scheduler 的调度决策,缺任何一块都撑不起一个高吞吐的推理服务。


2. 整体:三个机制共同服务于调度层

在拆细节之前,先理解三个机制各自解决什么问题,以及它们在代码里分别住在哪里。

EngineCore.step()
  ├─ Scheduler.schedule()          ← 调度决策(持有 KVCacheManager)
  │    ├─ KVCacheManager            → 管理 KV 块的分配/释放/前缀缓存
  │    └─ num_computed_tokens       → Chunked Prefill 的关键字段
  │
  ├─ Executor.execute_model()      ← GPU forward(内部用 slot_mapping 读写 KV)
  │
  └─ [HTTP serving 场景]
       AsyncLLM + ZMQ              → 独立进程通信,绕开 Python GIL
  • KV Cache 管理:解决”显存碎片”——不同请求的序列长度差异大,预分配最长显存会有大量浪费,改用分页管理
  • Chunked Prefill:解决”prefill 饿死 decode”——长 prompt 的 prefill 被切成多步,和 decode 混在同一个 batch 里执行
  • 异步架构:解决”GIL 互相拖慢”——HTTP server(asyncio)和 EngineCore(调度循环)放在同一进程里会互相争 GIL,分到独立进程才能真正并行

3. KV Cache 管理

3.1 为什么要分块:显存碎片问题

LLM 推理的显存大头是 KV Cache,不是模型权重。以 LLaMA-3-8B(GQA,8 个 KV head,head_dim=128,fp16)为例:

  • 每层每个 token 的 KV Cache:$2 \times 8 \times 128 \times 2\ \text{bytes} = 4096\ \text{bytes}$
  • 32 层合计:$32 \times 4096 = 131072$ bytes ≈ 128 KB/token
  • 一个 4K 上下文的请求:$4096 \times 128\ \text{KB} \approx 512\ \text{MB}$

batch 里有 10 个这样的请求,仅 KV Cache 就需要 5 GB。更头疼的是,不同请求的序列长度差异极大。如果每个请求都预分配”最长可能序列”的显存,内存碎片极其严重——可能 80% 的显存空闲着,却没法给其他请求用。

PagedAttention 的思路和 OS 虚拟内存分页几乎一模一样:把 KV Cache 切成固定大小的”页”(块),物理上不连续,用地址表(block table)维护逻辑序列到物理块的映射。理解了 OS 虚拟内存,这套设计的动机就一目了然。

3.2 三层数据结构全景

vllm 的 KV Cache 数据结构分三层,下图展示了三层之间的持有关系与数据流:

KV Cache 块管理:PagedAttention 核心数据结构

  • 左侧(逻辑层):请求视角,序列按 block_size 切成逻辑块,block_table 记录每个逻辑块对应哪个物理 block_id,slot_mapping 进一步细化到每个 token 的物理槽
  • 右侧(BlockPool):管理器视角,FreeKVCacheBlockQueue 维护空闲块,Prefix Cache 缓存已计算过的块
  • 底部(GPU 物理层):实际的 KV Cache tensor,shape [num_blocks, 2, block_size, num_kv_heads, head_dim]

三条主线贯穿整个图:分配链(请求来了 → popleft() 取块 → 写入 GPU)、前缀复用链(hash 命中 → 跳过 prefill)、释放链(请求完成 → 归还到 FreeQueue)。

3.3 KVCacheBlock 和 FreeKVCacheBlockQueue

这里要先说清楚两个层次的东西:

KVCacheBlock 是 CPU 侧的元数据对象,住在 KVCacheManager 里,只记”这个块被谁占着、ref_cnt 是多少、hash 是什么”,不存任何实际的 K/V 向量

# vllm/v1/core/kv_cache_utils.py
# class KVCacheBlock(dataclass)
class KVCacheBlock:
    block_id: int                           # 物理块编号(0..num_blocks-1),是索引 key
    ref_cnt: int                            # 引用计数,0 表示空闲可分配
    _block_hash: BlockHashType | None       # 内容 hash(None = 还没填满)
    prev_free_block: KVCacheBlock | None    # 双向链表指针
    next_free_block: KVCacheBlock | None

kv_cache 是 GPU 上预分配的大 tensor,住在 attention layer 里(由 EngineCore._initialize_kv_caches() 在启动时一次性分配),才是实际存 K/V 向量的地方

# 在 GPUModelRunner 初始化时,attention layer 持有这个 tensor
# shape: [num_blocks, 2, block_size, num_kv_heads, head_dim]
# 其中 2 表示 K 和 V 各一份

# 具体例子:block_size=16, num_kv_heads=8, head_dim=128, 共分配了 1000 个块
# kv_cache.shape = [1000, 2, 16, 8, 128]  ← 一次分配好,不动

两者通过 block_id 连接:KVCacheBlock.block_id = 42 意味着这个块的实际 K/V 数据存在 kv_cache[42]KVCacheManager 负责分配/回收 KVCacheBlock 对象(CPU 侧),attention kernel 用 block_id 去索引 kv_cache tensor(GPU 侧):

# KVCacheManager 分配了一个 block_id=42 的块给某个请求(CPU 侧记账)
block = KVCacheBlock(block_id=42, ref_cnt=1, ...)

# attention kernel 用 block_id 去读写实际数据(GPU 侧)
kv_cache[42, 0]  # 第 42 号物理块的 K cache,shape [16, 8, 128]
kv_cache[42, 1]  # 第 42 号物理块的 V cache,shape [16, 8, 128]
# kv_cache[42, 0, 3] 就是该块第 4 个 token 位置的 K 向量,shape [8, 128]

FreeKVCacheBlockQueue 维护所有 ref_cnt == 0 的块,是 KV Cache 管理最核心的数据结构。它是一个双向链表,按 hash 状态分两段:

head → [无hash块] → [无hash块] → [有hash块] → [有hash块] → tail
        ↑ 优先被分配(无缓存价值)                ↑ 尽量保留(前缀缓存候选)

四种操作全是 O(1):

操作 触发场景 位置
popleft() 分配新块 从 head 取
appendleft(block) 释放无 hash 块 插到 head(优先再用)
append(block) 释放有 hash 块 插到 tail(LRU 保留)
remove(block) 前缀缓存命中 从中间摘出

为什么用双链表而不是 deque 前缀缓存命中时,需要把命中的块从 free list 中间移除——它从空闲状态变为被占用。deque 中间移除是 O(n),双链表保存了前后指针,O(1) 就能摘出来。做过 LeetCode 146(LRU Cache)对这个模式很熟悉。

3.4 Block Table 和 slot_mapping:逻辑块到 GPU 物理槽

KVCacheManager 管的是”哪个逻辑块用哪个物理 block_id”,但 attention kernel 需要更细的粒度——每个 token 具体写入哪个物理槽。这一步转换靠 block_table 和 slot_mapping 完成。

slot_mapping 计算与 FlashAttention KV 读写

Block Tablevllm/v1/worker/block_table.py)是 CPU 侧 pinned numpy 数组,每步调度后异步 H2D 拷贝到 GPU:

# vllm/v1/worker/block_table.py
# class BlockTable
# 成员变量
self._block_table: np.ndarray
# shape [max_num_reqs, max_num_blocks_per_req], dtype int32
# block_table[req_i][j] = 请求 i 第 j 个逻辑块对应的物理 block_id

# 具体例子:假设 block_size=16,当前 batch 有 3 个请求
# 请求 0:prompt 20 tokens,分配了物理块 5 和 12
# 请求 1:prompt  8 tokens,分配了物理块 3
# 请求 2:prompt 35 tokens,分配了物理块 7、21、9
# block_table =
# [[  5, 12, -1],   # req 0:逻辑块 0→物理块 5,逻辑块 1→物理块 12,无第 3 块
#  [  3, -1, -1],   # req 1:逻辑块 0→物理块 3
#  [  7, 21,  9]]   # req 2:逻辑块 0→物理块 7,逻辑块 1→物理块 21,逻辑块 2→物理块 9

slot_mapping 从 block_table 算出每个 token 的物理槽,公式:

\[\text{slot\_id} = \text{block\_table}[\text{req}][\lfloor\text{pos} / B\rfloor] \times B + (\text{pos} \bmod B)\]

$B$ 是 block_sizepos 是 token 在请求中的绝对位置。沿用上面的例子,假设 block_size=16:

请求 0 的 token 17(pos=17):
  逻辑块 = 17 // 16 = 1  → 物理块 = block_table[0][1] = 12
  块内偏移 = 17 % 16 = 1
  slot_id = 12 * 16 + 1 = 193

请求 2 的 token 32(pos=32):
  逻辑块 = 32 // 16 = 2  → 物理块 = block_table[2][2] = 9
  块内偏移 = 32 % 16 = 0
  slot_id = 9 * 16 + 0 = 144

这个映射由 Triton kernel 并行计算(gpu_model_runner.py _compute_slot_mapping() 方法),对 batch 里所有请求的所有 token 同时算,输出 [N_tokens] 的 1D tensor,和 input_ids 的 1D varlen 格式完全对齐。

FlashAttention 2/3 原生支持 paged KV,所以读写接口很干净:

# vllm/v1/attention/backends/flash_attn.py
# class FlashAttentionImpl
# def forward(self, query, key, value, attn_metadata, ...)

# 写:把新计算的 K/V scatter 写入物理槽(按 slot_mapping 的位置)
ops.reshape_and_cache_flash(
    key, value, key_cache, value_cache, attn_metadata.slot_mapping, ...
)

# 读:直接用 block_table 做 paged attention
# 无需先 gather 成连续 buffer,避免了一次额外的显存拷贝
flash_attn_varlen_func(
    q=query, k=key_cache, v=value_cache,
    block_table=attn_metadata.block_table_gpu, ...
)

3.5 Prefix Caching:system prompt 不用重复计算

有一个很常见的场景:所有请求都带同一个长 system prompt,每次都重新 prefill 这几百 token 既浪费算力又慢。

Prefix Caching 的核心思路:只要两个请求的 token 前缀完全相同,对应的 KV 块可以直接复用,跳过 prefill

判断”前缀相同”靠滚动 hash。第 k 块的 hash 由该块的 token ids 和第 k-1 块的 hash 共同决定,保证了只要前缀 token 一致,hash 就一致,和物理位置无关:

# vllm/v1/core/kv_cache_utils.py
# def hash_request_tokens(token_ids, model_config)(伪代码)
# 第 k 块的 hash = f(token_ids_in_block_k, parent_hash_{k-1})

查询时要求连续命中,一旦 miss 就停(前缀必须连续,跳过中间的 miss 没有意义):

# vllm/v1/core/kv_cache_manager.py
# class KVCacheManager
# def get_computed_blocks(self, request)  第 110 行
def get_computed_blocks(self, request):
    # request.block_hashes 是提前算好的每个逻辑块的滚动 hash 列表
    # 例如:prompt 有 48 token,block_size=16,则有 3 个块的 hash
    # request.block_hashes = [hash_block0, hash_block1, hash_block2]
    computed = []
    for block_hash in request.block_hashes:
        block = self.prefix_cache_map.get(block_hash)
        if block is None:
            break   # 前缀必须连续,一旦 miss 就停
        computed.append(block)
    return computed  # 返回命中的块列表,传给 allocate_slots()

命中后,调度器将 request.num_computed_tokens 直接跳到 len(computed) × block_size,本轮跳过这些 token 的 prefill。例如命中了前 2 块(block_size=16),num_computed_tokens 直接设为 32,只 prefill 第 33 个 token 开始的部分。

哈希注册时机:每步 Scheduler.update_from_output() 调用后,被完整填满的 KV 块(内容确定了)才计算 hash 并写入 prefix_cache_map

# vllm/v1/core/kv_cache_manager.py
# class KVCacheManager
# def cache_blocks(self, request)
def cache_blocks(self, request):
    for block in request.kv_blocks:
        if block.is_full and block._block_hash is None:
            block._block_hash = compute_hash(request.token_ids, block)
            self.prefix_cache_map[block._block_hash] = block
            # 同时从 FreeKVCacheBlockQueue 的头部移到尾部(有 hash → LRU 保留)

被抢占的请求不一定从头来:被抢占后 KV 块释放,但有 hash 的块会进 LRU 尾部而不是立即消失。如果这期间显存够用,下次调度时前缀缓存可能还在,只需重新 prefill 新增部分。

3.6 块的完整生命周期

下图展示了一个请求从入队到 KV 块释放的完整时序,对应上面各个数据结构的工作节点:

KV Cache 分配与释放完整时序

① 入队时:请求进 waiting 队列,此时没有任何 KV 块,num_computed_tokens = 0

② 调度时Scheduler.schedule() 先调 get_computed_blocks() 查前缀缓存,命中则跳过对应 token 的 prefill;未命中则从 FreeKVCacheBlockQueue.popleft() 分配新块。分配失败(显存不够)则触发抢占。

③ 执行时GPUModelRunner._prepare_inputs() 把 block_table 翻译成 slot_mapping,FlashAttention 按 slot_mapping 写入新 K/V,同时按 block_table 读取历史 K/V 做 attention。

④ 完成时Scheduler.update_from_output() 先调 cache_blocks() 把已满的块注册进前缀缓存,再调 free() 归还所有块。有 hash 的块进 FreeQueue 尾部(LRU 保留),无 hash 的进头部(优先再用)。


4. Chunked Prefill:prefill 和 decode 混排

4.1 v0 的问题:prefill 饿死 decode

v0 里 prefill 和 decode 是两个独立阶段,交替执行——先把所有等待中的请求 prefill 完,再切换到 decode。一个 10000 token 的长 prompt,prefill 一次需要 GPU 独占几秒,这期间所有正在 decode 的请求全部卡住,用户侧完全没有输出,TTFT(首 token 延迟)被严重拖长。

另一个副作用是 GPU 利用率不稳定:prefill 是 compute-bound,decode 是 memory-bound,来回切换导致 GPU 无法稳定在最优工作点。

4.2 v1 的解法:num_computed_tokens + token_budget

v1 删掉了 prefill/decode 的阶段概念,每个请求只有一个字段 num_computed_tokens(已经过 forward pass 的 token 数)。正在 decode 的请求(num_computed_tokens == prompt_len)留在 self.running;还没 prefill 完的请求留在 self.waiting。调度器每步先处理 running 队列,再从 waiting 准入新请求:

# vllm/v1/core/sched/scheduler.py
# class Scheduler
# def schedule(self)  第 396 行(精简)

# remaining_token_budget 在循环外初始化:
# remaining_token_budget = self.scheduler_config.max_num_batched_tokens
# 例如 max_num_batched_tokens=256,表示这步最多处理 256 个 token(prefill+decode 合计)

# 先处理 running 队列(decode 请求每步只贡献 1 个 token,开销很小)
for request in self.running:
    remaining_token_budget -= 1  # decode 每步消耗 1 个 token budget

# 再从 waiting 准入新请求(这里的 request 全是还没 prefill 完的)
for request in self.waiting:
    # remaining 是这个请求还有多少 prompt token 没有过 forward pass
    # 例如:prompt 有 1000 token,已经 prefill 了 256,remaining = 744
    # decode 请求不在 self.waiting 里,所以 remaining 永远 > 0
    remaining = len(request.prompt_token_ids) - request.num_computed_tokens

    # num_new 是本步实际处理多少 token:
    # 如果 budget 够,一次全部 prefill(remaining <= remaining_token_budget)
    # 如果 budget 不够,只 prefill 一部分(这就是 chunk)
    num_new = min(remaining, remaining_token_budget)

    if not enable_chunked_prefill and num_new < remaining:
        break  # 不支持 chunk 时要整个 prefill 进来,否则等

    remaining_token_budget -= num_new
    request.num_computed_tokens += num_new  # 更新进度
    # 分配 KV 块,移入 running 队列

具体例子:max_num_batched_tokens=256,当前有 2 个 decode 请求(各消耗 1 token),remaining_token_budget = 256 - 2 = 254。新来一个 1000-token prompt(Req A),Req B 是一个正在 decode 的请求:

  • Step 1:remaining=1000num_new=min(1000,254)=254,prefill token[0..253]
  • Step 2:remaining=746num_new=min(746,254)=254,prefill token[254..507]
  • Step 3:remaining=492num_new=254,prefill token[508..761]
  • Step 4:remaining=238num_new=238,prefill 完成,进入 decode

Chunked Prefill:prefill 和 decode 共享同一个 batch

prefill chunk 和 decode 怎么混在同一个 forward pass 里?

这是整个机制的核心,图里没有画出来。关键在于文章(二)讲过的 1D varlen 格式:vllm 的 input_ids 不是 [batch, seq_len] 的 2D tensor,而是把所有请求的 token 直接拼成一个 1D tensor,用 cu_seqlens(累积序列长度)记录每个请求的边界。

Step 1 的 batch 示意:

input_ids = [A0, A1, A2, ..., A253,  B0]
             ↑─────────────────────↑  ↑
             Req A 的 254 个 prefill   Req B 的 1 个 decode token

cu_seqlens = [0, 254, 255]
# 告诉 FlashAttention:token[0..253] 是 Req A,token[254] 是 Req B

# Req A 的 attention:token[0..253] 互相 attend(prefill,O(N²) 计算)
# Req B 的 attention:token[254] attend 它的全部历史 KV cache(decode,从 block_table 读)

对 GPU 来说,这就是一次 forward pass,处理一个 [255] 长的 1D tensor。FlashAttention 的 varlen 接口会按边界分别处理每个请求的 attention,prefill 段和 decode 段互不干扰。

FlashAttention varlen 接口怎么同时处理 prefill 和 decode?

关键是 flash_attn_varlen_func 接受两组独立的 cu_seqlens

flash_attn_varlen_func(
    q=query,           # [N_tokens_q, num_heads, head_dim],所有请求的 Q 拼在一起
    k=key_cache,       # GPU KV cache tensor(paged,不连续)
    v=value_cache,
    cu_seqlens_q=...,  # Q 的累积长度边界,每个请求这步送了多少个新 token
    cu_seqlens_k=...,  # K 的累积长度边界,每个请求能 attend 的历史总长度
    block_table=...,   # KV cache 的物理块索引,告诉 FlashAttention 去哪读历史 K/V
)

cu_seqlens_qcu_seqlens_k 可以不一样——这正是混排的关键:

Step 1 的 batch(Req A prefill chunk 254 token,Req B decode 1 token):

cu_seqlens_q = [0, 254, 255]
# Req A:Q 有 254 个 token(本步新 token)
# Req B:Q 有 1 个 token(本步新 token)

cu_seqlens_k = [0, 254, 756]
# Req A:K/V 也只有 254(全新,无历史 cache)
# Req B:K/V 有 755(1 个新 token + 754 个历史 cache token)
#         历史 K/V 通过 block_table 从 GPU KV cache 里读,不需要先 gather 成连续 buffer

FlashAttention 对每个序列独立计算 attention:

  • Req A(prefill):254 个 Q token,attend 自身的 254 个 K/V,标准的 prefill attention,计算量 O(254²)
  • Req B(decode):1 个 Q token,attend 全部 755 个历史 K/V(从 block_table 找到各个物理块读出来),计算量 O(755)

两个序列共享同一次 kernel 调用,在 GPU 内部并行处理,不存在串行等待。Req B 始终在每步的 batch 里,每步都能产出一个新 token,不会因为 Req A 的 prefill 被阻塞。

max_num_batched_tokens 怎么设? 控制每步最多处理多少 token(prefill + decode 合计)。设太小,长 prompt 的 prefill 被切很碎,TTFT 变慢;设太大,GPU 单步计算量过大,latency 抖动。官方建议从 2048~8192 开始,根据目标 latency SLO 和 GPU 显存决定。

4.3 抢占:KV Cache 不够时的兜底

调度器发现 KV Cache 不够给 running 队列用时,会抢占优先级最低的请求:

# vllm/v1/core/sched/scheduler.py
# class Scheduler
# def _preempt(self, request)
def _preempt(self, request):
    self.kv_cache_manager.free(request)    # 释放全部 KV 块
    request.num_computed_tokens = 0        # 重置进度(被抢占需要重新 prefill)
    request.status = RequestStatus.PREEMPTED
    self.running.remove(request)
    self.waiting.add(request)              # 回到等待队列,output_token_ids 保留

调度器有 watermark 机制:只有 free_blocks > watermark 时才允许新请求准入,提前留缓冲,避免频繁触发抢占。


5. 异步架构:HTTP serving 怎么绕开 Python GIL

5.1 为什么要独立进程

离线推理用 LLM 类,HTTP serving 用 AsyncLLMvllm/v1/engine/async_llm.py)。两者最大的区别是通信架构。

HTTP server 是 asyncio 驱动(IO 密集型),EngineCore 是调度 + GPU 执行(CPU/GPU 密集型)。如果在同一进程里,asyncio 事件循环在等网络 IO 时,理论上应该让 EngineCore 跑,但 EngineCore 里有大量 Python 调度逻辑,频繁争 GIL,结果两边都磕磕绊绊。

解法是把 EngineCore 放到独立进程,独立进程之间没有 GIL 限制,两边真正并行。进程间通信用 ZMQ(ZeroMQ,高性能异步消息队列)+ msgpack(比 pickle 轻量的序列化协议)。

5.2 ZMQ 通信全链路

下图是完整的通信时序,展示从 HTTP 请求进来到逐 token 流式输出的全链路:

AsyncLLM + ZMQ 前后端通信时序

沿着时序图从上往下走:

① HTTP 请求进来:FastAPI handler 调用 AsyncLLM.generate(),把请求序列化成 EngineCoreRequest(msgpack + array_like=True 格式,字段名不传只传值,开销极小),通过 ZMQ PUSH socket 发给 EngineCore 进程。

② WAKEUP 唤醒:如果 EngineCore 正在阻塞等待消息,前端同时发一条 \x05 WAKEUP 消息唤醒它,避免 EngineCore 因无新消息而空转。

③ EngineCore 步进:EngineCore 收到请求后调用 step() 做调度 + GPU forward + 采样,每步完成后把所有请求的新 token 打包成 EngineCoreOutputs,通过 ZMQ PUB socket 广播出去。

④ 前端 SUB 接收:前端进程的 subscriber socket 收到 EngineCoreOutputs,立即拆包,找到对应 request_id 的新 token,调 IncrementalDetokenizer 解码成文字。

⑤ SSE 推流:解码结果通过 async for output yield 给 HTTP handler,HTTP handler 用 SSE(Server-Sent Events)推给客户端。全链路事件驱动,没有任何轮询。

ZMQ 消息协议(单字节类型标识 + msgpack payload):

类型字节 含义 方向
\x00 ADD 新请求 前端 → EngineCore
\x01 ABORT 中止请求 前端 → EngineCore
\x05 WAKEUP 唤醒 EngineCore 前端 → EngineCore
(PUB) EngineCoreOutputs EngineCore → 前端

5.3 流式输出的实现

# vllm/v1/engine/async_llm.py
# class AsyncLLM
# async def generate(self, prompt, sampling_params, request_id)
async def generate(self, prompt, sampling_params, request_id):
    await self.engine_core.add_request(EngineCoreRequest(...))

    async for output in self._result_handler.get_output(request_id):
        yield output   # 每生成一个 token 就 yield 一次给上层 HTTP handler

这是 OpenAI API stream=True 时逐 token 输出的底层实现。EngineCore 每完成一个 step 就 PUB 推送,前端 SUB 收到后立即 detokenize 并 yield,整条链路没有任何等待或轮询。


6. 小结

三个机制各自解决一个独立问题,共同服务于 EngineCore.step() 的调度层:

KV Cache 管理KVCacheManager 维护逻辑块(纯 CPU);GPUModelRunner 把逻辑块翻译成 slot_mapping,喂给 FlashAttention(GPU)。FreeKVCacheBlockQueue 是 LRU 双链表,O(1) 支持分配/释放/前缀缓存命中。Prefix Caching 靠滚动 hash 识别相同前缀,命中后跳过对应 prefill。

Chunked Prefillnum_computed_tokens 字段替代 prefill/decode 阶段标记,调度器每步按 max_num_batched_tokens 切 chunk,prefill 和 decode 混排在同一个 batch 里执行,长 prompt 不再饿死 decode 请求。

异步架构:EngineCore 独立进程 + ZMQ PUB/SUB,绕开 Python GIL,HTTP server 和 GPU 执行真正并行。每步完成就 PUB,前端 SUB 收到后立即推给 HTTP client。

遇到问题去哪找

问题场景 代码位置
显存不够 / KV Cache 分配失败 EngineCore._initialize_kv_caches()
OOM during forward GPUModelRunner.execute_model(),检查 max_num_batched_tokens
Attention 报错 vllm/v1/attention/backends/flash_attn.py
前缀缓存没有命中 KVCacheManager.get_computed_blocks(),检查 block_hashes 计算
HTTP server 延迟高 AsyncLLM ZMQ 通信,检查 EngineCore 进程是否正常
Chunked prefill 相关 vllm/v1/core/sched/scheduler.py,检查 max_num_batched_tokens

我们团队做 LLM 效率方向的研究,也出了本相关的书《动手学 AutoML:从 NAS 到大语言模型优化实战》——KV Cache 管理和调度这些偏工程实现的内容书里没有直接覆盖,书更多是 AutoML、NAS 以及它们怎么用在大模型上。如果你对这个方向感兴趣,可以翻翻。

动手学AutoML书籍封面

Flag Counter