minimind/course/12_rope_and_kv_cache.md

26 KiB
Raw Blame History

第 12 课:RoPE 与 KV cache

这一课只解决一个问题:MiniMind 如何给 Q/K 注入位置信息,并在生成时复用历史 K/V,做到每步只计算新 token 但仍然和整段 forward 对齐。

目录

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_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:

"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. 本节检查

  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。