1. 为什么 JMeter 压测大模型时 TTFT 这么难拿
TTFT(Time to First Token)指的是从你发出请求,到模型吐出第一个 token 的这段时间。它跟总耗时(Total Latency)完全是两码事:一个模型可能整体要 8 秒才答完,但首 token 只等了 300ms,用户体感就是"秒回";反过来总耗时 3 秒、首 token 却卡了 2.5 秒,用户会觉得这模型"半天不吭声"。所以做压测时,TTFT 是判断交互流畅度的核心指标,尤其对聊天、代码补全、Agent 这类流式场景。
问题在于,JMeter 天生是给"请求-响应"这种一次性返回的接口设计的。你发一个 HTTP 请求,它等完整响应体回来才记录时间。可大模型的流式接口(SSE,text/event-stream)是一块一块往回吐的,JMeter 默认会把整个流读完才算一次采样结束,于是你拿到的Sample Time是"最后一个 token 的时间",而不是"第一个 token 的时间"。这就是很多人压测完发现延迟数据大得离谱、跟实际体验对不上的原因。
我试过直接在 JMeter 里对 SSE 接口压测,如果不做特殊处理,结果里根本区分不出首 token 和尾 token。要拿到 TTFT,核心思路只有一条:在客户端记录请求发出的时刻,再从流式响应里抓出第一个非空数据块到达的时刻,两者相减。这篇就围绕这个思路,用 JMeter 的 HTTP 采样器 + JSR223 后置脚本把 TTFT 采出来,同时用 TaoToken 的统一 Key 和 API 通道来发请求,省去在多个模型厂商之间来回配 Key 的麻烦。
适合谁看:需要给大模型接口做性能基线、写压测报告、或者要给流式接口做 SLA 监控的测试和后端同学。跟着做能产出一份可复用的 TTFT 统计结果。
2. 前置准备:TaoToken 统一 Key 与 API 通道
在动手写脚本前,先把请求通道搭好。压测大模型最烦的一点是:你要测的模型可能来自不同厂商,每家 Key 格式、鉴权头、endpoint 都不一样,压测脚本得写好几套。TaoToken 在这里的作用是提供一个统一的 API 入口和统一的 Key,你换模型时只改请求体里的model字段,鉴权和地址都不用动,压测脚本可以复用。
具体要准备的东西:
- 一个 TaoToken 账号,登录后在控制台创建 API Key。地址是
https://taotoken.net/api-keys,创建后复制那串sk-开头的 Key,只显示一次,记得存好。 - 确认你要压测的模型名。可以在模型对话页面先手动发一条消息,确认这个模型能正常返回,再拿去压测。模型对话入口:
https://taotoken.net/model-chat。 - 记下 API 基地址:
https://taotoken.net/api。流式对话接口走的是 OpenAI 兼容格式,路径是/v1/chat/completions,请求体里带上"stream": true就是流式。
注意:API Key 属于敏感凭证,压测脚本里不要硬编码明文提交到代码仓库。建议用 JMeter 的"用户定义变量"或者从环境变量读取,本地调试时再临时填。
鉴权方式跟 OpenAI 一致,请求头里放Authorization: Bearer <你的Key>,Content-Type: application/json。这样一套配置,无论你后面把model换成哪个,JMeter 侧都不用改。
3. 可复制配置:线程组、HTTP 采样器与 JSR223 脚本
这一节是全文重点,配置能直接抄。整体结构是:一个线程组 → 一个 HTTP 请求采样器(发流式请求)→ 一个 JSR223 后置处理器(算 TTFT)→ 一个聚合报告或结果树看数据。
3.1 线程组配置
新建线程组,参数按你的压测目标来。做 TTFT 基线测试时,建议先用小并发摸清楚单请求表现,再逐步加压:
| 参数 | 建议值 | 说明 |
|---|---|---|
| 线程数(用户数) | 1 → 5 → 20 递增 | 先单线程验证脚本正确性 |
| Ramp-Up 时间 | 与线程数相同 | 避免瞬时冲击导致数据失真 |
| 循环次数 | 10 起 | 单请求 TTFT 波动大,需要多次采样 |
| 调度器 | 按需勾选 | 做持续压测时用 |
单线程先跑通,是因为 TTFT 脚本一旦写错,多线程下你根本分不清是脚本问题还是并发问题。
3.2 HTTP 采样器配置
添加"HTTP 请求"采样器,关键配置如下:
- 协议:
https - 服务器名称或 IP:
taotoken.net - 端口:
443 - 方法:
POST - 路径:
/api/v1/chat/completions - 勾选"Use KeepAlive"
- 在"HTTP 请求"的"高级"里,把"超时"设成足够大,比如 60000ms,流式响应慢的时候别被超时打断
请求头管理器里加两条:
Content-Type: application/json Authorization: Bearer ${TAOTOKEN_KEY}其中${TAOTOKEN_KEY}来自你在"用户定义变量"里配的变量,别写死。
请求体(Body Data)这样写:
{ "model": "gpt-4o-mini", "stream": true, "messages": [ {"role": "user", "content": "用一句话解释什么是首 token 延迟"} ] }stream: true是必须的,否则服务端一次性返回,你采不到"首块"这个概念。model换成你在 TaoToken 上确认可用的任意模型即可。
3.3 JSR223 后置处理器:抓首块时间戳
这是整个方案的核心。在 HTTP 采样器上右键 → 添加 → 后置处理器 → JSR223 PostProcessor,语言选Groovy,脚本如下:
import org.apache.jmeter.samplers.SampleResult // 请求发出的时刻(毫秒) long startTime = prev.getStartTime() // 拿到完整响应体(JMeter 已把流读完) String responseBody = prev.getResponseDataAsString() // 按 SSE 行切分,找第一个带 content 的数据块 String[] lines = responseBody.split("\n") long firstTokenTime = -1L for (String line : lines) { line = line.trim() if (line.startsWith("data:") && !line.contains("[DONE]")) { String jsonPart = line.substring(5).trim() // 只要这个块里有非空 content,就认为是首个 token if (jsonPart.contains("\"content\"") && !jsonPart.contains("\"content\":\"\"")) { // 用当前时间近似首块到达时刻 firstTokenTime = System.currentTimeMillis() break } } } if (firstTokenTime > 0) { long ttft = firstTokenTime - startTime vars.put("TTFT_MS", String.valueOf(ttft)) log.info("TTFT = " + ttft + " ms") } else { vars.put("TTFT_MS", "-1") log.warn("未从响应中解析到首个 token") }这里要坦白一个精度问题:JMeter 的 HTTP 采样器默认会把整个流读完才交给后置处理器,所以上面用System.currentTimeMillis()拿到的时间其实是"脚本执行时刻",而不是"首块真正到达网卡的时刻"。它比服务端真实 TTFT 偏大,偏大的量约等于"剩余流读取时间"。要更精确,得让 JMeter 边收边解析,那就要用BSF或自定义Java Request去接管流读取,复杂度陡增。
所以务实的做法是:用这个脚本拿到的是"客户端观测 TTFT 上界",做横向对比(不同模型、不同并发下的相对趋势)完全够用;要绝对值,还是优先看推理框架自己暴露的指标。这一点在压测报告里要写清楚,别把客户端值当服务端真值。
3.4 把 TTFT 写进结果文件
光在日志里看不够,要能统计。加一个"聚合报告"或"用 Simple Data Writer"把结果落盘。更推荐的做法是在 JSR223 里把 TTFT 写进一个 CSV:
import java.io.File long ttft = vars.get("TTFT_MS") as Long File f = new File("ttft_result.csv") f.append("${ttft}\n")压测跑完,这个文件里就是每次请求的 TTFT 序列,后面用 Python 或 awk 算平均值、P95、P99 都行。
4. 验证请求:跑一次看 TTFT 是否采到
配置完先别急着上并发,单线程跑一次,看三件事。
第一,看"察看结果树"里响应是不是流式的。正常的话响应体里会是一行行data: {...},最后一行data: [DONE]。如果返回的是一整块 JSON、没有data:前缀,说明stream没生效,检查请求体。
第二,看 JMeter 日志里有没有打印TTFT = xxx ms。有值说明脚本解析到了首块;打印未从响应中解析到首个 token说明切分逻辑没匹配上,多半是响应格式跟预期不同,把responseBody前几百字符打出来看看实际长什么样。
第三,看ttft_result.csv有没有追加数据。跑 10 次循环,文件里应该有 10 行。
一个正常的单请求结果大概是这样:TTFT 在几百毫秒量级,总响应时间(JMeter 的 Sample Time)在几秒量级,两者明显拉开差距,说明你确实采到了"首块"而不是"尾块"。如果两个值几乎相等,那基本可以断定脚本没生效,采到的还是完整响应时间。
验证通过后,再把线程数往上加,观察 TTFT 随并发的变化。通常并发升高时 TTFT 会先平稳后陡增,那个拐点就是你要找的容量边界。
5. 本篇常见错排查
报 401 或 403:Key 没带对。检查请求头是不是Authorization: Bearer sk-xxx,中间有空格,Bearer后面一个空格再跟 Key。变量没替换成功也会这样,去"察看结果树"的请求头里确认实际发出去的值。
响应不是流式、没有data:前缀:请求体里stream没设成true,或者被别的配置覆盖了。也有可能是模型本身不支持流式,换个模型试。
TTFT 一直是 -1:脚本没匹配到首个 token。常见原因是响应里 content 字段的格式跟脚本假设的不一样,比如有的返回"content":"你"有的返回"content": "你"(带空格)。把判断条件放宽,或者先把responseBody打印出来对照着改。
TTFT 数值大得离谱、跟总耗时差不多:说明脚本在流读完之后才执行,采到的是尾块时间。这是 JMeter 默认行为的固有限制,不是脚本 bug。要么接受它是"上界值",要么改用能边收边解析的方案。
多线程下 CSV 写入错乱:多个线程同时 append 同一个文件会互相覆盖。给文件名加上线程号,比如ttft_result_${__threadNum}.csv,或者用 JMeter 自带的监听器落盘再后处理。
压测中途连接被断:流式响应时间长,检查 HTTP 采样器的超时设置,以及是否有中间层对长连接做了限制。
6. 后续怎么把这套配置用起来
脚本跑通之后,这套配置的复用价值在于:TaoToken 的统一 Key 让你换模型时只改一个model字段,JMeter 侧的线程组、采样器、JSR223 脚本全都不用动。你可以把不同模型的 TTFT 曲线放在同一张图里对比,压测报告的说服力会强很多。
如果后面要做长期的编码类、Agent 类压测,请求量大、调用频繁,可以了解下 Coding Plan 这类按周期计费的方案,比按量付费更适合持续压测场景,入口在https://taotoken.net/coding-plan。接入细节和鉴权说明统一看文档:https://taotoken.net/doc。要新建或轮换 Key 就去控制台:https://taotoken.net/console。
最后提醒一句:客户端采到的 TTFT 是"上界",写报告时标注清楚采集口径,别和服务端指标混着用。真正要卡 SLA,服务端指标才是准绳,客户端值用来做趋势和回归对比最合适。