AI 工程基础体系 · 第 27/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。
AI 模型服务:批处理、连续批次、KV Cache、量化和 GPU 容量
先建立一个统一模型:一次请求如何消耗 GPU
生成式 AI 模型服务通常同时处理三类资源:
- 模型权重:加载一次后驻留在 GPU 或分布在多个设备上。
- 中间激活:执行某一层计算时产生,通常随计算阶段变化。
- 请求状态:尤其是 Transformer 的 KV Cache,它随并发请求数、输入长度和已生成长度增长。
对于一次自回归生成请求,输入通常称为 prompt,模型先执行一次 prefill,处理完整输入;之后反复执行 decode,每次生成一个或少量新 token。
设:
- :输入 prompt 的 token 数;
- :最终生成 token 数;
- :某一时刻的上下文长度;
- :Transformer 层数;
- :隐藏维度;
- :KV head 数;
- :每个 attention head 的维度,其中 是 query head 数;
- :KV Cache 每个元素占用的字节数。
在使用 GQA 或 MQA 时, 小于 ,KV Cache 按 计算,而不是按 query head 数计算。
对单个请求,KV Cache 的近似显存占用为:
其中前面的 2 分别代表 Key 和 Value。这个公式忽略了页表、对齐、临时缓冲区和框架元数据,但足以解释容量变化。
prefill 的注意力计算需要同时处理大量 prompt token,通常具有较高的计算并行度;decode 每次只新增一个 token,却必须读取该请求历史上下文的 KV,因此经常更受显存带宽和 KV Cache 读取影响。两者都叫“推理”,但资源行为并不相同。
flowchart LR
A[请求进入] --> B[排队与准入]
B --> C[Prefill:处理完整 prompt]
C --> D[写入 KV Cache]
D --> E[Decode:逐 token 生成]
E --> F{是否结束}
F -- 否 --> D
F -- 是 --> G[释放或缓存 KV]
关键路径是:prompt 只在 prefill 阶段批量计算一次;之后每个 decode step 都需要读取已有 KV,并把当前 token 的 K/V 追加到缓存。因此,增大上下文长度会同时增加 KV 容量和 decode 阶段的读取成本。
批处理到底批什么
离线批处理:先收集,再统一执行
最简单的批处理是把多个样本组成一个 batch:
outputs = model(input_ids_batch)
如果输入长度不同,就需要 padding 到同一个长度。设一个 batch 中的序列长度为:
若直接 padding,实际计算长度接近:
但真实 token 数只有:
这部分多出来的计算称为 padding 浪费。对于训练,padding 通常可以接受或通过 bucketing 减少;对于在线生成服务,padding 还会造成不必要的显存和延迟。
离线批处理适合:
- 文档嵌入;
- 批量分类;
- 离线摘要;
- 有固定输入集合和宽松延迟要求的任务。
它的主要优点是 GPU 利用率高、调度简单;缺点是必须等待一批请求形成,且短请求可能被长请求拖慢。
动态批处理:在一个时间窗口内合并请求
在线服务通常不会无限等待,而是在很短的 batching window 内收集请求。调度器可能执行以下流程:
- 请求 A 到达,进入队列;
- 等待最多几毫秒;
- 请求 B、C 在窗口内到达;
- 将 A、B、C 组成一个 batch;
- 对 batch 执行一次模型计算;
- 请求结果分别返回。
动态批处理改善了 GPU 利用率,但引入了排队延迟。对用户可见的首 token 延迟可以近似分解为:
其中:
- :等待进入 batch 的时间;
- :处理 prompt 的时间;
- :调度、拷贝和框架开销。
batch window 增大,通常提高合并率,却增加 。因此“batch 越大越快”只对吞吐量成立,不一定对单请求延迟成立。
连续批次:在每个生成迭代重新调度
连续批次(continuous batching,也常被称为 iteration-level batching)不是把请求从开始到结束固定放在同一个 batch 中,而是在每一个 decode 迭代重新决定 batch 内容。
假设三个请求的生成长度分别为:
- A:生成 2 个 token;
- B:生成 5 个 token;
- C:生成 3 个 token。
固定批处理可能必须等待最长的 B:
| 迭代 | 固定 batch 中的请求 |
|---|---|
| 1 | A、B、C |
| 2 | A、B、C |
| 3 | A、B、C,但 A、C 可能已经完成 |
| 4 | B |
| 5 | B |
如果 A 完成后仍占据 batch 槽位,GPU 会做无效计算,或者需要 padding。连续批次会在每轮移除已完成请求,并加入新请求:
| 迭代 | 活跃请求 |
|---|---|
| 1 | A、B、C |
| 2 | A、B、C |
| 3 | B、D |
| 4 | B、D、E |
| 5 | B、E |
这里的“连续”指请求可以在迭代边界加入或退出,而不是一个 batch 必须从头到尾固定。
连续批次的调度状态至少包括:
- 请求 ID;
- 输入 token 和已生成 token;
- 当前长度 ;
- KV Cache 的物理位置;
- 停止条件;
- 优先级或 deadline;
- 是否处于 prefill、decode、暂停或完成状态。
一个实际调度器需要在每一轮同时考虑:
- 哪些请求已经完成;
- 哪些请求可以继续 decode;
- 新请求的 prompt 是否要执行 prefill;
- 剩余 GPU 显存能容纳多少新 KV;
- prefill 是否会阻塞已有请求的 decode;
- 是否需要限制单轮新增 token 数。
如果一次性对超长 prompt 做完整 prefill,它可能占满计算资源,使已有请求长时间拿不到下一个 token。这就是在线服务中常见的 prefill 阻塞 decode。一些推理引擎会使用 chunked prefill,把长 prompt 分成多个块,在 prefill 和 decode 之间交错调度。
连续批次不等于动态 padding
动态批处理主要解决“什么时候把请求合并”;连续批次还要解决“请求在不同生成长度下如何逐轮加入和退出”。
此外,生成请求每一轮的有效序列长度不同,普通二维张量通常需要 padding。推理引擎会使用长度元数据、压紧后的 token 表示或分页式 KV 管理来减少无效计算。因而:
- 动态批处理可能仍使用固定 batch;
- 连续批次可能仍有少量 padding;
- 是否高效取决于调度器、attention kernel 和 KV 内存布局。
Prefill 和 Decode 的计算差异
Transformer 的 self-attention 对长度为 的序列,注意力矩阵的理论复杂度约为:
但在 prefill 中,所有 prompt token 可以并行计算;在 decode 中,每次只增加一个 token。对于第 个生成 token,模型需要计算当前 token 的 Query,并与长度约为 的历史 Key 做注意力:
因此 decode 每一步新增的注意力工作大致随历史长度线性增长,而不是重新计算整个 prompt 的 Query、Key、Value。
KV Cache 的核心作用是保存历史 token 的 Key 和 Value,使后续 decode 不必重新计算它们。如果没有 KV Cache,每生成一个 token,都需要重新对完整上下文执行大量层计算;有 KV Cache 后,历史 K/V 被复用,只计算新 token 对应的部分。
这也解释了一个容易混淆的事实:
KV Cache 减少重复计算,但不免费。它把部分计算成本转换成了显存容量和显存带宽成本。
在短 prompt、小并发下,计算可能是瓶颈;在长上下文、高并发 decode 下,KV Cache 读取和 GPU 显存容量可能成为瓶颈。
KV Cache 的结构与生命周期
每层保存什么
对每一层 attention,模型会产生:
- :Key;
- :Value。
典型逻辑形状可以表示为:
[batch, kv_heads, sequence_length, head_dim]
不同框架可能采用:
[sequence_length, batch, kv_heads, head_dim]
或把多个请求的 token 组织成压紧的连续缓冲区,但语义仍然是:每个请求、每一层、每个历史位置都有一组 K 和一组 V。
请求开始时,KV Cache 通常为空。prefill 完成后,它包含整个 prompt 的 K/V;每次 decode 后,追加新 token 的 K/V;请求结束、取消或超时时,相关空间被释放或归还缓存池。
用一个完整算例计算 KV Cache
假设模型有:
- 层;
- 个 KV head;
- ;
- KV 使用 FP16,每个元素 字节;
- 上下文长度 。
则单 token 的 KV 占用为:
即:
- 128 KiB/token;
- 4096 token 约为 512 MiB;
- 8 个同时占满 4096 token 的请求约为 4 GiB。
如果错误地按 32 个 query head 计算,会得到 4 倍结果。对于 GQA 模型,KV Cache 使用的是 KV head 数,不是 query head 数。
如果该模型没有 GQA,而是 ,则单请求占用变为:
这说明架构中的 GQA/MQA 不只是影响计算,也直接影响长上下文并发容量。
Prefix Cache 不是普通 KV Cache
如果多个请求具有完全相同的前缀,例如所有请求都包含相同的系统提示词,服务可以复用该前缀的 KV。这通常称为 prefix caching 或 prompt caching。
只有满足以下条件时,复用才在语义上成立:
- token 序列完全相同,而不是“文本看起来相似”;
- 使用同一个模型权重和 tokenizer;
- 位置编码、RoPE 配置、attention 配置相同;
- 不存在依赖请求状态的特殊处理。
共享 KV 时,常见做法是引用计数或 copy-on-write。多个请求可以只读共享相同前缀;某个请求继续生成时,新 token 写入自己的后续区域。
prefix cache 的命中可以降低 TTFT,但不会自动消除 decode 阶段的 KV 读取,也不应把“缓存命中”误认为“模型推理完全没有成本”。
连续批次中的 KV Cache 管理
为什么简单的连续大数组会浪费显存
假设最大上下文长度配置为 8192,服务同时运行 16 个请求。如果直接为每个请求预留完整空间,那么即使多数请求只使用 500 token,也会按照 8192 token 分配,造成严重浪费。
另一种问题是显存碎片:请求以不同长度结束,释放出的区域可能无法拼成足够大的连续块。此时 nvidia-smi 看起来还有空闲显存,但一次大分配仍然失败。
分页式 KV 管理
分页式 KV 管理将 KV Cache 切成固定大小的 block,例如每个 block 保存若干 token。调度器维护:
逻辑 token 区间 -> 物理 KV block
请求增长时,只申请足够的新 block;请求结束时,block 返回空闲池。这样可以降低内部碎片,并使请求不必占用连续物理显存。
这类机制是推理引擎常见实现,vLLM 的 PagedAttention 属于这一方向。需要区分:
- 逻辑连续:一个请求的 token 顺序连续;
- 物理连续:GPU 内存地址连续。
分页式管理只要求逻辑映射正确,不要求物理地址连续。attention kernel 需要根据 block table 找到对应的 K/V。
分页不会改变单 token 的理论 KV 字节数,也不会让 KV Cache“消失”;它主要提高空间利用率和调度灵活性。
一个请求的状态转换
stateDiagram-v2
[*] --> Queued
Queued --> Prefilling: 获得调度额度
Prefilling --> Decoding: prompt 完成且 KV 已写入
Decoding --> Decoding: 生成普通 token
Decoding --> Finished: EOS/长度/停止串
Decoding --> Cancelled: 客户端取消或超时
Queued --> Rejected: 容量或策略拒绝
Prefilling --> Failed: OOM/Kernel/输入错误
Decoding --> Failed: OOM/设备错误
Finished --> [*]
Cancelled --> [*]
Rejected --> [*]
Failed --> [*]
每个终止路径都必须释放或回收 KV block。若取消请求只停止 HTTP 流而没有释放模型侧状态,服务会出现“请求数下降但显存不下降”的泄漏表现。
GPU 容量:不能只看模型参数量
权重容量
最粗略的权重大小估算是:
其中:
- :参数数量;
- :每个权重的字节数。
例如 7B 参数模型:
- FP16:约 GB;
- INT8:约 7 GB;
- 4-bit:约 3.5 GB。
这里的 GB 是十进制近似;GPU 工具常以 GiB 展示。实际占用还包括:
- 量化 scale 和 zero-point;
- 分组量化元数据;
- 对齐;
- embedding、lm_head 等未量化或特殊处理的张量;
- 框架 workspace;
- CUDA allocator 保留块;
- 多 GPU 通信缓冲区。
因此不能把“4 bit”直接等价为“FP16 显存的四分之一”。
激活和临时工作区
训练时,激活、梯度和优化器状态往往远大于推理时的临时激活。推理通常可以通过 inference_mode 或类似机制减少 autograd 状态,但仍需要:
- attention 和 GEMM 的 workspace;
- logits;
- sampling 临时张量;
- 通信缓冲;
- kernel 特定的临时内存。
PyTorch 中,torch.no_grad() 和 torch.inference_mode() 可避免建立训练所需的 autograd 图;它们不等于“所有中间张量都不占内存”,也不等于自动启用推理引擎的 KV 管理。相关语义应以当前 PyTorch 版本文档为准。
KV Cache 容量
总显存可以粗略写为:
可用于 KV 的容量是:
最大并发不是简单的:
而应按每个请求的实际 token 数计算:
其中 是第 个请求当前已占用的上下文长度。
一个容量估算脚本
下面的脚本不加载模型,只计算理论 KV Cache 和粗略容量,适合验证模型配置是否算错:
# kv_capacity.py
from dataclasses import dataclass
@dataclass
class KVConfig:
layers: int
kv_heads: int
head_dim: int
kv_bytes: int # FP16/BF16=2, FP8/INT8通常按1估算
gpu_gib: float
weights_gib: float
reserved_gib: float
def kv_bytes_per_token(cfg: KVConfig) -> int:
# 2: K 和 V
return 2 * cfg.layers * cfg.kv_heads * cfg.head_dim * cfg.kv_bytes
def gib(n_bytes: float) -> float:
return n_bytes / (1024 ** 3)
def main():
cfg = KVConfig(
layers=32,
kv_heads=8,
head_dim=128,
kv_bytes=2,
gpu_gib=24,
weights_gib=13.04,
reserved_gib=2.0,
)
per_token = kv_bytes_per_token(cfg)
available = cfg.gpu_gib - cfg.weights_gib - cfg.reserved_gib
max_tokens = int(available * (1024 ** 3) // per_token)
context = 4096
per_request = per_token * context
max_requests = int(available * (1024 ** 3) // per_request)
print(f"KV bytes/token: {per_token:,}")
print(f"KV KiB/token: {per_token / 1024:.1f}")
print(f"KV per request at {context} tokens: {gib(per_request):.3f} GiB")
print(f"rough available KV memory: {available:.2f} GiB")
print(f"rough total KV tokens: {max_tokens:,}")
print(f"rough requests at {context} tokens: {max_requests}")
if __name__ == "__main__":
main()
在给定参数下,理论输出接近:
KV bytes/token: 131,072
KV KiB/token: 128.0
KV per request at 4096 tokens: 0.500 GiB
rough available KV memory: 8.96 GiB
rough total KV tokens: 73,400
rough requests at 4096 tokens: 17
这个结果不是可直接用于生产的并发上限。脚本把 24 GiB GPU、13.04 GiB 权重和 2 GiB 预留空间作为输入,但没有精确计算 workspace、碎片、临时激活和框架实现开销。生产服务应进一步留出安全余量,并通过压测确认 OOM 前的稳定水位。
量化:减少什么,不能减少什么
量化是用更低位宽的数值表示权重、激活或缓存中的张量。它不是单一技术,而是至少包含三个不同对象:
- 权重量化:减少模型权重容量;
- 激活量化:降低矩阵计算中的激活带宽或计算成本;
- KV Cache 量化:降低每个请求的上下文状态容量。
三者的误差来源和工程收益不同,不能统称为“模型变成 4 bit”。
对称和非对称量化
以一组浮点数 为例,最简单的对称量化可写为:
反量化为:
其中 是 scale, 是低位整数。若要限制在 到 :
非对称量化还会引入 zero-point :
实际量化通常按 tensor、channel 或 group 设置 scale。分组越小,通常越能适应局部数值范围,但 scale 元数据和 kernel 复杂度也会上升。
量化误差可以直观表示为:
误差并不只由 bit 数决定,还受到以下因素影响:
- 权重分布是否存在离群值;
- scale 的粒度;
- 激活是否动态变化;
- 模型层和任务;
- 校准数据是否代表真实输入;
- 推理 kernel 是否原生支持该格式。
权重量化和运行时 dtype
常见组合包括:
- W16A16:权重和激活都用 FP16/BF16;
- W8A16:权重低比特存储,计算时可能反量化到更高精度;
- W4A16:常见的权重低比特部署形式;
- W8A8:权重和激活都以 8 bit 路径计算。
W4A16 的主要直接收益是降低权重驻留容量和权重读取带宽,但不意味着所有算子都以 4 bit 原生执行。某些实现会在 kernel 内部进行反量化;如果 GPU、算子或 batch 形态不适合,可能出现:
- 显存下降但延迟没有下降;
- 单请求更快,但大 batch 吞吐不变;
- 某些层仍使用 FP16;
- 量化 kernel 的启动和转换开销抵消收益。
因此选择量化格式时,必须同时验证:
- 权重是否能装入目标 GPU;
- 推理引擎是否有对应 kernel;
- prefill 和 decode 是否都受益;
- 质量指标是否满足要求;
- 量化后的服务是否仍支持目标上下文长度和并发。
KV Cache 量化是另一条质量边界
若 KV 从 FP16 改为 8 bit,理论存储字节数大致减半;改为 4 bit,理论上可进一步下降。但 KV 是注意力直接读取的历史状态,量化误差会随着上下文使用而影响后续 token。
权重 4 bit 不代表 KV 也自动是 4 bit。常见部署可能是:
权重:4 bit
激活:FP16/BF16
KV Cache:FP16/BF16
也可能是:
权重:4 bit
激活:FP16/BF16
KV Cache:FP8/INT8
这两种方案的容量、质量和 kernel 要求不同。KV 量化尤其需要用真实任务评测长上下文、检索内容位置、代码生成和多轮对话,而不能只观察短文本困惑度。
量化的完整验证方法
量化部署至少应比较四组结果:
| 维度 | 原始模型 | 量化模型 |
|---|---|---|
| 任务质量 | 基线 | 是否下降 |
| TTFT | 基线 | prefill 是否改善 |
| decode 速度 | 基线 | 每秒 token 是否改善 |
| GPU 容量 | 基线 | 权重和 KV 各减少多少 |
如果只比较模型文件大小,无法证明服务性能改善;如果只比较平均输出速度,也可能掩盖长上下文质量下降和尾延迟恶化。
批处理、KV Cache 和量化之间的耦合
batch 增大时,KV 线性增长
若每个请求上下文长度相同,KV Cache 总量近似为:
其中 是活跃请求数。对于长度不等的请求,应使用各请求长度求和,而不是简单用最大长度乘 batch。
因此,增大 batch 可能:
- 提高矩阵计算利用率;
- 提高吞吐;
- 增加 KV 占用;
- 增加调度和排队延迟;
- 增加 OOM 风险。
权重量化可能释放空间给 KV
假设 24 GiB GPU:
- FP16 权重约 13.04 GiB;
- 4 bit 权重约 3.5 GiB 加量化元数据;
- 其余预留 2 GiB。
权重量化可能释放约 9 GiB 空间。若每个请求的 KV 是 0.5 GiB,理论上可以多容纳约 18 个满长度请求。但这不表示吞吐必然提高 18 倍,因为 decode 还受显存带宽、kernel 效率和调度开销限制。
长 prompt 不一定适合无限合并
Prefill 可以利用大矩阵并行,但多个超长 prompt 同时进入会造成:
- 短请求 TTFT 增大;
- 临时激活峰值上升;
- KV 分配瞬间增加;
- decode 请求被推迟。
因此连续批次通常需要同时限制:
- 活跃请求数;
- 活跃 KV token 总数;
- 单次 prefill token 数;
- 单轮 decode token 数;
- 单租户或单优先级配额。
调度策略:吞吐、TTFT 和尾延迟如何冲突
生成服务至少要区分三个指标:
- 吞吐量:单位时间生成或处理的 token 数;
- TTFT:从请求到达到返回首个 token 的时间;
- ITL(inter-token latency):相邻输出 token 之间的延迟。
一个策略可能提高吞吐,却恶化 TTFT。例如把很多新请求合并到一个巨大 prefill batch 中,GPU 利用率很高,但已有请求无法及时 decode。
常见调度思路包括:
FIFO
先到先服务,容易实现,也容易解释。缺点是一个超长 prompt 可能阻塞大量短请求,形成 head-of-line blocking。
长度分桶
按 prompt 长度或预计生成长度分桶,减少 padding 和长度差异。缺点是需要等待同桶请求,并且长度预测可能不准。
Decode 优先
优先保证已有请求继续生成,以降低 ITL。缺点是新请求 TTFT 可能变高。
Prefill 与 decode 配额
每轮给 prefill 和 decode 分别设置 token 预算。例如一轮最多处理 2048 个新 prompt token,同时为现有 decode 请求各生成一个 token。这样可以避免长 prompt 无限占用调度器。
公平性和限流
连续批次会让“请求数”不再是唯一负载指标。一个 100 token 请求和一个 100,000 token 请求都算一个请求,但它们的 KV 和计算成本完全不同。配额应考虑:
- prompt token 数;
- 最大上下文;
- 预计输出 token;
- 当前 KV token;
- 租户预算;
- 优先级和 deadline。
运行时内存与 PyTorch 诊断
PyTorch 的 CUDA allocator 可能保留已经向 CUDA 申请的显存,因此 nvidia-smi、torch.cuda.memory_allocated() 和 torch.cuda.memory_reserved() 的含义不同:
memory_allocated():当前 Tensor 实际占用;memory_reserved():PyTorch allocator 保留的显存;nvidia-smi:进程从驱动侧占用的显存,通常还包括上下文和其他运行时开销。
可以用以下代码观察一轮推理前后的差异:
import torch
def report(label):
torch.cuda.synchronize()
print(
label,
f"allocated={torch.cuda.memory_allocated() / 2**30:.2f} GiB, "
f"reserved={torch.cuda.memory_reserved() / 2**30:.2f} GiB, "
f"max_allocated={torch.cuda.max_memory_allocated() / 2**30:.2f} GiB"
)
assert torch.cuda.is_available(), "需要 CUDA GPU"
device = torch.device("cuda")
torch.cuda.reset_peak_memory_stats()
report("before")
# 使用小张量模拟一次显存分配;真实服务中这里应替换为模型推理
x = torch.empty((4096, 4096), dtype=torch.float16, device=device)
report("after allocation")
del x
torch.cuda.empty_cache()
report("after release")
这里的 empty_cache() 只会把未被活动 Tensor 使用的缓存块归还给 CUDA allocator/驱动可再利用区域,不能释放仍被引用的 Tensor,也不能修复 KV 生命周期泄漏。生产诊断应同时记录:
- 活跃请求数;
- 活跃 KV token 数;
- 每个请求的上下文长度;
- prefill 和 decode 阶段;
- allocated/reserved/peak memory;
- OOM 发生前的 batch 和 token 预算。
典型失败表现与诊断路径
启动时 OOM
表现:模型尚未接收请求,加载权重时就失败。
可能原因:
- 权重 dtype 不符合预期;
- 量化模型加载时仍保留了高精度副本;
- 多 GPU 分片不均;
- CUDA context 或其他进程已占用显存。
诊断:
- 检查模型文件格式和实际加载 dtype;
- 使用
nvidia-smi查看其他进程; - 记录每个 rank 的显存;
- 在加载阶段分段报告 allocated/reserved;
- 验证是否发生了 CPU 到 GPU 的临时双份拷贝。
首个请求成功,第二批请求 OOM
表现:单请求正常,多并发或长上下文失败。
可能原因:KV Cache 随请求和 token 长度增长,且容量估算只计算了权重。
诊断:
- 比较单请求和多请求的
max_memory_allocated(); - 计算活跃 KV token 数;
- 查看请求是否都按最大上下文预留;
- 检查 block 是否在取消后归还;
- 检查 prefill 峰值是否超过 decode 稳态。
显存看似充足但仍无法分配
表现:监控显示有空闲显存,但大块分配失败。
可能原因:
- 显存碎片;
- allocator 保留块与实际可用块不匹配;
- kernel workspace 需要瞬时大块连续空间;
- 多 GPU 中某一张卡成为瓶颈。
处理:
- 使用分页式 KV 管理;
- 降低单轮 prefill token 上限;
- 降低最大上下文或并发;
- 让服务在压力测试中观察峰值,而不是只看稳态平均;
- 不要把频繁
empty_cache()当作根治方案,它可能增加分配开销和延迟。
吞吐提高但用户感觉更慢
表现:GPU 利用率和 tokens/s 上升,但 TTFT、P95/P99 延迟恶化。
原因:调度器偏向吞吐,batch window 或 prefill batch 过大,排队和 head-of-line blocking 增加。
验证:
- 将 TTFT 拆成排队、prefill、调度耗时;
- 分别统计 prompt token/s 和 decode token/s;
- 查看长 prompt 到达时短请求的延迟;
- 比较 FIFO、decode 优先和 token-budget 策略。
量化后速度没有改善
表现:模型显存下降,但 TTFT 或 decode 速度基本不变,甚至变慢。
原因:
- 量化只降低了存储,没有使用高效量化 kernel;
- decode 阶段瓶颈在 KV 读取而不是权重读取;
- 反量化开销抵消收益;
- batch 太小,无法发挥 kernel 优势;
- GPU 架构不适合该量化格式。
此时应分别测试 prefill、decode、不同 batch、不同上下文长度,并确认实际执行的算子和 dtype,而不是仅查看量化文件名。
生产容量模型应该如何写
不要只配置:
max_batch_size = 32
更有意义的限制通常是:
max_active_requests
max_total_kv_tokens
max_context_length
max_new_tokens
max_prefill_tokens_per_iteration
max_queue_time
其中 max_total_kv_tokens 比 max_batch_size 更接近真实显存压力。例如:
- 32 个请求,每个 512 token;
- 8 个请求,每个 8192 token;
两者请求数不同,但 KV token 总量分别为 16384 和 65536,后者显著更容易 OOM。
一个简化的准入判断可以写成:
其中 是当前所有请求已占用的 KV token 数, 是新请求 prompt token 数。实际系统还需考虑 block 粒度:如果一个 block 保存 个 token,那么每个请求的分配量通常是:
这会产生内部碎片,因此理论 token 上限还要乘以实现相关的保守系数。
服务端应在容量不足时选择明确的故障路径:
- 排队但设置最大等待时间;
- 拒绝新请求并返回可重试错误;
- 降低允许的最大输出长度;
- 对低优先级请求降级到 CPU、较小模型或异步队列;
- 取消超时请求并确认 KV 已回收。
静默截断上下文、静默降低质量或无限排队,会使成本和用户体验变得不可预测。
与常见推理引擎的关系
Ollama、llama.cpp、vLLM 等工具在模型格式、量化格式、KV 管理、批处理策略和 API 语义上并不完全相同。
可以用以下维度比较,而不能只比较“能否启动模型”:
| 维度 | 需要确认的问题 |
|---|---|
| 模型格式 | 是否支持目标权重格式和 tokenizer |
| 权重量化 | 量化发生在哪些层,是否有对应 kernel |
| KV Cache | dtype、分页、prefix cache、最大 token 管理 |
| 批处理 | 是否支持动态或连续批次 |
| 调度 | 是否区分 prefill 和 decode |
| 多 GPU | 张量并行、流水线并行和通信开销 |
| 观测 | 是否能看到 TTFT、ITL、队列、token 和显存 |
| 兼容性 | 采样参数、停止串、流式输出和取消语义 |
“支持 4 bit”只说明某种量化权重可以被加载,不保证:
- KV 也使用 4 bit;
- 所有层都量化;
- 所有 GPU 都有高效 kernel;
- 连续批次和 prefix cache 同时可用;
- 长上下文下质量和稳定性满足要求。
具体参数名称和可用特性随引擎版本、模型架构及 GPU 架构变化,部署前应以对应版本文档和实际启动日志为准。PyTorch 的 CUDA、推理模式和内存统计语义可参考其官方文档与教程,例如 PyTorch Documentation 和 PyTorch Tutorials;这些文档不能替代具体推理引擎对 paged KV、量化 kernel 或连续批次的实现说明。
成本治理:token 才是更接近真实负载的单位
GPU 成本不应只按请求数统计。对生成服务,至少应记录:
- prompt token 数;
- output token 数;
- 当前上下文 token 数;
- prefill GPU 时间;
- decode GPU 时间;
- TTFT;
- ITL;
- 排队时间;
- prefix cache 命中率;
- 峰值和平均 KV token 数;
- 量化版本和模型版本。
相同的 1000 个请求可能有完全不同的成本:
- 1000 个短分类请求,几乎没有持续 KV;
- 1000 个长上下文生成请求,需要大量 KV 并发驻留;
- 1000 个共享长系统提示词的请求,可能受益于 prefix cache;
- 1000 个低命中率、长输出请求,会长时间占据 decode batch。
因此预算和限流最好使用 token 或 GPU 时间维度,而不是单纯使用 request count。一个租户可以被限制每分钟输入 token、输出 token 和最大并发 KV token;超出预算时,再根据优先级执行排队、拒绝、截断或降级。
最终判断框架
选择批处理方式时,先判断请求形态:
- 离线、输入集合固定:静态批处理通常足够;
- 在线、请求短且到达集中:动态批处理可以降低等待;
- 在线生成、请求长度和完成时间差异大:连续批次更适合;
- 长 prompt 与实时 decode 混合:需要区分 prefill/decode,并限制 prefill 预算;
- 高并发长上下文:KV Cache 管理通常比单纯增大 batch 更关键。
选择量化方式时,分别回答:
- 要减少的是权重、激活还是 KV?
- 目标 GPU 是否有匹配 kernel?
- 质量下降是否经过真实任务评测?
- 节省的显存是否确实转化为更高并发或更低成本?
- 量化后的 decode 是否仍受 KV 带宽限制?
估算 GPU 容量时,至少计算:
其中最容易被遗漏、却会随并发和上下文持续增长的部分是 KV Cache。批处理决定 GPU 同时服务多少工作,连续批次决定这些工作如何进出,量化决定部分数据每个元素占多少空间,而 GPU 容量决定调度器最终能否兑现这些计划。只有把四者放在同一个 token、显存和延迟模型中,吞吐、TTFT、稳定性与成本之间的取舍才是可计算、可验证的。
系列导航与关联阅读
- 系列入口:AI 工程完整学习路线:从机器学习与 Transformer 到 RAG、Agent 和生产治理
- 上一篇:模型微调与适配:SFT、LoRA、数据治理、评测和何时不该微调
- 下一篇:本地模型与推理引擎:Ollama、llama.cpp、vLLM、量化和部署取舍
- 延伸:AI 可观测性与成本治理:Trace、Token、TTFT、预算、缓存和降级
官方资料
本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论