Serving⚓︎
Abstract
本文整理 language model 推理服务中的请求进入、准入控制、batching、prefill 与 decode 调度、cache 生命周期、资源隔离,以及这些机制如何共同决定吞吐、延迟与稳定性。这里讨论的不是单次前向如何计算,而是模型如何在真实在线系统中被稳定、高效地提供服务。
Serving 的目标⚓︎
推理服务的目标是在有限 GPU 资源下,同时兼顾单请求延迟、系统整体吞吐、最大并发请求数、显存占用与尾延迟稳定性。
因此,serving 的问题天然是系统级折中,而不是单一算子优化。即使底层模型和 attention kernel 已经足够快,若请求进入、准入、调度、缓存和资源隔离设计不合理,整体服务质量仍然会明显下降。
请求进入系统之后⚓︎
从系统视角看,一个推理请求通常会经历以下链路:进入前端队列、通过准入控制、进入 prefill、转入 decode、持续占用 KV cache、命中停止条件后释放相应资源。
若把这条链路展开,可以得到更细的阶段划分:
- 请求到达前端入口并进入等待队列
- 系统根据当前负载决定是否立刻接纳、排队、降级或拒绝
- 被接纳的请求进入 prefill,处理完整 prompt 并建立初始 KV cache
- 请求转入 decode,逐 token 生成输出
- 请求结束后释放 decode 槽位与相关缓存状态
serving 的很多问题,并不出在某一步单独过慢,而是出在这条链路的各个阶段之间没有形成稳定配合。例如,前端队列过长会推高 TTFT;decode 槽位长期被长请求占用会拖慢新请求进入;cache 回收不及时又会进一步压缩准入能力。
核心指标⚓︎
推理服务最常见的几个指标分别刻画不同阶段的系统行为。
TTFT⚓︎
TTFT,Time To First Token,表示用户发起请求到看到第一个 token 的时间。它主要受以下因素影响:
- 队列等待时间
- 准入控制是否延迟请求进入 prefill
- prefill 的计算时间
- 请求是否需要等待凑批
- 长 prompt 是否抢占了当前资源
因此,TTFT 更接近“请求从进入系统到开始得到响应”的综合结果,而不只是 prefill 本身的耗时。
TPOT⚓︎
TPOT,Time Per Output Token,表示 decode 阶段平均每生成一个 token 所需的时间。它更能反映 decode 的稳定性与带宽利用率。
TPOT 主要受以下因素影响:
- KV cache 读取成本
- decode batch 的大小与稳定性
- 显存带宽是否成为瓶颈
- 调度器是否能稳定复用 decode 槽位
- 是否使用量化、GQA、MQA、投机解码等优化
Throughput⚓︎
throughput 指单位时间内系统能完成多少 token 生成或多少请求处理。它更偏向系统整体资源利用率,而不是单个请求体验。
在离线批处理或高并发 API 场景下,throughput 往往是核心指标;在交互式助手场景下,通常不能只追求 throughput,而忽略 TTFT 与尾延迟。
尾延迟⚓︎
平均延迟往往无法反映最差体验。真实系统更关注 P95、P99 一类尾延迟指标,因为系统中最慢的那部分请求通常来自:
- 超长 prompt
- 异常大的 decode batch
- 某些请求占用了过多 cache
- 调度器长期偏向吞吐而压缩交互请求优先级
- 某些缓存未能及时回收,导致后续请求长期等待
因此,serving 优化不仅要看平均值,还要看延迟分布是否稳定。
准入控制⚓︎
当请求到达速度超过处理能力时,系统不能只回答“是否还有队列空间”,还必须回答“是否值得让这个请求现在进入系统”。从 serving 视角看,这类机制通常统一归入 admission control。
系统在高负载时通常不会把所有请求一视同仁,而是结合队列长度、请求长度、业务优先级、当前 decode 槽位和显存水位决定是否接纳请求。
准入动作⚓︎
一个请求到达后,系统通常有四种基本动作:
- 立即接纳,直接进入当前执行链路
- 暂时排队,等待后续资源释放
- 降级处理,例如限制最大输出长度或改走低优先级池
- 直接拒绝,避免系统继续恶化
这意味着 admission control 不是单纯的过载保护,而是系统维持 TTFT、TPOT 和公平性目标的前置决策层。
准入依据⚓︎
仅看队列长度通常是不够的,因为 serving 的真实瓶颈往往出现在后续阶段,而不是入口本身。系统至少需要关注:
- prefill 是否还有可接受的等待空间
- decode 槽位是否接近饱和
- 显存中还能否容纳更多活跃请求的 KV cache
- 当前是否有长 prompt 或长输出请求正在放大尾延迟
因此,准入控制天然和后续调度耦合。一个表面上“只是排队”的请求,实际上可能对应未来数十秒的 decode 占用与持续 cache 驻留。
长请求限制⚓︎
长 prompt 请求往往在进入 prefill 之前就需要特殊对待。因为它们不仅会带来更高的首 token 计算成本,也会在进入 decode 后占据更长时间的 cache 空间。
因此,很多系统会对超长 prompt 单独限流、分层或降级。这并不是因为系统无法处理长请求,而是因为在混合负载下,长请求过多会显著恶化整体交互体验。
admission control 示例
class AdmissionController:
def __init__(self, max_waiting, max_decode_slots, max_kv_blocks):
self.max_waiting = max_waiting
self.max_decode_slots = max_decode_slots
self.max_kv_blocks = max_kv_blocks
def decide(self, request, scheduler):
if len(scheduler.waiting_prefill) >= self.max_waiting:
return "reject"
if request.prompt_len > scheduler.long_prompt_limit:
return "downgrade"
if scheduler.decode_slots_in_use() >= self.max_decode_slots:
return "queue"
if scheduler.kv_blocks_in_use() + request.estimated_kv_blocks > self.max_kv_blocks:
return "queue"
return "admit"
这个示例强调两个判断:请求是否值得现在进入系统,以及它进入后是否会立即挤压 decode 槽位与 KV cache 余量。真实系统通常还会继续叠加业务优先级、租户预算和预测输出长度等信息。
Batching⚓︎
batching 的做法,是把多个请求打包进同一批前向计算中,以提高硬件利用率。对 GPU 而言,这通常能减少空转,提高总吞吐。
但 batch 变大并不意味着一切都更好,因为系统需要为凑批付出等待成本。请求长度差异较大时,padding、批内不均衡和资源占用不对称也会增加。
静态批处理⚓︎
静态批处理要求一整批请求同时进入、同时推进。它的实现最简单,也容易和常规训练式前向兼容。
但这种方式的问题也很明显:
- 请求必须等待系统凑够 batch
- batch 中最短请求必须等待最长请求
- 不同长度请求之间容易形成 padding 浪费
因此,静态批处理更适合离线场景,不适合高并发、低延迟的交互式服务。
连续批处理⚓︎
连续批处理,又称 continuous batching 或 in-flight batching,允许系统在已有 batch 运行过程中动态加入新请求,并在旧请求结束后立即回收位置。
它的核心收益是:batch 的生命周期不再绑定于最长请求,而是变成 token 级动态调整。这样可以显著减少批内空洞和 padding 浪费。
从执行过程看,连续批处理至少包含两个关键动作:
- 当某些请求结束后,系统立即释放对应槽位
- 当有空闲槽位出现时,调度器尽快把等待中的合适请求送入下一轮执行
这意味着“谁什么时候进入 batch、谁什么时候退出 batch”不再是批次边界上的一次性决定,而是每一轮 decode 之后都可能发生变化。
因此,连续批处理的价值不只是提升平均吞吐,更重要的是提高槽位复用速度,并减少长短请求混合时的资源空洞。它和 KV cache 管理天然耦合,因为请求能否灵活进出 batch,取决于系统能否同步完成 cache 分配、状态迁移与回收。
Prefill 与 Decode 调度⚓︎
从 serving 角度看,prefill 与 decode 不只是模型内部两个阶段,也是两类不同的服务负载。
Prefill 负载⚓︎
prefill 需要处理完整 prompt。若 prompt 很长,它通常会带来:
- 较高的单次算力消耗
- 更长的首 token 等待
- 更明显的批内长度不均衡
因此,长 prompt 请求经常成为 TTFT 抖动的重要来源。
Decode 负载⚓︎
decode 是逐 token 的持续生成过程。单步计算看似较轻,但它需要高频调度、持续读取权重与 KV cache,因此非常容易暴露显存带宽和调度器开销问题。
在真实系统中,decode 往往不是算不动,而是带宽、调度和 cache 管理难以始终保持高效。
阶段差异⚓︎
如果把 prefill 和 decode 完全混合调度,prefill 的高算力需求会干扰 decode 的稳定节奏,decode 的高频小步调度也会让 prefill 更难形成高效大 batch。
因此,prefill 与 decode 的差异不只是模型内部的计算阶段不同,更意味着两者对系统提出了不同资源需求:
- prefill 更强调一次性算力利用
- decode 更强调稳定的带宽与调度节奏
- 二者对 batch 组织和资源分配的理想形态并不一致
调度策略⚓︎
推理服务的核心并不只是“批处理”,而是如何决定谁先执行、谁延后执行、哪个请求占用多少资源。
FIFO⚓︎
最简单的方式是 FIFO,即按到达顺序处理请求。它实现简单、公平性强,但在负载复杂时会出现明显问题:一个超长 prompt 或超长生成请求可能拖慢后续大量短请求。
长短请求分层⚓︎
一些系统会把请求按 prompt 长度、预估输出长度或业务优先级分层。这样做的目标,是避免长请求完全主导调度器。
典型收益包括:
- 降低短请求的 TTFT
- 避免超长请求拖垮交互式服务体验
- 提高同类请求凑批的相似度
优先级调度⚓︎
若系统同时服务不同业务,例如在线聊天、批量生成和内部工具请求,通常还需要优先级调度。高优先级请求可能被优先送入 prefill 或 decode,而低优先级请求则在系统空闲时批量处理。
这类机制的重点不是绝对公平,而是在业务目标上把资源分配给更重要的流量。
Cache 生命周期⚓︎
在 serving 系统中,cache 不是“分配完就结束”的静态资源,而是伴随请求生命周期不断经历创建、共享、追加、回收、驱逐与重路由的动态状态。
因此,系统必须管理的不只是 cache 能否放下,还包括:
- 何时回收空闲 blocks
- 何时主动驱逐低价值缓存
- 新请求是否可以路由到已有前缀
- 不同 worker 上的缓存是否仍然值得保留
Block Recycling⚓︎
当请求结束,或者某些尾部状态已经不再需要时,系统必须把对应 blocks 返回到 free pool。若回收速度不够快,即使总显存看起来仍然充足,调度器也可能因为拿不到可复用块而无法及时接纳新请求。
这意味着 block recycling 不是简单的释放动作,而是直接影响准入能力和槽位复用速度的 serving 机制。回收过粗会让空闲资源滞后暴露,回收过慢则会让 TTFT 和排队时间被动上升。
工程上通常需要持续处理几个问题:
- 请求何时被判定为真正完成
- 引用关系何时可以安全释放
- 部分复用后是否会形成碎片或零散空洞
Cache Eviction⚓︎
当显存压力升高时,系统未必能一直等到缓存自然失效或请求自然结束。此时,就可能需要主动 reclaim 某些缓存状态,这类机制通常可以归入 cache eviction。
被驱逐的对象通常包括:
- 长时间不活跃的会话缓存
- 低优先级请求占用的缓存
- 命中价值较低但仍驻留的共享前缀缓存
eviction 的本质,是用未来可能的复用收益换取当前的准入能力和系统稳定性。因此,它本身就是一种系统级权衡,而不是单纯的内存清理。
需要区分的是:
- recycling 是请求自然结束后的正常回收
- eviction 是系统在压力下提前回收仍可能被再次使用的状态
这类策略会间接影响 TTFT 和尾延迟。因为一旦驱逐过于激进,后续相似请求就失去复用路径,需要重新 prefill;但如果驱逐过于保守,又会挤压新请求进入系统的空间。
Prefix Cache Routing⚓︎
当系统支持前缀共享后,一个新请求进入系统时,调度器不仅要决定“是否接纳”,还要决定“应不应该把它路由到一个已有的共享前缀上”。这就是 prefix cache routing。
它不是单纯的 cache 命中判断,而是一种 serving 路由决策,因为它会改变请求接下来经过的 worker、prefill 负载以及后续 decode 队列的形态。
prefix routing 通常需要知道:
- 当前有哪些前缀仍然驻留
- 哪个 worker 或资源池持有这些前缀
- 这些前缀是否仍然足够热门,值得继续复用
因此,prefix reuse 改变的不只是内存占用,还会改变请求路由和调度器的选择空间。若系统把一个请求路由到已有共享前缀上,它可以减少重复 prefill;若命中成本过高、前缀过冷或路由代价过大,系统也可能选择直接重新计算。
资源隔离⚓︎
随着系统规模变大,单一 GPU 池往往难以同时满足低延迟交互和高吞吐离线任务。因此,资源隔离会成为 serving 架构的重要部分。
PD 分离⚓︎
一种典型做法是将 prefill 与 decode 分离到不同资源池。prefill 池更强调算力利用,decode 池更强调显存带宽和稳定调度。
这样做的收益包括:
- 降低长 prompt 对 decode 的干扰
- 让 decode 的 TPOT 更稳定
- 让 prefill 更容易形成高效大 batch
多租户隔离⚓︎
若系统服务多个业务方,还可能进一步做多租户隔离,例如给不同业务分配独立 GPU 池、独立队列或独立优先级预算。这样可以避免一个业务的流量高峰拖垮整个推理平台。
资源隔离和 cache 生命周期并不是独立问题。一个资源池是否能稳定服务某类流量,不仅取决于算力和带宽,也取决于该池内 cache 是否能够及时回收、是否容易被长会话长期占满,以及前缀共享是否被路由到合适位置。
与 KV Cache 的关系⚓︎
serving 和 KV cache 不是两个独立主题。实际上,serving 的很多调度与吞吐问题,本质上都由 KV cache 约束。
并发上限⚓︎
一个请求一旦进入 decode,就会持续占用其对应的 KV cache 空间。并发用户数并不只由模型权重是否放得下决定,还取决于显存里还能容纳多少活跃请求的缓存。
调度自由度⚓︎
连续批处理之所以能成立,是因为系统能够在请求级别管理和回收 KV cache。若缓存分配方式僵硬、回收成本高、碎片严重,调度器的灵活性也会被显著限制。
尾延迟来源⚓︎
一些尾延迟并不是纯粹由计算慢造成,而是因为:
- 某些请求占用过大的 cache
- cache 碎片导致无法快速接纳新请求
- 长上下文请求使 decode batch 不稳定
因此,serving 层的稳定性在很大程度上依赖于底层 KV cache 的组织方式,但二者关注的问题并不相同:KVCache.md 更关注缓存如何组织与复用,Serving.md 更关注这些缓存约束如何反过来塑造调度、准入和系统行为。
常见瓶颈⚓︎
推理服务中的主要瓶颈通常来自以下几类。
KV cache 占用过高⚓︎
若 cache 占用过高,并发请求数就会被直接限制。系统看起来可能还有算力,但新请求无法进入,因为显存已经被活跃会话的历史上下文占满。
请求长度差异过大⚓︎
当 prompt 长度或输出长度差异过大时,batch 内部会变得高度不均衡,调度器更难维持稳定吞吐。短请求容易被长请求拖住,长请求又可能长期占用 decode 槽位。
首 token 延迟过高⚓︎
TTFT 过高通常来自排队与 prefill 叠加,而不是单纯模型算得慢。尤其是长 prompt、多租户混跑和静态凑批场景下,首 token 延迟往往是最早暴露问题的指标。
Decode 阶段利用率不稳定⚓︎
decode 每轮只生成少量 token,调度频繁,且对显存带宽高度敏感。若 batch 管理不稳定、KV cache 读取路径不高效或请求进出过于频繁,GPU 很难始终保持高效运行。
网络与跨节点传输⚓︎
在 PD 分离、多机推理或张量并行部署中,网络传输也会成为瓶颈。例如:
- KV cache 从 prefill 节点传输到 decode 节点
- 多个 GPU 之间做张量并行同步
- 前端调度器与后端 worker 的状态同步
因此,serving 不只是单机 GPU 调优,还需要考虑系统级数据路径。
常见优化方向⚓︎
现代推理服务通常会同时使用多种优化手段,而不是只依赖单一策略。
批处理优化⚓︎
- 使用连续批处理替代静态批处理
- 尽量让同类长度请求凑批
- 按 TTFT 或优先级控制 prefill 队列
缓存优化⚓︎
- 使用分页 KV cache 降低碎片
- 使用前缀共享与 prefix cache routing 减少重复 prefill
- 通过 block recycling 提高空闲缓存回收速度
- 在高压下通过 eviction 策略换取准入能力
- 使用 KV cache 量化降低显存和带宽压力
计算优化⚓︎
- 使用模型量化降低权重读取成本
- 使用投机解码减少 decode 串行步数
- 使用 GQA/MQA 降低 decode 阶段 KV 读取规模
架构优化⚓︎
- 对 prefill 与 decode 做资源分离
- 在多业务场景中加入优先级和租户隔离
- 在高负载时启用背压与降级策略
这些优化的共同目标,不是让某一次请求跑得最快,而是让系统在真实混合负载下仍能保持稳定吞吐和可接受延迟。
简化调度示例⚓︎
下面的代码不是生产级 serving 框架,而是为了说明一个最小调度器如何同时处理 waiting queue、running batch 与请求完成后的槽位回收。
连续批处理调度示例
from collections import deque
class Request:
def __init__(self, req_id, prompt_tokens, max_new_tokens):
self.req_id = req_id
self.prompt_tokens = prompt_tokens
self.output_tokens = []
self.max_new_tokens = max_new_tokens
self.finished = False
def append_token(self, token_id, eos_token_id):
self.output_tokens.append(token_id)
if token_id == eos_token_id or len(self.output_tokens) >= self.max_new_tokens:
self.finished = True
class ServingScheduler:
def __init__(self, max_batch_size):
self.max_batch_size = max_batch_size
self.waiting_prefill = deque()
self.running_decode = []
def submit(self, request):
self.waiting_prefill.append(request)
def run_prefill(self, model):
admitted = []
while len(admitted) < self.max_batch_size and self.waiting_prefill:
admitted.append(self.waiting_prefill.popleft())
if not admitted:
return
model.prefill(admitted)
self.running_decode.extend(admitted)
def run_decode_step(self, model, eos_token_id):
if not self.running_decode:
return
next_tokens = model.decode(self.running_decode)
next_running = []
for req, token_id in zip(self.running_decode, next_tokens):
req.append_token(token_id, eos_token_id)
if not req.finished:
next_running.append(req)
self.running_decode = next_running
这个示例刻画了三个最基本的系统动作:
- 新请求先进入等待队列
- prefill 结束后请求进入 decode 集合
- decode 每轮回收已完成请求,并释放出新的 batch 槽位
cache 生命周期钩子示例
class CacheAwareScheduler(ServingScheduler):
def __init__(self, max_batch_size, block_pool, prefix_router):
super().__init__(max_batch_size)
self.block_pool = block_pool
self.prefix_router = prefix_router
def submit(self, request):
route = self.prefix_router.lookup(request.prompt_tokens)
if route is not None:
request.attach_shared_prefix(route.worker_id, route.block_ids)
self.waiting_prefill.append(request)
def recycle_finished(self):
still_running = []
for req in self.running_decode:
if req.finished:
self.block_pool.release(req.private_block_ids)
self.prefix_router.dec_ref(req.shared_prefix_id)
else:
still_running.append(req)
self.running_decode = still_running
def maybe_evict(self):
if not self.block_pool.near_capacity():
return
victim = self.prefix_router.pick_evictable_prefix()
if victim is not None:
self.block_pool.release(victim.block_ids)
self.prefix_router.remove(victim.prefix_id)
这个示例额外补上了三类控制流:新请求进入时先做 prefix routing,请求结束时回收私有 block 与共享前缀引用,显存逼近上限时主动驱逐低价值前缀。这样才能把 admission、continuous batching 与 cache lifecycle 真正连接成一条完整链路。
真实推理引擎会在此基础上继续加入优先级调度、KV cache 分配、PD 分离、超时控制、限流和多机路由等机制。