64.7 Transformer 与大模型推理

一句话

Transformer 把「理解上下文」变成一次矩阵乘法,把「生成」变成自回归循环;大模型推理的性能瓶颈不在 FLOPs,而在显存带宽与 KV Cache:prefill 是算力密集,decode 是带宽密集。

📚 参考:Hello-Agents 第 3 章《大语言模型基础》对 Transformer、BPE、缩放法则与幻觉的讲解。

一个 Block:注意力 + FFN + 残差 + 归一化

组件 公式/作用 工程含义
自注意力 Attention(Q,K,V)=softmax(QKᵀ/√d_k)V 每个 token 与上下文所有 token 交互,O(n²)
多头 把 Q/K/V 切成 h 份,各自注意力后拼接 不同头学不同关系;头数影响并行与 KV 大小
位置编码 正弦/可学习/RoPE 自注意力本身无序,必须注入位置
FFN W2·ReLU(W1·x),中间维通常 4× 大部分参数与计算在这里
残差 + LayerNorm LN(x + Sublayer(x)) 深层训练稳定;pre-norm 更常见
因果掩码 上三角置 −∞ decoder-only 只能看左边,保证自回归

🔑 自注意力的 O(n²) 是长上下文成本的根本来源:序列翻倍,注意力矩阵变 4 倍;这也是为什么长上下文需要 FlashAttention、稀疏/线性注意力、KV Cache 分页等工程优化。

Encoder-Decoder 与 Decoder-Only

Encoder-Decoder(原始 Transformer) Decoder-Only(GPT 类)
结构 编码器理解输入 + 解码器生成 只保留解码器,自回归预测下一个 token
注意力 编码器双向;解码器因果 + 交叉注意力 因果自注意力
训练目标 序列到序列(翻译、摘要) 下一个 token 预测
代表 T5、BART GPT、Llama、Qwen、DeepSeek
推理 编码一次 + 解码多步 prefill + decode

💡 现代大模型几乎都是 decoder-only:结构简单、 scaling 好、同一个目标函数适配问答/代码/推理。理解 decoder-only 的推理,重点就是 prefill 与 decode 两阶段。

代码:因果注意力 + KV Cache 的账

package main

import (
	"fmt"
	"math"
)

func softmax(x []float64) []float64 {
	m := x[0]
	for _, v := range x {
		if v > m {
			m = v
		}
	}
	s := 0.0
	out := make([]float64, len(x))
	for i, v := range x {
		out[i] = math.Exp(v - m)
		s += out[i]
	}
	for i := range out {
		out[i] /= s
	}
	return out
}

func main() {
	// 4 个 token,每个 4 维;为了可复现,Q/K/V 用固定值。
	Q := [][]float64{{1, 0, 1, 0}, {0, 1, 0, 1}, {1, 1, 0, 0}, {0, 0, 1, 1}}
	K := [][]float64{{1, 0, 0, 1}, {0, 1, 1, 0}, {1, 0, 1, 0}, {0, 1, 0, 1}}

	fmt.Println("=== 因果自注意力(seq=4, d=4, 单头)===")
	fmt.Println("注意力权重(每行和为 1,只能看自己和左边):")
	for i := range Q {
		scores := make([]float64, len(K))
		for j := range K {
			if j > i { // 因果掩码
				scores[j] = math.Inf(-1)
				continue
			}
			dot := 0.0
			for d := 0; d < 4; d++ {
				dot += Q[i][d] * K[j][d]
			}
			scores[j] = dot / math.Sqrt(4)
		}
		w := softmax(scores)
		fmt.Printf("  token%d: ", i)
		for _, x := range w {
			fmt.Printf("%.3f ", x)
		}
		fmt.Println()
	}

	fmt.Println("\n=== KV Cache 显存(fp16,32k 上下文)===")
	type cfg struct {
		name          string
		layers, heads int
		dim           int
		weightsGB     float64
	}
	cfgs := []cfg{{"7B", 32, 32, 128, 14}, {"70B", 80, 64, 128, 140}, {"70B-GQA(8 KV heads)", 80, 8, 128, 140}}
	fmt.Printf("%-22s %-14s %-14s %-14s %s\n", "model", "KV/token", "KV@8k", "KV@32k", "weights(fp16)")
	for _, c := range cfgs {
		perToken := 2 * c.layers * c.heads * c.dim * 2 // K + V,2 字节
		mib := float64(perToken) / (1 << 20)
		kv8 := mib * 8192 / 1024
		kv32 := mib * 32768 / 1024
		fmt.Printf("%-22s %-14.2f %-14.1f %-14.1f %.0f GB\n", c.name, mib, kv8, kv32, c.weightsGB)
	}

	fmt.Println("\n=== decode 是内存带宽瓶颈(2 TB/s)===")
	for _, m := range []struct {
		name string
		gb   float64
	}{{"7B fp16", 14}, {"70B fp16", 140}, {"70B int4", 35}} {
		fmt.Printf("  %-10s 权重 %.0f GB -> 单 token 下界 %.1f ms(还没算 KV cache 读取)\n",
			m.name, m.gb, m.gb/2000*1000)
	}
	fmt.Println("\n结论:prefill 是算力密集,decode 是带宽密集;上下文越长,KV cache 越可能先于权重压垮显存。")
}

