详解连续批处理(Continuous Batching)如何将10并发用户从10倍延迟降为轻微延迟,并给出KV Cache显存占用的精确计算公式。
笔记本上跑一个人和服务器上跑十个人是不同的工程问题,后者主要涉及内存记账。这完全可以在事前计算出来,幸好如此,因为在周一上午十点才发现这个问题可不好受。
单用户运行时一次只处理一个请求。两个人同时提问意味着第二个要等第一个生成完毕,体验很差,也是对硬件的浪费。
连续批处理解决了这个问题。因为生成的速度瓶颈是从内存中流式吐出权重而非算术运算,用来生成一个 token 的权重可以同时为多个序列生成 token。支持在每个步骤将新请求接纳进运行中批处理的服务器——vLLM、SGLang 和 TGI 都是这样做的——把十个并发用户从十倍减速变成了略微减速,而 aggregate throughput 是单流速率的数倍。
代价是内存。每个在飞序列都占用一个 KV cache,而这正是你要计算的约束。
vram = weights + kv_total + activation_overhead
weights = params * bits_per_weight / 8
kv_token = 2 * layers * kv_heads * head_dim * elem_bytes
kv_total = kv_token * avg_context * max_concurrent_sequences
# rearranged, which is the form you actually want:
max_concurrent = (vram - weights - overhead) / (kv_token * avg_context)
注意是 max_concurrent_sequences,不是 users。用户不是持续生成的:他们会阅读、思考和打字。人数与同时在飞请求数之间的比值才是决定规格的数字,对于交互式聊天来说这个比值非常有利——十人团队产生的同时生成数远少于十个,大多数时候。
分页注意力让这一点更出色。服务器用固定块分配缓存而不是为每个序列预留完整上下文,只按实际生成的 token 计费,所以一个在 800 token 处停止的对话不会占用 32k 的预留。因此你的 avg_context 应该是平均值,而不是最大值。
以上一节的算术为基础,取 8B 类模型——32 层、8 个 KV head、head dimension 128——放在单张 24 GB 卡上,以 fp16 而非 GGUF 量化来服务,因为这是批处理服务器想要的。
weights, fp16 8e9 * 16 / 8 = 16.0 GB -> 14.9 GiB
overhead CUDA context, activations -> ~2.0 GiB
left for KV 24 - 14.9 - 2.0 -> 7.1 GiB
kv_token 2 * 32 * 8 * 128 * 2 = 128 KiB
avg_context 4,000 tokens (measure yours)
kv_per_seq 128 KiB * 4000 = 500 MiB
max_concurrent 7.1 GiB / 500 MiB = 14 sequences
十四个同时生成足以服务一个做交互工作的十人团队。现在看看一个决定能对它产生什么影响:
把平均上下文提升到 16k,kv_per_seq 就变成了 2 GiB——容量跌到三个。Prompt 中的长文档是部署从好使变得不好使的最常见原因。
用四比特 GPU 原生权重服务同一模型,你为 cache 释放了大约 11 GiB,在 4k 上下文下并发超过 35。在内存受限的机器上,量化权重换来的是并发数,不只是能不能装得下。
换用 8 比特 KV cache,上面每个数字都翻倍。
选择 MoE 模型,weights 项跟总参数量走,而 throughput 跟活跃参数量走——好的计算经济学,单卡上的内存经济学却很艰难。
vllm serve org/model-8b-instruct \
--max-model-len 8192 \ # hard cap; also caps kv_per_seq
--gpu-memory-utilization 0.90 \ # leave room for the driver
--max-num-seqs 16 \ # your computed max_concurrent
--api-key "$TEAM_KEY" \
--host 0.0.0.0 --port 8000
# two cards, one model too large for either:
# --tensor-parallel-size 2
# quantised weights to buy KV headroom:
# --quantization awq (weights must be an AWQ checkpoint)
--max-model-len 是重要的那个,而且它是策略决策,不是技术默认值。它限制单个请求能占用的最大 cache,这样一个人粘贴一部小说就不会把其他人驱逐出去。把它设为你实际工作所需的最小值。
在前面放一个带认证的反向代理。服务器自己的 key 是单一共享密钥,没有单用户归属,越来越多的人使用之后你会想知道谁发了什么、能够撤销其中某个人、以及应用一个速率限制来阻止失控脚本占满批处理。
这个计算是一个预测。在人们依赖它之前用你预测的并发数和形状像真实 prompt 的请求来测试:
# vLLM 自带 serving benchmark;在你预测的并发下驱动它
vllm bench serve \
--model org/model-8b-instruct \
--base-url http://localhost:8000 \
--dataset-name random \
--random-input-len 3000 --random-output-len 400 \
--max-concurrency 14 --num-prompts 200
# 关注:p99 time to first token、每个请求的 output tok/s,
# 以及是否有请求被排队而不是被接纳。
然后在双倍并发下重复,因为你需要知道的是失败的样子。压力下的批处理服务器会排队而不是崩溃:首 token 时间上升、每请求生成率下降、aggregate throughput 保持大致平稳。这是优雅降级,知道 p99 首 token 时间超过用户容忍度的并发数才是真正的容量数字。在旁边盯着 nvidia-smi——如果内存在目标并发下接近上限,你的平均上下文估计过于乐观了。
上下文蔓延。某人加了检索,平均 prompt 长度变成三倍,并发崩溃。在平均 prompt token 数上告警,不只是请求计数。
一个用户、一个脚本。批处理任务指向团队端点会占满批处理。在代理层做每 key 速率限制,而不是靠礼貌。
冷启动。加载数十 GB 权重需要真实时间。不要让为 Web 服务调优的 liveness check 触发编排器重启容器。
单点故障。一台机器意味着一台宕机。提前决定fallback 是同一个 OpenAI 兼容接口背后的托管端点,还是接受停机——但要决定,而不是事后发现。
升级。Serving 引擎版本迭代很快,偶尔会改变模板、工具调用或量化支持相关的行为。锁定版本,在升级后重新跑你的 eval 集,而不是在投诉之后。
在你自己构建 failover 之前有一种值得了解的架构:因为这些服务器使用 OpenAI 兼容 API,相同的客户端可以在你的机器宕机或饱和时回退到托管同一份 open weights 的主机,除了 base URL 之外无需代码变更。这为正常流量保持了隐私属性并消除了单点故障——不过如果你是出于数据完全不能外流的原因而自托管,正确的 fallback 是一个队列,而不是另一个网络。