找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2074

积分

0

好友

274

主题
发表于 1 小时前 | 查看: 3| 回复: 0

本文将深入探索大语言模型(LLM)的内部实现,以直观理解其工作机制。为辅助本次探究,我们将使用llama.cpp的源码——这是Meta公司LLaMA模型的纯C++实现。我个人发现,llama.cpp是深入理解LLM的绝佳学习工具,其代码简洁清晰、直指核心,没有过多抽象层的设计。本文的研究重点为LLM的推理环节,即已完成训练的模型如何基于用户提示词生成响应

全文将完整讲解LLM的推理流程,涵盖以下核心内容:

  • 张量(Tensors):简要介绍如何通过张量执行数学运算,以及张量运算向GPU的卸载逻辑。
  • Tokenization:将用户提示词拆分为token序列的过程,token是LLM的输入单位。
  • Embedding:将token转换为向量表示的过程。
  • 变换器(The Transformer):LLM架构的核心模块,负责实际的推理计算,本文将重点讲解自注意力机制(self-attention mechanism)。
  • 采样(Sampling):选择模型预测的下一个token的过程,本文将介绍两种采样方法。
  • KV缓存(The KV cache):用于提升长提示词推理速度的常用优化技术,本文将讲解一种基础的KV缓存实现方案。

从提示词到输出的高层级流程

作为一款大语言模型,LLaMA的工作逻辑是接收输入文本(即提示词),并预测后续应该出现的token(或单词)。

为便于说明,我们以维基百科中关于量子力学的首句作为示例提示词:

Quantum mechanics is a fundamental theory in physics that

LLaMA会根据其训练所学习到的语言规律,以最符合逻辑的方式续写这句话。通过llama.cpp运行后,得到的续写内容为:

provides insights into how matter and energy behave at the atomic scale.

我们首先解析这一过程的高层级流程。从核心逻辑来看,LLM每次仅能预测一个token,完整句子(或更长文本)的生成,是通过将模型的输出token不断追加到原提示词后,反复调用LLM模型实现的。这类模型被称为自回归模型(autoregressive model)。因此,本文的研究重点将围绕单个token的生成过程展开,相关流程可参考下方的高层级示意图:

Transformer模型推理完整流程图

从用户提示词生成单个token的完整流程包含TokenizationEmbedding、Transformer神经网络计算、采样等多个阶段,本文将逐一详解。

结合示意图,具体流程如下:

  1. Tokenization器将提示词拆分为token序列,根据模型的词汇表(vocabulary),部分单词可能会被拆分为多个token,每个token对应一个唯一的数字标识。
  2. 每个数字形式的token会被转换为一个Embedding向量(embedding),Embedding向量是固定维度的向量,其表示形式更便于LLM进行计算。所有token的Embedding向量共同构成一个Embedding矩阵。
  3. Embedding矩阵作为Transformer的输入,Transformer是作为LLM核心的神经网络,由多个层级串联组成。每一层都会接收一个输入矩阵,并结合模型参数执行各类数学运算(其中最核心的是自注意力机制),该层的输出将作为下一层的输入。
  4. 一个最终的神经网络会将Transformer的输出转换为对数几率(logits),每个可能的后续token都对应一个logit值,该值代表该token作为句子“正确”续写的概率。
  5. 采用某一种采样技术,从所有logit对应的token中选择下一个token。
  6. 选中的token将作为输出返回。若要继续生成后续token,需将该token追加至步骤1得到的token序列中,重复上述整个流程。这一过程会持续进行,直到生成指定数量的token,或LLM输出特殊的流结束标识(end-of-stream,EOS)token。

在后续章节中,我们将逐一深入解析每个步骤的细节。在此之前,我们需要先熟悉张量(tensor)的相关知识。

基于ggml理解张量

张量是神经网络中执行数学运算的核心数据结构。llama.cpp采用了ggml——一款纯C++实现的张量库,其功能与Python生态中的PyTorch、Tensorflow等价。本文将通过ggml解析张量的运行机制。

张量代表一个多维数字数组,可以存储单个数值、向量(一维数组)、矩阵(二维数组),甚至三维或四维数组,更高维度的张量在实际应用中基本不会用到。

需要明确区分两种类型的张量:一种是存储实际数据的张量,其内部包含多维数字数组;另一种是仅表示张量间运算结果的张量,这类张量在实际计算前不会存储任何数据。本文后续将详细解析二者的区别。

张量的基本结构

在ggml中,张量由ggml_tensor结构体表示,为便于理解,本文对其做了简化处理,核心结构如下:

// ggml.h
struct ggml_tensor {
    enum ggml_type     type;
    enum ggml_backend  backend;

