推理引擎与性能底层
性能篇真正难的不是把 QPS 压上去,而是知道瓶颈到底卡在 prefill、decode、排队、显存、调度还是工具链。对大模型系统来说,请求不是普通 HTTP 文本处理,而是一条会和 GPU 显存、KV Cache、批处理调度、长上下文成本发生复杂耦合的推理链路。
教学导读
**定位:**这一章把"大模型接口很慢"拆成可定位的系统层问题:排队、prefill、decode、缓存、调度、显存和工具等待。 **前置依赖:**建议已学习性能测试基础、Token Budget 和长上下文影响。 **适用场景:**AI 接口压测、流式体验优化、推理引擎选型、量化与缓存策略评估。 **学完产出:**你应该能画出推理链路分段图,并知道性能告警应该落到哪一层去追。
先说结论。
大模型性能测试最怕"只测接口,不测推理"。你看到的接口响应时间,其实是排队时间、prefill 计算、decode 循环、网络传输、工具调用等待综合叠加的结果。不知道底层分段,就不知道慢在哪里,更不知道应该压哪一层。
01. Prefill 与 Decode:一条请求不是一种计算,而是两种完全不同的负载
Prefill(预填充) 是把输入上下文整体过一遍模型,生成初始隐藏状态和 KV Cache;Decode(解码) 是随后一个 token 一个 token 地继续生成。两者在资源消耗和性能特征上差别很大。
| 阶段 | 输入特征 | 主要成本 | 计算类型 | 用户感知 |
|---|---|---|---|---|
| Prefill | 一次性处理全部输入 token | 算力密集(计算瓶颈) | 高度并行的矩阵乘法 | 决定 TTFT(首 token 延迟) |
| Decode | 逐 token 生成 | 带宽密集(显存读写瓶颈) | 序列化的自回归 | 决定流式速度和总时长 |
总耗时 ≈ 排队时间 + Prefill 时间 + Decode 时间 × 输出 token 数 + 工具/网络开销
一条请求的完整时间线:
├── 排队等待 ──┤ Prefill ├── Decode (token 1) ──┤
t0 t1 t2 t3
│ │ │ │
│ │ │ │
↓ ↓ ↓ ↓
请求到达 开始计算 首 token 返回 持续输出...
(用户看到首字)
TTFT = t2 - t0 = 排队 + prefill
TPOT = (t_n - t2) / (n-1) = 平均每个 token 的 decode 时间
Total = t_n - t0 = TTFT + TPOT × (n-1) + overhead1.1 为什么长上下文用户特别拖慢首包
因为 prefill 要把全部输入 token 都先过一遍。对于 Transformer,prefill 的计算复杂度与输入长度的关系: Prefill FLOPs ≈ 2 × L × n_params + 2 × L² × d_model × n_layers 其中 L 是输入 token 数,第一项是线性变换(FFN 等),第二项是注意力(self-attention)。注意力部分是 O(L²) 的,这就是为什么长上下文的 prefill 特别慢。
# Prefill 耗时随输入长度的变化
def estimate_prefill_time(input_tokens, model_params):
"""估算 prefill 时间(简化模型)"""
L = input_tokens
n_layers = model_params["n_layers"]
d_model = model_params["d_model"]
gpu_tflops = model_params["gpu_tflops"]
# 线性部分 FLOPs
linear_flops = 2 * L * model_params["total_params"]
# 注意力部分 FLOPs (O(L²))
attention_flops = 2 * L * L * d_model * n_layers
total_flops = linear_flops + attention_flops
# 转换为时间(假设 GPU 利用率 60%)
time_seconds = total_flops / (gpu_tflops * 1e12 * 0.6)
return time_seconds * 1000 # ms
# 示例:7B 模型在 A100 上
params_7b = {
"n_layers": 32,
"d_model": 4096,
"total_params": 7e9,
"gpu_tflops": 312 # A100 FP16
}
for L in [256, 1024, 4096, 16384, 65536]:
t = estimate_prefill_time(L, params_7b)
print(f"输入 {L:>6} tokens → Prefill ≈ {t:>8.1f} ms")
# 输出(近似):
# 输入 256 tokens → Prefill ≈ 12.3 ms
# 输入 1024 tokens → Prefill ≈ 48.7 ms
# 输入 4096 tokens → Prefill ≈ 235.2 ms
# 输入 16384 tokens → Prefill ≈ 2840.6 ms
# 输入 65536 tokens → Prefill ≈ 42150.0 ms ← 42 秒!01.5 Transformer 推理的计算量分析
一个 Transformer 层在推理时的计算量可以精确分解:
单层 Transformer 的推理 FLOPs:
1. Self-Attention:
Q, K, V 投影: 3 × 2 × L × d_model × d_model = 6Ld²
Attention 分数: 2 × L × L × d_model = 2L²d
输出投影: 2 × L × d_model × d_model = 2Ld²
小计: 8Ld² + 2L²d
2. FFN (Feed-Forward Network):
第一层: 2 × L × d_model × d_ff = 2Ld·d_ff
激活: L × d_ff ≈ 0
第二层: 2 × L × d_ff × d_model = 2Ld·d_ff
小计: 4Ld·d_ff (通常 d_ff = 4d, 所以 = 16Ld²)
3. 总计 (单层):
FLOPs ≈ 24Ld² + 2L²d (标准 Transformer)
4. 全模型 (N 层 + embedding):
Total ≈ N × (24Ld² + 2L²d) + 2L × d × V
其中 V 是词表大小
关键洞察:
- 当 L 较小时: 24Ld² 主导 → 计算瓶颈 (compute-bound)
- 当 L 较大时: 2L²d 主导 → 内存瓶颈 (memory-bound)
- 临界点约在 L ≈ 12d 时 → 对于 d=4096,L ≈ 49KDecode 阶段的算力特征
Decode 每步只处理 1 个新 token,但需要读取所有历史 KV Cache:
Decode 单步 FLOPs:
- 线性变换: 2 × 1 × d_model × (8d + 4d_ff) ≈ 48d² (per layer)
- Attention: 2 × 1 × L_current × d_model ≈ 2Ld (L 随步骤增长)
总计: N × (48d² + 2Ld)
但 decode 的真正瓶颈不是计算,而是显存读取:
- 每步要读 KV Cache: N × L × 2d × dtype_bytes
- 要读模型权重: 全部参数 × dtype_bytes
- GPU 的算力远大于显存带宽 → 带宽瓶颈
这就是为什么 decode 的 TPOT 主要取决于显存带宽而非算力。
A100 算力 312 TFLOPS,但显存带宽仅 2 TB/s。
大部分 decode 时间花在"等数据从显存搬到计算单元"上。02. KV Cache:为什么后续生成能变快,但显存会吃紧
每次 attention 都要用到之前 token 的 Key 和 Value。如果每生成一个 token 都把整个前文重新算一遍,成本会非常高。KV Cache 的作用就是把历史 token 的 K/V 存起来,后续 decode 直接复用。
收益与代价同时存在。
KV Cache 显著降低了 decode 重复计算,但也会持续占显存。上下文越长、并发越高、batch 越大,缓存压力越重。于是性能问题常常不是"GPU 算不动",而是"显存装不下这么多活跃会话的缓存"。
02.5 KV Cache 显存占用的精确推导
KV Cache 显存公式:
Memory_KV = n_layers × seq_len × 2 × n_heads × head_dim × dtype_bytes × batch_size
简化(n_heads × head_dim = d_model):
Memory_KV = n_layers × seq_len × 2 × d_model × dtype_bytes × batch_size
具体计算示例:
模型: LLaMA-2 70B
n_layers = 80
d_model = 8192
dtype = FP16 (2 bytes)
单条请求,上下文长度 4096:
Memory = 80 × 4096 × 2 × 8192 × 2
= 80 × 4096 × 2 × 8192 × 2
= 10,737,418,240 bytes
≈ 10 GB
如果并发 16 条请求:
Memory = 10 × 16 = 160 GB ← 超过单张 A100 80GB!
如果上下文长度为 32K:
Memory = 80 × 32768 × 2 × 8192 × 2
≈ 80 GB ← 单条请求就吃满一张 A100
这就是为什么"长上下文 + 高并发"是推理系统最大的挑战。# KV Cache 显存计算器
def kv_cache_memory_gb(n_layers, d_model, seq_len,
batch_size=1, dtype_bytes=2,
kv_heads=None):
"""
计算 KV Cache 显存占用 (GB)
kv_heads: GQA 中的 KV head 数量
如果为 None,使用标准 MHA (kv_heads = n_heads)
"""
if kv_heads is not None:
# GQA: KV 只在 kv_heads 上存储
head_dim = d_model // (d_model // 128) # 假设 head_dim=128
effective_d = kv_heads * head_dim
else:
effective_d = d_model
bytes_total = (n_layers * seq_len * 2 * effective_d
* dtype_bytes * batch_size)
return bytes_total / (1024**3)
# 典型模型的 KV Cache 占用
models = {
"LLaMA-7B": {"layers": 32, "d": 4096, "kv_heads": None},
"LLaMA-70B": {"layers": 80, "d": 8192, "kv_heads": 8},
"GPT-4级别": {"layers": 120,"d": 12288,"kv_heads": None},
}
print(f"{'模型':<15} {'L=1K':>8} {'L=4K':>8} {'L=32K':>8} {'L=128K':>8}")
for name, m in models.items():
for L in [1024, 4096, 32768, 131072]:
mem = kv_cache_memory_gb(m["layers"], m["d"], L,
kv_heads=m.get("kv_heads"))
print(f"{name:<15} {mem:>7.1f}G", end=" ")
print()02.6 KV Cache 优化技术
GQA (Grouped Query Attention)
多个 query head 共享一组 KV head。LLaMA-2 70B 使用 8 个 KV head(而非 64 个),KV Cache 减少 8 倍。 公式:KV_ratio = n_kv_heads / n_q_heads
MQA (Multi-Query Attention)
GQA 的极端情况:所有 query head 共享 1 组 KV。显存节省最多,但可能影响质量。 **代表:**PaLM、Falcon
KV Cache 量化
将 KV Cache 从 FP16 压缩到 INT8 或 INT4。显存减半或四分之一,但可能引入数值误差。 **风险:**长文本尾部精度下降
Sliding Window Attention
只保留最近 W 个 token 的 KV Cache(如 Mistral 的 W=4096)。显存恒定,但长距离依赖丢失。 **适合:**对话类任务
03. Continuous Batching:为什么混跑不同长度请求会互相影响
传统批处理更像"等一批凑齐再一起上车",而 continuous batching 更像"车一直在跑,新乘客上车后动态插入批次"。它能显著提高吞吐,但不同长度请求混在一起时,也会出现资源争抢和尾延迟问题。
Static Batching
等一批请求都完成后才接下一批。简单但效率低:短请求在等长请求,GPU 大量空闲。
Continuous Batching
请求完成后立即让出位置,新请求随时插入。GPU 利用率大幅提升,但调度更复杂。
测试启发
不要只压同质请求,要压"短 + 中 + 长"混合流量。关注短请求是否被长请求拖死。
# Static vs Continuous Batching 的对比
Static Batching (batch_size=4):
┌─────────────────────────────────────────┐
│ Req A: [████████████████████] │ 长请求
│ Req B: [██████] │ 短请求(完成后闲置)
│ Req C: [████████████] │ 中请求(完成后闲置)
│ Req D: [████] │ 短请求(完成后闲置)
└──────────── 等 A 完成才接下一批 ──────────┘
问题:B/C/D 完成后 GPU slot 空闲,浪费算力
Continuous Batching:
┌─────────────────────────────────────────┐
│ Req A: [████████████████████] │
│ Req B: [██████] Req E: [████████] │ B 完成,E 插入
│ Req C: [████████████] Req F: [██████] │ C 完成,F 插入
│ Req D: [████] Req G: [██████████████] │ D 完成,G 插入
└──────────── 持续满载运行 ──────────────────┘
优势:GPU 利用率从 ~40% 提升到 ~80%+03.5 调度器的关键决策
Continuous batching 的调度器需要在每一个 decode step 做出决策:
class InferenceScheduler:
"""推理调度器的简化模型"""
def __init__(self, max_batch_size, max_total_tokens, gpu_memory_gb):
self.max_batch_size = max_batch_size
self.max_total_tokens = max_total_tokens # KV Cache 总预算
self.gpu_memory_gb = gpu_memory_gb
self.running_requests = []
self.waiting_queue = []
def schedule_step(self) -> list:
"""每个 decode step 的调度决策"""
# 1. 检查是否有请求完成(EOS 或 max_tokens)
completed = [r for r in self.running_requests if r.is_done()]
for r in completed:
self.running_requests.remove(r)
r.finish() # 释放 KV Cache
# 2. 计算当前 KV Cache 占用
current_tokens = sum(
r.current_seq_len for r in self.running_requests
)
# 3. 尝试从等待队列加入新请求
while (self.waiting_queue
and len(self.running_requests) < self.max_batch_size):
next_req = self.waiting_queue[0]
tokens_needed = current_tokens + next_req.input_length
# 检查是否有足够的 KV Cache 空间
if tokens_needed > self.max_total_tokens:
break # 显存不够,等待
# 检查是否需要抢占(preemption)
if self._should_preempt(next_req):
self._preempt_lowest_priority()
self.waiting_queue.pop(0)
self.running_requests.append(next_req)
current_tokens = tokens_needed
# 4. 返回本步要执行的请求
return self.running_requests
def _should_preempt(self, new_req):
"""是否应该抢占低优先级请求"""
if new_req.priority == "high":
low_priority = [r for r in self.running_requests
if r.priority == "low"]
return bool(low_priority)
return False调度对性能测试的影响。
同样的请求在不同的并发场景下,TTFT 和 TPOT 可能差异很大。原因不是模型变慢了,而是调度策略导致的排队和抢占。所以性能测试必须在真实并发下进行,单请求压测的结果几乎不可参考。
04. Prefix Cache 与 Speculative Decoding:快并不总是免费
4.1 Prefix Cache
如果很多请求共享相同的系统提示词、公共上下文或固定前缀,prefix cache 可以复用这部分 prefill 结果,显著降低首包开销。
Prefix Cache 的工作原理:
请求 A: [System Prompt] + [用户问题 1]
请求 B: [System Prompt] + [用户问题 2]
请求 C: [System Prompt] + [用户问题 3]
不使用 Prefix Cache:
A: prefill(System + Q1) → 全量计算
B: prefill(System + Q2) → 全量计算(重复了 System 部分)
C: prefill(System + Q3) → 全量计算(又重复了)
使用 Prefix Cache:
首次: prefill(System) → 缓存 KV
A: load_cache(System) + prefill(Q1) → 只计算增量
B: load_cache(System) + prefill(Q2) → 只计算增量
C: load_cache(System) + prefill(Q3) → 只计算增量
节省比例 ≈ System Prompt 长度 / 总长度
如果 System=2000, Q=200, 则节省 ≈ 90% 的 prefill 计算4.2 Speculative Decoding
核心思想:用一个更小、更快的草稿模型先猜若干 token,再由大模型验证。
Speculative Decoding 工作流程:
Step 1: 草稿模型快速生成 K 个候选 token
Draft model (1.5B) 生成: ["the", "cat", "sat", "on", "the"]
速度: 非常快(小模型)
Step 2: 大模型一次性验证这 K 个 token
Target model (70B) 同时计算所有 K 个位置的概率
验证结果: ["the" ✓, "cat" ✓, "sat" ✓, "on" ✓, "the" ✗→"a"]
Step 3: 接受匹配的 token,拒绝第一个不匹配的
接受 4 个 + 从大模型的分布采样第 5 个
实际输出: "the cat sat on a"
加速比 ≈ K × accept_rate / (1 + K × draft_cost / verify_cost)
典型加速比: 1.5x ~ 3x
关键指标: 接受率 (accept rate)
- accept_rate > 80%: 效果好
- accept_rate < 50%: 可能不划算| 机制 | 适合场景 | 潜在风险 | 对测试的影响 |
|---|---|---|---|
| Prefix Cache | 大量共享前缀请求 | 缓存失效、内存占用管理 | 需要测 cache hit/miss 混合场景 |
| Speculative Decoding | 追求高吞吐低延迟 | 接受率低时收益不明显 | 需要验证输出与标准 decode 一致 |
04.5 vLLM / PagedAttention 到底解决了什么
传统推理框架为每个请求预分配连续的 KV Cache 显存。这会造成两个问题:
传统方式(连续分配):
显存布局:
[Request A KV Cache (预分配 max_len)] [Request B KV Cache] [碎片] [Request C]
问题 1: 内部碎片
Request A 实际生成了 500 token,但预分配了 2048 token 的空间
浪费: 75% 的显存空间
问题 2: 外部碎片
Request B 结束后,留下的空间可能装不下新的长请求
导致明明总显存够用,但无法分配
PagedAttention 的解决方案(类似操作系统的虚拟内存/页式管理):
显存布局(Page Size = 16 tokens):
[Page 0: A] [Page 1: B] [Page 2: A] [Page 3: C] [Page 4: B] ...
每个请求的 KV Cache 不需要连续存放
按需分配页:用到哪里分配到哪里
请求结束立即回收页
效果:
- 内部碎片: 只浪费最后一页的部分空间 (≤ page_size)
- 外部碎片: 基本消除
- 显存利用率: 从 ~50-60% 提升到 ~95%+
- 同等显存下可支持的并发数: 提升 2-4 倍测试人员要记的不是实现细节,而是影响路径。
如果推理引擎升级到了更高效的 cache 管理方案(如 PagedAttention),你应该重点盯:长上下文并发容量、尾延迟、短长请求混跑表现、缓存命中与显存占用,而不是只看平均 QPS。
05. Quantization:为什么便宜了,却可能让边界 case 更脆
量化通过降低权重或激活精度,换来更低显存占用和更高吞吐。但代价是数值精度下降,某些边界 case、长链推理、细粒度结构化输出可能更敏感。
| 精度 | 位宽 | 显存(7B模型) | 相对速度 | 质量影响 |
|---|---|---|---|---|
| FP32 | 32 bit | ~28 GB | 1x (基准) | 无损 |
| FP16/BF16 | 16 bit | ~14 GB | ~2x | 几乎无损 |
| INT8 | 8 bit | ~7 GB | ~2.5x | 通用任务 <1% 退化 |
| INT4 (GPTQ/AWQ) | 4 bit | ~3.5 GB | ~3x | 边界 case 可感知退化 |
| GGUF Q4_K_M | ~4.5 bit | ~4 GB | ~2.8x | 综合质量较好的 4bit |
05.5 量化的数学原理
线性量化的基本公式:
量化 (float → int):
x_q = round((x - zero_point) / scale)
反量化 (int → float):
x_dq = x_q × scale + zero_point
其中:
scale = (max_val - min_val) / (2^bits - 1)
zero_point = round(-min_val / scale)
量化误差:
error = x - x_dq = x - (round((x - zp) / s) × s + zp)
最大误差 = scale / 2
对于 INT8: scale ≈ range / 255
对于 INT4: scale ≈ range / 15 ← 误差是 INT8 的 17 倍!
这就是为什么 INT4 在边界 case 上更脆弱:
同样的权重范围,4bit 的量化步长是 8bit 的 17 倍。# 量化对模型质量的影响评估
def evaluate_quantization_impact(model_fp16, model_quantized,
eval_set, judge):
"""对比量化前后的质量"""
results = {"fp16": [], "quantized": []}
for case in eval_set:
# FP16 基准
output_fp16 = model_fp16.generate(case["input"])
score_fp16 = judge.evaluate(output_fp16, case["expected"])
results["fp16"].append(score_fp16)
# 量化版本
output_q = model_quantized.generate(case["input"])
score_q = judge.evaluate(output_q, case["expected"])
results["quantized"].append(score_q)
# 按 slice 分析退化
slices = {
"general_qa": [],
"math_reasoning": [],
"structured_output": [],
"long_context": [],
"edge_cases": [],
}
for case, s_fp16, s_q in zip(eval_set, results["fp16"],
results["quantized"]):
delta = s_q - s_fp16
for tag in case.get("tags", []):
if tag in slices:
slices[tag].append(delta)
print("量化影响分析 (分数变化):")
for slice_name, deltas in slices.items():
if deltas:
mean_delta = np.mean(deltas)
print(f" {slice_name}: {mean_delta:+.3f} "
f"({'退化' if mean_delta < -0.05 else '可接受'})")量化上线前要跑的不是"平均分",而是高风险 slice。
对大多数通用问答,量化后的感知差异未必明显。但对长推理、数学、多步工具调用、极端边界条件,误差更可能放大。所以量化评估的重点是高风险 slice 而不是整体平均分。
06. TTFT、TPOT、吞吐应该怎么一起看
| 指标 | 含义 | 公式 | 主要关联层 | 测试解读 |
|---|---|---|---|---|
| TTFT | 首 token 延迟 | T(first_token) - T(request) | 排队 + prefill | 长上下文、批处理、缓存效果 |
| TPOT | 每输出 token 时间 | (T_end - T_first) / (n-1) | decode | 流式体验是否顺畅 |
| Tokens/s | 吞吐率 | 总输出 tokens / 总时间 | 整体引擎 | 资源利用率和部署效率 |
| P95/P99 | 尾延迟 | 分位数统计 | 调度 + 并发竞争 | 线上稳定性是否可接受 |
| ITL | Token 间延迟 | T(token_n) - T(token_{n-1}) | decode + 调度 | 流式输出是否有卡顿 |
不要只看平均值。
平均 TTFT 看起来不错,不代表高峰期没有长尾;平均吞吐很高,不代表短请求不会被拖死。企业上线决策真正看的是分位数、混合流量表现和退化曲线。
06.5 容量估算框架
class CapacityEstimator:
"""推理服务容量估算器"""
def __init__(self, model_config, gpu_config):
self.model = model_config
self.gpu = gpu_config
def estimate(self, traffic_profile: dict) -> CapacityReport:
"""
traffic_profile: {
"avg_input_tokens": 3000,
"avg_output_tokens": 400,
"concurrent_sessions": 50,
"long_context_ratio": 0.20, # 20% 长上下文
"peak_rps": 10,
}
"""
tp = traffic_profile
# 1. KV Cache 显存需求
kv_memory = self._estimate_kv_memory(tp)
# 2. 模型权重显存
model_memory = self._model_memory()
# 3. 总显存需求
total_memory = model_memory + kv_memory
# 4. 所需 GPU 数量
gpus_needed = math.ceil(
total_memory / (self.gpu["memory_gb"] * 0.85)
)
# 5. 吞吐估算
throughput = self._estimate_throughput(tp, gpus_needed)
# 6. 瓶颈分析
bottleneck = self._identify_bottleneck(tp)
return CapacityReport(
kv_memory_gb=kv_memory,
model_memory_gb=model_memory,
total_memory_gb=total_memory,
gpus_needed=gpus_needed,
estimated_throughput=throughput,
bottleneck=bottleneck,
headroom_ratio=gpus_needed * self.gpu["memory_gb"] / total_memory
)
def _estimate_kv_memory(self, tp):
avg_seq = tp["avg_input_tokens"] + tp["avg_output_tokens"]
long_seq = avg_seq * 4 # 长上下文用户
normal_mem = kv_cache_memory_gb(
self.model["n_layers"], self.model["d_model"],
avg_seq,
int(tp["concurrent_sessions"] * (1 - tp["long_context_ratio"]))
)
long_mem = kv_cache_memory_gb(
self.model["n_layers"], self.model["d_model"],
long_seq,
int(tp["concurrent_sessions"] * tp["long_context_ratio"])
)
return normal_mem + long_mem
def _model_memory(self):
return self.model["total_params"] * self.model["dtype_bytes"] / (1024**3)
def _identify_bottleneck(self, tp):
avg_input = tp["avg_input_tokens"]
if avg_input > 8000:
return "prefill_compute" # 长输入 → prefill 是瓶颈
if tp["concurrent_sessions"] > 100:
return "kv_cache_memory" # 高并发 → 显存是瓶颈
if tp["peak_rps"] > 50:
return "decode_bandwidth" # 高 QPS → decode 带宽瓶颈
return "balanced"给定场景估算示例:
模型: LLaMA-70B (FP16)
GPU: 4× A100 80GB
流量画像:
平均输入: 3000 tokens
平均输出: 400 tokens
并发会话: 50
长上下文占比: 20%
估算结果:
模型权重: ≈ 140 GB (需要 2 张 A100 做 tensor parallel)
KV Cache:
普通用户 40×3400 token ≈ 17.5 GB
长上下文 10×13600 token ≈ 17.5 GB
总计 ≈ 35 GB
总需求: 140 + 35 = 175 GB
4× A100 (320 GB) → 利用率 55%
预留空间: 用于应对突发的长上下文用户增加
瓶颈预判:
- 正常负载: decode 带宽是瓶颈
- 长上下文占比翻倍时: KV Cache 显存成为瓶颈
- 建议: 监控 P95 TTFT 和显存使用率07. 压测框架设计
import time
import asyncio
import numpy as np
from dataclasses import dataclass
from typing import Optional
@dataclass
class BenchmarkResult:
ttft_ms: float
tpot_ms: float
total_ms: float
input_tokens: int
output_tokens: int
status: str
error: Optional[str] = None
class LLMBenchmark:
"""大模型推理压测框架"""
def __init__(self, client, config):
self.client = client
self.config = config
async def run_benchmark(self,
test_cases: list[dict],
concurrency: int,
duration_seconds: int = 60) -> BenchmarkReport:
"""运行压测"""
results = []
semaphore = asyncio.Semaphore(concurrency)
start_time = time.time()
async def single_request(case):
async with semaphore:
result = await self._timed_request(case)
results.append(result)
# 持续发送请求直到时间结束
tasks = []
case_idx = 0
while time.time() - start_time < duration_seconds:
case = test_cases[case_idx % len(test_cases)]
tasks.append(asyncio.create_task(single_request(case)))
case_idx += 1
await asyncio.sleep(1.0 / self.config["target_rps"])
await asyncio.gather(*tasks, return_exceptions=True)
return self._analyze_results(results)
async def _timed_request(self, case) -> BenchmarkResult:
"""带计时的单次请求"""
t_start = time.time()
first_token_time = None
output_tokens = 0
try:
async for chunk in self.client.stream(
case["input"], **case.get("params", {})
):
if first_token_time is None:
first_token_time = time.time()
output_tokens += 1
t_end = time.time()
ttft = (first_token_time - t_start) * 1000
total = (t_end - t_start) * 1000
tpot = ((t_end - first_token_time) * 1000 /
max(output_tokens - 1, 1))
return BenchmarkResult(
ttft_ms=ttft,
tpot_ms=tpot,
total_ms=total,
input_tokens=case.get("input_tokens", 0),
output_tokens=output_tokens,
status="success"
)
except Exception as e:
return BenchmarkResult(
ttft_ms=0, tpot_ms=0, total_ms=0,
input_tokens=0, output_tokens=0,
status="error", error=str(e)
)
def _analyze_results(self, results) -> dict:
"""分析压测结果"""
success = [r for r in results if r.status == "success"]
errors = [r for r in results if r.status == "error"]
if not success:
return {"error": "全部失败", "error_count": len(errors)}
ttfts = [r.ttft_ms for r in success]
tpots = [r.tpot_ms for r in success]
totals = [r.total_ms for r in success]
return {
"total_requests": len(results),
"success_count": len(success),
"error_count": len(errors),
"error_rate": len(errors) / len(results),
"ttft": {
"p50": np.percentile(ttfts, 50),
"p95": np.percentile(ttfts, 95),
"p99": np.percentile(ttfts, 99),
"mean": np.mean(ttfts),
},
"tpot": {
"p50": np.percentile(tpots, 50),
"p95": np.percentile(tpots, 95),
"p99": np.percentile(tpots, 99),
"mean": np.mean(tpots),
},
"total_latency": {
"p50": np.percentile(totals, 50),
"p95": np.percentile(totals, 95),
"p99": np.percentile(totals, 99),
},
"throughput_tokens_per_sec": sum(
r.output_tokens for r in success
) / (max(totals) / 1000),
}08. 企业实战案例
8.1 案例:从"接口慢"到"定位 prefill 瓶颈"
背景:
某企业 RAG 客服系统,TTFT P99 从 800ms 飙升到 3200ms。
团队最初以为是"模型变慢了"。
分段定位过程:
Step 1: 分离排队时间
→ 排队时间 P99 从 50ms 变成 1200ms
→ 说明有大量请求在等待 GPU slot
Step 2: 检查并发量
→ 并发从 20 增加到 45(业务增长)
→ KV Cache 显存使用率从 60% 飙到 92%
Step 3: 分析请求长度分布
→ 新上线的"文档总结"功能平均输入 8000 tokens
→ 之前的客服问答平均输入仅 1500 tokens
→ 长请求的 prefill 时间占用了大量 GPU 算力
Step 4: 根因
→ 长请求 prefill 占用 GPU 时间过长
→ 导致短请求排队等待
→ KV Cache 接近满载,调度器开始拒绝新请求
解决方案:
1. 分流:长请求路由到独立 GPU 集群
2. Prefix Cache:文档总结共享大量公共 system prompt
→ prefill 时间降低 60%
3. 增加 GPU(从 4 张 A100 增加到 6 张)
4. 设置请求级 KV Cache 预算上限
效果:
TTFT P99: 3200ms → 750ms (-77%)
短请求 TTFT P99: 2800ms → 400ms (-86%)
GPU 利用率: 92% → 72% (更健康的水位)8.2 案例:量化上线前的质量评估
场景:将 70B 模型从 FP16 量化到 INT4 (AWQ)
评估策略:
不只看整体通过率,而是按 slice 分析
结果:
整体通过率: FP16=92.3% INT4=91.1% (差距 1.2%)
一般问答: FP16=95.0% INT4=94.5% (可接受)
数学推理: FP16=82.0% INT4=75.0% (退化 7%!)
长链工具调用: FP16=88.0% INT4=80.0% (退化 8%!)
结构化输出: FP16=97.0% INT4=94.0% (边缘可接受)
安全分类: FP16=99.0% INT4=98.5% (可接受)
决策:
- 客服问答:使用 INT4(成本降低 4 倍,质量影响可控)
- 数学/推理:保持 FP16(质量退化不可接受)
- 结论:不是"用不用量化"的二选一,
而是"哪些场景可以用量化"的分场景决策09. 测试设计清单
- 按请求类型拆压测:短输入短输出、长输入短输出、短输入长输出、长输入长输出。
- 混合流量压测,模拟线上真实分布,关注短请求被长请求拖垮的程度。
- 分段记录:排队时间、TTFT、TPOT、总 tokens、工具等待。
- 对开启与关闭 cache、batching、quantization、speculative decode 做 A/B 对照。
- 对长上下文单独做容量测试,观察显存与尾延迟拐点。
- 量化评估按 slice 做,不只看整体平均分。
- 建立容量估算模型,预测流量增长下的资源需求。
- 监控 ITL(token 间延迟)发现流式输出卡顿。
9.1 课堂练习
- 如果一个接口 TTFT 很高但 TPOT 正常,你会优先怀疑 prefill、排队还是 decode?写出你的排查步骤。
- 设计一个混合压测场景,同时覆盖短问答、长 RAG 问答和流式写作请求。给出流量比例和监控指标。
- 给出一个量化后吞吐提升但边界 case 退化的排查思路。需要对比哪些 slice?
- 为一个 70B 模型在 4×A100 上估算 KV Cache 能支持的最大并发数(假设平均 seq_len=4096)。
9.2 参考答案要点
- TTFT 高但 TPOT 正常,通常先查排队和 prefill。排查步骤:(1) 看排队时间占比 (2) 看输入长度分布 (3) 看并发量和 KV Cache 使用率 (4) 看 prefix cache 命中率。
- 混合压测建议:60% 短问答(512 token)、25% 长 RAG(4096 token)、15% 写作(8K+ token)。监控:TTFT P95 按类型分组、TPOT P95、显存使用率、短请求被拖慢的程度(对比单独压测的基线)。
- 量化退化排查:优先对比数学推理、长链工具调用、结构化输出、安全分类这几个高风险 slice。如果退化集中在数学/推理,说明量化精度损失在多步计算中被累积放大。
- KV Cache 估算:70B 模型 KV (80层, d=8192, GQA kv_heads=8),seq=4096,FP16。单请求 KV ≈ 80 × 4096 × 2 × (8×128) × 2 = 1.07 GB。模型权重 ≈ 140 GB (2 GPU)。剩余 2 GPU (160 GB) 给 KV Cache,最大并发 ≈ 160/1.07 ≈ 149 个会话。考虑安全系数(80%):≈ 119 个并发。
9.3 自测标准
学完这一页后,你应该能:
- 解释 prefill、decode、TTFT、TPOT 之间的关系和各自的计算瓶颈。
- 说明 KV Cache 为什么快、为什么占显存,并能估算显存占用。
- 知道 continuous batching 为什么会让短长请求互相影响。
- 理解 PagedAttention、Prefix Cache、Speculative Decoding 的原理和测试关注点。
- 按 slice 评估量化影响,而不是只看整体平均分。
- 把性能测试从"压 API"升级成"压推理系统",并能做分段定位。