前阵子帮一个团队复核性能测试报告,报告上写着“系统 QPS 达到 500,性能表现优秀”。我顺手问了句:那用户从输入到看到第一个字,要等多久?报告里没有。用户侧的真实反馈是“半天不出字,出来了又很慢”。这就是当前 AI 性能测试最尴尬的地方:我们还在用 2015 年的指标,测 2025 年的系统。当 RAG 已经成为标配,流式生成变成默认交互方式,QPS 这种传统吞吐指标越来越失真,TTFT(首 Token 时延)和 FT(完成整段回答的时间)这类指标必须走上台面。这篇文章我会把我这几年的测试复盘、踩坑和一套能直接落地的方案写清楚,适合正在做 LLM 应用性能评估、稳定性测试和容量规划的团队参考。
1. 为什么 QPS 在生成式 AI 面前“失真”:三个前提被打破
1.1 传统 QPS 的三个隐含前提
QPS 这个概念设计出来的时候,面对的是典型的“短请求-短响应”服务:一请求进来,服务端算一下,立刻返回一个固定大小的 JSON,然后连接就释放了。在这种模型里,QPS 是一个干净、直观的吞吐指标,它的前提有三个:请求处理时间短、响应长度基本固定、服务端不保留太多跨请求状态。
但大模型推理服务完全不是这套逻辑。一次流式生成请求可能持续 5 秒、20 秒甚至几分钟,响应是分块吐出来的,长度取决于生成内容,输入上下文又可能因为 RAG 拼接了很长的检索结果。三个前提全部失效。这时候你把 QPS 放在压测报告的核心位置,数字本身没问题,但解读方式已经错了。
我举个简化计算:假设服务端能同时处理 100 个并发会话,平均一次会话要跑 5 秒,那最大吞吐大概就是 100 / 5 = 20 QPS。数字很难看。但同一时刻服务端可能正以每秒几十个 Token 的速度往外吐数据,如果压测脚本把每个数据块都当成一次“成功响应”,瞬时 QPS 又能统计出几千。你说这个服务到底算 20 QPS 还是几千 QPS?两个数字都不能回答用户真正关心的问题——我得多快能看到第一个字、完整回答需要多久。
1.2 从“服务端吞吐”到“用户体感”
QPS 本质是从服务端资源消耗视角衡量系统容量,它默认“请求处理完毕”就是“用户体验完成”。但生成式 AI 的交互是流式的,用户看到的不是“请求完成”,而是一个连续展开的过程:等待第一个 Token、等待内容滚动、等待结束。这个过程里用户的心理时钟是完全不同的。
用 QPS 作为核心指标,还会掩盖两类问题。第一,长尾延迟被平均掉:大量请求在 200 毫秒内返回首包,但个别请求因为排队、资源竞争要等 5 秒,平均值仍然好看。第二,响应长度差异造成的吞吐假象:一个回答 20 个字的短问和一个生成 2000 字的长文,对系统资源的消耗差一个数量级,QPS 却把它们当成同一个“单位”来计数。
这也是为什么我觉得,“从 QPS 到 TT/FT”不仅仅是换两个指标,而是测试思维要从“评估服务器能吞多少请求”转变成“评估用户在多并发、多场景下需要等多久”。性能测试的意义不是给一个漂亮的吞吐数字,而是回答:系统在什么条件下会变得让人不可接受,瓶颈到底卡在哪一层。
2. TTFT 与 FT:从“每秒处理多少”转向“用户等多久”
2.1 指标定义和测量边界
TTFT 是 Time To First Token 的缩写,我把标题里的 TT/FT 理解为一组指标:TTFT 负责衡量“从请求发起后到收到第一个返回 Token 的时间”,FT 负责衡量“从请求发起到收到流式结束标记的时间”。后者也可以理解成整段回答的完成时延。它们合在一起,描述了一次生成式交互的完整生命周期。
测量边界非常关键。TTFT 计时起点是客户端准备发送请求的那一刻,终点是客户端收到第一个业务 Token。注意是“业务 Token”,不是 TCP 层第一个数据包,因为有些服务端会先把 HTTP 响应头、SSE 的注释行发过来,真正的内容还在后面憋着。如果测试脚本把“收到第一个字节”当首 Token 时间,TTFT 会被低估。
FT 的终点也有讲究。是读到流式接口的[DONE]结束标记,还是业务自己定义的结束符?我建议统一为“客户端判定业务完成”的事件。在 SSE 里通常是结束标记;在 WebSocket 里可能是服务端发来的特定消息。报告里如果不写清楚终点定义,后续团队对齐数据时一定会吵架。
2.2 为什么必须看分位数而不是平均值
延迟分布从来不是正态的,特别是有 GPU 调度、排队、缓存竞争存在的场景,尾部尤其肥。一个典型现象是:100 个请求里 90 个 TTFT 在 300 毫秒左右,9 个在 800 毫秒,还有 1 个因为排队等了 5 秒。平均值大约 490 毫秒,看起来完全没问题。但真实感受是每 100 次请求就有一次让人抓狂,而这个“抓狂请求”平均指标完全看不出来。
所以最关键的两个统计值是 P95 和 P99。P95 定义是“把所有请求的 TTFT 排序,取 95% 位置上的值”,代表大多数用户会遇到的最差体验;P99 则近似代表最倒霉的那个用户。我建议 SLO 以 P95 为主、P99 做参考,不要用平均值为准。平均值适合做容量趋势分析,不适合做体验承诺。
统计分位数时还要注意窗口长度。如果压测脚本每 10 秒算一次 P95,然后报告里写“P95 平均是 1.2 秒”,这个数字是没有意义的,因为你是把一组不同窗口的分位数值又取了一次算术平均。正确做法是:在稳定阶段选取一个完整时间窗口,把窗口内所有原始请求样本合并、排序,再取 P95。样本量至少几千条,不然尾部噪声会很大。
2.3 最简单的流式压测脚本长什么样
用伪代码演示一个测量思路,跑通之后再替换成你们团队熟悉的 HTTP 客户端和并发框架。核心点只有一个:必须逐块读取流式响应,在读取第一块时记录 TTFT,在流结束时记录 FT。
# 伪代码:测量一次流式请求的 TTFT 和 FT import time start = time.perf_counter() ttft = None async for chunk in stream_response(url, payload): if ttft is None: ttft = time.perf_counter() - start # 这里可以顺便累计 chunk 的字节数或 Token 数 # 便于最后计算 Token 吞吐 # 收到流式结束标记后 ft = time.perf_counter() - start return ttft, ft很多人第一次写压测脚本时会犯一个致命错误:用普通 HTTP 客户端请求,等整个响应完整返回后再取数据。这么写,得到的“TTFT”其实等于 FT,因为客户端根本没有在首包到达时打点。还有另一个极端:统计脚本把服务端推来的每个 SSE 块都当成一次独立请求,QPS 瞬间飙升,但这个数字除了误导没有任何价值。
正确做法是:请求级别打点,每个请求记录 (ttft, ft, token数, 是否成功) 四条信息。并发跑完后再统一算 P50、P95、P99 和平均吞吐。
3. RAG 接入后,性能测试的“被测对象”发生了质变
3.1 检索链路:从模型单点测试到链路测试
RAG 成为标配之前,性能测试的核心对象是模型推理服务本身:发 prompt,收 completion,测生成性能。RAG 接入之后,一次请求要经历 Query 向量化、向量库检索、重排序、上下文拼装,最后才进入模型生成。任何一个环节变慢,都会直接体现在 TTFT 上。
我见过最典型的翻车案例:团队辛辛苦苦压测 LLM 服务,报告显示 TTFT 很优秀,上线后用户却觉得响应慢。一排查才发现慢在检索链路。Query 向量化服务和向量检索服务各自只有几十毫秒,看起来都不高,但串联起来再加上网络往返,就多出了 300 到 500 毫秒;如果重排序再引入一次模型调用,总时延可能翻倍。单点测模型当然发现不了这个问题。
所以做 RAG 应用的性能测试,首先要做的不是选指标,而是把被测链路画出来:客户端发起请求、查询向量化、检索、重排、构造上下文、模型排队、模型预填充、模型逐 Token 生成、返回流。每一跳都要有独立的埋点和耗时记录。用应用层中间件打点即可,不需要侵入模型内部。
3.2 上下文长度、缓存命中率、并发叠加的复杂效应
RAG 对性能的最大影响是上下文变长。原本一段 500 Token 的 prompt,挂上检索结果后可能膨胀到 3000 Token。模型预填充时间随输入长度显著增长,TTFT 会上升;长上下文还推高 KV Cache 内存占用,降低并发承载能力。如果测试报告不记录每次的 prompt Token 数,只写“TTFT 从 300ms 涨到 800ms”,别人根本不知道为什么会涨。
缓存命中率是另一个隐藏变量。RAG 应用普遍会做 query 缓存、文档检索缓存、甚至整段 prompt 的响应缓存。线上真实用户的 Query 千差万别,命中率可能很低;但如果压测脚本里只有几十条固定 Query 来回循环,测出来的数据会虚高到离谱。我在后面第四部分会详细展开这个问题。
并发场景下的叠加效应更要命。单个请求检索耗时 50ms,看起来毫无压力;但 100 个并发同时发起检索,向量库连接池、重排序服务的线程池、embedding 服务的批次排队全都会开始堆积。如果不做链路级并发测试,你看到的瓶颈永远只是“压测机不够努力”,而不是“服务端有瓶颈”。
3.3 怎么设计一个可复现的 RAG 基准场景
RAG 性能测试最怕不可复现。今天语料库 1 万篇文档,明天改成 10 万篇,检索耗时完全不一样。所以我建议在压测前把环境变量固定下来:固定文档库版本,固定切分参数和 topK 设置,固定向量检索服务的索引版本,固定 prompt 拼装模板,固定 max_tokens 和采样参数。
Query 集合需要按业务类型设计,而不是随机造几百条。我常用的分类方法是分成三组:高频短问,长度短、意图清晰,检索通常能拿到强相关片段;模糊长问,长度长、完整度低,检索结果不固定,召回数量和内容波动大;超长文档问答,需要召回的片段多,拼装后的 prompt 明显膨胀。每组至少准备几百条真实或接近真实的 Query,并保证组内不重复,降低缓存命中干扰。
每一轮测试都要记录场景基线:语料库大小、平均 prompt Token 数、平均检索到多少片段、缓存命中率是多少。有了这些基线,两轮测试之间才有可比性。否则你压测报告上的“本次 TTFT 比上次慢 20%”,根本说不清是模型变慢了、检索变慢了,还是数据集中不同导致的。
4. 真正卡住 AI 性能测试的五个隐蔽瓶颈
4.1 压测客户端反客为主,成了瓶颈
我看到过很多团队压测 AI 应用,并发加到 200 的时候客户端先崩了,然后得出一个“系统只能扛 150 并发”的错误结论。为什么客户端先崩?因为流式请求会让连接长时间占用,压测机需要同时维护大量挂起的 Socket 连接,内存、文件描述符、带宽都会被快速消耗。加上解析 SSE 本身存在 CPU 开销,压测客户端半路失效非常常见。
排查方法很简单:压测时同时监控压测机的 CPU、内存、TCP 连接数,并且看服务端接入层的连接数是否随并发线性增长。如果客户端已经 100% CPU,服务端还很空闲,说明瓶颈不在被测系统。应对方案是压测机横向扩展、复用连接池、减少每请求不必要的日志输出。另外,压测脚本不要记录每个 chunk 的完整内容,只统计长度即可,否则内存会被原始数据撑爆。
4.2 冷启动数据污染,结论没有参考价值
大模型服务冷启动阶段有一堆额外开销:权重加载、CUDA 上下文初始化、KV Cache 分配、缓存建立。如果上来直接压测,第一批请求的 TTFT 可能比稳定期慢 3 到 5 倍。更麻烦的是,冷启动期的慢请求会污染整个测试窗口的分位数统计,特别是当测试时长本来就不长时。
我的习惯是:正式压测前先跑 5 到 10 分钟的预热流量,目标是让模型推理服务进入稳定状态,让缓存命中分布也接近真实。然后丢弃预热阶段的所有采样,只统计稳定阶段数据。如果被测的是一套新部署的模型服务,预热时长还要更长。报告里要写明预热方式和丢弃数据的时间范围,否则换个团队来复测,结果一定对不上。
4.3 缓存命中率混入测试样本,数据虚高
RAG 应用的性能测试还有一个隐蔽污染源:响应缓存。如果压测 Query 集合规模很小,比如只有 100 条,多轮循环之后缓存命中率会非常高。服务端根本不需要重新检索、重新生成,直接返回缓存结果,TTFT 自然低得漂亮。但线上用户输入几乎不会完全重复,缓存命中率远低于这个水平,所以这份报告会误导容量规划。
对策是双管齐下。第一,Query 集合要足够大且有真实分布,尽量采集线上日志里的真实 Query,按频次加权采样来重建压测流量。第二,明确区分“缓存命中场景”和“缓存未命中场景”,分别压测和出报告。缓存命中场景适合评估服务端能支撑多大并发;缓存未命中场景才是评估真实用户体验和系统容量的关键数据。
4.4 指标统计口径不统一,团队对不上账
性能测试团队之间最经典的吵架原因是口径不一致。同样说“TTFT”,有人从连接建立完成开始计时,有人从发送请求体开始计时;有人计入 DNS 解析和排队时间,有人不计入。同样说“并发”,有人理解为每秒新建请求数,有人理解为同时在途的会话数。压测工具显示出的 200 并发,跟服务端监控里看到的活跃会话数,可能完全不是一回事。
我建议团队内部先定一套统一口径,写进测试规范:TTFT 的起止点是“客户端准备发送请求前”到“客户端收到第一个业务 Token”;FT 的终点是“流式结束标记到达”;并发指“同一时刻处于进行中状态的会话数”;分位数指“完整时间窗口内所有请求样本排序后的位置值”。任何报告的指标都必须附带口径说明,写不清楚视为无效数据。这个规则可以省掉一大半扯皮。
4.5 排队与重试:压力一大先崩的是中间层
当并发超过系统的实际处理能力时,请求并不会立刻失败,而是进入各种队列排队:应用线程池队列、任务调度队列、GPU 推理队列。排队时间会被记进 TTFT,但你从模型服务本身可能看不出异常,因为真正的问题在更靠前的调度层和接入层。
还有一个连锁问题:很多客户端设置了较短的超时时间,超时后自动重试,重试请求又排到队尾,导致更多请求超时,形成重试风暴。我在一次压测里见过:服务端实际只接到了 60% 的原始流量,剩余 40% 全是重试请求,最后所有请求一起超时,服务端日志还排得满满当当。排查方法是压测时分层观察:接入层的连接拒绝数、应用层的队列长度、推理服务的等待任务数、客户端的错误类型分布。只要发现超时和重试形成正反馈,就要先放宽超时时间、限制重试次数,再继续加压。
5. 从指标到结论:一套可以直接抄走的 AI 性能测试方案
5.1 场景矩阵先行,别先选工具
拿到一个 AI 应用要做性能测试,我建议你先别急着搭压测工具,先把场景矩阵列出来。参考模板如下:
| 场景 | 输入特征 | 并发目标 | 核心关注指标 |
|---|---|---|---|
| 短问答 | prompt 200 Token 以内,输出 100 Token 以内 | 高并发 | TTFT P95、成功率 |
| 长文生成 | prompt 500 Token 左右,输出 1500 Token 以上 | 中低并发 | FT P95、Token 吞吐 |
| RAG 问答 | 固定语料库,检索片段拼装到 prompt | 中并发 | 检索耗时、TTFT P95、FT P95 |
| 冷启动突发 | 无预热,服务刚启动 | 低并发 | TTFT P99、失败率 |
场景不同,核心指标完全不同。短问答主要看首 Token 时延;长文生成主要看完成时间和吞吐;RAG 场景必须拆开检索与生成两端分别评估。场景矩阵的作用是让所有人先对齐“要回答什么问题”,再去选具体工具,避免拿一把榔头把所有钉子都敲一遍。
5.2 指标组合和 SLO 设定
单看 TTFT 或单看 FT 都有偏颇,我建议固定用一组组合指标:
- TTFT P95 和 P99:评估交互响应速度
- FT P95:评估整段回答完成速度
- 成功率:包括 HTTP 错误、超时、空响应
- Token 吞吐:评估生成效率,单位可以是 Token/秒
- 检索链路耗时占比:区分 RAG 场景下检索和生成的贡献
QPS 可以保留在报告里,但它不再是核心指标,而是作为容量表象的一个参考值。设定 SLO 时不要拍脑袋,可以从线上真实请求的历史分位数倒推。比如线上 TP95 的 TTFT 是 1.8 秒,那就先定 1.5 秒作为目标,留一点余量;等优化到 1.2 秒再压紧。SLO 设定也建议区分场景:短问答和长文生成的 FT 目标必须不同,用同一个阈值会导致短期调优严重偏向某一类场景。
5.3 压测执行与数据汇聚的实操细节
最后说一下执行流程,按顺序走完基本不会出大错:
- 固定环境:模型版本、索引版本、显存配置、并发上限、max_tokens、日志级别。
- 准备数据集和 Query 集,记录语料库大小和平均 prompt Token 数。
- 预热 5 到 10 分钟,丢弃预热数据。
- 分阶段加压,从 10 并发开始,每 3 到 5 分钟提升一档,观察 TTFT 和 FT 是否出现拐点。
- 找到容量拐点后进行 30 分钟以上的长稳测试,重点看连接泄漏、内存增长、响应延迟是否随时间劣化。
- 汇总报告,给出稳定阶段的分位数、吞吐、瓶颈分层和时间线拆分。
时间线拆分是我特别推荐的呈现方式。比如一次典型的 RAG 请求:Query 向量化 80ms、向量检索 45ms、重排序 120ms、prompt 拼装 5ms、模型排队 880ms、模型预填充 650ms、模型生成 4200ms。这张时间线一出来,瓶颈在哪个环节一目了然。没有这种拆解,报告上只有“TTFT 从 500ms 涨到 3s”,团队只能凭感觉猜。
我个人的实际体感是:AI 性能测试真正难的不是学会数 QPS 或者 TTFT,而是愿意先承认用户体感和服务端资源不是一回事。测过了几轮假数据之后,我现在每做一个压测场景,都会先问三句话:用户能等多久?服务端此刻有没有在排队?检索和生成各花了多少时间?把这三个问题回答清楚,性能报告就不太容易翻车。