# 第 12 课:RoPE 与 KV cache 这一课只解决一个问题:MiniMind 如何给 Q/K 注入位置信息,并在生成时复用历史 K/V,做到每步只计算新 token 但仍然和整段 forward 对齐。 ## 目录 - [0. 本节主线](#l12-mainline) - [1. 本节要懂的 6 个层次](#l12-levels) - [2. RoPE 与 KV cache 一次完整原理讲解](#l12-complete-principle) - [3. 从 generate 到 Attention 的完整源码走读](#l12-source-walkthrough) - [4. 源码变量字典](#l12-variable-dict) - [5. 关键源码对照](#l12-source-evidence) - [6. 实验验证](#l12-experiment) - [7. 本节检查](#l12-check) - [8. 下一课](#l12-next) ## 0. 本节主线 RoPE 与 KV cache 的完整逻辑是: ```text 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 ``` 一句话: ```text RoPE 解决“当前位置怎么进入 attention 打分”,KV cache 解决“历史 token 的 K/V 不要每步重算”,二者靠 start_pos 对齐。 ``` 这节课不是单独背 RoPE 公式,也不是单独看 cache 变量。你要形成一个完整观念: ```text 如果生成第 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 一次完整原理讲解 先不要看源码。先把两个问题分开: ```text RoPE 解决的问题: Attention 怎么知道 token 的位置关系? KV cache 解决的问题: 生成时为什么不用每一步重复计算所有历史 token? ``` 它们是两件事,但在生成阶段必须配合: ```text RoPE 负责位置正确; KV cache 负责计算复用; start_pos 负责让“只输入新 token”时的位置仍然正确。 ``` ### 2.1 Attention 为什么需要位置 Attention 的核心打分是: ```text score(i, j) = q_i · k_j / sqrt(head_dim) ``` 这个公式只说: ```text 位置 i 的 query 和位置 j 的 key 有多匹配。 ``` 但如果 Q/K 里没有位置信息,模型很难知道: ```text A 在 C 前面两个位置 A 在 C 前面十个位置 A 在 C 后面一个位置 ``` 这些不是同一件事。语言模型不只需要知道 token 是什么,还要知道 token 的顺序和距离。比如: ```text 我 喜欢 吃 苹果 苹果 喜欢 吃 我 ``` 这两句话 token 很接近,但位置关系变了,意思就变了。 一种做法是在 embedding 上加位置向量。MiniMind 用的是 RoPE:不直接给 `hidden_states` 加位置,而是在 attention 打分前,把位置信息放进 Q/K。 ### 2.2 RoPE 是什么 RoPE 是 Rotary Position Embedding,中文可以理解成“旋转位置编码”。 它做的事情很具体: ```text 给不同位置的 Q/K 做不同角度的旋转。 ``` 先看二维向量: ```text x = [a, b] ``` 如果 token 在位置 `pos`,RoPE 会根据 `pos` 得到一个角度 `theta_pos`,然后把 `[a, b]` 旋转成: ```text a' = a * cos(theta_pos) - b * sin(theta_pos) b' = a * sin(theta_pos) + b * cos(theta_pos) ``` 高维向量也是同样逻辑。一个 head 里的向量维度是 `head_dim`,RoPE 会把它拆成多组维度,每组按不同频率旋转: ```text 低频维度:适合表达较长距离的位置变化 高频维度:适合表达较短距离的位置变化 ``` 所以 RoPE 不是给 token 附加一个位置编号,而是让 Q/K 的方向随着位置变化。 MiniMind 代码里的批量写法是: ```text x_rope = x * cos + rotate_half(x) * sin ``` 这就是上面二维旋转公式的向量化实现。 ### 2.3 RoPE 干了什么 第 11 课讲过,Attention 是用 Q/K 点积决定“看谁”: ```text score(i, j) = q_i · k_j / sqrt(head_dim) ``` 加了 RoPE 之后,真正参与点积的不是原始 Q/K,而是旋转后的 Q/K: ```text 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 对应的旋转矩阵”。 关键点在这里: ```text (R(i) q_i) · (R(j) k_j) 等价于 q_i 和 k_j 做点积时,中间带上了 i 与 j 的相对位置关系。 ``` 直观说: ```text 位置 i 的 token 要看位置 j 的 token 时, attention 分数不只取决于 q_i 和 k_j 的内容, 还取决于 i 和 j 隔多远、方向如何。 ``` 这就是 RoPE 最重要的作用: ```text RoPE 让 Q/K 的匹配分数带有相对位置信息。 ``` 不用死记矩阵推导,但要记住这个结论。它比“RoPE 把向量旋转一下”更重要。 ### 2.4 为什么 RoPE 只作用在 Q/K 上 Attention 里三种角色是: ```text Q:当前位置想找什么 K:每个位置提供什么可匹配特征 V:每个位置真正贡献什么内容 ``` 位置最直接影响的是“匹配关系”: ```text 当前位置 i 的 Q,要和历史位置 j 的 K 做匹配。 匹配分数应该知道 i 和 j 的相对位置。 ``` 所以 MiniMind 对 Q/K 做 RoPE: ```text xq, xk = apply_rotary_pos_emb(xq, xk, cos, sin) ``` 但 V 不旋转。 原因是: ```text Q/K 负责决定“看谁、看多少”; V 负责提供“拿回来什么内容”。 ``` 先按 Q/K 算出注意力权重,再用权重去加权 V。位置主要影响权重,不直接改 V 的内容。 ### 2.5 KV cache 是什么 现在换到生成阶段。 假设 prompt 是: ```text 位置: 0 1 2 token: A B C ``` 模型第一次 forward 处理 `A B C`,这一步通常叫 prefill。 在每一层 Attention 里,模型会算出: ```text Q_A, Q_B, Q_C K_A, K_B, K_C V_A, V_B, V_C ``` 其中 Q 是当前 token 做 query 时才用的。prefill 结束后,如果下一个 token 是 `D`,下一步只需要: ```text D 的 Q 历史 A/B/C 的 K/V ``` 历史 A/B/C 的 Q 不再需要,因为 A/B/C 作为 query 的输出已经算完了。 所以 KV cache 存的是每一层的历史 K/V: ```text past_key_values[layer] = (past_k, past_v) ``` 形状是: ```text past_k: [batch, past_seq, num_key_value_heads, head_dim] past_v: [batch, past_seq, num_key_value_heads, head_dim] ``` 在 MiniMind 里要注意: ```text cache 里的 K 是已经做过 RoPE 的 K; cache 里的 V 是没有做 RoPE 的 V。 ``` 因为源码顺序是: ```text Q/K 做 RoPE -> 拼接历史 K/V -> 保存 past_kv ``` ### 2.6 KV cache 干了什么 不用 cache 时,每生成一步都把完整序列重新送进模型: ```text 已知 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 时,时间线变成: ```text 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 的作用不是改变模型会说什么,而是改变计算方式: ```text 不使用 cache:每步重复算整个历史序列 使用 cache:历史 K/V 复用,每步只算新 token ``` ### 2.7 为什么 RoPE 和 cache 必须靠 start_pos 对齐 这里是本节最容易错的地方。 prefill 处理 `A B C` 后,cache 里保存的是: ```text K_A(pos0), K_B(pos1), K_C(pos2) V_A, V_B, V_C ``` 现在生成出新 token `D`,下一轮 decode 只输入 `D`: ```text 本轮 input_ids 形状看起来是 [batch, 1] seq_length = 1 ``` 但 `D` 在完整序列里的真实位置不是 0,而是 3: ```text 位置: 0 1 2 3 token: A B C D ``` 所以给 `D` 算 RoPE 时,不能用位置 0 的 cos/sin,必须用位置 3 的 cos/sin。 MiniMind 用 cache 长度得到这个位置: ```text start_pos = past_key_values[0][0].shape[1] ``` 如果历史 cache 长度是 3,那么: ```text start_pos = 3 position_embeddings = freqs[3:4] ``` 这样新 token `D` 的 Q/K 才会带上位置 3。 如果错误地用位置 0,那么: ```text D 会被当成一个新序列的第 0 个 token 它和 A/B/C 的相对位置关系会错 cache 生成就不再等价于整段 forward ``` ### 2.8 判断 cache 是否正确的标准 正确的 KV cache 不应该改变模型输出。它只是省计算。 所以可以用下面这个等价关系检查: ```text 不用 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 应该非常接近。 本节可以先记住这句话: ```text RoPE 让 Q/K 打分知道相对位置; KV cache 保存历史 K/V,避免重复计算; start_pos 让只输入新 token 时仍然使用真实位置。 ``` 实验会验证这一点。 ## 3. 从 generate 到 Attention 的完整源码走读 这一节沿着真实执行路径走,不先拆小片段。 ```text 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: ```python 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: ```python 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: ```python 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: ```python 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: ```python 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: ```python 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: ```python 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: ```python 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: ```python 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 | 两个维度最容易混: ```text seq_length: 本次 forward 输入长度 past_len/start_pos: 之前已经缓存的历史长度 ``` 不用 cache 时: ```text seq_length = 完整上下文长度 start_pos = 0 ``` 用 cache 解码时: ```text 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: ```python 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: ```python 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: ```python 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: ```python 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_embed` shape 不变,只是数值带上了位置旋转。 ### 5.5 已下载 transformers 模型也带 use_cache 配置 File: `minimind-3/config.json:23-35` Read this to understand: transformers 格式模型配置里也记录了位置长度、RoPE 参数和默认 cache 行为。 Code/config/template excerpt: ```json "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 随机模型验证三个事实: ```text 1. RoPE cos/sin 的 shape 和 Q/K 对齐。 2. KV cache 的 K/V 长度会随着生成增长。 3. 整段 forward 的最后 token logits 与 cache 增量 forward 的 logits 接近一致。 ``` 命令: ```bash cd /home/sun/minimind python course/labs/trace_rope_kv_cache.py ``` 重点记录这些输出: ```text 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 = ``` 你应该看到: ```text 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` 很大,通常说明: ```text start_pos 错了; RoPE 位置切片错了; cache 拼接维度错了; 或者生成时没有正确传 past_key_values。 ``` ## 7. 本节检查 1. RoPE 为什么作用在 Q/K 上,而不是作用在 V 上? 2. `freqs_cos[start_pos:start_pos + seq_length]` 里的 `start_pos` 为什么不能总是 0? 3. `past_key_values[0][0].shape[1]` 表示什么? 4. `generate` 里为什么用 `input_ids[:, past_len:]`,而不是每轮都把完整 `input_ids` 送进 forward? 5. KV cache 为什么存 K/V,而不是存 Q/K/V? 6. 使用 KV cache 应该改变模型输出结果,还是只改变计算效率?为什么? ## 8. 下一课 第 13 课进入 `FFN 与 MoE`。 Attention 负责让 token 从上下文中取信息;FFN 负责对每个 token 的 hidden 向量做非线性变换。MiniMind 里还有 dense FFN 和 MoE FFN 两条路径,下一课会先讲普通 FFN,再看 MoE 的 router、expert 和 aux loss。