负载测试不是压到挂推理服务要找到拐点而不是崩溃点一、我们在压测时犯的最大的错把压挂了当成测试目标很多团队做负载测试的流程是这样的起一个 Vegeta 或 wrkQPS 从 100 一路加到 5000看到服务报 502 了截图写报告——服务最大吞吐 4500 QPS。然后心安理得地把这个数字写进容量规划文档。这根本不是负载测试这是破坏实验。真正的负载测试要回答三个问题服务在什么 QPS 下开始出现第一个 5xx 错误拐点从出现拐点到服务崩溃之间有多少缓冲空间韧性带宽在拐点之前的稳定区间内延迟分布是什么样子的用户体验基线崩溃点是一个一次性数据拐点才是有工程价值的容量边界。如果你不知道服务什么时候开始不舒服你永远无法做出合理的扩缩容决策、告警阈值和 SLO 定义。二、推理服务的负载模型与拐点定位方法推理服务不同于普通 HTTP 服务它的负载模型有两个特征请求处理时间差异极大简单问题 200ms复杂问题 15s、对 GPU 显存极度敏感并发过高时 KV Cache 挤爆显存。graph LR subgraph 负载注入 A[阶梯式 QPS 增长] -- B[每级持续 5 分钟] end subgraph 实时观测 B -- C[延迟: P50/P95/P99] B -- D[错误率: 4xx 5xx] B -- E[GPU 显存使用率] B -- F[排队长度] end subgraph 拐点判定 C -- G{P99 SLO 阈值?} D -- H{错误率 0.1%?} E -- I{显存 90%?} G -- J[标记拐点 QPS] H -- J I -- J end subgraph 韧性带宽 J -- K[继续加压] K -- L{服务崩溃?} L --|否| K L --|是| M[崩溃 QPS - 拐点 QPS 韧性带宽] end拐点定位的核心指标P99 延迟突变点不是 P50不是 P95是 P99。在推理服务中长尾请求复杂推理链、大批量 Token 生成最先感受到压力。P50 在拐点之前可能毫无变化。GPU 显存临界点推理服务的真实 QPS 上限通常不是 CPU 或带宽而是 GPU 显存。当并发请求的 KV Cache 总和超过可用显存时CUDA OOM 会瞬间杀死服务——这不是渐进式退化而是悬崖式崩溃。请求排队长度在应用层维护的请求队列长度。当排队时间超过处理时间时继续加压只会制造更多的超时。三、生产级负载测试脚本Go Vegeta 的组合方案Vegeta 是 HTTP 负载测试的标准工具但直接用命令行跑无法实现阶梯式加压和拐点检测。以下 Go 代码包装了 Vegeta实现自动化的拐点定位。package main import ( fmt os time vegeta github.com/tsenart/vegeta/v12/lib ) // LoadTestConfig 定义阶梯式负载测试的参数 type LoadTestConfig struct { TargetURL string // 目标 URL Method string // HTTP Method BodyFile string // 请求 Body 文件路径JSONL 格式 StartQPS int // 起始 QPS EndQPS int // 结束 QPS StepQPS int // 每级步长 StepDuration time.Duration // 每级持续时间 P99Threshold time.Duration // P99 拐点阈值 } // LoadTestResult 包含一次完整测试的全部结果 type LoadTestResult struct { KneePointQPS int // 拐点 QPS CrashPointQPS int // 崩溃点 QPS0 表示未崩溃 ResilienceBand int // 韧性带宽崩溃 - 拐点 LevelResults []LevelResult // 每级详细指标 } type LevelResult struct { QPS int P50 time.Duration P95 time.Duration P99 time.Duration ErrorRate float64 SuccessRate float64 } // KneeDetector 拐点检测器 type KneeDetector struct { config LoadTestConfig } func NewKneeDetector(config LoadTestConfig) *KneeDetector { return KneeDetector{config: config} } // Run 执行阶梯式负载测试返回包含拐点和韧性带宽的结果 func (kd *KneeDetector) Run() (*LoadTestResult, error) { result : LoadTestResult{} kneeFound : false for qps : kd.config.StartQPS; qps kd.config.EndQPS; qps kd.config.StepQPS { levelResult, err : kd.runLevel(qps) if err ! nil { return nil, fmt.Errorf(QPS%d 测试失败: %w, qps, err) } result.LevelResults append(result.LevelResults, *levelResult) fmt.Printf(QPS%d | P50%v P99%v ErrRate%.2f%%\n, qps, levelResult.P50, levelResult.P99, levelResult.ErrorRate*100) // 拐点判定P99 超过阈值 或 错误率 0.1% if !kneeFound (levelResult.P99 kd.config.P99Threshold || levelResult.ErrorRate 0.001) { result.KneePointQPS qps kneeFound true fmt.Printf( 发现拐点: QPS%d\n, qps) // 继续加压寻找崩溃点 } // 崩溃判定成功率 70% if levelResult.SuccessRate 0.7 { result.CrashPointQPS qps break } } if result.KneePointQPS 0 { return nil, fmt.Errorf(在测试范围内未找到拐点建议扩大 EndQPS 或降低 P99Threshold) } if result.CrashPointQPS 0 { result.ResilienceBand result.CrashPointQPS - result.KneePointQPS } return result, nil } // runLevel 在指定 QPS 下运行一轮测试 func (kd *KneeDetector) runLevel(qps int) (*LevelResult, error) { rate : vegeta.Rate{Freq: qps, Per: time.Second} // 从文件加载多个请求 Body模拟真实流量多样性 targeter : vegeta.NewJSONTargeter( vegeta.Target{ Method: kd.config.Method, URL: kd.config.TargetURL, Body: []byte({prompt: 解释 Kubernetes 的核心组件,max_tokens: 512}), Header: map[string][]string{Content-Type: {application/json}}, }, // 生产环境应从文件加载多个 Body 变体 ) attacker : vegeta.NewAttacker( vegeta.Timeout(30*time.Second), vegeta.Workers(uint64(qps/1010)), // worker 数动态调整 ) var metrics vegeta.Metrics for res : range attacker.Attack(targeter, rate, kd.config.StepDuration, load-test) { metrics.Add(res) } metrics.Close() // 提取关键分位数 p50 : metrics.Latencies.P50 p95 : metrics.Latencies.P95 p99 : metrics.Latencies.P99 errRate : 0.0 if metrics.Requests 0 { errCount : float64(metrics.StatusCodes[500] metrics.StatusCodes[502] metrics.StatusCodes[503] metrics.StatusCodes[504]) errRate errCount / float64(metrics.Requests) } return LevelResult{ QPS: qps, P50: p50, P95: p95, P99: p99, ErrorRate: errRate, SuccessRate: metrics.Success, }, nil } func main() { config : LoadTestConfig{ TargetURL: http://inference-service/v1/chat/completions, Method: POST, StartQPS: 10, EndQPS: 200, StepQPS: 10, StepDuration: 5 * time.Minute, P99Threshold: 5 * time.Second, // P99 5s 即视为拐点 } detector : NewKneeDetector(config) result, err : detector.Run() if err ! nil { fmt.Fprintf(os.Stderr, 测试失败: %v\n, err) os.Exit(1) } fmt.Printf(\n 负载测试报告 \n) fmt.Printf(拐点 QPS: %d\n, result.KneePointQPS) fmt.Printf(崩溃 QPS: %d\n, result.CrashPointQPS) fmt.Printf(韧性带宽: %d QPS (%.0f%%)\n, result.ResilienceBand, float64(result.ResilienceBand)/float64(result.KneePointQPS)*100) fmt.Printf(\n容量建议: 推荐最大 QPS 为拐点的 70%%即 %d QPS\n, int(float64(result.KneePointQPS)*0.7)) }设计考量阶梯式加压而非线性增长推理服务需要时间预热模型加载、KV Cache 填充瞬时跳变无法反映真实负载模式。多 Body 变体真实流量的 Prompt 长度、MaxToken 分布差异巨大。用单一 Prompt 压测相当于只用晴天测试雨伞。生产环境应从生产日志中采样真实请求作为 Vegeta 的输入。容量建议 拐点 × 70%在拐点的 70% 处运行保留 30% 的缓冲应对突发流量。这比崩溃点 × 50%更合理——后者可能给出过于保守的数字。四、负载测试的边界与常见误区预热期的掩盖效应。推理服务在刚启动时GPU 显存尚未被长连接占满、模型还未充分预热。如果从 QPS10 开始加压第一个阶梯的结果往往过于乐观。建议在正式测试开始前先以基准 QPS 预热 10 分钟让服务进入稳态。拐点的多重定义。不同服务的拐点定义应不同。在线对话场景应以 P99 延迟为首要拐点指标批量推理场景应以吞吐量拐点处理能力不再随并发线性增长为主要指标。不要在需要吞吐的场景上纠结延迟也不要在需要延迟的场景上追求吞吐。Not All QPS Are Equal。100 QPS 的短 Prompt50 tokens 输入 100 tokens 输出和 100 QPS 的长 Prompt8000 tokens 输入 2000 tokens 输出对推理服务的压力完全不在一个量级。负载测试的 Body 必须按生产流量的 Prompt 长度分布、MaxToken 分布进行加权采样。循环依赖负载测试本身也是负载。Vegeta Attacker 运行在本地机器上时网络带宽和 CPU 可能成为瓶颈而非被测服务。建议在独立的压测节点上运行 Attacker或在 Kubernetes 集群中分配专用 Pod。五、总结负载测试的目标不是压垮服务而是精确画出服务的容量边界。拐点才是工程决策的依据崩溃点只是事故报告的数据点。落地三步第一步按生产流量分布采样 200 个真实请求作为 Vegeta 的 Body 库第二步部署本文的 KneeDetector在 staging 环境阶梯式加压标定拐点 QPS第三步将拐点 QPS × 70% 写入 HPA 的maxReplicas计算公式和告警阈值。基础设施的容量规划不能靠感觉需要的是精确的拐点数据和基于拐点的安全边际。压垮服务很容易控制服务在拐点以内运行才是真正的工程能力。