AI 基建
0%
第五部分 · 推断与服务 · 第 31 章

服务问题

作者Changkun Ou
阅读时长约 12 分钟

训练好的模型是一个文件;被服务的模型则是一套按秒和按钱计量的系统。服务首先把一次前向传播分成两个阶段:预填充(prefill)先读完整个提示词,主要受算力限制;解码(decode)一次只发出一个词元,通常受带宽限制。这种分裂把延迟、吞吐量与内存合成同一个调度问题。真正的目标是有效吞吐量(goodput),而非原始吞吐量或原始延迟;KV 缓存则决定这套服务系统能接纳多少并发请求。

本章从这个资源约束出发。缓存的形状由架构固定,成本落在加速器内存上,并被每个在飞请求继续拉长。一旦它成为约束,服务引擎的历史就容易读了:连续批处理不再浪费已完成请求的槽位,PagedAttention 不再预留用不上的缓存,前缀缓存不再重算共享提示词,分块预填充或预填充/解码分离则不再让一个阶段阻塞另一个阶段。

一个必须守时的文件

预训练只花一次算力,造出一个权重文件。服务却要持续消耗算力,每当一个用户发来请求就消耗一次,而且是在用户能感觉到的时钟下消耗。问题不再是最小化某个损失,而是把一组固定的权重跑在一条请求流上,让每个请求返回得足够快,集群又保持足够高的利用率。

有两种延迟定义了什么叫「足够快」,而它们不是同一个数。首词元时间(TTFT)是用户在答案的第一个片段出现之前要等多久。每输出词元时间(TPOT),有时也叫词元间延迟,是流开始之后每个后续词元要花多久。聊天界面需要短的 TTFT,好让回复显得及时;长生成需要短的 TPOT,好让文本连续输出。这两个时钟并非随意定下的产品目标,它们源于同一次前向传播分裂成两个硬件瓶颈不同的阶段。

预填充与解码的不同瓶颈

请求到来时,模型先读完整个提示词。这是**预填充**阶段:所有提示词词元在一次传播中被一起处理,每个位置都注意到每个更早的位置,这是一串大的矩阵-矩阵乘法。预填充是计算受限的,它会占满加速器的算术单元,成本随提示词长度增长。TTFT 本质上就是预填充所花的时间。

随后模型一次输出一个答案词元。这是**解码**阶段:每一步把最近的那一个词元送过网络,产出下一个,这是一串细长的矩阵-向量乘法。解码受限于内存带宽,而非算力。每步的算术量极小,但每步都得把完整的权重矩阵、以及不断增长的过去键值缓存从内存里流式读出,主导时间的是这次流式读取,而非算术。roofline 模型按算力或内存带宽中的一者来界定一个内核的性能上限,而这种 roofline 分裂正是推断性能的基本事实:预填充顶着算力上限,解码顶着带宽上限 (Yuan et al. 2024; Pope et al. 2022)。TPOT 本质上就是每步的解码延迟,而一个空转的解码步会让加速器的大部分算力闲置。

图 31.1 追踪一个请求穿过两个阶段,并列出每个阶段的算术形状、它主要占用的资源,以及它设定的延迟指标。

cluster_pf 预填充(一次传播) cluster_dec 解码(每步一个词元) req 请求到来 pfa 所有提示词词元一起 req->pfa pfb 矩阵-矩阵乘法 pfa->pfb pfc 计算受限 pfb->pfc deca 只有最新词元 pfc->deca 设定 TTFT decb 矩阵-向量乘法 deca->decb decc 内存带宽受限 decb->decc decc->deca 循环至停止,设定 TPOT done 答案流式输出 decc->done
图 31.1. 一个请求穿过预填充与解码。两个阶段占用不同的资源,并设定不同的延迟指标。

