手撕代码题(模型与 Agent 方向)¶
本章导读:大模型岗的手撕分两类:一类是通用算法题,另一类是“本岗位特色题”——手写 MHA、RoPE、LoRA、KV Cache、采样、MoE、ReAct。后者近两年权重明显上升,面试官看的不只是 AC,还有维度、mask、数值稳定性和边界。
使用建议:每道题先自己在纸上写,再对照代码;重点记张量维度和易错点,不要背整段实现。
核心要点¶
- 模型题先写清张量维度,再写计算;维度错了,后面全错。
- attention 的三个易错点:缩放、mask、多头 reshape/transpose。
- RoPE 要能解释“成对旋转”和相对位置性质,代码只需写对 cos/sin 与 rotate_half。
- LoRA 要会算参数量、初始化、merge;注意 scaling 是 \(\alpha/r\)。
- KV Cache 只缓存历史 K/V,当前 token 的 Q 不缓存。
- 采样要区分 top-k(固定数量)与 top-p(累计概率),并处理全 mask/数值稳定。
- MoE 要写对 top-k 路由、权重归一化、负载统计;真实实现用 grouped GEMM。
- ReAct 要写清循环、终止条件、工具异常、最大步数。
- 手撕时先讲思路与复杂度,再写代码,最后跑一个样例。
1. 多头自注意力(MHA)¶
要求:实现 MultiHeadAttention(d_model, n_heads),支持 causal mask,返回 [B, S, d_model]。
关键点:
d_head = d_model // n_heads;- QKV 投影后拆头:
[B, S, H, D] -> [B, H, S, D]; - 缩放因子 \(1/\sqrt{d_{head}}\);
- mask 用
-inf或 dtype 最小值,避免全 mask 行 NaN; - 输出拼接后过
W_O。
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
class MHA(nn.Module):
def __init__(self, d_model: int, n_heads: int, dropout: float = 0.0):
super().__init__()
assert d_model % n_heads == 0
self.h = n_heads
self.dh = d_model // n_heads
self.qkv = nn.Linear(d_model, 3 * d_model, bias=False)
self.o = nn.Linear(d_model, d_model, bias=False)
self.drop = nn.Dropout(dropout)
def forward(self, x, causal=True):
B, S, D = x.shape
q, k, v = self.qkv(x).chunk(3, dim=-1)
# [B, S, H, Dh] -> [B, H, S, Dh]
q = q.view(B, S, self.h, self.dh).transpose(1, 2)
k = k.view(B, S, self.h, self.dh).transpose(1, 2)
v = v.view(B, S, self.h, self.dh).transpose(1, 2)
scores = q @ k.transpose(-2, -1) / math.sqrt(self.dh) # [B,H,S,S]
if causal:
mask = torch.triu(torch.ones(S, S, device=x.device, dtype=torch.bool), 1)
scores = scores.masked_fill(mask, torch.finfo(scores.dtype).min)
attn = self.drop(F.softmax(scores, dim=-1))
out = attn @ v # [B,H,S,Dh]
out = out.transpose(1, 2).contiguous().view(B, S, D)
return self.o(out)
追问:
- 为什么除以 \(\sqrt{d_h}\)?→ 控制点积方差。
- 为什么用
transpose后要contiguous?→view需要连续内存。 - 全 mask 行怎么处理?→ 保证至少一个可见位置,或 softmax 后置零。
- 参数量?→ \(4d^2\)。
2. 带 RoPE 的注意力¶
要求:在 MHA 基础上对 Q/K 应用 RoPE。
关键点:
- 频率 \(\theta_i=\text{base}^{-2i/d}\);
- 位置 \(m\) 的旋转角 \(m\theta_i\);
rotate_half实现前后半交换;- 只作用于 Q/K,不作用于 V。
def rope_cache(S, dim, base=10000.0, device="cpu"):
inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2, device=device).float() / dim))
t = torch.arange(S, device=device).float()
freqs = torch.outer(t, inv_freq) # [S, dim/2]
return torch.cos(freqs), torch.sin(freqs)
def rotate_half(x):
x1, x2 = x.chunk(2, dim=-1)
return torch.cat([-x2, x1], dim=-1)
def apply_rope(x, cos, sin):
# x: [B,H,S,Dh]; cos/sin: [S, Dh/2]
cos = torch.cat([cos, cos], dim=-1)[None, None, :, :]
sin = torch.cat([sin, sin], dim=-1)[None, None, :, :]
return x * cos + rotate_half(x) * sin
追问:
- 为什么内积只依赖相对位置?→ \(R(m)^\top R(n)=R(n-m)\)。
- base 变大有什么影响?→ 波长变长,外推更好,但局部分辨率下降。
- PI/NTK/YaRN 的区别?→ 位置压缩 / 基频缩放 / 分段+温度修正。
3. RMSNorm¶
要求:实现 RMSNorm,并说明与 LayerNorm 的区别。
class RMSNorm(nn.Module):
def __init__(self, d: int, eps: float = 1e-6):
super().__init__()
self.weight = nn.Parameter(torch.ones(d))
self.eps = eps
def forward(self, x):
# 在最后一维上求均方根
rms = x.pow(2).mean(dim=-1, keepdim=True).add(self.eps).rsqrt()
return self.weight * x * rms
追问:
- 为什么去掉均值?→ 特征均值接近 0,缩放是主要作用;省一次归约。
- 为什么用
rsqrt?→ 数值稳定且快。 - 与 LayerNorm 的参数量?→ RMSNorm 少一个 bias。
4. SwiGLU FFN¶
要求:实现 SwiGLU,并说明 \(d_{ff}=8d/3\) 的来历。
class SwiGLU(nn.Module):
def __init__(self, d, d_ff):
super().__init__()
self.w1 = nn.Linear(d, d_ff, bias=False) # gate
self.w3 = nn.Linear(d, d_ff, bias=False) # value
self.w2 = nn.Linear(d_ff, d, bias=False)
def forward(self, x):
return self.w2(F.silu(self.w1(x)) * self.w3(x))
参数量:\(3d\cdot d_{ff}\)。令其等于 \(8d^2\),得 \(d_{ff}=8d/3\)。
追问:
- 为什么门控有效?→ 动态选择信息,增加表达力。
- 为什么实际取 128 的倍数?→ 硬件对齐。
5. LoRA 层¶
要求:实现 LoRA 线性层,支持初始化、前向、merge。
class LoRALinear(nn.Module):
def __init__(self, base: nn.Linear, r=8, alpha=16, dropout=0.0):
super().__init__()
self.base = base
for p in self.base.parameters():
p.requires_grad = False
self.r = r
self.scaling = alpha / r
self.A = nn.Parameter(torch.randn(r, base.in_features) * 0.01)
self.B = nn.Parameter(torch.zeros(base.out_features, r))
self.drop = nn.Dropout(dropout)
self.merged = False
def forward(self, x):
# B 初始化为 0,保证训练开始时等价于原模型
if self.merged:
return self.base(x)
return self.base(x) + self.drop(x) @ self.A.T @ self.B.T * self.scaling
@torch.no_grad()
def merge(self):
# W' = W + scaling * B @ A;重复调用会重复累加,需先判重
if self.merged:
return
self.base.weight.add_(self.B @ self.A * self.scaling)
self.merged = True
参数量:\(r(d_{\text{in}}+d_{\text{out}})\)。
追问:
- 为什么 B 初始化为 0?→ 初始时 \(\Delta W=0\),不破坏基座。
- scaling 为什么是 \(\alpha/r\)?→ 解耦秩与学习率尺度。
- merge 后还能继续训练吗?→ 可以,但需要重新挂 LoRA 或解冻。
6. KV Cache 单步 decode¶
要求:实现带 KV Cache 的单步注意力:输入当前 token,缓存历史 K/V。
class KVCacheAttention(nn.Module):
def __init__(self, d_model, n_heads):
super().__init__()
self.h = n_heads
self.dh = d_model // n_heads
self.qkv = nn.Linear(d_model, 3 * d_model, bias=False)
self.o = nn.Linear(d_model, d_model, bias=False)
def forward(self, x, cache=None):
# x: [B, 1, D](decode 单步)
B, S, D = x.shape
assert S == 1, "KV Cache 版本只演示单步 decode;prefill 请另写带 causal mask 的实现"
q, k, v = self.qkv(x).chunk(3, dim=-1)
q = q.view(B, S, self.h, self.dh).transpose(1, 2)
k = k.view(B, S, self.h, self.dh).transpose(1, 2)
v = v.view(B, S, self.h, self.dh).transpose(1, 2)
if cache is not None:
k_old, v_old = cache
k = torch.cat([k_old, k], dim=2) # 拼接历史 K
v = torch.cat([v_old, v], dim=2)
new_cache = (k, v)
scores = q @ k.transpose(-2, -1) / math.sqrt(self.dh)
# 单步 decode 不需要 causal mask:当前 token 可见全部历史
attn = F.softmax(scores, dim=-1)
out = (attn @ v).transpose(1, 2).contiguous().view(B, S, D)
return self.o(out), new_cache
追问:
- 为什么只缓存 K/V,不缓存 Q?→ Q 只用于当前步,历史 Q 不再需要。
- KV Cache 显存公式?→ \(2L n_{kv}d_h S B\) bytes。
- 为什么 decode 是 memory-bound?→ 每步矩阵-向量,算术强度低。
7. Top-k / Top-p / Temperature 采样¶
要求:实现一个采样函数,支持 temperature、top-k、top-p。
def sample(logits, temperature=1.0, top_k=None, top_p=None):
# logits: [V]
if temperature <= 0:
return torch.argmax(logits).item()
logits = logits / temperature
probs = torch.softmax(logits, dim=-1)
if top_k is not None and top_k > 0:
k = min(top_k, probs.numel())
kth = torch.topk(probs, k).values[-1]
probs = torch.where(probs < kth, torch.zeros_like(probs), probs)
if top_p is not None and 0 < top_p < 1:
sorted_probs, sorted_idx = torch.sort(probs, descending=True)
cum = torch.cumsum(sorted_probs, dim=-1)
# 保留累计概率达到 top_p 的最小集合
mask = cum - sorted_probs >= top_p
sorted_probs[mask] = 0.0
probs = torch.zeros_like(probs).scatter(0, sorted_idx, sorted_probs)
if probs.sum() <= 0:
return torch.argmax(logits).item()
probs = probs / probs.sum()
return torch.multinomial(probs, 1).item()
追问:
- top-k 与 top-p 的区别?→ 固定数量 vs 自适应累计概率。
- temperature 的作用?→ 缩放 logits,改变分布尖锐度。
- 数值稳定?→ 先减 max 再 softmax;全零时回退 argmax。
8. MoE 路由¶
要求:实现 top-k 门控,返回聚合输出与负载统计。
class TopKRouter(nn.Module):
def __init__(self, d, n_experts, top_k=2):
super().__init__()
self.gate = nn.Linear(d, n_experts, bias=False)
self.top_k = top_k
def forward(self, x, experts):
# x: [N, D]
logits = self.gate(x)
probs = torch.softmax(logits, dim=-1)
top_p, top_i = probs.topk(self.top_k, dim=-1) # [N,k]
# 负载统计:每个专家被选中的 token 数
load = torch.bincount(top_i.flatten(), minlength=len(experts)).float()
# 简化:逐 token 调专家;生产用 scatter/gather + grouped GEMM
out = torch.zeros_like(x)
for i in range(x.size(0)):
for j in range(self.top_k):
out[i] += top_p[i, j] * experts[top_i[i, j]](x[i])
return out, load
追问:
- 为什么需要负载均衡?→ 防止赢家通吃、专家欠训练。
- 辅助损失公式?→ \(\alpha E\sum f_iP_i\)。
- 容量因子?→ 限制每专家 token 数,超出丢弃或走残差。
9. 最小 ReAct Agent¶
要求:实现一个 ReAct 循环:工具注册、Thought/Action/Observation、最大步数、异常处理。
import json
class Agent:
def __init__(self, llm, tools, max_steps=8):
self.llm = llm
self.tools = tools # {name: callable}
self.max_steps = max_steps
def run(self, question):
history = [f"Question: {question}"]
for step in range(self.max_steps):
prompt = "\n".join(history) + "\nThought:"
text = self.llm(prompt)
if "Final Answer:" in text:
return text.split("Final Answer:", 1)[1].strip()
if "Action:" not in text:
history.append(text)
continue
try:
action = text.split("Action:", 1)[1].strip()
name, arg = action.split("(", 1)
name = name.strip()
arg = arg.rsplit(")", 1)[0].strip().strip('"').strip("'")
if name not in self.tools:
raise ValueError(f"unknown tool: {name}")
obs = self.tools[name](arg)
history.append(f"{text}\nObservation: {obs}")
except Exception as e:
# 工具错误要回填给模型,而不是直接崩溃
history.append(f"{text}\nObservation: ERROR: {e}")
return "达到最大步数,未能完成任务。"
追问:
- 怎么终止?→ Final Answer、最大步数、超时、重复动作检测。
- 工具报错怎么办?→ 回填错误、重试、换工具、请求人工。
- 怎么防死循环?→ 预算、动作指纹、状态哈希。
- 安全?→ 工具白名单、参数校验、沙箱、审批。
10. RRF 融合¶
要求:实现多路召回结果的 Reciprocal Rank Fusion。
def rrf(rankings, k=60):
# rankings: list[list[doc_id]],每路按相关性降序
score = {}
for docs in rankings:
for rank, doc in enumerate(docs, start=1):
score[doc] = score.get(doc, 0.0) + 1.0 / (k + rank)
return sorted(score.items(), key=lambda x: -x[1])
追问:
- 为什么用 RRF?→ 不需要分数归一化,对异构检索器鲁棒。
- k 的作用?→ 平滑头部优势;常用 60。
- 与加权分数融合对比?→ RRF 更稳,加权需要校准。
11. BM25(简化)¶
import math
from collections import Counter
def bm25(query, docs, k1=1.5, b=0.75):
N = len(docs)
avgdl = sum(len(d) for d in docs) / max(N, 1)
df = Counter()
for d in docs:
for w in set(d):
df[w] += 1
scores = []
q = query.split()
for d in docs:
tf = Counter(d)
s = 0.0
for w in q:
if w not in tf:
continue
idf = math.log(1 + (N - df[w] + 0.5) / (df[w] + 0.5))
denom = tf[w] + k1 * (1 - b + b * len(d) / avgdl)
s += idf * tf[w] * (k1 + 1) / denom
scores.append(s)
return scores
追问:
- BM25 的 idf/tf 饱和分别解决什么?→ 稀有词权重、词频边际递减。
- 为什么 RAG 里还要 BM25?→ 精确匹配、编号、人名。
- 与向量检索怎么融合?→ RRF 或加权。
12. 通用算法题清单¶
| 题目 | 关键点 | 复杂度 |
|---|---|---|
| 反转链表 | 三指针/递归 | O(n) |
| 链表相加 | dummy 头、进位 | O(n) |
| 排序链表 | 归并 | O(n log n) |
| 二叉树层序 | 队列 | O(n) |
| 路径总和 III | 前缀和 + 哈希 | O(n) |
| 接雨水 | 双指针/单调栈 | O(n) |
| 无重复最长子串 | 滑动窗口 | O(n) |
| 最大正方形 | DP | O(mn) |
| Top-K | 堆/quickselect | O(n log k)/O(n) |
| 字符串解码 | 栈 | O(n) |
| 两正序数组中位数 | 二分 | O(log min(m,n)) |
| 最长递增子序列 | DP/二分 | O(n log n) |
| 只出现一次的数字 | 异或 | O(n) |
13. 手撕答题模板¶
- 澄清:输入输出、维度、是否 batch、是否 causal、精度要求。
- 思路:先讲公式/流程,再讲复杂度。
- 边界:空输入、单 token、全 mask、长度不整除头数。
- 实现:变量命名清晰,维度注释。
- 测试:给一个 2×3 的小例子,口算验证。
- 追问:参数量、显存、数值稳定、怎么并行/加速。
本文档代码用于面试手写与理解,生产实现请使用框架融合算子与成熟库。