介绍
本文是Parrot: Efficient Serving of LLM-based Applications with Semantic Variable的译文。
摘要
大型语言模型(LLM)的兴起催生了基于 LLM 的应用(又称 AI 智能体 或 协作式副驾驶),这是一种将 LLM 的能力与传统软件相结合的新型软件范式。来自不同租户的多样化 LLM 应用可以设计复杂的工作流,通过多个 LLM 请求来完成一个任务。然而,它们只能使用当今公共 LLM 服务所提供的过度简化的、以请求为中心的 API,从而丢失了关键的应用级信息。公共 LLM 服务只能“盲目地”优化单个 LLM 请求,导致基于 LLM 的应用在端到端性能上表现欠佳。
本文介绍了 Parrot,一种关注基于 LLM 应用端到端体验的 LLM 服务系统。Parrot 提出了 语义变量(Semantic Variable),作为一种统一的抽象,用于将应用级知识暴露给公共 LLM 服务。语义变量对请求提示(prompt)中的输入/输出变量进行标注,并在连接多个 LLM 请求时创建数据流水线,从而提供了一种自然的方式来编程 LLM 应用。将语义变量暴露给公共 LLM 服务后,服务端可以执行传统的数据流分析,以揭示多个 LLM 请求之间的相关性。这种相关性打开了一个全新的优化空间,用于提升基于 LLM 应用的端到端性能。大量评估结果表明,对于流行且实用的 LLM 应用场景,Parrot 最多可以实现数量级的性能提升。
引言
大型语言模型(LLM)已经展现出卓越的语言理解能力。这推动了应用开发范式的转变。在这一新范式中,一个或多个应用实体(通常称为 AI 智能体 或 协作式副驾驶)通过自然语言(即 prompt)与 LLM 进行交互,以协同方式完成任务。例如,像 Microsoft Teams 或 Google Meet 这样的会议应用可以利用 LLM 对会议讨论进行总结;搜索引擎(如 Google 和 Bing)也可以通过 LLM 增强其聊天能力。人们普遍认为,这类基于 LLM 的应用将在不久的将来成为主流应用。
为了完成一个任务,基于 LLM 的应用通常需要多轮对话。这些对话通过多次对 LLM 的 API 调用来实现,呈现出复杂的工作流模式。图 1 展示了几种流行的对话模式。例如,会议摘要应用通常会将一篇较长的文档拆分为多个较短的部分,每个部分都满足 LLM 对话的长度限制,然后通过 Map-Reduce 或 链式摘要 的方式将各部分摘要合并为最终结果。基于聊天的应用(例如 Bing Copilot)会根据用户查询多次调用 LLM API 来生成答案。此外,多个智能体也可以进行协作,每个智能体代表由不同 LLM 调用所扮演的不同角色,从而共同完成任务。
公共 LLM 服务提供商必须面对来自不同租户和应用的多样化需求,每种应用都有其独特的工作流和性能偏好。然而,现有的 LLM 服务 API 设计仍然是以请求为中心的。公共 LLM 服务只能看到大量彼此独立的请求,却无法获知任何应用级信息,例如:哪些请求属于同一个应用、不同请求之间如何连接、或者它们之间是否存在相似性。这种应用级信息的缺失,使得公共 LLM 服务只能对单个请求进行优化,从而导致基于 LLM 的应用在端到端性能上表现不佳。本文观察到,通过利用应用级信息,尤其是多个 LLM 请求之间的相关性,可以显著提升 LLM 应用的端到端体验。
图 1:流行的基于 LLM 应用的工作流。最终结果需要多个 LLM 请求。
首先,多个连续的 LLM 请求可能存在依赖关系:一个请求的结果可能直接作为下一个请求的输入。因此,理想情况下,应当将这些请求协同放置(colocate),并在 LLM 服务端连续执行。然而,由于服务端并不了解它们之间的依赖关系,这些请求不得不在基于 LLM 的应用客户端与公共 LLM 服务之间交互式执行。这些客户端通常位于互联网的另一端,只有在收到第一个请求的结果之后,才能发起第二个请求。这不仅在连续请求中额外引入了网络时延开销,还错失了对这些连续请求进行协同调度的机会。
其次,LLM 请求具有多样化的调度偏好,即使在同一个应用内部也是如此。例如,在 图 1a 中,为了降低端到端时延,表示多个 Map 任务的请求应当被更激进地批处理,以提升 Map 阶段的吞吐量;而 Reduce 任务由于数量稀少,则应当优先优化时延。不幸的是,公共 LLM 服务无法区分这两类任务之间的差异。因此,当前的实践往往是盲目地对单个请求进行时延优化,这在端到端体验层面上可能并不理想。
第三,LLM 请求之间存在高度的共性。流行的 LLM 应用(例如 Bing Copilot、GPTs)通常使用较长的 system prompt,其中包含任务定义、示例以及安全规则,用以引导 LLM 应用的行为。这些较长的 system prompt 往往是静态的,并且对所有用户通用。由于现有公共 LLM 服务将每个请求视为独立个体,这些公共前缀提示在每个请求中都会被重复提供,从而在存储、计算以及内存带宽上造成了巨大的浪费。我们对一个生产级基于 LLM 的搜索引擎的分析表明,请求中超过 94% 的 token 在不同用户之间是重复的。
尽管已经出现了一些引擎层面的技术来优化上述三种情况,但它们都依赖于某种应用级知识,而这些知识在当今的公共 LLM 服务中已经丢失。简而言之,由于缺乏对 LLM 请求之间相关性的理解,现有的 LLM 服务无法利用上述三类优化机会,导致端到端服务时延较高、吞吐量降低。
图 2:多智能体应用中连续 LLM 请求之间的通信方式。
基于上述事实与洞察,本文提出了 Parrot,一种将 LLM 应用视为一等公民 的 LLM 服务系统。Parrot 通过一种简单的抽象——语义变量(Semantic Variable)——来保留大部分应用级信息,在增加系统复杂度与引入可用于优化的新信息之间取得了良好的平衡。语义变量是 prompt 中具有特定语义目的的文本区域,例如任务指令、few-shot 示例列表、输入或输出。语义变量还可以作为连接多个 LLM 请求的数据流水线。通过语义变量,prompt 的结构信息以及请求之间的相关性能够被自然地暴露给 LLM 服务。Parrot 在运行时检查语义变量,从而可以即时执行传统的数据流分析,推导出 LLM 请求之间的数据依赖关系。
通过分析应用级信息,Parrot 的统一抽象能够自然地支持联合优化,从而获得更好的全局最优性。由语义变量构建的数据流水线可以同时启用多种优化,包括隐藏数据流水线的时延、通过目标推断实现更优的调度,以及通过共性分析进行去重。Parrot 的调度机制也在这一统一抽象下综合考虑了不同的优化机会。我们在多种流行的基于 LLM 的应用(包括生产级系统和开源项目)上的广泛评估表明,与当前最先进的解决方案相比,Parrot 可实现最高 11.7× 的加速或12× 的吞吐量提升。Parrot 已在 https://github.com/microsoft/ParrotServe 上开源,并包含用于复现实验结果的评测代码。
背景
LLM 服务
大多数 LLM 服务以条件生成服务的形式提供,通过文本补全 API 调用:
Completion(prompt : str) −→ generated_text : str
应用客户端提供一个文本 prompt,LLM 服务返回生成的文本。在 API 背后,LLM 服务提供商运行一个或多个 LLM 推理引擎集群。请求调度器从队列中分发 LLM 请求到某个 LLM 推理引擎,由一组 GPU 执行 LLM 推理。
图 3:当前 LLM 服务的端到端时延分解。开销主要来自 LLM 应用与 LLM 服务之间频繁交互所导致的网络和排队延迟,而这些在 Parrot 系统中被消除。
基于 LLM 的应用
图 1 展示了 LLM 在应用中使用的代表性工作流。由于 LLM 的上下文窗口受限(例如 GPT-3.5-Turbo 的上下文长度为 4096),对长文档的数据分析通常采用 Map-Reduce 风格(图 1a)或 链式(图 1b)工作流来生成最终结果。该过程会将长文本切分为多个片段,使用多个请求为每个片段生成部分结果(Map 任务),再将它们合并(Reduce 任务),或以增量方式(链式)生成最终结果。
图 1c 中的基于聊天的搜索引擎可能会使用连续的 LLM 请求来判别查询意图、利用补充信息扩展查询、检索相关数据、进行安全检查,最终生成响应。图 1d 和 图 2 所示的多智能体是另一类工作流,它使用多个 LLM 请求,每个请求对应一个特定角色。不同角色在同一任务上协同工作,例如 AutoGen 和 MetaGPT 使用诸如 产品经理、架构师、工程师 和 QA 测试人员 等角色。它们在一个软件项目中相互通信,每个角色由一个或多个 LLM 请求支撑,以扮演其设计的角色并生成相应的响应。
服务 LLM 应用的问题
尽管 LLM 的文本补全 API 为构建 LLM 应用提供了灵活性,但它向公共 LLM 服务隐藏了应用级信息,从而带来了以下挑战。
连续请求的过度开销
如 图 1 所示,LLM 应用通常需要进行多次 LLM 调用才能完成一个任务。由于现有公共 LLM 服务采用以请求为中心的设计,对每个请求独立生成响应,开发者不得不在客户端解析一个 LLM 请求的输出,并在客户端组合后续 LLM 请求的 prompt。图 3a 展示了我们在生产环境中对一个流行 LLM 应用的 LLM 调用进行的时延分解实证研究,该应用采用链式工作流。其 prompt 长度在 150–4000 token 之间,输出长度约为 50 token。我们发现,LLM API 调用中有相当一部分时延来源于 LLM 引擎之外(平均 30–50%,最坏情况下超过 70%)。并且,随着 prompt 长度的增加,这部分开销也会增大。高时延有时甚至会导致 API 超时和请求重试。
这种开销源于 LLM 服务与客户端之间频繁的交互。图 3b 展示了一个简单的两步 LLM 应用(例如对两个文本片段进行链式摘要)的开销情况。现有 LLM 服务并不了解这些请求之间的依赖关系,而前一个请求的输出可能正是下一个请求的直接输入。对于这类连续且相互依赖的请求,客户端必须等待第一个 LLM 请求的响应返回后,才能提交下一个 LLM 请求。这在客户端与 LLM 服务通常位于不同数据中心的情况下,会不必要地引入较大的网络时延。此外,下一个 LLM 请求还会遭受额外的排队延迟,因为在两个连续请求之间,可能会插入来自其他应用的请求。
图 4:面向请求的调度与面向应用的调度在 Map-Reduce 风格文档摘要任务中的对比。
表 1:LLM 应用中 LLM 调用的统计信息。
图 5:搜索协作助手的 prompt 结构,展示了一个被不同用户查询复用的长 prompt。
在 表 1 中,我们评估了四种流行的 LLM 应用。前两种来自我们的生产系统,后两种是流行的开源项目。它们在完成单个任务时都需要数十次 LLM 调用,从而导致较高的用户感知时延。我们在 §8.2 中的评估表明,将请求逐个独立处理的 LLM 服务,可能会使端到端时延增加超过 2×。如果 LLM 服务能够批量处理连续请求,则可以消除这些额外开销。Parrot 采用了这种方法。如 图 3c 所示,同一应用的两个步骤被联合调度,从而使步骤 A 的输出可以直接馈送到步骤 B,绕过网络和排队开销。
调度目标不一致
由于应用级信息的缺失(包括工作流和应用的性能目标),现有公共 LLM 服务只能对所有请求采取统一且盲目的处理方式,例如优化单个请求的时延。然而,基于 LLM 的应用更关注端到端体验,而非单个请求的性能。这种优化目标的不一致可能会对端到端性能产生负面影响。以 图 1a 中的 Map-Reduce 文档摘要为例,系统应当最小化获得最终摘要所需的端到端时间,而不是单个请求的时延。针对单个请求进行优化的 LLM 服务,并不能实现端到端时延的最优。
如 图 4 所示,当前 LLM 服务必须限制每个 LLM 引擎上并发运行的请求数量,以控制单个请求的时延。然而,在 LLM 推理中,时延与吞吐量之间存在权衡。增加批大小可以带来最高 8.2× 的吞吐量提升,但会导致95% 的时延增长。如果能够理解应用级的性能目标(在该示例中即端到端时延),则可以确定理想的调度策略:在 Map 阶段最大化吞吐量(使用更大的批大小),而在 Reduce 阶段最小化请求时延。该策略可将端到端时延降低 2.4×。此外,它还揭示了在不牺牲 LLM 应用端到端时延的前提下,提升集群吞吐量的潜力。这一洞察对于解决需求增长与硬件资源受限之间的矛盾至关重要,同时也凸显了从应用视角调度 LLM 请求的必要性,但这也带来了管理具有不同性能目标的多样化 LLM 请求的挑战。
图 6:Parrot 系统概览。
冗余计算
当前,大多数基于 LLM 的应用在其请求的 prompt 中表现出高度的冗余性。例如,Bing Chat 已处理了超过 10 亿 条聊天 prompt,这些 prompt 共享相同的 system prompt,用于定义 Bing Chat 的功能。OpenAI 推出了 GPTs,使用户能够为特定用途定制 ChatGPT,而其 prompt 模板在不同用户之间是相同的。prompt 中的这种共性至关重要,因为它界定了基于 LLM 应用的功能和约束。
图 5 所示的 prompt 结构包含角色定义、若干用于增强 LLM 行为精确性的示例以及用户查询细节。尽管用户输入是动态变化的,但任务角色始终固定,而 few-shot 示例在同一类任务中通常是准静态的。这也是为何在不同用户的 LLM 请求中,超过 94% 的前缀 token 会被重复使用(见 表 1)。这种共性在多智能体应用中同样存在。例如,MetaGPT 和 AutoGen 会在多轮 LLM 请求中反复将对话历史纳入 prompt,分别导致 72% 和 99% 的冗余。
这些冗余部分会大量消耗 GPU 内存带宽,并被重复计算。已有研究在 LLM 引擎层面提出了避免共享 prompt 冗余 GPU 内存访问的优化方法。然而,对于公共 LLM 服务而言,要从来自不同应用的大量动态生成请求中,快速识别并协同放置共享 prompt 的请求是十分困难的。在缺乏 prompt 结构信息的情况下,对每个 LLM 请求进行逐 token 匹配在集群层面代价高昂。因此,如果公共 LLM 服务的集群调度器无法将共享 prompt 的请求调度到同一引擎上,引擎层的冗余规避优化也就难以生效。
Parrot 设计
图 6 展示了 Parrot 的整体设计。Parrot 通过 语义变量(Semantic Variable)标注 提供了一种自然的 LLM 应用编程方式(§4.1),并且与现有的 LLM 编排框架(例如 LangChain)兼容。围绕这一抽象,Parrot Manager 被设计用于在集群层面调度 LLM 请求,通过推导应用级知识(§4.2),并对应用的端到端性能进行优化(§5)。管理器将 LLM 请求调度到 LLM Engine 上,该引擎由集群中的一台 GPU 服务器(或一组服务器)构成,能够独立地为 LLM 请求提供服务。
图 7:示例——Parrot 中的一个多智能体应用。
语义变量
Parrot 将一个 LLM 请求视为一个语义函数,该函数使用自然语言实现,并由 LLM 执行。语义变量(Semantic Variable) 被定义为语义函数的输入或输出变量,在 prompt 中以占位符的形式引用。图 7 展示了一个类似 MetaGPT 的简化多智能体应用示例。该示例包含两个语义函数:一个用于软件工程师编写代码,另一个用于 QA 工程师编写测试代码。它包含三个语义变量:task、code 和 test,分别表示任务描述、由软件工程师开发的代码,以及由 QA 工程师开发的测试代码。
尽管现有的 LLM 编排框架(例如 LangChain)也允许在 prompt 中使用占位符,但这些占位符在提交请求前会被真实数据渲染,因此公共 LLM 服务无法感知这种结构。相反,Parrot 依赖语义变量来保留 prompt 的结构,以便公共 LLM 服务侧进行进一步的跨请求分析。
除了语义函数之外,LLM 应用开发者还可以定义编排函数来连接多个语义函数(例如 图 7 中的 WriteSnakeGame)。连接多个语义函数的语义变量共同构成了公共 LLM 服务中多个 LLM 请求的数据流水线。对语义函数进行简单的数据流分析即可揭示多个 LLM 请求之间的连接关系。例如,在 图 7 中,code 变量连接了源自 WritePythonCode 和 WriteTestCode 的两个 LLM 请求,表明它们之间存在顺序依赖关系。
不同于传统的补全 API,Parrot 将一次补全请求拆分为提交(submit)操作和获取(get)操作。调用一个语义函数会触发 submit API,提交包含其 prompt 以及输入语义变量的 LLM 请求。语义函数的执行是异步的,因此它会返回输出语义变量的 future。应用可以通过 get API 按需从公共 LLM 服务中获取输出语义变量的值。这种异步设计使得基于 Parrot 的 LLM 服务能够在不被本地函数阻塞的情况下接收所有 LLM 请求,并即时分析它们之间的关系。
get 操作支持对性能准则进行标注,用以表示应用的端到端性能需求,例如端到端时延或吞吐量(也可扩展到诸如流式场景下的 per-token latency、time-to-first-token 等指标)。例如,在 图 7 中,最终输出 code 和 test 是通过带有端到端时延目标的 get 操作获取的。中间变量的准则会从最终输出中自动推导并传播。在传播完成后,每个变量都会附带一个准则,最终作为提示信息供 Parrot 的调度器使用。
跨请求分析的原语
总体而言,Parrot 主要通过从语义变量中推导出的两类应用级信息来执行跨请求分析:请求的 DAG 以及 prompt 结构。图 8 展示了 图 7 示例对应的 DAG 工作流,以及用于跨请求分析和优化的若干原语。
基于 DAG 的分析
由于请求(即语义函数)会被提前提交,Parrot 可以在服务端一次性接收所有请求,并即时分析它们之间的相关性。Parrot 在每个用户注册的会话中维护一个 DAG 结构。其中,每个节点要么是一个请求,要么是连接不同请求的语义变量。当一个请求到达时,Parrot 会通过 prompt 中占位符所引用的语义变量,将其插入到 DAG 中并建立相应的边。
Parrot 可以使用传统的数据流分析方法,通过原语(例如 GetProducer 和 GetConsumers)来获取语义变量的生产者和消费者,从而恢复 LLM 请求之间的依赖关系。结合请求 DAG 以及通过 GetPerfObj 标注在最终输出语义变量上的性能准则,Parrot 可以通过分析 DAG 结构和最终输出的性能目标,推导请求级别的调度偏好。
基于 Prompt 结构的分析
基于语义变量所声明的 prompt 结构,Parrot 支持在由语义变量分割的多个位置上为 LLM 请求提取哈希值(即 PrefixHash)。例如,WritePythonCode 的 prompt 可能存在两个可共享的前缀:{{input:task}} 之前的文本,以及 {{output:code}} 之前的文本,因此会生成两个前缀哈希值。这些前缀哈希可用于快速检测多个请求之间的共性,既支持静态内容,也支持动态生成内容,且不仅适用于同一类应用,还可跨应用使用。
图 8:用于跨请求分析的原语(部分)。
使用语义变量的优化
服务依赖请求
为了避免不必要的客户端侧执行,需要在应用层面获知请求之间的依赖关系,而这在当今的公共 LLM 服务中是缺失的。借助 §4.2 中展示的 DAG 和分析原语,Parrot 通过基于图的执行器高效地服务依赖请求。该执行器持续轮询,当请求就绪(即其所有生产者请求均已完成)时,便将其发送至对应的引擎,从而实现即时执行并最大化批处理机会。对于依赖请求的连续执行,物化后的值通过为相应语义变量分配的消息队列进行传输,避免了客户端与 LLM 服务之间不必要的频繁交互。
在请求之间交换语义变量的值时,可能需要进行转换。例如,从 LLM 请求的 JSON 格式输出中提取语义变量的值,再将其传递给后续的 LLM 请求。类似于支持消息转换的消息队列系统(如 Kafka),Parrot 也支持字符串转换,用于在 LLM 请求之间交换值时对语义变量进行处理。Parrot 支持 LangChain 中大多数输出解析方法,覆盖了 LLM 应用的绝大多数使用场景。
图 9:为一个生成两个时延敏感语义变量的基于 LLM 的应用进行性能推导。
性能目标推导
为了优化应用的端到端性能,需要了解应用级的性能准则。为将端到端的性能需求映射为请求级调度偏好,必须理解 LLM 应用的工作流,即由 Parrot 原语推导出的 LLM 请求 DAG。
当应用将某个语义变量标注为偏好更高吞吐量时,所有直接或间接生成该语义变量的请求,在调度时都会被标记为吞吐优先。这种调度偏好通常适用于离线数据处理场景,例如批量文档分析。
处理时延敏感的应用则更加复杂。如 图 4 所示,实现低端到端时延有时需要在 Map 阶段优先考虑吞吐量。可以牺牲单个请求的时延,以减少整个请求 DAG 的完成时间。Parrot 按逆拓扑顺序分析 LLM 请求,从与时延关键语义变量相连的请求开始,如 图 9 所示。基于提取的 DAG,直接生成时延关键语义变量的 LLM 请求会被标记为时延敏感(请求 1 和 2),它们的直接前驱(请求 3)也同样如此。处于同一阶段的并行 LLM 请求会被分组为一个任务组(任务组 0 和 1)。调度器应当最小化整个任务组的时延,这通常会导致采用更大的批容量以提高 token 生成的吞吐量。
共享 Prompt 前缀
当一个 LLM 请求被调度到某个 LLM 引擎时,引擎会创建一个上下文来存储该请求的模型执行状态(主要是 KV cache)。已有研究提出在 LLM 引擎中共享 prompt 的公共前缀对应的 KV cache 以节省 GPU 内存。然而,正如 §3 所述,当今公共 LLM 服务面对来自不同应用的大量多样化请求时,在集群层面识别共性十分困难。逐 token 比较在时间复杂度上不可行,尤其是在上下文很长且请求数量巨大的情况下。
在 Parrot 中,通过将语义变量暴露给 LLM 服务,可以理解 prompt 的结构,从而在语义变量粒度上更高效地自动检测共性。借助 Parrot 的 PrefixHash 原语,只需检查 prompt 中每个语义变量之后位置的哈希值。Parrot 维护一个键值存储,将(哈希后的)前缀 token 映射到请求列表,使调度器能够以在线方式快速发现共享机会,既支持静态 prompt,也支持动态生成的 prompt,且可在同一应用内甚至跨应用生效。
此外,我们为具有公共前缀的请求提出了更优的 GPU 注意力计算内核。我们首先利用 vLLM 的分页内存管理来减少冗余的 GPU 内存占用,但 vLLM 的内核仍然存在对共享 token 的重复计算与内存加载。因此,我们结合 FlashAttention 与 PagedAttention 设计了一种新的注意力解码算法,将共享 token 与非共享 token 分别处理,从而显著加速共享上下文的注意力计算(实现细节见 §7)。
面向应用的调度
Parrot 的调度问题本质上是将 LLM 请求匹配到 LLM 引擎,即集群级调度;引擎级调度的细节将在 §7 的实现部分介绍。为解决现有公共 LLM 服务盲目优化单个请求的问题,Parrot 的调度策略利用应用级知识来优化端到端性能。具体而言,Parrot 调度器的首要目标是在优化 GPU 集群利用率的同时,满足 LLM 应用多样化的性能目标。
如 §3 所述,吞吐优先与时延优先请求并存会产生冲突:大批大小可以提升吞吐量和 GPU 效率,但会恶化时延,反之亦然。基于 Transformer 的 LLM 推理在很大程度上受内存带宽限制,其时延受引擎内并发 token 数量影响。为满足 LLM 应用(尤其是时延)的性能目标,LLM 引擎必须将 token 数量控制在某个阈值以下,该阈值由时延约束最严格的请求决定。因此,Parrot 的调度原则有两点:(1)将性能需求相似的 LLM 请求分组,以规避冲突;(2)最大化请求间的共享机会。
算法 1 给出了 Parrot 的请求调度流程。基于提取的 DAG,系统按照拓扑顺序排列 LLM 请求(第 1 行)。Parrot 倾向于将同一应用的请求一起调度,以避免交错调度带来的性能下降(§8.2)。对于通过性能目标推导被识别为同一任务组的请求,调度器会尝试整体分配任务组(第 4–5 行)。此外,如果 Parrot 检测到队列中或正在运行的上下文存在公共前缀,则会尝试将它们分配到同一 LLM 引擎(第 3 行、第 6–9 行),以利用 Parrot 的上下文分叉减少冗余计算和 GPU 内存事务。对于不具备上述机会的请求,Parrot 将其独立调度(第 10–11 行)。
算法 1:Parrot 的请求调度

