| 实验台 | 你能动手做什么 | 对应的知识 |
|---|---|---|
| ① Tokenizer 切词台 | 输入任意文本,看它被切成 token 块 | BPE 子词、为什么数字母会错 |
| ② 注意力显微镜 | 调缩放系数,看 6×6 注意力权重热力图实时变化 | Q·K 打分、softmax、因果掩码、指代消解 |
| ③ 前向传播动画 | 播放/单步/调速,看 token 走完全部六站 | Embedding→注意力→FFN→残差→输出 |
| ④ KV Cache 计算器 | 拖滑块配模型,实时算三种注意力方案的显存 | KV Cache 公式、GQA/MLA 的意义 |
建议顺序:先玩 ①② 找手感,再看 ③ 把全流程串起来,最后用 ④ 把「工程问题」变成可计算的数字。每个实验台下面都配了对应源码精读(第 6 节),先玩再看代码,或边玩边对照。
大模型眼里的文字不是「字」也不是「词」,而是 token(子词单元)。切词器(BPE 等算法)按统计规律把高频组合编进词表:常见词整块保留,生僻词拆成小块。先用下面的实验台亲手切一切(本台内置一个约 100 条的教学版迷你词表,真实大模型词表有 10 万+ 条,原理相同):
现在把「注意力」放到显微镜下。玩具句:「小猫 坐在 垫子上 因为 它 很困」——其中的「它」指谁?人一眼看出是小猫,注意力层能不能学会?每个词先被表示成 4 维特征向量(动作性/物体性/位置性/指代性,教学设定),再经过两个可学习矩阵 Wq、Wk 投影出 Q(想找什么)和 K(能提供什么),然后真刀真枪算一遍 打分 → 缩放 → softmax:
其中 s 是缩放系数(下面滑块控制,真实模型里固定为 1,由训练学出合适的分布)。行=「谁在看(Query)」,列=「看谁(Key)」,颜色越深权重越大:
注意力只是其中一站。现在把整个 Decoder 层的流水线搭起来,跟着一个 token 走完六站——用控制条播放、单步、调速(空格键=播放/暂停):
07-06 页说过:推理时 KV Cache 是显存与带宽的第一大户。公式只有一行:
(2=K 和 V 各一份,L=层数,s=序列长度,hkv=KV 头数,dh=头维度,b=每元素字节数)。拖动滑块,看同一个配置下 MHA(不省)/ GQA(分组共享)/ MLA(低秩压缩) 三方案的显存差距——这就是 07-06 第 5.2 节那条演进线的代价账:
下面五段代码与四个实验台一一对应,全部是「骨架级」真实逻辑(简化到每段十几行,保留主干)。逐段给出「逐行讲人话」。
import numpy as np
def attention(Q, K, V, causal=False): # Q,K,V 形状 (n词, d_k维)
d_k = Q.shape[-1]
scores = Q @ K.T / np.sqrt(d_k) # ① 打分:每个词的Q和每个词的K做点积
scores = scores - scores.max(-1, keepdims=True) # ② 减最大值防 exp 溢出(结果不变)
w = np.exp(scores)
w = w / w.sum(-1, keepdims=True) # ③ 每行 softmax 归一化成权重
if causal: # ④ 因果掩码(Decoder 需要)
mask = np.tril(np.ones(w.shape[-2:])) # 下三角=1:只许看自己+前文
w = np.where(mask > 0, w, 0)
w = w / w.sum(-1, keepdims=True)
return w @ V # ⑤ 每个词的新表示=按权重汇聚全句的V
讲人话:①就是实验台②热力图里每个格子的来历;③对应你拖动的缩放滑块(s 大 → exp 拉开差距 → 尖锐);④的「下三角」就是你勾选的因果掩码;⑤得到的新表示送进下一层。整个注意力不过五步,大模型最核心的计算就这么多——难的不是公式,是把它的显存与带宽管好。
def rope(q, pos, theta=10000.0): # q:(d,) 一个词的查询向量,pos=它的位置
half = q.shape[-1] // 2
angles = pos / (theta ** (2*np.arange(half)/q.shape[-1]))
cos, sin = np.cos(angles), np.sin(angles) # 位置 pos → 每个二维子空间一个旋转角
q2 = q.reshape(-1, 2) # 向量拆成 d/2 个平面
return np.concatenate([q2[:,0]*cos - q2[:,1]*sin,
q2[:,0]*sin + q2[:,1]*cos]) # 每个平面旋转对应角度
讲人话:把向量的相邻两维看成平面上的一个点,位置 pos 决定旋转多少度。两个词做点积时,旋转角自动相减——点积结果只依赖「相对位置差」,这就是 RoPE 天然表达相对位置、且能用插值/YaRN 外推到训练时没见过的长度的原因(07-06 第 5.1 节)。
# 32 个 Q 头,只有 8 份 K/V:第 0~3 号 Q 头共用第 0 份 KV,以此类推
kv_expanded = kv.repeat(4, axis=1) # (s, 8, d) -> (s, 32, d) 逻辑展开
scores = q @ kv_expanded.transpose(0, 2, 1) # 照常做点积,数学形式不变
讲人话:GQA 的全部思想就是「4 个 Q 共享 1 份 KV」——存储时只存 8 份,计算时逻辑展开(现代框架用广播视图,不真复制)。KV Cache 和显存读取量直接除以 4,精度几乎无损。这就是它成为 Llama-2-70B 之后开源标配的原因:一行代码,三分之一显存。
logits = x @ W_router.T # 路由器给每个专家打分 (n_tok, n_expert)
topk_idx = np.argpartition(logits, -8)[-8:] # 只要分数最高的 8 个专家
w = softmax(logits[topk_idx]) # 这 8 个人的分数归一化成权重
y = sum(wi * experts[i](x) for wi, i in zip(w, topk_idx)) # 加权求和,其余专家不参与
讲人话:DeepSeek-V3 有 256 个专家,每个 token 只激活 8 个——「argpartition 取前 8」这行就是全部路由逻辑。训练时的难点不在这一行,而在负载均衡:路由器偏心会让少数专家累死、多数专家闲置(07-01 第 8 节的辅助损失/动态偏置就是治这个的)。
past_kv = None # 缓存:历史所有 token 的 K/V
for step in range(max_new_tokens):
logits, past_kv = model(last_token, past_kv=past_kv) # 只喂最新的 1 个 token!
last_token = sample(logits) # 从概率分布采样下一个词
output.append(last_token) # 历史 K/V 已在缓存,无需重算
讲人话:没有 KV Cache 时,生成第 1000 个词要把前 999 个词全部重算一遍——O(n²) 的浪费。缓存后每步只算 1 个新 token 的 Q/K/V,历史的直接取。代价就是实验台④那笔账:省下的是计算,欠下的是显存——cache 越滚越大,decode 阶段每步都要全量读它,这就是「访存密集」的来源(07-06 图⑦)。
四个实验台串起来,就是大模型的完整微观图景:tokenizer 决定模型「看见什么」(token 而非文字),注意力决定「怎么交流」(打分-缩放-softmax-加权),六站流水线决定「怎么思考」(嵌入→交流→消化→堆叠→采样),KV Cache 决定「多贵」(省计算欠显存,一切推理工程围绕它)。把这四件事讲清楚,你就超过了绝大多数只会报模型名的人。配合 07-06 的宏观演进史与 07-01/07-02 的架构深讲,「从 Transformer 到前沿」这条线就闭环了。