这个分裂之所以要紧,是因为两个阶段以不同方式压在同一份硬件上。预填充需要足够的工作量,让算术单元保持忙碌。解码需要把足够多请求放进同一个批,让一次昂贵的权重流式读取同时服务许多用户。批处理就是主要手段,它把带宽受限的解码转成更高效的计算:权重读一次,然后摊销到批中的每个请求上 (Pope et al. 2022)。于是吞吐量,即所有用户合计的每秒词元数,随批大小上升,而单个用户看到的延迟,会随批被填满而恶化。服务问题就是这种取舍的具体化:从批处理里赢得吞吐量,同时不耗尽用户的延迟预算。

单一指标为何误导

人们很想追逐某个单一指标,可两个显而易见的候选,吞吐量和延迟,单独拎出来看都具有误导性。吞吐量统计集群产出的每一个词元,延迟统计一个用户要等多久,二者都不是业务目标。一台服务器可以靠激进批处理报出巨大的吞吐量,激进到每个单独请求都错过自己的截止期;它也可以靠在空闲硬件上一次只跑一个请求,报出极小的延迟。能经受现实检验的目标是**有效吞吐量**:在各自延迟目标之内完成请求的速率。一个请求一旦超过自己的 TTFT 或 TPOT 预算,其后产出的词元就不算有用的工作。有效吞吐量,是只在满足服务等级目标(SLO)(服务等级目标,service-level objective)的请求上统计的吞吐量。

有效吞吐量重新框定了之后的每一个决策。调度器的任务,是把尽可能多的请求安排到加速器上,同时不把其中任何一个推过它的 SLO。这是一个带硬约束的装箱问题,而约束由内存设定。要看清为何如此,得先问解码必须保留什么。

KV 缓存是主要约束

图 31.2. 键值缓存内存随上下文长度的变化。拖动层数、键值头数与批大小的滑块,看缓存如何反超权重。

tt 步的注意力需要所有更早位置的键与值。与其每步重算,服务器不如把它们缓存下来。这就是 KV 缓存。它的大小由架构固定,而非由服务层固定:

KV bytes=2LnkvdheadbdtypeT\text{KV bytes} = 2 \cdot L \cdot n_{\text{kv}} \cdot d_{\text{head}} \cdot b_{\text{dtype}} \cdot T

其中 LL 是层数,nkvn_{\text{kv}} 是键值头数,dheadd_{\text{head}} 是头维度,bdtypeb_{\text{dtype}} 是每元素字节数,TT 是被缓存的词元数。因子二是为键与值,即每个被注意到的词元贡献的两个向量:用来与查询匹配的键,以及被加权汇入输出的值。缓存随序列长度线性增长,也随并发请求数线性增长,而且它住在与权重同一份稀缺加速器内存里。压力由此变得清楚:权重占去一块固定的内存切片,剩下的全部在当前在飞的各请求的 KV 缓存之间瓜分。能批进多少请求,从而吞吐量与有效吞吐量的上限,取决于剩下多少 KV 缓存内存、以及用得多有效。图 31.4 展示了这道划分,而 图 31.3 把这种线性增长及其后果具体化:每个请求的缓存随上下文长度沿一条直线上升,剩下的池则决定能同时容纳多少个这样的请求。

2026-06-23T21:05:50.251579 image/svg+xml Matplotlib v3.11.0, https://matplotlib.org/ 0 2000 4000 6000 8000 10000 12000 14000 16000 每请求缓存词元数(上下文长度) 0 10 20 30 40 50 KV 缓存内存(GB) 剩余 KV 池(总内存减权重) 内存池可容纳 33 个此类请求 单个请求的 KV 缓存
图 31.3. KV 缓存压力的示意:一个请求的缓存随其上下文长度线性增长,而固定的剩余池,即加速器内存总量减去权重切片,决定能容纳多少并发请求。理想化数值,仿 Kwon et al. (2023)。
memory mem 加速器内存(固定总量) weights 权重(固定切片) mem->weights pool 剩下的 KV 池 mem->pool r1 请求 1 的 KV 缓存 pool->r1 r2 请求 2 的 KV 缓存 pool->r2 rn 请求 N 的 KV 缓存 pool->rn cap N 由池大小决定,有效吞吐量上限亦然 rn->cap
图 31.4. 加速器内存分成固定的权重切片与剩下的、由在飞请求共享的 KV 池。池的大小决定能批进多少请求,从而决定有效吞吐量上限。

