Skip to content

推理引擎与性能底层

性能篇真正难的不是把 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) + overhead

1.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 ≈ 49K

Decode 阶段的算力特征

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 做出决策:

python
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模型)相对速度质量影响
FP3232 bit~28 GB1x (基准)无损
FP16/BF1616 bit~14 GB~2x几乎无损
INT88 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尾延迟分位数统计调度 + 并发竞争线上稳定性是否可接受
ITLToken 间延迟T(token_n) - T(token_{n-1})decode + 调度流式输出是否有卡顿

不要只看平均值。

平均 TTFT 看起来不错,不代表高峰期没有长尾;平均吞吐很高,不代表短请求不会被拖死。企业上线决策真正看的是分位数、混合流量表现和退化曲线。

06.5 容量估算框架

python
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. 压测框架设计

python
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. 测试设计清单

  1. 按请求类型拆压测:短输入短输出、长输入短输出、短输入长输出、长输入长输出。
  2. 混合流量压测,模拟线上真实分布,关注短请求被长请求拖垮的程度。
  3. 分段记录:排队时间、TTFT、TPOT、总 tokens、工具等待。
  4. 对开启与关闭 cache、batching、quantization、speculative decode 做 A/B 对照。
  5. 对长上下文单独做容量测试,观察显存与尾延迟拐点。
  6. 量化评估按 slice 做,不只看整体平均分。
  7. 建立容量估算模型,预测流量增长下的资源需求。
  8. 监控 ITL(token 间延迟)发现流式输出卡顿。

9.1 课堂练习

  1. 如果一个接口 TTFT 很高但 TPOT 正常,你会优先怀疑 prefill、排队还是 decode?写出你的排查步骤。
  2. 设计一个混合压测场景,同时覆盖短问答、长 RAG 问答和流式写作请求。给出流量比例和监控指标。
  3. 给出一个量化后吞吐提升但边界 case 退化的排查思路。需要对比哪些 slice?
  4. 为一个 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"升级成"压推理系统",并能做分段定位。