服务与批处理 把一个 LLM 服务给成千上万的并发用户,远不止是加载模型再跑推理这么简单。本文件涵盖预填充-解码的拆分、连续批处理(continuous batching)、PagedAttention 和 vLLM、调度策略、分离式服务、多模型与 LoRA 服务,以及那些真正重要的指标 单个 LLM 推理请求很简单:喂入 token,生成输出 token。但要把 LLM 以低延迟、高吞吐服务给一万个并发用户,就是个系统工程问题了。最朴素的办法(一次处理一个请求)会浪费 90% 以上的 GPU 算力。聪明的批处理和调度能在不加硬件的前提下把吞吐提升 10-50 倍。
把一个 LLM 服务给成千上万的并发用户,远不止是加载模型再跑推理这么简单。本文件涵盖预填充-解码的拆分、连续批处理(continuous batching)、PagedAttention 和 vLLM、调度策略、分离式服务、多模型与 LoRA 服务,以及那些真正重要的指标
LLM 推理有两个计算特性根本不同的阶段:
预填充(prefill)(prompt 处理):一次性处理所有输入 token。这是一次大矩阵乘:O(\text{prompt\_length} \times d_{\text{model}}^2)。prompt 可以并行处理(所有 token 都已知)。预填充是**计算受限(compute-bound)**的:GPU 的 ALU 是瓶颈。
解码(decode)(token 生成):自回归地一次生成一个 token。每个新 token 都要通过 KV-cache attend 到所有历史 token。解码是**访存带宽受限(memory-bandwidth-bound)**的:GPU 大部分时间在从显存搬模型权重和 KV-cache,而不是在算。每个解码步只产出一个 token,却要搬动整个模型(70B 模型 FP16 约 140 GB)。
几点含义:
| 预填充(prefill) | 解码(decode) | |
|---|---|---|
| 处理的 token | 一次性全部(并行) | 一次一个(串行) |
| 瓶颈 | 计算(FLOPS) | 显存带宽 |
| 算术强度 | 高 | 很低 |
| GPU 利用率 | 高(50-80%) | 无批处理时很低(1-10%) |
| 延迟指标 | 首 token 延迟(TTFT) | 每输出 token 耗时(TPOT) |
最简单的批处理:攒 B 个请求,把它们填充到同样长度,当成一个 batch 处理。
问题 1:请求的 prompt 长度不同,生成的输出 token 数也不同。短请求早早结束,却得等 batch 里最长的那个请求结束,才能开始下一个 batch。GPU 在为唯一剩下的长请求生成时空转。
问题 2:填充(padding)浪费算力。最长 prompt 2000 token、最短 50 token,batch 就得填充到 2000。GPU 要为短请求处理 1950 个 padding token——纯浪费。
连续批处理(continuous batching)(也叫迭代级批处理)以单个解码步为粒度运作(而不是整个请求),从而同时解决上面两个问题。
每个解码步:
batch 大小每一步都在动态变化。GPU 永远不会因等拖后腿的请求而空转,也没有浪费的 padding(每个请求只用自己需要的槽位)。
效果:连续批处理通常比静态批处理提升 2-10 倍吞吐,且不改变模型质量、也不显著增加延迟。
PagedAttention(Kwon 等,2023)把操作系统的虚拟内存概念(第 13 章)用到 KV-cache 上。缓存被切成固定大小的页(pages)(一组 token 位置的块)。页按需分配,在物理 GPU 显存里可以不连续。
好处:
vLLM 就是围绕 PagedAttention 构建的推理引擎。它通过几乎消除 KV-cache 显存浪费,实现了比静态分配服务(如不带分页注意力的 HuggingFace text-generation-inference)高 2-4 倍的吞吐。
当多个请求在排队、而 GPU 只能处理有限 batch 时,**调度(scheduling)**决定服务哪些请求:
先来先服务(FCFS):按到达顺序处理请求。简单但不公平:一个提交 1 万 token 生成的用户会阻塞后面所有人。
最短作业优先(SJF):先处理会最快完成的请求。最小化平均延迟,但惩罚长请求(可能饿死)。实际中输出长度估计未知,所以 SJF 用启发式(prompt 长度、用户历史)。
抢占(preemption):如果高优先级请求到来,暂停一个低优先级的在途请求(把它的 KV-cache 换出到 CPU 显存或 SSD),服务完高优先级请求,再恢复被暂停的。vLLM 支持这个。
基于优先级:给用户或请求类型分配优先级。实时交互查询比批处理任务优先级高。配合抢占,能保证高优先级流量的延迟 SLO。
token 预算:限制活跃 batch 里的总 token 数。防止少数长请求独占 GPU 显存、饿死新请求。
预填充和解码的计算画像恰好相反。在同一张 GPU 上跑两者,意味着 GPU 在计算受限(预填充)和访存带宽受限(解码)之间来回切换,哪种资源都用不满。
**分离式服务(disaggregated serving)**把两者分开:
预填充节点算出初始 KV-cache,发给解码节点(通过 NVLink 或网络)。解码节点用收到的缓存生成 token。
这就是 Mooncake(Moonshot AI)的架构,目前也有不少 LLM 服务团队在探索。好处:每种 GPU 类型都匹配它的工作负载特征,整体利用率更高。
在生产环境中,你常常要同时服务多个模型(不同档位用不同大小、不同任务用不同微调变体)。
模型多路复用(model multiplexing):在同一张 GPU 上加载多个模型,把请求路由到合适的模型。GPU 显存共享:一张 40 GB 的 GPU 可以同时装一个 13B 模型(26 GB)和一个 7B 模型(14 GB)。
LoRA 服务:与其部署各自独立的微调模型,不如部署一个基座模型配多个 LoRA 适配器(LoRA adapters)(第 6 章)。每个适配器增加 <1% 的参数。推理时把请求路由到合适的适配器。
S-LoRA(Sheng 等,2023):从一个基座模型服务上千个 LoRA 适配器。适配器存在 CPU 上,按需分页进 GPU 显存。基座模型的 KV-cache 和权重是共享的;每个请求只有小的 LoRA 矩阵不同。
Punica(Chen 等,2023):用一个自定义 CUDA kernel,在同一 batch 里给不同请求套用不同的 LoRA 矩阵,从而跨不同 LoRA 适配器做批处理。避免了每个请求切换适配器的开销。
很多应用需要 LLM 输出特定格式:合法的 JSON、SQL 查询、某种语言的代码,或符合某个 schema 的响应。**约束生成(constrained generation)**保证输出符合某个文法或 schema。
文法约束解码(grammar-constrained decoding):在每个解码步,屏蔽掉会违反文法的 token。如果当前输出是 {"name": "Alice", "age": 且文法要求下一个是整数,就屏蔽掉除数字外的所有 token。LLM 的概率分布在合法 token 上重新归一化。
Outlines(Willard & Louf,2023):把 JSON schema 或正则表达式编译成有限状态机(FSM)。每个解码步,FSM 决定哪些 token 是合法的后续。非法 token 概率置 0。这保证 100% 的 schema 合规,零重试。
SGLang 原生集成了约束生成:你用 Python 描述输出结构,引擎高效地处理 token 屏蔽和缓存。配合 RadixAttention(前缀缓存),结构化输出能复用缓存的前缀。
为什么重要:没有约束生成,你得自由生成再解析输出,失败就重试。复杂 JSON schema 的重试率常常有 10-30%,浪费算力。约束生成彻底消除重试。
不是每个查询都需要最大的模型。**请求路由(request routing)**根据估计的难度把查询导向不同模型:
级联(cascading):先试小模型。如果小模型的置信度低于阈值(例如 top token 的 softmax 概率 < 0.8),升级到大模型。简单查询(80% 以上的流量)由小模型便宜地服务;只有难查询才用贵的模型。
学习型路由:训练一个轻量分类器(或用小模型的困惑度)预测查询需要哪个模型档位。把"2+2 等于几"路由给 3B 模型,把"解释量子纠缠的数学基础"路由给 70B 模型。
效果:如果 80% 的查询能由成本低 10 倍的模型处理,每查询平均成本就降约 70%。这是多模型部署中投入产出比最高的成本优化之一。
端云混合路由:Cactus(开源项目,仓库位于 github.com/cactus-compute/cactus)在设备层实现请求路由。它通过自定义的 ARM SIMD kernel 在设备端(手机、笔记本、可穿戴)运行小模型,并在本地模型置信度低或查询超出设备能力时自动路由到云端模型。应用对两条路径都用 OpenAI 兼容 API——路由是透明的。这是基础设施级的级联:第一档免费(端侧),第二档要钱(云 API)。对于大多数查询都很简单的应用(助手问答、自动补全、转录),端侧处理能以零边际成本覆盖 70-90% 的流量。
| 指标 | 衡量什么 | 目标(对话) | 目标(批处理) |
|---|---|---|---|
| TTFT | 首 token 延迟 | <1 s | 不那么重要 |
| TPOT | 每输出 token 耗时 | <100 ms | 不那么重要 |
| 吞吐 | 每秒 token 数(总量) | 不那么重要 | 最大化 |
| p99 延迟 | 最差的 1% 请求 | <5 s | <30 s |
| 每 token 成本 | 美元/百万 token | 最小化 | 最小化 |
| SLO 达标率 | 满足延迟目标的请求比例 | >99% | >95% |
TTFT vs TPOT 权衡:激进的批处理提升吞吐(每秒总 token 更多)但会增加 TPOT(每个 token 耗时更长,因为 GPU 要处理更多请求)。调度策略必须在吞吐(收入)和延迟(用户体验)之间平衡。
每 token 成本是生产的终极指标。它综合了硬件成本(GPU 租金)、吞吐(每秒 token)和利用率。一个跑在 50% GPU 利用率的系统,每 token 成本是 100% 利用率系统的 2 倍。这就是为什么批处理、调度和 PagedAttention 这么重要——它们提高利用率。
import random import time def simulate_static_batching(requests, batch_size=8): """按固定 batch 处理请求。等所有请求都结束。""" total_tokens = 0 total_time = 0 for i in range(0, len(requests), batch_size): batch = requests[i:i + batch_size] max_len = max(r['output_len'] for r in batch) # batch 里所有请求都按最长的算时间 batch_time = max_len * 0.01 # 每 token 10ms total_time += batch_time total_tokens += sum(r['output_len'] for r in batch) return total_tokens / total_time # 每秒 token def simulate_continuous_batching(requests, max_batch=8): """用连续批处理。结束的移除,新的加入。""" total_tokens = 0 total_time = 0 active = [] queue = list(requests) while active or queue: # 填满 batch while len(active) < max_batch and queue: active.append({'remaining': queue.pop(0)['output_len']}) if not active: break # 一次解码步:所有活跃请求各生成 1 个 token for req in active: req['remaining'] -= 1 total_tokens += len(active) total_time += 0.01 # 每步 10ms # 移除已完成的请求 active = [r for r in active if r['remaining'] > 0] return total_tokens / total_time # 生成输出长度各异的请求 random.seed(42) requests = [{'output_len': random.randint(10, 500)} for _ in range(100)] static_tps = simulate_static_batching(requests) continuous_tps = simulate_continuous_batching(requests) print(f"Static batching: {static_tps:.0f} tokens/s") print(f"Continuous batching: {continuous_tps:.0f} tokens/s") print(f"Speedup: {continuous_tps / static_tps:.1f}x")
def paged_vs_preallocated(n_requests, max_seq_len, avg_seq_len, page_size, kv_per_token_bytes): """对比显存占用:预分配 vs 分页 KV-cache。""" # 预分配:每个请求都拿到 max_seq_len 个槽位 preallocated_gb = n_requests * max_seq_len * kv_per_token_bytes / 1e9 # 分页:只按实际用量分配(按页粒度) import math avg_pages = math.ceil(avg_seq_len / page_size) paged_gb = n_requests * avg_pages * page_size * kv_per_token_bytes / 1e9 waste_preallocated = (max_seq_len - avg_seq_len) / max_seq_len waste_paged = (avg_pages * page_size - avg_seq_len) / (avg_pages * page_size) print(f"Requests: {n_requests}, Max seq: {max_seq_len}, Avg seq: {avg_seq_len}") print(f" Preallocated: {preallocated_gb:.1f} GB (waste: {waste_preallocated:.0%})") print(f" Paged: {paged_gb:.1f} GB (waste: {waste_paged:.0%})") print(f" Savings: {preallocated_gb - paged_gb:.1f} GB ({preallocated_gb/paged_gb:.1f}x)") print() # Llama-70B:每层每 token 约 1.3 KB,80 层 = 每 token 总计约 100 KB kv_bytes = 100_000 # 场景 1:短请求、最大长度大 paged_vs_preallocated(256, max_seq_len=4096, avg_seq_len=256, page_size=16, kv_per_token_bytes=kv_bytes) # 场景 2:长度不一 paged_vs_preallocated(256, max_seq_len=8192, avg_seq_len=1024, page_size=16, kv_per_token_bytes=kv_bytes) # 场景 3:长上下文 paged_vs_preallocated(64, max_seq_len=131072, avg_seq_len=16000, page_size=16, kv_per_token_bytes=kv_bytes)