ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

大模型底层源码解析:从Transformer架构到部署实战

大模型底层源码解析:从Transformer架构到部署实战

聊大模型的人都绕不开一个现状:调用API生成文本的人一抓一大把,但真正能把底层架构讲清楚、能把源码吃透的人并没有那么多。我身边不少做算法和工程的同事,跑通一个开源模型很容易,可一旦遇到显存溢出、推理速度上不去、微调效果不对这类问题,就只能靠试错。这份笔记是我啃过大模型底层源码之后沉淀下来的复盘,把架构、代码、部署和常见坑串成一条线。

这篇内容不是教你背论文公式,而是回答几个更实际的问题:Transformer架构里每个模块到底在算什么?源码里那些关键函数对应的理论是什么?本地部署时显存和速度怎么估算?微调、量化时底层发生了什么?适合正在做模型部署、想深入源码、或者准备从算法转向工程的同学参考,也适合想把LLM原理彻底弄明白的初学者。

1. 大模型的整体架构与逻辑拆解

1.1 Transformer到底长什么样

当前主流的大模型,几乎都是基于Transformer的Decoder-only架构衍生出来的。所谓Decoder-only,可以理解成一个“单向预测器”:给你一段已经写好的文本,让模型预测下一个词是什么,然后把预测结果拼回输入,继续预测下一个词,如此循环。

整个结构可以拆成四层来看:

  • 输入层:文本先通过Tokenizer切成token,每个token查表得到embedding向量,再加上位置信息。
  • 主干网络:N个结构相同的Decoder Block堆叠在一起,每个Block内部包含多头自注意力、前馈网络、残差连接和层归一化。
  • 输出层:最后一层Block的输出通过lm_head做线性变换,得到每个token在词表上的概率分布。
  • 采样层:从概率分布中选一个token作为输出,常见的是贪心解码、温度采样、top-k或top-p采样。

一个7B参数量的模型,hidden_size通常是4096,层数32层,注意力头数32个。这些参数都定义在config.json里。拿到一个模型权重,第一件事就是看它的config.json,里面是理解模型结构的第一手资料。

从工程角度看,模型不是一堆代码,而是一堆权重矩阵。推理过程就是一个数据流:embedding向量依次经过每一层的attention和MLP,最后输出概率。把这条数据流的shape变化搞清楚,后面读源码会轻松很多。

1.2 注意力机制到底在算什么

注意力机制是整个Transformer的核心,很多人只记住了公式,没有真正理解它在干什么。自注意力做的事情是:序列里的每个token,都去和所有token(包括自己)计算相关性,然后根据相关性加权聚合信息。

具体流程是:

  1. 对输入X分别乘三个权重矩阵,得到Q(查询)、K(键)、V(值)。
  2. 计算Q和K的点积,除以sqrt(d_k)做缩放,得到注意力分数。
  3. 经过softmax归一化,变成0到1之间的权重。
  4. 用这些权重对V做加权求和,得到每个token的输出。

为什么除以sqrt(d_k)? 当维度变高时,点积的结果会过大,导致softmax后的梯度非常小,训练不稳定。生活化地理解:一群人投票,如果票数差值太大,少数派的声音就完全被淹没了;缩放一下,让大家的差距保持在合理范围,才能正确聚合信息。

多头注意力就是把hidden_size分成多个头,每个头用自己的一套QKV矩阵,从不同角度观察特征。比如一个头可能专门关注语法关系,另一个头关注语义相关性,最后拼接起来。这也是为什么大模型能捕捉复杂语义的原因之一。

1.3 从架构到代码的映射

读源码时最怕的就是“每个函数都能看懂,串起来就晕”。其实开源模型代码的结构和架构图是一一对应的。以transformers库为例,一个Llama模型通常由这几个类组成:

  • LlamaModel:最上层,管理embedding、层列表和norm。
  • LlamaDecoderLayer:单个Transformer Block。
  • LlamaAttention:注意力模块。
  • LlamaMLP:前馈网络。
  • LlamaRMSNorm:归一化层。

