vLLM推理服务器探秘:提示到响应之旅

268 阅读16分钟

vLLM 通过动态批处理、PagedAttention 等技术优化 LLM 服务,实现高吞吐量和低延迟。流程包括接收提示、动态批处理和调度、token 化、输入嵌入、Transformer 模型处理、键值缓存、解码和流式传输响应。

译自:Inside the vLLM Inference Server: From Prompt to Response

作者:Janakiram MSV

本系列的前一篇中,我介绍了 vLLM 的架构以及它如何针对服务大型语言模型 (LLM) 进行优化。 在本文中,我们将深入了解 vLLM 的幕后工作原理,以了解从接受提示到生成响应的端到端工作流程。

vLLM 的架构针对高吞吐量和低延迟进行了优化。 它可以有效地管理 GPU 内存和调度,从而允许并行处理许多请求。 在下面的章节中,我们将详细介绍每个阶段,并使用简单的类比来阐明技术机制。

阶段 1:接收提示

一切都始于客户端将提示发送到 vLLM 服务器的 API 端点。 vLLM 运行一个 HTTP 服务器(基于 FastAPI),该服务器实现了用于补全和聊天补全的 OpenAI 的 REST 接口。 例如,用户可以使用包含其提示的 JSON 有效负载 POST 到 /v1/completions 或 /v1/chat/completions。 服务器解析此请求,提取提示文本和任何生成参数,例如最大 token 数、温度以及是否流式传输。

排队: vLLM 没有立即处理每个请求,而是使用内部队列来管理传入的提示。 将其想象成银行的排队:每个请求都会获取一张票证并等待服务。 排队的原因是为了在 GPU 上实现高效的批处理。 服务器收到提示后,它会将请求打包到一个数据结构中,并通知推理引擎有新的请求可供处理。 如果同时收到多个请求,它们将在该等待队列中排队。 这样可以防止 GPU 被太多的单个任务淹没,而是以优化的块向其提供工作。

阶段 2:动态批处理和调度请求

提示排队后,vLLM 的调度程序就会介入。 调度程序是一个核心组件,它决定何时以及如何执行 GPU 上的每个请求。 与可能一次处理一个请求或使用固定大小批次的简单处理不同,vLLM 使用连续动态批处理来最大限度地提高效率。

连续批处理: 传统的推理引擎通常使用静态批处理并以停止-启动的方式处理它们:填充一个批次,处理它,然后填充下一个批次。 另一方面,vLLM 的调度程序执行连续批处理,只要有空间可用,它就可以将新请求添加到正在进行的批处理中。 假设批处理中的一个请求提前完成(可能是一个简短的提示或它快速获得了答案),vLLM 会立即将另一个等待请求插入到该空闲的插槽中,而无需等待所有其他批处理成员完成。 这样,GPU 始终在做实际工作,而不是闲置等待较慢的序列赶上。 结果是更高的吞吐量和更低的延迟、每秒处理更多的查询以及更短的用户等待时间。

调度决策: 调度程序按步骤或迭代运行。 在每次迭代中,它都会决定是执行预填充步骤还是解码步骤(稍后将详细介绍)。 它查看系统的状态:是否有新的请求在等待? 是否有需要生成额外 token 的正在进行的请求? 是否有任何请求因内存限制而被“换出”? 基于此,它会选择一组要运行的请求。 值得注意的是,vLLM 的调度程序遵循严格的先进先出 (FCFS) 策略以确保公平性。 这意味着它总是会尝试在处理较新的请求之前处理队列中较旧的请求,即使较新的请求可能很容易适合该批处理。 换句话说,不允许插队——这使得服务可预测且公平,这对于 SLO 至关重要(确保没有用户请求被无限期地延迟或乱序)。

调度程序还优化了哪些请求可以批量处理在一起。 它可以将简短的提示或快速完成的请求与较长的提示或请求混合在一起,以平衡负载。 通过将兼容的请求分组在一起,它可以实现高 GPU 利用率,而不会让长时间运行的请求阻止所有操作。 在可变工作负载(一些提示很小,一些提示很大)下,这种智能调度可保持稳定的延迟和高吞吐量。

