LLM 应用压力测试:为什么 k6 和 Locust 会误导你
你运行了负载测试。k6 报告平均延迟为 200ms,P99 延迟低于 800ms,在 50 个并发用户时错误率为零。你上线到了生产环境。不到一周,用户就开始反馈 8 秒的卡顿、连接中断以及流式输出中途 Token 预算耗尽。发生了什么?
测试之所以通过,是因为你衡量错了指标。传统的负载测试工具是为在几毫秒内返回完整响应的无状态 HTTP 端点设计的。LLM API 的行为与这些工具所建模的完全不同:它们在几秒钟内流式传输 Token,按 Token 而非请求计费,消耗的是 GPU 显存而非 CPU 线程,并且响应速度完全取决于缓存是否命中。一个对 /chat/completions 端点进行压测的 k6 脚本产生的数据看起来像是性能数据,但实际上几乎无法反映生产环境的真实情况。
根本性的不匹配
经典的负载测试只有一个任务:增加并发请求直到服务器崩溃,然后记录延迟百分位和错误率。 其心理模型是 Web 服务器返回 HTML —— 速度快、无状态、响应大小以 KB 为单位。
LLM 打破了该模型中的每一个假设。
响应不是原子性的。 单个请求会在几秒钟内流式传输成百上千个 Token。记录“请求耗时”的测试工具记录的是从第一个字节到最后一个字节的墙钟时间。这个数字混淆了两个完全不同的性能特征:模型开始生成所需的时间(首字延迟,Time to First Token,简称 TTFT)以及开始生成后的速度(每秒 Token 数)。对于使用聊天界面的用户来说,TTFT 慢但生成速度快的系统会让人感觉已经挂掉了。而 TTFT 快但生成速度慢的系统对于对话场景还可以接受,但对于生成长文档则完全无法使用。平均延迟无法捕捉到这两者中的任何一个。
请求成本不均匀。 在传统 API 上,一个“请求”基本上就是一单位请求。但在 LLM API 上,一个请求可能消耗 150 个 Token,而另一个可能消耗 4,000 个 —— 在 GPU 时间、显存压力和 API 成本上存在 26 倍的差异。如果你的负载测试反复向同一个端点发送相同的简短问候语,那么你测试的是一种在生产环境中根本不存在的工作负载。真实的流量具有分布特性:短查询、长上下文检索、带有累积历史的多轮对话。使用统一的合成提示词进行测试产生的数据仅适用于该特定的合成工作负载。
瓶颈在于 GPU 显存,而非 CPU 线程。 传统负载测试中的并发意味着线程或连接争夺 CPU 和网络资源。在 LLM 推理中,并发意味着 KV 缓存槽位(KV cache slots)争夺 GPU 显存。随着并发量的增加,每秒 Token 数的吞吐量会持续增长,直到 GPU 的 KV 缓存填满,然后性能会急剧下降,而不是逐渐衰减。这种饱和曲线是陡峭且非线性的 —— 40 个并发用户可能运行良好,而 45 个可 能就是灾难性的。标准工具会向你展示错误率何时上升,但它们不会告诉你 GPU 显存压力在错误出现前五分钟就已经开始积聚。
缓存状态决定一切。 对于长共享提示词(系统提示词、文档上下文、few-shot 示例),热前缀缓存(warm prefix cache)可以将 TTFT 降低 85%,并将成本降低 90%。冷缓存测试看起来与热缓存测试完全像是两个不同的系统。如果你的负载测试从一开始就发送全新的请求,那么你测试的仅仅是冷缓存行为。如果你在生产环境中启用了任何前缀缓存,你的测试结果将与生产环境的实际表现大相径庭。
真正重要的指标
在讨论工具之前,先明确你要衡量什么。
首字延迟 (TTFT) 是交互式应用的主要用户体验指标。它衡量发送请求与接收到第一个流式 Token 之间的间隔。对于聊天界面,目标应在 500ms 以下;对于提供实时补全的代码助手,通常需要低于 200ms。该指标反映了预填充延迟(prefill latency)—— 即模型在开始生成之前处理输入所花费的时间。
Token 间延迟 (ITL) 衡量流式响应中连续 Token 之间的时间。超过 100ms 会产生明显的卡顿;超过 50ms 对于细心的用户来说就是可感知的。ITL 反映了解码速度,以及在负载下的队列深度。当服务器过载时,ITL 会比 TTFT 更早飙升 —— 这使其成为一个早期预警指标。
每秒 Token 数 (TPS) 是你的吞吐量指标,而不是每秒请求数 (RPS)。由于响应长度各不相同,RPS 是一个具有误导性的代理指标。一个处理 1,000 RPS 且每个响应为 10 Token 的系统,与一个处理 1,000 RPS 且每个响应为 1,000 Token 的系统截然不同。跟踪整个集群的总 TPS 以及每个请求的 TPS,以了解生成速度。
有效吞吐量 (Goodput) 是在你的 SLO 延迟目标内完成的请求比例。在负载下,95% 有效吞吐量的系统与 99% 有效吞吐量的系统有着本质的区别。这是你在容量规划模型中应该跟踪的指标,而不是平均延迟。
单次请求成本分布 应该出现在你的负载测试输出中,而不仅仅是账单仪表盘上。在负载下,重试、超时和被放弃的请求会消耗 Token 但不产生价值。忽略 Token 经济学的负载测试产生的吞吐量数字可能看起来很健康,但却掩盖了 20% 的 Token 被浪费在用户看到响应前就已超时的请求上这一事实。
为什么传统工具力有不逮
k6 在其设计的领域表现卓越。其基于 Go 的并发机制能高效处理高请求量,并能干净地集成到 CI 管道中。但它将每个请求视为一个单元,记录总响应时间,且对流式传输(streaming)缺乏原生理解。用 k6 脚本测量 LLM 延迟只能测得从请求到最后一个字节的时间 —— 这告诉你总的生成时间,但对用户体验的形态却一无所知。
Locust 在处理 LLM 工作负载时还有一个特定的问题:Python 的全局解释器锁(GIL)。准确测量 Token 级别的性能需要对流式响应进行分词(Tokenization,或者至少是字节流分析)。这是在 GIL 下运行的 CPU 密集型工作,这意味着分词过程会与请求生成过程竞争资源。在高并发下,分词积压会使测量结果失真 —— 你的基准测试报告的逐 Token 延迟(inter-token latency)会人为升高,这并非因为服务器慢,而是因为你的测试客户端遇到了瓶颈。
这两种工具还都面临 Prompt 统一化的问题。编写一个循环发送相同 Prompt 的脚本很容易。但真实的生产流量是多样化的:不同的 Prompt 长度、不同的上下文大小、不同的输出长度分布。使用统一 Prompt 的测试只测试了 Token 分布中的一个点,而非完整的工作负载。
构建一个真正有参考价值的 LLM 压力测试
使用真实的 Prompt 语料库。 从真实的生产日志中采样(在脱敏个人隐私信息后),构建一个反映输入长度和类型真实分布的测试数据集。如果没有生产日志,可以构建一个具有代表性方差的合成数据集:短查询(50-100 Token)、中等上下文请求(500-1000 Token)以及长上下文请求(2000+ Token),比例应匹配你预期的工作负载。
区分 TTFT 和生成吞吐量的测量。 调整你的测试客户端,分别记录接收到第一个字节的时间戳和最后一个字节的时间戳。你两者都需要,因为它们反映了系统不同部分的不同情况。
分别测试热缓存和冷缓存,然后进行混合测试。 在测量窗口开始前,运行一个专门的预热阶段来填充前缀缓存(prefix caches)。然后运行冷缓存测试、热缓存测试,以及通过显式控制缓存命中率来进行混合测试。如果热性能和冷性能之间存在巨大差距,你的生产环境表现将严重依赖 于随时间变化的流量模式、查询多样性和缓存配置 —— 你的容量规划需要将所有这些因素考虑在内。
在饱和点附近进行精细化的并发扫描。 KV 缓存的饱和曲线非常陡峭。不要直接从 10 跳到 50 再到 100 个并发用户。以 10、20、30、40、45、50 的步长进行测试 —— 40 和 45 之间的差异可能就是 400ms TTFT 与 4000ms TTFT 的区别。一旦找到饱和点,在 80% 的负载下运行持续压力测试,以验证其是否为稳定的运行点。
运行至少 4 小时的浸泡测试(Soak tests)。 渐进式的内存泄漏、KV 缓存碎片化以及连接池耗尽都会随着时间的推移而显现。10 分钟的压力测试无法捕捉到这些问题。周期性的 TTFT 峰值和缓慢增长的逐 Token 延迟是典型的症状。
容量规划的计算方法
一旦你获得了指标,规划模型就很简单了,但这一步经常被跳过。
获取你的峰值 TPS 目标(峰值负载下所有用户的总每秒 Token 数),除以系统在 80% 饱和并发下提供的 TPS,即可得到峰值所需的副本数或 GPU 实例数。添加一个预留系数(通常为 1.5–2 倍)以应对突发流量。这个数字就是你的最低配置容量。
让团队栽跟头的地方在于输入 Token 和输出 Token 之间的区别。输入 Token 处理(Prefill)是并行且快速的;输出 Token 生成(Decoding)是串行且缓慢的。它们在同一硬件上具有不同的吞吐量特性。你的容量模型需要分别估算 Prefill 吞吐量(每秒输入 Token 数)和 Decode 吞吐量(每秒输出 Token 数),并且你需要知道哪一个是你的工作负载的性能 瓶颈。输入较长的文档密集型工作负载通常受限于 Prefill;输入短、输出长的聊天应用通常受限于 Decode。
来自 API 提供商的速率限制添加了第三个变量。每分钟 Token 数(TPM)和每分钟请求数(RPM)配额是独立运行的,且可能同时达到上限。你的压力测试不仅应验证系统在自定义负载下的表现是否正确,还应验证你的重试和退避逻辑是否能优雅地处理提供商的速率限制响应(HTTP 429)。简单的重试逻辑如果在收到 429 后立即重试,可能会将临时的速率限制演变为持续的过载,从而更快地耗尽配额。
压力测试本身的成本
团队往往很晚才发现一个现实问题:针对线上端点进行 LLM API 压力测试非常昂贵。在中等并发下进行一次真实的浸泡测试可能会消耗数百万个 Token。按照标准 API 价格计算,一个彻底的压力测试计划每月可能耗资数万美元 —— 而其中大部分支出并不会产生用户价值。
解决方案是使用模拟(Mock)LLM 服务,在不消耗 Token 的情况下模拟真实的延迟分布。一个设计良好的 Mock 服务返回的响应应具有可配置的 TTFT 和 ITL,这些数据来自匹配生产环境观察结果的分布,支持流式传输,并能根据需求注入故障场景(速率限制、超时、部分响应)。你在 Mock 服务上验证应用程序在负载下的行为,并将实测提供商测试保留给容量预留的最终验证。
这不仅是为了节省成本。实测提供商测试会引入来自共享基础设施、区域流量变化以及提供商侧变更的环境噪声,这使得你无法将应用程序的行为与外部因素隔离开 来。Mock 服务为你提供确定性的、可重复的结果,让你能够自信地将性能变化归因于你自己的代码更改。
当负载测试失效时
没有任何负载测试能完美地预测生产环境。常见的失效模式有两种。
第一,你的测试流量在影响缓存的维度上与生产流量不匹配。如果你的测试使用多样化的随机 Prompt,缓存命中率将接近于零。如果生产环境存在常见模式(共享的系统 Prompt、重复查询),缓存命中率可能达到 60–80%。结果是生产性能显著优于负载测试的预测 —— 这听起来不错,但意味着你的容量预估过于保守,从而导致不必要的资金浪费。
第二,你的测试没有捕捉到多轮对话状态。负载测试通常测试单个请求。多轮会话在每一轮中累积上下文,这意味着每个请求的 Token 计数会随着对话的进行而增加。一个衡量新鲜单轮请求的测试将无法发现当用户处于对话的第 10 轮且累积上下文达到 8,000 Token 时出现的 TTFT 性能下降。
这两种失效都源于同一个根本原因:负载测试告诉你的是系统在测试负载下的表现,而不是在生产负载下的表现。你越能让这两者匹配 —— 通过真实的 Prompt 语料库、准确的缓存状态建模和对话模拟 —— 你的测试结果就越有用。
LLM 应用的负载测试从根本上并不比传统应用更难。它只是需要测量不同的指标、对不同的约束进行建模,并抵制那种“虽然测试工具报告的数据很漂亮,但其建模的基础负载与用户实际行为几乎没有相似之处,却依然选择相信它”的诱惑。
- https://blog.premai.io/load-testing-llms-tools-metrics-realistic-traffic-simulation-2026/
- https://reintech.io/blog/llm-load-testing-benchmark-ai-application-production
- https://gatling.io/blog/load-testing-an-llm-api
- https://www.truefoundry.com/blog/llm-locust-a-tool-for-benchmarking-llm-performance
- https://engineering.salesforce.com/how-a-mock-llm-service-cut-500k-in-ai-benchmarking-costs-boosted-developer-productivity/
- https://portkey.ai/blog/rate-limiting-for-llm-applications/
- https://www.typedef.ai/resources/handle-token-limits-rate-limits-large-scale-llm-inference
- https://bentoml.com/llm/inference-optimization/llm-performance-benchmarks
