JMeter 实战:大模型流式接口性能测试(含TTFT与Token统计)
一、背景与挑战
随着大模型(LLM)的爆发式增长,越来越多的业务系统采用流式(SSE/WebSocket)接口来提供对话生成能力。传统的HTTP请求测试工具(如JMeter默认的HTTP Sampler)无法有效处理Server-Sent Events(SSE) 的流式响应,更无法统计首Token时间(TTFT) 、生成Token数 、每秒Token数 等关键性能指标。
本文将手把手教你如何使用JMeter的 JSR223 Sampler + Groovy脚本 实现对LLM流式接口的完整压测,并输出TTFT、Token统计等专业指标。
二、基础功能:实现流式接口请求与响应查看
2.1 创建线程组与JSR223 Sampler
- 在JMeter中新建线程组(Thread Group)。
- 右键 → 添加 → Sampler → JSR223 Sampler。
- 语言选择
groovy。
2.2 配置请求信息(URL、Header、Body)
我们需要发送一个标准的POST请求,携带JSON Body,并设置SSE必需的Header:
Accept: text/event-streamContent-Type: application/json
在JSR223 Sampler的脚本区域编写如下基础代码(不含统计功能,仅实现请求与显示):
groovy
import java.net.HttpURLConnection
import java.net.URL
import java.io.*
// 从JMeter变量中获取参数(可通过用户自定义变量或CSV配置)
String authorization = vars.get("Authorization")
String cookie = vars.get("Cookie")
String conversationId = vars.get("conversationId")
String dialogueId = vars.get("dialogueId")
// 请求体
String requestBody = """
{
"conversationId": "${conversationId}",
"dialogueId": "${dialogueId}",
"modelVersion": "YAYI-V1-30B-8K",
"clientId": "jmeter_${__UUID()}"
}
"""
// 目标URL(可通过变量灵活配置)
String targetUrl = "http://your-server/lit/api/dialogue/stream_chat"
// 创建连接
URL url = new URL(targetUrl)
HttpURLConnection conn = (HttpURLConnection) url.openConnection()
conn.setRequestMethod("POST")
conn.setDoOutput(true)
conn.setConnectTimeout(30000)
conn.setReadTimeout(120000)
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Authorization", authorization)
conn.setRequestProperty("Cookie", cookie)
conn.setRequestProperty("Accept", "text/event-stream")
// 发送请求体
try (OutputStream os = conn.getOutputStream()) {
os.write(requestBody.getBytes("UTF-8"))
os.flush()
}
// 处理响应
int responseCode = conn.getResponseCode()
SampleResult.setResponseCode(String.valueOf(responseCode))
if (responseCode == 200) {
StringBuilder responseContent = new StringBuilder()
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), "UTF-8"))) {
String line
while ((line = reader.readLine()) != null) {
responseContent.append(line).append("\n")
}
}
SampleResult.setResponseData(responseContent.toString(), "UTF-8")
SampleResult.setSuccessful(true)
} else {
SampleResult.setSuccessful(false)
SampleResult.setResponseMessage("HTTP " + responseCode)
}
conn.disconnect()
三、进阶用法:统计TTFT、Token数、每秒Token数
3.1 核心指标定义
| 指标 | 含义 | 计算公式 |
|---|---|---|
| TTFT (Time To First Token) | 从请求发送完成到收到第一个有效内容块的时间 | 首次有效token时间 − 请求发送完成时间 |
| 输入Token数 | 用户发送给模型的Prompt所消耗的Token数 | 通常由服务端响应中的 input_tokens 字段提供 |
| 输出Token数 | 模型生成回复所使用的Token数 | 服务端响应的 output_tokens 或累计 content 长度估算 |
| 平均每秒Token数 | 生成阶段平均每秒产出的Token数量 | 输出Token数 / 生成耗时(从首token到结束) |
实际业务中,大模型接口通常会在流结束时返回一个包含统计信息的最终
data消息,例如:
json{"agentStatus":"stopped","usage":{"input_tokens":20,"output_tokens":150}}
3.2 完整脚本(带全指标统计)
以下脚本在基础功能之上,增加了:
- 精确的TTFT计算(基于请求发送完成时间)
- 从最终响应中提取
input_tokens/output_tokens - 计算平均每秒生成Token数(output_tokens / 生成耗时)
- 将结果存入JMeter变量,用于聚合报告或后续断言
groovy
import java.net.HttpURLConnection
import java.net.URL
import groovy.json.JsonSlurper
// ========== 配置 ==========
long TOTAL_TIMEOUT = 120000 // 整个流式响应最大等待时间(毫秒),此处设为2分钟
// 统计变量
long ttft = 0 // 首Token时间(ms)
long firstTokenTime = 0 // 首个有效token到达的绝对时间
long requestSentTime = 0 // 请求发送完成时间
long lastTokenTime = 0 // 最后一个token的到达时间
boolean firstTokenReceived = false
// 存储所有data:行内容(不含前缀)
List<String> allDataLines = new ArrayList<>()
StringBuilder rawResponse = new StringBuilder()
// 最终指标
int inputTokens = 0
int outputTokens = 0
double avgTokensPerSec = 0.0
// ========== 获取JMeter变量 ==========
String authorization = vars.get("Authorization")
String cookie = vars.get("Cookie")
String conversationId = vars.get("conversationId")
String dialogueId = vars.get("dialogueId")
String targetUrl = vars.get("stream_url") // 从用户变量中获取
// 构造请求体
String clientId = "jmeter_" + System.currentTimeMillis()
String requestBody = """
{
"conversationId": "${conversationId}",
"dialogueId": "${dialogueId}",
"modelVersion": "YAYI-V1-30B-8K",
"clientId": "${clientId}"
}
"""
SampleResult.setSamplerData(requestBody)
HttpURLConnection conn = null
try {
// 1. 创建连接
URL url = new URL(targetUrl)
conn = (HttpURLConnection) url.openConnection()
conn.setRequestMethod("POST")
conn.setDoOutput(true)
conn.setConnectTimeout(30000)
conn.setReadTimeout(180000)
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Authorization", authorization)
conn.setRequestProperty("Cookie", cookie)
conn.setRequestProperty("Accept", "text/event-stream")
// 2. 发送请求,记录发送完成时间
try (OutputStream os = conn.getOutputStream()) {
os.write(requestBody.getBytes("UTF-8"))
os.flush()
}
requestSentTime = System.currentTimeMillis()
// 3. 处理响应码
int responseCode = conn.getResponseCode()
SampleResult.setResponseCode(String.valueOf(responseCode))
if (responseCode == 200) {
// 4. 逐行读取SSE流
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), "UTF-8"))) {
String line
while ((line = reader.readLine()) != null) {
rawResponse.append(line).append("\n")
// 检测线程中断(手动停止测试)
if (Thread.currentThread().isInterrupted()) {
SampleResult.setResponseMessage("用户主动停止")
break
}
// 全局超时控制
if (System.currentTimeMillis() - requestSentTime > TOTAL_TIMEOUT) {
SampleResult.setResponseMessage("全局超时(${TOTAL_TIMEOUT}ms)")
SampleResult.setSuccessful(false)
break
}
// 只处理 data: 开头的行
if (line.startsWith("data:")) {
String dataJson = line.substring(5).trim()
if (dataJson.isEmpty() || dataJson.equals("ping") || dataJson.equals("ping -")) {
continue // 跳过心跳包
}
allDataLines.add(dataJson)
// 解析JSON
def json = new JsonSlurper().parseText(dataJson)
// ========== TTFT 计算 ==========
if (!firstTokenReceived && json.content != null && json.content.toString().trim().length() > 0) {
firstTokenTime = System.currentTimeMillis()
ttft = firstTokenTime - requestSentTime
firstTokenReceived = true
vars.put("TTFT", ttft.toString())
}
// 记录最后一个token的时间(只要有content就更新时间)
if (json.content != null && json.content.toString().trim().length() > 0) {
lastTokenTime = System.currentTimeMillis()
}
// ========== 从最终消息中提取usage ==========
if (json.agentStatus == "stopped" && json.usage != null) {
inputTokens = json.usage.input_tokens ?: 0
outputTokens = json.usage.output_tokens ?: 0
// 计算生成耗时(从首token到最后一个token的时间差)
if (firstTokenReceived && lastTokenTime > firstTokenTime) {
long generationDuration = lastTokenTime - firstTokenTime // 毫秒
if (generationDuration > 0) {
avgTokensPerSec = (outputTokens * 1000.0) / generationDuration
}
}
}
}
}
}
// 5. 设置采样结果和断言
SampleResult.setSuccessful(true)
String resultMsg = String.format(
"TTFT=%dms | in_tokens=%d | out_tokens=%d | avg_tokens/sec=%.2f",
ttft, inputTokens, outputTokens, avgTokensPerSec
)
SampleResult.setResponseMessage(resultMsg)
SampleResult.setResponseData(rawResponse.toString(), "UTF-8")
// 将指标存入JMeter变量(供聚合报告使用)
vars.put("ttft_ms", String.valueOf(ttft))
vars.put("input_tokens", String.valueOf(inputTokens))
vars.put("output_tokens", String.valueOf(outputTokens))
vars.put("avg_tokens_per_sec", String.format("%.2f", avgTokensPerSec))
// 可选:断言输出token数大于0
if (outputTokens == 0) {
SampleResult.setSuccessful(false)
SampleResult.setResponseMessage(resultMsg + " | 断言失败:输出Token数为0")
}
} else {
SampleResult.setSuccessful(false)
SampleResult.setResponseMessage("HTTP错误: " + responseCode)
// 读取错误流
try (BufferedReader errReader = new BufferedReader(
new InputStreamReader(conn.getErrorStream(), "UTF-8"))) {
String line
while ((line = errReader.readLine()) != null) {
rawResponse.append(line).append("\n")
}
}
SampleResult.setResponseData(rawResponse.toString(), "UTF-8")
}
} catch (Exception e) {
SampleResult.setSuccessful(false)
SampleResult.setResponseMessage("异常: " + e.getMessage())
SampleResult.setResponseData(e.toString(), "UTF-8")
log.error("JSR223执行错误", e)
} finally {
if (conn != null) conn.disconnect()
SampleResult.setLatency(ttft) // 将TTFT作为延迟时间
SampleResult.setDataType(org.apache.jmeter.samplers.SampleResult.TEXT)
}
3.3 关键代码解读
| 代码片段 | 作用 |
|---|---|
requestSentTime = System.currentTimeMillis() |
记录请求发送完成的精确时间,作为TTFT计算的起点 |
if (!firstTokenReceived && json.content != null && ...) |
首次遇到非空content字段时,计算TTFT并存入变量 |
lastTokenTime = System.currentTimeMillis() |
每次收到有内容的块就更新时间,用于计算生成阶段总耗时 |
if (json.agentStatus == "stopped" && json.usage != null) |
提取最终统计信息(输入/输出Token数) |
avgTokensPerSec = (outputTokens * 1000.0) / generationDuration |
计算平均每秒生成Token数 |
3.4 在聚合报告中展示自定义指标
为了在JMeter的聚合报告 或汇总报告 中看到TTFT、Token数等指标,需要将它们作为采样器变量 或响应消息 的一部分,然后通过后端监听器 (如InfluxDB+Grafana)或简单数据写入器保存。
更简便的方法:使用 SampleResult.setResponseMessage() 将关键指标以字符串形式保存,然后在查看结果树中直接查看;或者通过 vars.put() 存入变量,再使用 Debug Sampler 检查。
四、常见问题与避坑指南
4.1 查看结果树不显示流式内容
- 确保在JSR223 Sampler中调用了
SampleResult.setResponseData(...)。 - 如果响应过大,JMeter默认会截断显示,可以在
jmeter.properties中调整view.results.tree.max_size。
4.2 TTFT始终为0
- 检查是否成功识别到了第一个有效
content:打印json.content的值,确认不为空且不是纯空白字符。 - 确认
requestSentTime是否在读取响应之前正确赋值。
4.3 无法提取Token统计
- 不同大模型厂商的响应格式不同,需要根据实际情况修改提取逻辑。例如某些接口会在最后一条
data:中返回[DONE],而没有usage字段,此时可能需要自己累计content长度来估算Token数(简单方式:中文字符数/2,英文单词数/0.75)。
4.4 性能压测时内存溢出
- 流式响应如果非常长(几千个token),
allDataLines列表会占用大量内存。建议在压测时只保留必要的统计信息,不存储完整响应数据。例如可以只记录首尾时间、累计token长度,不存储每一行。
五、总结
通过JMeter的 JSR223 Sampler + Groovy脚本,我们可以轻松实现对LLM流式接口的性能测试,并获得:
- ✅ 完整的SSE数据流接收与展示
- ✅ TTFT 精确到毫秒
- ✅ 输入/输出Token数统计
- ✅ 平均生成速度(token/s)
这些指标能够帮助测试人员客观评估大模型接口的首字延迟 和生成吞吐,为性能优化和容量规划提供数据支撑。
本文为原创,转载需注明出处。