Last updated on October 11, 2026 pm
本文整理大模型算法面试中常见的 PyTorch 手写题。每道题都是独立实现;重点不仅是背出 API,还要能解释 Tensor Shape、Mask 语义和公式与代码的对应关系。
0. 统一约定
本文主要使用以下符号:
B B B :Batch Size;
T T T :Sequence Length;
D D D :Hidden Size;
H H H :Attention Head 数量;
D h = D / H D_h=D/H D h = D / H :每个 Attention Head 的维度;
V V V :Vocabulary Size;
mask=True/1:该位置有效;
Logits 表示 Softmax 前的分数,Log Probability 表示 log_softmax 后的值。
面试手写时应优先保证:
输入、输出 Shape 正确;
Mask 的广播方向和语义正确;
Padding 不进入 Loss;
数值计算使用 log_softmax、logsigmoid 等稳定实现;
需要固定的 Target、Old Log Probability 和 Advantage 使用 detach()。
S 级:需要熟练手写
1. Multi-head Attention
目标
输入:
X ∈ R B × T × D . X\in\mathbb R^{B\times T\times D}.
X ∈ R B × T × D .
先投影得到 Q , K , V Q,K,V Q , K , V ,再拆成 H H H 个 Heads:
Q , K , V ∈ R B × H × T × D h . Q,K,V\in\mathbb R^{B\times H\times T\times D_h}.
Q , K , V ∈ R B × H × T × D h .
Scaled Dot-product Attention 为:
Attention ( Q , K , V ) = softmax ( Q K T D h + M ) V . \operatorname{Attention}(Q,K,V)
=
\operatorname{softmax}
\left(
\frac{QK^T}{\sqrt{D_h}}+M
\right)V.
Attention ( Q , K , V ) = softmax ( D h Q K T + M ) V .
其中 M M M 是调用方直接传入的通用 Attention Mask,Shape 规定为 ( B , T , T ) (B,T,T) ( B , T , T ) ,可以是 Causal Mask、Padding Mask 或二者组合后的结果。本文约定 True 表示允许关注,False 表示屏蔽。
PyTorch 实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 import mathimport torchimport torch.nn as nnclass MultiHeadSelfAttention (nn.Module): def __init__ (self, hidden_size: int , num_heads: int ): super ().__init__() assert hidden_size % num_heads == 0 self .hidden_size = hidden_size self .num_heads = num_heads self .head_dim = hidden_size // num_heads self .q_proj = nn.Linear(hidden_size, hidden_size, bias=False ) self .k_proj = nn.Linear(hidden_size, hidden_size, bias=False ) self .v_proj = nn.Linear(hidden_size, hidden_size, bias=False ) self .out_proj = nn.Linear(hidden_size, hidden_size, bias=False ) def _split_heads (self, x: torch.Tensor ) -> torch.Tensor: batch_size, seq_len, _ = x.shape x = x.view( batch_size, seq_len, self .num_heads, self .head_dim, ) return x.transpose(1 , 2 ) def forward ( self, x: torch.Tensor, mask: torch.Tensor | None = None , ) -> torch.Tensor: """ x: (B, T, D) mask: (B, T, T),True 表示允许关注 return: (B, T, D) """ batch_size, seq_len, _ = x.shape q = self ._split_heads(self .q_proj(x)) k = self ._split_heads(self .k_proj(x)) v = self ._split_heads(self .v_proj(x)) scores = torch.matmul(q, k.transpose(-2 , -1 )) scores = scores / math.sqrt(self .head_dim) if mask is not None : mask = mask[:, None , :, :].bool () scores = scores.masked_fill(~mask, float ("-inf" )) attn_probs = torch.softmax(scores, dim=-1 ) context = torch.matmul(attn_probs, v) context = context.transpose(1 , 2 ).contiguous() context = context.view(batch_size, seq_len, self .hidden_size) output = self .out_proj(context) return output
例如,Decoder Self-Attention 的 Causal Mask 可以由调用方构造:
1 2 3 4 5 causal_mask = torch.tril( torch.ones(T, T, dtype=torch.bool , device=x.device) ) causal_mask = causal_mask.unsqueeze(0 ).expand(B, -1 , -1 ) output = attention(x, mask=causal_mask)
最容易写错的是:
K 应转置最后两个维度,而不是 Head 与 Sequence 维;
Attention Score 的 Shape 是 ( B , H , T , T ) (B,H,T,T) ( B , H , T , T ) ;
( B , T , T ) (B,T,T) ( B , T , T ) 的 Mask 需要增加 Head Dimension 后再广播;
masked_fill 必须发生在 Softmax 之前。
2. PPO Clipped Loss
目标
已知每个 Token 的:
old_log_probs:生成轨迹的 Old Policy Log Probability;
new_log_probs:Current Policy 重新计算的 Log Probability;
advantages:固定的 Advantage。
概率比为:
ρ t = exp ( ℓ t n e w − ℓ t o l d ) . \rho_t
=
\exp
\left(
\ell_t^{\mathrm{new}}-\ell_t^{\mathrm{old}}
\right).
ρ t = exp ( ℓ t new − ℓ t old ) .
PPO Clipped Objective 为:
J c l i p = E t [ min ( ρ t A t , clip ( ρ t , 1 − ϵ , 1 + ϵ ) A t ) ] . J_{\mathrm{clip}}
=
\mathbb E_t
\left[
\min
\left(
\rho_tA_t,
\operatorname{clip}(\rho_t,1-\epsilon,1+\epsilon)A_t
\right)
\right].
J clip = E t [ min ( ρ t A t , clip ( ρ t , 1 − ϵ , 1 + ϵ ) A t ) ] .
训练代码通常最小化其相反数。
PyTorch 实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 import torchdef ppo_clipped_loss ( old_log_probs: torch.Tensor, new_log_probs: torch.Tensor, advantages: torch.Tensor, clip_eps: float = 0.2 , ) -> torch.Tensor: """ 所有输入 Shape 均为 (B, T),并假设其中都是有效 Token。 """ old_log_probs = old_log_probs.detach() advantages = advantages.detach() log_ratio = new_log_probs - old_log_probs ratio = torch.exp(log_ratio) surrogate1 = ratio * advantages surrogate2 = torch.clamp( ratio, 1.0 - clip_eps, 1.0 + clip_eps, ) * advantages objective = torch.minimum(surrogate1, surrogate2) return -objective.mean()
torch.minimum 不能简单替换成“始终使用裁剪后的 Ratio”。PPO 只阻止沿正确方向获得过大的额外收益;如果 Policy 向错误方向移动,仍要保留未裁剪分支提供纠正梯度。
面试手写通常默认输入位置全部有效。实际处理变长 Response 时,还需要用 Response Mask 排除 Padding Token。
3. GRPO Loss
目标
假设一个 Batch 有 B B B 个 Prompt,每个 Prompt 生成 G G G 条 Response:
R ∈ R B × G . R\in\mathbb R^{B\times G}.
R ∈ R B × G .
GRPO 使用同组 Reward 的均值和标准差构造 Advantage:
A b , g = R b , g − μ b σ b + ε . A_{b,g}
=
\frac{
R_{b,g}-\mu_b
}{
\sigma_b+\varepsilon
}.
A b , g = σ b + ε R b , g − μ b .
一个 Response 的 Advantage 会广播到该 Response 的所有 Tokens,再结合 Importance Ratio 和 PPO Clipping。
PyTorch 实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 import torchdef grpo_loss ( rewards: torch.Tensor, old_log_probs: torch.Tensor, new_log_probs: torch.Tensor, clip_eps: float = 0.2 , norm_eps: float = 1e-6 , ) -> tuple [torch.Tensor, torch.Tensor]: """ rewards: (B, G) old_log_probs: (B, G, T) new_log_probs: (B, G, T) 假设所有 Token 都有效。 """ group_mean = rewards.mean(dim=1 , keepdim=True ) group_std = rewards.std( dim=1 , keepdim=True , unbiased=False , ) advantages = (rewards - group_mean) / (group_std + norm_eps) token_advantages = advantages.unsqueeze(-1 ) ratio = torch.exp( new_log_probs - old_log_probs.detach() ) surrogate1 = ratio * token_advantages.detach() surrogate2 = torch.clamp( ratio, 1.0 - clip_eps, 1.0 + clip_eps, ) * token_advantages.detach() token_objective = torch.minimum(surrogate1, surrogate2) loss = -token_objective.mean() return loss, advantages
若一组 Response 全部得到相同 Reward,则分子为 0 0 0 ,该组 Advantage 为 0 0 0 ,不会产生 Actor Gradient。unbiased=False 使用总体标准差,也避免 Group Size 较小时额外的 Bessel Correction。
实际处理变长 Response 时,需要在计算平均值时用 Response Mask 排除 Padding Token。
4. GAE
目标
TD Residual:
δ t = r t + γ ( 1 − d t ) V ( s t + 1 ) − V ( s t ) , \delta_t
=
r_t+\gamma(1-d_t)V(s_{t+1})-V(s_t),
δ t = r t + γ ( 1 − d t ) V ( s t + 1 ) − V ( s t ) ,
GAE 从后向前递推:
A t = δ t + γ λ ( 1 − d t ) A t + 1 . A_t
=
\delta_t
+\gamma\lambda(1-d_t)A_{t+1}.
A t = δ t + γλ ( 1 − d t ) A t + 1 .
其中 d t = 1 d_t=1 d t = 1 表示执行第 t t t 步动作后 Episode 结束。
PyTorch 实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 import torch@torch.no_grad() def compute_gae ( rewards: torch.Tensor, values: torch.Tensor, dones: torch.Tensor, mask: torch.Tensor, last_values: torch.Tensor | None = None , gamma: float = 0.99 , gae_lambda: float = 0.95 , ) -> tuple [torch.Tensor, torch.Tensor]: """ rewards: (B, T) values: (B, T) dones: (B, T),当前位置之后是否终止 mask: (B, T),有效时间步为 1 last_values: (B,),序列截断但未终止时的 Bootstrap Value """ batch_size, seq_len = rewards.shape if last_values is None : last_values = torch.zeros( batch_size, device=values.device, dtype=values.dtype, ) advantages = torch.zeros_like(rewards) next_advantage = torch.zeros_like(last_values) next_value = last_values for t in reversed (range (seq_len)): valid = mask[:, t].to(values.dtype) not_done = 1.0 - dones[:, t].to(values.dtype) delta = ( rewards[:, t] + gamma * not_done * next_value - values[:, t] ) next_advantage = ( delta + gamma * gae_lambda * not_done * next_advantage ) next_advantage = next_advantage * valid advantages[:, t] = next_advantage next_value = torch.where( valid.bool (), values[:, t], next_value, ) returns = advantages + values returns = returns * mask.to(returns.dtype) return advantages, returns
如果轨迹因为真正到达 EOS 而结束,应令最后有效位置的 done=1,此时不再 Bootstrap;如果只是因为固定长度切断但环境并未终止,则可以令 done=0,并通过 last_values Bootstrap。
5. DPO Loss
目标
每条偏好数据包含:
( x , y w , y l ) , (x,y_w,y_l),
( x , y w , y l ) ,
其中 y w y_w y w 是 Chosen Response,y l y_l y l 是 Rejected Response。定义 Policy 与 Reference Model 对整条 Response 的 Sequence Log Probability:
log π ( y ∣ x ) = ∑ t = 1 T log π ( y t ∣ x , y < t ) . \log\pi(y\mid x)
=
\sum_{t=1}^{T}
\log\pi(y_t\mid x,y_{<t}).
log π ( y ∣ x ) = t = 1 ∑ T log π ( y t ∣ x , y < t ) .
DPO Logit 为:
z = β [ log π θ ( y w ∣ x ) π r e f ( y w ∣ x ) − log π θ ( y l ∣ x ) π r e f ( y l ∣ x ) ] . z
=
\beta
\left[
\log\frac{\pi_\theta(y_w\mid x)}{\pi_{\mathrm{ref}}(y_w\mid x)}
-
\log\frac{\pi_\theta(y_l\mid x)}{\pi_{\mathrm{ref}}(y_l\mid x)}
\right].
z = β [ log π ref ( y w ∣ x ) π θ ( y w ∣ x ) − log π ref ( y l ∣ x ) π θ ( y l ∣ x ) ] .
PyTorch 实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 import torchimport torch.nn.functional as Fdef dpo_loss ( policy_chosen_logps: torch.Tensor, policy_rejected_logps: torch.Tensor, ref_chosen_logps: torch.Tensor, ref_rejected_logps: torch.Tensor, beta: float = 0.1 , label_smoothing: float = 0.0 , ) -> tuple [torch.Tensor, dict [str , torch.Tensor]]: """ 四个输入均为 Shape (B,) 的 Sequence Log Probability。 Reference Log Probability 应提前固定,不参与梯度传播。 """ policy_log_ratio = ( policy_chosen_logps - policy_rejected_logps ) reference_log_ratio = ( ref_chosen_logps - ref_rejected_logps ).detach() logits = beta * ( policy_log_ratio - reference_log_ratio ) positive_loss = -F.logsigmoid(logits) negative_loss = -F.logsigmoid(-logits) loss = ( (1.0 - label_smoothing) * positive_loss + label_smoothing * negative_loss ).mean() chosen_reward = beta * ( policy_chosen_logps - ref_chosen_logps.detach() ) rejected_reward = beta * ( policy_rejected_logps - ref_rejected_logps.detach() ) metrics = { "chosen_reward" : chosen_reward.mean().detach(), "rejected_reward" : rejected_reward.mean().detach(), "preference_accuracy" : ( chosen_reward > rejected_reward ).float ().mean().detach(), } return loss, metrics
DPO 不要求 Reward Model、Critic 或在线 Rollout,但需要固定的 Chosen/Rejected 偏好对以及 Reference Model。四个输入必须是 Response Tokens 的 Log Probability 之和,不能把 Prompt Tokens 也计入。
A 级:常见基础组件
6. RMSNorm
目标
输入:
X ∈ R B × T × D . X\in\mathbb R^{B\times T\times D}.
X ∈ R B × T × D .
RMSNorm 固定 b , t b,t b , t ,只沿 Hidden Dimension D D D 计算:
RMS ( X b , t , : ) = 1 D ∑ j = 1 D X b , t , j 2 + ϵ . \operatorname{RMS}(X_{b,t,:})
=
\sqrt{
\frac{1}{D}\sum_{j=1}^{D}X_{b,t,j}^2+\epsilon
}.
RMS ( X b , t , : ) = D 1 j = 1 ∑ D X b , t , j 2 + ϵ .
与 LayerNorm 不同,RMSNorm 不减均值,通常也不使用 Bias:
Y b , t , j = w j X b , t , j RMS ( X b , t , : ) . Y_{b,t,j}
=
w_j
\frac{
X_{b,t,j}
}{
\operatorname{RMS}(X_{b,t,:})
}.
Y b , t , j = w j RMS ( X b , t , : ) X b , t , j .
PyTorch 实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 import torchimport torch.nn as nnclass RMSNorm (nn.Module): def __init__ (self, hidden_size: int , eps: float = 1e-6 ): super ().__init__() self .weight = nn.Parameter(torch.ones(hidden_size)) self .eps = eps def forward (self, x: torch.Tensor ) -> torch.Tensor: """ x: (..., D) return: (..., D) """ input_dtype = x.dtype x_float = x.float () mean_square = x_float.pow (2 ).mean( dim=-1 , keepdim=True , ) normalized = x_float * torch.rsqrt( mean_square + self .eps ) return ( normalized.to(input_dtype) * self .weight )
可学习参数只有 weight,Shape 为 ( D , ) (D,) ( D , ) ,它会在前面的所有维度广播。
7. RoPE
目标
RoPE 将 Head Dimension 中相邻的两个维度组成二维向量。第 i i i 对维度的角频率为:
ω i = b a s e − 2 i D h , \omega_i
=
\mathrm{base}^{-\frac{2i}{D_h}},
ω i = base − D h 2 i ,
位置 t t t 的旋转角度为:
θ t , i = t ω i . \theta_{t,i}=t\omega_i.
θ t , i = t ω i .
对每一对元素 ( x 2 i , x 2 i + 1 ) (x_{2i},x_{2i+1}) ( x 2 i , x 2 i + 1 ) 执行:
x 2 i ′ = x 2 i cos θ − x 2 i + 1 sin θ , x 2 i + 1 ′ = x 2 i sin θ + x 2 i + 1 cos θ . \begin{aligned}
x'_{2i}
&=
x_{2i}\cos\theta-x_{2i+1}\sin\theta,\\
x'_{2i+1}
&=
x_{2i}\sin\theta+x_{2i+1}\cos\theta.
\end{aligned}
x 2 i ′ x 2 i + 1 ′ = x 2 i cos θ − x 2 i + 1 sin θ , = x 2 i sin θ + x 2 i + 1 cos θ .
RoPE 只应用于 Query 和 Key,不应用于 Value。
PyTorch 实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 import torchdef rotate_half (x: torch.Tensor ) -> torch.Tensor: """ (..., Dh) 中每对元素: (x_0, x_1) -> (-x_1, x_0) """ x_even = x[..., 0 ::2 ] x_odd = x[..., 1 ::2 ] rotated = torch.stack( (-x_odd, x_even), dim=-1 , ) return rotated.flatten(start_dim=-2 )def build_rope_cache ( seq_len: int , head_dim: int , device: torch.device, base: float = 10000.0 , ) -> tuple [torch.Tensor, torch.Tensor]: assert head_dim % 2 == 0 pair_index = torch.arange( head_dim // 2 , device=device, dtype=torch.float32, ) frequencies = base ** ( -2.0 * pair_index / head_dim ) positions = torch.arange( seq_len, device=device, dtype=torch.float32, ) angles = torch.outer(positions, frequencies) angles = torch.repeat_interleave( angles, repeats=2 , dim=-1 , ) cos = angles.cos()[None , None , :, :] sin = angles.sin()[None , None , :, :] return cos, sindef apply_rope ( q: torch.Tensor, k: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor, ) -> tuple [torch.Tensor, torch.Tensor]: """ q, k: (B, H, T, Dh) cos, sin: (1, 1, T, Dh) """ q_rotated = q * cos + rotate_half(q) * sin k_rotated = k * cos + rotate_half(k) * sin return q_rotated, k_rotated
若使用 KV Cache,历史 Key 在写入 Cache 前就应应用其对应位置的 RoPE;新 Token 的 Query 和 Key 只使用新位置的角度。
8. KV Cache Attention
目标
自回归生成第 t t t 个 Token 时,历史 Tokens 的 Key 和 Value 不会变化。KV Cache 保存:
K ≤ t , V ≤ t ∈ R B × H × T c a c h e × D h . K_{\le t},V_{\le t}
\in
\mathbb R^{B\times H\times T_{\mathrm{cache}}\times D_h}.
K ≤ t , V ≤ t ∈ R B × H × T cache × D h .
每一步只计算新 Token 的 Q t , K t , V t Q_t,K_t,V_t Q t , K t , V t ,把 K t , V t K_t,V_t K t , V t 追加到 Cache,然后让 Q t Q_t Q t 关注全部 Cached Keys。
PyTorch 实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 import mathimport torchimport torch.nn as nnclass KVCacheAttention (nn.Module): def __init__ (self, hidden_size: int , num_heads: int ): super ().__init__() assert hidden_size % num_heads == 0 self .hidden_size = hidden_size self .num_heads = num_heads self .head_dim = hidden_size // num_heads self .q_proj = nn.Linear(hidden_size, hidden_size, bias=False ) self .k_proj = nn.Linear(hidden_size, hidden_size, bias=False ) self .v_proj = nn.Linear(hidden_size, hidden_size, bias=False ) self .out_proj = nn.Linear(hidden_size, hidden_size, bias=False ) def _split_heads (self, x: torch.Tensor ) -> torch.Tensor: batch_size, seq_len, _ = x.shape x = x.view( batch_size, seq_len, self .num_heads, self .head_dim, ) return x.transpose(1 , 2 ) def decode_step ( self, x_new: torch.Tensor, cache: dict [str , torch.Tensor] | None = None , new_token_mask: torch.Tensor | None = None , ) -> tuple [torch.Tensor, dict [str , torch.Tensor]]: """ x_new: (B, 1, D),当前新 Token 的 Hidden State cache["k"], cache["v"]: (B, H, T_cache, Dh) cache["mask"]: (B, T_cache) new_token_mask: (B, 1),当前 Token 是否有效 """ assert x_new.size(1 ) == 1 if new_token_mask is None : new_token_mask = torch.ones( x_new.size(0 ), 1 , dtype=torch.bool , device=x_new.device, ) else : new_token_mask = new_token_mask.bool () q_new = self ._split_heads(self .q_proj(x_new)) k_new = self ._split_heads(self .k_proj(x_new)) v_new = self ._split_heads(self .v_proj(x_new)) if cache is None : k_all = k_new v_all = v_new cache_mask = new_token_mask else : k_all = torch.cat( [cache["k" ], k_new], dim=2 , ) v_all = torch.cat( [cache["v" ], v_new], dim=2 , ) cache_mask = torch.cat( [cache["mask" ], new_token_mask], dim=1 , ) new_cache = { "k" : k_all, "v" : v_all, "mask" : cache_mask, } scores = torch.matmul( q_new, k_all.transpose(-2 , -1 ), ) / math.sqrt(self .head_dim) key_mask = cache_mask[:, None , None , :] scores = scores.masked_fill( ~key_mask, torch.finfo(scores.dtype).min , ) attn_probs = torch.softmax(scores, dim=-1 ) context = torch.matmul(attn_probs, v_all) context = context.transpose(1 , 2 ).contiguous() context = context.view( x_new.size(0 ), 1 , self .hidden_size, ) output = self .out_proj(context) output = output * new_token_mask.unsqueeze(-1 ) return output, new_cache
该实现同步维护 cache["mask"],因此不同样本的 Prompt 长度不同时也不会关注 Padding KV。代码用 torch.cat 强调逻辑;生产环境会预分配最大 Cache,再按位置原地写入,避免每一步重新分配和复制历史 KV。
9. SFT Cross-entropy Loss
目标
Causal LM 在位置 t t t 的 Logits 用来预测位置 t + 1 t+1 t + 1 的 Token,因此需要 Shift:
1 logits[:, :-1] 预测 input_ids[:, 1:]
如果只希望对 Assistant Response 做 SFT,还要同时满足:
目标位置不是 Padding;
目标位置属于 Response。
PyTorch 实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 import torchimport torch.nn.functional as Fdef sft_cross_entropy_loss ( logits: torch.Tensor, input_ids: torch.Tensor, attention_mask: torch.Tensor, response_mask: torch.Tensor, ) -> torch.Tensor: """ logits: (B, L, V) input_ids: (B, L) attention_mask: (B, L),真实 Token 为 1 response_mask: (B, L),Assistant Response Token 为 1 """ shift_logits = logits[:, :-1 , :].contiguous() shift_labels = input_ids[:, 1 :].contiguous() valid_mask = ( attention_mask[:, 1 :].bool () & response_mask[:, 1 :].bool () ) token_loss = F.cross_entropy( shift_logits.transpose(1 , 2 ), shift_labels, reduction="none" , ) mask = valid_mask.to(token_loss.dtype) loss = (token_loss * mask).sum () / mask.sum ().clamp_min(1.0 ) return loss
response_mask 标记的是 Token 自身是否属于 Response,所以 Shift 后应使用 response_mask[:, 1:]。如果把 Prompt 也设为有效,就会变成对整段对话计算 Language Modeling Loss。
10. 从 Logits 提取 Token Log Probability
目标
模型输出:
Z ∈ R B × L × V . Z\in\mathbb R^{B\times L\times V}.
Z ∈ R B × L × V .
先在 Vocabulary Dimension 做:
log P = logsoftmax ( Z , dim = − 1 ) , \log P=\operatorname{logsoftmax}(Z,\dim=-1),
log P = logsoftmax ( Z , dim = − 1 ) ,
再使用真实 Token ID 从 V V V 个候选中 gather 对应位置。
PyTorch 实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 import torchimport torch.nn.functional as Fdef get_next_token_log_probs ( logits: torch.Tensor, input_ids: torch.Tensor, ) -> torch.Tensor: """ logits: (B, L, V) input_ids: (B, L) return: (B, L - 1) 返回第 1...L-1 个 Token 在模型中的 Log Probability。 """ shift_logits = logits[:, :-1 , :] target_ids = input_ids[:, 1 :] log_probs = F.log_softmax( shift_logits, dim=-1 , ) selected_log_probs = torch.gather( log_probs, dim=-1 , index=target_ids.unsqueeze(-1 ), ).squeeze(-1 ) return selected_log_probs
若只需要 Response Token 的 Log Probability,再使用 Shift 后的 Response Mask:
1 2 token_log_probs = get_next_token_log_probs(logits, input_ids) response_log_probs = token_log_probs * response_mask[:, 1 :]
不能先 softmax 再 log,log_softmax 的数值稳定性更好。
11. KL Divergence 与 Entropy
目标
给定 Policy Distribution p p p 和 Reference Distribution q q q :
D K L ( p ∥ q ) = ∑ a p ( a ) [ log p ( a ) − log q ( a ) ] . D_{\mathrm{KL}}(p\Vert q)
=
\sum_a p(a)
\left[
\log p(a)-\log q(a)
\right].
D KL ( p ∥ q ) = a ∑ p ( a ) [ log p ( a ) − log q ( a ) ] .
Policy Entropy 为:
H ( p ) = − ∑ a p ( a ) log p ( a ) . H(p)
=
-\sum_a p(a)\log p(a).
H ( p ) = − a ∑ p ( a ) log p ( a ) .
两者都在 Vocabulary Dimension 上求和。
PyTorch 实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 import torchimport torch.nn.functional as Fdef categorical_kl_and_entropy ( policy_logits: torch.Tensor, reference_logits: torch.Tensor, ) -> tuple [torch.Tensor, torch.Tensor]: """ 输入: (..., V) 输出: kl: (...) entropy: (...) """ policy_log_probs = F.log_softmax( policy_logits, dim=-1 , ) reference_log_probs = F.log_softmax( reference_logits, dim=-1 , ) policy_probs = policy_log_probs.exp() kl = ( policy_probs * (policy_log_probs - reference_log_probs) ).sum (dim=-1 ) entropy = -( policy_probs * policy_log_probs ).sum (dim=-1 ) return kl, entropydef sampled_log_ratio ( policy_token_log_probs: torch.Tensor, reference_token_log_probs: torch.Tensor, ) -> torch.Tensor: """ 输入 Shape 通常为 (B, T_response)。 这是已采样 Token 上的 Log Ratio,不是对整个词表求和的精确 KL。 只有 Token 确实从当前 Policy 采样时,其样本均值才直接估计 KL(Current Policy || Reference Policy)。 """ return ( policy_token_log_probs - reference_token_log_probs )
第一种实现需要保留完整 Vocabulary Distribution,计算精确但显存较高;大模型 RL 中经常只保存已采样 Token 的 Log Probability,使用第二种 Sample-based Estimator。此时必须说明 Token 的采样分布:若数据来自不同的 Old Policy,就不能直接把未加权的样本均值称为 Current Policy 对 Reference Policy 的无偏 KL 估计。
B 级:进阶结构与工程题
12. GQA 与 MQA
目标
MHA 中 Query、Key、Value 的 Head 数相同:
H q = H k v . H_q=H_{kv}.
H q = H k v .
GQA 让多个 Query Heads 共享一组 Key/Value Head:
1 < H k v < H q . 1<H_{kv}<H_q.
1 < H k v < H q .
MQA 是 GQA 的特殊情况:
H k v = 1. H_{kv}=1.
H k v = 1.
要求 H q H_q H q 能被 H k v H_{kv} H k v 整除。实现时将每个 KV Head 重复:
n r e p = H q H k v n_{\mathrm{rep}}=\frac{H_q}{H_{kv}}
n rep = H k v H q
次,使其与 Query Heads 对齐。
PyTorch 实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 import mathimport torchimport torch.nn as nnclass GroupedQueryAttention (nn.Module): def __init__ ( self, hidden_size: int , num_query_heads: int , num_kv_heads: int , ): super ().__init__() assert hidden_size % num_query_heads == 0 assert num_query_heads % num_kv_heads == 0 self .hidden_size = hidden_size self .num_query_heads = num_query_heads self .num_kv_heads = num_kv_heads self .head_dim = hidden_size // num_query_heads self .num_repeats = num_query_heads // num_kv_heads self .q_proj = nn.Linear( hidden_size, num_query_heads * self .head_dim, bias=False , ) self .k_proj = nn.Linear( hidden_size, num_kv_heads * self .head_dim, bias=False , ) self .v_proj = nn.Linear( hidden_size, num_kv_heads * self .head_dim, bias=False , ) self .out_proj = nn.Linear( num_query_heads * self .head_dim, hidden_size, bias=False , ) def forward ( self, x: torch.Tensor, attention_mask: torch.Tensor | None = None , ) -> torch.Tensor: """ x: (B, T, D) """ batch_size, seq_len, _ = x.shape q = self .q_proj(x).view( batch_size, seq_len, self .num_query_heads, self .head_dim, ).transpose(1 , 2 ) k = self .k_proj(x).view( batch_size, seq_len, self .num_kv_heads, self .head_dim, ).transpose(1 , 2 ) v = self .v_proj(x).view( batch_size, seq_len, self .num_kv_heads, self .head_dim, ).transpose(1 , 2 ) k = torch.repeat_interleave( k, repeats=self .num_repeats, dim=1 , ) v = torch.repeat_interleave( v, repeats=self .num_repeats, dim=1 , ) scores = torch.matmul( q, k.transpose(-2 , -1 ), ) / math.sqrt(self .head_dim) causal_mask = torch.tril( torch.ones( seq_len, seq_len, dtype=torch.bool , device=x.device, ) )[None , None , :, :] allowed = causal_mask if attention_mask is not None : allowed = ( allowed & attention_mask.bool ()[:, None , None , :] ) scores = scores.masked_fill( ~allowed, torch.finfo(scores.dtype).min , ) probs = torch.softmax(scores, dim=-1 ) output = torch.matmul(probs, v) output = output.transpose(1 , 2 ).contiguous() output = output.view(batch_size, seq_len, self .hidden_size) output = self .out_proj(output) if attention_mask is not None : output = output * attention_mask.unsqueeze(-1 ) return output
减少 H k v H_{kv} H k v 不会减少 Query Heads,因此仍能保留多组 Query 表示;它主要减少 K/V Projection 参数和 KV Cache 大小。
13. Top-k 与 Top-p Sampling
目标
Top-k:只保留 Logits 最大的 k k k 个 Tokens;
Top-p:保留按概率从高到低排序后,累计概率刚达到阈值 p p p 的最小 Token 集合;
Temperature:使用 z / τ z/\tau z / τ 调整分布尖锐程度。
过滤后重新 Softmax,再使用 torch.multinomial 采样。
PyTorch 实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 import torch@torch.no_grad() def top_k_top_p_sample ( logits: torch.Tensor, temperature: float = 1.0 , top_k: int | None = None , top_p: float | None = None , ) -> torch.Tensor: """ logits: (B, V) return: (B,),采样得到的 Token IDs """ if temperature <= 0 : raise ValueError("temperature must be positive" ) filtered_logits = logits / temperature if top_k is not None and top_k > 0 : top_k = min (top_k, filtered_logits.size(-1 )) top_k_indices = torch.topk( filtered_logits, k=top_k, dim=-1 , ).indices keep_mask = torch.zeros_like( filtered_logits, dtype=torch.bool , ) keep_mask.scatter_( dim=-1 , index=top_k_indices, value=True , ) filtered_logits = filtered_logits.masked_fill( ~keep_mask, float ("-inf" ), ) if top_p is not None and top_p < 1.0 : if top_p <= 0 : raise ValueError("top_p must be in (0, 1]" ) sorted_logits, sorted_indices = torch.sort( filtered_logits, descending=True , dim=-1 , ) sorted_probs = torch.softmax(sorted_logits, dim=-1 ) cumulative_probs = torch.cumsum(sorted_probs, dim=-1 ) remove_sorted = cumulative_probs > top_p remove_sorted[..., 1 :] = remove_sorted[ ..., :-1 ].clone() remove_sorted[..., 0 ] = False remove_original = torch.zeros_like( remove_sorted ).scatter( dim=-1 , index=sorted_indices, src=remove_sorted, ) filtered_logits = filtered_logits.masked_fill( remove_original, float ("-inf" ), ) probs = torch.softmax(filtered_logits, dim=-1 ) token_ids = torch.multinomial( probs, num_samples=1 , ).squeeze(-1 ) return token_ids
Top-p 中必须保留“第一个越过阈值”的 Token,否则保留集合的累计概率可能小于指定的 p p p 。
14. LoRA Linear
目标
冻结原始权重:
W 0 ∈ R D o u t × D i n , W_0\in\mathbb R^{D_{\mathrm{out}}\times D_{\mathrm{in}}},
W 0 ∈ R D out × D in ,
只训练两个低秩矩阵:
A ∈ R r × D i n , B ∈ R D o u t × r . A\in\mathbb R^{r\times D_{\mathrm{in}}},
\qquad
B\in\mathbb R^{D_{\mathrm{out}}\times r}.
A ∈ R r × D in , B ∈ R D out × r .
前向传播为:
y = W 0 x + α r B A x . y
=
W_0x
+
\frac{\alpha}{r}BAx.
y = W 0 x + r α B A x .
PyTorch 实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 import mathimport torchimport torch.nn as nnimport torch.nn.functional as Fclass LoRALinear (nn.Module): def __init__ ( self, in_features: int , out_features: int , rank: int = 8 , alpha: float = 16.0 , dropout: float = 0.0 , bias: bool = True , ): super ().__init__() assert rank > 0 self .base = nn.Linear( in_features, out_features, bias=bias, ) self .base.weight.requires_grad_(False ) if self .base.bias is not None : self .base.bias.requires_grad_(False ) self .lora_a = nn.Parameter( torch.empty(rank, in_features) ) self .lora_b = nn.Parameter( torch.zeros(out_features, rank) ) self .scaling = alpha / rank self .dropout = nn.Dropout(dropout) nn.init.kaiming_uniform_( self .lora_a, a=math.sqrt(5 ), ) nn.init.zeros_(self .lora_b) def forward (self, x: torch.Tensor ) -> torch.Tensor: base_output = self .base(x) low_rank = F.linear( self .dropout(x), self .lora_a, ) lora_output = F.linear( low_rank, self .lora_b, ) return base_output + self .scaling * lora_output @torch.no_grad() def merged_weight (self ) -> torch.Tensor: """ 推理时可将 LoRA 增量合并进基础权重。 """ delta_weight = self .lora_b @ self .lora_a return self .base.weight + self .scaling * delta_weight
lora_b 初始化为零非常重要:训练开始时模块行为与原始 Linear 完全一致,但 lora_b 在第一步可以获得梯度;之后 lora_a 也会逐渐获得非零梯度。
15. FlashAttention 原理伪代码
目标
普通 Attention 会显式构造:
S = Q K T ∈ R B × H × T × T , S=QK^T\in\mathbb R^{B\times H\times T\times T},
S = Q K T ∈ R B × H × T × T ,
其显存复杂度为 O ( T 2 ) O(T^2) O ( T 2 ) 。FlashAttention 将 Query 和 Key/Value 切成 Blocks,并使用 Online Softmax 维护:
当前最大值 m m m ;
Softmax 分母 l l l ;
未归一化输出累积量 O O O 。
这样不需要把完整的 T × T T\times T T × T Attention Matrix 写回显存。
PyTorch 原理实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 import mathimport torchdef flash_attention_forward ( q: torch.Tensor, k: torch.Tensor, v: torch.Tensor, block_size: int = 64 , causal: bool = True , ) -> torch.Tensor: """ q, k, v: (B, H, T, Dh) 这是说明 Online Softmax 的 PyTorch 伪代码, 不会比官方 CUDA/Triton Kernel 更快。 """ batch_size, num_heads, seq_len, head_dim = q.shape output = torch.zeros_like(q, dtype=torch.float32) scale = 1.0 / math.sqrt(head_dim) for q_start in range (0 , seq_len, block_size): q_end = min (q_start + block_size, seq_len) q_block = q[:, :, q_start:q_end, :].float () q_len = q_end - q_start running_max = torch.full( (batch_size, num_heads, q_len, 1 ), float ("-inf" ), device=q.device, dtype=torch.float32, ) running_sum = torch.zeros_like(running_max) running_output = torch.zeros( batch_size, num_heads, q_len, head_dim, device=q.device, dtype=torch.float32, ) for k_start in range (0 , seq_len, block_size): k_end = min (k_start + block_size, seq_len) if causal and k_start >= q_end: break k_block = k[:, :, k_start:k_end, :].float () v_block = v[:, :, k_start:k_end, :].float () scores = torch.matmul( q_block, k_block.transpose(-2 , -1 ), ) * scale if causal: q_positions = torch.arange( q_start, q_end, device=q.device, )[:, None ] k_positions = torch.arange( k_start, k_end, device=q.device, )[None , :] causal_mask = ( k_positions <= q_positions )[None , None , :, :] scores = scores.masked_fill( ~causal_mask, float ("-inf" ), ) block_max = scores.max ( dim=-1 , keepdim=True , ).values new_max = torch.maximum( running_max, block_max, ) old_scale = torch.exp( running_max - new_max ) block_exp = torch.exp(scores - new_max) running_sum = ( running_sum * old_scale + block_exp.sum (dim=-1 , keepdim=True ) ) running_output = ( running_output * old_scale + torch.matmul(block_exp, v_block) ) running_max = new_max output[:, :, q_start:q_end, :] = ( running_output / running_sum.clamp_min(1e-12 ) ) return output.to(q.dtype)
真正的 FlashAttention 会将 Block 计算融合进 CUDA/Triton Kernel,并实现对应的分块反向传播。面试重点是解释 Online Softmax 如何在最大值变化时重新缩放旧的分母与输出累积量。
目标
现代 Decoder-only LLM 通常使用 Pre-Norm:
X 1 = X + Attention ( RMSNorm ( X ) ) , X_1
=
X+\operatorname{Attention}(\operatorname{RMSNorm}(X)),
X 1 = X + Attention ( RMSNorm ( X )) ,
X o u t = X 1 + SwiGLU ( RMSNorm ( X 1 ) ) . X_{\mathrm{out}}
=
X_1+\operatorname{SwiGLU}(\operatorname{RMSNorm}(X_1)).
X out = X 1 + SwiGLU ( RMSNorm ( X 1 )) .
SwiGLU 为:
SwiGLU ( x ) = W d o w n [ SiLU ( W g a t e x ) ⊙ ( W u p x ) ] . \operatorname{SwiGLU}(x)
=
W_{\mathrm{down}}
\left[
\operatorname{SiLU}(W_{\mathrm{gate}}x)
\odot
(W_{\mathrm{up}}x)
\right].
SwiGLU ( x ) = W down [ SiLU ( W gate x ) ⊙ ( W up x ) ] .
PyTorch 实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 import mathimport torchimport torch.nn as nnimport torch.nn.functional as Fclass RMSNorm (nn.Module): def __init__ (self, hidden_size: int , eps: float = 1e-6 ): super ().__init__() self .weight = nn.Parameter(torch.ones(hidden_size)) self .eps = eps def forward (self, x: torch.Tensor ) -> torch.Tensor: variance = x.float ().pow (2 ).mean( dim=-1 , keepdim=True , ) normalized = x.float () * torch.rsqrt( variance + self .eps ) return normalized.to(x.dtype) * self .weightclass CausalSelfAttention (nn.Module): def __init__ (self, hidden_size: int , num_heads: int ): super ().__init__() assert hidden_size % num_heads == 0 self .hidden_size = hidden_size self .num_heads = num_heads self .head_dim = hidden_size // num_heads self .qkv_proj = nn.Linear( hidden_size, 3 * hidden_size, bias=False , ) self .out_proj = nn.Linear( hidden_size, hidden_size, bias=False , ) def forward ( self, x: torch.Tensor, attention_mask: torch.Tensor | None = None , ) -> torch.Tensor: batch_size, seq_len, _ = x.shape q, k, v = self .qkv_proj(x).chunk(3 , dim=-1 ) def split_heads (tensor: torch.Tensor ) -> torch.Tensor: return tensor.view( batch_size, seq_len, self .num_heads, self .head_dim, ).transpose(1 , 2 ) q, k, v = map (split_heads, (q, k, v)) scores = torch.matmul( q, k.transpose(-2 , -1 ), ) / math.sqrt(self .head_dim) allowed = torch.tril( torch.ones( seq_len, seq_len, dtype=torch.bool , device=x.device, ) )[None , None , :, :] if attention_mask is not None : allowed = ( allowed & attention_mask.bool ()[:, None , None , :] ) scores = scores.masked_fill( ~allowed, torch.finfo(scores.dtype).min , ) probs = torch.softmax(scores, dim=-1 ) output = torch.matmul(probs, v) output = output.transpose(1 , 2 ).contiguous() output = output.view(batch_size, seq_len, self .hidden_size) return self .out_proj(output)class SwiGLU (nn.Module): def __init__ ( self, hidden_size: int , intermediate_size: int , ): super ().__init__() self .gate_proj = nn.Linear( hidden_size, intermediate_size, bias=False , ) self .up_proj = nn.Linear( hidden_size, intermediate_size, bias=False , ) self .down_proj = nn.Linear( intermediate_size, hidden_size, bias=False , ) def forward (self, x: torch.Tensor ) -> torch.Tensor: gated = F.silu(self .gate_proj(x)) values = self .up_proj(x) return self .down_proj(gated * values)class TransformerBlock (nn.Module): def __init__ ( self, hidden_size: int , num_heads: int , intermediate_size: int , ): super ().__init__() self .attn_norm = RMSNorm(hidden_size) self .attention = CausalSelfAttention( hidden_size, num_heads, ) self .ffn_norm = RMSNorm(hidden_size) self .ffn = SwiGLU( hidden_size, intermediate_size, ) def forward ( self, x: torch.Tensor, attention_mask: torch.Tensor | None = None , ) -> torch.Tensor: x = x + self .attention( self .attn_norm(x), attention_mask, ) x = x + self .ffn(self .ffn_norm(x)) if attention_mask is not None : x = x * attention_mask.unsqueeze(-1 ) return x
完整现代 LLM 还会加入 RoPE、GQA、KV Cache、FlashAttention 和 Tensor Parallel;面试手写 Block 时,先保证 Pre-Norm、Residual 和 SwiGLU 的数据流正确。
17. RL Rollout Masking
目标
同一个 Batch 中的 Responses 长度不同,需要 Padding 到统一长度:
1 2 Response 1: y1 y2 EOS PAD PAD Response 2: y1 y2 y3 y4 EOS
Response Mask 应包含真实 Tokens 和第一个 EOS,但排除 EOS 后的 Padding。Reward、Log Probability、Advantage 和 PPO Loss 都必须使用同一套有效位置定义。
PyTorch 实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 import torchdef mask_from_lengths ( lengths: torch.Tensor, max_length: int , ) -> torch.Tensor: """ lengths: (B,),包含 EOS 在内的有效 Response 长度 return: (B, T) """ positions = torch.arange( max_length, device=lengths.device, ) return positions.unsqueeze(0 ) < lengths.unsqueeze(1 )def infer_lengths_from_eos ( responses: torch.Tensor, eos_token_id: int , ) -> torch.Tensor: """ responses: (B, T) 返回包含第一个 EOS 在内的长度; 若没有 EOS,则整条 Response 都有效。 """ batch_size, max_length = responses.shape positions = torch.arange( max_length, device=responses.device, ).expand(batch_size, -1 ) eos_positions = torch.where( responses.eq(eos_token_id), positions, torch.full_like(positions, max_length), ) first_eos = eos_positions.min (dim=1 ).values lengths = torch.where( first_eos < max_length, first_eos + 1 , torch.full_like(first_eos, max_length), ) return lengthsdef build_rollout_training_tensors ( responses: torch.Tensor, sequence_rewards: torch.Tensor, eos_token_id: int , response_lengths: torch.Tensor | None = None , terminated: torch.Tensor | None = None , ) -> dict [str , torch.Tensor]: """ responses: (B, T) sequence_rewards: (B,) response_lengths: (B,),Rollout Engine 提供时应优先使用 terminated: (B,),是否因 EOS 真正终止 """ batch_size, max_length = responses.shape if response_lengths is None : response_lengths = infer_lengths_from_eos( responses, eos_token_id, ) if torch.any (response_lengths < 1 ): raise ValueError("response length must be at least 1" ) if torch.any (response_lengths > max_length): raise ValueError("response length exceeds padded length" ) last_indices = response_lengths - 1 if terminated is None : last_tokens = responses.gather( dim=1 , index=last_indices.unsqueeze(1 ), ).squeeze(1 ) terminated = last_tokens.eq(eos_token_id) else : terminated = terminated.bool () response_mask = mask_from_lengths( response_lengths, max_length, ) token_rewards = torch.zeros( batch_size, max_length, device=responses.device, dtype=sequence_rewards.dtype, ) token_rewards.scatter_( dim=1 , index=last_indices.unsqueeze(1 ), src=sequence_rewards.unsqueeze(1 ), ) dones = torch.zeros( batch_size, max_length, device=responses.device, dtype=torch.bool , ) dones.scatter_( dim=1 , index=last_indices.unsqueeze(1 ), src=terminated.unsqueeze(1 ), ) return { "response_mask" : response_mask, "token_rewards" : token_rewards, "dones" : dones, "terminated" : terminated, "response_lengths" : response_lengths, }
若 pad_token_id == eos_token_id,不能简单使用 responses != pad_token_id 构造 Mask,否则真正的 EOS 也会被删除。最稳妥的方式是直接使用 Rollout Engine 返回的生成长度;没有长度时,再根据第一个 EOS 推断。
还要区分 terminated 与 truncated:生成 EOS 表示 Episode 真正结束,应在最后有效位置设置 done=True;达到最大长度但没有 EOS 只是截断,应保持 done=False,并在 GAE 中使用截断位置之后的 last_values 做 Bootstrap。
得到 response_mask 后,所有 Token-level 统计都应使用 Masked Reduction:
1 2 3 def masked_mean (x: torch.Tensor, mask: torch.Tensor ) -> torch.Tensor: mask = mask.to(x.dtype) return (x * mask).sum () / mask.sum ().clamp_min(1.0 )
18. 面试前的检查清单
写完代码后,至少检查:
Attention 的 Score Shape 是否为 ( B , H , T q , T k ) (B,H,T_q,T_k) ( B , H , T q , T k ) ;
Causal Mask 和 Padding Mask 是否在 Softmax 前生效;
Causal LM 的 Logits 与 Labels 是否正确 Shift;
Token ID 是否通过 gather 从 Vocabulary Dimension 取概率;
Padding、Prompt 和 EOS 是否按题意进入 Loss;
Old Log Probability、Advantage、Return、Reference Log Probability 是否停止梯度;
所有除法分母是否处理空 Mask 或零方差;
BF16/FP16 下归一化、Softmax 累积是否需要转 FP32;
.transpose() 后使用 .view() 前是否调用 .contiguous();
返回的是要最大化的 Objective,还是供 Optimizer 最小化的 Loss。
真正的面试重点不是逐字背代码,而是能够根据公式恢复实现,并在写代码的同时说清楚每个 Tensor 的 Shape 和 Mask 语义。