此时,调度程序仅决定了在下一个模型推理周期中要处理的一个或多个提示的批处理。 现在,实际的 LLM 处理开始了。

阶段 3:Token 化——将提示分解为“乐高积木”

在模型可以使用提示文本之前,必须将该文本转换为模型可以理解的形式。 此步骤称为 token 化。 vLLM 利用底层模型的 token 器(例如,GPT token 器SentencePiece)来执行此转换。

Token 化是指将输入文本拆分为离散单元,称为 token。 这些 token 可以是完整的单词、子词,甚至是单个字符,具体取决于 token 器的设计。 一种简单的思考方式是将句子分解成乐高积木——每个 token 都是一块积木,它们共同构成一个整体,并且可以一起用于重建原始句子。 例如,诸如“Transformers are great!”之类的句子可以被 token 化为诸如“Transform”、“ers”、“are”、“great”、“!”之类的块。 从模型的词汇表中为每个块分配一个数值 ID。

Token 化后,提示现在表示为 token ID 的序列(例如,[312, 1085, 42, …])。 这些 ID 将在下一阶段用于查找嵌入。

有关 token 和嵌入的简要介绍,请参阅我之前的关于此主题的文章之一。

值得注意的是,token 化通常在 CPU 上进行,并且速度相对较快。 在服务器上下文中,与神经网络所需的处理相比,这种开销很小。 尽管如此,这仍然是重要的一步:如果您发送一个 1000 个字符的提示,token 器可能会输出 200 个 token。 管道的其余部分将对这 200 个 token 而不是 1000 个字符进行操作。

阶段 4:输入嵌入——将 Token 转换为向量

Token 化之后,我们得到一个表示提示的 token ID 列表。 下一步是将这些 ID 转换为 Transformer 模型可以处理的数值向量。 模型的嵌入层执行此步骤。

该模型具有一个嵌入矩阵(本质上是一个大的数字表),其中每个 token ID 索引一个特定的行——该行是 token 的向量表示。 例如,token ID 312 可能对应于一个 768 维向量,例如 [0.12, -0.45, …, 0.67]。 这些向量有时称为词嵌入(尽管它们也编码子词或符号)。 它们捕获语义信息——具有相似含义或用法的 token 通常最终在该高维空间中具有相似的嵌入向量。

您可以将嵌入想象成将单词翻译成模型所说的秘密数字语言。 如果 token 化给了我们“单词”(token),那么嵌入以数学形式构建“意义”。

在句子或提示中,词序很重要。 Transformer 本身无法仅从向量列表中理解序列顺序,因此会将附加的位置编码添加到每个 token 的向量中,以告知模型每个 token 在序列中的位置。 这可以通过将位置向量(如正弦/余弦模式或学习的位置嵌入)添加到 token 的嵌入或与其连接来实现。 结果是每个 token 的最终输入向量都编码了 token 是什么以及它在序列中的位置。

在此阶段结束时,提示表示为向量序列(每个 token 一个向量,通常每个向量具有数百或数千个分量)。 现在繁重的工作(实际的 Transformer 推理)开始了。

阶段 5:Transformer 模型处理——注意力和层

vLLM 工作核心是通过 Transformer 模型运行提示,以编码提示或生成新文本。 现代 LLM(例如 GPT 和 Llama)本质上是大型仅解码器 Transformer。 它们由一堆相同的层(例如,24 层或更多)组成,每层包含一个自注意力机制和一个前馈网络。

以下是如何通过这些层处理提示:

自注意力: 在每个 Transformer 层中,模型利用自注意力来识别提示中哪些其他单词(token)对于理解给定的 token 至关重要。 这就好比模型有一个聚光灯,在处理下一个单词时可以照亮过去的单词。 如果我们的提示是一个长句子,那么当模型计算出下一个单词时,它不会平等地对待所有先前的单词——它会专注于最相关的单词。

