【JMeter 实战:大模型流式接口性能测试(含TTFT与Token统计)】

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

  1. 在JMeter中新建线程组(Thread Group)。
  2. 右键 → 添加 → Sampler → JSR223 Sampler
  3. 语言选择 groovy

2.2 配置请求信息(URL、Header、Body)

我们需要发送一个标准的POST请求,携带JSON Body,并设置SSE必需的Header:

  • Accept: text/event-stream
  • Content-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)

这些指标能够帮助测试人员客观评估大模型接口的首字延迟生成吞吐,为性能优化和容量规划提供数据支撑。


本文为原创,转载需注明出处。

相关推荐
测试改改2 小时前
Jmeter-上传图片(一直报500的错误)
jmeter
brucelee18618 小时前
使用 JMeter 进行 API 压力测试完整指南
jmeter·压力测试
Echoo华地21 小时前
Gatling压测案例
java·jmeter·压力测试·并发·scale·压测·gatling
chenjingming6662 天前
jmeter处理接口返回中文乱码的问题
jmeter
chenjingming6662 天前
jmeter线程组设置以及串行和并行设置
java·开发语言·jmeter
洛_尘2 天前
测试9:性能测试--工具篇(JMeter)
jmeter
夜晚打字声2 天前
7(七)Jmeter吞吐量设置
jmeter
chenjingming6662 天前
jmeter导入浏览器上按F12抓的数据包
前端·chrome·jmeter