AI应用压测方法论:LLM服务性能测试的完整实践
2026/4/30大约 8 分钟
AI应用压测方法论:LLM服务性能测试的完整实践
适读人群:负责AI应用上线前性能验证的Java工程师和测试工程师 阅读时长:约20分钟
那次让我在凌晨两点盯屏幕的压测
去年我们一个AI问答系统上线前,老板问我:"并发100个用户没问题吧?"
我当时很自信,因为代码写完了,本地测了几十次都很好。"没问题,完全没问题。"
上线第一天,用户量爬到并发30的时候,响应时间从平均3秒飙到了40秒,然后开始大量超时。客户端全部显示"请求失败",后端日志里OpenAI的报错和我们自己的超时异常刷屏。
事后复盘,问题出在我们的连接池配置错了,同时还有一个问题是:LLM服务的压测和传统API完全不一样,我压根没想到要测"Token吞吐量"这个指标。
这次翻车之后,我花了两个月时间专门研究LLM应用的压测方法论。今天把完整的体系分享出来,希望你比我少踩一些坑。
LLM压测与传统API压测的本质差异
先明确为什么LLM压测是个特殊问题:
| 对比维度 | 传统API压测 | LLM服务压测 |
|---|---|---|
| 响应时间特征 | 毫秒级,分布集中 | 秒到分钟级,长尾严重 |
| 核心指标 | QPS、RT、错误率 | QPS、TTFB、TPS(Token/s)、Token吞吐量 |
| 负载特征 | 请求大小相近 | Prompt长度差异大,影响计算量 |
| 超时设置 | 几百毫秒到几秒 | 通常10秒到数分钟 |
| 服务降级 | 返回错误码 | 可能截断输出,需要检测 |
| 上游限制 | 通常无 | Rate Limit(RPM/TPM)是核心约束 |
| 压测工具 | JMeter/Gatling/k6 | 需要自定义插件或专门工具 |
最关键的差异:传统API,"并发100"意味着同时处理100个请求。LLM接口,"并发100"意味着要同时维持100个长连接、追踪100路流式响应,对客户端的压力远超传统场景。
完整压测指标体系
压测工具选型
| 工具 | 适用场景 | LLM支持程度 | 学习成本 |
|---|---|---|---|
| JMeter | 传统场景,团队熟悉 | 低(需要插件) | 低 |
| Gatling | 代码化压测,Java/Scala | 中(需要自定义) | 中 |
| k6 | 现代化、JS脚本 | 中(支持SSE插件) | 中 |
| Locust | Python,LLM压测最友好 | 高 | 低 |
| 自研工具 | 精确控制Token指标 | 最高 | 高 |
我的推荐:用自研Java压测工具,和你的Spring Boot项目集成,可以精确测量每个指标,还能复用你的业务逻辑代码。
自研LLM压测框架实现
核心指标收集器
/**
* LLM请求指标,追踪单次请求的完整性能数据
*/
@Data
@Builder
public class LlmRequestMetrics {
private String requestId;
private long startTimeMs;
// 首Token时间(TTFB: Time To First Byte)
// 对于流式响应,这是用户感知到的"响应速度"
private long firstTokenTimeMs;
// 最后Token时间(TTLT: Time To Last Token)
private long lastTokenTimeMs;
// Token统计
private int promptTokens;
private int completionTokens;
// 状态
private boolean success;
private String errorType; // TIMEOUT / RATE_LIMIT / MODEL_ERROR / NETWORK_ERROR
private String errorMessage;
// 计算指标
public long getTtfb() {
return firstTokenTimeMs > 0 ? firstTokenTimeMs - startTimeMs : -1;
}
public long getTotalLatency() {
return lastTokenTimeMs > 0 ? lastTokenTimeMs - startTimeMs : -1;
}
public double getTokensPerSecond() {
long genTime = lastTokenTimeMs - firstTokenTimeMs;
if (genTime <= 0 || completionTokens <= 0) return 0;
return (double) completionTokens / genTime * 1000;
}
}
/**
* 统计聚合器
* 实时计算P50/P95/P99等分位数
*/
@Component
@Slf4j
public class MetricsAggregator {
private final List<LlmRequestMetrics> allMetrics = new CopyOnWriteArrayList<>();
private final AtomicLong totalRequests = new AtomicLong(0);
private final AtomicLong successRequests = new AtomicLong(0);
private final AtomicLong totalTokens = new AtomicLong(0);
// 每秒请求数的滑动窗口统计
private final ConcurrentLinkedDeque<Long> recentRequestTimestamps = new ConcurrentLinkedDeque<>();
public void record(LlmRequestMetrics metrics) {
allMetrics.add(metrics);
totalRequests.incrementAndGet();
recentRequestTimestamps.add(System.currentTimeMillis());
if (metrics.isSuccess()) {
successRequests.incrementAndGet();
totalTokens.addAndGet(metrics.getCompletionTokens());
}
// 清理超过60秒的时间戳
long cutoff = System.currentTimeMillis() - 60000;
recentRequestTimestamps.removeIf(ts -> ts < cutoff);
}
public PressureTestReport generateReport() {
List<LlmRequestMetrics> successMetrics = allMetrics.stream()
.filter(LlmRequestMetrics::isSuccess)
.collect(Collectors.toList());
if (successMetrics.isEmpty()) {
return PressureTestReport.empty();
}
// 计算延迟分位数
List<Long> latencies = successMetrics.stream()
.map(LlmRequestMetrics::getTotalLatency)
.filter(l -> l > 0)
.sorted()
.collect(Collectors.toList());
List<Long> ttfbs = successMetrics.stream()
.map(LlmRequestMetrics::getTtfb)
.filter(t -> t > 0)
.sorted()
.collect(Collectors.toList());
return PressureTestReport.builder()
.totalRequests(totalRequests.get())
.successRequests(successRequests.get())
.successRate(successRequests.get() * 100.0 / totalRequests.get())
// 延迟指标
.latencyP50(percentile(latencies, 50))
.latencyP95(percentile(latencies, 95))
.latencyP99(percentile(latencies, 99))
.latencyMax(latencies.isEmpty() ? 0 : latencies.get(latencies.size() - 1))
// TTFB指标
.ttfbP50(percentile(ttfbs, 50))
.ttfbP95(percentile(ttfbs, 95))
// 吞吐量指标
.totalTokens(totalTokens.get())
.avgTokensPerSecond(successMetrics.stream()
.mapToDouble(LlmRequestMetrics::getTokensPerSecond)
.average().orElse(0))
// 错误分析
.errorBreakdown(analyzeErrors())
.build();
}
public double getCurrentRps() {
long cutoff = System.currentTimeMillis() - 1000;
return recentRequestTimestamps.stream().filter(ts -> ts >= cutoff).count();
}
private long percentile(List<Long> sorted, int percentile) {
if (sorted.isEmpty()) return 0;
int index = (int) Math.ceil(percentile / 100.0 * sorted.size()) - 1;
return sorted.get(Math.max(0, Math.min(index, sorted.size() - 1)));
}
private Map<String, Long> analyzeErrors() {
return allMetrics.stream()
.filter(m -> !m.isSuccess())
.collect(Collectors.groupingBy(
m -> m.getErrorType() != null ? m.getErrorType() : "UNKNOWN",
Collectors.counting()
));
}
}压测执行引擎
/**
* LLM压测执行器
* 支持阶梯加压、恒定并发、爬坡压测等多种模式
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class LlmLoadTestEngine {
private final ChatClient chatClient;
private final MetricsAggregator metricsAggregator;
/**
* 执行阶梯加压测试
* 从低并发逐渐加到高并发,找出系统拐点
*/
public void runStepLoadTest(StepLoadConfig config) throws InterruptedException {
log.info("开始阶梯压测: startConcurrency={}, maxConcurrency={}, stepSize={}, stepDuration={}s",
config.getStartConcurrency(), config.getMaxConcurrency(),
config.getStepSize(), config.getStepDurationSeconds());
for (int concurrency = config.getStartConcurrency();
concurrency <= config.getMaxConcurrency();
concurrency += config.getStepSize()) {
log.info("=== 当前并发数: {} ===", concurrency);
ExecutorService executor = Executors.newFixedThreadPool(concurrency);
CountDownLatch stepLatch = new CountDownLatch(concurrency);
AtomicBoolean stepRunning = new AtomicBoolean(true);
// 启动并发用户
for (int i = 0; i < concurrency; i++) {
final int userId = i;
executor.submit(() -> {
try {
while (stepRunning.get()) {
executeRequest(userId, config.getTestMessages());
}
} finally {
stepLatch.countDown();
}
});
}
// 运行指定时间
Thread.sleep(config.getStepDurationSeconds() * 1000L);
stepRunning.set(false);
stepLatch.await(30, TimeUnit.SECONDS);
executor.shutdown();
// 打印当前阶段报告
PressureTestReport report = metricsAggregator.generateReport();
log.info("阶段报告 [并发{}] 成功率: {:.1f}%, P95延迟: {}ms, P99延迟: {}ms, 当前RPS: {:.1f}",
concurrency, report.getSuccessRate(),
report.getLatencyP95(), report.getLatencyP99(),
metricsAggregator.getCurrentRps());
// 如果成功率低于阈值,停止加压
if (report.getSuccessRate() < config.getMinSuccessRate()) {
log.warn("成功率低于阈值 {}%,停止加压", config.getMinSuccessRate());
break;
}
}
// 输出最终报告
printFinalReport(metricsAggregator.generateReport());
}
/**
* 执行单次请求并记录指标
*/
private void executeRequest(int userId, List<String> testMessages) {
String message = testMessages.get(userId % testMessages.size());
String requestId = UUID.randomUUID().toString();
LlmRequestMetrics.LlmRequestMetricsBuilder metricsBuilder = LlmRequestMetrics.builder()
.requestId(requestId)
.startTimeMs(System.currentTimeMillis());
AtomicBoolean firstTokenReceived = new AtomicBoolean(false);
AtomicInteger tokenCount = new AtomicInteger(0);
try {
StringBuilder fullResponse = new StringBuilder();
chatClient.prompt()
.user(message)
.stream()
.content()
.doOnNext(chunk -> {
if (!firstTokenReceived.getAndSet(true)) {
metricsBuilder.firstTokenTimeMs(System.currentTimeMillis());
}
tokenCount.addAndGet(estimateTokens(chunk));
fullResponse.append(chunk);
})
.blockLast(Duration.ofSeconds(60)); // 最大等待60秒
metricsBuilder
.lastTokenTimeMs(System.currentTimeMillis())
.completionTokens(tokenCount.get())
.success(true);
} catch (Exception e) {
metricsBuilder
.lastTokenTimeMs(System.currentTimeMillis())
.success(false)
.errorType(classifyError(e))
.errorMessage(e.getMessage());
}
metricsAggregator.record(metricsBuilder.build());
}
private String classifyError(Exception e) {
String msg = e.getMessage() != null ? e.getMessage().toLowerCase() : "";
if (msg.contains("timeout") || msg.contains("timed out")) return "TIMEOUT";
if (msg.contains("rate limit") || msg.contains("429")) return "RATE_LIMIT";
if (msg.contains("context length") || msg.contains("token")) return "TOKEN_LIMIT";
if (msg.contains("connection") || msg.contains("network")) return "NETWORK_ERROR";
return "MODEL_ERROR";
}
private int estimateTokens(String text) {
// 粗略估算:中文约1.5字/token,英文约4字/token
if (text == null) return 0;
return Math.max(1, text.length() / 3);
}
private void printFinalReport(PressureTestReport report) {
log.info("""
========= 压测最终报告 =========
总请求数: {}
成功率: {:.2f}%
延迟分布:
P50: {}ms
P95: {}ms
P99: {}ms
MAX: {}ms
首Token时间(TTFB):
P50: {}ms
P95: {}ms
Token吞吐:
总Token数: {}
平均TPS: {:.1f} tokens/s
错误分布: {}
================================
""",
report.getTotalRequests(),
report.getSuccessRate(),
report.getLatencyP50(), report.getLatencyP95(),
report.getLatencyP99(), report.getLatencyMax(),
report.getTtfbP50(), report.getTtfbP95(),
report.getTotalTokens(), report.getAvgTokensPerSecond(),
report.getErrorBreakdown()
);
}
}压测配置和测试用例
/**
* 压测配置和实际测试用例
* 测试用例要模拟真实的业务场景分布
*/
@Configuration
public class LoadTestConfig {
/**
* 阶梯压测配置
*/
@Bean
public StepLoadConfig defaultStepConfig() {
return StepLoadConfig.builder()
.startConcurrency(5) // 从5并发开始
.maxConcurrency(50) // 最高测到50并发
.stepSize(5) // 每次增加5个并发
.stepDurationSeconds(60) // 每个阶段持续60秒
.minSuccessRate(95.0) // 低于95%成功率则停止
// 测试消息集合,按业务场景分布
.testMessages(buildTestMessages())
.build();
}
private List<String> buildTestMessages() {
List<String> messages = new ArrayList<>();
// 短问题(占40%):简单查询
for (int i = 0; i < 40; i++) {
messages.add("什么是" + List.of("向量数据库", "RAG", "Transformer", "注意力机制", "微调").get(i % 5) + "?请简要回答。");
}
// 中等长度问题(占40%):需要一定推理
for (int i = 0; i < 40; i++) {
messages.add("请帮我分析一下电商系统中使用Redis和数据库的各自优势,以及何时应该选择哪种方案。字数在200字左右。");
}
// 长问题(占20%):复杂任务
for (int i = 0; i < 20; i++) {
messages.add("请为一个Java微服务系统设计一套完整的链路追踪方案,包括:选型理由、关键组件、接入步骤,以及常见问题和解决方法。需要详细说明。");
}
return messages;
}
}压测结果分析与调优
常见问题对应方案:
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| P99延迟极高但P50正常 | 部分请求被限流/排队 | 检查RPM配置,加限流保护 |
| 并发提升后成功率骤降 | 连接池耗尽 | 增大连接池,或加熔断器 |
| TTFB高但整体延迟正常 | 请求在队列等待 | 检查线程池大小 |
| 随时间推移内存增长 | 流式响应未及时释放 | 检查Flux subscription是否正确取消 |
| 周期性超时 | 上游Token限流(TPM) | 实现令牌桶,提前限速 |
