当你向一个大语言模型输入"今天天气怎么样",它在毫秒级别内就能返回一段流畅的回答。这个过程看似简单,背后却隐藏着一套精密的计算流程。输入的文本经历了分词、嵌入、多层Transformer处理、概率计算、采样选择等多个阶段,最终才能生成你所看到的每一个字符。

这篇文章将拆解大模型推理的完整流程,从文本如何被模型"理解",到模型如何逐词生成回答,揭示这套被封装在API背后的技术链路。

flowchart TD
    A[输入文本: 今天天气怎么样] --> B[分词 Tokenization]
    B --> C[嵌入 Embedding]
    C --> D[位置编码 Positional Encoding]
    D --> E[Transformer层处理]
    E --> F[输出层投影]
    F --> G[采样解码]
    G --> H{是否结束?}
    H -->|否| I[追加Token到序列]
    I --> C
    H -->|是| J[输出文本]
    
    style A fill:#e3f2fd
    style J fill:#e8f5e9
    style E fill:#fff3e0

分词:文本如何变成数字

大模型无法直接处理文字,它只能处理数字。因此,推理的第一步是将输入文本切分成一个个可处理的单元——Token,然后将每个Token映射为一个唯一的整数ID。

这个过程称为分词(Tokenization)。现代大模型普遍采用Byte Pair Encoding(BPE)算法,这是一种基于统计的子词切分方法。

BPE的核心思想是从字符级别开始,不断合并出现频率最高的相邻字符对,逐步构建更大的词汇单元。以单词"unhappiness"为例,BPE可能将其分解为[“un”, “happi”, “ness”]三个Token,而不是简单地按字符或按单词切分。这种方式的优势在于:常见词能作为整体被高效编码,罕见词则被分解为有意义的子词片段,避免词表无限膨胀。

# 分词示例
text = "今天天气怎么样"
tokens = tokenizer.encode(text)
# 假设输出: [3621, 4197, 102, 31123, 9821]
# 每个整数对应词表中的一个Token
flowchart LR
    A["今天天气怎么样"] --> B["今天"]
    A --> C["天气"]
    A --> D["怎么"]
    A --> E["样"]
    
    B --> F["3621"]
    C --> G["4197"]
    D --> H["102"]
    E --> I["31123"]
    
    style A fill:#e3f2fd
    style F fill:#fff3e0
    style G fill:#fff3e0
    style H fill:#fff3e0
    style I fill:#fff3e0

一个关键但常被忽视的事实是:不同语言在分词效率上存在巨大差异。以中文为例,同样的语义内容,中文所需的Token数量通常是英文的1.5-2倍。这直接影响推理成本——Token越多,计算量越大,API调用费用也越高。

词表大小是分词器的另一个重要参数。常见的开源模型词表大小在32K到128K之间。词表越小,每个Token承载的信息密度越高,但出现未知词(Out-of-Vocabulary)的概率也越大;词表越大,覆盖面越广,但模型参数量也会相应增加。

嵌入层:从离散ID到连续向量

Token ID仍然只是一个离散的整数,神经网络无法直接对其进行有效的数学运算。嵌入层(Embedding Layer)的作用是将每个Token ID映射到一个高维连续向量空间。

嵌入层本质上是一个巨大的查找表。假设模型的隐藏维度为4096,词表大小为32000,那么嵌入矩阵就是一个形状为[32000, 4096]的二维数组。输入一个Token ID,嵌入层就从矩阵中取出对应的行,得到一个4096维的向量。

flowchart TD
    subgraph 词表
        V1["Token 0: [0.1, -0.2, ...]"]
        V2["Token 1: [0.3, 0.5, ...]"]
        V3["..."]
        Vn["Token 3621: [0.12, -0.34, ..., 0.56]"]
    end
    
    A["Token ID: 3621"] --> B[查找嵌入矩阵]
    B --> Vn
    Vn --> C["嵌入向量: [0.12, -0.34, ..., 0.56]"]
    C --> D["维度: 4096"]
    
    style A fill:#e3f2fd
    style C fill:#e8f5e9

这些嵌入向量并非随机初始化,而是在模型训练过程中通过反向传播不断优化,逐渐捕获Token之间的语义关系。语义相近的词,其嵌入向量在高维空间中的距离也更近。例如,“开心"和"快乐"的向量可能非常接近,而"开心"和"悲伤"的向量则相距较远。