本机实测(macOS,Apple M1,Go 1.27.1):

=== 因果自注意力(seq=4, d=4, 单头)===
注意力权重(每行和为 1,只能看自己和左边):
  token0: 1.000 0.000 0.000 0.000 
  token1: 0.500 0.500 0.000 0.000 
  token2: 0.333 0.333 0.333 0.000 
  token3: 0.250 0.250 0.250 0.250 

=== KV Cache 显存(fp16,32k 上下文)===
model                  KV/token       KV@8k          KV@32k         weights(fp16)
7B                     0.50           4.0            16.0           14 GB
70B                    2.50           20.0           80.0           140 GB
70B-GQA(8 KV heads)    0.31           2.5            10.0           140 GB

=== decode 是内存带宽瓶颈(2 TB/s)===
  7B fp16    权重 14 GB -> 单 token 下界 7.0 ms(还没算 KV cache 读取)
  70B fp16   权重 140 GB -> 单 token 下界 70.0 ms(还没算 KV cache 读取)
  70B int4   权重 35 GB -> 单 token 下界 17.5 ms(还没算 KV cache 读取)

结论:prefill 是算力密集,decode 是带宽密集;上下文越长,KV cache 越可能先于权重压垮显存。

读数:

模型 KV/token 8k 上下文 32k 上下文 权重(fp16)
7B(32 层 × 32 头 × 128) 0.50 MiB 4 GiB 16 GiB 14 GB
70B(80 层 × 64 头 × 128) 2.50 MiB 20 GiB 80 GiB 140 GB
70B + GQA(8 个 KV 头) 0.31 MiB 2.5 GiB 10 GiB 140 GB

🔑 70B 模型 32k 上下文的 KV Cache 单独就要 80 GiB,已经超过一张 80GB 卡的可用显存;GQA 把 KV 头从 64 降到 8,直接降到 10 GiB——GQA/MQA 不是「效果更好」,而是「让长上下文在显存里放得下」。

Prefill 与 Decode:两个完全不同的阶段

Prefill Decode
输入 整段 prompt(n 个 token) 上一步生成的 1 个 token
计算 矩阵乘为主,可并行 逐 token,串行
瓶颈 算力(FLOPs) 显存带宽(读权重 + KV)
关键指标 TTFT(首 token 延迟) TPOT(每 token 延迟)
优化 FlashAttention、chunked prefill KV Cache、continuous batching、量化

粗略下界:

decode 单 token 时间 >= 模型权重字节数 / 显存带宽

7B fp16 = 14 GB,2 TB/s → 7 ms/token;70B fp16 = 140 GB → 70 ms/token。实际还要读 KV Cache、做通信/调度,通常更高。

⚠️ 用「GPU 利用率 100%」判断 LLM 服务好是错的:decode 阶段 GPU 计算单元大量空闲,瓶颈在 HBM 带宽。真正该看的是 tokens/s、TTFT、TPOT、KV Cache 命中/占用、batch 内 token 数。

四个工程优化

优化 解决什么 代价
Continuous batching 不同请求动态拼 batch,提高吞吐 调度复杂,尾延迟
PagedAttention KV Cache 分页,减少碎片 实现复杂(vLLM 的核心)
量化(INT8/FP8/INT4) 权重/激活更小,decode 更快 精度损失,需校准
投机解码 小模型草稿 + 大模型验证 额外算力,收益依赖接受率
MoE 只激活部分专家,降计算 路由/负载均衡/显存挑战

🔑 长上下文不是「把窗口开大」就完事:KV Cache 线性增长、注意力 O(n²)、首 token 延迟上升。生产上要配「上下文压缩/摘要、RAG 检索、分段处理」,而不是无脑塞满窗口(见 64.8 Agent Harness:工具、上下文与沙箱)。

