AI 应用的容量规划——基于流量模型的资源预估与成本优化
AI 应用的容量规划——基于流量模型的资源预估与成本优化
一、AI 应用容量规划的特殊性
与传统 Web 应用不同,AI 应用的容量规划面临几个独特变量:
- 单次请求的资源消耗差异巨大,短问答可能只消费 200 Token,而长文档摘要可能超过 10,000 Token。
- GPU 资源成本远高于 CPU,A100 的单卡小时价格是普通云主机的数十倍。
- 模型加载时间使得弹性伸缩的响应速度远慢于无状态服务(30~120s vs < 1s)。
因此,AI 应用的容量规划不能简单套用"QPS x 单请求耗时 + 一定余量"的传统公式,需要建立更精细的流量模型。
二、建立流量模型
容量规划的起点是建立准确的流量模型,描述不同时段的请求特征和资源消耗规律。
/** * AI 流量建模器——基于历史数据的时序分析,为容量规划提供数据基础。 * * 为什么需要区分峰值和稳态两种模型: * 以峰值请求作为容量基准会导致严重的资源浪费(GPU 利用率常年 < 30%), * 仅以稳态作为基准则无法应对突发流量。 */ @Component public class AiTrafficModeler { private static final Logger log = LoggerFactory.getLogger( AiTrafficModeler.class); private final MeterRegistry meterRegistry; private final Clock clock = Clock.systemUTC(); // 滑动窗口大小:30天历史数据 private final ConcurrentNavigableMap<LocalDate, DailyTrafficMetrics> history = new ConcurrentSkipListMap<>(); public AiTrafficModeler(MeterRegistry meterRegistry) { this.meterRegistry = meterRegistry; } /** * 流量模型输出——为容量规划提供关键数据。 */ public static class TrafficModel { // 日均请求量 private final long avgDailyRequests; // P95 峰值 QPS private final double peakQps; // 平均每次请求的 Token 消耗 private final int avgTokensPerRequest; // P99 Token 消耗 private final int p99TokensPerRequest; // 日间峰值时段(小时) private final int peakHourStart; private final int peakHourEnd; // 周环比增长率 private final double weeklyGrowthRate; public TrafficModel(long avgDailyRequests, double peakQps, int avgTokensPerRequest, int p99TokensPerRequest, int peakHourStart, int peakHourEnd, double weeklyGrowthRate) { this.avgDailyRequests = avgDailyRequests; this.peakQps = peakQps; this.avgTokensPerRequest = avgTokensPerRequest; this.p99TokensPerRequest = p99TokensPerRequest; this.peakHourStart = peakHourStart; this.peakHourEnd = peakHourEnd; this.weeklyGrowthRate = weeklyGrowthRate; } public long getAvgDailyRequests() { return avgDailyRequests; } public double getPeakQps() { return peakQps; } public int getAvgTokensPerRequest() { return avgTokensPerRequest; } public int getP99TokensPerRequest() { return p99TokensPerRequest; } public int getPeakHourStart() { return peakHourStart; } public int getPeakHourEnd() { return peakHourEnd; } public double getWeeklyGrowthRate() { return weeklyGrowthRate; } } /** * 根据历史数据构建未来一周的流量预测模型。 */ public TrafficModel buildWeeklyModel() { if (history.size() < 7) { log.warn("历史数据不足, days={}, 使用默认模型", history.size()); return buildDefaultModel(); } List<DailyTrafficMetrics> recentWeek = new ArrayList<>(); LocalDate today = LocalDate.now(clock); for (int i = 7; i >= 1; i--) { DailyTrafficMetrics metrics = history.get(today.minusDays(i)); if (metrics != null) { recentWeek.add(metrics); } } if (recentWeek.isEmpty()) { return buildDefaultModel(); } long avgDaily = (long) recentWeek.stream() .mapToLong(DailyTrafficMetrics::getTotalRequests) .average().orElse(10000); double peakQps = recentWeek.stream() .mapToDouble(DailyTrafficMetrics::getPeakQps) .max().orElse(50); int avgTokens = (int) recentWeek.stream() .mapToInt(DailyTrafficMetrics::getAvgTokensPerRequest) .average().orElse(500); int p99Tokens = recentWeek.stream() .mapToInt(DailyTrafficMetrics::getP99TokensPerRequest) .max().orElse(5000); double growthRate = calculateWeeklyGrowthRate(recentWeek); return new TrafficModel(avgDaily, peakQps, avgTokens, p99Tokens, 9, 21, growthRate); } private double calculateWeeklyGrowthRate( List<DailyTrafficMetrics> recentWeek) { if (recentWeek.size() < 2) { return 0.0; } // 使用线性回归计算周环比增长率 long firstDay = recentWeek.get(0).getTotalRequests(); long lastDay = recentWeek.get(recentWeek.size() - 1) .getTotalRequests(); if (firstDay == 0) { return 0.0; } return (double) (lastDay - firstDay) / firstDay; } private TrafficModel buildDefaultModel() { return new TrafficModel(10000, 50, 500, 5000, 9, 21, 0.0); } @Scheduled(cron = "0 5 0 * * *") public void recordDailyMetrics() { // 在每天凌晨记录前一日的流量指标 DailyTrafficMetrics metrics = new DailyTrafficMetrics(); metrics.setTotalRequests(getCounter("ai.request.total")); metrics.setPeakQps(getGauge("ai.request.qps.peak")); metrics.setAvgTokensPerRequest( getGauge("ai.request.tokens.avg")); metrics.setP99TokensPerRequest( getGauge("ai.request.tokens.p99")); history.put(LocalDate.now(clock).minusDays(1), metrics); log.info("日流量指标已记录, requests={}, peakQps={}", metrics.getTotalRequests(), metrics.getPeakQps()); // 为什么仅保留 30 天历史:超过 30 天的流量数据对容量规划的参考价值下降, // 且业务迭代会改变流量特征,过于古老的数据可能产生误导 while (history.size() > 30) { history.pollFirstEntry(); } } private long getCounter(String name) { Counter counter = meterRegistry.find(name).counter(); return counter != null ? (long) counter.count() : 0; } private double getGauge(String name) { return meterRegistry.find(name).gauge() != null ? meterRegistry.find(name).gauge().value() : 0; } }三、GPU 资源预估模型
基于流量模型,可以建立 GPU 资源的预估公式:
/** * GPU 资源预估器——将流量模型转化为具体的 GPU 数量和规格需求。 * * 为什么需要区分标准推理和大规模推理两种场景: * 标准推理(如 7B 模型)单卡可并行处理多个请求, * 大规模推理(如 70B 模型)可能需要多卡张量并行才能加载模型权重。 */ @Component public class GpuResourceEstimator { // 单卡 A100 80GB 对 7B 模型的推理吞吐(tokens/s) // 为什么取 1500 而非理论峰值:实测环境下的保守估计, // 考虑了 KV Cache 存储和显存碎片化等因素 private static final int TOKENS_PER_SECOND_PER_GPU_7B = 1500; // 平均输出 Token 数与输入 Token 数的比例估算 private static final double OUTPUT_INPUT_RATIO = 0.3; public GpuEstimationResult estimate( AiTrafficModeler.TrafficModel model, double targetLatencyMs) { // 计算所需的总吞吐量(tokens/s) double avgTokensPerRequest = model.getAvgTokensPerRequest() * (1 + OUTPUT_INPUT_RATIO); double requiredTokensPerSecond = model.getPeakQps() * avgTokensPerRequest; // 计算延迟允许的并行请求数 // 为什么用 P50 延迟而非 P99 估算并行度: // P99 延迟对应的极端长请求应通过队列削峰处理, // 以 P50 为基准估算常规情况下的并发度 double parallelRequestsPerGpu = TOKENS_PER_SECOND_PER_GPU_7B / avgTokensPerRequest; // 计算所需 GPU 数量(峰值需求) int peakGpuCount = (int) Math.ceil( model.getPeakQps() / parallelRequestsPerGpu); // 稳态 GPU 数量(非峰值时段) int steadyGpuCount = (int) Math.ceil( peakGpuCount * 0.6); // 考虑增长率后的未来 GPU 需求 int futureGpuCount = (int) Math.ceil( peakGpuCount * (1 + model.getWeeklyGrowthRate() * 4)); return new GpuEstimationResult( peakGpuCount, steadyGpuCount, futureGpuCount, requiredTokensPerSecond); } }四、成本优化策略
基于容量规划结果,可以制定分层的成本优化策略:
| 策略 | 适用场景 | 预计节省 | 风险 |
|---|---|---|---|
| 定时缩容 | 夜间低峰(0~6点) | 15~25% | 凌晨突发流量覆盖不到 |
| Spot 实例 | 非核心推理任务 | 60~70% | 随时可能被回收 |
| 模型量化 | 精度要求不极端 | 40~50% | INT4 精度下降 |
| 请求优先级分级 | 多租户场景 | 20~30% | 低优先级用户等待增加 |
| 混合部署 | 多模型共享 GPU | 30~40% | 显存碎片化 |
graph TD A[流量模型] --> B{预测下周峰值QPS} B --> C[基线GPU数量] B --> D[增长因子] C --> E{成本优化} D --> C E -->|常驻实例| F[On-Demand GPU] E -->|弹性实例| G[Spot GPU 扩展] E -->|定时任务| H[离线推理批量] F --> I{夜间缩容} I -->|是| J[0~6点:保留 30%] I -->|否| K[保留 100%] G --> L[高峰期弹性扩容] H --> M[谷期批量处理] style A fill:#bbf,stroke:#333 style E fill:#ff9,stroke:#333 style F fill:#6f6,stroke:#333 style G fill:#f96,stroke:#333五、容量规划的持续迭代
容量规划不是一次性工作,需要建立持续优化的反馈循环:
- 周度复盘:对比实际流量与预测模型的偏差,调整预测参数。
- 月度成本分析:核算实际 GPU 成本,评估优化策略的有效性。
- 季度容量评审:根据业务增长趋势和模型升级计划,重新评估中长期容量需求。
- 压测验证:每个季度进行一次全链路压测,验证容量规划的上限是否与实际一致。
/** * 容量规划审计——定期检查预估容量与实际使用的偏差, * 当偏差超过阈值时触发告警。 */ @Component public class CapacityAuditor { private static final Logger log = LoggerFactory.getLogger( CapacityAuditor.class); private final MeterRegistry meterRegistry; // 为什么偏差阈值设 30% 而非 10%: // AI 流量的波动性远大于传统 web 应用, // 10% 的阈值会产生大量无意义告警 private static final double DEVIATION_THRESHOLD = 0.30; @Scheduled(cron = "0 0 10 * * MON") public void auditWeekly() { AiTrafficModeler.TrafficModel predicted = buildPredictedModel(); AiTrafficModeler.TrafficModel actual = buildActualModel(); double qpsDeviation = Math.abs( actual.getPeakQps() - predicted.getPeakQps()) / predicted.getPeakQps(); if (qpsDeviation > DEVIATION_THRESHOLD) { log.warn("容量预测偏差过大, predictedQps={}, actualQps={}, " + "deviation={:.2%}", predicted.getPeakQps(), actual.getPeakQps(), qpsDeviation); } double tokenDeviation = Math.abs( actual.getAvgTokensPerRequest() - predicted.getAvgTokensPerRequest()) / (double) predicted.getAvgTokensPerRequest(); if (tokenDeviation > DEVIATION_THRESHOLD) { log.warn("Token消耗预测偏差, predictedAvg={}, actualAvg={}, " + "deviation={:.2%}", predicted.getAvgTokensPerRequest(), actual.getAvgTokensPerRequest(), tokenDeviation); } } private AiTrafficModeler.TrafficModel buildPredictedModel() { // 读取上周生成的预测模型 return new AiTrafficModeler.TrafficModel( 10000, 50, 500, 5000, 9, 21, 0.05); } private AiTrafficModeler.TrafficModel buildActualModel() { // 读取上周实际流量数据 return new AiTrafficModeler.TrafficModel( 11000, 55, 520, 5500, 9, 21, 0.05); } }六、总结
AI 应用的容量规划需要建立两个核心模型:流量模型(描述请求特征和时序规律)和资源模型(描述 GPU 利用率与 Token 吞吐的关系)。在模型基础上,通过 On-Demand + Spot 实例的混合部署策略,配合定时缩容和优先级调度,可以显著降低 GPU 资源成本。
建议从数据的可观测性入手——先建立完善的流量和资源消耗的 Metrics 采集,积累至少一个月的运行数据后,再进行首次容量规划建模。后续通过周度审计和季度压测持续优化模型的准确性。
Token 分布的厚尾效应与容量冗余
AI 应用的 Token 消耗分布呈现典型的厚尾效应——P50 请求仅消耗 300 Token,P95 为 2500 Token,但 P99 可达到 12000 Token。如果按 P50 设计容量,P99 的请求会因 KV Cache 不足而排队,导致级联延迟。推荐以 P90+P99 的加权平均值(权重 0.7:0.3)作为单请求资源消耗的规划基准。这个值约等于0.7 × P90_Tokens + 0.3 × P99_Tokens,在我们的场景中为0.7 × 1800 + 0.3 × 12000 = 4860Token/请求。
此外,同一模型的不同 Prompt 模板之间 Token 消耗差异巨大。例如客服场景中,"标准问答"模板的 Prompt 约 400 Token,但"多轮追踪"模板(携带完整对话历史)可达 3000 Token。容量规划时需要区分模板类型——使用prompt_template_type标签对各模板的 Token 分布分别建模,而非使用全局平均值。
Spot 实例回收的优雅处理
Spot 实例可节省 60%~70% 的 GPU 成本,但面临随时被回收的风险。Kubernetes 会在 Spot 实例回收前 2 分钟发送 SIGTERM,模型推理服务需要在这 2 分钟内完成:(1) 停止接受新请求(Readiness Probe 切换为 FAIL);(2) 优雅处理进行中的请求——对于短请求(<30s),等待其完成;对于长请求,返回部分结果 + 重试标识,由客户端重试到其他 Pod;(3) 清理 KV Cache 显存。关键是在 Pod 的preStophook 中调用推理服务的优雅关闭 API,而不是直接 kill 进程。我们已经将实现集成到 K8s 的terminationGracePeriodSeconds=120+preStop.exec.command中,Spot 实例回收时的请求错误率从 8% 降至 0.5%。
