26 KiB
第 12 课:RoPE 与 KV cache
这一课只解决一个问题:MiniMind 如何给 Q/K 注入位置信息,并在生成时复用历史 K/V,做到每步只计算新 token 但仍然和整段 forward 对齐。
目录
- 0. 本节主线
- 1. 本节要懂的 6 个层次
- 2. RoPE 与 KV cache 一次完整原理讲解
- 3. 从 generate 到 Attention 的完整源码走读
- 4. 源码变量字典
- 5. 关键源码对照
- 6. 实验验证
- 7. 本节检查
- 8. 下一课
0. 本节主线
RoPE 与 KV cache 的完整逻辑是:
hidden_states
-> 投影成 Q/K/V
-> 对 Q/K 做 norm
-> 按当前位置取出 cos/sin
-> 用 RoPE 旋转 Q/K,让 attention 打分带上位置信息
-> Attention 用带位置的 Q/K 算分数,用 V 汇总内容
-> use_cache=True 时保存当前层的 K/V
-> 下一步生成只输入新 token
-> 用历史 cache 长度得到 start_pos
-> 新 token 的 Q/K 用正确位置的 RoPE
-> 新 K/V 拼到旧 K/V 后继续 attention
一句话:
RoPE 解决“当前位置怎么进入 attention 打分”,KV cache 解决“历史 token 的 K/V 不要每步重算”,二者靠 start_pos 对齐。
这节课不是单独背 RoPE 公式,也不是单独看 cache 变量。你要形成一个完整观念:
如果生成第 100 个 token,只输入这个新 token,
它的 RoPE 位置仍然必须是 99,
并且它要和 cache 里的 0..98 位置 K/V 一起做 attention。
1. 本节要懂的 6 个层次
| 层次 | 要理解什么 | 源码位置 |
|---|---|---|
| 位置为什么进 attention | 语言模型不仅要知道 token 是什么,还要知道 token 在哪里 | model/model_minimind.py:111-124 |
| RoPE 表怎么预先算好 | 每个位置、每个 head 维度都有对应的 cos/sin | model/model_minimind.py:62-78, model/model_minimind.py:205-207 |
| RoPE 怎么作用到 Q/K | RoPE 旋转 Q/K,不旋转 V | model/model_minimind.py:80-84, model/model_minimind.py:117-119 |
| cache 时怎么选正确位置 | start_pos 来自历史 K 的长度 |
model/model_minimind.py:211-219 |
| KV cache 存什么 | 每层保存已经算好的 K/V,下一步和新 K/V 拼接 | model/model_minimind.py:120-123, model/model_minimind.py:221-232 |
| generate 如何使用 cache | 每轮只把未缓存的新 token 送进 forward | model/model_minimind.py:257-288, eval_llm.py:82-87 |
学完本节,你应该能说明:RoPE 为什么必须跟 cache 长度配合;past_key_values[0][0].shape[1] 代表什么;为什么 cache 里存 K/V 而不是存所有 hidden_states。
2. RoPE 与 KV cache 一次完整原理讲解
先不要看源码。先把两个问题分开:
RoPE 解决的问题:
Attention 怎么知道 token 的位置关系?
KV cache 解决的问题:
生成时为什么不用每一步重复计算所有历史 token?
它们是两件事,但在生成阶段必须配合:
RoPE 负责位置正确;
KV cache 负责计算复用;
start_pos 负责让“只输入新 token”时的位置仍然正确。
2.1 Attention 为什么需要位置
Attention 的核心打分是:
score(i, j) = q_i · k_j / sqrt(head_dim)
这个公式只说:
位置 i 的 query 和位置 j 的 key 有多匹配。
但如果 Q/K 里没有位置信息,模型很难知道:
A 在 C 前面两个位置
A 在 C 前面十个位置
A 在 C 后面一个位置
这些不是同一件事。语言模型不只需要知道 token 是什么,还要知道 token 的顺序和距离。比如:
我 喜欢 吃 苹果
苹果 喜欢 吃 我
这两句话 token 很接近,但位置关系变了,意思就变了。
一种做法是在 embedding 上加位置向量。MiniMind 用的是 RoPE:不直接给 hidden_states 加位置,而是在 attention 打分前,把位置信息放进 Q/K。
2.2 RoPE 是什么
RoPE 是 Rotary Position Embedding,中文可以理解成“旋转位置编码”。
它做的事情很具体:
给不同位置的 Q/K 做不同角度的旋转。
先看二维向量:
x = [a, b]
如果 token 在位置 pos,RoPE 会根据 pos 得到一个角度 theta_pos,然后把 [a, b] 旋转成:
a' = a * cos(theta_pos) - b * sin(theta_pos)
b' = a * sin(theta_pos) + b * cos(theta_pos)
高维向量也是同样逻辑。一个 head 里的向量维度是 head_dim,RoPE 会把它拆成多组维度,每组按不同频率旋转:
低频维度:适合表达较长距离的位置变化
高频维度:适合表达较短距离的位置变化
所以 RoPE 不是给 token 附加一个位置编号,而是让 Q/K 的方向随着位置变化。
MiniMind 代码里的批量写法是:
x_rope = x * cos + rotate_half(x) * sin
这就是上面二维旋转公式的向量化实现。
2.3 RoPE 干了什么
第 11 课讲过,Attention 是用 Q/K 点积决定“看谁”:
score(i, j) = q_i · k_j / sqrt(head_dim)
加了 RoPE 之后,真正参与点积的不是原始 Q/K,而是旋转后的 Q/K:
q_i_rope = R(i) q_i
k_j_rope = R(j) k_j
score(i, j) = q_i_rope · k_j_rope / sqrt(head_dim)
= (R(i) q_i) · (R(j) k_j) / sqrt(head_dim)
这里的 R(i) 可以理解成“位置 i 对应的旋转矩阵”。
关键点在这里:
(R(i) q_i) · (R(j) k_j)
等价于
q_i 和 k_j 做点积时,中间带上了 i 与 j 的相对位置关系。
直观说:
位置 i 的 token 要看位置 j 的 token 时,
attention 分数不只取决于 q_i 和 k_j 的内容,
还取决于 i 和 j 隔多远、方向如何。
这就是 RoPE 最重要的作用:
RoPE 让 Q/K 的匹配分数带有相对位置信息。
不用死记矩阵推导,但要记住这个结论。它比“RoPE 把向量旋转一下”更重要。
2.4 为什么 RoPE 只作用在 Q/K 上
Attention 里三种角色是:
Q:当前位置想找什么
K:每个位置提供什么可匹配特征
V:每个位置真正贡献什么内容
位置最直接影响的是“匹配关系”:
当前位置 i 的 Q,要和历史位置 j 的 K 做匹配。
匹配分数应该知道 i 和 j 的相对位置。
所以 MiniMind 对 Q/K 做 RoPE:
xq, xk = apply_rotary_pos_emb(xq, xk, cos, sin)
但 V 不旋转。
原因是:
Q/K 负责决定“看谁、看多少”;
V 负责提供“拿回来什么内容”。
先按 Q/K 算出注意力权重,再用权重去加权 V。位置主要影响权重,不直接改 V 的内容。
2.5 KV cache 是什么
现在换到生成阶段。
假设 prompt 是:
位置: 0 1 2
token: A B C
模型第一次 forward 处理 A B C,这一步通常叫 prefill。
在每一层 Attention 里,模型会算出:
Q_A, Q_B, Q_C
K_A, K_B, K_C
V_A, V_B, V_C
其中 Q 是当前 token 做 query 时才用的。prefill 结束后,如果下一个 token 是 D,下一步只需要:
D 的 Q
历史 A/B/C 的 K/V
历史 A/B/C 的 Q 不再需要,因为 A/B/C 作为 query 的输出已经算完了。
所以 KV cache 存的是每一层的历史 K/V:
past_key_values[layer] = (past_k, past_v)
形状是:
past_k: [batch, past_seq, num_key_value_heads, head_dim]
past_v: [batch, past_seq, num_key_value_heads, head_dim]
在 MiniMind 里要注意:
cache 里的 K 是已经做过 RoPE 的 K;
cache 里的 V 是没有做 RoPE 的 V。
因为源码顺序是:
Q/K 做 RoPE
-> 拼接历史 K/V
-> 保存 past_kv
2.6 KV cache 干了什么
不用 cache 时,每生成一步都把完整序列重新送进模型:
已知 A B C,要预测 D:
forward(A B C)
生成 D 后,要预测 E:
forward(A B C D)
生成 E 后,要预测 F:
forward(A B C D E)
问题是:A B C 在后面每一步都会被重复计算。
用 cache 时,时间线变成:
prefill:
输入 A B C
算出 A/B/C 的 K/V cache
用 C 位置 logits 预测 D
decode 1:
把 D 追加到 input_ids
本轮只输入 D
复用 A/B/C 的 K/V cache
只计算 D 的 Q/K/V
用 D 位置 logits 预测 E
decode 2:
把 E 追加到 input_ids
本轮只输入 E
复用 A/B/C/D 的 K/V cache
只计算 E 的 Q/K/V
用 E 位置 logits 预测 F
所以 KV cache 的作用不是改变模型会说什么,而是改变计算方式:
不使用 cache:每步重复算整个历史序列
使用 cache:历史 K/V 复用,每步只算新 token
2.7 为什么 RoPE 和 cache 必须靠 start_pos 对齐
这里是本节最容易错的地方。
prefill 处理 A B C 后,cache 里保存的是:
K_A(pos0), K_B(pos1), K_C(pos2)
V_A, V_B, V_C
现在生成出新 token D,下一轮 decode 只输入 D:
本轮 input_ids 形状看起来是 [batch, 1]
seq_length = 1
但 D 在完整序列里的真实位置不是 0,而是 3:
位置: 0 1 2 3
token: A B C D
所以给 D 算 RoPE 时,不能用位置 0 的 cos/sin,必须用位置 3 的 cos/sin。
MiniMind 用 cache 长度得到这个位置:
start_pos = past_key_values[0][0].shape[1]
如果历史 cache 长度是 3,那么:
start_pos = 3
position_embeddings = freqs[3:4]
这样新 token D 的 Q/K 才会带上位置 3。
如果错误地用位置 0,那么:
D 会被当成一个新序列的第 0 个 token
它和 A/B/C 的相对位置关系会错
cache 生成就不再等价于整段 forward
2.8 判断 cache 是否正确的标准
正确的 KV cache 不应该改变模型输出。它只是省计算。
所以可以用下面这个等价关系检查:
不用 cache:
forward(A B C D)
-> 取 D 位置 logits 预测 E
使用 cache:
forward(A B C, use_cache=True)
-> 得到 cache(A,B,C)
forward(D, past_key_values=cache, use_cache=True)
-> 取 D 位置 logits 预测 E
如果 RoPE 的 start_pos 正确,K/V 拼接正确,那么两边 D 位置的 logits 应该非常接近。
本节可以先记住这句话:
RoPE 让 Q/K 打分知道相对位置;
KV cache 保存历史 K/V,避免重复计算;
start_pos 让只输入新 token 时仍然使用真实位置。
实验会验证这一点。
3. 从 generate 到 Attention 的完整源码走读
这一节沿着真实执行路径走,不先拆小片段。
eval_llm.py
-> model.generate(...)
-> MiniMindForCausalLM.generate
-> MiniMindForCausalLM.forward
-> MiniMindModel.forward
-> MiniMindBlock.forward
-> Attention.forward
第 1 步:CLI 推理调用 generate
File: eval_llm.py:82-87
Read this to understand: CLI 推理时不是手写循环,而是把 prompt 的 input_ids 交给模型自己的 generate。
Code/config/template excerpt:
generated_ids = model.generate(
inputs=inputs["input_ids"], attention_mask=inputs["attention_mask"],
max_new_tokens=args.max_new_tokens, do_sample=True, streamer=streamer,
pad_token_id=tokenizer.pad_token_id, eos_token_id=tokenizer.eos_token_id,
top_p=args.top_p, temperature=args.temperature, repetition_penalty=1
)
This code shows:
inputs["input_ids"]是 prompt token。generate负责逐 token 生成。- 默认是否用 cache 不在这里显式传,因为
MiniMindForCausalLM.generate的use_cache=True是默认值。
第 2 步:generate 初始化 input_ids 和 past_key_values
File: model/model_minimind.py:257-263
Read this to understand: 生成循环开始前,模型准备好完整 prompt、空 cache 和结束标记。
Code/config/template excerpt:
def generate(..., use_cache=True, ...):
input_ids = kwargs.pop("input_ids", inputs).repeat(num_return_sequences, 1)
attention_mask = attention_mask.repeat(num_return_sequences, 1) if attention_mask is not None else None
past_key_values = kwargs.pop("past_key_values", None)
finished = torch.zeros(input_ids.shape[0], dtype=torch.bool, device=input_ids.device)
if streamer: streamer.put(input_ids.cpu())
This code shows:
past_key_values初始通常是None。use_cache=True是默认生成方式。input_ids会一直增长,包含 prompt 和已经生成出的 token。
第 3 步:每一轮只把未缓存部分送进 forward
File: model/model_minimind.py:263-266
Read this to understand: KV cache 真正省计算的入口是 input_ids[:, past_len:]。
Code/config/template excerpt:
for _ in range(max_new_tokens):
past_len = past_key_values[0][0].shape[1] if past_key_values else 0
outputs = self.forward(input_ids[:, past_len:], attention_mask, past_key_values, use_cache=use_cache, **kwargs)
attention_mask = torch.cat([attention_mask, attention_mask.new_ones(attention_mask.shape[0], 1)], -1) if attention_mask is not None else None
This code shows:
- 第一次循环
past_key_values=None,所以past_len=0,整段 prompt 会进入 forward。 - 后面循环
past_len等于 cache 中已经保存的序列长度。 input_ids[:, past_len:]只取新 token,历史 token 不再重复送入模型。attention_mask仍然随着完整序列增长,因为新 query 要能看到完整 key/value 序列。
第 4 步:CausalLM forward 把任务交给 MiniMindModel
File: model/model_minimind.py:245-253
Read this to understand: MiniMindForCausalLM.forward 主要负责调用 backbone、接 lm_head、返回 cache。
Code/config/template excerpt:
def forward(self, input_ids, attention_mask=None, past_key_values=None, use_cache=False, logits_to_keep=0, labels=None, **kwargs):
hidden_states, past_key_values, aux_loss = self.model(input_ids, attention_mask, past_key_values, use_cache, **kwargs)
slice_indices = slice(-logits_to_keep, None) if isinstance(logits_to_keep, int) else logits_to_keep
logits = self.lm_head(hidden_states[:, slice_indices, :])
...
return MoeCausalLMOutputWithPast(..., logits=logits, past_key_values=past_key_values, hidden_states=hidden_states)
This code shows:
- cache 是从
self.model(...)返回的。 lm_head只负责把 hidden_states 变成 logits,不负责位置和 cache。- 返回对象里的
past_key_values会被generate下一轮继续使用。
第 5 步:MiniMindModel 用 cache 长度计算 start_pos
File: model/model_minimind.py:209-219
Read this to understand: RoPE 与 KV cache 在这里真正接上。
Code/config/template excerpt:
batch_size, seq_length = input_ids.shape
if hasattr(past_key_values, 'layers'): past_key_values = None
past_key_values = past_key_values or [None] * len(self.layers)
start_pos = past_key_values[0][0].shape[1] if past_key_values[0] is not None else 0
hidden_states = self.dropout(self.embed_tokens(input_ids))
...
position_embeddings = (self.freqs_cos[start_pos:start_pos + seq_length], self.freqs_sin[start_pos:start_pos + seq_length])
This code shows:
seq_length是本次 forward 输入的长度,不一定是完整上下文长度。start_pos是历史 cache 的长度。position_embeddings从start_pos开始切,保证新 token 用它在完整序列中的位置。
这是本节最关键的一段源码。
第 6 步:每层 block 接收自己的 past_key_value
File: model/model_minimind.py:221-232
Read this to understand: cache 是逐层保存的,不是全模型只存一份。
Code/config/template excerpt:
presents = []
for layer, past_key_value in zip(self.layers, past_key_values):
hidden_states, present = layer(
hidden_states,
position_embeddings,
past_key_value=past_key_value,
use_cache=use_cache,
attention_mask=attention_mask
)
presents.append(present)
...
return hidden_states, presents, aux_loss
This code shows:
- 每个 transformer layer 都有自己的 K/V cache。
past_key_value是当前层的历史 K/V。present是当前层更新后的 K/V。presents会返回给generate,成为下一轮的past_key_values。
第 7 步:Attention 里先算 Q/K/V,再对 Q/K 做 RoPE
File: model/model_minimind.py:111-119
Read this to understand: RoPE 进入 attention 的位置在 Q/K projection 之后、attention 打分之前。
Code/config/template excerpt:
bsz, seq_len, _ = x.shape
xq, xk, xv = self.q_proj(x), self.k_proj(x), self.v_proj(x)
xq = xq.view(bsz, seq_len, self.n_local_heads, self.head_dim)
xk = xk.view(bsz, seq_len, self.n_local_kv_heads, self.head_dim)
xv = xv.view(bsz, seq_len, self.n_local_kv_heads, self.head_dim)
xq, xk = self.q_norm(xq), self.k_norm(xk)
cos, sin = position_embeddings
xq, xk = apply_rotary_pos_emb(xq, xk, cos, sin)
This code shows:
- RoPE 作用在 reshape 成 head 之后的 Q/K 上。
xv没有进入apply_rotary_pos_emb。position_embeddings是上一步按start_pos切出来的 cos/sin。
第 8 步:Attention 把历史 K/V 和新 K/V 拼起来
File: model/model_minimind.py:120-124
Read this to understand: KV cache 的主体就是拼接历史 K/V 和当前 K/V。
Code/config/template excerpt:
if past_key_value is not None:
xk = torch.cat([past_key_value[0], xk], dim=1)
xv = torch.cat([past_key_value[1], xv], dim=1)
past_kv = (xk, xv) if use_cache else None
xq, xk, xv = (xq.transpose(1, 2), repeat_kv(xk, self.n_rep).transpose(1, 2), repeat_kv(xv, self.n_rep).transpose(1, 2))
This code shows:
dim=1是序列长度维度。- 拼接后的
xk/xv包含历史 token 和当前 token。 past_kv保存的是 repeat 之前的 K/V,shape 仍然是[batch, seq, num_key_value_heads, head_dim]。repeat_kv是为了 GQA,让 K/V head 数对齐 attention head 数。
第 9 步:generate 更新 cache 并追加 next_token
File: model/model_minimind.py:267-288
Read this to understand: 每轮生成后,input_ids 变长,cache 也变长。
Code/config/template excerpt:
logits = outputs.logits[:, -1, :] / temperature
...
next_token = torch.multinomial(torch.softmax(logits, dim=-1), num_samples=1) if do_sample else torch.argmax(logits, dim=-1, keepdim=True)
...
input_ids = torch.cat([input_ids, next_token], dim=-1)
past_key_values = outputs.past_key_values if use_cache else None
...
return input_ids
This code shows:
- 每轮只用最后一个位置的 logits 选出
next_token。 input_ids追加新 token。past_key_values更新为本轮 forward 返回的新 cache。- 下一轮的
past_len会更大,所以只会处理更新后的新 token。
4. 源码变量字典
| 变量 | 形状或类型 | 含义 |
|---|---|---|
freqs_cos |
[max_position_embeddings, head_dim] |
每个位置、每个 head 维度对应的 cos |
freqs_sin |
[max_position_embeddings, head_dim] |
每个位置、每个 head 维度对应的 sin |
position_embeddings |
(cos_slice, sin_slice) |
当前 forward 需要的 RoPE 位置片段 |
start_pos |
int | 当前输入在完整序列中的起始位置,来自 cache 长度 |
xq |
[batch, seq, num_attention_heads, head_dim] |
当前输入 token 的 query |
xk |
[batch, seq, num_key_value_heads, head_dim] |
当前输入 token 的 key,RoPE 后会进入 cache |
xv |
[batch, seq, num_key_value_heads, head_dim] |
当前输入 token 的 value |
past_key_values |
list | 每层一个 (past_k, past_v) |
past_key_value |
tuple or None | 当前层的历史 K/V |
present |
tuple or None | 当前层更新后的 K/V |
past_len |
int | generate 里已经缓存的 token 数 |
input_ids[:, past_len:] |
tensor | 本轮还没有进入 cache 的新 token |
两个维度最容易混:
seq_length: 本次 forward 输入长度
past_len/start_pos: 之前已经缓存的历史长度
不用 cache 时:
seq_length = 完整上下文长度
start_pos = 0
用 cache 解码时:
seq_length = 1
start_pos = 历史上下文长度
5. 关键源码对照
这里不是重新走完整源码,而是把几个关键点单独拎出来,方便复习时定位。
5.1 RoPE 的配置来自模型 config
File: model/model_minimind.py:27-39
Read this to understand: RoPE 的最大位置、频率底数和推理外推配置都在 config 里。
Code/config/template excerpt:
self.max_position_embeddings = kwargs.get("max_position_embeddings", 32768)
self.rms_norm_eps = kwargs.get("rms_norm_eps", 1e-6)
self.rope_theta = kwargs.get("rope_theta", 1e6)
self.tie_word_embeddings = kwargs.get("tie_word_embeddings", True)
self.inference_rope_scaling = kwargs.get("inference_rope_scaling", False)
self.rope_scaling = {
"beta_fast": 32,
"beta_slow": 1,
"factor": 16,
"original_max_position_embeddings": 2048,
"attention_factor": 1.0,
"type": "yarn"
} if self.inference_rope_scaling else None
This code shows:
max_position_embeddings决定预计算 RoPE 表的长度。rope_theta影响不同维度的旋转频率。inference_rope_scaling是推理外推相关,先知道它会影响 RoPE 表即可。
5.2 预计算 cos/sin 表
File: model/model_minimind.py:62-78
Read this to understand: MiniMind 不是每次 attention 临时算 RoPE 表,而是在模型初始化时预先算出 cos/sin。
Code/config/template excerpt:
def precompute_freqs_cis(dim: int, end: int = int(32 * 1024), rope_base: float = 1e6, rope_scaling: dict = None):
freqs, attn_factor = 1.0 / (rope_base ** (torch.arange(0, dim, 2)[: (dim // 2)].float() / dim)), 1.0
...
t = torch.arange(end, device=freqs.device)
freqs = torch.outer(t, freqs).float()
freqs_cos = torch.cat([torch.cos(freqs), torch.cos(freqs)], dim=-1) * attn_factor
freqs_sin = torch.cat([torch.sin(freqs), torch.sin(freqs)], dim=-1) * attn_factor
return freqs_cos, freqs_sin
This code shows:
dim对应head_dim。end对应最大位置数量。torch.outer(t, freqs)得到每个位置、每个频率的角度。freqs_cos/freqs_sin最终扩展到[end, head_dim]。
5.3 RoPE 表注册成 buffer
File: model/model_minimind.py:205-207
Read this to understand: RoPE 表不是训练参数,但会跟着模型移动设备。
Code/config/template excerpt:
freqs_cos, freqs_sin = precompute_freqs_cis(dim=config.head_dim, end=config.max_position_embeddings, rope_base=config.rope_theta, rope_scaling=config.rope_scaling)
self.register_buffer("freqs_cos", freqs_cos, persistent=False)
self.register_buffer("freqs_sin", freqs_sin, persistent=False)
This code shows:
- RoPE 表由
config.head_dim和config.max_position_embeddings决定。 register_buffer表示它属于模型状态,但不是可训练参数。persistent=False表示它不作为普通权重持久保存。
5.4 apply_rotary_pos_emb 的关键公式
File: model/model_minimind.py:80-84
Read this to understand: MiniMind 用 rotate_half 实现批量旋转。
Code/config/template excerpt:
def apply_rotary_pos_emb(q, k, cos, sin, unsqueeze_dim=1):
def rotate_half(x): return torch.cat((-x[..., x.shape[-1] // 2:], x[..., : x.shape[-1] // 2]), dim=-1)
q_embed = ((q * cos.unsqueeze(unsqueeze_dim)) + (rotate_half(q) * sin.unsqueeze(unsqueeze_dim))).to(q.dtype)
k_embed = ((k * cos.unsqueeze(unsqueeze_dim)) + (rotate_half(k) * sin.unsqueeze(unsqueeze_dim))).to(k.dtype)
return q_embed, k_embed
This code shows:
- 输入是 Q/K,不包含 V。
cos.unsqueeze(1)让 cos/sin 从[seq, head_dim]变成可以广播到[batch, seq, heads, head_dim]。- 返回的
q_embed/k_embedshape 不变,只是数值带上了位置旋转。
5.5 已下载 transformers 模型也带 use_cache 配置
File: minimind-3/config.json:23-35
Read this to understand: transformers 格式模型配置里也记录了位置长度、RoPE 参数和默认 cache 行为。
Code/config/template excerpt:
"max_position_embeddings": 32768,
"num_attention_heads": 8,
"num_hidden_layers": 8,
"num_key_value_heads": 4,
"rope_scaling": null,
"rope_theta": 1000000.0,
"use_cache": true,
This code shows:
- 下载好的
minimind-3模型也使用 RoPE 参数。 use_cache: true说明推理时默认倾向于使用 KV cache。- 这里是 transformers 配置;本节主要读的是项目自定义
model/model_minimind.py的实现。
6. 实验验证
本节实验不需要真实权重。它用 tiny 随机模型验证三个事实:
1. RoPE cos/sin 的 shape 和 Q/K 对齐。
2. KV cache 的 K/V 长度会随着生成增长。
3. 整段 forward 的最后 token logits 与 cache 增量 forward 的 logits 接近一致。
命令:
cd /home/sun/minimind
python course/labs/trace_rope_kv_cache.py
重点记录这些输出:
freqs_cos.shape =
q_after_rope.shape =
prefix_layer0_k.shape =
step_layer0_k.shape =
full_vs_incremental_last_logits_max_abs_diff =
cache_and_no_cache_generate_match =
你应该看到:
q_after_rope.shape 和 q_before_rope.shape 一样
prefix_layer0_k 的 seq 维度等于 prefix 长度
step_layer0_k 的 seq 维度等于 prefix 长度 + 1
full_vs_incremental_last_logits_max_abs_diff 非常接近 0
cache_and_no_cache_generate_match=True
如果 full_vs_incremental_last_logits_max_abs_diff 很大,通常说明:
start_pos 错了;
RoPE 位置切片错了;
cache 拼接维度错了;
或者生成时没有正确传 past_key_values。
7. 本节检查
- RoPE 为什么作用在 Q/K 上,而不是作用在 V 上?
freqs_cos[start_pos:start_pos + seq_length]里的start_pos为什么不能总是 0?past_key_values[0][0].shape[1]表示什么?generate里为什么用input_ids[:, past_len:],而不是每轮都把完整input_ids送进 forward?- KV cache 为什么存 K/V,而不是存 Q/K/V?
- 使用 KV cache 应该改变模型输出结果,还是只改变计算效率?为什么?
8. 下一课
第 13 课进入 FFN 与 MoE。
Attention 负责让 token 从上下文中取信息;FFN 负责对每个 token 的 hidden 向量做非线性变换。MiniMind 里还有 dense FFN 和 MoE FFN 两条路径,下一课会先讲普通 FFN,再看 MoE 的 router、expert 和 aux loss。