搞大模型压测这些年有个指标几乎每次都会被问到但很多人的压测报告里始终没抓准TTFT。聊起响应时间大家都盯着总时延可真上线后用户抱怨最多的却是“问题发出去半天不出字”。这俩根本不是一个东西——引擎的吞吐再高首字迟迟不来用户体感一样是卡。TTFTTime To First Token就是专门衡量“从请求发出到第一个token返回”这个用户感知最强烈的窗口的指标。本文要解决的只有一件事压测大模型时怎么把TTFT准确捞出来。我整理了三条实战路线——手写Python流式脚本、vLLM自带的Prometheus指标、JMeter自定义采样器并把压测中常见的脏数据坑一并列出来。适合正在做LLM服务端压测、推理服务性能调优或者准备搭建大模型性能监控体系的开发同学参考。1. TTFT到底在测量哪段时间三个字容易误解的指标1.1 一次推理请求的时间都花在哪了很多压测新手第一次接触TTFT以为它约等于“网络响应快慢”。实际上一次大模型推理请求从发出到完整返回至少要经历这么几段网络传输客户端把prompt报文发到服务端这一段受物理距离和带宽影响。排队等待请求到达推理服务后如果并发数超过了服务的调度容量比如vLLM的max-num-seqs上限会先排队。prefill阶段服务端把整段prompt一次性读入并完成预填充计算生成首个token。decode阶段逐字生成后续token直到输出结束或到达max_tokens上限。TTFT的定义很朴素从请求发出的时间戳到收到第一个token的时间戳中间的时间差。但这里最核心的、也是决定TTFT数值大小的部分其实是prefill阶段的计算耗时。你可以把prefill想象成老师拿到一张阅读理解试卷需要先把整篇文章从头到尾读一遍、理解一遍然后才有可能说出第一句点评而decode则是读完之后的逐字输出。1.2 prefill为什么这么决定TTFT的命prefill是个典型的计算密集型过程。模型需要同时处理prompt里所有的token计算它们的注意力关系生成第一轮KV Cache。这意味着TTFT对两个东西极其敏感一是prompt的总长度二是单次算力规模。举一个直观的数量级感受在单张A100上跑一个70B量级模型一个几百token的短promptTTFT可能在几百毫秒量级但当你塞进去一篇几千字的长文档做问答prefill的计算量会随prompt长度明显上升TTFT很容易突破两三秒。所以网上很多关于“为大模型接口做性能压测”的帖子都会在测试报告里同时标注输入长度——没有这个前提裸谈TTFT毫无意义。理解了这一点你就能明白另一个常被混淆的概念TPOTTime Per Output Token它衡量的是decode阶段每生成一个token的耗时。总时延可以粗略写成TTFT TPOT × (总输出token数 - 1)。所以调优时你要分清楚用户嫌慢是慢在“第一个字出不来的TTFT”还是慢在“一个字一个字往外蹦的TPOT”两个方向的优化手段完全不同。我后面讲数据分析时还会专门提到这件事。1.3 测量TTFT的真正难点在哪里TTFT的原理一句话能说清但真正去测时问题就来了主流HTTP压测工具默认记录的是完整响应时间——也就是等到整个响应报文收完才停表。你拿JMeter直接压一个流式接口跑完看到的是全部token都接收完毕的耗时不是TTFT。如果用SDK调用OpenAI兼容接口在不开启stream参数的情况下SDK同样会在完整内容返回后才调用你的回调你根本感知不到首token的到达时刻。也就是说测TTFT的第一道坎不是“怎么计算”而是“怎么在数据流里捕捉第一个字节到达的那个瞬间”。通常有三种做法自己写脚本走流式请求并记录首个数据块到达时间利用推理框架自带的指标端点直接读现成数据在JMeter这类工具里自定义采样逻辑。下面每一条我都会展开先说结论手写脚本最适合单机快速摸底框架自带指标最适合生产环境长期监控JMeter定制方案适合团队统一压测平台时使用。2. 三种主流获取方案对比按应用场景选不要只抄一个先把三条路线放在一起看方便对号入座。方案定位优点缺点适合场景Python流式脚本手动实测灵活、可控、不依赖框架单机脚本不太适合大规模并发排查问题、对比优化效果、快速验证vLLM Prometheus指标生产监控零埋点、框架原生统计、天然支持直方图仅适用vLLM部署环境需要搭Grafana线上监控、容量规划、发布对比JMeter自定义采样器统一压测工具能融入现有JMeter压测体系、并发能力好需要写Groovy、上手成本较高团队已有JMeter压测基线、需统一出报告三条路本质都在回答同一个问题流式响应中第一个chunk是什么时候到的。区别是脚本自己掐表框架把掐表的逻辑内置在推理引擎里JMeter则需要你在采样器里手动实现。关于工具选型我的建议是别一上来就折腾平台化方案。如果你只是想验证一下当前服务的TTFT是否达标Python脚本5分钟就能出数如果要长期监控每次上线的性能波动那必须上框架自带的指标至于JMeter是因为很多团队的压测资产和汇报口径都沉淀在这套工具里新增一个TTFT指标比整体替换压测方案便宜得多。下面三章每一条我都会给出可直接运行的样例和关键参数说明。3. 路线A20行Python脚本亲手把首个Token的到达时刻掐出来3.1 用httpx而不是requests做流式计时我见过不少人用requests库去测流式接口requests对流式响应的处理相对粗糙容易把连接缓冲期的耗时算进去。我自己更推荐httpx它对流式HTTP的支持更干净严格按chunk边界迭代。写脚本前要确认一件事被压测的服务是否兼容OpenAI的/v1/completions或/v1/chat/completions接口并且支持stream参数。目前主流的本地部署方案无论是vLLM、Ollama还是FastChat基本都兼容这套流式协议。Ollama也可以用它的/api/chat原生接口JSON结构稍有差异但测量思路完全一致。3.2 一份可直接跑的首Token计时脚本下面这段脚本的逻辑很简单向服务端发一个流式请求从请求发出的瞬间开始掐表在迭代响应的过程中一旦收到第一个以“data:”开头且不是[DONE]的数据块立刻记录时间戳两者相减就是TTFT。为了不干扰后续请求拿到首token后我通常会继续把连接读完再关闭避免连接池里残留未读数据。import time import httpx def measure_ttft(prompt, modelyour-model, serverhttp://localhost:8000, max_tokens128, samples20): payload { model: model, prompt: prompt, max_tokens: max_tokens, stream: True } url f{server}/v1/completions ttft_list [] for i in range(samples): start time.perf_counter() first_token_time None with httpx.stream(POST, url, jsonpayload, timeout120) as resp: for line in resp.iter_lines(): if not line or not line.startswith(data:): continue if [DONE] in line: break if first_token_time is None: # 第一次读到真实token数据掐表 first_token_time time.perf_counter() ttft_ms (first_token_time - start) * 1000 ttft_list.append(ttft_ms) print(f第{i1}次 TTFT {ttft_ms:.1f} ms) # 后续数据继续读完保证连接正常释放 for _ in resp.iter_lines(): pass break if ttft_list: ttft_list.sort() p50 ttft_list[len(ttft_list) // 2] p95 ttft_list[int(len(ttft_list) * 0.95) - 1] p99 ttft_list[int(len(ttft_list) * 0.99) - 1] print(f\n样本数: {len(ttft_list)}) print(fP50 TTFT: {p50:.1f} ms) print(fP95 TTFT: {p95:.1f} ms) print(fP99 TTFT: {p99:.1f} ms)几个关键点说明一下time.perf_counter()比time.time()更适合做短间隔计时它有更高的精度且不易受系统时间调整影响。统计时不要只报平均值。TTFT的长尾非常明显只要服务端有一次排队抖动平均值就会被拉高。我在压测报告里一定是p50、p95、p99三个数一起给。每个请求之间要有冷却时间否则连续请求会把服务端并发队列打满测出来的不是“理想服务状态下的TTFT”而是“排队叠加之后的TTFT”。如果就是要测高并发下的表现那需要配合并发工具脚本方案仅适合单请求逐次采样。3.3 脚本只测一个维度怎么办把输入长度做成变量TTFT对prompt长度高度敏感建议在脚本里把prompt设计成多个档位分别测。比如同一脚本里跑“短问题几十token”“中等文档几百token”“长文档一两千token”三组画成曲线看增长趋势。这一步对判断性能拐点非常有用——有些服务短文本TTFT很漂亮prompt一长就原形毕露原因几乎都出在prefill的计算量上。有次我压一个量化部署的7B模型短prompt的TTFT稳定在300ms左右把prompt拉到2000 token之后TTFT直接飙到1.8秒。一开始怀疑是显存碎片导致KV Cache分配变慢后来逐段排查才发现是量化权重下的prefill算子在小batch大输入时性能退化。这个排查过程里分组变输入长度的压测方式功劳很大。4. 路线BvLLM部署环境下的内置指标不用写代码就能拿到TTFT4.1 打开Prometheus指标端点如果你用的是vLLM做推理服务恭喜你最省事的一条路就藏在框架自己肚子里。vLLM从较早版本开始就内置了Prometheus指标支持启动时加一个参数即可python -m vllm.entrypoints.openai.api_server \ --model meta-llama/Llama-3-70B-Instruct \ --tensor-parallel-size 4 \ --gpu-memory-utilization 0.9 \ --max-num-seqs 64 \ --enable-prometheus-metrics服务起来后访问http://localhost:8000/metrics就能看到一堆以vllm_开头的指标。在压测过程中你关注的指标是这几个vllm:time_to_first_token_seconds_bucket vllm:time_to_first_token_seconds_sum vllm:time_to_first_token_seconds_count vllm:time_per_output_token_seconds_bucket vllm:time_per_output_token_seconds_sum vllm:time_per_output_token_seconds_count注意TTFT和TPOT的指标名里带冒号这是vLLM做聚合后的命名方式。可以直接通过curl验证一下指标通没通curl -s http://localhost:8000/metrics | grep time_to_first_token | head -204.2 把直方图变成看得懂的P95vLLM输出的TTFT指标是Prometheus直方图Histogram原始输出是一串不同le小于等于阈值的计数桶。直接看这堆桶是不直观的要借助PromQL里的histogram_quantile把它转换成百分位数。Grafana面板里的查询语句可以这样写histogram_quantile(0.95, sum(rate(vllm:time_to_first_token_seconds_bucket[5m])) by (le))这句的意思是统计最近5分钟内所有请求的TTFT分布算出95分位值。把0.95分别换成0.5、0.99你就能在同一张图里叠加三条曲线中位数、P95、P99。时间窗口建议选5分钟或10分钟太短噪声大太长掩盖突刺。这里我要多说一句vLLM的直方图是指数递减的时间桶短延迟区间的桶更密长延迟区间桶更稀疏所以你在看原始bucket数据时会发现几毫秒到几百毫秒之间有很多桶到了秒级以上桶就变少了。这是设计上的取舍不影响百分位数计算只是提醒你不要试图从原始桶里直接数“最多请求落在哪”那会误导你。4.3 生产环境建议压测时把Grafana面板盯起来我的习惯是压测前先把Grafana面板配好重点放三个图TTFT的P50/P95/P99曲线、TPOT同款曲线、服务端吞吐requests per second。压测过程中不是等报告出来而是边压边盯曲线。很多时候错峰一出现你当场就能在面板看到拐点比事后翻日志高效得多。另外vLLM指标里还有几个和TTFT强相关的辅助指标值得一起看指标名含义和TTFT的关联vllm:num_requests_running当前正在执行的请求数并发打满时TTFT会明显抬升vllm:num_requests_waiting排队中的请求数一旦大于0TTFT中大概率包含排队时间vllm:prefix_cache_hit_rate前缀缓存命中率命中率越高实际prefill计算量越少TTFT越低看这几个指标的联动能快速判断TTFT升高到底是并发容量不足还是prefill本身变慢。前缀缓存命中率这个指标尤其重要——你压测时如果反复发相同前缀的请求命中率会很高TTFT数据会“虚低”误导压测结论这一点后面的坑里我会专门展开。5. 路线CJMeter压测时的TTFT捕获绕开默认计时的陷阱5.1 JMeter默认根本测不出TTFTJMeter是很多团队压测的首选工具但它有个根深蒂固的特性一个HTTP采样器默认记录的是从请求开始到完整响应结束的时间。你用JMeter压一个大模型流式接口哪怕接口每200ms吐一个chunkJMeter也要等最后一个chunk到齐才落一条SampleResult。所以默认配置下你看到的“响应时间”是全部输出读完的总耗时不是TTFT。有些同学会说那我配合正则提取在响应数据里找第一个时间戳不行吗不行。响应数据是在整个请求完成之后才被JMeter写进采样结果的正则只能拿到完整响应里的内容拿不到“第一个字节抵达”的时间点。想测TTFT必须在请求过程中拦截数据流。5.2 用JSR223采样器写自己的TTFT计时逻辑JMeter里能自定义请求行为的地方是JSR223 Sampler配合Groovy脚本可以控制HTTP连接的读流过程。核心思路并不复杂自己发起一个流式POST请求循环读取输入流在第一次读到字节时记录毫秒时间戳减去请求发出时刻就是TTFT。下面是一个经过简化但能跑通的脚本骨架import java.io.InputStream import java.net.HttpURLConnection import java.net.URL // 压测参数按实际情况改成从JMeter变量读取 String endpoint http://localhost:8000/v1/completions String payload {model:test-model,prompt:这里是压测输入,max_tokens:128,stream:true} URL url new URL(endpoint) HttpURLConnection conn (HttpURLConnection) url.openConnection() conn.setRequestMethod(POST) conn.setRequestProperty(Content-Type, application/json) conn.setDoOutput(true) conn.setConnectTimeout(3000) conn.setReadTimeout(120000) conn.getOutputStream().write(payload.getBytes(UTF-8)) long start System.currentTimeMillis() long firstTokenAt 0 InputStream in conn.getInputStream() try { byte[] buf new byte[1024] int read while ((read in.read(buf)) ! -1) { if (firstTokenAt 0) { // 输入流中第一次读到数据视为首个chunk到达 firstTokenAt System.currentTimeMillis() } if ((firstTokenAt 0) new String(buf, 0, read, UTF-8).contains([DONE])) { break } } } finally { in.close() conn.disconnect() } long ttft firstTokenAt - start // 构建自定义SampleResult让监听器能统计这条数据 SampleResult result new SampleResult() result.setSampleLabel(TTFT) result.setSuccessful(true) result.setResponseCode(200) result.setResponseMessage(TTFT ttft ms) result.setTimeStamp(start) result.setEndTime(firstTokenAt 0 ? firstTokenAt : start) result.setResponseData(TTFT ttft ms, UTF-8) result result脚本核心就一段firstTokenAt记录的瞬间正是HTTP输入流第一次有数据可读的时刻。要注意的是响应里第一个chunk可能只包含部分字节不要用“是否读到完整个JSON”作为判断标准只要读到数据就算首token到达。5.3 把TTFT从JMeter里统计出来两种顺手的方式写完JSR223采样器后TTFT的数据要能被汇总。最简单的方式是用JMeter的监听器读取自定义SampleResult脚本里result.setSampleLabel(TTFT)生成的样本会出现在聚合报告等监听器中你直接在“聚合报告”里筛选或按标签分组统计。还可以配置后缀__jm_ttft变量把每次ttft写入CSV日志文件事后统一离线分析。另一个思路是JMeter的后端监听器Backend Listener把自定义指标发给InfluxDB再在Grafana里做展示。这条路适合已经有InfluxDB/Grafana监控体系的团队能把压测机的触发时间和线上监控数据放在同一个时间轴里对比。相比vLLM的Prometheus直通车JMeter后端监听器能拿到更丰富的标签维度比如并发数、线程组名但需要额外维护一套时序库。JMeter方案的本质不是“用JMeter测框架”而是在JMeter里实现一个自定义客户端来精确采集TTFT指标。所以你在跑高并发压测前先单线程跑两轮确认脚本输出的TTFT和Python脚本接近再放大并发否则并发一上去了脚本里的网络连接逻辑如果有bug全量数据都会废掉。6. 实测中的坑与对策为什么你测出来的TTFT总不对劲6.1 并发排队把TTFT污染得看不出真相这是我在一线压测里见到最多的问题。大模型推理服务普遍有并发上限vLLM通过max-num-seqs限制同时处理的序列数超过上限的请求会进入排队队列。如果你压测时一上来就打满并发等待时间会直接叠加进TTFT里。这带来的结果是你看到的TTFT膨胀本质是调度队列的等待不是prefill变慢。排查技巧在压测统计里把请求拆成“直接执行的请求”和“排队过的请求”分别统计。vLLM里可以通过日志或指标的num_requests_waiting判断排队深度如果是自己手写脚本就在客户端记录请求发出去到服务端accept的时间并和服务端日志里的process_start原子时间对齐。简而言之单请求看TTFT必须清空并发队列高并发看TTFT必须看分段曲线不要混在一起下结论。6.2 前缀缓存命中导致TTFT虚低现代推理框架普遍支持前缀缓存Prefix CachevLLM默认开着。它的原理是如果新请求的prompt前缀和之前请求的prompt前缀相同直接复用已有KV Cache跳过一部分prefill计算。这本来是好机制但会毒害你的压测数据如果你压测时不停地发一模一样的prompt前缀缓存命中率接近100%测出的TTFT远低于真实用户的随机输入场景。对策很直接压测数据集要刻意混合前缀。至少准备几百条不同开头的prompt让缓存命中率回落到一个真实水平比如观察vllm:prefix_cache_hit_rate在30%以下再下结论。如果你想单独评估“缓存命中对TTFT的收益”那就做一个对照同一组prompt一组开缓存一组关缓存分别测TTFT分布报告中注明命中率。6.3 流式与非流式接口的体验差异不等于TTFT差异不少压测脚本调模型SDK时默认不传stream: true服务端会攒完整段回复再一次性返回。这种情况下你测得的时间是完整延迟比TTFT大了整整一个decode生成周期。假设一个请求我要生成200个tokenTPOT是50ms那么完整延迟里大约有10秒是decode阶段贡献的TTFT可能只有800ms。拿完整延迟去对标别人的TTFT数字会显得离谱地高。所以我压测前第一件事永远是确认接口契约是OpenAI兼容的stream接口还是非流式的普通请求客户端SDK是否支持逐token回调。用对了流式模式才谈得上TTFT。6.4 平均值会骗人P95才是用户体感的真相TTFT的分布通常是右偏的。绝大多数请求很快但总有零星请求因为偶发调度、显存分配、网络重传等因素慢四五倍。平均值被这些长尾拉高后整个系统的真实水平反而看不清。我测过一批请求P50是400msP95到了1.8秒平均值740ms——如果只报平均值你会以为系统还行但P95已经暴露了明显的抖动问题。严谨的做法是记录每次采样的原始值汇总时至少给出P50、P95、P99三个分位点并在报告里附上样本量和测试时长。如果两次优化之间的P95有明显下降而不是只在P50上打转说明你解决的不只是运气问题。7. 拿到TTFT之后用数据反推推理服务的瓶颈7.1 一图三线从TTFT分布曲线看系统状态把TTFT样点按耗时排序画一条累积曲线你会发现几个典型形态曲线整体平缓从低到高均匀爬升说明系统处于稳定状态TTFT主要受prefill计算时长限制。曲线在低段密集突然翘尾说明有长尾请求大概率是偶发排队或资源争抢。曲线整体右移且斜率变大说明prefill阶段成了瓶颈或者并发容量被打满。这个形态判断很实用能帮你快速决定下一步看什么。翘尾就查调度和排队整体右移就查算力和输入长度别一上来就去调模型参数。7.2 TTFT和TPOT联动定位到底是prefill慢还是decode慢只盯着TTFT很容易漏掉另一半问题。有时候首字很快但后文生成像挤牙膏用户体感依然差这是TPOT的锅。我习惯把这两个指标放在一起看如果TTFT和TPOT同时上升多半是并发变大导致算力争抢如果只有TTFT上升、TPOT稳定问题集中在prefill或排队如果只有TPOT上升问题集中在decode阶段的显存带宽或批处理效率。举个例子某次压测一个量化7B模型整体响应时间从2秒涨到6秒按平均值看好像整体变慢了。拆开看TTFT几乎没变TPOT却从25ms涨到70ms一下锁定是decode阶段的资源瓶颈而非prompt处理问题。后来把batch size调低并开启更长距离的调度TPOT恢复正常。不拆开这两个指标排查方向就会南辕北辙。7.3 面向TTFT的调优先动哪里最划算根据我的实测经验几个方向按性价比排序如下缩短prompt把系统提示词、历史对话精简化。每少几百tokenprefill就省下可观的计算量TTFT立竿见影。开启前缀缓存并且优化命中率对多轮对话和文档问答场景收益极大TTFT直降两三倍也不奇怪。调低max-num-seqs或限制并发注入当排队导致的TTFT占大头时限制并发反而能改善整体响应曲线。升级算力或调整prefill算子只适用于前几步做完之后还有硬瓶颈的场景成本最高放在最后。特别提醒一句别为了TTFT好看而把所有并发都砍掉那样单个请求是快了系统吞吐却废了。性能测试衡量的永远是多个指标的平衡TTFT只是其中的一块拼图。最后分享一个我自己的压测习惯每次压测开始之前我会先跑一个20样本的Python流式脚本把基准TTFT拿到手记在当前环境的备注里。随后无论用vLLM指标还是JMeter跑大规模压测都以这个基准作为坐标系。压测结束后把同时间的vllm:prefix_cache_hit_rate拉到报表里一起归档。这样一周后翻数据时你不会对着一个突然变快或变慢的TTFT挠头因为影响它涨跌的核心变量——输入长度、并发、缓存命中率——都已经是被记录的事实了。TTFT这个指标说复杂也复杂prefill、排队、网络每一层都来掺一脚说简单也简单抓住了“流式响应第一个数据块抵达时刻”这条主线剩下的都是统计口径和维护习惯的问题。希望这篇里的三条路线和那些踩过的坑能帮你少走几步冤枉路。