#十四点五、手撕代码完整参考实现(新增)
前面列出了手撕代码的题单和评分要点,但面试准备时“知道考什么”和“能手写出来”之间还有一段距离。本轮分成两条线:先用一个最小 NumPy Multi-Head Attention 对齐判题机的输入、权重、mask 和输出;再把它扩展到完整 Transformer。面试主线只使用 NumPy 的数组运算,不调用现成 Attention 层;后面的标准库版本和框架代码作为对照。
#面试主线:NumPy Multi-Head Attention
完整实现:mha_interview.py;测试:test_mha_interview.py。这才是面试现场应该先写的版本:输入和输出统一为 (B, T, D),W_q/W_k/W_v/W_o 由调用方传入,避免随机初始化掩盖公式,也方便判题机用同一组权重做数值对照。
面试实现的固定契约
| 对象 | 形状 | 实现约定 |
|---|---|---|
X | (B, T, D) | batch-first;当前实现是 self-attention。 |
W_q/W_k/W_v/W_o | (D, D) | 按 [out_features][in_features] 存储,等价于 x @ W.T + b。 |
| 拆头后 | (B, H, T, d) | d = D / H,每个 head 独立计算。 |
| attention mask | (B, T) | True 表示该 key 可读;causal mask 另行禁止未来位置。 |
| 输出 | (B, T, D) | 合并 heads 后经过 W_o 和 b_o。 |
Q = linear(X, W_q, b_q)
K = linear(X, W_k, b_k)
V = linear(X, W_v, b_v)
Q = reshape_to_heads(Q) # (B, H, T, d)
K = reshape_to_heads(K)
V = reshape_to_heads(V)
scores = Q @ transpose(K, -2, -1) / sqrt(d)
scores = apply_causal_and_padding_mask(scores)
weights = stable_softmax(scores, dim=-1)
heads = weights @ V
heads = merge_heads(heads) # (B, T, D)
output = linear(heads, W_o, b_o)
和参考实现一致的必要条件:必须使用同一组投影权重和 bias、同一 batch-first/layout、同一缩放维度、同一 mask 语义、关闭 dropout,并在相同精度下比较。具体 Hugging Face 模型还可能增加 RoPE、GQA/MQA、RMSNorm 等模块,不能只拿基础 MHA 去对齐整个模型输出。
#参考实现 0:纯 Python Attention 与 Decoder-only Transformer
完整代码:transformer_from_scratch.py;回归测试:test_transformer_from_scratch.py。运行测试:python3 -m unittest discover -s notes/llm-interview-question-bank/code -p 'test_*.py'。实现的核心数值依赖只来自标准库中的 math 和 random;它的目标是让数据流可读,不追求生产级矩阵 kernel 的速度。
先把每一步的 shape 说清楚
| 步骤 | 张量 / 列表形状 | 发生了什么 |
|---|---|---|
| 输入 | (B, T) | 每个 batch 是一串 token id。 |
| Embedding | (B, T, D) | 每个 token id 查表得到 hidden vector。 |
| Q/K/V 投影 | 各为 (B, T, D) | 同一份 hidden state 通过三张不同的线性映射得到 query、key、value。 |
| 拆头 | (B, H, T, d) | D = H × d;每个 head 独立计算关系。 |
| 分数 | (B, H, T, T) | 每个 query 位置和每个 key 位置做缩放点积。 |
| 加权聚合 | (B, H, T, d) | softmax 后沿 key 维加权求和 V。 |
| 合头 / 输出投影 | (B, T, D) | 拼回 hidden 维度,再经过 W_o。 |
| LM head | (B, T, V) | 每个位置输出整个词表的 logits,训练时预测下一个 token。 |
Attention 的核心不是一张相似度表,而是一个受 mask 约束的信息路由器
对第 \(i\) 个 query,先计算 \(s_{ij}=q_i\cdot k_j/\sqrt{d}\),再对允许读取的位置做稳定 softmax,最后计算 \(o_i=\sum_j a_{ij}v_j\)。代码中的 allowed 列表同时承载两种约束:causal attention 要求 \(j\leq i\),padding mask 要求 key 位置是真实 token。被禁止的位置权重直接为 0,而不是让它们参与归一化。
def one_query_attention(query, keys, values, query_position):
scores = []
allowed = []
for key_position, key in enumerate(keys):
scores.append(dot(query, key) / sqrt(head_dim))
allowed.append(key_position <= query_position) # causal mask
weights = stable_softmax(scores, allowed) # 减最大值,避免 exp 溢出
output = [
sum(weights[j] * values[j][dimension] for j in range(len(values)))
for dimension in range(head_dim)
]
return output, weights
这里的代码块只展示逻辑骨架,完整版本把 dot、sqrt、稳定 softmax、矩阵乘法和多头循环全部展开。注意:sqrt(d) 只控制 logits 尺度,不会让 causal mask 自动出现;mask 必须显式构造。
一个 Transformer block 如何串起来
实现采用常见的 pre-norm 路径:x → LayerNorm → Self-Attention → residual add → LayerNorm → GELU FFN → residual add。Attention 在 token 维度混合信息,FFN 对每个 token 独立做非线性变换;两次 residual 让新分支只需学习“如何修正旧表示”。多个 block 叠起来后,最后的 LayerNorm 和词表投影才产生 logits。
代码还提供了一个最小的 greedy generate。它每生成一个 token 都重新跑当前上下文,因此是教学上最直观的 \(O(T^2)\) 路径;生产实现会保存历史 K/V,避免重复计算。这个差异正是“会写 Attention”与“理解 Transformer 推理系统”的连接点。
这份实现的边界
- 权重是随机初始化的,所以输出只用于检查 shape 和信息流,不代表模型已经学会语言。
- 没有反向传播、优化器、dropout、量化、KV cache 或高性能 kernel;下一步如果要继续手撕训练,应该先实现一个小损失和有限差分/反向传播,而不是直接跳到完整训练框架。
- 这里实现的是 decoder-only self-attention。Encoder-decoder 的 cross-attention 只需把 Q 来自 decoder hidden state、K/V 改为 encoder 输出,并使用相应的 padding mask;核心缩放点积不变。
#参考实现 1:手写 Multi-Head Attention(PyTorch API 对照)
题目要求:给定 Q/K/V 三个张量,实现 Scaled Dot-Product Attention + Multi-Head 拆分与合并,支持 padding mask 和 causal mask,并保证数值稳定性。
import torch
import torch.nn as nn
import math
class MultiHeadAttention(nn.Module):
"""
手写 Multi-Head Attention 完整实现。
面试评分点对应:
1. shape 是否正确流转 -> 每步都有 shape 注释
2. 是否记得除以 sqrt(d_k) -> scaled_dot_product_attention 内
3. causal mask 方向是否正确 -> scaled_dot_product_attention 内,上三角填 -1e9
4. 数值稳定性(softmax 前减最大值) -> scaled_dot_product_attention 内
5. 多头拆分与合并的维度操作是否正确 -> view + transpose / transpose + view
"""
def __init__(self, d_model: int, num_heads: int, dropout: float = 0.1):
super().__init__()
assert d_model % num_heads == 0, "d_model 必须能被 num_heads 整除"
self.d_model = d_model # 模型隐藏维度,如 512, 768, 4096
self.num_heads = num_heads # 注意力头数,如 8, 12, 32
self.head_dim = d_model // num_heads # 每头维度 d_k,如 64
# 线性投影:把输入映射到 Q/K/V 空间
self.W_q = nn.Linear(d_model, d_model)
self.W_k = nn.Linear(d_model, d_model)
self.W_v = nn.Linear(d_model, d_model)
# 输出投影:把多头拼接后的结果映射回 d_model
self.W_o = nn.Linear(d_model, d_model)
self.dropout = nn.Dropout(dropout)
self.scale = math.sqrt(self.head_dim)
def forward(self, x: torch.Tensor, mask: torch.Tensor = None, is_causal: bool = False):
B, T, _ = x.shape
# Step 1: 线性投影
Q = self.W_q(x) # (B, T, d_model)
K = self.W_k(x) # (B, T, d_model)
V = self.W_v(x) # (B, T, d_model)
# Step 2: 拆分为多头: (B, T, d) -> (B, num_heads, T, head_dim)
Q = Q.view(B, T, self.num_heads, self.head_dim).transpose(1, 2)
K = K.view(B, T, self.num_heads, self.head_dim).transpose(1, 2)
V = V.view(B, T, self.num_heads, self.head_dim).transpose(1, 2)
# Step 3: Scaled Dot-Product Attention
attn_output, attn_weights = self.scaled_dot_product_attention(
Q, K, V, mask=mask, is_causal=is_causal
)
# Step 4: 多头合并
attn_output = attn_output.transpose(1, 2).contiguous() # (B, T, H, d_k)
attn_output = attn_output.view(B, T, self.d_model) # (B, T, d_model)
# 踩坑点:transpose 后必须 contiguous(),否则 view 可能报错
# Step 5: 输出投影
output = self.W_o(attn_output)
output = self.dropout(output)
return output, attn_weights
def scaled_dot_product_attention(self, Q, K, V, mask=None, is_causal=False):
B, H, T, d_k = Q.shape
# 注意力分数: (B, H, T, d_k) @ (B, H, d_k, T) -> (B, H, T, T)
scores = torch.matmul(Q, K.transpose(-2, -1)) / self.scale
# 应用 mask
if mask is not None:
if mask.dim() == 2:
mask = mask.unsqueeze(1).unsqueeze(2)
scores = scores.masked_fill(mask == 0, -1e9)
if is_causal:
causal_mask = torch.triu(torch.ones(T, T, device=scores.device, dtype=torch.bool), diagonal=1)
scores = scores.masked_fill(causal_mask.unsqueeze(0).unsqueeze(0), -1e9)
# 除以 sqrt(d_k) 只控制尺度,不能替代减最大值;torch.softmax 内部已做这一步,
# 这里显式写出,对应手写 exp / sum 时必须先减每行最大值
attn_weights = torch.softmax(
scores - scores.amax(dim=-1, keepdim=True), dim=-1
)
attn_weights = self.dropout(attn_weights)
# 加权求和: (B, H, T, T) @ (B, H, T, d_k) -> (B, H, T, d_k)
output = torch.matmul(attn_weights, V)
return output, attn_weights
# 快速测试
def test_mha():
B, T, d = 2, 10, 512
num_heads = 8
mha = MultiHeadAttention(d_model=d, num_heads=num_heads)
x = torch.randn(B, T, d)
# Test 1: 无 mask
out, attn = mha(x)
assert out.shape == (B, T, d)
assert attn.shape == (B, num_heads, T, T)
print("Test 1 通过: 无 mask 前向")
# Test 2: padding mask
pad_mask = torch.ones(B, T, dtype=torch.bool)
pad_mask[:, 8:] = False
out2, attn2 = mha(x, mask=pad_mask)
assert torch.allclose(attn2[:, :, :, 8:].sum(), torch.tensor(0.0), atol=1e-6)
print("Test 2 通过: padding mask 正确")
# Test 3: causal mask
out3, attn3 = mha(x, is_causal=True)
upper_tri = torch.triu(torch.ones(T, T), diagonal=1).bool()
assert torch.allclose(attn3[:, :, upper_tri].sum(), torch.tensor(0.0), atol=1e-6)
print("Test 3 通过: causal mask 正确")
print("\n所有测试通过!")
if __name__ == "__main__":
test_mha()
#面试官追问要点
- 为什么除以
sqrt(d_k)? 假设 Q、K 元素独立同分布 N(0,1),则点积方差为d_k。除以sqrt(d_k)把方差拉回 1,避免 softmax 输入过大导致梯度消失。 - 为什么
masked_fill填 -1e9 而不是-inf? 当一整行都被 mask 时,-inf会让 softmax 在减最大值时出现-inf - (-inf),整行变成 NaN,并沿反向传播污染梯度;这与硬件无关。-1e9只是避免了 NaN:整行都是-1e9时 softmax 退化成均匀分布,仍会读到 padding,所以全屏蔽的 query 行还要在输出端清零。另外 fp16 下-1e9会溢出成-inf,半精度实现通常改用torch.finfo(dtype).min。 - 为什么 transpose 后要
contiguous()? PyTorch 的view要求张量内存连续。transpose只改变 strides 不复制数据,所以必须contiguous()后再view。 - W_q/W_k/W_v 能否合并? 可以,很多实现用
nn.Linear(d_model, 3*d_model)一次性投影再切分,效率略高。面试写分开的更清晰。
#参考实现 2:手写简化 PPO 算法(PyTorch)
题目要求:实现简化版 PPO,包含 Actor-Critic、GAE、PPO-Clip 目标、KL early stopping。
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.distributions import Categorical
import numpy as np
class ActorCritic(nn.Module):
def __init__(self, obs_dim: int, action_dim: int, hidden_dim: int = 256):
super().__init__()
self.shared = nn.Sequential(
nn.Linear(obs_dim, hidden_dim), nn.ReLU(),
nn.Linear(hidden_dim, hidden_dim), nn.ReLU(),
)
self.actor_head = nn.Linear(hidden_dim, action_dim)
self.critic_head = nn.Linear(hidden_dim, 1)
def forward(self, obs):
feat = self.shared(obs)
return self.actor_head(feat), self.critic_head(feat)
def get_action_and_value(self, obs, action=None):
logits, value = self.forward(obs)
dist = Categorical(logits=logits)
if action is None:
action = dist.sample()
return action, dist.log_prob(action), dist.entropy(), value.squeeze(-1)
def compute_gae(rewards, values, dones, gamma=0.99, lam=0.95):
"""GAE: 反向计算,从最后一个时间步往前推。"""
T = len(rewards)
advantages = torch.zeros_like(rewards)
last_gae = 0.0
for t in reversed(range(T)):
next_value = 0.0 if t == T - 1 else values[t + 1]
delta = rewards[t] + gamma * next_value * (1 - dones[t]) - values[t]
last_gae = delta + gamma * lam * (1 - dones[t]) * last_gae
advantages[t] = last_gae
return advantages, advantages + values
class PPOTrainer:
def __init__(self, ac, lr=3e-4, gamma=0.99, lam=0.95,
clip_eps=0.2, value_coef=0.5, entropy_coef=0.01,
max_kl=0.015, num_epochs=4, batch_size=64, device="cpu"):
self.ac = ac.to(device)
self.optimizer = torch.optim.Adam(self.ac.parameters(), lr=lr)
self.gamma, self.lam = gamma, lam
self.clip_eps = clip_eps
self.value_coef = value_coef
self.entropy_coef = entropy_coef
self.max_kl = max_kl
self.num_epochs = num_epochs
self.batch_size = batch_size
self.device = device
def update(self, traj):
obs = traj["obs"].to(self.device)
actions = traj["actions"].to(self.device)
rewards = traj["rewards"].to(self.device)
dones = traj["dones"].to(self.device)
old_log_probs = traj["old_log_probs"].to(self.device)
old_values = traj["old_values"].to(self.device)
T = len(obs)
# Step 1: GAE
with torch.no_grad():
advantages, returns = compute_gae(rewards, old_values, dones, self.gamma, self.lam)
advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
# Step 2: PPO epochs
for epoch in range(self.num_epochs):
indices = torch.randperm(T)
for start in range(0, T, self.batch_size):
idx = indices[start:start + self.batch_size]
_, new_log_probs, entropy, new_values = self.ac.get_action_and_value(
obs[idx], actions[idx]
)
# PPO-Clip
ratio = torch.exp(new_log_probs - old_log_probs[idx])
surr1 = ratio * advantages[idx]
surr2 = torch.clamp(ratio, 1 - self.clip_eps, 1 + self.clip_eps) * advantages[idx]
policy_loss = -torch.min(surr1, surr2).mean()
value_loss = F.mse_loss(new_values, returns[idx])
loss = policy_loss + self.value_coef * value_loss - self.entropy_coef * entropy.mean()
self.optimizer.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(self.ac.parameters(), 0.5)
self.optimizer.step()
# KL early stopping
with torch.no_grad():
kl = (old_log_probs[idx] - new_log_probs).mean().abs()
if kl > self.max_kl:
print(f"KL={kl:.4f} > {self.max_kl}, early stopping")
return
#手撕 PPO 的面试官评分 checklist
| 评分项 | 权重 | 考察内容 |
|---|---|---|
| Actor-Critic 结构 | 20% | policy 和 value 输出头是否分开 |
| GAE 计算 | 20% | 反向递推、done 截断是否正确 |
| PPO-Clip 目标 | 25% | ratio、clip、min 是否正确 |
| Loss 组合 | 15% | policy + value + entropy 符号和系数 |
| KL 监控 | 10% | 是否有 early stopping 意识 |
| 工程细节 | 10% | 梯度裁剪、advantage 归一化 |
#参考实现 3:极简 Attention(PyTorch API 对照)
import torch, math
def attention(q, k, v, mask=None):
scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(q.size(-1))
if mask is not None:
scores = scores.masked_fill(mask == 0, float('-inf'))
attn = torch.softmax(scores, dim=-1)
return torch.matmul(attn, v), attn
追问点:causal mask 怎么加?为什么除以 sqrt(d_k)?多头在哪里拆?如果某一行的 key 全部被屏蔽,softmax 会输出什么?这里用 -inf 填充会得到 NaN;参考实现 1 用 -1e9 覆盖整行则会变成均匀分布,悄悄读到 padding。面试主线的 NumPy 版本因此对全屏蔽行单独返回全 0。