生产落地:指名 → 可抄 → 可验 → 可退

指名:vLLM(PagedAttention、continuous batching)、TensorRT-LLM(NVIDIA 优化)、SGLang(结构化生成/前缀缓存);云上用 Bedrock/Vertex/百炼等托管推理。

可抄(vLLM 0.6+,未验证:本机未安装 GPU/vLLM):

python3 -m vllm.entrypoints.openai.api_server \
  --model Qwen/Qwen2.5-7B-Instruct \
  --max-model-len 32768 \
  --gpu-memory-utilization 0.90 \
  --max-num-seqs 256 \
  --enable-prefix-caching

可验:用固定 prompt 测 TTFT/TPOT/tokens/s;用 nvidia-smi 看显存与 HBM 带宽;对比开启/关闭 prefix caching、不同 max-num-seqs 的吞吐。可退:量化/批大小/上下文长度都是配置,可回退;长上下文请求超时降级到 RAG/摘要;模型版本与 tokenizer 绑定,回滚时一起切。

反模式

反模式 后果
只看 FLOPs 不看显存带宽 优化方向错,decode 仍然慢
长上下文直接塞满 KV Cache 爆显存、TTFT 飙升
忽略 GQA/MQA 70B 32k 光 KV 就 80 GiB
用平均 token 延迟掩盖 TPOT 抖动 用户体验不稳定
不做 continuous batching GPU 利用率低、吞吐差
量化不做校准 精度掉点,输出异常
把 prefill 与 decode 混在一起压测 指标不可比,容量算错
不固定 tokenizer/模型版本 回滚后 token 边界变化,输出错乱

自检

Q1 自注意力的计算复杂度为什么是 O(n²)?对长上下文意味着什么?

▸ 答案:每个 token 的 Q 要与所有 token 的 K 做点积,得到 n×n 注意力矩阵;序列翻倍,矩阵变 4 倍,计算与显存都按平方增长。长上下文因此需要 FlashAttention(减少显存读写)、稀疏/线性注意力、KV Cache 分页、RAG 等工程手段,不能只靠「窗口开大」。

Q2 Prefill 与 Decode 的瓶颈有什么不同?

▸ 答案:Prefill 一次处理整段 prompt,矩阵乘为主,瓶颈在算力(FLOPs),指标是 TTFT;Decode 每次只生成 1 个 token,要读全部权重与 KV Cache,瓶颈在显存带宽,指标是 TPOT。优化手段也不同:prefill 用 FlashAttention/chunked prefill,decode 用 KV Cache/continuous batching/量化。

Q3 70B 模型 32k 上下文的 KV Cache 为什么可能比权重还致命?

▸ 答案:70B(80 层 × 64 头 × 128 维,fp16)每 token KV 约 2.5 MiB,32k 就是 80 GiB;权重 140 GB 可以多卡分片,但 KV Cache 按请求/序列增长,直接吃单卡显存。GQA 把 KV 头降到 8,32k 只要 10 GiB,才让长上下文可行。

Q4 为什么 decode 用「权重字节数 / 带宽」估下界?实际为什么更高?

▸ 答案:decode 每生成一个 token 都要把全部权重读一遍(batch 内可复用),所以时间下界是权重字节数/显存带宽;实际还要读 KV Cache、做 attention、通信/调度、采样,且带宽利用率达不到 100%,所以真实 TPOT 更高。

Q5 Continuous batching 与静态 batching 的区别是什么?

▸ 答案:静态 batching 等一批请求凑齐/全部完成才处理下一批,短请求被长请求拖住;continuous batching 在每个 decode step 动态加入新请求、移除已完成请求,GPU 不空等,吞吐更高。代价是调度复杂、尾延迟与公平性要处理。

Q6 GQA/MQA 解决什么问题?代价是什么?

▸ 答案:GQA/MQA 让多个 query 头共享少量 KV 头,减少 KV Cache 与 attention 的显存/带宽,使长上下文/大模型放得下;代价是表达力略降(MQA 最激进),需要通过训练/校准验证效果,不能直接套到已训练模型上。

Q7 长上下文 RAG 场景,应该扩上下文还是做检索?

▸ 答案:优先检索/摘要,而不是无脑扩上下文。扩上下文会增加 KV Cache、TTFT、成本,并可能因 context rot 降低精度;RAG 只把相关片段送进去,成本低、可引用、可更新。只有「需要全局理解、跨文档推理」且检索无法覆盖时,才用长上下文,并配压缩/分页/前缀缓存。

关联