这就是为什么架构章的注意力变体对一个服务工程师如此要紧。多查询注意力(MQA)(多查询注意力,让多个查询头共享同一套键/值头)与分组查询注意力(GQA)(分组查询注意力,让一组查询头共享键/值头)缩小 nkvn_{\text{kv}}多头潜在注意力(MLA)压缩被缓存的状态,每一种都直接削减每词元的 KV 字节数。它们的设计,部分正是为了松开这道服务约束。至于 TT 增长到数十万时会发生什么,第 35 章 会再回来谈。

下层约束

第 8 章 通过层数、头几何、以及多查询、分组查询或潜在注意力的选择固定了 KV 缓存的大小,而服务层必须调度的正是这个资源。服务层无法自由重新设计缓存;架构把它传下来,调度器只能在这个约束内工作。同一条约束也会反向传导,并回应 第 5 章 开启的问题:这里所度量的解码成本,在模型的整个生命周期里按每个词元支付,这正是较小模型值得训练到远超计算最优点的原因之一。一个词元训练起来便宜、服务起来昂贵,于是服务层的经济学反过来影响栈的上层,重塑一个看似纯属预训练的决策。

少浪费缓存的四步

一旦把缓存认定为主要约束,服务引擎的历史就可以概括为「少浪费一点缓存」。服务栈是靠一次去掉一处浪费,才得到如今形状的,而每一次去掉,在成为一个内核之前,都先是一个调度思想。

别再占着一个已完成请求的槽位。 最早的系统像 Web 服务器服务 HTTP 那样服务请求:攒一个批,跑到完成,返回这个批。这是请求级批处理,它把加速器浪费得很厉害,因为批中一个短生成早早完成,然后空转着等最长那个生成跑完,同时占着它的槽位。Orca 用迭代级调度取代了请求级批处理:调度器在单个解码步的粒度上决定跑什么,于是一个完成的请求立刻离开批,一个新到的请求无需等其余请求就能加入 (Yu et al. 2022)。这个思想,通常称为连续批处理(continuous batching),是每个现代引擎的地基。

别再预留用不上的缓存。 迭代级调度暴露了下一个瓶颈,那就是内存。连续批处理能在槽位一打开的瞬间就接纳一个请求,但前提是有 KV 缓存内存来容纳它,而早期引擎按最大可能长度,为每个请求分配一整块连续内存。多数请求远短于那个最大值,于是多数被预留的内存空着,把内存池碎片化,把批大小压在远低于硬件能跑的水平。PagedAttention 分页注意力(PagedAttention) 与 vLLM 引擎从操作系统那里借来了虚拟内存的分页:把 KV 缓存存进固定大小的块,这些块不必连续,随序列增长按需分配块,并让一张块表把逻辑位置映射到物理块 (Kwon et al. 2023)。碎片化显著减少,批增大,吞吐量在相同延迟下据报上升两到四倍 (Kwon et al. 2023)。那套分页的机制、以及它如何让跨请求的前缀共享成为可能,第 32 章 会专门展开;在这里,看见上文点名的内存压力怎样催生这个设计,就够了。

别再让一次预填充阻塞解码。 最近的一个转折正面处理预填充与解码之间的紧张关系。接纳一个新请求会跑一次昂贵的预填充,与已在飞的解码请求争抢资源:偏向预填充则 TTFT 改善,而正在输出的请求变得不平稳;偏向解码则 TTFT 上升,而进行中的流保持顺滑。因为一次预填充是一阵大的计算爆发,一个解码步是小的,把它们混在一个批里,会让一次预填充阻塞排在后面等待的解码请求,使它们的 TPOT 出现尖峰。出现了两类方案。Sarathi-Serve 把两个阶段留在同一份硬件上,但把每次预填充切成块、与进行中的解码交错,于是没有解码被一个长提示词阻塞 (Agrawal et al. 2024)。DistServe 走了另一条路,把两个阶段拆分到分开的加速器池上,于是预填充干扰根本碰不到解码延迟,代价是要在池之间运送 KV 缓存 (Zhong et al. 2024)。二者都明确地在优化有效吞吐量,而非原始吞吐量,这正是为什么分块预填充与拆分,是让人不必在 TTFT 与 TPOT 之间二选一的两种有原则的办法。