    int      n_dims;
    // 元素数量
    int64_t ne[GGML_MAX_DIMS];
    // 按字节计算的步长
    size_t   nb[GGML_MAX_DIMS];

    enum ggml_op       op;

    struct ggml_tensor * src[GGML_MAX_SRC];

    void   * data;

    char     name[GGML_MAX_NAME];
};

前几个字段的含义较为直观:

  • type:存储张量元素的基本数据类型,例如GGML_TYPE_F32表示每个元素为32位浮点数。
  • backend:标识张量的计算后端,可选CPU或GPU,后续将详细讲解。
  • n_dims:张量的维度数,取值范围为1到4。
  • ne:存储张量每个维度的元素数量,ggml采用行优先的存储顺序,即ne[0]表示每行的元素数量,ne[1]表示每列的元素数量,以此类推。
  • nb:该字段的设计更为精巧,存储张量每个维度的步长(即同一维度中相邻元素之间的字节数)。对于第一维度,步长为单个元素的字节大小;第二维度的步长为行大小乘以单个元素的字节大小,依此类推。

以一个4×3×2的张量为例:

32位浮点型4×3×2张量的示例

步长的设计目的是让部分张量运算无需复制数据即可执行。例如,将二维张量的行和列互换的转置运算,仅需交换nenb的取值,并让新张量指向原张量的底层数据即可实现:

// ggml.c (函数做了简化处理)
struct ggml_tensor * ggml_transpose(
        struct ggml_context * ctx,
        struct ggml_tensor  * a) {
    // 初始化result,指向与a相同的数据
    struct ggml_tensor * result = ggml_view_tensor(ctx, a);

    result->ne[0] = a->ne[1];
    result->ne[1] = a->ne[0];

    result->nb[0] = a->nb[1];
    result->nb[1] = a->nb[0];

    result->op   = GGML_OP_TRANSPOSE;
    result->src[0] = a;

    return result;
}

在上述函数中,新生成的result张量是原张量a视图,二者指向内存中同一处多维数字数组。通过交换ne中的维度值和nb中的步长值,无需复制任何数据即可完成转置运算。

张量运算与视图

如前所述,部分张量存储实际数据,而另一部分仅表示多个张量间的运算结果。回到ggml_tensor结构体,其余核心字段的含义如下:

  • op:表示张量支持的任意运算类型。若该字段设为GGML_OP_NONE,说明该张量为数据张量,存储实际数据;若为其他取值,则代表该张量是运算张量,例如GGML_OP_MUL_MAT表示该张量不存储数据,仅代表两个张量的矩阵乘法运算结果。
  • src:指向参与运算的源张量的指针数组。例如,若op == GGML_OP_MUL_MAT,则src中会存储两个待相乘张量的指针;若op == GGML_OP_NONE,则src为空。
  • data:指向张量实际数据的指针;若为运算张量,该指针为NULL;若该指针指向另一个张量的数据,则该张量被称为视图(view)。例如上述ggml_transpose()函数中,结果张量就是原张量的视图,仅修改了维度和步长,data指针仍指向原内存地址。

矩阵乘法函数能很好地体现上述概念:

// ggml.c (做了简化和注释)
struct ggml_tensor * ggml_mul_mat(
        struct ggml_context * ctx,
        struct ggml_tensor  * a,
        struct ggml_tensor  * b) {
    // 检查张量维度是否满足矩阵乘法要求
    GGML_ASSERT(ggml_can_mul_mat(a, b));

    // 根据矩阵乘法规则,设置新张量的维度
    const int64_t ne[4] = { a->ne[1], b->ne[1], b->ne[2], b->ne[3] };
    // 分配新的ggml_tensor结构体
    // 仅分配结构体本身,不分配实际的数据存储空间
    struct ggml_tensor * result = ggml_new_tensor(ctx, GGML_TYPE_F32, MAX(a->n_dims, b->n_dims), ne);

    // 设置运算类型和源张量
    result->op   = GGML_OP_MUL_MAT;
    result->src[0] = a;
    result->src[1] = b;

    return result;
}

在上述函数中,result张量不包含任何实际数据,仅作为ab矩阵乘法运算结果的逻辑表示

张量的计算过程

上述的ggml_mul_mat()函数,或其他任何张量运算函数,都不会执行实际的计算,仅会为运算准备好对应的张量结构。换个角度理解,这类函数的作用是构建计算图(computation graph)——计算图中每个节点代表一次张量运算,节点的子节点则为参与该运算的源张量。以矩阵乘法为例,计算图中会有一个标识为GGML_OP_MUL_MAT的父节点,以及两个分别代表待乘张量的子节点。

以下是llama.cpp中的实际代码示例,该代码实现了自注意力机制(属于每个Transformer层的核心模块,后续将深入讲解):

