vLLM架构解析:整体架构图、请求处理流程、KV Cache管理机制、调度器工作原理

说实话,vLLM这个项目我第一次接触时,就被它的架构设计惊艳到了。它不是那种「为了快而快」的暴力优化,而是把大模型推理的每个环节都拆解得明明白白。今天我就带大家把它的骨架摸清楚。

一、整体架构图:vLLM的「三驾马车」

先看一张我手绘的架构图。vLLM的核心可以拆成三个模块:调度器(Scheduler)KV Cache管理器(Block Manager)执行引擎(Worker)。它们各司其职,又紧密配合。

vLLM 核心架构图 客户端请求 调度器 (Scheduler) KV Cache管理器 (Block Manager) 执行引擎 (Worker) GPU 显存 KV Cache块 模型权重 临时缓冲区 分配/释放 读写 返回结果

这张图我画了好几次才满意。你看,客户端请求先到调度器,调度器决定「谁先跑、跑多少」,然后KV Cache管理器负责「显存怎么分、怎么回收」,最后执行引擎真正干活。三个模块像流水线一样,环环相扣。

二、请求处理流程:一个请求的「一生」

一个请求进来,到底经历了什么?我拆成5步来讲。

  1. 请求入队:客户端发来一个prompt,调度器把它放进等待队列。这里有个细节——vLLM会先做一次prefill,把prompt的KV Cache算出来。
  2. 调度决策:调度器每轮迭代(iteration)会从队列里挑一批请求。挑谁?看优先级、看显存够不够。我习惯把这一步叫「抢车位」——谁先抢到显存块谁先跑。
  3. KV Cache分配:Block Manager给每个请求分配物理块。注意,vLLM用的是逻辑块到物理块的映射,不是直接分配物理地址。这个设计很巧妙,后面细说。
  4. 执行推理:Worker拿到调度指令,在GPU上跑Transformer的前向计算。每次只生成一个token,然后更新KV Cache。
  5. 结果返回:生成完整个序列后,结果返回给客户端。如果请求还没结束(比如还在流式输出),调度器会把它放回「运行中队列」,下一轮继续。

核心要点:vLLM的请求处理是迭代级(iteration-level)的调度,不是请求级。这意味着每生成一个token,调度器都可能重新做决策。吞吐量就是这么提上来的。

三、KV Cache管理机制:PagedAttention的精髓

说到KV Cache管理,就不得不提vLLM的成名绝技——PagedAttention。这个名字起得好,说白了就是「分页式注意力」。我当年第一次看到这个设计时,拍了一下大腿:这不就是操作系统的虚拟内存吗?

传统做法是给每个请求预分配一块连续显存,但问题是——你根本不知道这个请求最终会生成多长的序列。预分配少了会OOM,预分配多了浪费显存。vLLM的做法是:

  • 逻辑块:每个请求有一个逻辑地址空间,按块(block)分配。块大小通常是16或32个token。
  • 物理块:GPU显存里维护一个物理块池。逻辑块和物理块之间通过块表(Block Table)映射。
  • 按需分配:生成一个token,就分配一个物理块。不够了再申请,绝不浪费。

避坑指南:我曾经在生产环境遇到过一个问题——块大小设成了64,结果短请求(比如只有10个token的回复)也占了一个完整的块,显存利用率反而下降了。后来我改成动态块大小,短请求用小块,长请求用大块。嗯,效果好了不少。

另外,vLLM还支持Copy-on-Write。什么意思?多个请求如果共享同一个前缀(比如系统提示词),它们的KV Cache可以共用物理块,直到某个请求要修改它。这招在对话场景下特别管用。

四、调度器工作原理:谁先跑?跑多久?

调度器是vLLM的大脑。它的核心逻辑在schedule()方法里,我简化一下关键步骤:

def schedule(self):
    # 1. 检查运行中的请求,哪些可以继续
    running = self.running_queue
    for req in running:
        if req.is_finished():
            self.free_blocks(req)
            running.remove(req)
    
    # 2. 从等待队列里挑新请求
    waiting = self.waiting_queue
    while waiting and self.has_free_blocks():
        req = waiting.pop(0)
        # 检查显存是否够做prefill
        if self.can_prefill(req):
            running.append(req)
        else:
            # 不够?放回队列,等下一轮
            waiting.insert(0, req)
            break
    
    # 3. 生成调度指令
    return self.build_schedule(running)

你看,逻辑其实不复杂。但有几个关键点:

  • 抢占(Preemption):如果显存不够了,调度器会暂停一些低优先级请求,把它们的KV Cache换出到CPU内存。等显存释放了再换回来。我遇到过最极端的情况——一个请求被换入换出了十几次,延迟直接爆炸。后来我调大了块大小,情况好多了。
  • 批处理(Batching):调度器会把多个请求拼成一个batch,一次性喂给GPU。批大小越大,吞吐量越高。但也不能太大,否则单个请求的延迟会变高。这是个trade-off。
  • 优先级:vLLM支持按请求的到达时间、TTFT(首token延迟)等指标排序。我个人习惯给交互式请求(比如聊天)高优先级,给离线批处理任务低优先级。

注意:调度器的决策频率是每生成一个token就做一次。如果请求数量很大(比如上千个并发),调度本身也会成为瓶颈。我建议在调度器前面加一层请求聚合器,把短时间内的请求合并后再交给调度器。

五、总结:架构设计的「取舍」

vLLM的架构设计,说白了就是在显存效率调度灵活性之间找平衡。PagedAttention让显存利用率接近100%,迭代级调度让吞吐量翻了几倍。但代价是什么?

  • 调度开销变大(每token都要做决策)
  • 实现复杂度变高(块表、换入换出、Copy-on-Write)
  • 对长序列的支持不够好(块太多时,块表本身也占显存)

不过话说回来,没有完美的架构,只有适合场景的设计。vLLM在在线推理场景下,确实做到了「又快又省」。如果你也在做类似的项目,我建议你先把调度器和KV Cache管理器这两个模块吃透——它们是整个系统的灵魂。


公众号:蓝海资料掘金营,微信deep3321