大模型流式接口压测实战:JMeter脚本实现TTFT与Token吞吐统计 大模型接口的压测和普通接口完全是两码事。普通接口发一个 POST等响应回来记个时间、看个大小就完事了流式接口建立连接之后数据会持续往客户端推可能几十秒才结束。这时候你光看“总响应时间”没有任何意义——用户感知的“快慢”主要由首字什么时候出来决定也就是 TTFTTime To First Token。加上 token 生成速度、总耗时、错误率这些指标才是完整的大模型流式接口性能评估维度。这篇文章是我压测一个内部大模型流式服务时沉淀下来的完整方案核心工具是 JMeter但不用它默认的 HTTP 采样器去硬压而是通过 JSR223 采样器自己实现流式请求顺带把 TTFT 和 Token 统计一起做了。脚本可以直接抄配置思路和踩过的坑也会一并讲清楚适合正在搞大模型接口压测、但又不想换工具链的测试同学。1. 流式接口性能测试的思路拆解——先把“测什么”搞清楚1.1 流式请求与普通HTTP请求的本质差异普通 HTTP 接口是请求-响应模型客户端发出请求服务端处理完一次性把完整的响应体返回连接要么关闭、要么复用。整个请求的耗时基本上等于“服务端处理时间 网络传输时间”而接口的性能上限也主要看服务端能不能扛住并发。大模型流式接口不是这样。它走的是 SSEServer-Sent Events这类协议连接建立后服务端会持续输出片段。一个典型的流式响应长这样data: {id:demo-001,choices:[{index:0,delta:{role:assistant},finish_reason:null}]} data: {id:demo-001,choices:[{index:0,delta:{content:你},finish_reason:null}]} data: {id:demo-001,choices:[{index:0,delta:{content:好},finish_reason:null}]} data: [DONE]每次返回一个以data:开头的数据帧最后以[DONE]结束。对 JMeter 这类压测工具来说这意味着它要面对一个持续到达的响应流而不是一个“完整响应包”。普通接口压测里那些惯性思路放到这里大概率会翻车比如拿着原生 HTTP 采样器压了一轮发现平均响应时间特别大但其实这只能说明“整个流跑完了要多久”完全反应不出首字延迟。1.2 真正要盯的指标TTFT、Token速率和总耗时我经常跟开发同学说大模型流式接口的压测至少要看四个维度指标说明业务意义TTFTTime To First Token请求发出到收到第一个有效token的时间用户感知“开始回答”的等待时间属于体验关键指标totalTime从请求发出到流结束或收到 [DONE]的总耗时单次完整生成会话的持续时间token吞吐单位时间内服务端实际产出的token数常见tokens/s或tokens/min服务端生成能力和产线资源利用水平error rate非200状态码、连接中断、流被截断的请求占比稳定性与可用性也是限流判断依据TTFT 是流式接口特有的核心指标用生活化的类比就是去餐厅点餐你等的不只是“菜全部上齐”的时间而是“第一道菜端上来”的时间。如果第一道菜等了 60 秒后厨再厉害用户体验也已经被拖垮了。大模型场景同理用户看到界面上的光标一直转第一个字迟迟不出来再高的 token 吞吐都救不回来。token 吞吐则需要拆成两类口径一类是服务端返回的 usage 字段里直接给的completion_tokens这种最准另一类是没有 usage 时用输出字符数做一个粗算系数估出来的近似值。后面第 3 章会详细说这个折算问题。1.3 测试场景设计单流基准、并发加压、长稳验证别一上来就上高并发。大模型服务的资源占用和成本压力比普通接口高一个数量级压测第一步应该是“单流基准”。我会按这个顺序设计场景单用户单请求基准验证脚本、确认指标口径同时拿到 TTFT 基线和单流峰值 token 速率。小并发摸底5、10、20 个并发逐步观察 TTFT 和 token 吞吐的变化找出分界点。阶梯加压从 20 往上翻看服务端是平滑劣化还是突然锁死通常能测出限流阈值。长稳验证固定并发持续跑 10 到 30 分钟观察 TTFT 和错误率有没有随运行时间增长而劣化这往往能暴露出内存泄漏、连接泄漏这类问题。场景设计中要确认两个前提一个是模型版本、提示词、采样参数在整轮测试里保持不变不然结果完全没有可比性另一个是测试数据要避开生产环境的真实用户流量用专门的服务实例或独立资源池去压。2. 工具选型——为什么绕开JMeter原生HTTP采样器2.1 原生HTTP采样器的局限它等不了“第一帧”很多人第一次拿 JMeter 压大模型接口会直接拖一个 HTTP 请求采样器把 URL、Header、Body 填好然后开跑。结果问题很明显JMeter 的 HTTP 采样器是一个同步等待的组件它会把整个响应全部读完、读到 EOF才认为这个请求结束。你拿它测流式接口拿到的是“完整流完成时间”而不是分阶段的指标。可能有人会说响应数据里不是能看到完整文本吗对能看到完整拼接后的文本但看不到“第一个 data 帧是什么时候到的”。这就像你能知道一辆车什么时候到终点但不知道它什么时候出站中间的每一个关键节点全是黑的。原生 HTTP 采样器还有个更实际的问题它默认处理响应的方式和流式场景不匹配。如果响应是 chunked 传输JMeter 必须等0\r\n\r\n这个收尾标记出现才算完一旦服务端源源不断地推数据read timeout 设短了还会误报超时。不是说它完全不能压流式接口而是“总耗时”之外的指标基本拿不到这不符合我们的核心诉求。2.2 JSR223采样器方案可控、可扩展、贴近真实协议我的选择是在 JMeter 里用 JSR223 采样器配上 Groovy 脚本自己在客户端代码里发请求、读流、算阶段时间。这样做的原因很直接每次样本的每一步耗时都由我自己的代码控制不会丢指标。可以直接拿到网络流逐行解析 SSE 帧按“是不是 data 帧”“有没有 content 字段”来精确判断首 token。不引入额外插件JMeter 5.4 以上自带 Groovy 运行环境。后续要接入连接池复用、动态参数、复杂断言都只是在脚本里加逻辑。代价是没有原生采样器那么“低代码”需要写点脚本。但这类脚本只要写一次就能长期复用后面换模型、换地址、换 prompt改变量就行。2.3 环境准备与基础参数规划环境方面我用的是 JMeter 5.6JDK 8 及以上都可以跑Groovy 库是 JMeter 自带的不需要额外安装。压测机建议放在跟服务端同一个内网环境网络延迟不能成为变量。4 核 8G 以上的机器比较稳妥长时间压测时内存要给 JMeter 留足。准备阶段我会把不定信息全部抽到 JMeter 的“用户定义的变量”里这样测试脚本不需要反复改代码。需要定义的参数包括参数示例值说明endpointhttp://192.168.1.20:8000/v1/chat/completions被测大模型接口地址apiKeysk-xxxx鉴权信息压测环境使用modelNamedemo-chat-model被测模型标识prompt请写一篇关于秋天的短文测试提示词可配合CSV文件做多样化maxTokens500生成上限控制单请求成本connectTimeout5000TCP连接超时毫秒readTimeout60000读超时毫秒这些参数在 JSR223 脚本里通过vars.get(参数名)读取非常方便。接下来就是核心脚本的实现了。3. 核心脚本实现JSR223写一个可复制的流式压测采样器3.1 请求头、请求体与超时参数的约定流式接口请求体一般是标准的 JSON关键是要带stream: true{ model: demo-chat-model, messages: [{role: user, content: 请写一篇关于秋天的短文}], stream: true, max_tokens: 500, temperature: 0.7 }请求头有四个必须设置Content-Type: application/json、Authorization: Bearer xxx、Accept: text/event-stream。最后一个头容易漏漏了之后部分服务端会退化成非流式返回整个脚本统计的 TTFT 就会变成“等完整响应”完全失真。超时设置要特别说一句readTimeout指的是“两次读取之间”没有数据到达的等待上限不是整个流的时长上限。在流式场景下服务端可能持续推 token也可能在一个很生僻的问题上思考十几秒不发数据。readTimeout 设得太短会把正常思考误判成连接卡死设得太长又会让异常请求占住线程。我一般设 60 秒如果模型有长时间静默的情况再单独调大。3.2 完整Groovy脚本在测试计划里添加一个 JSR223 采样器语言选 Groovy把下面这段脚本粘贴进去。脚本逻辑分四步发起连接、逐行读取 SSE 流、记录阶段时间、把结果写回 JMeter 的 SampleResult 和独立指标文件。import java.net.HttpURLConnection import java.net.URL import java.io.BufferedReader import java.io.InputStreamReader import java.nio.charset.StandardCharsets import groovy.json.JsonSlurper String endpoint vars.get(endpoint) String apiKey vars.get(apiKey) String modelName vars.get(modelName) String prompt vars.get(prompt) String maxTokens vars.get(maxTokens, 500) String body {\model\:\${modelName}\, \messages\:[{\role\:\user\,\content\:\${prompt}\}], \stream\:true, \max_tokens\:${maxTokens}, \temperature\:0.7} long startTs System.currentTimeMillis() long ttft -1L long totalTime -1L int chunkCount 0 int contentChars 0 long serverTokens 0L StringBuilder fullText new StringBuilder() HttpURLConnection conn null BufferedReader reader null try { URL url new URL(endpoint) conn (HttpURLConnection) url.openConnection() conn.setRequestMethod(POST) conn.setRequestProperty(Content-Type, application/json) conn.setRequestProperty(Accept, text/event-stream) if (apiKey) { conn.setRequestProperty(Authorization, Bearer apiKey) } conn.setDoOutput(true) conn.setConnectTimeout(Integer.parseInt(vars.get(connectTimeout, 5000))) conn.setReadTimeout(Integer.parseInt(vars.get(readTimeout, 60000))) conn.getOutputStream().write(body.getBytes(StandardCharsets.UTF_8)) conn.getOutputStream().flush() conn.getOutputStream().close() int code conn.getResponseCode() if (code 400) { def errReader new BufferedReader(new InputStreamReader(conn.getErrorStream(), StandardCharsets.UTF_8)) String errText errReader.text SampleResult.setSuccessful(false) SampleResult.setResponseCode(String.valueOf(code)) SampleResult.setResponseMessage(HTTP error: errText) return } reader new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8)) JsonSlurper jsonSlurper new JsonSlurper() String line while ((line reader.readLine()) ! null) { if (!line.startsWith(data:)) { continue } String data line.substring(5).trim() if (!data || data [DONE]) { continue } def eventJson jsonSlurper.parseText(data) def choices eventJson?.choices def delta (choices ! null choices.size() 0) ? choices[0].delta : null String content delta?.content if (content ! null content.length() 0) { if (ttft 0) { ttft System.currentTimeMillis() - startTs } chunkCount contentChars content.length() fullText.append(content) } if (eventJson?.usage ! null) { serverTokens eventJson.usage.completion_tokens } } totalTime System.currentTimeMillis() - startTs int estimatedTokens (int)(contentChars / 4.0) SampleResult.setSuccessful(true) SampleResult.setResponseCode(200) SampleResult.setLatency(ttft 0 ? ttft : totalTime) SampleResult.setResponseMessage( ttft${ttft}ms,total${totalTime}ms,chunks${chunkCount},chars${contentChars}, estTokens${estimatedTokens},serverTokens${serverTokens} ) String preview fullText.length() 500 ? fullText.substring(0, 500) ... : fullText.toString() SampleResult.setResponseData(preview, StandardCharsets.UTF_8.name()) File metricsFile new File(stream_metrics_ ctx.getThreadNum() .csv) if (!metricsFile.exists()) { metricsFile.write(timestamp,ttft,total_time,chunk_count,chars,est_tokens,server_tokens\n, UTF-8) } metricsFile.append( ${System.currentTimeMillis()},${ttft},${totalTime},${chunkCount},${contentChars},${estimatedTokens},${serverTokens}\n, UTF-8 ) } catch (Exception e) { SampleResult.setSuccessful(false) SampleResult.setResponseCode(STREAM_ERROR) SampleResult.setResponseMessage(e.toString()) log.warn(Streaming request failed: e.toString(), e) } finally { if (reader ! null) { reader.close() } if (conn ! null) { conn.disconnect() } }脚本里有两处值得展开讲。第一ctx.getThreadNum()返回当前虚拟用户的线程编号我让每个线程各自写一个stream_metrics_线程号.csv目的是避开多线程同时写一个文件时的锁竞争和互相覆盖问题。压测结束后把文件合并成一个 CSV用 Excel 或者 Python 做汇总即可。第二SampleResult.setLatency(ttft)会把 TTFT 写到 JMeter 聚合报告的 Latency 列这是后面第 3.4 节的核心技巧。3.3 脚本里的指标是怎么算出来的脚本的统计逻辑里有一个变量叫chunkCount很多人会误以为它等于“token 数”。它不是。它统计的是“收到多少个带非空 content 字段的数据帧”也就是服务端推送了多少个有效片段。有的服务端一个片段就是 1 个 token有的会攒多个 token 放在一个片段里推出来所以 chunkCount 和真实 token 数不一定相等。contentChars是累计的输出字符数这个最直观但它也不等于 token 数。中英混写时token 和字符之间没有固定换算关系。我脚本里用contentChars / 4粗算estimatedTokens这个 4 更适合英文场景中文场景经常没有这么准。更可靠的做法是直接从流尾的usage.completion_tokens里取脚本已经把结果放进了serverTokens。如果服务端没有返回 usage就靠自己模型配套的 tokenizer 提前做一次校准找一段固定文本用 tokenizer 数出真实 token 数再跟contentChars一比得出你这个模型的字符- token 折算系数替换掉脚本里的 4。TTFT 的记录点也有讲究。脚本里ttft的起点是startTs终点是第一次读到非空 content 的那一瞬间。这里包含连接建立、请求发送、响应头返回、首包到达的完整链路是业务侧真实感受到的“首字延迟”。如果你只统计到getResponseCode()返回200之前的时间那只是“响应头到达时间”跟用户感知的 TTFT 还差了一段首包 body 的传输时间。3.4 让聚合报告直接展示TTFTLatency字段的妙用JMeter 的聚合报告里有个 Latency 列原生 HTTP 采样器里它表示“从请求发出到接收到响应头前两个字节”的时间。大模型流式接口场景下响应头之后很快就是 body 里的第一个 data 帧所以把 Latency 语义顺势扩展为“首帧到达时间”是合理且实用的。实现方式就是脚本里那句SampleResult.setLatency(ttft 0 ? ttft : totalTime)。这样跑完一轮压测聚合报告会有两个关键列Average全部样本的平均总耗时代表“完整流跑完要多久”。Average Latency全部样本的平均 TTFT代表“首个 token 平均要等多久”。配合 90% 或 95% 分位可以直接判断极端场景下首字延迟有没有超过卡顿阈值。这个方案比“事后翻日志”高效得多JMeter 官方文档里很少提但实测在 JSR223 采样器里完全可用。4. 测试执行与结果分析——从一堆数字里看出瓶颈4.1 线程组与运行方式建议线程组配置我一般这样设线程数按场景需要传参小并发先 5阶梯加压到目标值。Ramp-Up设成线程数 × 2 秒左右让虚拟用户错峰建立连接避免启动瞬间把服务端连接数打爆。循环次数压固定时长用调度器设置持续时间例如 300 秒这样指标统计更稳定。每次启动时清空旧结果避免跟上一轮数据混在一起。跑测试建议用非 GUI 模式。JMeter 在 GUI 模式下的渲染和监听器开销会吃掉不少压测机资源并发高了之后测出来的数据就不再是纯接口性能了。常见命令是jmeter -n -t streaming_test.jmx -l result.jtl -j test.log -Jthreads20 -Jduration300-J参数可以在命令行传入变量脚本里的vars.get(threads)并不读这一层所以更稳妥的方式是把线程数、时长直接写在测试计划里或者用__P()函数读取。如果要用命令行传参我建议把脚本里的参数读取方式统一改成${__P(threads,10)}这类写法。4.2 典型结果表怎么看TTFT和Token吞吐下面是我在项目里跑过的一组示意数据结构可以拿来参考具体数字不作为任何性能结论并发平均TTFT(ms)P95 TTFT(ms)平均总耗时(ms)token吞吐(tokens/min)错误率1420460620046000%54605206800108000%105206507100154000%2078012008600198000.5%40135026009800204003.2%这个表格里最值得看的是两件事。第一TTFT 在并发从 10 到 20 的区间出现了明显上翘说明请求在服务端开始排队了。并发翻倍首字延迟没有跟着翻倍但明显变差这时就要结合服务端的排队模型判断承载力。第二token 吞吐从 20 并发到 40 并发只从 19800 涨到 20400增长几乎停滞说明瓶颈已经从并发容量变成了服务端生成能力。继续加并发只会推高错误率和延迟不会有吞吐收益。4.3 指标异常时的排查路径具体实战中我总结了几条排查路径按优先级排序错误率突然升高先看状态码分布。429 是限流503 是过载499 是客户端等不及断开502 是网关层问题。不同状态码对应完全不同的解决方向。TTFT 高但总耗时正常说明排队/调度有问题重点看网关的并发层和模型推理的前置环节。TTFT 正常但总耗时明显拉长说明 token 生成速率下降重点看显存/内存占用、上下文长度变长带来的二次规划开销。TTFT 和总耗时都随压测时间缓慢劣化基本可以怀疑资源泄漏或者缓存失效需要结合服务端监控看内存曲线和连接数曲线。不要只看聚合报告的平均值。流式接口的平均值经常被少数极端值带偏TTFT 的 P95、P99 更有决策价值。5. 常见问题与避坑实战5.1 流式请求卡死直到超时这是压流式接口遇到最多的现象请求发出去了服务端也返回了 200但客户端一直读不到数据最后砸出一个SocketTimeoutException。排查思路要先区分“模型在思考”和“连接假死”。模型思考时连接是活着的只是没有数据帧连接假死时可能连 TCP 层都没有数据包了。如果服务端支持心跳帧或注释帧那是最好判断的SSE 协议允许发送以冒号开头的注释行作为心跳脚本里line.startsWith(data:)会跳过这类行不影响统计。如果服务端不支持心跳就用 readTimeout 值来控制异常等待上限同时跟服务端开发确认单次静默期的合理范围。另一个常见诱因是客户端没有正确设置Accept: text/event-stream服务端把它当成非流式请求开始拼装完整响应直到完整结果生成完才返回看起来就像“卡住”了。检查请求头是第一步。5.2 Token统计对不上服务端统计出来的 token 数跟服务端日志总是对不上这是全项目最容易被挑战的点。我踩过三个坑第一个坑是把 chunkCount 当 token 数前面已经解释过两者不是一回事第二个坑是字符数对中文不友好直接用字符除以固定系数会差不少第三个坑是流尾的 usage 字段没解析到因为有些服务端把 usage 放进了最后一条data:帧里有些则放在[DONE]之后就不再返回任何帧了。建议的做法是多指标同时记录chunkCount、contentChars、estTokens、serverTokens全部落到 CSV。对外汇报的时候优先引用服务端 usage 里的完成 token 数没有 usage 时明确标注采用的是“字符数折算”并附上折算系数来源。这样无论是开发、算法还是运维来质疑都能有据可查。5.3 JMeter自身成了瓶颈脚本里每次请求都新建一个HttpURLConnection不开连接池。这对压测机来说开销不小并发超过几十以后JMeter 进程可能先成为瓶颈。现象是压测机 CPU 飙高、文件句柄用尽、结果文件写入变慢。几个优化方向把监听器数量降到最低只保留聚合报告和必要的结果写入长时间压测用非 GUI 模式调大 JMeter 的 JVM 堆内存启动参数加-Xms2g -Xmx4g。如果还不行就要考虑分布式压测或者优化脚本、引入连接池。更彻底的方案是把HttpURLConnection换成支持连接复用的 HTTP 客户端比如在 JSR223 里缓存一个HttpClient对象到线程变量。但连接会持有到流结束流式压测里连接池的收益没有普通接口那么大所以我没有在一开始就上等压到高并发再优化不迟。5.4 注意压测成本和服务端限流大模型压测不是免费的。每跑一轮都会产生真实的推理算力消耗很多服务按 token 计费或按调用量计费。我见过压测把配额跑光的案例最后开发只能临时申请新配额整个排期被拖后了。压测前务必确认三件事压测用的模型实例是不是独立部署或独立路由配额限流阈值是多少压测会不会干扰线上业务单请求的max_tokens设定是不是符合测试目标不要让每个请求都跑满上限。cost 控制是压测工程师必须负起来的责任。另外模型冷启动对 TTFT 干扰极其明显。如果服务端有空闲实例回收机制第一批请求的 TTFT 会被抬得很高混在整体数据里误导结论。我会在正式压测前先用低并发预热请求几十次把推理实例和缓存都激活再开始采集数据。最后按我个人这些项目的经验再说一句流式接口性能测试的难点从来不在于“把工具跑起来”而在于指标定义是否和业务体感对齐。TTFT 是不是真的反映了用户等待token 统计口径是不是能跟上线后的监控对上这些比跑出几个漂亮的数字重要得多。先花时间把指标口径跟团队对齐再动脚本你会少走很多弯路。