由于篇幅所限,这里省略了 Parrot 选择 LLM 引擎(即 FindEngine)的细节。简而言之,Parrot 会在满足请求调度偏好的前提下,选择负面影响最小的引擎。例如,如果将一个时延敏感请求调度到一个最多可并行运行 64,000 个吞吐导向 token 的引擎上,其容量将被显著压缩至 2,000,以满足严格的时延要求;而如果将其调度到一个已经在运行时延敏感请求的引擎上,则容量缩减几乎可以忽略不计。
讨论
动态应用与函数调用
目前,Parrot 仅支持云端侧的 LLM 请求编排,不涉及动态控制流和本地函数(例如 Python 代码)。这些仍需要在客户端侧执行。我们有意禁止将此类函数卸载到公共 LLM 服务中,以最小化恶意注入带来的安全风险。对于私有 LLM 服务(其 LLM 应用是可信的,或存在可信执行区域来运行这些函数),Parrot 的 API 可以很容易地扩展以支持条件连接和本地代码提交。此外,这些扩展还能进一步启用新的优化,例如:基于历史画像,对动态应用中的高概率分支进行推测性预启动。这也证明了 Parrot 设计在应对新类型应用时的潜力。我们将这些扩展留作未来工作。
跨请求分析的其他应用
Parrot 中的跨请求分析开启了一个全新的优化空间,并不限于 §5 中介绍的内容。大规模服务还需要考虑更多调度特性,包括异常值处理、作业失败、延迟调度、公平性、饥饿问题,以及异构集群支持等,这些问题在其他系统中已有广泛研究。Parrot 从基于 LLM 的应用视角提供了一种新的观察方式:要优化应用的端到端性能,就必须理解 LLM 请求之间的互联关系和共性。这些特性都可以结合 LLM 应用的新特征,在 LLM 服务系统中重新审视。本文聚焦于 Parrot 的机制及少量用例,其余优化方向作为有前景的未来工作。
Parrot 与 LLM 编排框架
目前已有多种用于构建基于 LLM 应用的框架,例如 LangChain、SemanticKernel 和 PromptFlow。这些框架的核心功能是将多个 LLM 调用“粘合”在一起,以完成复杂任务(即 LLM 编排)。Parrot 可以通过扩展这些框架对 LLM 服务 API 的调用方式,引入语义变量来实现集成。大多数此类框架已经采用了基于模板的方法,开发者可以设计带占位符的模板,并在运行时渲染这些占位符。这些占位符在概念上与 Parrot 的语义变量是一致的。然而,由于这些框架会在提交前渲染模板 prompt,LLM 服务便失去了 prompt 结构信息。为使这些框架与 Parrot 兼容,需要将模板本身以及用于渲染模板的变量(在 Parrot 中即语义变量)共同封装为一个语义函数,从而将必要的信息暴露给 Parrot 的 LLM 服务。
实现
Parrot 是一个面向 LLM 应用的端到端 LLM 服务系统,使用 Python 实现,总计约 14,000 行代码。其前端提供了语义变量和语义函数的抽象,并将其转换为 Parrot 的 API(基于 FastAPI 实现)以提交为 LLM 请求。一个集中式的 Parrot Manager 负责 LLM 请求的管理,包括语义变量、通信和调度。我们还构建了一个基于高效内核的 LLM 引擎,结合了 vLLM、xFormers 以及我们自研的优化。该引擎支持多种高级 LLM 服务特性,包括分页内存管理和连续批处理。Parrot 的前端和管理器分别由 1,600 行 和 3,200 行 Python 代码实现;LLM 引擎由 5,400 行 Python 和 1,600 行 CUDA 代码实现。我们基于 PyTorch 和 Transformers 实现了 OPT 和 LLaMA 模型。
API
通过语义函数或其他前端编写的应用,最终都会经由不同的适配器,转换为对统一 API 的请求。Parrot 提供了类 OpenAI API,并扩展以支持语义变量。§4.1 中提到的两种操作的请求体如下所示:
(submit) {
"prompt": str,
"placeholders": [
{
"name": str,
"in_out": bool,
"semantic_var_id": str,
"transforms": str
}
],
"session_id": str
}
(get) {
"semantic_var_id": str,
"criteria": str,
"session_id": str
}
除了静态字符串 prompt 之外,Parrot 还会保留输入和输出占位符。每个占位符都会关联一个语义变量,用于渲染输入或解析输出。正如 §5.1 所述,Parrot 支持在输入前或输出后进行转换操作。此外,Parrot 还支持用于设置和获取语义变量值的其他 API。当获取某个语义变量时,如果其中间步骤(包括引擎、通信或字符串转换)失败,则会返回错误信息。
内核优化
尽管 vLLM 的 GPU 内核能够复用 prompt 中共享前缀 token 在 GPU 内存中的缓存结果,但在某些情况下,它仍会频繁地将这些 token 从全局内存加载到共享内存中,从而影响注意力分数的计算效率。我们使用 OpenAI Triton 和 CUDA 开发了一种新的 GPU 内核,结合了 PagedAttention 和 FlashAttention 的思想,用于加速涉及共享前缀的注意力解码计算。该内核延续了 PagedAttention 将 KV cache 存储在分散内存段中的方式,并为每个请求使用页表来跟踪块的状态和位置。同时,借助 FlashAttention 的原理,该内核在共享内存中最大化数据复用。与 PagedAttention 中反复加载 tile 不同,该实现仅将共享前缀的 KV cache tile 加载一次到共享内存,从而减少 L2 Cache 与共享内存之间的内存事务。内核首先利用加载的 tile 计算共享前缀的中间注意力量(包括注意力分数、qk_max 和 exp_sum),并将其写回 HBM;随后处理前缀之后的新 token 的部分注意力,并将其与前缀的中间结果合并,得到最终的注意力输出。
通用引擎抽象
Parrot 的集群管理器可以控制多个运行不同模型、分词器和 KV cache 布局的引擎。为了支持 Parrot 的优化,LLM 引擎需要具备以下能力:(1)有状态生成;(2)在不同请求之间共享 KV cache 状态。因此,我们提出了一种通用抽象,用于描述 LLM 引擎集成到 Parrot 所需的最小能力:
def Fill(token_ids: List[int], context_id: int,
parent_context_id: int)
def Generate(sampling_configs: Dict, context_id: int,
parent_context_id: int)
def FreeContext(context_id: int)
这三个方法不仅覆盖了 LLM 推理引擎的基本补全功能,还提供了灵活的上下文管理接口。Fill 方法用于处理初始 prompt token,计算并将 KV cache 填充到对应上下文中;Generate 方法在给定采样配置(如 temperature)下,通过生成式解码逐 token 生成输出,直到达到长度限制、用户定义的终止符或 EOS 标记。Fill 和 Generate 会由引擎调度器按迭代方式进行调度和批处理,实现连续批处理。通过设置 context_id 和 parent_context_id,还可以实现上下文的创建与分叉。FreeContext 方法用于显式释放上下文(即释放其在 GPU 内存中的 KV cache)。将 Fill 与 Generate 分离不仅天然契合语义变量的使用方式(常量文本和输入值由 Fill 处理,输出值由 Generate 生成),还将请求级依赖拆分到更细粒度,从而创造出更多的并行执行机会。
评估
实验设置
测试平台。 我们在两种独立配置上评估 Parrot:单 GPU 与 多 GPU。单 GPU 实验使用一台配备 24 核 AMD-EPYC-7V13 CPU 与 1 张 NVIDIA A100(80GB)GPU 的服务器;多 GPU 实验使用一台配备 64 核 AMD EPYC CPU 与 4 张 NVIDIA A6000(48GB)GPU 的服务器。两台服务器均运行 CUDA 12.1 与 cuDNN 8.9.2。
工作负载。 评估涵盖四类具有代表性的 LLM 应用。每个 LLM 引擎使用一张 GPU,并运行 LLaMA 13B 或 LLaMA 7B 模型。针对长文档的 LLM 数据分析,我们使用 Arxiv 数据集,在大量学术论文上执行链式与 Map-Reduce 摘要。为研究多用户场景下的共享机会,我们运行来自 Bing Copilot 与 GPTs 的 prompt 并合成用户查询。对于多智能体应用,我们基于 MetaGPT 构建了一个多智能体编程应用,包含系统架构师(设计 API)、多名程序员(为不同文件编写代码)以及评审者(共享评审意见),程序员还会根据评审意见修改代码。针对聊天服务负载,我们基于 ShareGPT 数据集构建场景。根据测量分布,我们为 LLM 请求引入 200–300 ms 的随机延迟以模拟互联网中的典型网络开销。为构建逼真的工作负载,我们使用 GPT-4 记录 LLM 响应,确保 LLaMA 生成的文本长度相近,以便进行系统性能分析。表 2 给出了工作负载及 Parrot 中生效的优化。
表 2:工作负载及其对应生效的优化。
基线。 我们将 Parrot 与当前用于构建 LLM 应用和服务 LLM 请求的最先进方案进行对比。基线中的大多数 LLM 应用基于 LangChain 开发。基线使用 FastChat 提供的 OpenAI 风格聊天补全 API。FastChat 是一个广泛使用的开源 LLM 服务系统。进入 FastChat 的请求会被分配到运行 Transformers 或 vLLM 的 LLM 引擎,这些引擎集成了 FlashAttention、PagedAttention 与连续批处理等优化。FastChat 的默认调度策略是将新请求分配给当前队列最短的 LLM 引擎。由于现有 LLM 服务通常通过“聊天”补全 API 暴露功能,基线评估将所有请求视为相互独立且对时延高度敏感。为控制 token 生成的响应时间,每个 LLM 引擎设置了容量阈值,即引擎上所有活跃请求的 token 总数。由于 LLM 的 token 生成通常受内存带宽限制,引擎的 TPOT(Time-per-output-token)主要由批内并发 token 数量决定。
图 10:vLLM 在不同 token 容量与请求速率下的每输出 token 时延(TPOT)。请求采样自 ShareGPT,到达时间服从泊松分布。
如 图 10 所示,当连续批处理开启且批容量超过 6144 时,vLLM 的 TPOT 会显著上升。在评估中,我们设置 LLM 引擎在时延敏感请求下将生成时延控制在 40 ms/s 以内,这与我们对 OpenAI LLM 服务的经验一致。当所有 LLM 引擎达到最大容量时,额外请求将以 FIFO 方式排队,等待资源释放。以满意时延服务超长上下文(如 32k 甚至 1M token)需要使用张量并行或序列并行,或采用近似注意力方法,这超出了本文范围。
长文档数据分析
在数据分析实验中,我们从 Arxiv-March 数据集中随机选取 10 篇长文档,分别使用链式摘要与 Map-Reduce 摘要。每篇文档均超过 20,000 token。结果报告所有文档的平均端到端时延。
链式应用。 评估展示了 Parrot 通过降低客户端交互导致的通信开销来提升链式摘要性能。图 11 给出了在单个 LLM 引擎(A100,LLaMA 13B)上对单篇文档进行摘要的平均端到端时延。我们调整分块大小(每块 token 数)与输出长度,结果分别见 图 11a 与 图 11b。与使用 vLLM 和 Transformers 的基线相比,Parrot 的端到端时延最高分别降低 1.38× 与 1.88×。效率提升主要来自网络时延的降低,这是减少客户端交互的直接结果。随着输出长度增加,生成时间占比提升,Parrot 相对基线的优势减小。增大分块大小会减少分块数量,但加速幅度取决于每块节省的网络时延。鉴于 token 生成通常比 prompt 处理更耗时,在固定输出长度下,随着分块大小变化,我们仍观察到稳定的加速(相对 vLLM 为 1.2×,相对 Transformers 为 1.66×)。这表明 Parrot 对依赖 LLM 请求的优化尤其有利于短输出场景,这在摘要、短答、打分与选择题等应用中十分常见。由于 Transformers 相较 vLLM 性能更慢,后续评估仅比较 Parrot 与 vLLM。
图 11:在不同输出长度与分块大小下,链式摘要的平均端到端时延。
图 12a 进一步在不同速率的后台 LLM 请求下评估 Parrot 减轻依赖请求排队时延的能力。与基线(vLLM)相比,Parrot 将端到端时延降低至 2.38×。在 Parrot 中,一旦首块摘要完成,后续分块即可立刻处理,并将先前分块的摘要并入 prompt 以生成下一块摘要;而基线将请求视为独立个体,除客户端交互带来的网络时延外,后续请求还需重新入队,导致额外排队。
图 12b 展示了并发提交多个链式摘要应用(每个应用处理不同文档)时的端到端时延。根据 图 13,Parrot 在不拖慢任何应用的情况下,将所有应用的平均端到端时延降低 1.68×。相反,基线通过不同应用的交错执行,加剧了所有应用的端到端时延。上述实验验证了:识别 LLM 请求之间的关联,相较于孤立处理请求,能够显著提升端到端性能。
图 12:在后台负载与并发应用条件下的链式摘要端到端时延。
图 13:25 个链式摘要应用在基线与 Parrot 之间的端到端时延差异。Parrot 中所有应用均更早完成。
图 14:在不同输出长度与分块大小下,Map-Reduce 文档摘要的平均端到端时延。
Map-Reduce 应用
文档摘要应用的另一种实现方式遵循 Map-Reduce 范式,如 图 1a 所示。该方法包含多个并行的 Map 阶段 LLM 请求,每个请求对文档的不同片段进行摘要,随后由一个 Reduce 阶段 LLM 请求 将这些独立摘要聚合为最终摘要。如 图 14 所示,在使用单个 LLM 引擎(A100,LLaMA 13B)时,Parrot 相较基线实现了 2.37× 的加速。由于 Map 阶段的 LLM 请求彼此独立,Parrot 与基线都会并行调度这些请求。Parrot 的主要优势来源于其性能目标推导,能够识别 Map 任务并将其视为一个任务组。通过识别这种关系,Parrot 能够采用更大的批大小来优化整个任务组的时延,从而提升吞吐量。相比之下,基线将每个 LLM 请求孤立处理,并假设它们都对时延高度敏感。这迫使基线在 LLM 引擎上使用受限的 token 容量(4096 token)来优化单个任务的时延,从而损害了应用的端到端性能。这进一步强调了 LLM 服务区分不同 LLM 请求以优化多样化应用端到端性能的必要性。
图 15:在不同批大小下,Bing Copilot 的时延。
服务流行的 LLM 应用
生产级应用需要面对海量用户。如 图 5 所示,开发者通常需要使用很长的 system prompt 来定义 LLM 的行为。因此,同一 LLM 应用的不同用户往往会共享相同的 prompt,这可以受益于 Parrot 的上下文分叉机制以及其将共享长前缀 prompt 的 LLM 请求协同放置的调度策略。由于我们无法访问 Bing Copilot 的中间步骤,这里仅评估生成最终用户响应的请求。我们根据 Bing Copilot 的长度分布合成了 64 个请求。system prompt 长度约为 6000 token,输出长度在 180–800 token 之间。图 15 展示了 Parrot 与基线在 Bing Copilot 场景下的平均请求时延。
由于基线系统中的 LLM 服务不了解 prompt 结构,难以从大量 LLM 请求中推断出共享的 prompt。与不共享 prompt 的基线相比,在批大小为 8 和 16 时,Parrot 实现了 1.8×–2.4× 的加速。进一步增大批大小会因共享 system prompt 的巨大 KV cache 而导致显存不足。我们还构建了一个使用 vLLM 的 paged attention、支持静态前缀共享的高级基线。Parrot 与 vLLM 均使用 paged memory management,因此在同一 LLM 引擎(A100,LLaMA 7B)上可容纳相同数量的 token。得益于更优的 GPU 内核,Parrot 相较 vLLM 仍实现了 1.1×–1.7× 的加速。尽管 vLLM 能减少共享 prompt 的额外内存使用,但其 GPU 内核仍需反复加载共享 token。由于 LLM 的 token 生成受内存带宽限制,这种冗余内存加载会拖慢端到端推理。通过结合 FlashAttention 与 PagedAttention,Parrot 在计算不同用户分支 token 的注意力时,只需加载一次共享 prompt 的 token。Parrot 在共享 prompt 场景下的加速主要来自 token 生成阶段,因此输出长度越长,性能提升越明显。图 16 显示,在批大小为 32 时,Parrot 相较使用 paged attention 的 vLLM 实现了 1.58× 和 1.84× 的加速,并保持 40 ms 的每输出 token 时延。
图 16:Bing Copilot 的每输出 token 时延。
在 图 17 中,我们进一步评估了在多 GPU 集群中同时服务多个 GPTs 应用 的情况。部署了 4 张 A6000(48GB)GPU,运行 4 个 LLM 引擎(LLaMA 7B)。我们选择了四类流行的 GPTs 应用:生产力、编程、图像生成和数据分析。来自四个类别的 LLM 请求以相同概率随机生成,并按泊松分布以固定速率到达。与不进行共享的基线相比,Parrot 能够支持 12× 更高的请求速率。由于基线的调度策略无法感知每个 LLM 应用内部的共享 prompt,请求会被混合分配到所有 LLM 引擎上,从而无法复用公共前缀。Parrot 的调度策略将同一应用的 LLM 请求协同放置,最大化共享机会,实现更低的推理时延和更高的集群吞吐量。在关闭这种亲和性调度策略后,Parrot 相较基线仅表现出 3× 的请求速率提升,因为共享前缀的请求往往被分配到不同引擎,降低了共享机会。此外,Parrot 的注意力内核通过避免共享 prompt 注意力计算中的冗余内存加载,使其相较使用 vLLM PagedAttention 的 Parrot 版本实现了 2.4× 更高的请求速率。
图 17:服务多个 GPTs 应用。
多智能体应用
我们评估了 Parrot 中基于 MetaGPT 的多智能体系统性能。工作流包含三个不同角色:首先,架构师为给定任务设计项目文件结构并定义各文件中的 API;随后,多个 程序员 分别负责实现不同文件;在整合所有代码后,多个 评审者 分别检查并评论单个文件;随后程序员根据评论修改代码。该评审—修改循环重复三次以生成最终代码。图 18 展示了在单张 A100(LLaMA 13B)上,Parrot 与基线系统在不同文件数量下的时延与内存使用情况。与时延导向基线相比,Parrot 实现了 最高 11.7× 的加速。主要提升来自 Parrot 根据端到端性能准则推导 LLM 请求性能目标的能力。在该多智能体场景中,目标是最小化最终代码生成时间。Parrot 识别出编码、评审与修改阶段中的多个并行任务组,从而允许使用更大的批大小以提升吞吐量并减少任务组完成时间。我们还将 Parrot 与一个吞吐导向基线进行对比,该基线刻意使用更大的批大小来优化集群吞吐量,相较时延导向基线也展现出更高并发和更短完成时间。
即便与吞吐导向基线相比,Parrot 仍然表现更优,最高快 2.45×。这一提升主要来自 Parrot 通过 prompt 结构分析 降低冗余,贡献了 2.35× 的加速。由于 MetaGPT 中角色之间具有高度交互性,不同角色的上下文存在大量重叠,Parrot 能将这些公共上下文作为 prompt 前缀共享。vLLM 的静态前缀共享机制在这种动态场景下并不适用;在不了解 prompt 结构的情况下,它无法识别运行时动态生成、同样可共享的语义变量。如 图 18b 所示,缺乏该共享能力的 Parrot 将很快触及 GPU 显存上限。此外,Parrot 针对共享前缀设计的专用 GPU 内核在文件数为 16 时,相较使用 vLLM PagedAttention 还能实现 额外 1.2× 的加速,得益于内存事务的减少。
图 18:多智能体编程在不同文件数量下的时延与内存使用情况。
混合工作负载的调度
为评估 Parrot 在多 GPU 环境下的性能,我们配置了一个包含 4 张 A6000(48GB)GPU 的集群,每张 GPU 运行一个独立的 LLM 引擎(LLaMA 7B),共计 4 个 LLM 引擎。我们通过注入来自聊天应用(速率为 1 req/s)以及 §8.2 中分析的数据分析任务(即 Map-Reduce 应用)的混合请求,来模拟真实世界中 LLM 服务面临的多样化需求。聊天应用请求以低时延为主要目标,而 Map-Reduce 应用则更关注高吞吐量,当它们被同一 LLM 引擎并行处理时,会产生显著冲突。
我们将 Parrot 与两种参考实现进行对比:一种时延导向实现,通过限制引擎容量来降低解码时延;另一种吞吐导向实现,使用引擎的全部容量以最大化 GPU 利用率。
图 19:聊天应用与 Map-Reduce 应用的混合负载。
如 图 19 所示,与时延导向和吞吐导向基线相比,Parrot 在聊天应用的归一化时延(即 每输出 token 的请求时延)上分别实现了 5.5× 和 1.23× 的提升。在聊天应用的 token 生成速度方面,Parrot 与时延导向基线性能相当,并相较吞吐导向基线提升 1.72×。对于 Map-Reduce 应用,Parrot 相较时延导向基线实现了 3.7× 的加速,并相较吞吐导向基线提升 1.05×。Parrot 能够同时为聊天应用提供低时延、为 Map-Reduce 应用提供高吞吐量,其关键在于通过智能调度将不同类型的工作负载分配到不同引擎上,从而缓解二者之间的资源竞争。这些结果凸显了对多样化请求进行差异化处理以提升 LLM 服务整体性能的重要性。
相关工作
深度学习服务系统
近年来,模型服务领域的研究迅速增长,出现了大量系统以应对深度学习模型部署中的不同挑战,包括 Clipper、TensorFlow Serving、Clockwork、REEF 和 AlpaServe。这些系统在批处理、缓存、部署、调度以及模型并行等方面进行了深入探索,用于服务单模型或多模型。然而,这些系统主要面向通用深度学习模型,对大型语言模型的独特需求(例如自回归解码)考虑较少。
Orca 提出了连续批处理,在迭代级别对多个 LLM 请求进行批处理。vLLM 提出了 PagedAttention,通过使用非连续内存对不同长度的 LLM 请求进行批处理,提高内存利用率。尽管这些系统在 LLM 服务方面取得了显著进展,但它们仍将 LLM 请求视为相互独立的个体,未能理解应用内部的请求关联性,也无法充分利用请求之间的共性。Parrot 与这些工作是正交的:通过语义变量暴露更多应用级信息,Parrot 能够对 LLM 请求进行数据流分析,从而开启一个以优化应用端到端性能为目标的全新优化空间,而非仅关注单个请求。
LLM 编排框架
LLM 编排框架帮助开发者创建和管理基于 LLM 的应用,简化了 prompt 设计以及多次 LLM 请求的编排过程。LangChain 提供了诸如 chain、map-reduce 等工作流模式,便于开发者定制 LLM 应用。Semantic Kernel 引入了 Planner,能够根据用户需求自动生成执行计划。PromptFlow 支持本地函数与语义函数的链式组合,并以图的形式进行可视化。LlamaIndex 允许开发者使用自然语言查询来检索相关文档。Parrot 与这些框架同样是正交的,并且可以通过引入语义变量抽象轻松与它们集成,如 §6 所述。
DAG 感知的系统优化
依赖图或 DAG 在许多系统中广泛存在,已有大量工作利用 DAG 信息来优化系统性能。Tez、Dryad 和 Graphene 利用任务依赖关系来优化并行数据分析工作负载的调度与打包。SONIC、Caerus 和 Orion 从通信、时延和成本等角度优化无服务器函数。Parrot 从这些系统中汲取经验,认识到LLM 请求之间的相关性对于优化 LLM 应用端到端性能至关重要,这促使 Parrot 设计 API 来显式暴露此类依赖信息。此外,与其他系统不同的是,LLM 应用还需要理解 prompt 结构,以支持通信优化和请求共性识别。这也是我们提出语义变量抽象,而不仅仅使用请求 DAG 的原因。
总结
本文提出了 Parrot,一种将 LLM 应用视为一等公民 的系统,目标是优化 LLM 应用的端到端性能,而不仅仅是单个 LLM 请求。我们提出 语义变量 作为关键抽象,用于暴露 LLM 请求之间的依赖关系与共性,从而开启新的优化空间。实验评估表明,Parrot 可将基于 LLM 的应用性能提升 最高达 11.7×。我们相信,这一从端到端视角提升 LLM 应用效率的新思路,将为未来研究(例如 LLM 应用端到端性能公平性等调度特性)提供广阔方向。
致谢
我们感谢匿名评审人和论文指导人提出的建设性反馈与建议。Zhenhua Han、Yuqing Yang 和 Chen Chen 为本文的通讯作者。