争议所在

要把预填充与解码拆分到分开的硬件上,还是把它们留在一起并交错,在这些系统问世的头几年确实悬而未决。拆分(DistServe)消除了预填充与解码的相互干扰,让每个阶段独立地扩展与并行,在每输出词元时间目标紧、而首词元时间目标松时胜出 (Zhong et al. 2024)。带分块预填充的聚合(Sarathi-Serve)让 KV 缓存留在本地,避免了在池之间转移它的代价,在相反的 SLO 平衡下、以及在更低的请求率下胜出 (Agrawal et al. 2024)。近期工作主张这个选择并非二选一,调度器应当随负载与 SLO 的变动,在两种模式之间切换,或将其统一。到前沿规模,这场争论已大体收场:拆分式服务是如今生产在用的编排层的核心设计,NVIDIA Dynamo 与 CNCF 的 llm-d 项目都在其列 (NVIDIA 2025; llm-d project 2025)。仍有争议的是交叉点:机队规模、提示词长度与互连带宽低到什么程度,那次传输就不再划算。所以对单机或短提示词的部署,仍应把拆分看作一个取决于负载与 SLO 的选择,而非自动的默认。

余下取舍在哪里发生

上面那两个预填充与解码的答案,解决了服务的一道取舍。还剩三道,每一道都在延迟、吞吐量或内存之间,用一个去换另一个,而 KV 池是它们共同面对的约束。

  • 批大小:吞吐量与延迟。 更大的解码批把权重流式读取的成本摊销到更多请求上,提高吞吐量,但它消耗更多 KV 缓存内存、拉长每一步,提高 TPOT。正确的批是仍能满足 TPOT SLO 的那个最大批,而它随请求长度变化而移动。图 31.5 以批大小为横轴画出两条曲线:吞吐量带着递减的回报接近一个带宽受限的上限,TPOT 则上升,被选中的批就坐在 TPOT 达到其预算的那一点。

    2026-06-21T23:30:53.416970 image/svg+xml Matplotlib v3.11.0, https://matplotlib.org/ 10 20 30 40 50 60 解码批大小(并发请求) 0.0 0.2 0.4 0.6 0.8 1.0 吞吐量(归一化) SLO 内最大批量 吞吐量 TPOT 1.0 1.2 1.4 1.6 1.8 2.0 每词元延迟 / TPOT(归一化) TPOT SLO
    图 31.5. 解码批大小权衡的示意:吞吐量带着递减的回报接近一个内存带宽上限,而<a href="../glossary#gls-tpot" class="rdr-gls">每词元延迟(TPOT)</a>上升,所以最好的批是仍在 TPOT SLO 之下的那个最大批。理想化数值,仿 Pope et al. (2022)。
  • KV 缓存内存与被接纳的请求。 花在权重上的内存装不了 KV 缓存,花在缓存一个长上下文请求上的内存接纳不了一个新请求。当池满时,引擎必须要么拒绝或排队新请求,要么逐出并稍后重算一个被暂停请求的缓存,用内存压力换重算成本。

  • 延迟下限与利用率。 留着加速器空闲作储备,能保护 TTFT 抵御突发,但浪费了服务之所以存在、本要变现的那份算力。有效吞吐量正是为这笔取舍定价的那个函数,因为它只在利用率产出满足 SLO 的完成时才统计它。

那个循环,以及它报告什么

一个服务引擎,其核心是一个循环:每次迭代选出一组要跑的请求,对它们跑一个模型步,并释放任何已完成请求的资源。下面这个形状,就是 Orca 引入、而每个现代引擎都在精炼的连续批处理循环 (Yu et al. 2022)。