嵌入向量的维度(即隐藏维度)是模型架构的核心参数之一。GPT-3使用12288维,LLaMA-7B使用4096维,LLaMA-70B使用8192维。维度越高,模型的表达能力越强,但计算和存储开销也越大。

位置编码:注入序列信息

Transformer架构的一个关键特性是:自注意力机制本身对Token的顺序是无感知的。如果你打乱输入序列中Token的顺序,注意力计算的输出只是相应位置的重新排列,模型无法区分"我喜欢你"和"你喜欢我"的区别。

位置编码(Positional Encoding)的引入正是为了解决这个问题。它为每个位置生成一个独特的向量,与Token嵌入相加,从而将位置信息注入模型。

原始Transformer论文提出使用正弦和余弦函数生成固定位置编码:

$$PE_{(pos, 2i)} = \sin(pos / 10000^{2i/d_{model}})$$$$PE_{(pos, 2i+1)} = \cos(pos / 10000^{2i/d_{model}})$$

其中$pos$是位置索引,$i$是嵌入维度的索引。这种设计的优雅之处在于:任何两个位置之间的相对位置关系都可以通过线性变换得到,模型能够学习利用这种相对位置信息。

flowchart LR
    subgraph 序列位置
        P0["位置 0"]
        P1["位置 1"]
        P2["位置 2"]
        P3["位置 3"]
    end
    
    subgraph 位置编码向量
        PE0["PE₀: [sin(0), cos(0), ...]"]
        PE1["PE₁: [sin(1), cos(1), ...]"]
        PE2["PE₂: [sin(2), cos(2), ...]"]
        PE3["PE₃: [sin(3), cos(3), ...]"]
    end
    
    P0 --> PE0
    P1 --> PE1
    P2 --> PE2
    P3 --> PE3
    
    style P0 fill:#e3f2fd
    style PE0 fill:#e8f5e9

现代大模型更多采用可学习的位置嵌入或旋转位置编码(RoPE)。RoPE将位置信息通过旋转矩阵注入注意力计算,不仅保留了相对位置信息,还具有更好的外推性——模型能够处理比训练时更长的序列。

Transformer层:注意力与前馈网络的交替

嵌入向量经过位置编码增强后,进入模型的核心——Transformer层。一个典型的大语言模型包含数十层Transformer层,每层由两个子模块组成:多头自注意力机制(Multi-Head Self-Attention)和前馈神经网络(Feed-Forward Network)。

flowchart TD
    subgraph Transformer层
        A[输入] --> B[Layer Norm]
        B --> C[多头自注意力]
        C --> D[残差连接]
        D --> E[Layer Norm]
        E --> F[前馈神经网络]
        F --> G[残差连接]
        G --> H[输出]
    end
    
    I[上一层的输出] --> A
    H --> J[下一层的输入]
    
    style C fill:#fff3e0
    style F fill:#fce4ec

自注意力机制

自注意力是Transformer的核心创新。它让模型能够计算序列中每个Token与其他所有Token之间的相关性,从而捕获长距离依赖关系。

计算过程分为三步:

第一步,生成Query、Key、Value三个向量。对于输入序列中的每个Token,模型通过三个独立的线性变换,将其嵌入向量投影为Query向量、Key向量和Value向量:

$$Q = XW_Q, \quad K = XW_K, \quad V = XW_V$$

其中$X$是输入嵌入,$W_Q$、$W_K$、$W_V$是可学习的投影矩阵。

flowchart TD
    A[输入嵌入 X] --> B[线性变换 W_Q]
    A --> C[线性变换 W_K]
    A --> D[线性变换 W_V]
    
    B --> E[Query Q]
    C --> F[Key K]
    D --> G[Value V]
    
    E --> H[注意力计算]
    F --> H
    G --> H
    
    H --> I[输出向量]
    
    style A fill:#e3f2fd
    style I fill:#e8f5e9

第二步,计算注意力分数。Query向量与所有Key向量进行点积运算,得到每个Token对其他Token的注意力分数。为防止点积值过大导致梯度消失,通常需要除以向量维度的平方根进行缩放:

$$\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V$$