前馈网络: 在注意力之后,每个 token 的表示都会通过一个小型前馈神经网络。 这独立地应用于每个 token 的数据。 这就像在从注意力步骤吸收了上下文之后,进一步处理或完善每个 token 的含义。 前馈步骤可以被认为是模型混合和转换每个 token 中的信息的方式,使其对于预测更有用。 从技术上讲,它通过应用非线性变换来增强模型捕获复杂模式的能力。

多层: Transformer 有许多这样的层堆叠在一起。 一层的输出输入到下一层。 随着每一层的出现,模型对文本的表示变得更加丰富和抽象。 早期层可能会捕获低级模式(例如语法或短语结构),而后期层可能会捕获高级概念或意图。 到顶层时,模型已经对提示有了深刻的理解。

预填充(上下文编码): 此步骤涉及在所有提示 token 上运行模型,而不生成新输出。 这是在请求首次进入系统时完成的。 模型处理从第一个 token 到最后一个 token 的提示,填充内部状态(KV 缓存),但尚未生成任何新的 token。 将这想象成在尝试回答之前阅读和理解问题。

解码(自回归生成): 此步骤使用模型逐个预测新的 token,并借助预填充中的上下文。 在解码中,每次迭代通常都会通过 Transformer 来生成下一个 token(我们将在下一节中详细介绍)。

vLLM 的调度程序知道这些阶段——它可能会处理大量新请求的预填充批处理,然后切换到解码步骤以生成这些请求(以及其他正在进行的请求)的输出。 以这种方式对请求进行分组可以提高效率。

在我们提示的预填充阶段结束时,模型已经有效地“理解”了提示并准备好开始生成响应。 Transformer 对提示 token 的所有繁重计算都已完成,重要的是,键值缓存现在已填充提示的上下文。

阶段 6:键值缓存——高效地记住过去的 Token

基于 Transformer 的 LLM(以及 vLLM 的效率)的关键创新之一是使用 KV 缓存。 这是在自注意力步骤中为每一层中每个 token 计算的键和值张量的缓存。 它可以作为模型迄今为止处理内容的内存。 让我们分解一下为什么这很重要以及 vLLM 如何处理它:

为什么使用 KV 缓存? 通常,如果模型不得不在没有缓存的情况下生成文本,它将不得不为整个序列(提示 + 到目前为止生成的 token)上的每个新 token 重新计算所有注意力计算。 这意味着大量重复的工作——实际上是从头开始读取到目前为止的整个对话,以决定每次的下一个单词。 KV 缓存通过存储中间结果来避免这种情况。 这就像模型在处理文本时做笔记,这样当它需要生成下一个单词时,它可以直接查看它的笔记,而不是重新阅读所有内容。

在 vLLM 中,KV 缓存的管理非常谨慎,因为它直接影响内存使用和性能。 每个提示 token 和每个生成的 token 都会向该缓存贡献一些数据(对于每个 Transformer 层)。 对于大型模型和长提示,这可能会导致大量数据——事实上,缓存大小随着序列长度线性增长,并且可能成为内存瓶颈。 传统系统通常为最大可能的缓存预分配一块大的连续 GPU 内存,如果并非所有内存都已使用,这可能会浪费内存。 vLLM 引入了一项名为 PagedAttention 的创新,以提高效率。

PagedAttention 以类似于操作系统处理虚拟内存的方式处理 KV 缓存的 GPU 内存。 它没有使用一个巨大的连续数组,而是将 KV 缓存分成固定大小的页面(小块)并按需分配它们。 如果某些请求较短或提前完成,则可以释放其页面并将其重新用于新请求,从而减少浪费。 这就像将笔记本分成可移除的页面——使用您需要的页面并在其他地方重复使用空白页面,而不是为每个请求永久保留一个巨大的笔记本。 这种非连续分配可防止内存碎片问题,并允许 vLLM 支持更大的批次和更长的上下文,而不会耗尽内存。 与朴素缓存相比,PagedAttention 可以通过有效回收和重用内存来支持同一 GPU 上两到三倍的并发用户。

