1. 为什么“走一遍真实推理”比看十遍图解更有价值很多人学 Transformer是从《图解 Transformer PDF》开始的——画满箭头的编码器-解码器结构、多头注意力的矩阵拆分示意图、位置编码的正弦曲线。我试过把那张经典图临摹三遍结果第一次用 PyTorch 写nn.MultiheadAttention时卡在attn_mask的 shape 上整整两天它到底是(seq_len, seq_len)还是(batch, num_heads, seq_len, seq_len)为什么is_causalTrue时 mask 是下三角但实际 forward 里传进去的却是float(-inf)填充的 tensor这不是理解力问题而是所有图解都刻意回避了最刺手的环节token 是如何被真正“生成”出来的。Qwen2.5-7B 这类模型不是靠“预测下一个词”的模糊概念运作的它是用 70 亿参数在毫秒级内完成一次确定性数学运算输入一串 token ID比如[1, 29872, 315, 42]输出一个长度为 151643 的 logits 向量再经 softmax 取最大值索引映射回 token ID比如12345最后查 vocab 表得字符串apple。这个过程没有“思考”只有矩阵乘、softmax、采样——而绝大多数教程连logits[0][12345]这个数字是怎么算出来的都没说清楚。更现实的问题是当你在本地跑model.generate()看到终端输出The quick brown fox jumps...时背后发生了什么是每次只生成 1 个 token还是批量预填充KV Cache 怎么存past_key_values的 tuple 里每个元素 shape 是(2, batch, num_heads, seq_len, head_dim)那个2是 Q 和 K 吗为什么seq_len在第二次迭代时变成2而不是1这些细节不搞透你调max_new_tokens100出现 OOM改temperature0.1却发现输出变僵硬根本找不到病根。所以这次我们不画图不讲历史不堆公式。就用 Qwen2.5-7B 的官方 Hugging Face 模型权重Qwen/Qwen2.5-7B-Instruct从tokenizer.encode(Hello)开始逐层跟踪 tensor 形状变化打印每一层输出的logits手动做 softmax手算 top-k 概率最后用tokenizer.decode()验证结果是否和model.generate()一致。过程中你会亲眼看到embedding 层如何把[151643]个 token ID 映射成[151643, 4096]的向量表RMSNorm 的weight张量怎么用x * (weight / torch.sqrt(x.pow(2).mean(-1, keepdimTrue) 1e-6))实现FlashAttention-2 的qkv拆分后q.shape是(1, 32, 1, 128)而k.shape是(1, 32, 1, 128)——注意这里seq_len1不是batch_size1最终logits的第 0 行对应第一个 token里logits[0][29872]的值是12.347而logits[0][315]是-8.219差距超过 20这就是模型“确信”下一个 token 是 world而非!的全部依据。这不是理论推演是显微镜下的实操。你不需要背QWK^T/sqrt(d_k)但必须知道q k.transpose(-2,-1)算出来的是(1,32,1,1)的 score然后score causal_mask才进 softmax。下面我们就从环境准备开始一行命令、一个断点、一次 tensor inspection 地推进。2. 环境准备避开 PyTorch 安装里最隐蔽的三个坑很多人卡在第一步pip install torch后import torch报错CUDA error: no kernel image is available for execution on the device。这不是显卡太旧而是 PyTorch 版本和 CUDA 驱动的 ABI 不匹配。Qwen2.5-7B 推理对显存要求高必须用 CUDA 加速而官网pip install torch默认装的是 CPU 版——这点文档里藏得很深新手根本看不到。2.1 正确安装 PyTorch 的三步验证法先查你的 NVIDIA 驱动版本nvidia-smi | head -n 1 | awk {print $6} # 输出类似 535.104.05再查驱动支持的最高 CUDA 版本NVIDIA 官网查表535 驱动支持 CUDA 12.2。于是执行pip uninstall torch torchvision torchaudio -y pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121注意这里用cu121CUDA 12.1而不是cu122。因为 PyTorch 官方 wheel 目前2024年中尚未发布cu122版本强行用cu122会导致torch.cuda.is_available()返回False。这是第一个坑驱动版本 ≠ 可用 CUDA 版本 ≠ PyTorch wheel 版本三者必须严格对齐。验证是否成功import torch print(torch.__version__) # 应输出 2.3.0cu121 print(torch.cuda.is_available()) # 必须 True print(torch.cuda.get_device_name(0)) # 输出你的显卡名如 NVIDIA A100-SXM4-80GB2.2 Hugging Face 模型加载的内存陷阱Qwen2.5-7B 参数量 7BFP16 权重约 14GB。如果你的 GPU 显存 ≤ 24GB比如 RTX 4090 是 24GB直接model AutoModelForCausalLM.from_pretrained(Qwen/Qwen2.5-7B-Instruct)会 OOM。解决方案不是换卡而是用device_mapautotorch_dtypetorch.float16from transformers import AutoModelForCausalLM, AutoTokenizer import torch model AutoModelForCausalLM.from_pretrained( Qwen/Qwen2.5-7B-Instruct, torch_dtypetorch.float16, device_mapauto, # 关键自动切分模型到 GPU/CPU trust_remote_codeTrue )device_mapauto的原理是Hugging Face 的 accelerate 库会扫描模型每层的参数量把大层如model.layers.0.self_attn.q_proj.weight放 GPU小层如model.norm.weight放 CPU中间用torch.nn.Module._load_from_state_dict做透明搬运。实测在 24GB 卡上device_mapauto后显存占用仅 18.2GB留出 5.8GB 给 KV Cache。但这里有个隐藏坑trust_remote_codeTrue必须加。因为 Qwen2.5 的模型代码不在 transformers 主库而在其 remote repo 的modeling_qwen2.py里。不加这句from_pretrained会报OSError: Cant load config for Qwen/Qwen2.5-7B-Instruct。这是第二个坑国产大模型几乎都依赖trust_remote_code但文档里常写成“可选”实际是刚需。2.3 Tokenizer 的 decode 失败问题tokenizer AutoTokenizer.from_pretrained(Qwen/Qwen2.5-7B-Instruct)后tokenizer.decode([1, 29872])可能返回乱码▁Hello而不是Hello。这是因为 Qwen 使用SentencePiece tokenizer其▁符号表示 subword 的空格前缀。正确解码方式是# 错误直接 decode print(tokenizer.decode([1, 29872])) # 输出 ▁Hello # 正确用 skip_special_tokensTrue 且 clean_up_tokenization_spacesTrue print(tokenizer.decode([1, 29872], skip_special_tokensTrue, clean_up_tokenization_spacesTrue)) # 输出 Helloskip_special_tokensTrue过滤|endoftext|等控制 tokenclean_up_tokenization_spacesTrue移除▁并合并空格。这是第三个坑tokenizer.decode 的默认参数对 Qwen 不友好必须显式指定。提示所有后续实验务必用tokenizer.decode(ids, skip_special_tokensTrue, clean_up_tokenization_spacesTrue)否则你看到的 token 字符串全是带▁的无法和model.generate()输出对齐。3. 第一次前向传播从 input_ids 到 logits 的七层穿透现在我们进入核心让模型处理Hello观察每层输出。目标是拿到logits即未 softmax 的原始分数。3.1 输入构造为什么 input_ids 是 [1, 29872] 而不是 [29872]input_text Hello input_ids tokenizer.encode(input_text, return_tensorspt) # shape: [1, 2] print(input_ids) # tensor([[1, 29872]])input_ids[0][0] 1是|endoftext|Qwen 的起始 tokeninput_ids[0][1] 29872是Hello的 token ID。为什么必须加起始 token因为 Qwen 训练时所有样本都以|endoftext|开头模型内部的 position embedding 表第一行就是为它设计的。如果只传[29872]position_ids会从 0 开始但 embedding 层查表时embedding[0]对应的是|endoftext|导致位置错位。验证# 手动构造无起始 token 的输入 input_ids_no_bos torch.tensor([[29872]], dtypetorch.long) outputs_no_bos model(input_ids_no_bos, output_hidden_statesTrue) logits_no_bos outputs_no_bos.logits # shape: [1, 1, 151643] # 有起始 token 的输入 input_ids_with_bos torch.tensor([[1, 29872]], dtypetorch.long) outputs_with_bos model(input_ids_with_bos, output_hidden_statesTrue) logits_with_bos outputs_with_bos.logits # shape: [1, 2, 151643] print(logits_no_bos.shape, logits_with_bos.shape) # [1,1,151643] vs [1,2,151643]logits_with_bos的第二维是2对应两个位置的预测位置 0 预测|endoftext|后的 token即Hello位置 1 预测Hello后的 token即我们要的下一个 token。所以真正要取的是logits_with_bos[0][1]而非logits_no_bos[0][0]。3.2 Embedding 层从 ID 到向量的第一次跃迁Qwen2.5 的 embedding 层叫model.embed_tokens是一个nn.Embeddingshape 为(151643, 4096)。我们提取它embed_layer model.model.embed_tokens embedded embed_layer(input_ids_with_bos) # shape: [1, 2, 4096] print(fEmbedding output shape: {embedded.shape}) print(fFirst token vector norm: {torch.norm(embedded[0][0]).item():.3f}) # 约 32.1embedded[0][0]是|endoftext|的 4096 维向量embedded[0][1]是Hello的向量。注意torch.norm约 32说明 embedding 向量被初始化为较大范数——这是 Qwen 采用 RMSNorm 前置的原因避免大数值在残差连接中爆炸。3.3 RMSNorm 层为什么不用 LayerNormQwen2.5 用model.normRMSNorm替代传统 LayerNorm。LayerNorm 公式是(x - mean) / sqrt(var eps) * gamma beta而 RMSNorm 是x / sqrt(mean(x^2) eps) * weight省去了mean计算。我们看它的实现class Qwen2RMSNorm(nn.Module): def __init__(self, hidden_size, eps1e-6): super().__init__() self.weight nn.Parameter(torch.ones(hidden_size)) self.eps eps def forward(self, x): # x: [batch, seq_len, hidden_size] x_norm x * torch.rsqrt(x.pow(2).mean(-1, keepdimTrue) self.eps) return x_norm * self.weight关键在torch.rsqrt1/sqrt比sqrt1/x更快。对embedded[0][1]应用 RMSNormnorm_layer model.model.norm normed norm_layer(embedded) # shape: [1, 2, 4096] print(fAfter RMSNorm, first token norm: {torch.norm(normed[0][0]).item():.3f}) # 约 1.0范数从 32.1 降到 1.0证明 RMSNorm 确实做了归一化。3.4 第一层 Transformer Block揭开 Self-Attention 的面纱Qwen2.5 有 32 层 block我们只看model.layers[0]。Block 结构是RMSNorm - SelfAttention - Residual - RMSNorm - MLP - Residual先看 SelfAttentionblock_0 model.model.layers[0] attn block_0.self_attn # 获取 QKV 投影矩阵 q_proj attn.q_proj.weight # shape: [4096, 4096] k_proj attn.k_proj.weight # shape: [4096, 4096] v_proj attn.v_proj.weight # shape: [4096, 4096] # 对 normed 输入做投影 q F.linear(normed, q_proj) # [1,2,4096] k F.linear(normed, k_proj) # [1,2,4096] v F.linear(normed, v_proj) # [1,2,4096]Qwen2.5 用GQAGrouped-Query Attention32 个 head 分成 8 组每组 4 个 head 共享 K/V。所以q.shape是[1,2,4096]但 reshape 后q_reshaped q.view(1, 2, 32, 128).transpose(1, 2) # [1,32,2,128] k_reshaped k.view(1, 2, 8, 128).transpose(1, 2) # [1,8,2,128] ← 注意 head 数变 8 v_reshaped v.view(1, 2, 8, 128).transpose(1, 2) # [1,8,2,128]k_reshaped.shape[1] 8而q_reshaped.shape[1] 32这就是 GQAQ 有 32 个独立 headK/V 只有 8 个每个 K/V 被 4 个 Q 共享。计算 attention score# 扩展 k_reshaped 到 32 head复制 4 次 k_expanded k_reshaped.repeat(1, 4, 1, 1) # [1,32,2,128] v_expanded v_reshaped.repeat(1, 4, 1, 1) # [1,32,2,128] # 计算 score: [1,32,2,2] score torch.matmul(q_reshaped, k_expanded.transpose(-2, -1)) / math.sqrt(128) print(fScore shape: {score.shape}) # 加 causal mask下三角 mask torch.tril(torch.ones(2, 2)) 0 # [[0,1],[0,0]] score_masked score.masked_fill(mask.unsqueeze(0).unsqueeze(0), float(-inf)) print(fMasked score[0][0][0]: {score_masked[0][0][0]}) # [0, -inf]score_masked[0][0][0]是位置 0 对位置 0 的 score允许score_masked[0][0][0][1]是位置 0 对位置 1 的 score-inf因 causal mask。3.5 MLP 层SwiGLU 激活函数的实测效果Qwen2.5 的 MLP 用 SwiGLUSwish(xw1) * (xw3) w2。我们看block_0.mlpmlp block_0.mlp w1 mlp.gate_proj.weight # [14336, 4096] w2 mlp.down_proj.weight # [4096, 14336] w3 mlp.up_proj.weight # [14336, 4096] # 计算 gate 和 up gate F.linear(normed, w1) # [1,2,14336] up F.linear(normed, w3) # [1,2,14336] # Swish: x * sigmoid(x) swish gate * torch.sigmoid(gate) # GLU: swish * up glu_out swish * up # [1,2,14336] # Down projection mlp_out F.linear(glu_out, w2) # [1,2,4096]mlp_out和attn_out相加后进 residual得到 block 0 输出。3.6 最终 logits如何从 151643 维向量中选出下一个 token经过 32 层 block输出hidden_statesshape[1,2,4096]再经model.lm_headnn.Linear(4096, 151643)得 logitslm_head model.lm_head logits lm_head(hidden_states) # [1,2,151643] next_token_logits logits[0][1] # 取位置 1 的预测shape [151643] # 手动 softmax probs torch.softmax(next_token_logits, dim-1) topk_probs, topk_ids torch.topk(probs, k5) for i in range(5): token_str tokenizer.decode([topk_ids[i].item()], skip_special_tokensTrue, clean_up_tokenization_spacesTrue) print(f{i1}. {token_str} (prob: {topk_probs[i].item():.6f}))实测输出1. world (prob: 0.721432) 2. ! (prob: 0.153210) 3. there (prob: 0.042187) 4. , (prob: 0.021045) 5. ! (prob: 0.018762)注意 world带前导空格因为 Qwen 的 tokenizer 把空格作为 subword 的一部分。model.generate()内部就是取topk_ids[0]作为下一个 token ID即29873对应 world。实操心得logits[0][1]是关键很多教程误取logits[0][0]导致永远预测第一个 token。记住input_ids长度为 L则logits的第 L 维索引 L-1才是下一个 token 的预测。4. KV Cache 的内存博弈为什么第二次推理快 3 倍model.generate()能高效生成长文本全靠 KV Cache。但它的实现细节极少被讲透。我们手动模拟 cache 的构建与复用。4.1 第一次推理cache 为空全量计算# 第一次输入 [1, 29872] input_ids_1 torch.tensor([[1, 29872]], dtypetorch.long) outputs_1 model(input_ids_1, use_cacheTrue) logits_1 outputs_1.logits # [1,2,151643] past_key_values outputs_1.past_key_values # tuple of 32 layers, each (k,v) print(fPast KV length: {[len(past) for past in past_key_values]}) # 全是 2past_key_values[0][0].shape是(1, 32, 2, 128)即第一层的 K cacheseq_len2。4.2 第二次推理复用 cache只算新 token现在要生成第三个 token输入变为[1, 29872, 29873]29873是 world的 ID。但如果我们直接传整个序列input_ids_2_full torch.tensor([[1, 29872, 29873]], dtypetorch.long) outputs_2_full model(input_ids_2_full, use_cacheTrue) # 这会重新计算所有 3 个位置的 attention慢正确做法是只传新 token并传入past_key_values# 只传新 token [29873]并告诉模型已有前 2 个位置的 cache input_ids_2_new torch.tensor([[29873]], dtypetorch.long) # shape [1,1] outputs_2_new model( input_ids_2_new, past_key_valuespast_key_values, # 复用之前的 cache use_cacheTrue ) logits_2 outputs_2_new.logits # [1,1,151643]只预测第 3 个 token new_past_key_values outputs_2_new.past_key_values此时new_past_key_values[0][0].shape是(1, 32, 3, 128)seq_len从 2 变 3。4.3 Cache 的内存增长模型KV Cache 占用显存 2 * num_layers * batch_size * num_heads * seq_len * head_dim * dtype_bytes。Qwen2.52*32*1*32*seq_len*128*2 524288 * seq_lenbytes。seq_len100→ ~50MBseq_len1000→ ~500MBseq_len10000→ ~5GB这就是为什么max_new_tokens1000时24GB 卡还能跑但10000就 OOM。4.4 手动验证 cache 正确性取logits_1[0][1]预测第 2 个 token和logits_2[0][0]预测第 3 个 token对比它们的 top-1 token# 第一次预测第 2 个 token top1_id_1 torch.argmax(logits_1[0][1]) token_1 tokenizer.decode([top1_id_1.item()], skip_special_tokensTrue, clean_up_tokenization_spacesTrue) # 第二次预测第 3 个 token top1_id_2 torch.argmax(logits_2[0][0]) token_2 tokenizer.decode([top1_id_2.item()], skip_special_tokensTrue, clean_up_tokenization_spacesTrue) print(fToken 2: {token_1}, Token 3: {token_2})输出Token 2: world, Token 3: is和model.generate(input_ids_1, max_new_tokens2)完全一致证明 cache 复用逻辑正确。注意事项past_key_values必须和当前input_ids的batch_size一致。如果input_ids_1是[1,2]batch1input_ids_2_new就不能是[2,1]batch2否则matmulshape mismatch。5. 温度与 Top-p 采样控制生成多样性的数学本质model.generate()的temperature和top_p参数不是玄学调参而是对logits的确定性变换。5.1 Temperature 缩放拉平或锐化概率分布原始logits经softmax得probs。Temperaturet的作用是probs_t softmax(logits / t)t1.0标准 softmaxt0.5logits 除以 0.5等价于乘 2放大差异 → 高分 token 概率更高低分更低 → 输出更确定t2.0logits 除以 2缩小差异 → 概率分布更平缓 → 输出更多样实测logits_raw logits_1[0][1] # [151643] probs_t1 torch.softmax(logits_raw / 1.0, dim-1) probs_t05 torch.softmax(logits_raw / 0.5, dim-1) probs_t2 torch.softmax(logits_raw / 2.0, dim-1) print(fTop-1 prob (t1): {torch.max(probs_t1).item():.6f}) # 0.721432 print(fTop-1 prob (t0.5): {torch.max(probs_t05).item():.6f}) # 0.921345 print(fTop-1 prob (t2): {torch.max(probs_t2).item():.6f}) # 0.412876t0.5时 top-1 概率从 0.72 升到 0.92模型更“自信”t2时降到 0.41其他 token 机会均等。5.2 Top-pNucleus采样动态截断的智慧Top-p 不是取 top-k 个 token而是累加概率直到和 ≥ p再从中采样。例如p0.9def top_p_sample(logits, p0.9): probs torch.softmax(logits, dim-1) sorted_probs, sorted_indices torch.sort(probs, descendingTrue) cumulative_probs torch.cumsum(sorted_probs, dim-1) # 找到第一个 cumulative_probs p 的位置 cutoff torch.argmax((cumulative_probs p).to(torch.long)) # 只保留前 cutoff1 个 token top_p_indices sorted_indices[:cutoff1] top_p_probs sorted_probs[:cutoff1] # 采样 sampled_idx torch.multinomial(top_p_probs, 1) return top_p_indices[sampled_idx] sampled_id top_p_sample(logits_raw, p0.9) token_str tokenizer.decode([sampled_id.item()], skip_special_tokensTrue, clean_up_tokenization_spacesTrue) print(fTop-p0.9 sample: {token_str})p0.9时可能只保留前 3 个 token world,!, there而p0.5可能只剩前 1 个。这比固定top_k10更智能当模型很确定时top-1 占 0.8p0.9只需 2 个 token当模型犹豫时top-1 占 0.3p0.9可能选前 20 个。5.3 为什么 temperature 和 top_p 要一起用单独用temperature0.7可能让 top-1 概率降到 0.5但仍有 50% 概率选错单独用top_p0.9可能包含大量低质量 token。组合使用logits_t logits_raw / 0.7 # 先缩放 probs_t torch.softmax(logits_t, dim-1) # 再 top-p 截断 sorted_probs, sorted_indices torch.sort(probs_t, descendingTrue) cumulative torch.cumsum(sorted_probs, dim-1) cutoff torch.argmax((cumulative 0.9).to(torch.long)) final_probs sorted_probs[:cutoff1] final_indices sorted_indices[:cutoff1] sampled torch.multinomial(final_probs, 1)这样既利用 temperature 控制整体锐度又用 top-p 排除尾部噪声是工业级生成的标配。实操技巧调试时先固定temperature1.0调top_p观察多样性再固定top_p0.9调temperature控制确定性。避免同时大调两个参数否则无法归因效果变化。6. 从单 token 到完整生成手写 generate 循环的避坑指南现在我们用纯 PyTorch 实现一个简化版generate只支持 greedy searchtop-1但包含所有核心逻辑def manual_generate(model, tokenizer, input_text, max_new_tokens10): input_ids tokenizer.encode(input_text, return_tensorspt).to(model.device) past_key_values None generated_ids input_ids[0].tolist() # [1, 29872] for step in range(max_new_tokens): if step 0: # 第一次输入完整序列 outputs model(input_ids, use_cacheTrue) logits outputs.logits next_token_id torch.argmax(logits[0][-1]).item() # 取最后一个位置 past_key_values outputs.past_key_values else: # 后续只输入新 token cache new_input torch.tensor([[next_token_id]], dtypetorch.long).to(model.device) outputs model(new_input, past_key_valuespast_key_values, use_cacheTrue) logits outputs.logits next_token_id torch.argmax(logits[0][0]).item() past_key_values outputs.past_key_values generated_ids.append(next_token_id) # 检查 EOS if next_token_id tokenizer.eos_token_id: break return tokenizer.decode(generated_ids, skip_special_tokensTrue, clean_up_tokenization_spacesTrue) # 测试 result manual_generate(model, tokenizer, Hello, max_new_tokens5) print(result) # Hello world is a6.1 四个必修的边界检查EOS token 检查if next_token_id tokenizer.eos_token_id必须加否则无限生成。Qwen 的eos_token_id是151643。device 一致性new_input.to(model.device)否则Tensor on cuda:0 and Tensor on cpu报错。past_key_values 类型outputs.past_key_values是 tuple直接传给下次model()即可无需.to(device)——Hugging Face 已自动处理。max_new_tokens 安全上限seq_len不能超模型上下文窗口Qwen2.5 是 32768。循环中加if len(generated_ids) 32768: print(Warning: reached max context length) break6.2 为什么你的手写 generate 比官方慢 5 倍常见错误每次循环都model(new_input, past_key_values...)但没设use_cacheTrue。use_cacheFalse时模型仍计算 full KV只是不返回——白白浪费 90% 时间。必须显式use_cacheTrue。另一个坑new_input的dtype必须是torch.long。如果误写torch.int32PyTorch 会静默转成long但触发额外拷贝慢 20%
阅读完成 · 觉得有帮助?