第三步,加权求和。将注意力分数通过Softmax归一化为概率分布,然后用这个分布对Value向量进行加权求和,得到每个位置的新表示。

在自回归生成场景中,模型需要确保每个位置只能"看到"它之前的Token,不能"偷看"未来的内容。这通过因果掩码(Causal Mask)实现——在计算注意力分数时,将未来位置对应的分数设为负无穷,这样Softmax后它们的权重就变为零。

多头注意力进一步增强了模型的表达能力。它将嵌入向量分割成多个"头”,每个头独立执行注意力计算,最后将所有头的输出拼接并通过线性变换投影回原始维度。这让模型能够同时关注不同类型的依赖关系:一个头可能学习句法关系,另一个头可能学习语义关系。

前馈神经网络

注意力机制负责在Token之间传递信息,前馈神经网络(FFN)则负责对每个Token的特征进行深度变换。

一个标准的前馈神经网络包含两个线性层和一个非线性激活函数:

$$\text{FFN}(x) = \text{GELU}(xW_1 + b_1)W_2 + b_2$$

第一个线性层将隐藏维度扩大(通常是4倍),第二个线性层再投影回原始维度。中间层的扩展让模型能够在更高维的空间中进行特征变换,增强非线性表达能力。

值得注意的是,前馈神经网络占据了模型大部分参数。以GPT-3为例,1750亿参数中,约三分之二来自前馈神经网络。这意味着FFN在知识存储和特征处理中扮演着至关重要的角色。

残差连接与层归一化

每个子模块(注意力和FFN)的输出都会经过残差连接(Residual Connection)和层归一化(Layer Normalization):

$$\text{Output} = \text{LayerNorm}(x + \text{Sublayer}(x))$$

残差连接让梯度能够直接流过网络,有效缓解了深度网络中的梯度消失问题。层归一化则稳定了训练过程,防止激活值过大或过小。

现代大模型普遍采用Pre-Norm架构,即在子模块之前进行层归一化,而非原始Transformer论文中的Post-Norm。Pre-Norm在训练深层网络时更加稳定,梯度流动更加平滑。

输出层:从隐藏状态到概率分布

经过数十层Transformer处理后,输入序列被转化为一系列上下文相关的隐藏状态向量。这些向量包含了丰富的语义信息,但还不是可直接输出的文本。

输出层的作用是将最后的隐藏状态向量投影到词表空间,生成每个Token的概率分布。这通过一个线性变换实现:

$$\text{Logits} = hW_{vocab}$$

其中$h$是最后一个Token位置的隐藏状态向量,$W_{vocab}$是投影矩阵,输出是一个维度等于词表大小的向量,称为Logits。

Logits是原始的分数,可以理解为模型对每个候选Token的"偏好程度"。通过Softmax函数,这些分数被转化为和为1的概率分布:

$$P(w_t | w_{其中$z_w$是Token $w$对应的Logit值,$V$是整个词表。

flowchart LR
    A[隐藏状态 h<br/>维度: 4096] --> B[线性投影 W_vocab]
    B --> C[Logits<br/>维度: 32000]
    C --> D[Softmax]
    D --> E[概率分布<br/>每个Token的概率]
    
    style A fill:#e3f2fd
    style E fill:#e8f5e9

至此,模型完成了对下一个Token的预测。输出的概率分布告诉模型:在给定上下文的情况下,每个Token作为下一个Token的可能性有多大。

解码策略:如何选择下一个Token

有了概率分布,下一步是选择一个Token作为输出。这个看似简单的步骤,实际上有多种策略可选,每种策略都会显著影响生成文本的质量和风格。

flowchart TD
    A[概率分布] --> B{解码策略}
    B --> C[贪婪搜索]
    B --> D[束搜索]
    B --> E[温度采样]
    B --> F[Top-k采样]
    B --> G[Top-p采样]
    
    C --> C1[选择概率最高的Token]
    D --> D1[保留多个候选序列]
    E --> E1[调节分布陡峭程度]
    F --> F1[从top-k中采样]
    G --> G1[从累积概率p中采样]
    
    style A fill:#e3f2fd

贪婪搜索

最直接的方法是贪婪搜索:每次选择概率最高的Token。这种方法简单高效,但有一个致命缺陷——它可能导致局部最优但全局不佳的序列。例如,模型可能因为"我喜欢"后面接"你"的概率略高于"吃苹果",就选择了前者,但"我喜欢吃苹果"可能是一个更自然、更合理的句子。

束搜索

束搜索(Beam Search)通过同时保留多个候选序列来缓解这个问题。在每个时间步,模型保留概率最高的$k$个序列($k$称为束宽),继续扩展。最终选择整体概率最高的序列作为输出。

束搜索在机器翻译等任务中效果良好,但在开放式文本生成中,它倾向于产生重复、机械的输出。因为"最可能"的序列往往不是"最有趣"的序列。

温度采样

温度(Temperature)是一个控制概率分布"陡峭程度"的参数。将Logits除以温度$T$后再通过Softmax:

$$P(w_t) = \frac{\exp(z_{w_t}/T)}{\sum_{w \in V} \exp(z_w/T)}$$

温度越低,概率分布越陡峭,模型倾向于选择高概率Token,输出更确定、更一致;温度越高,概率分布越平坦,低概率Token被选中的机会增加,输出更多样、更有创意。当温度趋近于0时,采样退化为贪婪搜索。

Top-k采样

Top-k采样限制模型只能从前$k$个最可能的Token中采样。这过滤掉了极不可能的Token,同时保留了随机性。例如,设置$k=50$,模型会从概率最高的50个Token中根据它们的相对概率随机选择一个。

Top-p采样(核采样)

Top-p采样采用动态的候选集大小:选择累积概率达到$p$的最小Token集合。例如,设置$p=0.9$,模型会按概率从高到低选择Token,直到这些Token的累积概率达到90%,然后从这个集合中采样。

Top-p采样相比Top-k采样更加灵活。当模型对下一个Token很确定时,候选集可能很小;当模型不确定时,候选集会自动扩大。

实际应用中,温度、Top-k和Top-p通常结合使用,通过调整这些参数来控制输出的确定性和多样性。

自回归生成:为什么必须逐词输出

到目前为止,我们只讨论了如何生成一个Token。但实际应用中,模型需要生成完整的句子或段落。这是通过自回归(Autoregressive)过程实现的:每次生成一个Token后,将其追加到输入序列末尾,重新输入模型,继续预测下一个Token,直到生成结束符或达到最大长度限制。

sequenceDiagram
    participant User
    participant Model
    participant Output
    
    User->>Model: 输入: "今天天气"
    Model->>Model: Prefill阶段
    Model-->>Output: 预测: "不错"
    Output-->>User: Token 1: "不错"
    
    loop 自回归生成
        Model->>Model: Decode阶段
        Model-->>Output: 预测下一个Token
        Output-->>User: 输出Token
    end
    
    Model-->>User: 结束符,生成完成

这个过程的数学基础是概率链式法则。一个序列$w = (w_1, w_2, \ldots, w_T)$的联合概率可以分解为条件概率的乘积:

$$P(w) = \prod_{t=1}^{T} P(w_t | w_1, w_2, \ldots, w_{t-1})$$

模型在每个时间步预测的条件概率$P(w_t | w_{

自回归生成的一个直接后果是:生成长度为$T$的序列需要执行$T$次模型前向传播。这也是为什么大模型生成文本的速度比处理输入要慢得多——输入可以在一次前向传播中处理完(这部分称为Prefill阶段),而输出必须逐Token生成(这部分称为Decode阶段)。

KV Cache:推理优化的关键技术

自回归生成的一个朴素实现会导致严重的计算浪费:每次生成新Token时,都需要重新计算所有历史Token的Query、Key、Value向量。生成第100个Token时,前99个Token的计算完全是重复劳动。

KV Cache正是为解决这个问题而设计。其核心观察是:在自注意力计算中,历史Token的Key和Value向量在生成新Token时不会变化,只有新Token需要计算Query、Key、Value,而历史Token只需要复用之前缓存的Key和Value。

flowchart TD
    subgraph 无KV Cache
        A1[Token 1-99] --> B1[重新计算Q, K, V]
        A2[Token 100] --> B2[计算Q, K, V]
        B1 --> C1[注意力计算]
        B2 --> C1
    end
    
    subgraph 有KV Cache
        D1[KV Cache<br/>Token 1-99] --> E1[直接复用]
        D2[Token 100] --> E2[只计算新Token的K, V]
        E1 --> F1[注意力计算]
        E2 --> F1
    end
    
    style D1 fill:#e8f5e9
    style F1 fill:#fff3e0

有了KV Cache,生成每个新Token的计算复杂度从$O(n^2)$降低到$O(n)$,其中$n$是序列长度。对于长序列生成,这个优化可能带来数倍甚至数十倍的加速。

KV Cache的代价是内存占用。对于一个大模型,每个Token的KV Cache大小可以用以下公式估算:

$$\text{KV Cache per token} = 2 \times n_{layers} \times n_{heads} \times d_{head} \times \text{precision}$$

以LLaMA-7B为例,使用FP16精度,每个Token的KV Cache约为128KB。生成一个4000Token的序列,需要约512MB的KV Cache内存。这解释了为什么长上下文模型对GPU内存有极高要求。

推理性能的两个阶段

理解推理流程后,我们可以更清晰地看到性能瓶颈所在。

flowchart LR
    subgraph Prefill阶段
        A1[输入Token] --> B1[并行处理]
        B1 --> C1[计算密集型]
        C1 --> D1[GPU利用率高]
    end
    
    subgraph Decode阶段
        A2[逐Token生成] --> B2[串行处理]
        B2 --> C2[内存密集型]
        C2 --> D2[GPU利用率低]
    end
    
    style C1 fill:#e8f5e9
    style C2 fill:#fff3e0

在Prefill阶段,模型并行处理所有输入Token,计算密集型,GPU利用率高。瓶颈通常是计算能力。在Decode阶段,模型逐Token生成,每个时间步只处理一个Token,内存密集型,GPU大量时间花在等待数据传输上,利用率低。

这种差异带来几个优化方向:

批量推理:同时处理多个请求,提高GPU利用率。但批量推理的挑战是不同请求的输出长度不同,需要高效的调度策略。

KV Cache量化:将KV Cache从FP16压缩到INT8甚至INT4,减少内存占用和带宽压力。

投机解码:使用一个小模型快速生成多个候选Token,大模型一次性验证,减少大模型的前向传播次数。

连续批处理:不等所有请求完成再处理下一批,而是动态地将新请求加入正在处理的批次。

这些优化技术在现代推理框架中得到广泛应用,使得大模型能够在合理延迟和成本下提供服务。

从输入到输出的完整链路

让我们用一个完整的例子串联整个推理流程。假设输入为"今天天气",模型需要继续生成:

第一步:分词 将"今天天气"切分为Token,例如:[今天, 天气] → [3621, 4197]

第二步:嵌入查找 每个Token ID映射为一个高维向量:[3621] → [0.12, -0.34, …, 0.56](4096维)

第三步:位置编码 为位置0和位置1生成位置向量,加到嵌入向量上。

第四步:Transformer层处理 经过32层Transformer层(以LLaMA-7B为例),每层包含:

  • 多头自注意力:计算Token间的相关性
  • 前馈神经网络:对每个Token的特征进行变换
  • 残差连接和层归一化

第五步:输出投影 最后一个Token位置的隐藏状态投影到词表空间,得到Logits向量。

第六步:采样 根据解码策略(如Top-p采样)从概率分布中选择一个Token,假设选中"不错"。

第七步:自回归循环 将"不错"追加到输入序列,重复步骤1-6,生成后续Token,直到遇到结束符。

第八步:分词还原 将生成的Token ID序列转换回文本:“今天天气不错”。

总结

大模型推理是一个多阶段的数据处理流程:文本被分词器切分为Token,嵌入层将Token映射为向量,位置编码注入序列信息,Transformer层通过注意力和前馈网络层层提取和变换特征,输出层将隐藏状态投影为概率分布,解码策略从概率分布中选择Token,自回归地生成完整序列。

每个环节都有其设计哲学和技术权衡:分词器平衡词表大小与覆盖率,位置编码平衡表达能力与外推性,注意力机制平衡全局信息融合与计算复杂度,解码策略平衡确定性与多样性。

理解这套流程,不仅有助于更好地使用和调优大模型,也为深入理解模型行为、诊断生成问题、设计系统优化提供了必要的知识基础。当你下次调用一个语言模型API时,可以想象:在毫秒级的响应背后,正是这套精密的计算流程在运转。