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"升级成"压推理系统",并能做分段定位。