从LlamaModel的forward函数进去,数据经过embedding层后,进入一个for循环,逐个调用LlamaDecoderLayer,每层输出经过残差后再喂给下一层。只看代码时,建议先打印模型的整体结构,类似于:

from transformers import AutoModelForCausalLM model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf") print(model)

输出是嵌套的类结构,每个类的名字和源码文件里的类名对应。这样你就知道,想改注意力逻辑就找LlamaAttention,想看前馈网络就找LlamaMLP,不用在几千行代码里瞎逛。

接下来要做的,是把每个模块里的张量维度算一遍。比如输入X的shape是[batch, seq_len, hidden_size],经过q_proj后变成[batch, seq_len, num_heads * head_dim],再reshape成[batch, seq_len, num_heads, head_dim],然后每个头单独计算attention。自己手推一遍这个变化过程,比读十遍论文都有用。

2. 源码级关键实现解析

2.1 注意力算子的实现与验证

源码里注意力实现有多个版本:eager模式、SDPA、FlashAttention。它们的数学结果是等价的,区别只在计算效率和显存占用。

Eager模式是最直观的实现方式:先生成完整的注意力矩阵,shape是[batch, num_heads, seq_len, seq_len],然后做softmax和加权求和。它有一个致命问题:注意力矩阵的大小是序列长度的平方,当seq_len到4096时,一个头就要占掉不少显存,长文本根本跑不动。

FlashAttention换了个思路:不把完整的注意力矩阵存下来,而是把Q、K、V切成小块,分块计算softmax,通过在线softmax的叠加来得到正确结果。它的优势有两个:一是显存占用从O(n^2)降到O(n);二是计算时充分利用GPU的SRAM,减少和HBM之间的数据搬运,速度明显更快。

从源码验证这一点有一个很实用的方法:打开modeling_llama.py,找到attention实现里调用self.spda或flash_attention的地方。很多开源模型的代码里保留了多种实现,你可以把use_flash_attention_2这个参数改为True和False,对比同样的prompt,看速度和显存差多少。实测下来,在A100上同样生成512个token,FlashAttention比eager模式快30%以上,显存峰值能低20%左右。

2.2 激活函数、归一化与位置编码的演变

大模型架构里,每个小细节都经历过数轮迭代。激活函数从早期Transformer的ReLU,到后来常用的GELU,再到今天很多主流开源模型使用的SwiGLU。SwiGLU的表达式是x * sigmoid(x) * W,本质上是把两个线性变换的结果做门控相乘。它的梯度更平滑,信息流控制更灵活。源码里通常长这样:

def forward(self, x): x_proj = self.gate_proj(x) * self.up_proj(x) return self.down_proj(act_fn(x_proj))

归一化层也发生了改变。原始Transformer用LayerNorm,需要计算均值和方差。Llama这类模型用的是RMSNorm,直接去掉均值计算,只保留方差的归一化。它的计算量更小,训练更稳定,而且在大规模参数下效果比LayerNorm还好。源码实现只有十几行:

def forward(self, x): variance = x.pow(2).mean(-1, keepdim=True) x = x * torch.rsqrt(variance + self.eps) return x * self.weight

位置编码从绝对位置编码演进到RoPE旋转位置编码。RoPE的思路是给每个token的Q和K向量乘一个旋转矩阵,让向量在二维平面内旋转,旋转角度由位置决定。这样带来的好处是:位置信息以“相对距离”的形式内嵌在注意力计算过程中。源码里实现通常分三步:先把x按维度切成两半,用cos和sin做旋转,再拼接回去。理解RoPE的数学形式很重要,因为很多长文本优化工作都和RoPE的扩展有关。

2.3 KV Cache与推理优化的底层逻辑

大模型推理和训练有个根本区别:推理是自回归的,一个一个token生成。生成第100个token时,前面99个token的Q、K、V其实都已经算过了。如果每次都重新计算一遍,浪费算力不说,速度会慢到不可接受。

KV Cache就是把这些历史计算缓存下来。具体做法是:只在模型初始化时给KV分配显存,每一步只算当前token的K和V,追加到缓存里;只计算当前token的Q和缓存中所有K的注意力分数。这样每一步的计算量大幅减小。

