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 只把相关片段送进去,成本低、可引用、可更新。只有「需要全局理解、跨文档推理」且检索无法覆盖时,才用长上下文,并配压缩/分页/前缀缓存。