前阵子有个朋友问我“我一张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大约等于 2K和V两组 × 层数 × 隐藏维度 × 每个元素的字节数。不同模型结构会有差异但粗略估算一个大几十层、隐藏维度4000上下的模型每个token大概是1MB上下。你想想模型配置允许每路请求输入输出加起来4096个token那就意味着每路请求最多会吃掉4GB左右的KV Cache。如果模型还开了8192上下文单请求就能顶满8GB甚至更多。所以那个“80G显卡能跑27B模型”的结论真实情况可能是加载完权重还剩60GB看起来绰绰有余但只要并发请求一多每路请求的KV Cache累加起来显存很快就见了底。这也是为什么单卡推理的并发天花板往往不是算力而是显存。1.2 单卡上看到的低延迟为什么扛不住在线流量单条请求跑出几十毫秒的延迟这数据本身没问题但它只能代表“空闲实例的prefilldecode能力”。线上服务是并发场景所有请求都要共享同一份显存、同一组计算单元。当并发数上去之后会发生两件事第一Continuous Batching连续批处理让多个请求在同一个step里混合decodeGPU算力被分走单请求的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卡机用TP8避免跨机做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-vllm5.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-utilization0.85-0.90不要拉满拉满会导致显存分配抖动反而影响KV Cache预留max-num-seqs从16开始压测太小吞吐上不去太大极端情况下OOMmax-model-len按业务最大上下文输出留余量设小了长请求直接拒设大了KV预占过多tensor-parallel-size单机卡数以内不要轻易跨机做TP通信开销会拖慢每步decodeenable-prefix-caching开启对话轮次多、共享前缀明显的场景收益很大避坑记录里最想说的是两件第一压测结果不要直接外推。我曾经在单机4卡上压出很漂亮的吞吐以为加机器就能线性扩展后来发现跨机通信、调度器决策开销、KV Cache不均衡等原因实际扩展效率只有七成不到。正确做法是先从2卡、4卡、8卡分别测画出扩展曲线再决定集群规模。第二显存utilization和吞吐是非线性关系。不是把gpu-memory-utilization调高吞吐就会一直涨。超过某个临界点KV Cache可用量太小调度器被迫频繁换出/换入请求实际有效吞吐反而下降。这个临界点必须在你的真实业务流量下压测找到每个模型、每类请求都不一样。另外PD分离场景下要给prefill实例和decode实例设置不同的扩缩容策略prefill实例跟输入流量关系更紧密缩容要慢一点因为长请求的prefill一旦迟到整条请求的TTFT首token延迟就废了decode实例可以更激进的跟随在线并发波动。我自己在项目中做推理集群最大的体会是负载均衡这件事不是“把请求平均分出去”就算结束了它是模型特征、显存管理、硬件状态、业务流量四个维度交叉作用的结果。单卡推理让你看到模型的极限集群架构让你看到资源的极限而等开销调度是在这两个极限之间找到一条尽量平滑的路。最后一个小建议如果团队人力有限优先把可观测体系做扎实再把调度策略从“简单轮询”切换到“等开销”模式。因为前者决定了你出了故障能不能快速定位后者决定了你在高并发下能不能真正扛住压力。其他架构上的花活都可以等这两点立稳了再慢慢加。
阅读完成 · 觉得有帮助?