AI 推理服务的容量管理——基于历史数据的容量预测与弹性策略
一、容量管理的核心矛盾:资源浪费与服务降级之间的博弈
AI 推理服务的资源成本远高于传统 Web 服务——单张 A100/H100 GPU 的月租赁成本动辄数千元,而一个中等规模的推理集群可能需要 8~16 张 GPU 才能支撑业务峰值。在这种成本结构下,容量管理的决策失误会产生严重后果:
- 容量不足:高峰时段请求排队、超时、降级,用户体验受损
- 容量过剩:GPU 空转率超过 30%,每月的资源浪费可达数万元
更棘手的是,LLM 推理的流量模式与传统 Web 服务不同:对话类应用的流量具有明显的早晚高峰(类似 IM 工具);内容生成类应用可能因为热点事件爆发式增长(类似新闻资讯);RAG 类应用则与上游数据更新频率强相关。这些特征要求容量管理从"经验化"走向"数据化"。
二、容量管理的数据驱动框架
容量管理的数据驱动框架主要由三个核心层次构成,它们共同协作以实现自动化决策:
- 数据采集层:负责持续收集多维度的输入信息,包括历史流量数据(QPS / Token 消耗 / 模型分布)、实时资源指标(GPU 利用率 / KV Cache / 排队深度)以及业务事件数据(大促 / 新功能 / 节假日日历)。
- 分析与预测层:基于采集到的数据进行深度处理,涵盖时序预测(Prophet / ARIMA + LSTM)、容量建模(QPS → GPU 换算公式)、异常检测(流量突增 / 模式漂移)以及 SLA 模拟(不同容量下的延迟分布推演)。
- 决策与执行层:将分析结果转化为具体的系统动作,包括扩容决策(HPA / KEDA 弹性伸缩)、调度决策(GPU 碎片整理 / 模型迁移)、降级决策(限流阈值 / 优先级队列)以及成本优化(竞价实例 / 混合部署)。
这三个层次形成一个闭环:数据采集层持续收集历史和实时数据,分析层基于这些数据做出预测和推演,执行层将决策落地为具体的扩缩容和调度动作。而执行的结果又作为新的历史数据反哺分析层。
三、容量预测模型:从时序分析到 GPU 换算
容量预测的核心是一个看似简单的问题:明天的这个时间,我们需要多少张 GPU?
这个问题可以拆解为两个步骤:
步骤一:流量预测。基于过去 30 天的流量数据(小时粒度),使用时序预测模型预测未来 24 小时的 QPS 和 Token 消耗分布。对于有明显周期性的流量(日周期 + 周周期),Prophet 模型即可达到较好的预测精度;对于需要捕捉复杂模式的场景,可以引入 LSTM 或 Transformer 模型。
步骤二:容量换算。将预测的 QPS 和 Token 消耗换算为需要的 GPU 数量。换算公式为:
所需 GPU 数 = 预测峰值 QPS × 平均 Token 数 ÷ (单 GPU Token 吞吐 × GPU 利用率目标)其中"GPU 利用率目标"是关键参数——设为 100% 会在流量毛刺时出现降级,设为 60% 又过于保守。实际生产中的推荐值为 75%~85%,通过压力测试确定具体数值。
/** * AI 推理服务的容量预测与弹性调度引擎 * 基于历史数据的时序预测 + 容量换算 + 自动扩缩容 */ @Service public class InferenceCapacityManager { private final GpuClusterClient gpuClusterClient; private final MetricsRepository metricsRepository; private final NotificationService notificationService; // 容量安全系数:预测容量 × 系数 = 实际分配容量 private static final double CAPACITY_SAFETY_FACTOR = 1.2; // GPU 利用率目标:低于此值表示容量过剩,高于此值触发扩容 private static final double GPU_UTILIZATION_TARGET = 0.75; public InferenceCapacityManager(GpuClusterClient gpuClusterClient, MetricsRepository metricsRepository, NotificationService notificationService) { this.gpuClusterClient = gpuClusterClient; this.metricsRepository = metricsRepository; this.notificationService = notificationService; } /** * 执行一次容量评估与弹性调度 * 建议每 5 分钟通过调度框架执行一次 */ public void evaluateAndScale(String clusterId) { try { // 1. 获取过去 1 小时的实时候 GPU 利用率 double currentUtilization = metricsRepository .getAvgGpuUtilization(clusterId, Duration.ofHours(1)); log.info("集群 {} 当前 GPU 利用率: {:.2%}", clusterId, currentUtilization); // 2. 获取未来 1 小时的流量预测 TrafficPrediction prediction = predictTraffic(clusterId, Duration.ofHours(1)); // 3. 计算所需的 GPU 数量 int requiredGpus = calculateRequiredGpus(prediction, clusterId); int currentGpus = gpuClusterClient.getGpuCount(clusterId); log.info("集群 {} 当前 GPU 数: {}, 预测所需 GPU 数: {}", clusterId, currentGpus, requiredGpus); // 4. 扩容/缩容决策 if (requiredGpus > currentGpus) { // 扩容:差异大于 1 张 GPU 时执行 int scaleUp = requiredGpus - currentGpus; if (scaleUp > 0) { gpuClusterClient.scaleUp(clusterId, scaleUp); notificationService.notify( "集群 %s 扩容 %d 张 GPU,当前利用率: %.2f%%" .formatted(clusterId, scaleUp, currentUtilization * 100)); } } else if (requiredGpus < currentGpus - 1) { // 缩容:需要谨慎,至少保留一张 GPU int scaleDown = Math.min(currentGpus - requiredGpus, currentGpus - 1); // 缩容前确认已持续低负载超过 30 分钟 double sustainedUtilization = metricsRepository .getAvgGpuUtilization(clusterId, Duration.ofMinutes(30)); if (sustainedUtilization < GPU_UTILIZATION_TARGET * 0.6) { gpuClusterClient.scaleDown(clusterId, scaleDown); log.info("集群 {} 缩容 {} 张 GPU(持续低负载 {})", clusterId, scaleDown, sustainedUtilization); } } } catch (Exception e) { log.error("容量评估与弹性调度失败, 集群: {}", clusterId, e); notificationService.alert("容量管理异常", e.getMessage()); } } /** * 基于历史数据预测未来流量 * 简化实现:使用最近 7 天同时段的均值 + 趋势修正 */ private TrafficPrediction predictTraffic(String clusterId, Duration window) { // 获取过去 7 天同时段的流量数据 LocalDateTime now = LocalDateTime.now(); double totalQps = 0; int daysWithData = 0; for (int i = 1; i <= 7; i++) { LocalDateTime pastTime = now.minusDays(i); Double qps = metricsRepository.getQpsAt(clusterId, pastTime); if (qps != null && qps > 0) { totalQps += qps; daysWithData++; } } if (daysWithData == 0) { // 无历史数据,使用当前值 return new TrafficPrediction( metricsRepository.getCurrentQps(clusterId), now); } double avgQps = totalQps / daysWithData; return new TrafficPrediction(avgQps, now); } /** * QPS + Token 分布 → GPU 数量换算 */ private int calculateRequiredGpus(TrafficPrediction prediction, String clusterId) { // 单 GPU 的 Token 吞吐(通过压测获得,此处为示意值) double tokensPerSecondPerGpu = gpuClusterClient .getTokenThroughput(clusterId); // 平均每个请求的 Token 数(历史均值) double avgTokensPerRequest = metricsRepository .getAvgTokensPerRequest(clusterId, Duration.ofDays(7)); // 预测的 Token 总吞吐 double predictedTokenThroughput = prediction.qps() * avgTokensPerRequest; // 所需 GPU 数 = 预测吞吐 / (单 GPU 吞吐 × 利用率目标) double rawGpus = predictedTokenThroughput / (tokensPerSecondPerGpu * GPU_UTILIZATION_TARGET); // 加安全系数并向上取整 int requiredGpus = (int) Math.ceil(rawGpus * CAPACITY_SAFETY_FACTOR); return Math.max(1, requiredGpus); } /** * 流量预测结果 */ public record TrafficPrediction(double qps, LocalDateTime timestamp) {} }四、弹性策略的设计原则:快扩慢缩、分级响应
弹性伸缩的决策逻辑比"高就扩、低就缩"要复杂得多,实际生产中需要遵循以下原则:
快扩慢缩(Fast Scale-Up, Slow Scale-Down):扩容应当在检测到容量不足后的 30 秒内开始执行(如果是基于 KEDA 的事件驱动),而缩容需要确认低负载状态持续 15~30 分钟后再执行。这个策略的核心考量是:业务受损的代价远大于资源浪费的代价。
分级响应:设置三级响应策略——一级(预警):GPU 利用率 > 75%,发送通知但不下发扩容指令;二级(扩容):GPU 利用率 > 85% 持续 2 分钟,自动扩容 1~2 张 GPU;三级(紧急扩容):GPU 利用率 > 95%,立即扩容并触发流量限流保护。
冷启动成本:LLM 推理服务的"冷启动"包括模型加载到 GPU 显存、KV Cache 预热等过程,通常需要 1~3 分钟。因此,弹性伸缩不能等到"已经开始降级"了才扩容,需要预留足够的提前量。
五、容量管理的生产实践与成本优化
在实际生产环境中运行容量管理系统半年后,我们总结出几条核心经验:
- 预测模型的准确度比复杂度更重要。对于大多数流量模式,简单的移动平均(过去 7 天同时段均值)结合业务日历修正,其准确度已经足够做扩容决策。不需要过早引入深度学习模型。
- 缩容的安全边际要留够。假设预测明天只需要 2 张 GPU,实际保留 3 张,多出的 1 张 GPU 成本一个月不过数千元。但一旦预测失误导致服务降级,业务损失可能远超这个数字。
- 竞价/抢占式实例是成本优化的关键杠杆。将基础负载部署在包年包月实例上,弹性部分使用竞价实例,可以将 GPU 成本降低 40%~60%。但需要确保推理服务能容忍竞价实例的回收中断(提前 30 秒通知 + 优雅退出)。
- 容量管理不仅是扩缩容,还包括 GPU 碎片整理。当多个模型共享一个集群时,不同模型的 GPU 请求量不均衡会导致碎片。定期对 GPU 上的模型实例做迁移合并,可以释放碎片资源、提高整体利用率。
容量管理的终极目标是在满足 SLA 的前提下最小化资源成本——这是一个持续的优化过程,而非一次性的参数配置。