AI应用的质量保障,大家盯着RAG检索命中率、回答忠实度这些"对不对"的指标,但"快不快"——性能测试,往往是最晚补的一课。等线上用户开始骂"转圈圈"才想起来压测,已经晚了。
LLM应用的性能测试和传统Web压测有本质区别:输出是流式的,延迟被拆成TTFT和TPOT两段;结果受prompt缓存影响极大,冷热缓存下TTFT能差一个数量级;压测还会真实烧钱,一次并发压测跑掉几百刀是常态。这篇文章把指标选型、压测架构、缓存感知设计和踩过的坑一次讲清楚。
一、先拆指标:TTFT和TPOT是两回事
传统Web压测看响应时间,LLM应用必须拆开看,因为用户感知完全不同:
| 指标 | 定义 | 用户感知 | 典型量级 |
|---|---|---|---|
| TTFT | 首token延迟(请求发出到第一个token返回) | "有没有反应" | 300ms-3s |
| TPOT | 每输出token耗时(流式节奏) | "打字快不快" | 20-80ms/token |
| E2E | 整轮完成时间 | 总等待 | TTFT + TPOT×长度 |
| Queue Time | 排队时间(网关/推理服务内部) | 高峰期"卡住" | 0-10s+ |
| Cache Hit Rate | 输入token缓存命中率 | 影响TTFT和成本 | 30%-90% |
关键认知:TTFT是缓存和算力调度问题,TPOT是推理引擎和带宽问题。优化手段完全不同——TTFT靠prompt缓存、前缀复用、更快的调度器;TPOT靠小模型、量化、投机解码。不拆开测,你根本不知道瓶颈在哪。
下面是流式计时的最小实现,用OpenAI SDK的流式接口直接量TTFT和TPOT:
import time from openai import OpenAI client = OpenAI() def measure_stream(model: str, messages: list, expected_tokens: int = 200): t0 = time.perf_counter() ttft = None tokens = 0 first_byte = None stream = client.chat.completions.create( model=model, messages=messages, stream=True, stream_options={"include_usage": True} ) for chunk in stream: if ttft is None and chunk.choices and chunk.choices[0].delta.content: ttft = time.perf_counter() - t0 # 首token延迟 if chunk.choices and chunk.choices[0].delta.content: tokens += 1 if chunk.usage: # 流式结束携带usage first_byte = time.perf_counter() - t0 tpot = (first_byte - ttft) / max(tokens, 1) * 1000 # ms/token return {"ttft_ms": round(ttft * 1000, 1), "tpot_ms": round(tpot, 1), "tokens": tokens, "cache_hit": chunk.usage.prompt_tokens_details.cached_tokens > 0} print(measure_stream("gpt-4o-mini", [{"role": "user", "content": "写一段200字的RAG架构说明"}]))注意两个细节:一是必须用流式接口,非流式只能拿到E2E,TTFT完全不可见;二是读prompt_tokens_details.cached_tokens,顺手把缓存命中情况一起记下来,后面分析数据时靠它分组。
二、压测架构:网关层压真实,Provider层压Mock
压测要分两层,不要一上来就打真实模型API:
第一层:网关/应用层(自己可控的部分)。用k6打HTTP接口,验证的是你的编排逻辑、检索链路、限流、队列,这一层可以放心跑高并发:
// k6 脚本:对RAG网关做流式压测,校验SSE与延迟SLO import http from 'k6/http'; import { check, sleep } from 'k6'; import { Rate, Trend } from 'k6/metrics'; const ttft = new Trend('ttft_ms', true); const failRate = new Rate('ttft_slo_fail'); export const options = { scenarios: { ramp: { executor: 'ramping-vus', stages: [ { duration: '2m', target: 20 }, // 预热 { duration: '5m', target: 50 }, // 峰值 { duration: '2m', target: 0 }, // 消退 ], }, }, thresholds: { ttft_ms: ['p(95)<1500'], // TTFT p95 < 1.5s failRate: ['rate<0.01'], // SLO失败率 < 1% }, }; export default function () { const payload = JSON.stringify({ question: '某产品线7月的退款率趋势', top_k: 5 }); const res = http.post('http://gateway:8080/v1/rag/stream', payload, { headers: { 'Content-Type': 'application/json' }, }); // 网关返回SSE流,解析首个 data: 块出现的时间 const firstChunk = res.body.indexOf('data:'); const t = firstChunk >= 0 ? firstChunk : res.timings.duration; ttft.add(t); failRate.add(t > 1500); check(res, { 'status 200': (r) => r.status === 200 }); sleep(1); }第二层:Provider层(模型推理)。真实API并发压测有两个问题:贵、且会被限流(429)干扰结果。正确做法是搭一个record/replay的mock provider——用真实请求录一段响应(含token节奏),压测时按录制的节奏回放。这样:
- 压测可重复,缓存变量被隔离;
- 不烧钱,可以放心压到500并发;
- 网关层的排队、超时、重试逻辑照样被测到。
录制时把TTFT和TPOT的分布也录下来,回放时按分位数采样,比均匀回放更接近真实。
三、缓存感知:不分开冷热缓存,数据全是假的
这是LLM压测和传统压测最大的分水岭。三家主流API的缓存机制差异很大:
| 厂商 | 缓存方式 | 命中收益 | 注意点 |
|---|---|---|---|
| OpenAI | 自动,无需配置 | 输入token约50%折扣,TTFT显著下降 | 前缀≥1024 token才触发,命中与否看cached_tokens |
| Anthropic | 显式cache_control断点 | 缓存段输入约90%折扣,长prompt TTFT可降80%+ | TTL 5分钟,需在system prompt埋断点 |
| Gemini | 隐式,1小时TTL | 命中段计费大幅降低 | 前缀需≥1024 token |
同一个系统提示词(比如塞了产品知识库的system prompt),冷缓存TTFT可能1.8s,热缓存直接掉到300ms。把冷热混在一起压,p95会被冷缓存污染,你会误判网关性能;全用热缓存压,又会把网关的问题掩盖掉。正确做法是分两个场景:
# 压测场景按缓存状态拆分,分别出报告 SCENARIOS = { "cold_cache": {"prompt": random_system_prompt(), "warmup": 0}, # 每次换前缀,模拟冷启动 "warm_cache": {"prompt": FIXED_SYSTEM_PROMPT, "warmup": 2}, # 固定前缀,先跑2轮暖缓存 } def gen_prompt(scenario: str) -> list: if scenario == "cold_cache": return [{"role": "system", "content": f"你是客服助手。会话ID: {uuid4().hex}"}, {"role": "user", "content": USER_QUESTION}] return [{"role": "system", "content": FIXED_SYSTEM_PROMPT}, {"role": "user", "content": USER_QUESTION}]Anthropic侧要主动埋缓存断点,长文档场景收益最大:
from anthropic import Anthropic client = Anthropic() resp = client.messages.create( model="claude-sonnet-4-5", max_tokens=1024, system=[ {"type": "text", "text": KNOWLEDGE_BASE, "cache_control": {"type": "ephemeral"}}, {"type": "text", "text": "你是资深客服,回答要简洁。"}, ], messages=[{"role": "user", "content": question}], ) print(resp.usage) # cache_read_input_tokens / cache_creation_input_tokens报告里务必给两组数字:TTFT_cold_p95和TTFT_warm_p95,上线前的SLO以冷缓存为准(用户第一次访问就是冷缓存),容量规划用热缓存(稳态流量下大部分是热缓存)。
四、踩坑记录
1. 429重试风暴制造延迟假象。并发一高,provider开始限流,客户端SDK自动重试,重试请求又挤占配额,延迟曲线变成锯齿状,p99直接爆表。这不是性能问题,是重试策略问题。压测前先确认:指数退避+抖动上限设了没?重试是否只对可重试错误生效?gateway有没有独立的队列和熔断?
2. 非流式压测完全掩盖TTFT。有人图省事用普通HTTP压测工具打非流式接口,拿到的E2E里TTFT和生成时间混在一起,根本定位不了问题。LLM应用的压测脚本必须消费流。
3. 长短prompt混跑稀释TPOT。短问答TPOT 30ms,长文生成TPOT 55ms,混在一起平均40ms,看起来"还行",实际上长文场景已经超SLO。按用例类型分组出报告,别只给一个总均值。
4. 缓存污染让压测"越跑越快"。同一个system prompt跑10分钟,命中率从0爬到80%,TTFT曲线一路向下,你以为系统变好了,其实是缓存热了。压测时长超过缓存TTL(5分钟)后,务必分段看数据,或干脆按缓存状态分场景。
5. 压测账单。一次50并发、5分钟、gpt-4o级别的压测,账单可能几百刀。Provider层用mock,需要真实数据时用mini档模型或降采样(比如10%流量走真实API)。
一个排查实例:TTFT从1.2s劣化到3.4s。某RAG客服应用,压测数据里TTFT p95从1.2s涨到3.4s,第一反应是模型变慢了。分缓存状态一看:冷缓存场景TTFT没变化,热缓存场景劣化严重——说明问题不在推理,在网关。顺着trace查,发现是新增的"会话摘要"中间件把整段历史对话拼进了system prompt,前缀长度翻倍导致缓存命中失效,命中率从82%掉到31%。修复方式是摘要前置+缓存断点下沉,TTFT p95回到900ms。这个案例说明两件事:指标不按缓存分组,问题定位会走弯路;压测数据要能回溯到单次请求的trace,否则只能靠猜。
五、线上观测:压测数据和线上数据对不上,等于白测
压测只是实验室结果,线上真实流量才是最终裁判。建议把压测和线上观测打通,共用同一套指标定义:压测用k6出报告,线上用Langfuse/LangSmith/Phoenix这类工具盯分位数。线上至少盯四个数:TTFT p95(分冷热缓存)、TPOT p95、缓存命中率、单次请求成本。告警阈值不要用均值,用p95+p99双阈值——均值在LLM场景下几乎没有意义,长尾才是用户真实体验。
一个容易被忽略的点:线上流量的prompt长度分布和压测脚本要一致。压测用的都是几百token的短问题,线上全是带长文档的RAG查询,TPOT和TTFT会系统性偏高。定期从线上采样真实prompt(脱敏后)回放进压测集,让实验室和线上的差距可控。
六、工具选型与SLO基线
| 工具 | 适用层 | 特点 |
|---|---|---|
| k6 | 网关层 | JS脚本、内置阈值和分位数,适合进CI |
| Locust | 网关/自定义客户端 | Python,适合写复杂LLM客户端逻辑 |
| Gatling | 网关层 | Scala,高并发场景性能好 |
| Langfuse / LangSmith | 观测 | 线上真实流量的延迟分位数、成本、缓存命中率 |
| Phoenix (Arize) | 观测 | 追踪+评测一体,可查单次请求的token级耗时 |
工具链搭配建议:k6做压测,Langfuse类工具做线上观测,两者共用同一套指标名(TTFT/TPOT/缓存命中率),压测数据和线上数据才能对得上。
给一套可落地的初始SLO(按业务调整):TTFT p95 < 1.5s(冷缓存)、TPOT p95 < 80ms/token、错误率 < 0.5%、单次问答成本 < $0.05。压测报告里除了达标情况,必须附缓存命中率和成本两个数——LLM应用的性能、成本、质量是三角关系,只看延迟会漏掉一半问题。
总结与进阶方向
LLM应用性能测试的要点就四句话:指标拆到TTFT/TPOT两级,压测分层(网关打真实、Provider打mock),缓存状态分开测,报告绑定成本。把这套跑通,线上"转圈圈"的问题基本能提前暴露。
进阶方向:① 压测进CI,模型版本升级、prompt模板改动时自动跑性能回归(金丝雀对比新旧版本的TTFT分布);② 基于OpenTelemetry GenAI语义约定(gen_ai.*属性)把压测和线上追踪打通,单次请求从网关到provider的全链路耗时可视化;③ 把性能SLO和成本SLO一起做预算管理,超预算自动告警。
如果你也在做 AI 应用(RAG / Agent / LLM),不知道质量怎么测——我最近在给 AI 应用做免费质量体检,出一份可执行的测评报告(检索命中率、回答忠实度、噪声敏感度等维度),感兴趣可以直接私信我。