embedding 位置编码 self-attention 的简单学习二

0 阅读22分钟

前言

“模型怎么预测下一个词”这件事的理解还停留在“输入一句话,模型自动补全”的层面。至于内部怎么做的?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

比如(这只是个示例,不同模型的词典不同):

TokenToken ID
57668
38942
中国45231

Token就是LLM世界的货币——你的输入按Token计费,模型的输出按Token计费,上下文长度按Token计算。就是这么现实。


二、痛点与场景:为什么要搞懂这些?

你可能会问:“我就调个API,为什么要搞懂Token、Embedding、注意力机制?”

在实际业务中,不懂底层原理会遇到这些真实痛点

场景不懂原理的后果懂原理的解决方案
计费优化盲目截断文本,丢失关键信息知道中英文Token比例不同,针对性做截断策略
上下文超长报错一脸懵,只会暴力截断知道Tokenization规则,精确计算Token数
模型输出乱码怀疑模型坏了知道是UTF-8字节被错误切分,调整后处理逻辑
追问效果差不知道怎么调试Prompt知道Self-Attention机制,针对性设计上下文关联

面试的时候更直接——面试官问你“Transformer的Self-Attention是怎么算的”,你说“就是让模型关注重要的词”——这种回答等于没回答。

所以,搞懂原理不是为了炫技,而是为了在生产环境里不踩坑,在面试场上不卡壳


三、全流程剖析:从输入到输出,一步不落

现在,我们正式进入LLM的“内部工厂”,看看你输入一句话之后,模型到底做了哪些处理。

我用一个完整的流程图先给大家一个全局视角:

image.png

下面我们一层一层剥开来看。

第一步:Tokenization — 把文字变成编号

用户输入 "中国的首都是",首先进入分词器(Tokenizer)

分词器做的事情很简单:

  1. 按照模型预定义的词元表(Vocabulary),把输入文本切分成Token序列
  2. 把每个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,模型会通过三个不同的线性变换矩阵,生成三个向量:

名称全称作用生活中的比喻
QQuery(查询)"我在找什么?"求职者在看招聘要求
KKey(键)"我能提供什么?"招聘JD写的要求
VValue(值)"我实际贡献什么?"求职者的实际能力

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个位置的词相关性越强

image.png

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参数,就随便调,结果输出要么太随机、要么太死板。

经验值参考

场景TemperatureTop-P
代码生成、数学推理0.1 - 0.30.9 - 1.0
创意写作、头脑风暴0.7 - 1.00.85 - 0.95
翻译、总结摘要0.3 - 0.50.9 - 1.0
多轮对话0.5 - 0.70.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的原因——在长文本场景下效果更好


七、总结与互动

一张图回顾全文

image.png

核心 takeaways

  1. LLM只做一件事:根据已有上下文预测下一个Token
  2. Token不是词:是子词/字符粒度的最小计算单位,理解了Token才算入了门
  3. Embedding是语义空间中的坐标:语义相近的词,向量距离近
  4. Self-Attention是上下文理解的灵魂:通过Q·K点积计算词间相关性
  5. 自回归生成是串行的:一次只生成一个Token,不断重复