// llama.cpp
static struct ggml_cgraph * llm_build_llama(/* ... */) {
    // ...

    // K,Q,V为提前初始化的张量
    struct ggml_tensor * KQ = ggml_mul_mat(ctx0, K, Q);
    // KQ_scale为提前初始化的标量张量
    struct ggml_tensor * KQ_scaled = ggml_scale_inplace(ctx0, KQ, KQ_scale);
    struct ggml_tensor * KQ_masked = ggml_diag_mask_inf_inplace(ctx0, KQ_scaled, n_past);
    struct ggml_tensor * KQ_soft_max = ggml_soft_max_inplace(ctx0, KQ_masked);
    struct ggml_tensor * KQV = ggml_mul_mat(ctx0, V, KQ_soft_max);

    // ...
}

上述代码由一系列张量运算组成,构建的计算图与原始Transformer论文中描述的自注意力计算图完全一致:

注意力机制计算图:Scaled Dot-Product Attention与Multi-Head Attention

要得到最终的结果张量(此处为KQV),需执行以下步骤:

  1. 将数据加载至所有叶子张量data指针中,本示例中的叶子张量为KQV
  2. 通过ggml_build_forward()将输出张量(KQV)转换为计算图,该函数的逻辑较为简单,会以深度优先的顺序对计算图的节点进行排序。
  3. 通过ggml_graph_compute()运行计算图,该函数会以深度优先的顺序对每个节点执行ggml_compute_forward(),而ggml_compute_forward()是实际执行计算的核心函数——它会完成对应的数学运算,并将结果填充至张量的data指针中。

完成上述过程后,输出张量的data指针将指向最终的计算结果。

张量计算向GPU的卸载

矩阵加法、矩阵乘法等多数张量运算,在GPU上执行的效率远高于CPU,这得益于GPU的高并行计算特性。当有可用的GPU时,可将张量的backend字段设为GGML_BACKEND_GPU,此时ggml_compute_forward()会尝试将计算任务卸载至GPU执行,GPU完成运算后,结果会存储在GPU显存中,而非张量的data指针(该指针指向CPU内存)。

以前文的自注意力计算图为例,假设KQV为固定张量,其计算过程可卸载至GPU,具体流程如下:

GPU与CPU数据交互与计算流程图

注:在实际的Transformer中,KQV并非固定张量,KQV也并非最终输出,后续将详细讲解。

掌握了张量的相关知识后,我们回到LLaMA的推理流程继续解析。

Tokenization

推理的第一步是Tokenization(tokenization),即把提示词拆分为一系列短字符串(称为token)的过程,这些token必须属于模型的词汇表——也就是LLM在训练阶段所使用的token集合。例如,LLaMA的词汇表包含32k个token,该词汇表会随模型一同发布。

以本文的示例提示词为例,Tokenization后会得到11个token(空格会被替换为特殊元符号(U+2581)):

|Quant|um|▁mechan|ics|▁is|▁a|▁fundamental|▁theory|▁in|▁physics|▁that|

LLaMA采用SentencePiece Tokenization器,并结合字节对编码(byte-pair-encoding,BPE)算法实现Tokenization。该Tokenization器的核心特点是基于子词(subword),即单个单词可能会被拆分为多个token。在上述示例中,单词Quantum被拆分为Quantum两个token。在模型训练的词汇表构建阶段,BPE算法会确保常用单词作为单个token被纳入词汇表,而生僻单词则会被拆分为子词。上述示例中,Quantum未被纳入词汇表,但Quantum作为两个独立的token存在于词汇表中。空格不会被特殊处理,若其出现频率足够高,会以元符号的形式被包含在token中。

基于子词的Tokenization方式具备诸多优势:

  1. 既能让LLM学习生僻单词(如Quantum)的语义,又能通过将常见的词缀表示为独立token,控制词汇表的大小。
  2. 无需设计针对特定语言的Tokenization规则,即可让模型学习到语言的专属特征。
  3. Tokenization方式同样适用于代码解析。例如,变量名model_size会被Tokenizationmodel|_|size,让LLM能够“理解”该变量的含义。

在llama.cpp中,Tokenization功能由llama_tokenize()函数实现,该函数接收提示词字符串作为输入,返回由整数表示的token序列:

// llama.h
typedef int llama_token;

// common.h
std::vector<llama_token> llama_tokenize(
        struct llama_context * ctx,
        // 提示词
        const std::string & text,
        bool   add_bos);

Tokenization的执行流程为:首先将提示词拆分为单个字符的token,然后迭代尝试将相邻的两个token合并为一个更大的token,只要合并后的token存在于词汇表中,该过程就会持续进行,最终得到尽可能长的token序列。本文示例提示词的Tokenization步骤如下:

Q|u|a|n|t|u|m|▁|m|e|c|h|a|n|i|c|s|▁|i|s|▁a|▁|f|u|n|d|a|m|e|n|t|a|l|

Qu|an|t|um|▁m|e|ch|an|ic|s|▁|is|▁a|▁f|u|nd|am|en|t|al|

Qu|ant|um|▁me|chan|ics|▁is|▁a|▁f|und|am|ent|al|

Quant|um|▁mechan|ics|▁is|▁a|▁fund|ament|al|

Quant|um|▁mechan|ics|▁is|▁a|▁fund|amental|

Quant|um|▁mechan|ics|▁is|▁a|▁fundamental|

注:上述每一个中间步骤的结果,都是符合模型词汇表的有效Tokenization结果,但只有最后一步的结果会被作为LLM的输入。

Embedding

Tokenization得到的token会作为LLaMA的输入,用于预测下一个token,核心实现函数为llm_build_llama()

// llama.cpp (做了简化处理)
static struct ggml_cgraph * llm_build_llama(
         llama_context & lctx,
     const llama_token * tokens,
                  int   n_tokens,
                  int   n_past);

该函数接收tokens(token序列)和n_tokens(token数量)作为输入,构建LLaMA完整的张量计算图,并将其以ggml_cgraph结构体的形式返回,此阶段不会执行任何实际计算n_past参数目前被设为0,暂时可忽略,后续讲解KV缓存时会重新介绍该参数。

除了token序列,该函数还会使用模型权重(model weights)(也称为模型参数,model parameters),这些是模型在训练阶段学习到的固定张量,会随模型一同发布。在推理开始前,模型参数会被提前加载至lctx中。

接下来我们开始解析计算图的结构,计算图的第一部分是将token转换为Embedding向量(embedding)

Embedding向量是每个token的固定维度向量表示,相比纯整数形式,其更适合深度学习模型进行计算,因为Embedding向量能够捕捉单词的语义信息。Embedding向量的维度被称为模型维度(model dimension),不同模型的模型维度不同,例如LLaMA-7B的模型维度为n_embd=4096

模型参数中包含一个token Embedding矩阵(token-embedding matrix),用于将token转换为Embedding向量。由于LLaMA的词汇表大小为n_vocab=32000,因此该矩阵的维度为32000×4096,矩阵的每一行对应一个token的Embedding向量:

词嵌入(Token-embedding)矩阵示意图

每个token的Embedding向量都是在模型训练阶段学习得到的,可通过token Embedding矩阵直接获取。

计算图的第一部分,会从token Embedding矩阵中提取每个输入token对应的行,生成新的矩阵:

// llama.cpp (做了简化处理)
static struct ggml_cgraph * llm_build_llama(/* ... */) {
    // ...

    struct ggml_tensor * inp_tokens = ggml_new_tensor_1d(ctx0, GGML_TYPE_I32, n_tokens);
    memcpy(
        inp_tokens->data,
        tokens,
        n_tokens * ggml_element_size(inp_tokens)
    );

    inpL = ggml_get_rows(ctx0, model.tok_embeddings, inp_tokens);
}
//

上述代码的执行流程:

  1. 创建一个一维的整型张量inp_tokens,用于存储数字形式的token序列;
  2. 将token值复制到该张量的data指针指向的内存;
  3. 创建一个标识为GGML_OP_GET_ROWS的张量运算,将token Embedding矩阵model.tok_embeddings与输入token序列结合。

该运算在实际执行时,会从token Embedding矩阵中提取对应行,生成一个新的n_tokens × n_embd维度的矩阵,该矩阵仅包含输入token的Embedding向量,且向量顺序与输入token的顺序一致:

嵌入(Embedding)矩阵示意图

Embedding过程会为每个原始token生成一个固定维度的Embedding向量,这些向量按顺序堆叠后,就构成了提示词对应的Embedding矩阵。

变换器

计算图的核心部分是变换器(Transformer),这是一种神经网络架构,作为LLM的核心负责执行主要的推理逻辑。在本节中,我们将从工程视角解析Transformer的核心特性,重点讲解自注意力机制。

自注意力机制

先聚焦解析自注意力机制(self-attention)的核心逻辑,再从整体视角解析其在Transformer架构中的位置。

自注意力机制的作用是,接收一个token序列,生成该序列的紧凑向量表示,并在生成过程中考虑token之间的关联关系。这是LLM架构中唯一计算token间关联关系的模块,因此是模型实现语言理解的核心(语言理解的本质是理解单词间的关联)。由于该机制涉及跨token的计算,从工程视角来看也是最具研究价值的模块——尤其是当输入序列较长时,计算量会大幅增加。

