前阵子有个朋友问我:“我一张80G的卡跑27B模型,单流延迟也就五十来毫秒,为什么非要上集群?”我说你把并发压到20以上,再测一次尾延迟。他测完就沉默了——P99从八十毫秒直接飙到三百多毫秒,还有几个请求因为显存不够被拒掉。这就是很多人对大模型推理集群的第一课:单卡推理的“性能展示”和真实请求压力下的“服务能力”,完全不是一回事。
这篇文章想聊的就是从单卡推理到千卡负载均衡这条路上,我踩过的坑、算过的账、拆过的框架。不写泛泛的架构图,只讲真正能落地的设计思路和实操经验,适合正在做大模型推理服务、或者准备从单卡Demo往集群上迁的人。
1. 单卡推理的边界:先算清显存账和并发账
1.1 一张卡能跑多大的模型,算一笔账就知道
很多人的第一步是把模型加载进显卡,看到能generate就认为“跑起来了”。但线上服务不是“能跑”就行,你得算清楚:权重占多少显存、KV Cache最多能占多少、还剩多少给并发请求。
拿一个27B左右的稠密模型举例。FP16/BF16精度下,权重显存大约是参数量的2倍,也就是54GB左右。如果做量化,比如用FP8或者混合精度,权重能压到27-30GB。这一层大家都会算,真正容易被忽略的是KV Cache。
KV Cache的大小公式不复杂:每个token的KV Cache大约等于 2(K和V两组) × 层数 × 隐藏维度 × 每个元素的字节数。不同模型结构会有差异,但粗略估算一个大几十层、隐藏维度4000上下的模型,每个token大概是1MB上下。你想想,模型配置允许每路请求输入输出加起来4096个token,那就意味着每路请求最多会吃掉4GB左右的KV Cache。如果模型还开了8192上下文,单请求就能顶满8GB甚至更多。
所以那个“80G显卡能跑27B模型”的结论,真实情况可能是:加载完权重还剩60GB,看起来绰绰有余,但只要并发请求一多,每路请求的KV Cache累加起来,显存很快就见了底。这也是为什么单卡推理的并发天花板往往不是算力,而是显存。
1.2 单卡上看到的低延迟,为什么扛不住在线流量
单条请求跑出几十毫秒的延迟,这数据本身没问题,但它只能代表“空闲实例的prefill+decode能力”。线上服务是并发场景,所有请求都要共享同一份显存、同一组计算单元。
当并发数上去之后,会发生两件事:第一,Continuous Batching(连续批处理)让多个请求在同一个step里混合decode,GPU算力被分走,单请求的decode时间拉长;第二,KV Cache碎片化越来越严重,显存分配开始出现小块空洞,原本能塞下20路请求的显存,可能塞16路就报OOM。我那位朋友测出来的P99飙升,就是这两种效应叠加的结果。
所以判断单卡是否够用,不能只看单流延迟,要看三个指标:达到某一P99延迟上限时的最大并发数、单位时间的token吞吐量、以及显存利用率的波动曲线。如果你发现并发加5路,P99就翻倍,说明单卡已经在极限边缘。
1.3 该上集群的触发信号,其实很明显
根据我自己的经验,下面几个信号只要出现两三个,就说明该规划推理集群了:
- 单实例的P99延迟在低并发下就不达标,或者稍微压一点流量就明显恶化;
- 显存利用率长期超过85%,KV Cache已经无法容纳合理并发;
- 机器出现单点故障,一宕机整个服务全断,业务完全不可接受;
- 多路请求打到不同网卡/不同NUMA节点时,性能差异明显,单机拓扑已经开始干扰服务质量。
还有一个很多人忽略的信号:峰值流量和平均值差距过大。你会发现按峰值采购单卡机器很亏,而集群可以通过多实例分摊峰值,缩容时又能节省成本。这个阶段再回头看“从单卡到千卡”,本质上就是从“堆单机性能”转向“做水平扩展和资源调度”。
2. 推理集群的骨架:为什么不能照抄训练集群
2.1 推理和训练是两种完全不同的负载特征
不少团队做推理集群,第一反应是“训练集群怎么搭我就怎么搭”。这个思路很危险。训练任务是长任务,一次训练跑几小时甚至几天,中间有checkpoint,失败可以重算;训练对延迟不敏感,对吞吐和稳定性要求高,但它的负载曲线相对平滑。
推理恰恰相反。每个请求的耗时只有几十毫秒到几秒,但它要求实时响应;请求长度、生成token数量、到达速率全都高度动态,负载曲线剧烈波动。更关键的是,训练集群的重心在“算得快”,推理集群的重心在“分得匀”——能不能把几十上百个并发请求均匀地、按照各自的实际资源消耗分配到最合适的实例上。
这就决定了推理集群的控制面必须比训练集群更“懂请求”。数据并行、张量并行、流水线并行都要用,但用法和训练场景很不一样。
2.2 三种并行在推理里的正确用法
我整理了一张表,是我们在设计推理集群时对三种并行方式的定位:
| 并行方式 | 适用阶段 | 推理场景下的作用 | 主要代价 |
|---|---|---|---|
| 数据并行(DP/实例副本) | 请求分配层 | 水平扩展吞吐,不切分模型 | 每副本都要完整加载权重 |
| 张量并行(TP) | 单模型内部 | 切分权重和KV Cache,单请求多卡协作 | 通信开销大,需要高速互联 |
| 流水线并行(PP) | 单模型内部 | 按层切分,降低单卡显存需求 | 流水线气泡,延迟增加 |
推理场景下,通常不推荐纯流水线并行。因为推理请求是逐token生成的,流水线的气泡效应会让每个token的延迟都变得不可控。真要切模型,优先选择张量并行,而且TP size尽量控制在单机GPU数以内,比如8卡机用TP=8,避免跨机做TP——跨机做TP会把通信延迟直接写进每次decode的关键路径里,尾延迟很快会恶化。
如果模型大到单机放不下,再考虑“单机内TP + 跨机DP”的混合模式。也就是说,每台机器上放一个完整模型的TP切片,多台机器之间做数据并行。这样跨机只有请求分发,没有每token级别的通信,架构会干净很多。
2.3 PD分离:当KV Cache变大之后,这件事躲不掉
PD分离(Prefill-Decode Separation)在参数规模到一定级别之前,很多人觉得没必要。但集群规模一大、前后文长度一长,就不得不做。
推理请求有两个明显不同的阶段:
- Prefill阶段:处理整段输入上下文,计算密集,耗时与输入长度近似线性,显存需求大;
- Decode阶段:逐token生成输出,内存带宽密集,每个token的计算量很小,但延迟要求高。
如果把两者混在同一批实例上,长上下文的prefill会把大量算力瞬时占住,导致正在decode的请求全部被拖慢。这个干扰在单卡上还能忍,在集群上会被放大:一台实例被一个巨大prefill阻塞,调度器还在往它身上派新请求,整个队首阻塞影响全局。
PD分离的做法是:一部分实例专门做prefill,另一部分专门做decode,中间通过队列/缓存把KV Cache传过去。这样长上下文prefill不会再干扰正常生成,decode实例的负载也更平滑。代价是要么多一跳传输,要么把KV Cache留在共享缓存里。千卡规模的集群,我认为PD分离不是可选项,是标配。
2.4 集群整体分层,我的典型五层划分
如果画一张不那么正式的部署图,我习惯把推理集群分成五层:
- 接入层:负责鉴权、限流、协议转换,把HTTP/gRPC请求统一收进来;
- 调度路由层:这是负载均衡的核心,决定每个请求去哪台推理实例;
- 推理实例层:一组组张量并行的模型副本,运行着推理引擎;
- KV Cache/上下文状态层:存prefix cache、跨实例共享的KV数据,帮助PD分离和长对话续接;
- 控制与可观测层:实例心跳、健康检查、指标采集、自动扩缩容。
调度路由层是整篇最值得下功夫的区域。传统Web负载均衡做到第二层就够,但大模型推理必须做到在第三层动态决策——因为每个请求“多重”只有实际跑起来才知道。
3. 负载均衡的里子:等开销调度与KV Cache的隐性倾斜
3.1 LVS级别的负载均衡,解决不了token粒度的问题
很多系统里都见过LVS,它做四层负载均衡非常优秀,把连接分发到一组后端服务器,性能极高。但大模型推理的请求不是无状态的HTTP请求——一条请求可能携带几千token的上下文,生成的回复也是几千token,各个请求之间消耗的显存和算力相差十倍都不奇怪。
在LVS眼里,所有连接是均等的;在推理引擎眼里,一条32token的请求和一条4096token的请求完全不是一个量级。所以推理集群的负载均衡,必须在应用层、甚至在模型运行时状态之上做决策。它需要感知每个请求的输入长度、预估输出长度、当前实例的空闲槽位数、剩余KV Cache容量、排队中的请求总量。
这就是“等开销负载均衡”的出发点。
3.2 等开销调度:让每个请求不管去哪,预计完成时间都接近
“等开销负载均衡”(Equal-Cost Load Balancing,也叫等耗时调度)的核心理念很简单:不要追求每个实例上的请求数量相等,要追求每个请求的预计总耗时在不同实例之间尽量一致。请求数相等但长短不一,照样会有人饿死有人累死。
预估一个请求在某实例上的总耗时,我会用这样一个简化模型:
预估总耗时 = 排队耗时 + prefill耗时 + decode耗时
- 排队耗时 ≈ 当前实例排队的总请求量 × 平均请求处理时长;
- prefill耗时 ≈ 输入token数 / 该实例实测的prefill吞吐;
- decode耗时 ≈ 预估输出token数 / 该实例实测的decode吞吐。
这里decode吞吐要用“有效并发下的吞吐”而不是单流最优吞吐,否则预估值会严重偏乐观。另外还要加两个惩罚项:实例当前KV Cache存量越高,惩罚越大;实例近期错误率、慢节点分数越高,惩罚越大。
调度的伪代码大致长这样:
def estimate_total_time(req, inst): queue_time = inst.queue_len * inst.avg_req_time prefill_time = req.prompt_len / inst.prefill_tps decode_time = req.max_tokens / inst.decode_tps kv_penalty = max(0, inst.kv_used_ratio - 0.8) * 100 slow_penalty = inst.slow_score * 50 return queue_time + prefill_time + decode_time + kv_penalty + slow_penalty def pick_instance(req, instances): best_inst = None best_time = float("inf") for inst in instances: if inst.healthy == False: continue if inst.available_slots < 1: continue t = estimate_total_time(req, inst) if t < best_time: best_time, best_inst = t, inst return best_inst你可能注意到,这个打分函数里没有“当前实例请求数相等”的逻辑,因为实践下来,单纯均分请求数一定会出问题:短请求实例很快空转,长请求实例还在排队,最后尾延迟照样难看。等开销调度的最终目标是最小化每个请求的完成时间,让所有请求的预计完成时间方差尽量小。
实现的时候要注意:预估输出token数(req.max_tokens)不能简单用默认值,要按业务特征分桶。比如聊天场景多半是几百token,文档总结场景可能上千token,摘要场景可能几十token。分桶能让预估更准,调度效果会好很多。
3.3 最容易被忽视的隐性倾斜:KV Cache
很多团队把请求分配做到了“等开销”,但线上还是会出现某个实例的P99和别人差一大截。查到最后,问题往往出在KV Cache倾斜。
KV Cache和普通显存不一样,它是分配了又被释放、释放了再分配的动态资源,而且伴随请求“地址碎片化”。即使每个实例分到的请求总数一样、预计总token数一样,长请求在哪个实例、前缀缓存命中了哪个实例,都会让KV Cache的实际分布完全不同。
比如某个实例命中了一段很长的公共前缀缓存,看起来省了显存,但因为长请求都集中在它身上,KV Cache占用反而先暴涨。又比如某实例刚跑完一批长上下文请求,显存虽然释放了,但碎片化导致后续请求分配KV块时命中率低、性能下降。
解决思路有几条:
- 调度打分时把实例的KV Cache剩余量纳入因子,也就是上文代码里那个kv_penalty,而且要设置硬阈值,低于阈值的实例不再派新请求;
- 启用Prefix Cache(前缀缓存),并且把前缀缓存放成共享的、跨实例的形态,不要让同一段前缀在每个实例里各自存一份;
- 周期性做KV Cache整理,把碎片化严重的实例调低权重,让它慢慢空下来再恢复;
- 在PD分离架构里,prefill实例和decode实例之间的KV Cache传递链路要足够快,否则decode实例等数据的时间会白白吃掉优势。
换句话说,KV Cache不是“显存资源”,它是“带状态的动态缓存资源”。做负载均衡时,必须像操作系统管理虚拟内存一样管理它:分配、释放、碎片整理、冷热分离。
3.4 MoE模型的负载均衡:专家层面也要管
模型层面还要单独说一句:MoE(混合专家)架构在推理集群里越来越多,它的负载均衡比稠密模型多了一层——专家负载均衡。
MoE模型每个Transformer层里有若干专家,每个token只激活其中top-k个专家。问题是,不是所有专家都被同等频率选中。有些专家因为学到的知识更通用,被反复激活,算力耗尽;有些专家冷门,长期空转。训练阶段通常会加aux loss来均衡专家使用率,但推理阶段模型参数已经固定,你再怎么调调度器,也改变不了token对专家的偏好。
那推理侧能做什么?我的实践里有三招:
- 监控专家热度,把高热度专家所在的模型副本部署到更多实例上,或者让这些副本承接更多流量;
- 在调度打分时,把目标实例的“当前专家级负载”作为一个因子。也就是说,如果某个请求大概率激活热门专家,就优先派到专家负载较低的实例;
- 如果MoE采用了专家并行(Expert Parallelism),也就是不同专家切在不同设备上,那请求路由时要考虑跨设备通信代价,尽量让请求落在“专家所在设备离得近”的实例上。
MoE负载均衡和传统请求负载均衡最大的区别是:前者的决策粒度是“token”,后者是“请求”。要做细,只能从调度层和部署层双管齐下。
4. 千卡规模才暴露的稳定性设计:慢节点、故障转移与可观测
4.1 三张卡里总有一张“不太行”:慢节点的识别与处理
单卡推理的时候,硬件偶尔降频你可能感觉不到。到了集群,只要有一个慢节点存在,整个调度系统的“木桶效应”就会被无限放大:请求被等开销调度器反复派给慢节点,因为它的排队时间看起来最小,结果跑起来后性能死活达不到预期,拖累全局P99。
慢节点是怎么产生的?我见过几类:GPU核心因散热问题降频、HBM温度过高导致显存带宽下降、PCIe链路自动降速、网络队列堆积、甚至同一台机器上的其他进程抢占了CPU资源。
识别慢节点的办法就是指标化:给每个实例维护一个慢节点分数(slow_score),由几个实时指标加权计算。比如“实际decode吞吐 vs 理论decode吞吐”的比值、单请求平均处理时长的滑动平均、与集群中位数实例的偏差、NCCL通信耗时的异常涨幅。慢节点分数一旦超过阈值,调度器就要降低它的权重,而不是直接把它踢掉——因为有时候慢节点只是暂时性温度问题,降权让它慢慢恢复,比硬摘除更平滑。
4.2 故障转移:心跳、摘除与快速重试
集群规模上千卡之后,故障就不再是“万一”,而是“日常”。每天都有卡损坏、驱动报错、实例OOM、网络瞬断这类事情发生。故障转移设计的目标不是“不发生故障”,而是“故障发生时用户无感”。
我常用的方案是三层:
- 健康检查:每个实例持续上报心跳,包含存活状态、显存状态、引擎状态。调度器连续N次没收到心跳,或者收到引擎不可用的信号,就把实例标记为“探活中”,暂时不派新请求;
- 优雅下线:如果实例上还有正在处理的请求,不要直接kill。先把新请求全部停掉,等待存量请求超时或被迁移,再摘除。否则正在生成的成千上万token全部白算;
- 快速重试:万一请求因为实例宕机而中断,调度器要能够把它重新派给其他实例。注意重试次数必须严格控制,LLM推理重试成本很高,一条长请求可能是几十秒的计算量,无限制重试会把负载风暴放大好几倍。
故障转移里最反直觉的一点是:不要所有请求都做双写备份。推理请求不像训练任务那样需要精确恢复,重试一次的成本已经够高,双写纯属浪费。做“快速失败 + 有限重试 + 业务侧兜底”才是推理场景的正确姿势。
4.3 全链路可观测:只盯延迟均值是不够的
我的经验是,推理集群的排障效率,完全取决于可观测体系的粒度。至少要盯住这几层指标:
- 流量层:QPS、请求到达率曲线、token进出量;
- 调度层:每个实例的排队长度、调度器打分分布、被拒绝请求数;
- 实例层:prefill/decode分开的吞吐和延迟、KV Cache总量与剩余量、显存碎片率、慢节点分数;
- 硬件层:GPU利用率、显存带宽、温度、PCIe/网络重传率。
这里面最容易被忽略的是“prefill延迟”和“decode延迟”分开统计。很多团队的监控面板只看到一个“平均延迟”,结果prefill慢还是decode慢完全不知道。把两者拆开,很多问题的定位速度会快一个量级:输入很长导致prefill满,你会直接看到prefill延迟飙升;显卡带宽不足,你会看到decode吞吐掉下去,而prefill还算正常。
4.4 扩缩容:别让冷却时间和流量震荡互相打架
千卡集群还有一个日常问题:流量潮汐来了,扩容;潮汐退了,缩容。看似简单,做起来全是细节。
扩缩容最大的坑是冷却时间。冷启动一个推理实例,需要拉模型镜像、加载权重、建立分布式通信组、做完健康检查,这个过程少说也要几分钟。如果流量已经冲上来了你才开始扩,等你扩完流量峰值已经过去了,扩了白扩。所以触发扩容的阈值不能只看实时QPS,要看“未来一段时间的预估值”,比如滑动窗口内增长速率。
更隐蔽的问题是缩容。明明流量退了,缩了几台实例,结果P99反而变高。原因往往是:缩掉的实例上正好还有大量长连接或前缀缓存,流量被强制迁移到剩余实例后,它们的KV Cache命中率下降、排队变长。我的建议是缩容也要“优雅退场”:先把实例权重调成0,等存量请求全部结束,再真正释放资源。
5. 框架选型与学习路径:从vLLM到nano-vllm
5.1 为什么不建议从零造推理引擎
自己去写一个支持连续批处理、PagedAttention、分布式张量并行的推理引擎,工作量之大,足够一个十人团队忙大半年,而且做出来的东西大概率不如开源方案成熟。vLLM已经是当前事实上的标准选择:PagedAttention解决KV Cache碎片化,Continuous Batching解决批处理效率,Prefix Cache提升前缀复用,PD分离和分布式推理也都有对应能力。
我在项目里用的就是vLLM作为底座,调度路由层自己写。调度层必须自己写的理由很简单:vLLM解决的是“单实例内部怎么高效跑”,而集群级的“请求到实例”的决策,需要结合业务场景、KV Cache状态、慢节点分数、甚至成本策略来定制,框架不会替你做。
5.2 想真正搞懂推理引擎,可以从nano-vllm入手
很多人看vLLM源码,第一反应是“太大了,看不过来”。vLLM的代码量很大,生产级别的优化细节一层套一层,对想理解核心机制的人并不友好。
这时候nano-vllm这类教学项目就很合适。它用远少于vLLM的代码量,把推理引擎最关键的主干逻辑重写了一遍,核心机制一目了然:调度循环怎么工作、显存管理器如何分配/释放KV块、连续的批处理如何把新请求加入batch、prefill和decode如何切换。你先把nano-vllm读透,再看vLLM源码,很多在一大堆代码里找半天的概念,一下就串起来了。
学习路径我个人建议是:
- 先用vLLM跑通一个模型的在线推理,把max-model-len、gpu-memory-utilization、max-num-seqs这些参数一个个调一遍,感受它们对吞吐和延迟的实际影响;
- 对照nano-vllm的代码,找到“一个请求从进来到出去”完整的代码路径;
- 再回到vLLM源码,只看对应模块的完整实现;
- 动手给自己的调度器写一版等开销路由的插件,接上真实KV Cache指标验证效果。
这套路径比直接啃vLLM源码效率高很多,尤其适合团队里负责推理平台的工程师。
5.3 几个我实测过的重要参数和避坑记录
最后分享一些配置经验。以下参数适用于vLLM以及兼容API的框架,具体含义和默认值以你使用的版本为准:
| 参数 | 建议做法 | 说明 |
|---|---|---|
| gpu-memory-utilization | 0.85-0.90,不要拉满 | 拉满会导致显存分配抖动,反而影响KV Cache预留 |
| max-num-seqs | 从16开始压测 | 太小吞吐上不去,太大极端情况下OOM |
| max-model-len | 按业务最大上下文+输出留余量 | 设小了长请求直接拒,设大了KV预占过多 |
| tensor-parallel-size | 单机卡数以内 | 不要轻易跨机做TP,通信开销会拖慢每步decode |
| enable-prefix-caching | 开启 | 对话轮次多、共享前缀明显的场景收益很大 |
避坑记录里最想说的是两件:
第一,压测结果不要直接外推。我曾经在单机4卡上压出很漂亮的吞吐,以为加机器就能线性扩展,后来发现跨机通信、调度器决策开销、KV Cache不均衡等原因,实际扩展效率只有七成不到。正确做法是先从2卡、4卡、8卡分别测,画出扩展曲线,再决定集群规模。
第二,显存utilization和吞吐是非线性关系。不是把gpu-memory-utilization调高,吞吐就会一直涨。超过某个临界点,KV Cache可用量太小,调度器被迫频繁换出/换入请求,实际有效吞吐反而下降。这个临界点必须在你的真实业务流量下压测找到,每个模型、每类请求都不一样。
另外,PD分离场景下要给prefill实例和decode实例设置不同的扩缩容策略:prefill实例跟输入流量关系更紧密,缩容要慢一点,因为长请求的prefill一旦迟到,整条请求的TTFT(首token延迟)就废了;decode实例可以更激进的跟随在线并发波动。
我自己在项目中做推理集群,最大的体会是:负载均衡这件事,不是“把请求平均分出去”就算结束了,它是模型特征、显存管理、硬件状态、业务流量四个维度交叉作用的结果。单卡推理让你看到模型的极限,集群架构让你看到资源的极限,而等开销调度,是在这两个极限之间找到一条尽量平滑的路。
最后一个小建议:如果团队人力有限,优先把可观测体系做扎实,再把调度策略从“简单轮询”切换到“等开销”模式。因为前者决定了你出了故障能不能快速定位,后者决定了你在高并发下能不能真正扛住压力。其他架构上的花活,都可以等这两点立稳了再慢慢加。