总之,vLLM 对 KV 缓存和 PagedAttention 的使用意味着,当模型处理您的提示和部分输出时,它会有效地使用 GPU 内存来记住上下文。 过去 token 的这种内存使得生成长响应变得可行和快速,因为模型不会不断地为每个新单词重新读取整个输入。 处理和缓存提示后,我们继续生成模型的响应。

阶段 7:解码——生成响应 Token

现在到了关键时刻:生成模型的答案。 解码是迭代生成 token 作为输出的过程。

以下是它在 vLLM 中的工作方式:

初始 Logits 预测: 处理完提示(预填充)后,模型就可以预测第一个输出 token。 最后一个提示 token 的 Transformer 输出将通过最终投影传递,以生成一组 logits——本质上是词汇表中每个 token 作为潜在下一个 token 的分数。 这些 logits 通过 softmax 转换为概率。 由 API 服务器解析的生成参数(温度、top-k、nucleus 采样等)此时应用,以决定如何选择下一个 token。 例如,如果使用贪婪解码,模型将简单地选择具有最高概率的 token 作为下一个 token。

发出 Token: 所选的下一个 token 现在是模型响应的第一个单词。 此时,vLLM 可以将此 token 发送回用户(我们将在下一节中介绍流式传输)。 新 token 也附加到此请求的序列。

更新 KV 缓存: 至关重要的是,模型现在计算每一层中新生成的 token 的键和值向量,并将这些向量附加到 KV 缓存。 由于我们的缓存,模型不需要重新计算任何先前 token 的键和值——它已经拥有这些键和值。 它只关注新的 token,并关注所有先前的 token(使用其缓存的键/值)。 此操作比从头开始处理整个序列要快得多。 本质上,模型已经扩展了它的笔记,以包括新 token 的信息。

下一次迭代: 现在序列的长度增加了一个 token,该过程会重复进行。 模型获取所有现有 token(提示 + 到目前为止生成的 token),使用缓存的上下文,并预测下一个 token。 借助动态批处理,vLLM 可能会在同一次迭代中并行地对多个序列执行此操作——批处理中的每个活动请求都会进一步获得一个 token。 如果某些序列完成(例如,模型输出一个文本结束 token 或达到长度限制),则调度程序会将这些序列从批处理中删除,并且可以在下一次迭代中将新的等待请求引入到它们的位置。

此循环会一直持续到请求生成其完整响应为止。 “完成”可能意味着模型生成了一个特殊的文本结束 token,或者达到了特定的最大 token 限制,或者遇到了用户设置的停止条件(例如停止序列)。

在阶段 7 结束时,模型已生成一个 token 序列,该序列构成了用户提示的答案。 现在我们需要将该答案返回给用户。

阶段 8:将响应流式传输回客户端

当模型生成 token 时,vLLM 不一定等到整个答案完成后才返回它。 它支持流式传输输出,这对于交互式设置中的良好用户体验至关重要(并且与 OpenAI 的 API 流式传输结果的方式一致)。

如果请求是使用 stream=true(在 OpenAI API 术语 中)发出的,vLLM 将以增量方式发回部分响应。 从技术上讲,HTTP 连接保持打开状态,并且服务器在生成新 token 时刷新数据。 客户端可能会收到 JSON 块的流,每个块都包含一个新生成的单词或短语,而不是在最后收到一个大的响应。

结论

总之,vLLM 推理服务器类似于一个高效的 AI 提示装配线——从摄取(排队和批处理)到处理(Token 化和 Transformer 计算)再到输出(流式传输 Token)——每个组件都针对性能进行了优化。

通过了解这个生命周期,人们可以理解现代 LLM 服务系统如何快速且大规模地提供智能响应,还可以识别在出现问题时在哪里进行监控或调整(例如,由于 KV 缓存导致的 GPU 内存使用情况,或者由于次优批处理导致的吞吐量瓶颈)。 vLLM 表明,通过巧妙的工程设计(连续批处理、PagedAttention、缓存),我们可以在 LLM 推理中实现速度和规模,从而将大型模型转化为实用的服务。