自注意力机制的输入是n_tokens × n_embd维度的Embedding矩阵,矩阵的每一行(即每个向量)代表一个独立的token。每个Embedding向量会被进一步转换为三个不同的向量,分别称为键向量(key)查询向量(query)值向量(value)。该转换过程通过将每个token的Embedding向量,与模型参数中的固定矩阵wkwqwv分别相乘实现:

注意力机制中嵌入向量向查询、键、值向量的转换

上述转换过程会对所有token执行(共n_tokens次),为提升效率,实际实现中不会通过循环逐一枚举token,而是通过矩阵乘法一次性完成所有行的转换,核心代码如下:

// llama.cpp (做了简化,忽略缓存的使用)

// `cur`为自注意力机制的输入
struct ggml_tensor * K = ggml_mul_mat(ctx0,
    model.layers[il].wk, cur);
struct ggml_tensor * Q = ggml_mul_mat(ctx0,
    model.layers[il].wq, cur);
struct ggml_tensor * V = ggml_mul_mat(ctx0,
    model.layers[il].wv, cur);

执行完成后,会得到三个维度均为n_tokens × n_embd的矩阵KQV,分别由所有token的键向量、查询向量、值向量按顺序堆叠而成。

自注意力机制的下一步,是将存储查询向量的Q矩阵,与存储键向量的K矩阵的转置矩阵相乘。对于不熟悉矩阵运算的读者,该操作的本质是为每一对查询向量和键向量计算一个联合得分(joint score),我们用S(i,j)表示第i个查询向量与第j个键向量的得分。

该操作会生成n_tokens²个得分,所有得分会被整合为一个矩阵,称为KQ。随后,该矩阵会被掩码(mask)处理,移除对角线上方的所有元素:

KQ矩阵与掩码(Mask)处理示意图

掩码操作是自注意力机制中的关键步骤,其作用是让每个token仅保留与前置token的得分。在模型训练阶段,该约束能确保LLM仅基于过往token预测下一个token,而非未来的token。此外,后续将详细讲解,该操作能为未来token的预测带来显著的优化空间。

自注意力机制的最后一步,是将掩码后的得分矩阵KQ_masked与之前得到的值向量矩阵V相乘。该矩阵乘法操作会生成所有前置token值向量的加权和,权重即为对应的得分S(i,j)。例如,对于第四个tokenics,该操作会生成Quantum▁mechanics这四个token值向量的加权和,权重为S(3,0)S(3,3)——这些得分由ics的查询向量与所有前置token的键向量计算得到。

KQV矩阵生成:加权和示意图

KQV矩阵的生成,标志着自注意力机制的计算完成。

The layers of the Transformer

自注意力机制是Transformer layer中的一个组件,除自注意力机制外,每个Transformer层还包含多个其他张量运算,其中大部分是前馈神经网络(feed-forward neural network)中的矩阵加法、乘法和激活函数运算。本文不会对这些运算做深入解析,仅需掌握以下核心要点:

  1. 前馈神经网络中会使用大型的固定参数矩阵,例如LLaMA-7B中,参数矩阵的维度为n_embd × n_ff = 4096 × 11008
  2. 除自注意力机制外,所有其他运算均为按行/按token执行的运算,如前文所述,只有自注意力机制涉及跨token的计算——这一点在后续讲解KV缓存时至关重要。
  3. 每个Transformer层的输入和输出维度均为n_tokens × n_embd:一行对应一个token,每列对应模型维度的一个维度。

为保证内容的完整性,本文附上LLaMA-7B中单个Transformer层的结构示意图,需注意的是,未来的模型可能会对该架构做小幅调整。

LLaMA-7B单个Transformer层计算图与模型参数

Transformer架构由多个层级串联组成,例如LLaMA-7B包含n_layers=32个层。所有层的结构完全相同,唯一的区别是每层拥有独立的参数矩阵(例如,每层的自注意力机制都有专属的wkwqwv矩阵)。第一层的输入为前文所述的Embedding矩阵,第一层的输出作为第二层的输入,依此类推。可以理解为,每层都会生成一个新的Embedding向量序列,只是此时的Embedding向量不再与单个token直接绑定,而是对token间关联关系的更复杂表示。

logits计算

Transformer的最后一步是计算对数几率(logits)。logit是一个浮点数值,代表某个token作为“正确”后续token的概率,logit值越高,该token成为正确后续token的概率越大。

logit的计算方式为:将Transformer最后一层的输出,与一个固定的n_embd × n_vocab维度的参数矩阵(在llama.cpp中也称为output矩阵)相乘,该操作会为词汇表中的每个token生成一个对应的logit值。例如,LLaMA的词汇表包含32000个token,因此会生成32000个logit值:

logits计算:Transformer输出与输出矩阵相乘

logits是Transformer的最终输出,其值反映了各个token作为下一个token的可能性。至此,所有张量计算均完成。以下是llm_build_llama()函数的简化注释版本,汇总了本节讲解的所有步骤:

// llama.cpp (做了简化和注释)

static struct ggml_cgraph * llm_build_llama(
         llama_context & lctx,
     const llama_token * tokens,
                  int   n_tokens,
                  int   n_past) {
    ggml_cgraph * gf = ggml_new_graph(ctx0);
    struct ggml_tensor * cur;
    struct ggml_tensor * inpL;

    // 创建存储token序列的张量
    struct ggml_tensor * inp_tokens = ggml_new_tensor_1d(ctx0, GGML_TYPE_I32, N);
    // 将token序列复制到张量中
    memcpy(
        inp_tokens->data,
        tokens,
        n_tokens * ggml_element_size(inp_tokens)
    );

    // 生成`Embedding`矩阵
    inpL = ggml_get_rows(ctx0,
        model.tok_embeddings,
        inp_tokens);

    // 迭代执行所有Transformer层的计算
    for (int il = 0; il < n_layer; ++il) {
        struct ggml_tensor * K = ggml_mul_mat(ctx0, model.layers[il].wk, cur);
        struct ggml_tensor * Q = ggml_mul_mat(ctx0, model.layers[il].wq, cur);
        struct ggml_tensor * V = ggml_mul_mat(ctx0, model.layers[il].wv, cur);

        struct ggml_tensor * KQ = ggml_mul_mat(ctx0, K, Q);
        struct ggml_tensor * KQ_scaled = ggml_scale_inplace(ctx0, KQ, KQ_scale);
        struct ggml_tensor * KQ_masked = ggml_diag_mask_inf_inplace(ctx0,
            KQ_scaled, n_past);
        struct ggml_tensor * KQ_soft_max = ggml_soft_max_inplace(ctx0, KQ_masked);
        struct ggml_tensor * KQV = ggml_mul_mat(ctx0, V, KQ_soft_max);

        // 执行前馈网络计算
        // 生成cur张量
        // ...

        // 将cur作为下一层的输入
        inpL = cur;
    }

    cur = inpL;

    // 根据最后一层的输出计算logits
    cur = ggml_mul_mat(ctx0, model.output, cur);

    // 构建并返回计算图
    ggml_build_forward_expand(gf, cur);
    return gf;
}

要执行实际的推理,需通过前文所述的ggml_graph_compute()函数,运行该函数返回的计算图。计算完成后,logits值会从最终张量的data指针中复制到浮点数组中,为下一步采样(sampling)做准备。

采样

得到所有logits值后,下一步是基于这些值选择下一个token,该过程被称为采样。目前有多种采样方法可供选择,适用于不同的使用场景。本节将讲解两种基础的采样方法,语法采样(grammar sampling)等更进阶的方法将在后续文章中介绍。

贪心采样

贪心采样(greedy sampling)是一种简单直接的采样方法,其逻辑为选择logit值最高的token

以本文的示例提示词为例,logit值最高的几个token如下表所示:

token logit
▁describes 18.990
▁provides 17.871
▁explains 17.403
▁de 16.361
▁gives 15.007

因此,贪心采样会确定性地选择▁describes作为下一个token。当需要对相同的提示词生成确定性的输出时,贪心采样是最佳选择。

温度采样

温度采样(temperature sampling)是一种概率性的采样方法,即对相同的提示词重新推理时,模型可能会生成不同的输出。该方法引入了一个名为温度(temperature)的参数,该参数为0到1之间的浮点数,用于调节输出的随机性,具体执行流程如下:

  1. 将所有logits值从高到低排序,并通过softmax函数做归一化处理,确保所有logits值的和为1——该转换会将每个logit值转换为对应的概率。
  2. 设置一个阈值(默认值为0.95),仅保留累计概率低于该阈值的top-k个token,该步骤会有效移除低概率token,避免采样到“无效”或“错误”的token。
  3. 将剩余token的logits值除以温度参数,再次做归一化处理,确保其和为1,得到最终的概率分布。
  4. 基于该概率分布随机采样一个token。例如,在本文的示例提示词中,token▁describes的概率为0.6,意味着其被选中的概率约为60%,重新推理时可能会选中其他token。

步骤3中的温度参数,是调节输出随机性的核心:

  • 低温度值:会抑制低概率token的选中概率,让模型在重新推理时更易选中相同的token,因此随机性更低;
  • 高温度值:会“拉平”概率分布,提升低概率token的选中概率,让模型在重新推理时更易生成不同的token,因此随机性更高。

