大模型是怎么工作的
大模型没有在思考,只是在预测下一个最可能的 Token:Token 化、自注意力、位置编码、多层解码器、KV Cache,这些零件拼起来就是推理的全过程。
这是 AI 系列的第二篇,写完文章后发现有一万多字,本来想拆分成上下两篇的,但是想了下,还是算了,目录比较清晰,拆分只能让大家在对照或者回顾的时候很麻烦。
我会从最底层的 Transformer 开始一步步拆解大模型的核心原理。
这个系列的目标是,普及 AI 的知识,而本篇文章,希望在大家读完之后,能让你看懂模型架构图,能向别人解释大模型到底在算什么。
从零开始理解 Token
在讲 Transformer 之前,我们得先搞懂一个最基础的概念:Token(词元)。
你可能会说,Token 不就是一个词吗?多简单啊,但其实没那么简单,不然也不会专门给一个中文名词了。
1.1 为什么不用字或词
我们看到的大模型,处理的是自然语言,但实际在它底层中,只能处理数字。所以大模型想要处理自然语言的第一步,就是要把文本转换成数字序列。
符合我们直觉的方案有两种:
-
按字切:把每个汉字/英文字母当作一个单位。但这种方式的问题是信息密度太低了,“人工智能”有四个字,但作为一个概念它只有一个意思,拆分成字就有不同的含义了,这种理解,我们人类可以很自然的完成。
-
按词切:把每个词当作一个单位。这种方案的问题在于,词表又太大了,中文常用词几十万,英文更甚。而且遇到新词(比如 ChatGPT)怎么办?没有记录在册的词(OOV)未来会是个大问题。
而 Token 方案走的是中间路线,它比字大,比词小。它是通过统计方法从大量语料中自动学习出来的最有性价比的切分单位。
输入文本:大模型是怎么工作的
按字切: 大 / 模 / 型 / 是 / 怎 / 么 / 工 / 作 / 的 → 9 个单位(太碎)
按词切: 大模型 / 是 / 怎么 / 工作 / 的 → 5 个单位(但"大模型"可能不在词表里)
Token 化:大 / 模型 / 是 / 怎么 / 工作 / 的 → 6 个单位(性价比最高)可以看到,分词器把模型这个高频词保留为一个完整的单位,把大单独切分出来,这就是统计学习的结果,相对字的粒度更大,相对词则更灵活。
如果你想试试你的一句话中,有多少 Token,可以试试用 AI 厂商的 Token 计算器,或者使用在码匠工具箱中对应的工具。
1.2 BPE 分词算法原理
那么 Token 方案是怎么分词的?
目前最常用的 Token 化算法叫 BPE(Byte Pair Encoding,字节对编码)。它的思想非常朴素,即,找出出现频率最高的相邻字符对,把它们合并成一个新 Token,反复迭代。
这里用一段 JS 代码来模拟这个过程,不过需要注意的是,真实的 BPE 是按 Token 数组处理的,不是按字符串,这样合并后的 Token 才能作为整体参与下一轮统计:
// 语料(简化版,实际是海量文本)
const corpus = ['大模型', '大语言模型', '大型模型'];
// 第一步:初始 Token 序列——每个字是一个 Token
let words = corpus.map(word => [...word]);
console.log('初始 Token 序列:', words);
// [['大','模','型'], ['大','语','言','模','型'], ['大','型','模','型']]
// 第二步:统计相邻 Token 对的频率
function getPairs(words) {
const pairs = new Map();
for (const tokens of words) {
for (let i = 0; i < tokens.length - 1; i++) {
const pair = tokens[i] + tokens[i + 1];
pairs.set(pair, (pairs.get(pair) || 0) + 1);
}
}
return pairs;
}
// 第三步:找出频率最高的 Token 对,把所有出现的位置合并
function mergePair(words, pair) {
return words.map(tokens => {
const result = [];
let i = 0;
while (i < tokens.length) {
if (i < tokens.length - 1 && tokens[i] + tokens[i + 1] === pair) {
result.push(pair); // 合并为一个 Token
i += 2;
} else {
result.push(tokens[i]);
i++;
}
}
return result;
});
}
// 迭代合并
let vocab = new Set();
words.flat().forEach(t => vocab.add(t));
for (let i = 0; i < 3; i++) {
const pairs = getPairs(words);
let bestPair = '';
let bestFreq = 0;
for (const [pair, freq] of pairs) {
if (freq > bestFreq) {
bestFreq = freq;
bestPair = pair;
}
}
console.log(`第${i + 1}轮合并: "${bestPair}" 出现 ${bestFreq} 次`);
words = mergePair(words, bestPair);
vocab.add(bestPair);
}
console.log('合并后:', words);
console.log('最终词表大小:', vocab.size);运行结果是这样的:
初始 Token 序列: [['大','模','型'], ['大','语','言','模','型'], ['大','型','模','型']]
第1轮合并: "模型" 出现 3 次
第2轮合并: "大模型" 出现 1 次
第3轮合并: "大语" 出现 1 次
合并后: [['大模型'], ['大语','言','模型'], ['大','型','模型']]
最终词表大小: 8注意 mergePair 函数,这个函数遍历 Token 数组,找到匹配的相邻对就合并为一个新 Token。下一次迭代时 getPairs 统计的是 ['大模型']、['大语','言','模型'] 这样的序列,这里的“模型”已经是一个不可分割的整体了。真实的 BPE 也一样,每轮的合并都会更新整个语料的 Token 表示,后续迭代在新的表示上继续统计。
实际上现代大模型常用的 byte-level BPE 不是直接从汉字或单词开始,而是先把文本按 UTF-8 转成字节序列,从字节开始合并。
初始词表包含 256 种可能的单字节值(0~255),通常通过 bytes_to_unicode 映射成可见字符。然后反复统计相邻 token 对的频率,把最高频的 pair 合并成一个新 token,加入词表。新的 token 可能对应两个字节、三个字节、一个完整汉字或常见词片段。重复直到词表达到目标大小,常见的是 3 万到 10 万的大小,或满足其他停止条件。
注意:BPE 原始含义是字节对编码,但 NLP 中早期 subword BPE 常从字符/符号开始;byte-level BPE 是 GPT-2 等推广的变体。英文并不拥有 256 个字节,256 是所有单字节取值的总数,英文只使用其中一小部分。
这个过程和前端的代码压缩有点像,就是把出现频率高的长字符串用更短的符号代替,达到压缩的效果。可以理解为BPE 就是对文本做了一次自适应压缩,但需要注意的是,这种压缩并不是将字节数变少了,只是让 token 数变少。
1.3 Token 数量和价格的关系
大模型 API 的计费单位是 Token,理解 Token 对我们估算使用成本做相应的成本管控有很大的帮助。
以 OpenAI 的 GPT 系列模型为例:
-
对于英文文本
-
1000 个 Token ≈ 750 个英文单词。
-
平均 1 个单词约 1.3 个 Token。长词、低频词、专有名词常被拆成多个子词,例如 unhappiness 可能被拆成 un + happ + iness。
-
-
对于中文文本
-
1000 个 Token 通常对应 600~800 个常见汉字,极端情况可能到 500 或 1000。
-
在 GPT-3.5/4 常用的 tokenizer 中,常用汉字经常是 1 个 Token,生僻字可能被拆成多个 Token,常见为 2~3 个,也可能更多。但具体的取决于 tokenizer、文本类型和语料分布,所以中文换算通常是一个范围,而不是固定值。
-
因此,表达同样一段语义时,中文可能比英文消耗更多 Token,自然我们在使用中文 Prompt 时也就容易感觉更贵。但要注意,不能简单说 1000 个 Token 英文能表达的内容一定比中文多,因为英文单词和汉字不是同一语义单位,比较口径是不同的。
怎么估算 Token 数量? 一个粗略的经验公式(也可以使用码匠工具箱来估算 Token):
英文:Token 数 ≈ 单词数 × 1.3;或 Token 数 ≈ 英文字符数 ÷ 4。
中文:Token 数 ≈ 汉字数 × 1.2~1.7;粗略可记 1 个汉字 ≈ 1~2 个 Token;或 1000 Token ≈ 600~800 个常见汉字。
混合文本:英文、中文、数字、标点、代码、空格、换行分别估算后相加。想要更精确的计算自己输入输出的 token 量,可以用官方的 Tokenizer 工具(如 OpenAI 的 tiktoken 库)来计算。因为不同模型的 tokenizer 不同,像 GPT-2、GPT-3.5/4、Claude、LLaMA 等换算起来会有差异,而且输入和输出通常分别计费,所以实际成本最好用对应模型的 tokenizer 或 API 返回的 usage 字段来精确计算。
1.4 中文 Token 的特殊性
中文 Token 有一些别的语言没有的问题:
-
没有空格,词的边界是学出来的:英文有空格,单词边界天然很清楚。但中文没有空格,Tokenizer 只能靠统计的频率自己去猜哪里是词的边界。这样就导致 Token 的边界经常落在字上,而不是词语上。
-
汉字太多,频率被摊薄:英文只有 26 个字母,常用汉字却有三五千个。同样大小的词表,分给每个汉字的合并机会更少。高频字容易变成单字 Token,低频字和很多词则被拆开来计算。
-
一个字 3 个字节,合并路径更长:在 Byte-level BPE 下,“大”是
E5 A4 A7,三个字节。而英文a只要 1 个字节。所以一个汉字要变成单 Token,需要更多轮的合并,一个中文词想要变成整词 Token,更是难上加难。 -
字不等于词,词又不够高频:比如在中文里学和习都是高频字,但学习作为整词的频率未必高到能被合并。于是很多常用词最后是两个字 Token 拼出来:
学+习。这就是中文 Token 数偏多的直接原因。 -
英文优先的语料,让中文更吃亏:多数 tokenizer 训练语料以英文为主。英文高频词、子词、数字格式更容易被合并。中文词想合并成整词 Token,需要更高的出现频率,自然更难。
理解 Token,是理解大模型上下文窗口、推理成本和注意力计算的基础之一。
Transformer 架构全景
文本被切成 Token 后,接下来要送进模型里进行计算。而现代的大模型,无论是 GPT、Claude 还是 LLaMA,核心骨架几乎都是 Transformer。所以,要理解上下文窗口、注意力机制和推理成本,就绕不开 Transformer。
2017 年,Google 在论文《Attention Is All You Need》中提出了 Transformer 架构。它做的是序列到序列的转换,但他并不是第一个做这件事情的。在此之前,序列建模的主流是 RNN 及其变体 LSTM、GRU。
RNN 按时间步顺序计算,难以并行,长距离依赖也容易衰减。而 Transformer 的核心创新是:以自注意力机制为核心建模序列,不再依赖循环和卷积,使训练可以大规模并行,从而成为现代大模型的基础。
2.1 为什么需要 Transformer
Transformer 为什么重要?这需要先看它之前的序列模型卡在哪里,出现了什么难以解决的问题。
我们以 RNN 为例:
flowchart LR
subgraph RNN_Seq["RNN 顺序计算"]
x1[x₁] --> h1[h₁]
x2[x₂] --> h2[h₂]
x3[x₃] --> h3[h₃]
x4[x₄] --> h4[h₄]
h1 --> h2 --> h3 --> h4
end
style x1 fill:#e8f0fe,stroke:#0a84ff
style x2 fill:#e8f0fe,stroke:#0a84ff
style x3 fill:#e8f0fe,stroke:#0a84ff
style x4 fill:#e8f0fe,stroke:#0a84ff
RNN 的核心问题是顺序依赖,想要算第 4 个位置的输出,就必须先算完前 3 个,这样一来便有如下限制:
-
时间维度难并行:第 t 步必须等第 t−1 步完成,GPU 在时间维度上利用率低(但要注意, batch、层维度上仍可并行。)
-
长距离依赖弱:普通 RNN 中信息在传递过程中会逐渐衰减,句子前面的词对后面的影响越来越小。LSTM/GRU 用门控缓解了这个问题,但没有彻底根治。
-
训练慢:因为难以并行,就导致训练时间特别长。
Transformer 创新地设计了自注意力机制(Self-Attention),它让模型可以一次性看到所有位置的信息(严格来说,编码器可以看全序列,解码器生成时只能看已生成部分,这一点下一节会展开。),同时通过注意力权重来决定每个位置该关注哪些其他位置。在模型训练时,这样的设计使得所有位置的计算可以完全并行。但这种设计也并不是没有代价,自注意力的计算和显存随序列长度呈 O(n^2) 增长,长上下文成本较高。
2.2 整体结构
来看原始 Transformer 的整体结构,原始的 Transformer 是一个编码器-解码器(Encoder-Decoder) 架构:
flowchart TB
subgraph 输入
I["输入序列<br/>#quot;大模型是怎么工作的#quot;"]
end
subgraph Encoder[编码器 N 层]
direction TB
E1[嵌入层 Embedding]
E2[位置编码 Positional Encoding]
E3[多头自注意力<br/>Multi-Head Self-Attention]
E4[前馈网络<br/>Feed-Forward Network]
E5[残差连接 + 层归一化]
E1 --> E2 --> E3 --> E4
E3 --> E5
end
subgraph Decoder[解码器 N 层]
direction TB
D1[嵌入层 + 位置编码]
D2[掩码多头自注意力<br/>Masked Multi-Head Attention]
D3[编码器-解码器注意力<br/>Encoder-Decoder Attention]
D4[前馈网络]
D1 --> D2 --> D3 --> D4
end
subgraph 输出
O["输出序列<br/>#quot;How does a large model work#quot;"]
end
I --> E1
E4 --> D3
D4 --> O
style Encoder fill:#f0f7ff,stroke:#0a84ff,stroke-width:2px
style Decoder fill:#fff8f0,stroke:#b8860b,stroke-width:2px
图中为简化示意,实际每个子层后都有残差连接和层归一化。
-
编码器(Encoder):负责理解输入序列,把每个 Token 转换成一个具有上下文信息的向量表示。
-
解码器(Decoder):负责生成输出序列,基于已生成部分和编码器输出,自回归地预测下一个 Token。
这种架构最适合机器翻译这类任务:输入一种语言,输出另一种语言。同样的,这也适合摘要、翻译等序列转换任务。
你可能听说过,GPT 只有解码器,BERT 只有编码器。这是为什么?
| 模型类型 | 架构 | 代表 | 擅长任务 |
|---|---|---|---|
| 编码器-only | 只有 Encoder | BERT | 理解类任务(分类、命名实体识别) |
| 解码器-only | 只有 Decoder | GPT 系列 | 生成类任务(对话、写作、代码) |
| 编码器-解码器 | 完整架构 | T5、BART | 转换类任务(翻译、摘要) |
现在的大语言模型(LLM)基本都是解码器-only架构,因为自回归生成是当前 LLM 的主流范式(生成式 AI)。我们后面的讨论也主要围绕解码器架构展开。
2.3 理解 Transformer
我们可以这样类比 Transformer 的各个组件:
| Transformer 组件 | 前端类比 | 作用 |
|---|---|---|
| Embedding 层 | Map<Token, Vector> 查表 | 把 Token(整数 ID)转换成向量 |
| 自注意力层 | array.map(item => weightedSum(allItems, weights))每个元素根据和其他元素的相似度,动态算出权重再加权求和 | 计算位置之间的关系 |
| 前馈网络(FFN) | 两层函数调用 ffn(x) = W2 · gelu(W1 · x + b1) + b2 | 对每个位置的向量做非线性变换 |
| 残差连接 | x + sublayer(x) | 防止梯度消失,让深层网络能训练 |
| 层归一化(LayerNorm) | (x - mean) / std * gamma + beta | 标准化数值范围,稳定训练 |
| 位置编码 | 给每个 Token 的向量加上一个表示位置的向量 | 告诉模型每个 Token 的位置信息 |
需要注意的是,自注意力里的权重不是预先写死的,而是由 Query 和 Key 实时算出来的。激活函数也因模型而异,GPT 用 GELU,LLaMA 用 SwiGLU。
简单来说就是,Transformer = 一堆向量在多层网络里传来传去,每层通过注意力机制让向量之间互相了解,最终输出新的向量。
2.4 有多少层?有多少参数?
我们经常听到7B 模型、70B 模型这样的说法。这里的B 是 Billion(十亿),指的是参数数量。
参数又是什么?就是模型中所有可学习张量的元素总数,包括权重矩阵、偏置、LayerNorm 的缩放和平移参数、Embedding 表等。它们主要分布在:
-
注意力层:Q、K、V 三个投影矩阵 + 输出投影矩阵。
-
前馈网络:两个线性层的权重和偏置。
-
Embedding 层:词表大小 × 隐藏层维度(输入输出共享权重时只算一次)。
-
层归一化:LayerNorm 有 gamma 和 beta 两个参数,LLaMA 用的 RMSNorm 只有 gamma。
以 GPT-3(175B 参数)为例,它有 96 层,隐藏层维度 12288,词表大小约 5 万。大部分参数都在前馈网络里,因为 FFN 的中间层通常是隐藏维度的 4 倍。
我这里粗略估算一下每层的参数:
代入 GPT-3,FFN 每层约 12 亿参数,注意力每层约 6 亿参数,FFN 占大头。
这就像一条有 96 层的加工流水线。每层内部有多个子层:一次自注意力 + 一次 FFN,各自带残差和 LayerNorm。层数越多,模型表达能力越强,但训练难度、显存和推理成本也越高,而且这并不是简单线性增长。
自注意力机制
自注意力(Self-Attention)是 Transformer 的灵魂。
为保证阅读体验,本文后续会以 [a, b] 表示二维张量形状 ( a 行 b 列),用 [a,b,c] 表示三维张量的形状,方括号内依次是第 0、1、2 维的长度,依次类推。具体向量值用圆括号表示,例如 (1.427,1.089)。
这里的方括号只描述形状,不是矩阵字面量。
3.1 Q / K / V 是什么
自注意力 = 加权求和。每个位置的输出,是所有位置 Value 的加权和。权重由相似度决定,也就是越相关的位置,权重越大。
但具体怎么算呢?这里引入三个概念:Q(Query,查询)、K(Key,键)、V(Value,值)。
你可以把它想象成查字典的过程:
-
Query(查询):你想查什么
-
Key(键):字典里每个条目的标题
-
Value(值):字典里每个条目的内容
计算过程是这样的:
-
用你的 Query 和每个 Key 做比较,算出相似度
-
把相似度转成权重(用 Softmax,所有权重加起来等于 1)
-
用权重对所有 Value 加权求和,得到最终结果
flowchart LR
subgraph 输入
X[输入向量序列 X]
end
subgraph 投影
Wq[Wq 矩阵] --> Q[Q 向量序列<br/>查询]
Wk[Wk 矩阵] --> K[K 向量序列<br/>键]
Wv[Wv 矩阵] --> V[V 向量序列<br/>值]
end
subgraph 注意力计算
S[Q × Kᵀ 相似度] --> SM[Softmax 归一化] --> W2[注意力权重]
W2 --> WS[权重 × V 加权求和]
end
subgraph 输出
Out[输出向量序列]
end
X --> Wq
X --> Wk
X --> Wv
V --> WS
WS --> Out
style Wq fill:#e8f0fe,stroke:#0a84ff
style Wk fill:#fdf0e9,stroke:#8b4a2b
style Wv fill:#e8f5e9,stroke:#1a7a3a
但等等,Q、K、V 又是怎么来的?很简单,它们都是输入向量 X 分别乘以三个不同的矩阵得到的。
这三个矩阵 是模型的参数,在训练过程中学习得到。每个位置的输入向量都会用同一组矩阵投影出各自的 Query、Key、Value。
可以把这三个矩阵理解为三种不同的视角:
-
Wq 把输入投影成我要找什么的表示
-
Wk 把输入投影成我能提供什么的表示
-
Wv 把输入投影成我的实际内容的表示
3.2 注意力计算的分步拆解
本节的拆解涉及到公式,挺复杂的,不想看的,知道自注意力 = Q 和 K 算相似度 → Softmax 成权重 → 对 V 加权求和。这个结论就行。
这里我举个例子来演示单头自注意力的计算过程,我们这里简化一些内容,以便能够更好的聚焦在核心的部分:
-
只考虑单头自注意力;不考虑 mask;
-
暂时忽略位置编码。在真实的 Transformer 需要位置编码,因为自注意力本身对顺序不敏感;
-
设
-
本来应该是训练得到的参数,这里固定成简单数值便于手算;
-
注意这里 ,否则 ,会掩盖 Query 和 Key 的不同角色。
定义输入序列是“我/爱/编程”,将每个 token 转成 4 维向量后得到:
这三行分别对应的是“我”“爱”“编程”。
第一步:计算 Q、K、V
取值为:
计算得到:
| token | Q | K | V |
|---|---|---|---|
| 我 | (1.5, 0) | (1.5, 0.5) | (2, 1) |
| 爱 | (0, 1) | (0.5, 1) | (0.5, 1.5) |
| 编程 | (0.5, 0.5) | (1, 1) | (1, 1) |
例如“我”的 Query 就是:
第二步:计算注意力分数
注意力分数就是 Query 和每个 Key 的点积(dot product)。点积越大,说明越相似。
其中第 行第 列表示:第 个 token 的 Query 与第 个 token 的 Key 的匹配分数。点积越大,表示在当前表示空间中越匹配、越相关。
比如“我”对“我 / 爱 / 编程”的原始分数是:
但这些只是原始匹配分数,有正有负、大小也不受约束,还不能直接当注意力权重。下一步要把它们变成每一行和为 1 的权重。
第三步:缩放 + Softmax
先以“我”这一行为例。原始分数是 ,在前提中,我们设定了 ,所以缩放因子是 ,对原始分数缩放后得到:。整体的计算如下:
然后我们对每一行做 Softmax,使每一行权重和为 1:
这个矩阵就是注意力权重矩阵。每一行表示一个 token 对其他所有 token 的关注程度。
例如“我”这一行表示“我”大约用 51.7% 的注意力看“我”,17.9% 看“爱”,30.4% 看“编程”。
而我们为什么要缩放?是因为当向量维度 很大时,点积的值会很大,导致 Softmax 之后的梯度很小,趋近于 0 或 1(one-hot),训练不稳定。
除以 可以把点积的方差拉回到 1 左右,拉回较稳定的范围。Softmax 实现中减去最大值只是数值稳定技巧,并不改变结果。
第四步:加权求和(权重 × V)
以“我”的输出第一维为例:
所以:
-
“我”的输出约为 (1.427,1.089)
-
“爱”的输出约为 (1.075,1.185)
-
“编程”的输出约为 (1.205,1.148)
注意,输出维度是 3 行 2 列,由 的列数 决定,不一定等于输入维度 4。
总结一下
自注意力可以理解为:每个位置的输出,是所有位置 Value 的加权和;权重由当前位置的 Query 与各位置 Key 的匹配分数决定。
完整公式是:
其中:
是模型参数,在训练中学习得到。真实 Transformer 中还会涉及多头注意力、位置编码和 mask,只是在这里为了演示核心计算,省略了这些部分。
3.3 为什么要除以
本节结论: 点积的方差会随维度 线性增大。 一大,Softmax 就会变得非常尖锐,权重集中在少数位置,梯度趋近于 0,训练不稳定。除以 是把点积方差拉回 1,让注意力权重的温度不随维度失控,记住这一句即可,如果不需要深入的了解,本节的很多公式可以跳过。
上一节的步骤中提到了缩放因子 ,这里稍微展开讲一下为什么需要它。
我们先做一个理想化假设:Q 和 K 的每个分量都是均值为 0、方差为 1 的随机变量,且各分量之间相互独立(注意这是依旧是为教学简化的,真实模型中各分量并不严格独立)。
先看点积:
对每一项 ,由于均值为 0 且独立,有 和 ,从而得到 。
于是点积的均值为 0,方差是 个独立项之和: ,自然,标准差就是 。
当 很大时(比如 512),点积会在很大范围内波动。送进 Softmax 后,输出会变得非常尖锐,导致大部分权重集中在一两个位置上,其他位置接近 0。Softmax 接近 one-hot 时,其雅可比矩阵接近 0,反向传播的梯度被压得很小,训练变得困难。
在除以 之后,点积的标准差回到 1,Softmax 的输出就不会那么极端。这类似于对数据做归一化,只不过这里归一化的是点积的尺度,目的是让注意力权重的温度不随 增大而失控,公式如下:
3.4 多头注意力
本节结论: 单头注意力只能捕捉一种相关性;多头让 h 组不同的 Q/K/V 投影并行计算,每个头在不同子空间学习不同的匹配模式,最后拼接并做一次输出投影 混合信息。注意:每个头各司其职是训练出来的,不是人为设计的,实际不少头是冗余的。
单头注意力有个问题,就是它只能捕捉一种相关性。但在实际的语言中,一个词和其他词的关系可能是多方面的。
比如在句子,小明把他的书给了小红中:
-
“他”指代“小明”(指代关系)
-
“书”是“给”的宾语(语法关系)
-
“小红”是接收者(语义关系)
多头注意力(Multi-Head Attention) 的思想就是:让 h 组不同的投影矩阵分别把输入投影成 h 组 Q、K、V,每组独立做一次注意力计算,最后把结果拼接起来再做一次线性投影。这样每个头可以在不同的子空间里学习不同的匹配模式。
这里说的严谨,主要是为了避免读者被我误导,导致后续深入学习的时候发现不对。 但为了方便理解,我们可以不要去想什么投影矩阵,就理解成,把 Q、K、V 分别拆成 h 份,每份独立的做注意力计算,最后把结果拼起来。从而让每个头可以关注不同类型的关系。
第 i 个头有自己的一组投影矩阵:,它们和其他头的矩阵不共享参数。比如 h=8 时,总共会有 8 组:,每组都是模型的可学习参数,训练时各自更新。
head 的计算公式:
其中 Q、K、V 的计算方式如之前所说,是:
同时,公式中的 是每个头的维度。最后我们将其拼接出来,就是:
但是这里要注意的是,实现上通常不真的维护 h 组小矩阵,而是先用一个大矩阵 投影,再按列切成 h 份。因为大矩阵可以按列分块,对 切分之后,正好就是 ,两种写法上是等价的,我们来整体梳理下流程:
flowchart TB
X[输入 X] --> P[线性投影]
P --> QKV[Q, K, V]
QKV --> H1[头 1]
QKV --> H2[头 2]
QKV --> Hh[头 h]
H1 --> C[Concat 拼接]
H2 --> C
Hh --> C
C --> O[线性投影 W_o]
O --> Out[输出]
用代码表示的话:
function multiHeadAttention(X, Wq, Wk, Wv, Wo, numHeads) {
const d_model = X[0].length;
const d_k = d_model / numHeads; // 每个头的维度
// 1. 大矩阵投影得到 Q, K, V,形状均为 [n, d_model]
const Q = matMul(X, Wq);
const K = matMul(X, Wk);
const V = matMul(X, Wv);
// 2. 按列切成 h 个头,得到 [h, n, d_k]
const Q_heads = splitHeads(Q, numHeads);
const K_heads = splitHeads(K, numHeads);
const V_heads = splitHeads(V, numHeads);
// 3. 每个头独立做缩放点积注意力
const attnOutputs = [];
for (let i = 0; i < numHeads; i++) {
// attention 内部为 softmax(QK^T / sqrt(d_k)) V
attnOutputs.push(attention(Q_heads[i], K_heads[i], V_heads[i]));
}
// 4. 拼回 [n, d_model]
const concatOutput = concatHeads(attnOutputs);
// 5. 输出投影,让不同头的信息交互
return matMul(concatOutput, Wo);
}其中 splitHeads 把 reshape 成 ,concatHeads 做逆操作。实际工程实现中,h 个头是一次批量矩阵乘法完成的,并不是真的开 h 个循环。
另外,不难发现代码里面出现了一个没见过的参数 Wo,它所做的事情就是把 h 个头拼接后的信息再混合一次,让不同头之间产生交互,如果没有这个参数,各头的结果只是简单并列。这也是 Transformer 中多头注意力模块常见的输出投影。
多头的常见配置是 ,也有 h=16 的变体。
为什么多头有效?直觉上,每个头可以学习不同类型的注意力模式:
-
有的头关注语法关系(主谓宾)
-
有的头关注指代关系(他/她/它指谁)
-
有的头关注相邻词
-
有的头关注远距离依赖
但这些分工是训练出来的,不是人为设计的。 实际研究还发现,不少头是冗余的,可以剪枝而不明显损失性能(Michel et al., 2019, Are Sixteen Heads Really Better than One?),所以不要太理想化地认为每个头各司其职。
3.5 用 20 行 JS 实现最简自注意力
最后,让我们用最少的代码实现一个完整的自注意力层,其中 matMul、transpose、softmax 是辅助函数(就是一个公式计算),这里省略:
function selfAttentionLayer(X, Wq, Wk, Wv, Wo) {
const Q = matMul(X, Wq); // 投影得到 Q
const K = matMul(X, Wk); // 投影得到 K
const V = matMul(X, Wv); // 投影得到 V
const d_k = K[0].length;
const scores = matMul(Q, transpose(K)); // Q × Kᵀ:计算匹配分数
const scaled = scores.map(row => row.map(s => s / Math.sqrt(d_k))); // 缩放
const weights = scaled.map(softmax); // 逐行 Softmax 得到权重
const attnOutput = matMul(weights, V); // 对 V 加权求和
return matMul(attnOutput, Wo); // 输出投影
}核心计算就这么几行。但严格来说,最简自注意力本体到 attnOutput 就结束了。
这里的 Wo 是 Transformer 中自注意力模块常见的输出投影,用来混合各头信息。若 Wo 取 ,输出形状与输入一致,仍是 。
当然了,真实实现还要考虑批量计算、多头、掩码、残差连接、LayerNorm 等等,但最本质的计算就是投影出 Q/K/V,算匹配分数,Softmax 归一化,对 V 加权求和。
3.6 小结
说实话,这一章公式看的人眼晕,其实可以简单一些,所谓自注意力,就是我作为招聘方去人才市场招聘,我拿着我的岗位说明和要求,人才拿着简历,我问遍了市场所有的人才信息,拿到了相关的简历,按照与我岗位的相关度整合成一个表格记录,按照对这个岗位的符合程度进行排序,越是匹配,就越是要关注这个人才,反之亦然。
但是我的注意力是有限的,所以我自然是更关注符合岗位的人才,在排序的时候,我们是按照技能或者工作经验进行赋分,得到一个分数表,然后逐一进行邀请。
而多头注意力则是,我是 HR,招这个岗位需要向岗位所属部门、领导部门等等进行沟通,有些人关注点落在技术上,有些人关注点是在工资,有些人还看中经验,作为 HR 汇总完这些信息,最后才得到对应的人是否真的符合要求。
看完这一节,再回头看前面,有些问题可能就会迎刃而解了。
位置编码
到这里,我们可能会注意到一个问题,自注意力既然是一次性看到所有位置的,那它怎么知道每个 Token 的顺序呢?
4.1 为什么 Transformer 本身不知道顺序
自注意力本身其实是不知道 token 的顺序,它只看两两之间的匹配关系,计算中不包含任何位置信息。
更准确地说,它是置换等变的,即如果把句子里的词打乱顺序,输出也会按同样的顺序打乱,但每个 token 对应的输出向量不变。换句话说,自注意力对每个 token 一视同仁,不区分谁在谁前面。
这对语言建模来说显然是不能接受的,因为语言是有顺序的,“我爱你”和“你爱我”意思完全不同。所以我们需要给模型注入位置信息。方法就是位置编码(Positional Encoding):给每个位置生成一个向量,逐元素加到 token 的 Embedding 向量上,这里要注意是相加而不是拼接,所以两者维度必须相同,这样一来,每个位置的向量就包含了两层信息:
-
Token 本身的语义信息(来自 Embedding)。
-
位置信息(来自位置编码)。
位置编码通常还希望满足两点要求,一是能处理训练时没见过的更长序列(外推性),二是不同位置之间有可区分、平滑的表示。这两点会直接影响后面具体方案的选择。
4.2 正弦位置编码
原始 Transformer 论文用的是正弦位置编码。它的公式是这样的:
其中:
-
pos是位置(0, 1, 2, …) -
是维度索引 ,注意,这两个公式是成对出现的,而 索引的是频率组。
-
是模型的隐藏层维度
这个公式看起来有点唬人,但它的设计是非常巧妙的。
为什么不用 0,1,2,… 或 0~1 线性编码? 因为句子长度不一样。长句子和短句子的位置编码范围如果不一样,模型会混乱。
直接用位置序号,数值是无界的,长序列会淹没有限的 Embedding 语义。
外推性也差,训练时没见过的长度,编码范围就变了。
如果归一化到 0~1,间隔又会随序列长度变化:同样隔 3 个词,在 10 词句子里差 0.3,在 100 词句子里差 0.03,模型就无法稳定学习相对位置。
为什么用 sin/cos?
-
不同维度用不同频率。 越小,频率越高、波长越短,擅长区分近距离, 越大,频率越低、波长越长,擅长区分远距离。高低频的搭配,让每个位置在不同尺度上都有可区分的编码。分母里的 10000 决定了最低频率,保证最慢的维度波长足够长,能覆盖很长的序列。
-
相对位置可以用线性变换表示。对任意固定偏移 ,存在一个只依赖 的线性变换(旋转矩阵),使得:,就像给每个元素一个坐标,坐标由 sin 和 cos 组成。两个元素的位置关系(谁在谁前面,隔了多远)可以通过坐标的数学关系直接计算出来,不需要重新学习。
这意味着模型可以很容易地学习相对位置关系,而不需要死记每个绝对位置。
另外,正弦位置编码是人工设计、固定不变的,不参与训练,而值域有界 ,和 Embedding 相加不会失控。这些性质让它成为原始 Transformer 的选择。
4.3 正弦位置编码的直观理解
我们用一张表来直观感受一下位置编码的样子(假设 ,简化版):
| 位置 pos | 维度 0 (sin) | 维度 1 (cos) | 维度 2 (sin) | 维度 3 (cos) |
|---|---|---|---|---|
| 0 | 0.000 | 1.000 | 0.000 | 1.000 |
| 1 | 0.841 | 0.540 | 0.010 | 1.000 |
| 2 | 0.909 | -0.416 | 0.020 | 0.999 |
| 3 | 0.141 | -0.990 | 0.030 | 0.999 |
| … | … | … | … | … |
观察一下:
-
低维度(维度 0、1)频率高,变化快,擅长区分近距离的位置差异
-
高维度(维度 2、3)频率低,变化慢,擅长区分远距离的位置差异。
-
i = 0,2i = 0, 2i + 1 = 1,索引到了维度 0 和维度 1。i = 1 同理。
这就像二进制编码一样,低位变化快,高位变化慢,单个维度会周期性重复,但高低频组合后,每个位置得到一个独特的多维编码。只不过这里用的是连续的 sin/cos 函数。
4.4 现代模型的位置编码演进
正弦位置编码已经是 2017 年的方案了。现代的大模型用的位置编码方案更先进,但大致上分为两类:绝对位置和相对位置编码
绝对位置编码:
- 可学习位置编码
-
直接把位置编码当作参数来训练
-
优点:灵活,模型自己学
-
缺点:不能处理超过训练长度的序列
-
代表模型:GPT-2、BERT
相对位置编码:
- RoPE(Rotary Position Embedding,旋转位置编码)
-
不是直接加位置向量,而是对 Q 和 K 做旋转(V 不旋转,因为位置信息只需影响匹配分数)
-
核心思想:两个向量的相对位置信息体现在它们的旋转角度差里
-
优点:天然支持相对位置,配合长度扩展方法(如 NTK 缩放、YaRN)后,外推性好(训练时的长度和推理时不一样也能用)
-
代表模型:LLaMA、GPT-NeoX、Qwen
- ALiBi(Attention with Linear Biases)
-
不加位置编码,而是在 Softmax 之前的注意力分数上加一个和距离相关的偏置
-
距离越远,偏置越小(负得越多,ALiBi 的偏置是人工设计、不参与训练的),注意力权重越低
-
优点:实现简单,外推性极好
-
代表模型:BLOOM、MPT
位置编码的演进方向很明确,从绝对位置到相对位置,从固定编码到更好的外推性。因为实际使用中,用户的输入长度是不可控的,模型需要能处理比训练时更长的序列。
推理过程到底在算什么
理解了 Transformer 的内部结构之后,我们来思考一个更实际的问题:在我们输入一句话,大模型输出一句话的过程中,到底发生了什么?
5.1 自回归生成
大模型的生成方式叫自回归(Auto-Regressive),它一次只生成一个 Token,然后把这个 Token 加到输入末尾,再生成下一个,如此循环。
flowchart LR
subgraph 第一步
I1["输入: 大模型是"] --> M1[模型] --> O1["输出: 怎"]
end
subgraph 第二步
I2["输入: 大模型是怎"] --> M2[模型] --> O2["输出: 么"]
end
subgraph 第三步
I3["输入: 大模型是怎么"] --> M3[模型] --> O3["输出: 工"]
end
subgraph 第N步
IN["输入: 大模型是怎么工作的"] --> MN[模型] --> ON["输出: EOS"]
end
O1 --> I2
O2 --> I3
O3 --> IN
整个过程是这样的:
-
输入“大模型是”
-
模型计算每个位置的输出,取最后一个位置的输出向量(它对应新 token 的位置)
-
把这个向量乘上一个输出矩阵(通常叫 LM Head),得到词表大小的 logits
-
对 logits 做 Softmax,得到每个词作为下一个 Token 的概率
-
根据概率采样(或直接取概率最高的),选出下一个 Token,比如“怎”
-
把“怎”加到输入后面,变成“大模型是怎”
-
重复以上步骤,直到生成结束标记(EOS)或达到最大长度
为什么一次只生成一个? 因为每个 Token 的生成依赖于之前所有的 Token。推理时下一步的输入包含上一步的输出,所以必须串行。训练时可以并行(teacher forcing),推理时不行,这是训练和推理的一大区别。
这也解释了为什么大模型“打字”是一个字一个字蹦出来的,而不是一下子全部显示。
5.2 KV Cache
你可能发现了上面描述的过程存在一个问题,我们每次生成新 Token 都要重新计算所有前面 Token 的注意力,但前面的 Token 都没变啊,这不是重复计算吗?
没错,这就需要 KV Cache 来做优化。
KV Cache 的思想很简单:把之前已经算好的 K 和 V 缓存起来,新 Token 只需要算自己的 Q、K、V,然后用自己的 Q 和缓存里所有的 K 做注意力,再把自己的 K、V 加入缓存。
flowchart TB
subgraph 第 n 步输入
Xn["x₁ x₂ ... xn"]
end
subgraph 无缓存
A1["计算所有 K, V<br/>(重复计算前 n-1 个)"]
end
subgraph 有 KV Cache
B1["缓存中已有 K₁..Kₙ₋₁, V₁..Vₙ₋₁"]
B2["只计算 xn 的 Q, K, V"]
B3["Qn × (K₁..Kₙ)ᵀ → 注意力"]
end
Xn --> A1
Xn --> B1
B1 --> B2 --> B3
style A1 fill:#fff0f0,stroke:#d00
style B3 fill:#f0fff0,stroke:#0a0
KV Cache 带来的加速是巨大的。没有 Cache 的话,生成长度为 N 的序列,第 n 步要重算整个长度为 n 的序列,单步 attention 是 O(n2) ,总计算量是 O(n3);有了 Cache,每步只算新 token 的 QKV,但 attention 仍要和所有历史 K 做点积,单步是 O(n),总量则降到 O(n2)。
省掉的主要是重复计算历史 K/V 投影这部分。这就像你每次算总和的时候,不是从头重新加一遍,而是在之前的和上加上新的数。
KV Cache 也是显存占用大户。上下文窗口越大,需要缓存的 KV 就越多。这也是为什么大模型很吃显存,不光模型参数要占显存,KV Cache 也要占。
5.3 上下文窗口的物理含义
我们经常听到“4K 上下文”“128K 上下文”“1M 上下文”这样的说法。上下文窗口(Context Window)指的是模型一次能处理的最大 Token 数量。
它主要受三个因素限制:注意力计算量、KV Cache 显存、位置编码的外推能力。
-
注意力计算量:自注意力的计算复杂度是 O(n²),n 是序列长度。序列越长,计算量越大。n 从 4K 到 128K,长度涨 32 倍,注意力计算量增加了 1024 倍。
-
KV Cache 显存:KV Cache 的大小和序列长度成正比。序列越长,需要的显存越多。
粗略估算一下 KV Cache 的显存占用: ,系数 2 是因为要同时缓存 K 和 V。
以一个 7B 模型为例,当前 7B 模型中最具代表性的一组参数是32 层,隐藏层维度 4096,16 位浮点即每元素 2 字节,4K 序列长度即 4096,算下来, ,也就是每 4K Token 约 2GB。128K 上下文则要 64GB,光存 KV 就超过大多数单卡显存。这就是为什么长上下文模型很难做,也很吃显存的原因。
补充一句:上面的估算是标准多头(MHA)的情况。如果模型用 GQA/MQA(多个 Query 头共享一组 K/V),KV Cache 会显著变小,这也是现代长上下文模型的常见做法。
5.4 为什么大模型通常要用 GPU
最后聊一个问题:为什么大模型一定要用 GPU,CPU 不行吗?当然可以,但是太慢了。
核心原因是 Transformer 的绝大部分计算都是矩阵乘法,而 GPU 的设计就是为了并行做大量的矩阵运算。我们知道 CPU 有几个到几十个核心,擅长串行逻辑计算,每个核心都很强,而 GPU 有几千到上万个核心,单核能力相对简单,但是擅长并行的简单计算,并且核心数量多。
矩阵乘法是简单但量大的计算,有大量的乘法和加法,可以高度并行。GPU 的数千个核心可以同时计算矩阵的不同部分,但是 CPU 的几十个核心只能分担一小部分。
这也是为什么显卡的显存大小和算力是大模型的硬约束,显存不够装不下模型,算力不够跑起来太慢。
所以不是 CPU 完全不能跑,而是同样的模型,GPU 能把推理时间大幅度的降低。大模型部署选择 GPU,本质是算力和显存的双重需求,而非是架构上的硬性约束。
小结
让我们再回顾一下大模型是怎么工作的:
输入一段文字,模型一个字一个字地输出回答。看起来像在“思考”,实际上是在根据前面所有的内容,预测下一个最可能的 Token。
输入文本先转成 Token,再转成向量(Embedding + 位置编码),然后经过 N 层 Transformer 解码器(这里以 GPT 类的 decoder-only 架构为例)。每层里,先做自注意力(让每个 Token 和所有其他 Token 交互),再做前馈网络(对每个 Token 的向量做非线性变换)。最后一层最后一个位置的输出经过一个线性层映射到词表大小,Softmax 之后得到每个词的概率,采样得到下一个 Token。
这个过程中,绝大部分计算都是矩阵乘法:,( 是激活函数,如 ReLU、GELU)。这些矩阵的参数(权重)是在训练阶段通过海量语料学习得到的,在推理阶段,权重固定,我们只是用它们来做计算。
理解了这些,你再看各种大模型的参数(层数、维度、头数、上下文窗口),就知道每个参数意味着什么了。你也能理解为什么模型越大越贵、为什么长上下文很难、为什么 GPU 如此重要。
下一篇,我们将在这个基础上,深入探讨 Prompt 工程,当你理解了大模型的工作原理,你就知道该怎么和它说话才能得到最好的效果。