# 连续批处理调度器的一次迭代(示意)。
while running or waiting:
    # 在 KV 缓存块空闲且 SLO 允许时,接纳新请求。
    while waiting and kv_pool.can_allocate(waiting.peek()):
        running.add(waiting.pop())          # 它的预填充加入这一步
    batch = scheduler.select(running)       # 可切块预填充,受内存约束
    outputs = model.step(batch)             # 一次预填充/解码传播
    for req in batch:
        req.append(outputs[req])
        if req.is_finished():
            kv_pool.free(req.blocks)        # 把块还回池
            running.remove(req)

关键决策不在模型步里,而在接纳请求与选择批的那两行里。kv_pool.can_allocate 是内存约束变成一个调度决策的地方,而 scheduler.select 是延迟-吞吐量权衡被敲定的地方:靠限制满足 TPOT 的批大小,靠把预填充切块使其不阻塞解码,靠针对 SLO 排序。一个真实引擎里对应的逻辑住在引擎步里,例如 vLLM V1 引擎的 EngineCore.step, vllm/v1/engine/core.py,它取代了旧的 LLMEngine 循环;批由 vllm/v1/core/sched/scheduler.py 里的调度器组成,块记账则在其 KV 缓存管理器里。

要紧的运维指标直接从问题里来。TTFT 与 TPOT 按请求度量,并作为百分位、而非均值来跟踪,因为一次尾部错过就是一次 SLO 错过。有效吞吐量是头条数字,按每秒满足 SLO 的完成数计算。KV 缓存利用率与抢占率,即引擎因内存耗尽而不得不暂停、稍后再恢复一个请求的频率,便表明当前是内存约束还是计算约束在起作用。当抢占上升,缓存就是瓶颈,解决方向是架构性的、在 第 32 章 里;当加速器满载而延迟尚可,吞吐量就是边界,解决方向在 第 33 章第 34 章 里。

服务把模型能力换成有价格的行为:一个模型只有能在延迟预算之内被服务时,才交付出一种行为。效率是核心问题,因为每加速器的有效吞吐量正是让一种能力负担得起的条件。信任则以 SLO 本身的形式进入,即一个请求会准时返回的承诺,调度器必须在给定负载下守住它。

延伸阅读

  • Yu et al., “Orca: A Distributed Serving System for Transformer-Based Generative Models,” 2022. usenix.org
  • Kwon et al., “Efficient Memory Management for Large Language Model Serving with PagedAttention” (vLLM), 2023. arXiv:2309.06180
  • Agrawal et al., “Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve,” 2024. arXiv:2403.02310
  • Zhong et al., “DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving,” 2024. arXiv:2401.09670
  • Yuan et al., “LLM Inference Unveiled: Survey and Roofline Model Insights,” 2024. arXiv:2402.16363
    本综述以 Roofline 模型为框架分析大语言模型推理效率,并引入 LLM-Viewer 工具,用于识别大语言模型部署在硬件上时的显存与计算瓶颈。
  • Pope et al., “Efficiently Scaling Transformer Inference,” 2022. arXiv:2211.05102
    本文提出用于 TPU v4 的张量并行分区框架与底层优化,使 PaLM 540B 在 int8 量化下实现 29ms/词元延迟与 76
  • NVIDIA, “NVIDIA Dynamo: A Datacenter-Scale Distributed Inference Serving Framework,” 2025. github.com
    NVIDIA Dynamo 是一个开源的数据中心级推理服务框架,围绕预填充/解码分离、KV 缓存感知的请求路由,以及跨 GPU 显存、主机 DRAM、SSD 与网络存储的多级 KV 缓存卸载构建。
  • llm-d project, “llm-d: Kubernetes-Native Distributed Inference Serving,” 2025. github.com
    llm-d 是由 Red Hat、Google Cloud、IBM Research、CoreWeave 与 NVIDIA 发起的 CNCF 沙箱项目,把预填充/解码分离、前缀缓存感知路由与多级 KV 缓存卸载带进基于 Kubernetes 的大模型服务。

评论

登录后评论