前言
“模型怎么预测下一个词”这件事的理解还停留在“输入一句话,模型自动补全”的层面。至于内部怎么做的?Token是什么?位置编码有什么用?Self-Attention到底在算什么?——一问三不知。
说实话,这不能怪大家。市面上90%的文章要么太科普(“就像老师批改作业一样”),要么太学术(满屏的矩阵公式),真正能把技术细节讲清楚、讲透彻的干货太少了。
今天这篇文章,我就带大家手撕LLM推理全流程,从你输入“中国的首都是”到模型输出“北京”,中间到底发生了什么。我会用最通俗的语言 + JavaScript代码注释 + Mermaid流程图,把每个环节都拆解清楚。
看完这篇文章,你可以:
- 跟面试官聊清楚Tokenization、Embedding、位置编码、Self-Attention的核心原理
- 理解为什么大模型“只会做一件事”——预测下一个词
- 在实际开发中避开Token长度超限、乱码输出等常见坑
废话不多说,我们直接开始。
一、核心概念:LLM本质上就是一个“超级猜词游戏”
如果你只记住一句话,那就是:
大语言模型(LLM)从头到尾只会做一件事:根据已有的上下文,预测下一个最可能出现的词元(Token)。
就这么简单。你问它“中国的首都是”,它预测“北京”;你把“北京”加进去变成“中国的首都是北京”,它再预测“。”;你把“。”加进去,它再预测下一个……这就是自回归生成(Autoregressive Generation) 。
整个过程就像你玩“成语接龙”或者输入法的“联想输入”,只不过LLM的“联想”能力被训练到了极致。
Token(词元):大模型世界的“货币”
在聊具体流程之前,我们必须先搞清楚一个最基础的概念——Token。
很多人以为大模型处理的是“单词”或者“汉字”,其实不对。大模型处理的最小单位叫做Token(词元) 。
来看一个例子:
text
"unhappiness" 这个英文单词
❌ 不会当成一个整体 → 模型要记住几十万英文单词
✅ 拆成 "un" + "happi" + "ness" → 模型只需要掌握几万个基础词元
中文也是一样:
text
"我爱人工智能,自然语言处理很有趣"
↓ Tokenization(分词)
['我', '爱', '人工智能', ',', '自然语言处理', '很', '有趣']
💡 为什么用Token而不是完整的词?
如果模型只认完整的词,英文有几十万个单词,中文有几百万个词汇,计算量爆炸。把词切成“子词”(Subword),模型只需要掌握几万个“基础积木”,就能组合出无限种表达。就像乐高一样,基础块越少,组合越多。
每个Token在模型的“词典”(Vocabulary)里都有一个唯一的编号——Token ID。
比如(这只是个示例,不同模型的词典不同):
| Token | Token ID |
|---|---|
| 你 | 57668 |
| 好 | 38942 |
| 中国 | 45231 |
Token就是LLM世界的货币——你的输入按Token计费,模型的输出按Token计费,上下文长度按Token计算。就是这么现实。
二、痛点与场景:为什么要搞懂这些?
你可能会问:“我就调个API,为什么要搞懂Token、Embedding、注意力机制?”
在实际业务中,不懂底层原理会遇到这些真实痛点:
| 场景 | 不懂原理的后果 | 懂原理的解决方案 |
|---|---|---|
| 计费优化 | 盲目截断文本,丢失关键信息 | 知道中英文Token比例不同,针对性做截断策略 |
| 上下文超长报错 | 一脸懵,只会暴力截断 | 知道Tokenization规则,精确计算Token数 |
| 模型输出乱码 | 怀疑模型坏了 | 知道是UTF-8字节被错误切分,调整后处理逻辑 |
| 追问效果差 | 不知道怎么调试Prompt | 知道Self-Attention机制,针对性设计上下文关联 |
面试的时候更直接——面试官问你“Transformer的Self-Attention是怎么算的”,你说“就是让模型关注重要的词”——这种回答等于没回答。
所以,搞懂原理不是为了炫技,而是为了在生产环境里不踩坑,在面试场上不卡壳。
三、全流程剖析:从输入到输出,一步不落
现在,我们正式进入LLM的“内部工厂”,看看你输入一句话之后,模型到底做了哪些处理。
我用一个完整的流程图先给大家一个全局视角:
下面我们一层一层剥开来看。
第一步:Tokenization — 把文字变成编号
用户输入 "中国的首都是",首先进入分词器(Tokenizer) 。
分词器做的事情很简单:
- 按照模型预定义的词元表(Vocabulary),把输入文本切分成Token序列
- 把每个Token映射成对应的Token ID
javascript
// JavaScript 示例:模拟分词过程
class SimpleTokenizer {
constructor() {
// 模拟词表(真实场景会使用预训练模型的vocab.json)
this.vocab = {
'中国': 45231,
'的': 3412,
'首都': 8832,
'是': 1234,
'北京': 9876,
'。': 5678
};
// 反向映射
this.idToToken = Object.fromEntries(
Object.entries(this.vocab).map(([k, v]) => [v, k])
);
}
// 模拟tokenize(真实场景使用BPE或SentencePiece算法)
tokenize(text) {
// 注意:真实的tokenization会使用子词切分算法
// 这里只是模拟演示
const tokens = [];
// 简单示范:如果是"中国的首都是",切分成['中国','的','首都','是']
if (text === '中国的首都是') {
return ['中国', '的', '首都', '是'];
}
// 实际场景中会使用更复杂的算法
return text.split('');
}
// 将token转换为token ID
convertTokensToIds(tokens) {
return tokens.map(token => {
const id = this.vocab[token];
if (id === undefined) {
throw new Error(`未知Token: ${token}`);
}
return id;
});
}
// 编码:文本 → Token IDs
encode(text) {
const tokens = this.tokenize(text);
return this.convertTokensToIds(tokens);
}
// 解码:Token IDs → 文本
decode(tokenIds) {
return tokenIds.map(id => this.idToToken[id] || '<UNK>').join('');
}
}
// 使用示例
const tokenizer = new SimpleTokenizer();
const text = '中国的首都是';
const tokenIds = tokenizer.encode(text);
console.log('Token IDs:', tokenIds);
// 输出示例:[45231, 3412, 8832, 1234]
// 注意:不同模型的分词结果可能不同
console.log('解码回文本:', tokenizer.decode(tokenIds));
// 输出:中国的是首都?(顺序不对,因为这是模拟)
⚠️ 新手坑预警:
很多新手以为“一个汉字 = 一个Token”,这是错的!
- 英文:1个Token ≈ 4个字符(约0.75个单词)
- 中文:1个Token ≈ 1.5~2个汉字
所以,同样是1000个Token,中文能表达的内容比英文更多,但计费是按Token数来的——中文更“省钱” ,但是上下文窗口能容纳的信息量也是中文更大。
第二步:Embedding — 给编号赋予“语义”
Token ID只是数字编号,比如45231,它本身没有任何语义含义。模型要理解这个词,需要把它转换成一个高维向量——这就是Embedding(词嵌入) 。
🎯 生活中的比喻:
Token ID就像图书馆里一本书的索书号(比如I247.5/1234),你拿着这个号去书架找书。
Embedding就像这本书的内容摘要——它包含了这本书讲了什么、风格如何、跟哪些书相似等信息。
大模型的Embedding Matrix(嵌入矩阵)就像整个图书馆的索引系统,每一本书(Token)都对应一份详细的摘要(向量)。
javascript
// JavaScript 示例:Embedding查表过程
class EmbeddingLayer {
constructor(vocabSize, embeddingDim) {
// vocabSize: 词表大小,比如 100000
// embeddingDim: 向量维度,比如 4096(7B模型)或 12288(175B模型)
this.vocabSize = vocabSize;
this.embeddingDim = embeddingDim;
// 模拟嵌入矩阵(真实场景中是预训练好的权重)
// [vocabSize, embeddingDim]
this.embeddingMatrix = this.initializeEmbeddings();
}
// 随机初始化嵌入矩阵(实际场景中是训练好的)
initializeEmbeddings() {
const matrix = [];
for (let i = 0; i < this.vocabSize; i++) {
const row = [];
for (let j = 0; j < this.embeddingDim; j++) {
// 使用小的随机数初始化
row.push(Math.random() * 0.01);
}
matrix.push(row);
}
return matrix;
}
// 前向传播:Token IDs → Embeddings
forward(tokenIds) {
// tokenIds: [45231, 3412, 8832, 1234]
const embeddings = [];
for (const tokenId of tokenIds) {
if (tokenId >= this.vocabSize) {
throw new Error(`Token ID ${tokenId} 超出词表范围`);
}
// 直接去"柜子"里把对应编号的向量抽出来
embeddings.push(this.embeddingMatrix[tokenId]);
}
return embeddings;
// 返回形状: [sequenceLength, embeddingDim]
}
}
// 使用示例
const vocabSize = 100000;
const embeddingDim = 4096;
const embedLayer = new EmbeddingLayer(vocabSize, embeddingDim);
const tokenIds = [45231, 3412, 8832, 1234];
const embeddings = embedLayer.forward(tokenIds);
console.log('Embeddings形状:', embeddings.length, 'x', embeddings[0].length);
// 输出: 4 x 4096
Embedding的精妙之处在于:
语义相近的词,在高维空间中的距离比较近。
text
"国王" - "男性" + "女性" ≈ "王后"
"苹果" 和 "香蕉" 的距离很近(都是水果)
"苹果" 和 "汽车" 的距离很远
模型的预训练过程,本质上就是在构建这个高维语义空间坐标系——让意思相近的词向量聚集在一起,意思不同的词向量相互远离。
第三步:位置编码 — 给向量加上“顺序标签”
现在,每个Token都有了语义向量。
但是有一个问题:Embedding本身不包含位置信息。
"我咬了狗" 和 "狗咬了我" — 同样的三个Token,顺序不同,意思完全相反。
如果不加位置信息,模型会把它们当成完全一样的输入。
🎯 生活中的比喻:
Embedding告诉你“房间里有哪些人”,但没告诉你“谁站在哪个位置”。
位置编码(Positional Encoding)就是给每个人贴上一个站位标签——“我在1号位”、“我在2号位”……
javascript
// JavaScript 示例:位置编码实现(Transformer原版正弦波编码)
class PositionalEncoding {
constructor(maxSeqLength, embeddingDim) {
this.maxSeqLength = maxSeqLength;
this.embeddingDim = embeddingDim;
// 预计算位置编码矩阵
this.positionalEncodings = this.computeEncodings();
}
// 计算位置编码(使用正弦/余弦函数)
computeEncodings() {
const encodings = [];
for (let pos = 0; pos < this.maxSeqLength; pos++) {
const encoding = [];
for (let i = 0; i < this.embeddingDim; i++) {
// 使用不同频率的正弦/余弦波
// 偶数维度用sin,奇数维度用cos
const angle = pos / Math.pow(10000, (2 * i) / this.embeddingDim);
if (i % 2 === 0) {
encoding.push(Math.sin(angle));
} else {
encoding.push(Math.cos(angle));
}
}
encodings.push(encoding);
}
return encodings;
}
// 获取指定位置的编码
getEncoding(position) {
if (position >= this.maxSeqLength) {
throw new Error(`位置 ${position} 超出最大序列长度 ${this.maxSeqLength}`);
}
return this.positionalEncodings[position];
}
// 将位置编码叠加到Embedding上
addToEmbeddings(embeddings) {
// embeddings: [seqLen, embedDim]
const result = [];
for (let i = 0; i < embeddings.length; i++) {
const posEncoding = this.getEncoding(i);
// 直接相加
const combined = embeddings[i].map(
(value, j) => value + posEncoding[j]
);
result.push(combined);
}
return result;
}
}
// 使用示例
const maxSeqLen = 512;
const embedDim = 4096;
const posEncoding = new PositionalEncoding(maxSeqLen, embedDim);
// 假设我们已有embeddings
const embeddings = [[...Array(embedDim).fill(0.1)], [...Array(embedDim).fill(0.2)]];
const finalEmbeddings = posEncoding.addToEmbeddings(embeddings);
// 最终每个Token携带两类信息:
// 1. 语义信息(Embedding):这个Token是什么?
// 2. 位置信息(Positional Encoding):这个Token在句子的哪个位置?
console.log('叠加位置编码后的向量形状:', finalEmbeddings.length, 'x', finalEmbeddings[0].length);
实际工程中,位置编码的实现有多种方式:
- Transformer原版:使用正弦/余弦函数生成固定编码
- GPT系列:使用可学习的位置编码(Learned Positional Encoding)
- RoPE(旋转位置编码) :目前很多新模型(如LLaMA、Qwen)采用,通过旋转矩阵来编码相对位置
💡 不管哪种实现,核心目标都一样:让模型知道每个词在句子里的顺序。
第四步:Self-Attention — 模型如何理解“上下文”?
这一步是整个Transformer架构的灵魂,也是最难理解的部分。我们慢慢拆。
前面我们说过,每个Token变成了一个向量。但这些向量目前是孤立的——每个词只知道自己是什么、在哪个位置,不知道其他词跟自己的关系。
模型需要一种机制来建模词与词之间的依赖关系。
🎯 生活中的比喻:
你在读这句话:
"那只动物没有过马路,因为它太累了。"读到"它"的时候,你会自动联想到"动物",而不是"马路"。
Self-Attention做的就是这件事——让模型自己学会判断,当前这个词应该"注意"句子中的哪些其他词。
4.1 Q、K、V 三剑客
对于每一个Token,模型会通过三个不同的线性变换矩阵,生成三个向量:
| 名称 | 全称 | 作用 | 生活中的比喻 |
|---|---|---|---|
| Q | Query(查询) | "我在找什么?" | 求职者在看招聘要求 |
| K | Key(键) | "我能提供什么?" | 招聘JD写的要求 |
| V | Value(值) | "我实际贡献什么?" | 求职者的实际能力 |
javascript
// JavaScript 示例:计算Q、K、V
class SelfAttention {
constructor(embedDim) {
this.embedDim = embedDim;
// 三个线性变换矩阵,在训练过程中学习
// 这里用随机矩阵模拟(实际场景中是训练好的权重)
this.Wq = this.initializeMatrix(embedDim, embedDim);
this.Wk = this.initializeMatrix(embedDim, embedDim);
this.Wv = this.initializeMatrix(embedDim, embedDim);
}
// 初始化矩阵(模拟)
initializeMatrix(rows, cols) {
const matrix = [];
for (let i = 0; i < rows; i++) {
const row = [];
for (let j = 0; j < cols; j++) {
row.push(Math.random() * 0.01);
}
matrix.push(row);
}
return matrix;
}
// 矩阵乘法(简化版)
matMul(matrix, vector) {
// matrix: [rows, cols], vector: [cols]
const result = [];
for (let i = 0; i < matrix.length; i++) {
let sum = 0;
for (let j = 0; j < matrix[i].length; j++) {
sum += matrix[i][j] * vector[j];
}
result.push(sum);
}
return result;
}
// 前向传播:生成 Q, K, V
forward(embeddings) {
// embeddings: [seqLen, embedDim]
const Q = embeddings.map(vec => this.matMul(this.Wq, vec));
const K = embeddings.map(vec => this.matMul(this.Wk, vec));
const V = embeddings.map(vec => this.matMul(this.Wv, vec));
return { Q, K, V };
}
}
// 使用示例
const embedDim = 4096;
const attention = new SelfAttention(embedDim);
// 假设有4个Token的embeddings
const embeddings = [
Array(embedDim).fill(0.1),
Array(embedDim).fill(0.2),
Array(embedDim).fill(0.15),
Array(embedDim).fill(0.25)
];
const { Q, K, V } = attention.forward(embeddings);
console.log('Q, K, V 形状:', Q.length, 'x', Q[0].length);
4.2 注意力分数的计算
对于句子中的每一个位置,模型会计算它跟所有其他位置的注意力分数。
具体的计算方式是:Q 和 K 做点积(Dot Product) 。
text
score(Q_i, K_j) = Q_i · K_j
点积的结果越大,说明两个向量越相似,也就是第i个位置的词和第j个位置的词相关性越强。
javascript
// JavaScript 示例:完整的Self-Attention计算
class CompleteSelfAttention {
constructor(embedDim) {
this.embedDim = embedDim;
this.Wq = this.initializeMatrix(embedDim, embedDim);
this.Wk = this.initializeMatrix(embedDim, embedDim);
this.Wv = this.initializeMatrix(embedDim, embedDim);
}
initializeMatrix(rows, cols) {
const matrix = [];
for (let i = 0; i < rows; i++) {
const row = [];
for (let j = 0; j < cols; j++) {
row.push(Math.random() * 0.01);
}
matrix.push(row);
}
return matrix;
}
matMul(matrix, vector) {
const result = [];
for (let i = 0; i < matrix.length; i++) {
let sum = 0;
for (let j = 0; j < matrix[i].length; j++) {
sum += matrix[i][j] * vector[j];
}
result.push(sum);
}
return result;
}
// 点积
dotProduct(vec1, vec2) {
let sum = 0;
for (let i = 0; i < vec1.length; i++) {
sum += vec1[i] * vec2[i];
}
return sum;
}
// Softmax
softmax(scores) {
const expScores = scores.map(s => Math.exp(s));
const sumExp = expScores.reduce((a, b) => a + b, 0);
return expScores.map(s => s / sumExp);
}
// 缩放点积注意力
scaledDotProductAttention(Q, K, V) {
// Q, K, V: [seqLen, embedDim]
const seqLen = Q.length;
const dk = Q[0].length;
// 1. 计算注意力分数: Q · K^T
const scores = [];
for (let i = 0; i < seqLen; i++) {
const row = [];
for (let j = 0; j < seqLen; j++) {
// 点积后除以 sqrt(dk) 进行缩放,防止梯度消失
const score = this.dotProduct(Q[i], K[j]) / Math.sqrt(dk);
row.push(score);
}
scores.push(row);
}
// scores: [seqLen, seqLen]
// 2. 对每个query进行softmax
const attentionWeights = scores.map(row => this.softmax(row));
// attentionWeights: [seqLen, seqLen]
// 3. 加权求和: attentionWeights · V
const output = [];
for (let i = 0; i < seqLen; i++) {
const outVec = Array(V[0].length).fill(0);
for (let j = 0; j < seqLen; j++) {
for (let k = 0; k < V[0].length; k++) {
outVec[k] += attentionWeights[i][j] * V[j][k];
}
}
output.push(outVec);
}
return { scores, attentionWeights, output };
}
forward(embeddings) {
// 生成Q, K, V
const Q = embeddings.map(vec => this.matMul(this.Wq, vec));
const K = embeddings.map(vec => this.matMul(this.Wk, vec));
const V = embeddings.map(vec => this.matMul(this.Wv, vec));
// 计算注意力
return this.scaledDotProductAttention(Q, K, V);
}
}
// 使用示例
const attention = new CompleteSelfAttention(embedDim);
// 模拟4个Token的输入
const testEmbeddings = [
Array(embedDim).fill(0).map((_, i) => 0.1 + i * 0.001),
Array(embedDim).fill(0).map((_, i) => 0.2 + i * 0.001),
Array(embedDim).fill(0).map((_, i) => 0.15 + i * 0.001),
Array(embedDim).fill(0).map((_, i) => 0.25 + i * 0.001)
];
const result = attention.forward(testEmbeddings);
console.log('注意力权重矩阵形状:', result.attentionWeights.length, 'x', result.attentionWeights[0].length);
// 输出: 4 x 4
console.log('输出向量形状:', result.output.length, 'x', result.output[0].length);
// 输出: 4 x 4096
继续用"它"的例子:
text
输入: "The animal didn't cross the street because it was too tired."
↑
关注哪个词?
Token "it" 的 Q 跟所有Token的 K 做点积:
- Q(it) · K(animal) = 高分 → "it" 和 "animal" 相关性强 ✅
- Q(it) · K(street) = 低分 → "it" 和 "street" 相关性弱 ❌
模型学到了:"it" 应该主要关注 "animal"
4.3 加权求和得到最终输出
得到所有注意力分数后,经过Softmax归一化成概率(所有分数加起来=1),然后用这些概率对V(Value)做加权求和。
text
最终输出 = Σ (注意力权重_i × V_i)
这个最终输出就是融合了上下文信息后的Token表示——"it"这个词的最终向量里,包含了"animal"的大量语义信息。
💡 核心领悟:
Self-Attention的本质就是让每个Token主动去"看"其他所有Token,根据相关性高低汲取信息,最终得到"知己知彼"的上下文表示。
第五步:输出与采样 — 模型如何"决定"下一个词?
经过N层Transformer的处理(GPT-3有96层),每个位置的Token向量已经包含了丰富的上下文信息。
最后,模型通过一个输出层(通常是线性层 + Softmax),计算词典中每个Token作为下一个词的概率。
javascript
// JavaScript 示例:输出层计算
class OutputLayer {
constructor(embedDim, vocabSize) {
this.embedDim = embedDim;
this.vocabSize = vocabSize;
// 输出投影矩阵
this.Wout = this.initializeMatrix(vocabSize, embedDim);
}
initializeMatrix(rows, cols) {
const matrix = [];
for (let i = 0; i < rows; i++) {
const row = [];
for (let j = 0; j < cols; j++) {
row.push(Math.random() * 0.01);
}
matrix.push(row);
}
return matrix;
}
matMul(matrix, vector) {
const result = [];
for (let i = 0; i < matrix.length; i++) {
let sum = 0;
for (let j = 0; j < matrix[i].length; j++) {
sum += matrix[i][j] * vector[j];
}
result.push(sum);
}
return result;
}
softmax(scores) {
const maxScore = Math.max(...scores);
const expScores = scores.map(s => Math.exp(s - maxScore)); // 数值稳定性
const sumExp = expScores.reduce((a, b) => a + b, 0);
return expScores.map(s => s / sumExp);
}
forward(finalEmbeddings) {
// finalEmbeddings: [seqLen, embedDim]
// 通常取最后一个位置的输出作为下一个词的预测
const lastEmbedding = finalEmbeddings[finalEmbeddings.length - 1];
// 线性变换:embeddingDim → vocabSize
const logits = this.matMul(this.Wout, lastEmbedding);
// logits: [vocabSize]
// Softmax:转换成概率分布
const probabilities = this.softmax(logits);
return probabilities;
}
}
以 "中国的首都是" 为例,模型输出的概率分布可能是:
| Token | 概率 |
|---|---|
| 北京 | 92% |
| 上海 | 3% |
| 北平 | 2% |
| 南京 | 1% |
| ... | ... |
采样策略:不只是"选概率最大的"
很多新手以为模型就是每次都选概率最高的词,这叫贪婪解码(Greedy Decoding) 。
但在实际生产中,我们通常使用采样(Sampling) 策略,让输出具有多样性:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| Greedy | 每次都选概率最高的 | 需要确定性输出的场景 |
| Top-K | 只在概率最高的K个词中采样 | 平衡质量和多样性 |
| Top-P (Nucleus) | 累积概率达到P的候选词中采样 | 动态调整候选集大小 |
| Temperature | 调整概率分布的"尖锐度" | temperature越高越随机 |
javascript
// JavaScript 示例:不同采样策略
class SamplingStrategy {
// Greedy解码:直接选概率最高的
static greedy(probabilities) {
let maxProb = -1;
let maxIdx = 0;
for (let i = 0; i < probabilities.length; i++) {
if (probabilities[i] > maxProb) {
maxProb = probabilities[i];
maxIdx = i;
}
}
return maxIdx;
}
// Top-K采样
static topK(probabilities, k = 50) {
// 构建带索引的概率数组
const indexed = probabilities.map((p, i) => ({ prob: p, index: i }));
// 按概率降序排序
indexed.sort((a, b) => b.prob - a.prob);
// 只保留前K个
const topK = indexed.slice(0, k);
// 重新归一化
const sum = topK.reduce((acc, item) => acc + item.prob, 0);
const normalized = topK.map(item => ({
prob: item.prob / sum,
index: item.index
}));
// 按归一化概率采样
return this.sampleFrom(normalized);
}
// Top-P (Nucleus) 采样
static topP(probabilities, p = 0.9) {
const indexed = probabilities.map((p, i) => ({ prob: p, index: i }));
indexed.sort((a, b) => b.prob - a.prob);
// 累积概率达到p
let cumSum = 0;
const selected = [];
for (const item of indexed) {
cumSum += item.prob;
selected.push(item);
if (cumSum >= p) break;
}
// 重新归一化
const sum = selected.reduce((acc, item) => acc + item.prob, 0);
const normalized = selected.map(item => ({
prob: item.prob / sum,
index: item.index
}));
return this.sampleFrom(normalized);
}
// 按概率采样
static sampleFrom(distribution) {
const rand = Math.random();
let cumSum = 0;
for (const item of distribution) {
cumSum += item.prob;
if (rand <= cumSum) {
return item.index;
}
}
return distribution[distribution.length - 1].index;
}
// Temperature采样:调整概率分布的"尖锐度"
static applyTemperature(probabilities, temperature = 0.7) {
if (temperature <= 0) {
// temperature=0 等同于 greedy
return this.greedy(probabilities);
}
// 对logits应用temperature,这里直接对概率取幂再归一化
// 注意:实际场景中应该对logits应用temperature,这里简化
const adjusted = probabilities.map(p => Math.pow(p, 1 / temperature));
const sum = adjusted.reduce((a, b) => a + b, 0);
const normalized = adjusted.map(p => p / sum);
// 用调整后的概率进行采样
return this.sampleFrom(normalized.map((p, i) => ({ prob: p, index: i })));
}
}
// 使用示例
const vocabSize = 100000;
const mockProbabilities = Array(vocabSize).fill(0).map((_, i) => {
// 模拟几个高概率的词
if (i === 9876) return 0.92; // "北京"
if (i === 4523) return 0.03; // "上海"
if (i === 6789) return 0.02; // "北平"
if (i === 3456) return 0.01; // "南京"
return 0.0002; // 其他词
});
// Greedy
const greedyIdx = SamplingStrategy.greedy(mockProbabilities);
console.log('Greedy选择:', greedyIdx); // 输出: 9876
// Top-K
const topKIdx = SamplingStrategy.topK(mockProbabilities, 50);
console.log('Top-K选择:', topKIdx);
// Top-P
const topPIdx = SamplingStrategy.topP(mockProbabilities, 0.9);
console.log('Top-P选择:', topPIdx);
// Temperature
const tempIdx = SamplingStrategy.applyTemperature(mockProbabilities, 0.8);
console.log('Temperature采样:', tempIdx);
生成完第一个词"北京"后,模型把"北京"加到输入里:
text
"中国的首都是北京" → 预测下一个 → ","
"中国的首都是北京," → 预测下一个 → 可能是"是"或者"。" ...
不断重复这个过程,直到模型输出<EOS>(结束标记)或达到最大长度限制。
这就是自回归生成的全部秘密——每次只多生成一个Token,然后把新Token喂回模型,循环往复。
四、重难点剖析:三个最容易被问倒的核心问题
重难点一:为什么Embedding能表达"语义"?
很多人的误解:以为Embedding矩阵就是一个"查表",跟查字典一样。
实际上:Embedding矩阵是在预训练过程中通过海量文本逐步学习出来的。模型在训练时,通过预测下一个词这个任务,反向传播更新Embedding矩阵里的每个数值。
为什么这么设计?
因为"预测下一个词"这个任务,迫使模型去理解语法、语义、逻辑关系。为了让预测准确,模型必须把语义相近的词放在向量空间中相近的位置——"苹果"和"香蕉"要靠近,"吃"和"喝"要在类似的语义方向上。
攻克思路:
理解Embedding的本质,可以类比成Word2Vec的升级版——只不过Word2Vec是在一个浅层网络上训练,而大模型的Embedding是在数百亿参数的Transformer上联合训练的,所以语义表达能力要强得多。
重难点二:Self-Attention的时间复杂度为什么是O(n²)?
核心问题:Self-Attention需要计算每个Token跟所有其他Token的注意力分数。
如果序列长度是n,就需要计算n×n次点积,时间复杂度就是O(n²) 。
这就是为什么:
- 处理长文本时,计算量会平方级增长
- 上下文窗口从4K扩展到128K,计算量增加约1000倍
攻克思路:
记住这个公式:复杂度 = O(n² × d),其中n是序列长度,d是向量维度。
这也是为什么业界在研究稀疏注意力(Sparse Attention) 、线性注意力(Linear Attention) 等技术——就是为了打破O(n²)的魔咒。
面试回答模板:
Self-Attention的复杂度是O(n²),因为需要计算n个Query跟n个Key的点积,产生n×n的注意力矩阵。这也是为什么大模型处理超长上下文时成本会急剧上升。
重难点三:为什么说LLM"没有真正的推理能力"?
这是面试中一个很刁钻的问题。
LLM通过自回归生成,每一步都在"猜"下一个词。它本质上是一个超大规模的概率模型——它不是"思考"出答案,而是根据训练数据中学到的模式,计算出最可能出现的词序列。
🎯 理解关键:
LLM就像一个超级维基百科速记员——它记住了海量的文本模式,但不会"推理"。
当你问"1+1等于几"时,它不是在做计算,而是它在训练数据里见过无数遍"1+1=2",所以它能"猜"出来。
当你问"一个全新的、从未出现过的问题"时,LLM就可能瞎编——这就是幻觉(Hallucination) 的来源。
攻克思路:
理解LLM的能力边界,可以帮助我们在业务中:
- 不把LLM当"逻辑推理引擎",而是当"模式匹配和生成引擎"
- 对需要精确计算的场景,配合工具调用(Function Calling) 使用计算器
- 对需要事实性的场景,配合RAG(检索增强生成) 使用外部知识库
五、避坑指南:新手最常踩的5个坑
坑1:Token长度计算错误
错误做法:
javascript
// ❌ 用字符数估算Token数
const textLen = inputText.length;
if (textLen > 4000) {
inputText = inputText.substring(0, 4000);
}
正确做法:
javascript
// ✅ 使用模型的分词器精确计算(以HuggingFace为例)
// 注意:@huggingface/transformers 需要安装
// npm install @huggingface/transformers
import { AutoTokenizer } from '@huggingface/transformers';
async function calculateTokenCount(text) {
const tokenizer = await AutoTokenizer.fromPretrained('Qwen/Qwen-7B');
const tokens = await tokenizer.encode(text);
return tokens.length;
}
// 或者在Node.js中使用其他库
// 如果无法使用官方分词器,可以用tiktoken(OpenAI的分词器)
// npm install tiktoken
import { encodingForModel } from 'tiktoken';
function getTokenCount(text, modelName = 'gpt-4') {
const encoder = encodingForModel(modelName);
const tokens = encoder.encode(text);
return tokens.length;
}
// 使用
const text = '中国的首都是';
const tokenCount = getTokenCount(text);
console.log('Token数量:', tokenCount);
坑2:输出乱码
原因:中文字符在UTF-8中占3个字节,如果Tokenization时把多字节字符切碎了,解码就会出问题。
解决方案:使用模型自带的分词器进行编码和解码,不要自己手动拼接字符串。
javascript
// ❌ 错误:手动拼接可能产生乱码
function decodeTokens(tokenIds) {
let output = '';
for (const id of tokenIds) {
output += String.fromCharCode(id); // 完全错误
}
return output;
}
// ✅ 正确:使用分词器的decode方法
function decodeTokens(tokenizer, tokenIds) {
// 使用分词器的decode方法
return tokenizer.decode(tokenIds, { skipSpecialTokens: true });
}
坑3:忽略"特殊Token"
很多模型有特殊Token,比如:
<|im_start|>、<|im_end|>(ChatML格式)[CLS]、[SEP](BERT系列)<s>、</s>(某些LLaMA变体)
新手坑:拼接Prompt时漏掉这些特殊Token,导致模型输出异常。
解决方案:使用模型的applyChatTemplate方法,让分词器自动处理。
javascript
// ✅ 推荐方式:使用chat模板
const messages = [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: '中国的首都是哪里?' }
];
// 使用applyChatTemplate(具体API取决于使用的库)
const prompt = tokenizer.applyChatTemplate(messages, {
tokenize: false,
addGenerationPrompt: true
});
坑4:Temperature和Top-P乱调
很多新手看到有Temperature和Top-P参数,就随便调,结果输出要么太随机、要么太死板。
经验值参考:
| 场景 | Temperature | Top-P |
|---|---|---|
| 代码生成、数学推理 | 0.1 - 0.3 | 0.9 - 1.0 |
| 创意写作、头脑风暴 | 0.7 - 1.0 | 0.85 - 0.95 |
| 翻译、总结摘要 | 0.3 - 0.5 | 0.9 - 1.0 |
| 多轮对话 | 0.5 - 0.7 | 0.9 - 0.95 |
坑5:把"概率"当"置信度"
致命误解:很多新手看到模型输出某个词的概率是92%,就觉得模型"非常确定"。
真相:模型输出的概率分布是相对概率,不是绝对置信度。
- 如果所有候选词的概率都差不多(比如40个词各占2.5%),说明模型不确定,但softmax强行归一化后,最大的那个"只有"2.5%——这种时候输出很可能是随机的。
- 如果某一个词的概率压倒性高(92%),但这个词是"北京",而正确答案应该是"上海"——说明模型学到了一个有偏差的模式,不是"确定"。
建议:可以查看候选概率的熵(Entropy) 来判断模型的不确定性——熵越高,模型越不确定。
javascript
// 计算概率分布的熵
function calculateEntropy(probabilities) {
let entropy = 0;
for (const p of probabilities) {
if (p > 0) {
entropy -= p * Math.log2(p);
}
}
return entropy;
}
// 使用
const entropy = calculateEntropy(mockProbabilities);
console.log('熵值:', entropy);
// 熵值越高,模型越不确定
六、面试高频考点:3个必问的底层原理
考点1:为什么Self-Attention可以并行计算,而RNN不能?
精炼回答:
RNN是串行计算的,每个时间步的输出依赖于上一个时间步的隐状态,所以无法并行。
Self-Attention是全连接计算的,所有位置的Q、K、V可以一次性计算出来,然后通过矩阵乘法一次性得到所有位置的注意力分数。没有时间依赖关系,所以可以完全并行化。
这也是Transformer能够取代RNN成为主流架构的核心原因之一——训练速度上碾压RNN。
考点2:什么是"因果掩码"(Causal Masking)?为什么需要它?
精炼回答:
在自回归语言模型中,预测第t个位置时,只能看到前t-1个位置的Token,不能"偷看"后面的词。
因果掩码就是在计算注意力分数时,把当前位置之后的所有位置的分数设为负无穷(-∞),这样Softmax之后这些位置的注意力权重就变成0了。
javascript
// JavaScript 示例:因果掩码
function createCausalMask(seqLen) {
// 创建上三角矩阵,为-∞,下三角为0
const mask = [];
for (let i = 0; i < seqLen; i++) {
const row = [];
for (let j = 0; j < seqLen; j++) {
if (j > i) {
row.push(-Infinity); // 看不到未来
} else {
row.push(0); // 能看到自己和过去
}
}
mask.push(row);
}
return mask;
}
// 假设序列长度为5
const mask = createCausalMask(5);
console.log('因果掩码矩阵:');
// 输出:
// [0, -∞, -∞, -∞, -∞] 位置0只能看自己
// [0, 0, -∞, -∞, -∞] 位置1能看0和1
// [0, 0, 0, -∞, -∞] 位置2能看0,1,2
// [0, 0, 0, 0, -∞] 位置3能看0,1,2,3
// [0, 0, 0, 0, 0] 位置4能看全部
考点3:为什么现在很多模型用RoPE(旋转位置编码)替代原版的位置编码?
精炼回答:
原版Transformer的绝对位置编码只告诉模型"每个Token在哪个位置",但对于"两个Token之间的相对距离"没有显式建模。
RoPE通过旋转矩阵把相对位置信息直接编码到Q和K的內积中,使得:
- 绝对位置信息仍然保留
- 相对位置信息被显式地建模(两个Token距离越远,內积衰减越大)
- 对长序列的泛化能力更强
这也是为什么很多新模型(LLaMA、Qwen、ChatGLM等)都采用RoPE的原因——在长文本场景下效果更好。
七、总结与互动
一张图回顾全文
核心 takeaways
- LLM只做一件事:根据已有上下文预测下一个Token
- Token不是词:是子词/字符粒度的最小计算单位,理解了Token才算入了门
- Embedding是语义空间中的坐标:语义相近的词,向量距离近
- Self-Attention是上下文理解的灵魂:通过Q·K点积计算词间相关性
- 自回归生成是串行的:一次只生成一个Token,不断重复