不同温度参数对token概率分布的影响

完成一个token的采样,标志着LLM一次完整迭代的结束。将采样得到的token追加至原token序列后,重新执行整个推理流程,模型的输出会不断作为新的输入,让token序列的长度每次增加1。

从理论上讲,后续的迭代过程可以完全复用初始流程,但随着token序列的不断变长,模型的推理性能会显著下降,因此需要引入特定的优化技术,下文将详细讲解。

推理过程的优化

当LLM的输入token序列变长时,Transformer的自注意力计算阶段会成为性能瓶颈:token序列越长,参与矩阵乘法的矩阵规模越大,而每次矩阵乘法都包含大量的数值运算,其运算量受限于GPU的浮点运算能力(flops)。解决该瓶颈最常用的优化技术是KV缓存(KV cache)

KV缓存

回顾前文内容,每个token的Embedding向量会通过与参数矩阵wkwv相乘,分别转换为键向量和值向量。KV缓存就是对这些键向量和值向量的缓存,通过缓存这些向量,可避免在每次迭代中重复计算,从而节省大量的浮点运算。

KV缓存的工作逻辑如下:

  1. 首次迭代:按前文所述的方式,计算所有输入token的键向量和值向量,并将其存入KV缓存。
  2. 后续迭代:仅需计算最新追加token的键向量和值向量,将缓存中的键/值向量与新计算的键/值向量拼接,即可得到当前迭代所需的K矩阵和V矩阵。该方式避免了对所有历史token的键/值向量的重复计算,能带来显著的性能提升。

K矩阵生成:缓存与拼接示意图

KV缓存的可行性,源于键/值向量在各次迭代中保持不变这一特性。例如,首次迭代处理4个token,第二次迭代处理5个token(新增1个),由于前4个token未发生变化,其对应的键/值向量在两次迭代中完全相同,因此第二次迭代无需重新计算前4个token的键/值向量。

该特性适用于Transformer的所有层级,而非仅第一层:在所有层级中,每个token的键/值向量仅依赖于其前置token,因此当新token被追加至序列后,历史token的键/值向量不会发生变化。

对于第一层,该特性很容易验证:token的键向量由其固定的Embedding向量与固定的wk矩阵相乘得到,因此无论后续追加多少token,历史token的键向量都不会改变,值向量同理。

对于第二层及之后的层级,该特性的逻辑稍复杂,但依然成立。要理解这一点,需回顾第一层自注意力机制的输出KQV矩阵:KQV矩阵的每一行都是一个加权和,其计算仅依赖于:

  1. 前置token的值向量;
  2. 由前置token的键向量计算得到的得分。

因此,KQV矩阵的每一行仅依赖于前置token,该矩阵经过若干按行执行的运算后,会作为第二层的输入。这意味着,后续迭代中,第二层的输入仅会新增对应新token的行,历史行保持不变。以此类推,该特性适用于Transformer的所有层级。

KQV矩阵计算的依赖关系示意图

后续迭代的进一步优化

读者可能会疑惑,既然能缓存键/值向量,为何不同时缓存查询向量?答案是,在后续迭代中,除最新token的查询向量外,历史token的查询向量均无使用价值。结合KV缓存,自注意力机制实际仅需接收最新token的查询向量即可完成计算:

  1. 将该查询向量与缓存中的K矩阵相乘,计算最新token与所有历史token的联合得分;
  2. 将得分结果与缓存中的V矩阵相乘,仅计算KQV矩阵的最后一行

事实上,在所有Transformer层中,后续迭代仅需传递1 × n_embd维度的向量,而非首次迭代中n_token × n_embd维度的矩阵。通过下方的示意图(后续迭代)与前文的首次迭代示意图对比,可更直观地理解这一点:

后续迭代的自注意力计算:利用KV缓存

该计算过程会在Transformer的所有层级中重复,且每层都会使用其专属的KV缓存。最终,Transformer的输出为一个包含n_vocab个logit值的一维向量,用于预测下一个token。

该优化方式避免了计算KQKQV矩阵中无需使用的行,随着token序列的变长,能节省大量的浮点运算。

KV缓存的实际实现

我们可以通过llama.cpp的源码,解析KV缓存的实际实现方式。不出意外,KV缓存同样基于张量实现,分别用一个张量存储键向量,一个张量存储值向量:

// llama.cpp (做了简化处理)

struct llama_kv_cache {
    // 存储键向量的缓存
    struct ggml_tensor * k = NULL;

    // 存储值向量的缓存
    struct ggml_tensor * v = NULL;

    int n; // 缓存中当前存储的token数量
};

初始化KV缓存时,会为每层分配足够的内存,用于存储512个键/值向量:

// llama.cpp (做了简化处理)
// n_ctx = 512 为默认值
static bool llama_kv_cache_init(
    struct llama_kv_cache & cache,
    ggml_type   wtype,
    int   n_ctx) {
    // 为每层分配可存储n_ctx个向量的元素空间
    const int64_t n_elements = n_embd*n_layer*n_ctx;

    cache.k = ggml_new_tensor_1d(cache.ctx, wtype, n_elements);
    cache.v = ggml_new_tensor_1d(cache.ctx, wtype, n_elements);

    // ...
}

回顾前文,推理过程中的计算图由llm_build_llama()函数构建,该函数有一个我们之前忽略的参数n_past,其作用如下:

  • 首次迭代n_tokens为输入token的数量,n_past设为0;
  • 后续迭代n_tokens设为1(仅处理最新的一个token),n_past为历史token的数量。

n_past参数的核心作用是,从KV缓存中提取对应数量的历史键/值向量。

以下是该函数中利用KV缓存计算K矩阵的核心代码,为便于理解,本文做了简化(忽略多头注意力)并添加了注释:

// llama.cpp (做了简化和注释)

static struct ggml_cgraph * llm_build_llama(
    llama_context & lctx,
    const llama_token * tokens,
    int   n_tokens,
    int   n_past) {
    // ...

    // 迭代执行所有Transformer层的计算
    for (int il = 0; il < n_layer; ++il) {
         // 计算最新token的键向量
         struct ggml_tensor * Kcur = ggml_mul_mat(ctx0, model.layers[il].wk, cur);
         // 在缓存的空闲位置,创建一个n_embd维度的视图
         struct ggml_tensor * k = ggml_view_1d(
            ctx0,
            kv_cache.k,
            // 视图大小
            n_tokens*n_embd,
            // 视图偏移量
            (ggml_element_size(kv_cache.k)*n_embd) * (il*n_ctx + n_past)
         );

         // 将最新token的键向量复制到缓存的空闲位置
         ggml_cpy(ctx0, Kcur, k);

         // 通过缓存创建视图,生成当前迭代的K矩阵
         struct ggml_tensor * K =
             ggml_view_2d(ctx0,
                 kv_self.k,
                 // 行大小
                 n_embd,
                 // 行数
                 n_past + n_tokens,
                 // 步长
                 ggml_element_size(kv_self.k) * n_embd,
                 // 缓存偏移量
                 ggml_element_size(kv_self.k) * n_embd * n_ctx * il);
    }
}

上述代码的执行流程:

  1. 计算最新token的键向量Kcur
  2. 根据n_past参数找到KV缓存中的下一个空闲位置,创建对应的张量视图;
  3. 将新计算的键向量复制到该空闲位置;
  4. 从缓存中创建一个包含n_past + n_tokens行的视图,作为当前迭代的K矩阵。

KV缓存是LLM推理优化的基础,需要注意的是,本文讲解的llama.cpp中的KV缓存实现版本(截至本文撰写时)并非最优版本。例如,该实现会提前分配大量内存,用于存储最大支持数量的键/值向量(本文中为512个)。vLLM等更先进的实现方案,会优化内存的使用效率,带来进一步的性能提升,这些进阶技术将在后续文章中介绍。此外,LLM领域的发展速度极快,未来大概率会出现更多更优的推理优化技术。

脚注

  1. ggml还提供了ggml_build_backward()函数,该函数会从输出到输入反向计算梯度,仅用于模型训练阶段的反向传播,推理阶段不会使用。
  2. 该文章讲解的是编码器-解码器(encoder-decoder)模型,而LLaMA是纯解码器(decoder-only)模型——因其每次仅预测一个token,但二者的核心概念完全相同。
  3. 为简化说明,本文讲解的是单头自注意力机制,而LLaMA实际使用的是多头自注意力机制。该区别仅会让张量运算的实现更复杂,不会影响本文讲解的核心思想。
  4. 准确来说,Embedding向量在输入自注意力机制前,会先经过一次归一化运算,对其值做缩放处理。为简化核心思想的讲解,本文省略了该步骤。
  5. 得分在与值向量矩阵相乘前,还会经过一次softmax运算,对得分做缩放处理,确保每一行的得分和为1。

参考

希望这篇通过llama.cpp源码进行的剖析,能帮助你更直观地理解大语言模型(LLM)推理过程的核心机制与关键优化。如果在实践中遇到问题,欢迎在云栈社区的技术论坛与更多开发者交流探讨。




上一篇:Linux系统监控与性能优化命令行实战指南
下一篇:基于M5Stack AtomS3R开发AI语音助手:三步快速搭建实战
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-3-10 22:05 , Processed in 0.429798 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表