KV Cache的显存占用可以用公式估算:

KV显存 = 2 * num_layers * hidden_size * seq_len * batch_size * 字节数

举个例子:7B模型,hidden_size=4096,32层,FP16精度(2字节),batch_size=1,seq_len=4096,那么KV Cache内存大约是2*32*4096*4096*1*2 = 4GB。这也是为什么长上下文的显存压力非常大,很多部署方案必须做KV Cache量化或PageAttention优化。

对比一下两个概念:KV Cache是推理时的时间换空间策略,注意力矩阵是训练时的空间换时间策略。理解了这一步,你在读vLLM或SGLang源码时就会明白它们优化的核心点在哪里。

3. 从源码到部署:量化、推理引擎与微调实战

3.1 llama.cpp与GGML量化

想在家用电脑或者普通工作站上跑大模型,直接加载FP16的7B模型,光权重就占14GB显存,很多人的显卡顶不住。llama.cpp走的是另一条路线:把模型量化到4bit或5bit,同时用C++实现高效的CPU/GPU混合推理。

量化本质上是对权重做有损压缩。FP16的每个权重占2字节,量化到4bit后每个权重只占0.5字节,体积直接缩到四分之一。但量化不是简单的截断,而是要做scale和zero-point校准,尽量保留原始分布的精度。llama.cpp里实现了多种量化格式,常见的有:

量化格式每位宽适用场景
q4_04bit追求速度,显存极小,精度损失中等
q4_K_M4bit混合兼顾质量和体积,日常使用推荐
q5_K_M5bit混合质量接近原版,体积稍大
q8_08bit质量损失很小,体积偏大

部署实操中,第一步是用llama.cpp的convert脚本把Hugging Face格式的模型转成GGUF格式,第二步做量化。比如把7B模型转成q4_K_M:

python convert.py --outfile llama-2-7b.gguf --outtype f16 ./llama-quantize llama-2-7b.gguf llama-2-7b.Q4_K_M.gguf Q4_K_M

之后加载和推理:

./llama-cli -m llama-2-7b.Q4_K_M.gguf -p "写一段关于AI架构的讲解" -n 256 -t 8

一个比较现实的建议:如果显存不紧张,优先用q8_0;如果显存很紧张,用q4_K_M,尽可能避免用更低位宽的量化,因为超过一定阈值后质量下降会非常明显。另外,量化后模型的输出会有一定随机性,对比效果时要跑同一组prompt多次取平均感受,不要拿一次生成结果下结论。

3.2 vLLM与PagedAttention

llama.cpp适合单机单卡或CPU推理,但如果是部署服务,需要同时服务多人,追求高吞吐,vLLM是目前社区很常用的引擎。vLLM的核心优化技术是PagedAttention。

传统KV Cache需要预先分配连续显存,长度超出就存不下,长度不够又浪费。PagedAttention借鉴了操作系统虚拟内存的分页思想:把KV Cache分成固定大小的块,允许它们在物理显存中不连续存放。逻辑上连在一起,物理上分散排列,用一张块表来记录映射关系。这样显存利用率大幅提高,并发能力也上来了。

用vLLM起一个兼容OpenAI接口的服务,代码比想象中短:

from vllm import LLM, SamplingParams llm = LLM(model="/models/llama-2-7b, tensor_parallel_size=1, gpu_memory_utilization=0.85) params = SamplingParams(temperature=0.7, top_p=0.9, max_tokens=512) outputs = llm.generate(["大模型的KV Cache有什么作用?"], params) print(outputs[0].outputs[0].text)

这里有个参数值得注意:gpu_memory_utilization。它决定了把多少显存留给模型权重和KV Cache。设太高容易OOM,设太低则浪费显存,实际经验是0.82到0.9之间比较合理。如果并发和长度需求大,优先调整这个参数,而不是无脑升级显卡。

在vLLM源码里,Scheduler和BlockManager是两个核心模块。Scheduler负责决定哪些请求可以进入执行,BlockManager负责KV块的分配和复用。理解了这两个模块,就理解了为什么vLLM能在高并发下保持稳定。

3.3 微调阶段的LoRA与源码注入点

大模型微调目前主流方案是LoRA(低秩适配)以及它的增强版QLoRA。LoRA的思想很巧妙:冻结原模型的全部权重,只在注意力层的Q和V矩阵旁边加一个低秩旁路。这个旁路是两个小矩阵相乘,秩一般只有8到64,参数量比原模型少好几个数量级。

在transformers库的源码里,LoRA的注入点通常是target_modules参数,传入的是模块名。常见配置:

from peft import LoraConfig, get_peft_model config = LoraConfig( r=16, lora_alpha=32, target_modules=["q_proj", "v_proj"], lora_dropout=0.05, bias="none", task_type="CAUSAL_LM" ) model = get_peft_model(model, config)

这里有个容易踩的坑:r和lora_alpha的配比。lora_alpha / r的值决定旁路对原权重的放大倍数,一般设为2到4比较稳。r设得越大,表达能力越强,但可训练参数和过拟合风险也越高。对很多业务场景,r=16已经够用。

微调完成后,要决定是保存LoRA适配器还是合并回原模型。保存适配器方便,文件只有几十MB,加载时要额外注入;合并后权重是完整的模型文件,可以直接部署,但不能再单独移除LoRA效果。推荐开发阶段用LoRA适配器方便迭代,上生产时合并回原权重,减少推理阶段的额外运算。

QLoRA进一步把模型先量化到4bit,再在量化后的权重上挂LoRA。这样做的好处是单卡就能微调7B甚至13B模型。但要注意,量化后的底层权重在反向传播时会走dequantize路径,如果训练数据太少,效果会比全精度微调差一些,需要多调参。

4. 常见问题与排查技巧实录

4.1 显存溢出与推理速度慢

显存溢出是部署大模型时最常遇到的问题。排查思路要按顺序来:

  1. 先算模型权重占多少。7B FP16大概14GB,8bit量化约7GB,4bit约4GB。
  2. 再算KV Cache占多少,公式前面写过。如果batch和seq_len都不大,说明显存主要耗在权重上。
  3. 最后算中间激活值。这里很多人会忽略,seq_len特别长时激活值涨得很快。

排查时先设置一个合理的gpu_memory_utilization,比如0.85,然后把seq_len从短到长逐步增加,看显存曲线的拐点在哪里。如果权重没变但OOM,基本就是KV Cache或激活值的问题。

推理速度慢的原因通常是这三个:模型太大而算力不够、batch_size设置太小导致GPU利用率低、使用了未优化的attention实现。你可以先在服务端分别测试纯预填充时间、纯解码时间、端到端时间,定位瓶颈在哪个阶段。解码阶段慢,优先检查KV Cache是否命中、是否用了FlashAttention;预填充阶段慢,优先检查batch大小和线程配置。

我来整理一张速查表,方便排查时对照:

问题现象常见原因处理方式
加载权重就OOM精度太高或显存太小换4bit量化,减小max_model_len
生成长文时OOMKV Cache爆了降低batch_size,启用KV Cache量化
第一个token很慢预填充阶段计算量大开FlashAttention,增大batch
生成了root词在重复循环采样参数问题增大top_p,设置no_repeat_ngram_size
多用户并发时吞吐低没有continuous batching用vLLM/SGLang替代原始transformers推理

4.2 微调中的灾难性遗忘与不收敛陷阱

LoRA微调最典型的失败案例是:模型在目标数据上表现不错,但通用能力大幅下降,聊天都变得干巴巴。这本质上是灾难性遗忘。

根源往往是微调数据集太单一、学习率太高、训练轮数太少。我复盘过的教训是:

  • 通用能力数据占总数据集的比例不能太低,建议至少20%。
  • LoRA的dropout不要设成0,尤其在数据量不足时,dropout有正则化作用。
  • 学习率一般从1e-4开始调,峰值不要超过2e-4,否则很容易过拟合。
  • 训练轮数3到5轮就够,微调不是预训练,不需要跑几十轮。

还有一个容易被忽视的问题:如果你用的是QLoRA,注意训练时的torch_dtype和推理时保持一致。混合精度不一致会导致量化误差在不同设备间表现差异很大,最明显的现象是本地测试正常,部署到另一台机器就变差。

4.3 读源码时的断点思路与调试技巧

很多人在源码面前无从下手,我给一个实战思路:只改一行代码,加一行print,跑一个最小样例。

比如你想搞明白attention输出的shape走向,就在LlamaAttention的forward里,在输出前加一句:

print("q shape:", q.shape, "k shape:", k.shape, "v shape:", v.shape) print("attn_output shape:", attn_output.shape)

然后跑一个很短序列的推理,immediately看到每个维度。这是最高效的理解方式。

另外明确一下上下文窗口相关参数:模型config里的max_position_embeddings是训练时见过的最大长度,而mlp尺寸是前馈网络的隐藏层维度,一般等于hidden_size的4倍左右甚至更高。看到这个参数能快速判断模型规模。

5. 学习路径建议与我的体会

5.1 一条可执行的路线

初学者最容易犯的错是从头啃论文。我的建议正好相反:先从运行开始,再读代码,最后回看论文。

阶段一:本地跑通一个大模型。用transformers加载一个3B或7B模型,跑通文本生成。重点不是效果,而是把每一步的输入输出理解清楚,用1.2节的方法把每个模块的tensor shape打印一遍,自己手画一张数据流图。

阶段二:优化推理。用llama.cpp把同一个模型量化成不同格式,对比速度和质量;再部署vLLM,压测一下并发吞吐,观察显存和延迟的变化。这个阶段你能真正理解KV Cache、量化和PagedAttention这些名词的价值。

阶段三:改模型。尝试用LoRA微调一个小模型,让它学会一种特定输出风格。然后回到代码层面,把注意力算子替换成自定义实现,或者做个简单的模型剪枝实验。改过代码之后,对底层的理解会上一个台阶。

阶段四:读论文。带着问题看,比如为什么用RMSNorm不用LayerNorm,为什么用SwiGLU,为什么用RoPE。你会发现这时候读论文的速度和吸收程度都远远超过一开始就硬啃。

5.2 我的踩坑记录

这个内容如果只让我留下一句话,那就是:不要在没有理解数据流的情况下去改配置。

我早期调参时,直接把一批模型的hidden_size改大了一倍,结果推理速度暴跌。因为改动一个维度,会导致所有权重矩阵形状不匹配,需要重新初始化,等于把预训练成果扔掉了。类似地,在部署阶段盲改量化方式,不如先搞清楚瓶颈在权重还是KV Cache。

另一个体会是:读源码的进度感很重要。每次只拆一个模块,把它单独拎出来测试,比同时盯着整个项目容易得多。我习惯在项目根目录建一个sandbox文件夹,把要测试的类复制出来,构造最小的输入去跑。到最后,源码里那些看似复杂的实现,都能对应到最基础的矩阵乘法、reshape和归一化操作。

5.3 关于「看懂源码」这件事的最后一个建议

大模型生态的迭代速度非常快,今天看llama.cpp的代码,明天可能出现新的推理引擎,后天新的架构又出来了。但底层的基础模块没有变过:embedding、attention、MLP、norm、位置编码、KV Cache、量化。把这七样东西从源码层面吃透,往后看任何新模型,都只是这些组件在排列组合上的变化。

我在实际学习过程中有个习惯:每读完一个模块,就写一段十几行的极简复现,喂相同的数据和权重,对比输出是否对齐。如果对齐了,说明我真的看懂了;如果对不上,就回去查原始实现。这个习惯帮我避开了很多“自以为懂了”的错觉。

最后再分享一个小技巧:把模型的config.json和源码文件对照着看。很多模型的源码里都有与config.json参数一一对应的断言或初始化逻辑,顺着这些对应关系去读,整个模型的构建过程就是一张关系图。等你能在这张关系图上自由标注每个模块的职责、显存占用和计算瓶颈,你就已经从“用模型的人”变成“懂模型的人”了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进