第三章 大语言模型基础

1 语言模型与 Transformer 架构

1.1 从 N-gram 到 RNN

语言模型 (Language Model, LM) 是自然语言处理的核心,其根本任务是计算一个词序列(即一个句子)出现的概率。一个好的语言模型能够告诉我们什么样的句子是通顺的、自然的。在多智能体系统中,语言模型是智能体理解人类指令、生成回应的基础。本节将回顾从经典的统计方法到现代深度学习模型的演进历程,为理解后续的 Transformer 架构打下坚实的基础。

(1)统计语言模型与N-gram的思想

在深度学习兴起之前,统计方法是语言模型的主流。其核心思想是,一个句子出现的概率,等于该句子中每个词出现的条件概率的连乘。对于一个由词 w1,w2,⋯ ,wmw_1,w_2,\cdots,w_mw1,w2,⋯,wm 构成的句子 S,其概率 P(S) 可以表示为:

P(S)=P(w1,w2,...,wm)=P(w1)⋅P(w2∣w1)⋅P(w3∣w1,w2)⋯P(wm∣w1,...,wm−1)P(S)=P(w_1,w_2,...,w_m)=P(w_1)⋅P(w_2∣w_1)⋅P(w_3∣w_1,w_2)⋯P(w_m∣w_1,...,w_{m−1})P(S)=P(w1,w2,...,wm)=P(w1)⋅P(w2∣w1)⋅P(w3∣w1,w2)⋯P(wm∣w1,...,wm−1)

这个公式被称为概率的链式法则。然而,直接计算这个公式几乎是不可能的,因为像 P(wm∣w1,⋯ ,wm−1)P(w_m∣w_1,\cdots,w_{m−1})P(wm∣w1,⋯,wm−1) 这样的条件概率太难从语料库中估计了,词序列 w1,⋯ ,wm−1w_1,\cdots,w_{m−1}w1,⋯,wm−1 可能从未在训练数据中出现过。

为了解决这个问题,研究者引入了马尔可夫假设 (Markov Assumption) 。其核心思想是:我们不必回溯一个词的全部历史,可以近似地认为,一个词的出现概率只与它前面有限的 n−1n−1n−1 个词有关,如图3.1所示。基于这个假设建立的语言模型,我们称之为 N-gram模型。这里的 "N" 代表我们考虑的上下文窗口大小。让我们来看几个最常见的例子来理解这个概念:

  • Bigram (当 N=2 时) :这是最简单的情况,我们假设一个词的出现只与它前面的一个词有关。因此,链式法则中复杂的条件概率 P(wi∣w1,⋯ ,wi−1)P(w_i∣w_1,\cdots,w_{i−1})P(wi∣w1,⋯,wi−1) 就可以被近似为更容易计算的形式:

P(wi∣w1,...,wi−1)≈P(wi∣wi−1)P(w_{i}∣w_{1},...,w_{i−1})≈P(w_{i}∣w_{i−1})P(wi∣w1,...,wi−1)≈P(wi∣wi−1)

  • Trigram (当 N=3 时) :类似地,我们假设一个词的出现只与它前面的两个词有关:

P(wi∣w1,...,wi−1)≈P(wi∣wi−2,wi−1)P(w_i∣w_1,...,w_{i−1})≈P(w_i∣w_{i−2},w_{i−1})P(wi∣w1,...,wi−1)≈P(wi∣wi−2,wi−1)

这些概率可以通过在大型语料库中进行最大似然估计(Maximum Likelihood Estimation,MLE) 来计算。这个术语听起来很复杂,但其思想非常直观:最可能出现的,就是我们在数据中看到次数最多的。例如,对于 Bigram 模型,我们想计算在词 wi−1w_{i−1}wi−1 出现后,下一个词是 wiw_iwi 的概率 P(wi∣wi−1)P(w_i∣w_{i−1})P(wi∣wi−1)。根据最大似然估计,这个概率可以通过简单的计数来估算:

P(wi∣wi−1)=Count(wi−1,wi)Count(wi−1)P(w_i∣w_{i−1})=\frac{Count(w_{i−1},w_i)}{Count(w_{i−1})}P(wi∣wi−1)=Count(wi−1)Count(wi−1,wi)

这里的 Count() 函数就代表"计数":

  • Count(wi−1,wi)Count(w_i−1,w_i)Count(wi−1,wi):表示词对 (wi−1,wi)(w_{i−1},w_i)(wi−1,wi) 在语料库中连续出现的总次数。
  • Count(wi−1)Count(w_{i−1})Count(wi−1):表示单个词 wi−1w_{i−1}wi−1 在语料库中出现的总次数。

公式的含义就是:我们用"词对 Count(wi−1,wi)Count(w_i−1,w_i)Count(wi−1,wi) 出现的次数"除以"词 Count(wi−1)Count(w_{i−1})Count(wi−1) 出现的总次数",来作为 P(wi∣wi−1)P(w_i∣w_{i−1})P(wi∣wi−1) 的一个近似估计。

为了让这个过程更具体,我们来手动进行一次计算。假设我们拥有一个仅包含以下两句话的迷你语料库:datawhale agent learns, datawhale agent works。我们的目标是:使用 Bigram (N=2) 模型,估算句子 datawhale agent learns 出现的概率。根据 Bigram 的假设,我们每次会考察连续的两个词(即一个词对)。

第一步:计算第一个词的概率 P(datawhale)P(datawhale)P(datawhale) 这是 datawhale 出现的次数除以总词数。datawhale 出现了 2 次,总词数是 6。

P(datawhale)=总语料中"datawhale"的数量总语料的词数=26≈0.333P(\text{datawhale}) = \frac{\text{总语料中"datawhale"的数量}}{\text{总语料的词数}} = \frac{2}{6} \approx 0.333P(datawhale)=总语料的词数总语料中"datawhale"的数量=62≈0.333

第二步:计算条件概率 P(agent∣datawhale)P(agent∣datawhale)P(agent∣datawhale) 这是词对 datawhale agent 出现的次数除以 datawhale 出现的总次数。datawhale agent 出现了 2 次,datawhale 出现了 2 次。

P(agent∣datawhale)=Count(datawhale agent)Count(datawhale)=22=1P(\text{agent}|\text{datawhale}) = \frac{\text{Count}(\text{datawhale agent})}{\text{Count}(\text{datawhale})} = \frac{2}{2} = 1P(agent∣datawhale)=Count(datawhale)Count(datawhale agent)=22=1

第三步:计算条件概率 P(learns∣agent)P(learns∣agent)P(learns∣agent) 这是词对 agent learns 出现的次数除以 agent 出现的总次数。agent learns 出现了 1 次,agent 出现了 2 次。

P(learns∣agent)=Count(agent learns)Count(agent)=12=0.5P(\text{learns}|\text{agent}) = \frac{\text{Count(agent learns)}}{\text{Count(agent)}} = \frac{1}{2} = 0.5P(learns∣agent)=Count(agent)Count(agent learns)=21=0.5

最后:将概率连乘 所以,整个句子的近似概率为:

P(datawhale agent learns)≈P(datawhale)⋅P(agent∣datawhale)⋅P(learns∣agent)≈0.333⋅1⋅0.5≈0.167P(\text{datawhale agent learns}) \approx P(\text{datawhale}) \cdot P(\text{agent}|\text{datawhale}) \cdot P(\text{learns}|\text{agent}) \approx 0.333 \cdot 1 \cdot 0.5 \approx 0.167P(datawhale agent learns)≈P(datawhale)⋅P(agent∣datawhale)⋅P(learns∣agent)≈0.333⋅1⋅0.5≈0.167

N-gram 模型虽然简单有效,但有两个致命缺陷:

  1. 数据稀疏性 (Sparsity) :如果一个词序列从未在语料库中出现,其概率估计就为 0,这显然是不合理的。虽然可以通过平滑 (Smoothing) 技术缓解,但无法根除。
  2. 泛化能力差: 模型无法理解词与词之间的语义相似性。例如,即使模型在语料库中见过很多次 agent learns,它也无法将这个知识泛化到语义相似的词上。当我们计算 robot learns 的概率时,如果 robot 这个词从未出现过,或者 robot learns 这个组合从未出现过,模型计算出的概率也会是零。模型无法理解 agentrobot 在语义上的相似性。

(2)神经网络语言模型与词嵌入

N-gram 模型的根本缺陷在于它将词视为孤立、离散的符号。为了克服这个问题,研究者们转向了神经网络,并提出了一种思想:用连续的向量来表示词。2003年,Bengio 等人提出的前馈神经网络语言模型 (Feedforward Neural Network Language Model) 是这一领域的里程碑[1]

其核心思想可以分为两步:

  1. 构建一个语义空间 :创建一个高维的连续向量空间,然后将词汇表中的每个词都映射为该空间中的一个点。这个点(即向量)就被称为词嵌入 (Word Embedding) 或词向量。在这个空间里,语义上相近的词,它们对应的向量在空间中的位置也相近。例如,agentrobot 的向量会靠得很近,而 agentapple 的向量会离得很远。
  2. 学习从上下文到下一个词的映射 :利用神经网络的强大拟合能力,来学习一个函数。这个函数的输入是前 n−1n−1n−1 个词的词向量,输出是词汇表中每个词在当前上下文后出现的概率分布。

如图3.2所示,在这个架构中,词嵌入是在模型训练过程中自动学习得到的。模型为了完成"预测下一个词"这个任务,会不断调整每个词的向量位置,最终使这些向量能够蕴含丰富的语义信息。一旦我们将词转换成了向量,我们就可以用数学工具来度量它们之间的关系。最常用的方法是余弦相似度 (Cosine Similarity) ,它通过计算两个向量夹角的余弦值来衡量它们的相似性。

similarity(a⃗,b⃗)=cos⁡(θ)=a⃗⋅b⃗∣a⃗∣∣b⃗∣\text{similarity}(\vec{a}, \vec{b}) = \cos(\theta) = \frac{\vec{a} \cdot \vec{b}}{|\vec{a}| |\vec{b}|}similarity(a ,b )=cos(θ)=∣a ∣∣b ∣a ⋅b

这个公式的含义是:

  • 如果两个向量方向完全相同,夹角为0°,余弦值为1,表示完全相关。
  • 如果两个向量方向正交,夹角为90°,余弦值为0,表示毫无关系。
  • 如果两个向量方向完全相反,夹角为180°,余弦值为-1,表示完全负相关。

通过这种方式,词向量不仅能捕捉到"同义词"这类简单的关系,还能捕捉到更复杂的类比关系。

一个著名的例子展示了词向量捕捉到的语义关系: vector('King') - vector('Man') + vector('Woman') 这个向量运算的结果,在向量空间中与 vector('Queen') 的位置惊人地接近。这好比在进行语义的平移:我们从"国王"这个点出发,减去"男性"的向量,再加上"女性"的向量,最终就抵达了"女王"的位置。这证明了词嵌入能够学习到"性别"、"皇室"这类抽象概念。

神经网络语言模型通过词嵌入,成功解决了 N-gram 模型的泛化能力差的问题。然而,它仍然有一个类似 N-gram 的限制:上下文窗口是固定的。它只能考虑固定数量的前文,这为能处理任意长序列的循环神经网络埋下了伏笔。

(3)循环神经网络 (RNN) 与长短时记忆网络 (LSTM)

前一节的神经网络语言模型虽然引入了词嵌入解决了泛化问题,但它和 N-gram 模型一样,上下文窗口是固定大小的。为了预测下一个词,它只能看到前 n−1 个词,再早的历史信息就被丢弃了。这显然不符合我们人类理解语言的方式。为了打破固定窗口的限制,循环神经网络 (Recurrent Neural Network, RNN) 应运而生,其核心思想非常直观:为网络增加"记忆"能力[2]

如图

所示,RNN 的设计引入了一个隐藏状态 (hidden state) 向量,我们可以将其理解为网络的短期记忆。在处理序列的每一步,网络都会读取当前的输入词,并结合它上一刻的记忆(即上一个时间步的隐藏状态),然后生成一个新的记忆(即当前时间步的隐藏状态)传递给下一刻。这个循环往复的过程,使得信息可以在序列中不断向后传递。

然而,标准的 RNN 在实践中存在一个严重的问题:长期依赖问题 (Long-term Dependency Problem) 。在训练过程中,模型需要通过反向传播算法根据输出端的误差来调整网络深处的权重。对于 RNN 而言,序列的长度就是网络的深度。当序列很长时,梯度在从后向前传播的过程中会经过多次连乘,这会导致梯度值快速趋向于零(梯度消失 )或变得极大(梯度爆炸)。梯度消失使得模型无法有效学习到序列早期信息对后期输出的影响,即难以捕捉长距离的依赖关系。

为了解决长期依赖问题,长短时记忆网络 (Long Short-Term Memory, LSTM) 被设计出来[3]。LSTM 是一种特殊的 RNN,其核心创新在于引入了细胞状态 (Cell State) 和一套精密的门控机制 (Gating Mechanism) 。细胞状态可以看作是一条独立于隐藏状态的信息通路,允许信息在时间步之间更顺畅地传递。门控机制则是由几个小型神经网络构成,它们可以学习如何有选择地让信息通过,从而控制细胞状态中信息的增加与移除。这些门包括:

  • 遗忘门 (Forget Gate):决定从上一时刻的细胞状态中丢弃哪些信息。
  • 输入门 (Input Gate):决定将当前输入中的哪些新信息存入细胞状态。
  • 输出门 (Output Gate):决定根据当前的细胞状态,输出哪些信息到隐藏状态。

1.2 Transformer 架构解析

在上一节中,我们看到RNN及LSTM通过引入循环结构来处理序列数据,这在一定程度上解决了捕捉长距离依赖的问题。然而,这种循环的计算方式也带来了新的瓶颈:它必须按顺序处理数据。第 t 个时间步的计算,必须等待第 t−1 个时间步完成后才能开始。这意味着 RNN 无法进行大规模的并行计算,在处理长序列时效率低下,这极大地限制了模型规模和训练速度的提升。Transformer在2017 年由谷歌团队提出。它完全抛弃了循环结构,转而完全依赖一种名为注意力 (Attention) 的机制来捕捉序列内的依赖关系,从而实现了真正意义上的并行计算。

(1)Encoder-Decoder 整体结构

最初的 Transformer 模型是为端到端任务机器翻译而设计的。如图3.4所示,它在宏观上遵循了一个经典的编码器-解码器 (Encoder-Decoder) 架构。

我们可以将这个结构理解为一个分工明确的团队:

  • 编码器 (Encoder) :任务是"理解"输入的整个句子。它会读取所有输入词元(这个概念会在3.2.2节介绍),最终为每个词元生成一个富含上下文信息的向量表示。
  • 解码器 (Decoder) :任务是"生成"目标句子。它会参考自己已经生成的前文,并"咨询"编码器的理解结果,来生成下一个词。

为了真正理解 Transformer 的工作原理,最好的方法莫过于亲手实现它。在本节中,我们将采用一种"自顶向下"的方法:首先,我们搭建出 Transformer 完整的代码框架,定义好所有需要的类和方法。然后,我们将像完成拼图一样,逐一实现这些类的具体功能。

python 复制代码
import torch
import torch.nn as nn
import math

# --- 占位符模块,将在后续小节中实现 ---

class PositionalEncoding(nn.Module):
    """
    位置编码模块
    """
    def forward(self, x):
        pass

class MultiHeadAttention(nn.Module):
    """
    多头注意力机制模块
    """
    def forward(self, query, key, value, mask):
        pass

class PositionWiseFeedForward(nn.Module):
    """
    位置前馈网络模块
    """
    def forward(self, x):
        pass

# --- 编码器核心层 ---

class EncoderLayer(nn.Module):
    def __init__(self, d_model, num_heads, d_ff, dropout):
        super(EncoderLayer, self).__init__()
        self.self_attn = MultiHeadAttention() # 待实现
        self.feed_forward = PositionWiseFeedForward() # 待实现
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x, mask):
        # 残差连接与层归一化将在 3.1.2.4 节中详细解释
        # 1. 多头自注意力
        attn_output = self.self_attn(x, x, x, mask)
        x = self.norm1(x + self.dropout(attn_output))

        # 2. 前馈网络
        ff_output = self.feed_forward(x)
        x = self.norm2(x + self.dropout(ff_output))

        return x

# --- 解码器核心层 ---

class DecoderLayer(nn.Module):
    def __init__(self, d_model, num_heads, d_ff, dropout):
        super(DecoderLayer, self).__init__()
        self.self_attn = MultiHeadAttention() # 待实现
        self.cross_attn = MultiHeadAttention() # 待实现
        self.feed_forward = PositionWiseFeedForward() # 待实现
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        self.norm3 = nn.LayerNorm(d_model)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x, encoder_output, src_mask, tgt_mask):
        # 1. 掩码多头自注意力 (对自己)
        attn_output = self.self_attn(x, x, x, tgt_mask)
        x = self.norm1(x + self.dropout(attn_output))

        # 2. 交叉注意力 (对编码器输出)
        cross_attn_output = self.cross_attn(x, encoder_output, encoder_output, src_mask)
        x = self.norm2(x + self.dropout(cross_attn_output))

        # 3. 前馈网络
        ff_output = self.feed_forward(x)
        x = self.norm3(x + self.dropout(ff_output))

        return x

(2)从自注意力到多头注意力

现在,我们来填充骨架中最关键的模块,注意力机制。

想象一下我们阅读这个句子:"The agent learns because it is intelligent."。当我们读到加粗的 "it " 时,为了理解它的指代,我们的大脑会不自觉地将更多的注意力放在前面的 "agent" 这个词上。自注意力 (Self-Attention) 机制就是对这种现象的数学建模。它允许模型在处理序列中的每一个词时,都能兼顾句子中的所有其他词,并为这些词分配不同的"注意力权重"。权重越高的词,代表其与当前词的关联性越强,其信息也应该在当前词的表示中占据更大的比重。

为了实现上述过程,自注意力机制为每个输入的词元向量引入了三个可学习的角色:

  • 查询 (Query, Q):代表当前词元,它正在主动地"查询"其他词元以获取信息。
  • 键 (Key, K):代表句子中可被查询的词元"标签"或"索引"。
  • 值 (Value, V):代表词元本身所携带的"内容"或"信息"。

这三个向量都是由原始的词嵌入向量乘以三个不同的、可学习的权重矩阵 (WQ,WK,WVW^Q,W^K,W^VWQ,WK,WV) 得到的。整个计算过程可以分为以下几步,我们可以把它想象成一次高效的开卷考试:

  • 准备"考题"和"资料":对于句子中的每个词,都通过权重矩阵生成其Q,K,VQ,K,VQ,K,V向量。
  • 计算相关性得分:要计算词AAA的新表示,就用词AAA的QQQ向量,去和句子中所有词(包括AAA自己)的KKK向量进行点积运算。这个得分反映了其他词对于理解词AAA的重要性。
  • 稳定化与归一化:将得到的所有分数除以一个缩放因子dk\sqrt{d_{k}}dk (dkd_{k}dk是KKK向量的维度),以防止梯度过小,然后用Softmax函数将分数转换成总和为1的权重,也就是归一化的过程。
  • 加权求和:将上一步得到的权重分别乘以每个词对应的VVV向量,然后将所有结果相加。最终得到的向量,就是词AAA融合了全局上下文信息后的新表示。

这个过程可以用一个简洁的公式来概括:

Attention(Q,K,V)=softmax(QKTdk)V\text{Attention}(Q,K,V)=\text{softmax}\left(\frac{QK^{T}}{\sqrt{d_{k}}}\right)VAttention(Q,K,V)=softmax(dk QKT)V

如果只进行一次上述的注意力计算(即单头),模型可能会只学会关注一种类型的关联。比如,在处理 "it" 时,可能只学会了关注主语。但语言中的关系是复杂的,我们希望模型能同时关注多种关系(如指代关系、时态关系、从属关系等)。多头注意力机制应运而生。它的思想很简单:把一次做完变成分成几组,分开做,再合并。

它将原始的 Q, K, V 向量在维度上切分成 h 份(h 就是"头"数),每一份都独立地进行一次单头注意力的计算。这就好比让 h 个不同的"专家"从不同的角度去审视句子,每个专家都能捕捉到一种不同的特征关系。最后,将这 h 个专家的"意见"(即输出向量)拼接起来,再通过一个线性变换进行整合,就得到了最终的输出。

如图所示,这种设计让模型能够共同关注来自不同位置、不同表示子空间的信息,极大地增强了模型的表达能力。以下是多头注意力的简单实现可供参考。

python 复制代码
class MultiHeadAttention(nn.Module):
    """
    多头注意力机制模块
    """
    def __init__(self, d_model, num_heads):
        super(MultiHeadAttention, self).__init__()
        assert d_model % num_heads == 0, "d_model 必须能被 num_heads 整除"

        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads

        # 定义 Q, K, V 和输出的线性变换层
        self.W_q = nn.Linear(d_model, d_model)
        self.W_k = nn.Linear(d_model, d_model)
        self.W_v = nn.Linear(d_model, d_model)
        self.W_o = nn.Linear(d_model, d_model)

    def scaled_dot_product_attention(self, Q, K, V, mask=None):
        # 1. 计算注意力得分 (QK^T)
        attn_scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k)

        # 2. 应用掩码 (如果提供)
        if mask is not None:
            # 将掩码中为 0 的位置设置为一个非常小的负数,这样 softmax 后会接近 0
            attn_scores = attn_scores.masked_fill(mask == 0, -1e9)

        # 3. 计算注意力权重 (Softmax)
        attn_probs = torch.softmax(attn_scores, dim=-1)

        # 4. 加权求和 (权重 * V)
        output = torch.matmul(attn_probs, V)
        return output

    def split_heads(self, x):
        # 将输入 x 的形状从 (batch_size, seq_length, d_model)
        # 变换为 (batch_size, num_heads, seq_length, d_k)
        batch_size, seq_length, d_model = x.size()
        return x.view(batch_size, seq_length, self.num_heads, self.d_k).transpose(1, 2)

    def combine_heads(self, x):
        # 将输入 x 的形状从 (batch_size, num_heads, seq_length, d_k)
        # 变回 (batch_size, seq_length, d_model)
        batch_size, num_heads, seq_length, d_k = x.size()
        return x.transpose(1, 2).contiguous().view(batch_size, seq_length, self.d_model)

    def forward(self, Q, K, V, mask=None):
        # 1. 对 Q, K, V 进行线性变换
        Q = self.split_heads(self.W_q(Q))
        K = self.split_heads(self.W_k(K))
        V = self.split_heads(self.W_v(V))

        # 2. 计算缩放点积注意力
        attn_output = self.scaled_dot_product_attention(Q, K, V, mask)

        # 3. 合并多头输出并进行最终的线性变换
        output = self.W_o(self.combine_heads(attn_output))
        return output

(3)前馈神经网络

在每个 Encoder 和 Decoder 层中,多头注意力子层之后都跟着一个逐位置前馈网络(Position-wise Feed-Forward Network, FFN) 。如果说注意力层的作用是从整个序列中"动态地聚合"相关信息,那么前馈网络的作用从这些聚合后的信息中提取更高阶的特征。

这个名字的关键在于"逐位置"。它意味着这个前馈网络会独立地作用于序列中的每一个词元向量。换句话说,对于一个长度为 seq_len 的序列,这个 FFN 实际上会被调用 seq_len 次,每次处理一个词元。重要的是,所有位置共享的是同一组网络权重。这种设计既保持了对每个位置进行独立加工的能力,又大大减少了模型的参数量。这个网络的结构非常简单,由两个线性变换和一个 ReLU 激活函数组成:

FFN(x)=max⁡(0,xW1+b1)W2+b2\mathrm{FFN}(x)=\max\left(0, xW_{1}+b_{1}\right) W_{2}+b_{2}FFN(x)=max(0,xW1+b1)W2+b2

其中,xxx是注意力子层的输出。 W1,b1,W2,b2W_1,b_1,W_2,b_2W1,b1,W2,b2是可学习的参数。通常,第一个线性层的输出维度 d_ff 会远大于输入的维度 d_model(例如 d_ff = 4 * d_model),经过 ReLU 激活后再通过第二个线性层映射回 d_model 维度。这种"先扩大再缩小"的模式,被认为有助于模型学习更丰富的特征表示。

在我们的 PyTorch 骨架中,我们可以用以下代码来实现这个模块:

Python 复制代码
class PositionWiseFeedForward(nn.Module):
    """
    位置前馈网络模块
    """
    def __init__(self, d_model, d_ff, dropout=0.1):
        super(PositionWiseFeedForward, self).__init__()
        self.linear1 = nn.Linear(d_model, d_ff)
        self.dropout = nn.Dropout(dropout)
        self.linear2 = nn.Linear(d_ff, d_model)
        self.relu = nn.ReLU()

    def forward(self, x):
        # x 形状: (batch_size, seq_len, d_model)
        x = self.linear1(x)
        x = self.relu(x)
        x = self.dropout(x)
        x = self.linear2(x)
        # 最终输出形状: (batch_size, seq_len, d_model)
        return x

(4)残差连接与层归一化

在 Transformer 的每个编码器和解码器层中,所有子模块(如多头注意力和前馈网络)都被一个 Add & Norm 操作包裹。这个组合是为了保证 Transformer 能够稳定训练。

这个操作由两个部分组成:

  • 残差连接 (Add) :该操作将子模块的输入 x 直接加到该子模块的输出 Sublayer(x) 上。这一结构解决了深度神经网络中的梯度消失 (Vanishing Gradients) 问题。在反向传播时,梯度可以绕过子模块直接向前传播,从而保证了即使网络层数很深,模型也能得到有效的训练。其公式可以表示为:Output=x+Sublayer(x)\text{Output} = x + \text{Sublayer}(x)Output=x+Sublayer(x)。
  • 层归一化 (Norm) :该操作对单个样本的所有特征进行归一化,使其均值为0,方差为1。这解决了模型训练过程中的内部协变量偏移 (Internal Covariate Shift) 问题,使每一层的输入分布保持稳定,从而加速模型收敛并提高训练的稳定性。

5 位置编码

我们已经了解,Transformer 的核心是自注意力机制,它通过计算序列中任意两个词元之间的关系来捕捉依赖。然而,这种计算方式有一个固有的问题:它本身不包含任何关于词元顺序或位置的信息。对于自注意力来说,"agent learns" 和 "learns agent" 这两个序列是完全等价的,因为它只关心词元之间的关系,而忽略了它们的排列。为了解决这个问题,Transformer 引入了位置编码 (Positional Encoding)

位置编码的核心思想是,为输入序列中的每一个词元嵌入向量,都额外加上一个能代表其绝对位置和相对位置信息的"位置向量"。这个位置向量不是通过学习得到的,而是通过一个固定的数学公式直接计算得出。这样一来,即使两个词元(例如,两个都叫 agent 的词元)自身的嵌入是相同的,但由于它们在句子中的位置不同,它们最终输入到 Transformer 模型中的向量就会因为加上了不同的位置编码而变得独一无二。原论文中提出的位置编码使用正弦和余弦函数来生成,其公式如下:

PE(pos,2i)=sin⁡(pos100002i/dmodel),PE_{(pos,2i)}=\sin\left(\frac{pos}{10000^{2i/d_{\text{model}}}}\right),PE(pos,2i)=sin(100002i/dmodelpos),

PE(pos,2i+1)=cos⁡(pos100002i/dmodel)PE_{(pos,2i+1)}=\cos\left(\frac{pos}{10000^{2i/d_{\text{model}}}}\right)PE(pos,2i+1)=cos(100002i/dmodelpos)

其中:

  • pospospos 是词元在序列中的位置(例如,000,111,222,...)
  • iii 是位置向量中的维度索引(从 000 到 dmodel/2d_{\text{model}}/2dmodel/2)
  • dmodeld_{\text{model}}dmodel是词嵌入向量的维度(与我们模型中定义的一致)

现在,我们来实现 PositionalEncoding 模块,并完成我们 Transformer 骨架代码的最后一部分。

python 复制代码
class PositionalEncoding(nn.Module):
    """
    为输入序列的词嵌入向量添加位置编码。
    """
    def __init__(self, d_model: int, dropout: float = 0.1, max_len: int = 5000):
        super().__init__()
        self.dropout = nn.Dropout(p=dropout)

        # 创建一个足够长的位置编码矩阵
        position = torch.arange(max_len).unsqueeze(1)
        div_term = torch.exp(torch.arange(0, d_model, 2) * (-math.log(10000.0) / d_model))

        # pe (positional encoding) 的大小为 (max_len, d_model)
        pe = torch.zeros(max_len, d_model)

        # 偶数维度使用 sin, 奇数维度使用 cos
        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)

        # 将 pe 注册为 buffer,这样它就不会被视为模型参数,但会随模型移动(例如 to(device))
        self.register_buffer('pe', pe.unsqueeze(0))

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # x.size(1) 是当前输入的序列长度
        # 将位置编码加到输入向量上
        x = x + self.pe[:, :x.size(1)]
        return self.dropout(x)

本小节主要是为了帮助理解 Transformer 的宏观结构和内部每个模块的运作细节。由于是为了补充智能体学习中大模型的知识体系,也就不再继续往下深入实现。至此,我们已经为理解现代大语言模型打下了坚实的架构基础。在下一节中,我们将探讨 Decoder-Only 架构,看看它是如何基于 Transformer 的思想演变而来。

3 Decoder-Only 架构

前面一节中,我们动手构建了一个完整的Transformer 模型,它能在很多端到端的场景表现出色。但是当任务转换为构建一个与人对话、创作、作为智能体大脑的通用模型时,或许我们并不需要那么复杂的结构。

Transformer的设计哲学是"先理解,再生成"。编码器负责深入理解输入的整个句子,形成一个包含全局信息的上下文记忆,然后解码器基于这份记忆来生成翻译。但 OpenAI 在开发 GPT (Generative Pre-trained Transformer) 时,提出了一个更简单的思想[5]语言的核心任务,不就是预测下一个最有可能出现的词吗?

无论是回答问题、写故事还是生成代码,本质上都是在一个已有的文本序列后面,一个词一个词地添加最合理的内容。基于这个思想,GPT 做了一个大胆的简化:它完全抛弃了编码器,只保留了解码器部分。 这就是 Decoder-Only 架构的由来。

Decoder-Only 架构的工作模式被称为自回归 (Autoregressive) 。这个听起来很专业的术语,其实描述了一个非常简单的过程:

  1. 给模型一个起始文本(例如 "Datawhale Agent is")。
  2. 模型预测出下一个最有可能的词(例如 "a")。
  3. 模型将自己刚刚生成的词 "a" 添加到输入文本的末尾,形成新的输入("Datawhale Agent is a")。
  4. 模型基于这个新输入,再次预测下一个词(例如 "powerful")。
  5. 不断重复这个过程,直到生成完整的句子或达到停止条件。

模型就像一个在玩"文字接龙"的游戏,它不断地"回顾"自己已经写下的内容,然后思考下一个字该写什么。

你可能会问,解码器是如何保证在预测第 t 个词时,不去"偷看"第 t+1 个词的答案呢?

答案就是掩码自注意力 (Masked Self-Attention) 。在 Decoder-Only 架构中,这个机制变得至关重要。它的工作原理非常巧妙:

在自注意力机制计算出注意力分数矩阵(即每个词对其他所有词的关注度得分)之后,但在进行 Softmax 归一化之前,模型会应用一个"掩码"。这个掩码会将所有位于当前位置之后(即目前尚未观测到)的词元对应的分数,替换为一个非常大的负数。当这个带有负无穷分数的矩阵经过 Softmax 函数时,这些位置的概率就会变为 0。这样一来,模型在计算任何一个位置的输出时,都从数学上被阻止了去关注它后面的信息。这种机制保证了模型在预测下一个词时,能且仅能依赖它已经见过的、位于当前位置之前的所有信息,从而确保了预测的公平性和逻辑的连贯性。

Decoder-Only 架构的优势

这种看似简单的架构,却带来了巨大的成功,其优势在于:

  • 训练目标统一:模型的唯一任务就是"预测下一个词",这个简单的目标非常适合在海量的无标注文本数据上进行预训练。
  • 结构简单,易于扩展:更少的组件意味着更容易进行规模化扩展。今天的 GPT-4、Llama 等拥有数千亿甚至万亿参数的巨型模型,都是基于这种简洁的架构。
  • 天然适合生成任务:其自回归的工作模式与所有生成式任务(对话、写作、代码生成等)完美契合,这也是它能成为构建通用智能体基础的核心原因。

总而言之,从 Transformer 的解码器演变而来的 Decoder-Only 架构,通过"预测下一个词"这一简单的范式,开启了我们今天所处的大语言模型时代。

4 与大语言模型交互

1 与大语言模型交互

1.1 提示工程

如果我们把大语言模型比作一个能力极强的"大脑",那么提示 (Prompt) 就是我们与这个"大脑"沟通的语言。提示工程,就是研究如何设计出精准的提示,从而引导模型产生我们期望输出的回复。对于构建智能体而言,一个精心设计的提示能让智能体之间协作分工变得高效。

(1)模型采样参数

在使用大模型时,你会经常看到类似Temperature这类的可配置参数,其本质是通过调整模型对 "概率分布" 的采样策略,让输出匹配具体场景需求,配置合适的参数可以提升Agent在特定场景的性能。

传统的概率分布是由 Softmax 公式计算得到的:pi=ezi∑j=1kezjp_i = \frac{e^{z_i}}{\sum_{j=1}^k e^{z_j}}pi=∑j=1kezjezi,采样参数的本质就是在此基础上,根据不同策略"重新调整"或"截断"分布,从而改变大模型输出的下一个token。

Temperature:温度是控制模型输出 "随机性" 与 "确定性" 的关键参数。其原理是引入温度系数T>0T\gt0T>0,将 Softmax 改写为pi(T)=ezi/T∑j=1kezj/Tp_i^{(T)} = \frac{e^{z_i / T}}{\sum_{j=1}^k e^{z_j / T}}pi(T)=∑j=1kezj/Tezi/T。

当T变小时,分布"更加陡峭",高概率项权重进一步放大,生成更"保守"且重复率更高的文本。当T变大时,分布"更加平坦",低概率项权重提升,生成更"多样"但可能出现不连贯的内容。

  • 低温度(0 ⩽\leqslant⩽ Temperature <\lt< 0.3)时输出更 "精准、确定"。适用场景: 事实性任务:如问答、数据计算、代码生成; 严谨性场景:法律条文解读、技术文档撰写、学术概念解释等场景。

  • 中温度(0.3 ⩽\leqslant⩽ Temperature <\lt< 0.7):输出 "平衡、自然"。适用场景: 日常对话:如客服交互、聊天机器人; 常规创作:如邮件撰写、产品文案、简单故事创作。

  • 高温度(0.7 ⩽\leqslant⩽ Temperature <\lt< 2):输出 "创新、发散"。适用场景: 创意性任务:如诗歌创作、科幻故事构思、广告 slogan brainstorm、艺术灵感启发; 发散性思考。

Top-k :其原理是将所有 token 按概率从高到低排序,取排名前 k 个的 token 组成 "候选集",随后对筛选出的 k 个 token 的概率进行 "归一化": \\hat{p}*i = \\frac{p_i}{\\sum*{j \\in \\text{候选集}} p_j}

  • 与温度采样的区别与联系:温度采样通过温度 T 调整所有 token 的概率分布(平滑或陡峭),不改变候选 token 的数量(仍考虑全部 N 个)。Top-k 采样通过 k 值限制候选 token 的数量(只保留前 k 个高概率 token),再从其中采样。当k=1时输出完全确定,退化为 "贪心采样"。

Top-p :其原理是将所有 token 按概率从高到低排序,从排序后的第一个 token 开始,逐步累加概率,直到累积和首次达到或超过阈值 p: ∑i∈Sp(i)≥p\sum_{i \in S} p_{(i)} \geq p∑i∈Sp(i)≥p,此时累加过程中包含的所有 token 组成 "核集合",最后对核集合进行归一化。

  • 与Top-k的区别与联系:相对于固定截断大小的 Top-k,Top-p 能动态适应不同分布的"长尾"特性,对概率分布不均匀的极端情况的适应性更好。

在文本生成中,当同时设置 Top-p、Top-k 和温度系数时,这些参数会按照分层过滤的方式协同工作,其优先级顺序为:温度调整→Top-k→Top-p。温度调整整体分布的陡峭程度,Top-k 会先保留概率最高的 k 个候选,然后 Top-p 会从 Top-k 的结果中选取累积概率≥p 的最小集合作为最终的候选集。不过,通常 Top-k 和 Top-p 二选一即可,若同时设置,实际候选集为两者的交集。

需要注意的是,如果将温度设置为 0,则 Top-k 和 Top-p 将变得无关紧要,因为最有可能的 Token 将成为下一个预测的 Token;如果将 Top-k 设置为 1,温度和 Top-p 也将变得无关紧要,因为只有一个 Token 通过 Top-k 标准,它将是下一个预测的 Token。

(2)零样本、单样本与少样本提示

根据我们给模型提供示例(Exemplar)的数量,提示可以分为三种类型。为了更好地理解它们,让我们以一个情感分类任务为例,目标是让模型判断一段文本的情感色彩(如正面、负面或中性)。

零样本提示 (Zero-shot Prompting) 这指的是我们不给模型任何示例,直接让它根据指令完成任务。这得益于模型在海量数据上预训练后获得的强大泛化能力。

案例: 我们直接向模型下达指令,要求它完成情感分类任务。

Python 复制代码
文本:Datawhale的AI Agent课程非常棒!
情感:正面

单样本提示 (One-shot Prompting) 我们给模型提供一个完整的示例,向它展示任务的格式和期望的输出风格。

案例: 我们先给模型一个完整的"问题-答案"对作为示范,然后提出我们的新问题。

Python 复制代码
文本:这家餐厅的服务太慢了。
情感:负面

文本:Datawhale的AI Agent课程非常棒!
情感:

模型会模仿给出的示例格式,为第二段文本补全"正面"。

少样本提示 (Few-shot Prompting) 我们提供多个示例,这能让模型更准确地理解任务的细节、边界和细微差别,从而获得更好的性能。

案例: 我们提供涵盖了不同情况的多个示例,让模型对任务有更全面的理解。

Python 复制代码
文本:这家餐厅的服务太慢了。
情感:负面

文本:这部电影的情节很平淡。
情感:中性

文本:Datawhale的AI Agent课程非常棒!
情感:

模型会综合所有示例,更准确地将最后一句的情感分类为"正面"。

(3)指令调优的影响

早期的 GPT 模型(如 GPT-3)主要是"文本补全"模型,它们擅长根据前面的文本续写,但不一定能很好地理解并执行人类的指令。

指令调优 (Instruction Tuning) 是一种微调技术,它使用大量"指令-回答"格式的数据对预训练模型进行进一步的训练。经过指令调优后,模型能更好地理解并遵循用户的指令。我们今天日常工作学习中使用的所有模型(如 ChatGPT, DeepSeek, Qwen)都是其模型家族中经过指令调优过的模型。

  • 对"文本补全"模型的提示(你需要用少样本提示"教会"模型做什么):
Plain 复制代码
这是一段将英文翻译成中文的程序。
英文:Hello
中文:你好
英文:How are you?
中文:
  • 对"指令调优"模型的提示(你可以直接下达指令):
Plain 复制代码
请将下面的英文翻译成中文:
How are you?

指令调优的出现,极大地简化了我们与模型交互的方式,使得直接、清晰的自然语言指令成为可能。

(4)基础提示技巧

角色扮演 (Role-playing) 通过赋予模型一个特定的角色,我们可以引导它的回答风格、语气和知识范围,使其输出更符合特定场景的需求。

Plain 复制代码
# 案例
你现在是一位资深的Python编程专家。请解释一下Python中的GIL(全局解释器锁)是什么,要让一个初学者也能听懂。

上下文示例 (In-context Example) 这与少样本提示的思想一致,通过在提示中提供清晰的输入输出示例,来"教会"模型如何处理我们的请求,尤其是在处理复杂格式或特定风格的任务时非常有效。

Plain 复制代码
# 案例
我需要你从产品评论中提取产品名称和用户情感。请严格按照下面的JSON格式输出。

评论:这款"星尘"笔记本电脑的屏幕显示效果惊人,但我不太喜欢它的键盘手感。
输出:{"product_name": "星尘笔记本电脑", "sentiment": "混合"}

评论:我刚买的"声动"耳机音质很棒,续航也超出了我的预期!
输出:

(5)思维链

对于需要逻辑推理、计算或多步骤思考的复杂问题,直接让模型给出答案往往容易出错。思维链 (Chain-of-Thought, CoT) 是一种强大的提示技巧,它通过引导模型"一步一步地思考",提升了模型在复杂任务上的推理能力。

实现 CoT 的关键,是在提示中加入一句简单的引导语,如"请逐步思考"或"Let's think step by step"。

Plain 复制代码
# 思维链提示
一个篮球队在一个赛季的80场比赛中赢了60%。在接下来的赛季中,他们打了15场比赛,赢了12场。两个赛季的总胜率是多少?
请一步一步地思考并解答。

>>>
(模型可能会输出)
好的,我们来一步步计算。
第一步:计算第一个赛季赢得的比赛数。
80场 * 60% = 48场。
第二步:计算两个赛季的总比赛数和总胜利数。
总比赛数 = 80 + 15 = 95场。
总胜利数 = 48 + 12 = 60场。
第三步:计算总胜率。
总胜率 = (总胜利数 / 总比赛数) * 100% = (60 / 95) * 100% ≈ 63.16%。
所以,两个赛季的总胜率约为63.16%。

通过显式地展示其推理过程,模型不仅更容易得出正确的答案,也让它的回答变得更可信、更易于我们检查和纠正。

1.2 文本分词

我们知道,计算机本质上只能理解数字。因此,在将自然语言文本喂给大语言模型之前,必须先将其转换成模型能够处理的数字格式。这个将文本序列转换为数字序列的过程,就叫做分词 (Tokenization)分词器 (Tokenizer) 的作用,就是定义一套规则,将原始文本切分成一个个最小的单元,我们称之为词元 (Token)

1.2.1 为何需要分词

早期的自然语言处理任务可能会采用简单的分词策略:

  • 按词分词 (Word-based) :直接用空格或标点符号将句子切分成单词。这种方法很直观,但也面临挑战:
    • 词表爆炸与未登录词:一个语言的词汇量是巨大的,如果每个词都作为一个独立的词元,词表会变得难以管理。更糟糕的是,模型将无法处理任何未在词表中出现过的词(例如 "DatawhaleAgent"),这种现象我们称为"未登录词" (Out-Of-Vocabulary, OOV)。
    • 语义关联的缺失:模型难以捕捉词形相近的词之间的语义关系。例如,"look"、"looks" 和 "looking" 会被视为三个完全不同的词元,尽管它们有共同的核心含义。同样,训练数据中的低频词由于出现次数少,其语义也难以被模型充分学习。
  • 按字符分词 (Character-based) :将文本切分成单个字符。这种方法词表很小(例如英文字母、数字和标点),不存在 OOV 问题。但它的缺点是,单个字符大多不具备独立的语义,模型需要花费更多的精力去学习如何将字符组合成有意义的词,导致学习效率低下。

为了兼顾词表大小和语义表达,现代大语言模型普遍采用子词分词 (Subword Tokenization) 算法。它的核心思想是:将常见的词(如 "agent")保留为完整的词元,同时将不常见的词(如 "Tokenization")拆分成多个有意义的子词片段(如 "Token" 和 "ization")。这样既控制了词表的大小,又能让模型通过组合子词来理解和生成新词。

1.2.2 字节对编码算法解析

字节对编码 (Byte-Pair Encoding, BPE) 是最主流的子词分词算法之一[6],GPT系列模型就采用了这种算法。其核心思想非常简洁,可以理解为一个"贪心"的合并过程:

  1. 初始化:将词表初始化为所有在语料库中出现过的基本字符。
  2. 迭代合并:在语料库上,统计所有相邻词元对的出现频率,找到频率最高的一对,将它们合并成一个新的词元,并加入词表。
  3. 重复:重复第 2 步,直到词表大小达到预设的阈值。

案例演示: 假设我们的迷你语料库是 {"hug": 1, "pug": 1, "pun": 1, "bun": 1},并且我们想构建一个大小为 10 的词表。BPE 的训练过程可以用下表来表示:

训练结束后,词表大小达到 10,我们就得到了新的分词规则。现在,对于一个未见过的词 "bug",分词器会先查找 "bug" 是否在词表中,发现不在;然后查找 "bu",发现不在;最后查找 "b" 和 "ug",发现都在,于是将其切分为 ['b', 'ug']

下面我们用一段简单的 Python 代码来模拟上述过程:

python 复制代码
import re, collections

def get_stats(vocab):
    """统计词元对频率"""
    pairs = collections.defaultdict(int)
    for word, freq in vocab.items():
        symbols = word.split()
        for i in range(len(symbols)-1):
            pairs[symbols[i],symbols[i+1]] += freq
    return pairs

def merge_vocab(pair, v_in):
    """合并词元对"""
    v_out = {}
    bigram = re.escape(' '.join(pair))
    p = re.compile(r'(?<!\S)' + bigram + r'(?!\S)')
    for word in v_in:
        w_out = p.sub(''.join(pair), word)
        v_out[w_out] = v_in[word]
    return v_out

# 准备语料库,每个词末尾加上</w>表示结束,并切分好字符
vocab = {'h u g </w>': 1, 'p u g </w>': 1, 'p u n </w>': 1, 'b u n </w>': 1}
num_merges = 4 # 设置合并次数

for i in range(num_merges):
    pairs = get_stats(vocab)
    if not pairs:
        break
    best = max(pairs, key=pairs.get)
    vocab = merge_vocab(best, vocab)
    print(f"第{i+1}次合并: {best} -> {''.join(best)}")
    print(f"新词表(部分): {list(vocab.keys())}")
    print("-" * 20)

>>>
第1次合并: ('u', 'g') -> ug
新词表(部分): ['h ug </w>', 'p ug </w>', 'p u n </w>', 'b u n </w>']
--------------------
第2次合并: ('ug', '</w>') -> ug</w>
新词表(部分): ['h ug</w>', 'p ug</w>', 'p u n </w>', 'b u n </w>']
--------------------
第3次合并: ('u', 'n') -> un
新词表(部分): ['h ug</w>', 'p ug</w>', 'p un </w>', 'b un </w>']
--------------------
第4次合并: ('un', '</w>') -> un</w>
新词表(部分): ['h ug</w>', 'p ug</w>', 'p un</w>', 'b un</w>']
--------------------
```

这段代码清晰地展示了 BPE 算法如何通过迭代合并最高频的相邻词元对,来逐步构建和扩充词表的过程。

后续的许多算法都是在BPE的基础上进行优化的。其中,Google 开发的 WordPiece 和 SentencePiece 是影响力最大的两种。

  • WordPiece :Google BERT 模型采用的算法[7]。它与 BPE 非常相似,但合并词元的标准不是"最高频率",而是"能最大化提升语料库的语言模型概率"。简单来说,它会优先合并那些能让整个语料库的"通顺度"提升最大的词元对。
  • SentencePiece :Google 开源的一款分词工具[8],Llama 系列模型采用了此算法。它最大的特点是,将空格也视作一个普通字符(通常用下划线 _ 表示)。这使得分词和解码过程完全可逆,且不依赖于特定的语言(例如,它不需要知道中文不使用空格分词)。

1.2.3 分词器对开发者的意义

理解分词算法的细节并非目的,但作为智能体的开发者,理解分词器的实际影响是重要,这直接关系到智能体的性能、成本和稳定性:

  • 上下文窗口限制 :模型的上下文窗口(如 8K, 128K)是以 Token 数量计算的,而不是字符数或单词数。同样一段话,在不同语言(如中英文)或不同分词器下,Token 数量可能相差巨大。精确管理输入长度、避免超出上下文限制是构建长时记忆智能体的基础。
  • API 成本:大多数模型 API 都是按 Token 数量计费的。了解你的文本会被如何分词,是预估和控制智能体运行成本的关键一步。
  • 模型表现的异常 :有时模型的奇怪表现根源在于分词。例如,模型可能很擅长计算 2 + 2,但对于 2+2(没有空格)就可能出错,因为后者可能被分词器视为一个独立的、不常见的词元。同样,一个词因为首字母大小写不同,也可能被切分成完全不同的 Token 序列,从而影响模型的理解。在设计提示词和解析模型输出时,考虑到这些"陷阱"有助于提升智能体的鲁棒性。

1.2.3 调用开源大语言模型

在本书的第一章,我们通过 API 来与大语言模型进行交互,以此驱动我们的智能体。这是一种快速、便捷的方式,但并非唯一的方式。对于许多需要处理敏感数据、希望离线运行或想精细控制成本的场景,将大语言模型直接部署在本地就显得至关重要。

Hugging Face Transformers 是一个强大的开源库,它提供了标准化的接口来加载和使用数以万计的预训练模型。我们将使用它来完成本次实践。

配置环境与选择模型 :为了让大多数读者都能在个人电脑上顺利运行,我们特意选择了一个小规模但功能强大的模型:Qwen/Qwen1.5-0.5B-Chat。这是一个由阿里巴巴达摩院开源的拥有约 5 亿参数的对话模型,它体积小、性能优异,非常适合入门学习和本地部署。

首先,请确保你已经安装了必要的库:

Plain 复制代码
pip install transformers torch

transformers 库中,我们通常使用 AutoModelForCausalLMAutoTokenizer 这两个类来自动加载与模型匹配的权重和分词器。下面这段代码会自动从 Hugging Face Hub 下载所需的模型文件和分词器配置,这可能需要一些时间,具体取决于你的网络速度。

Python 复制代码
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

# 指定模型ID
model_id = "Qwen/Qwen1.5-0.5B-Chat"

# 设置设备,优先使用GPU
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Using device: {device}")

# 加载分词器
tokenizer = AutoTokenizer.from_pretrained(model_id)

# 加载模型,并将其移动到指定设备
model = AutoModelForCausalLM.from_pretrained(model_id).to(device)

print("模型和分词器加载完成!")

我们来创建一个对话提示,Qwen1.5-Chat 模型遵循特定的对话模板。然后,可以将使用上一步加载的 tokenizer 将文本提示转换为模型能够理解的数字 ID(即 Token ID)。

Python 复制代码
# 准备对话输入
messages = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "你好,请介绍你自己。"}
]

# 使用分词器的模板格式化输入
text = tokenizer.apply_chat_template(
    messages,
    tokenize=False,
    add_generation_prompt=True
)

# 编码输入文本
model_inputs = tokenizer([text], return_tensors="pt").to(device)

print("编码后的输入文本:")
print(model_inputs)

>>>
{'input_ids': tensor([[151644, 8948, 198, 2610, 525, 264,  10950, 17847, 13,151645, 198, 151644, 872, 198, 108386, 37945, 100157, 107828,1773, 151645, 198, 151644, 77091, 198]], device='cuda:0'), 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]],
       device='cuda:0')}

现在可以调用模型的 generate() 方法来生成回答了。模型会输出一系列 Token ID,这代表了它的回答。

最后,我们需要使用分词器的 decode() 方法,将这些数字 ID 翻译回人类可以阅读的文本。

Python 复制代码
# 使用模型生成回答
# max_new_tokens 控制了模型最多能生成多少个新的Token
generated_ids = model.generate(
    model_inputs.input_ids,
    max_new_tokens=512
)

# 将生成的 Token ID 截取掉输入部分
# 这样我们只解码模型新生成的部分
generated_ids = [
    output_ids[len(input_ids):] for input_ids, output_ids in zip(model_inputs.input_ids, generated_ids)
]

# 解码生成的 Token ID
response = tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0]

print("\n模型的回答:")
print(response)

>>>
我叫通义千问,是由阿里云研发的预训练语言模型,可以回答问题、创作文字,还能表达观点、撰写代码。我主要的功能是在多个领域提
供帮助,包括但不限于:语言理解、文本生成、机器翻译、问答系统等。有什么我可以帮到你的吗?

当你运行完所有代码后,你将会在本地电脑上看到模型生成的关于Qwen模型的介绍。恭喜你,你已经成功地在本地部署并运行了一个开源大语言模型!

1.2.4 模型的选择

在上一节中,我们成功地在本地运行了一个小型的开源语言模型。这自然引出了一个对于智能体开发者而言至关重要的问题:在当前数百个模型百花齐放的背景下,我们应当如何为特定的任务选择最合适的模型?

选择语言模型并非简单地追求"最大、最强",而是一个在性能、成本、速度和部署方式之间进行权衡的决策过程。本节将首先梳理模型选型的几个关键考量因素,然后对当前主流的闭源与开源模型进行梳理。

由于大语言模型技术正处于高速发展阶段,新模型、新版本层出不穷,迭代速度极快。本节在撰写时力求提供当前主流模型的概览和选型考量,但请读者注意,文中所提及的具体模型版本和性能数据可能随时间推移而发生变化,且只列举了部分工作并不完整。我们更侧重于介绍其核心技术特点、发展趋势以及在智能体开发中的通用选型原则。

1.2.4.1 模型选型的关键考量

在为您的智能体选择大语言模型时,可以从以下几个维度进行综合评估:

  • 性能与能力:这是最核心的考量。不同的模型擅长的任务不同,有的长于逻辑推理和代码生成,有的则在创意写作或多语言翻译上更胜一筹。您可以参考一些公开的基准测试排行榜(如 LMSys Chatbot Arena Leaderboard)来评估模型的综合能力。
  • 成本:对于闭源模型,成本主要体现在 API 调用费用,通常按 Token 数量计费。对于开源模型,成本则体现在本地部署所需的硬件(GPU、内存)和运维上。需要根据应用的预期使用量和预算做出选择。
  • 速度(延迟):对于需要实时交互的智能体(如客服、游戏 NPC),模型的响应速度至关重要。一些轻量级或经过优化的模型(如 GPT-3.5 Turbo, Claude 3.5 Sonnet)在延迟上表现更优。
  • 上下文窗口:模型能一次性处理的 Token 数量上限。对于需要理解长文档、分析代码库或维持长期对话记忆的智能体,选择一个拥有较大上下文窗口(如 128K Token 或更高)的模型是必要的。
  • 部署方式:使用 API 的方式最简单便捷,但数据需要发送给第三方,且受限于服务商的条款。本地部署则能确保数据隐私和最高程度的自主可控,但对技术和硬件要求更高。
  • 生态与工具链:一个模型的流行程度也决定了其周边生态的成熟度。主流模型通常拥有更丰富的社区支持、教程、预训练模型、微调工具和兼容的开发框架(如 LangChain, LlamaIndex, Hugging Face Transformers),这能极大地加速开发进程,降低开发难度。选择一个拥有活跃社区和完善工具链的模型,可以在遇到问题时更容易找到解决方案和资源。
  • 可微调性与定制化:对于需要处理特定领域数据或执行特定任务的智能体,模型的微调能力至关重要。一些模型提供了便捷的微调接口和工具,允许开发者使用自己的数据集对模型进行定制化训练,从而显著提升模型在特定场景下的性能和准确性。开源模型在这方面通常提供更大的灵活性。
  • 安全性与伦理:随着大语言模型的广泛应用,其潜在的安全风险和伦理问题也日益凸显。选择模型时,需要考虑其在偏见、毒性、幻觉等方面的表现,以及服务商或开源社区在模型安全和负责任AI方面的投入。对于面向公众或涉及敏感信息的应用,模型的安全性和伦理合规性是不可忽视的考量。

1.2.4.2 闭源模型概览

闭源模型通常代表了当前 AI 技术的最前沿,并提供稳定、易用的 API 服务,是构建高性能智能体的首选。

  1. OpenAI GPT 系列:从开启大模型时代的 GPT-3,到引入 RLHF(人类反馈强化学习)、实现与人类意图对齐的 ChatGPT,再到开启多模态时代的 GPT-4,OpenAI 持续引领行业发展。最新的 GPT-5 更是将多模态能力和通用智能水平提升到新的高度,能够无缝处理文本、音频和图像输入,并生成相应的输出,其响应速度和自然度也大幅提升,尤其在实时语音对话方面表现出色。
  2. Google Gemini 系列:Google DeepMind 推出的 Gemini 系列模型是原生多模态的代表,其核心特点是能统一处理文本、代码、音视频和图像等多种模态的数据,并以其超长的上下文窗口在海量信息处理上具备优势。Gemini Ultra 是其最强大的模型,适用于高度复杂的任务;Gemini Pro 适用于广泛的任务,提供高性能和效率;Gemini Nano 则针对设备端部署进行了优化。最新的 Gemini 2.5 系列模型,如 Gemini 2.5 Pro 和 Gemini 2.5 Flash,进一步提升了推理能力和上下文窗口,特别是 Gemini 2.5 Flash 以其更快的推理速度和成本效益,适用于需要快速响应的场景。
  3. Anthropic Claude 系列:Anthropic 是一家专注于 AI 安全和负责任 AI 的公司,其 Claude 系列模型从设计之初就将 AI 安全放在首位,以其在处理长文档、减少有害输出、遵循指令方面的可靠性而闻名,深受企业级应用青睐。Claude 3 系列包括 Claude 3 Opus(最智能、性能最强)、Claude 3 Sonnet(性能与速度兼顾的平衡之选)和 Claude 3 Haiku(最快、最紧凑的模型,适用于近乎实时的交互)。最新的 Claude 4 系列模型,如 Claude 4 Opus,在通用智能、复杂推理和代码生成方面取得了显著进展,进一步提升了处理长上下文和多模态任务的能力。
  4. 国内主流模型:中国在大语言模型领域涌现出众多具有竞争力的闭源模型,以百度文心一言(ERNIE Bot)、腾讯混元(Hunyuan)、华为盘古(Pangu-α)、科大讯飞星火(SparkDesk)和月之暗面(Moonshot AI)等为代表的国产模型,在中文处理上具备天然优势,并深度赋能本土产业。

1.2.4.3 开源模型概览

开源模型为开发者提供了最高程度的灵活性、透明度和自主性,催生了繁荣的社区生态。它们允许开发者在本地部署、进行定制化微调,并拥有完整的模型控制权。

  • Meta Llama 系列:Meta 推出的 Llama 系列是开源大语言模型的重要里程碑。该系列凭借出色的综合性能、开放的许可协议和强大的社区支持,成为许多衍生项目和研究的基座。Llama 4 系列于2025年4月发布,是Meta首批采用混合专家(MoE)架构的模型,该架构通过仅激活处理特定任务所需的模型部分来显著提升计算效率。该系列包含三款定位分明的模型:LLama 4 Scout支持1000万token的上下文窗口专为长文档分析和移动端部署设计。Llama 4 Maverick专注于多模态能力,在编码、复杂推理及多语言支持方面表现卓越。Llama 4 Behemoth多项STEM基准测试中表现超越竞争对手。是Meta目前最强大的模型
  • Mistral AI 系列:来自法国的 Mistral AI 以其"小尺寸、高性能"的模型设计而闻名。其最新模型 Mistral Medium 3.1 于2025年8月发布,在代码生成、STEM推理和跨领域问答等任务上准确率与响应速度均有显著提升,基准测试表现优于Claude Sonnet 3.7与Llama 4 Maverick等同级模型。它具备原生多模态能力,可同时处理图像与文字混合输入,并内置"语调适配层",帮助企业更轻松实现符合品牌调性的输出。
  • 国内开源力量 :国内厂商和科研机构也在积极拥抱开源,例如阿里巴巴的通义千问 (Qwen) 系列和清华大学与智谱 AI 合作的 ChatGLM 系列,它们提供了强大的中文能力,并围绕自身构建了活跃的社区。

对于智能体开发者而言,闭源模型提供了"开箱即用"的便捷,而开源模型则赋予了我们"随心所欲"的定制自由。理解这两大阵营的特点和代表模型,是为我们的智能体项目做出明智技术选型的第一步。

5 大语言模型的缩放法则与局限性

大语言模型(LLMs)在近年来取得了令人瞩目的进展,其能力边界不断拓展,应用场景日益丰富。然而,这些成就的背后,离不开对模型规模、数据量和计算资源之间关系的深刻理解,即缩放法则(Scaling Laws)。同时,作为新兴技术,LLMs也面临着诸多挑战和局限性。本节将深入探讨这些核心概念,旨在帮助读者全面理解LLMs的能力边界,从而在构建智能体时扬长避短。

1 缩放法则

**缩放法则(Scaling Laws)**是近年来大语言模型领域最重要的发现之一。它揭示了模型性能与模型参数量、训练数据量以及计算资源之间存在着可预测的幂律关系。这一发现为大语言模型的持续发展提供了理论指导,阐明了增加资源投入能够系统性提升模型性能的底层逻辑。

研究发现,在对数-对数坐标系下,模型的性能(通常用损失 Loss 来衡量)与参数量、数据量和计算量这三个因素都呈现出平滑的幂律关系[9]。简单来说,只要我们持续、按比例地增加这三个要素,模型的性能就会可预测地、平滑地提升,而不会出现明显的瓶颈。这一发现为大模型的设计和训练提供了清晰的指导:在资源允许的范围内,尽可能地扩大模型规模和训练数据量。

早期的研究更侧重于增加模型参数量,但 DeepMind 在 2022 年提出的"Chinchilla 定律"对此进行了重要修正[10]。该定律指出,在给定的计算预算下,为了达到最优性能,模型参数量和训练数据量之间存在一个最优配比。具体来说,最优的模型应该比之前普遍认为的要小,但需要用多得多的数据进行训练。例如,一个 700 亿参数的 Chinchilla 模型,由于使用了比 GPT-3(1750 亿参数)多 4 倍的数据进行训练,其性能反而超越了后者。这一发现纠正了"越大越好"的片面认知,强调了数据效率的重要性,并指导了后续许多高效大模型(如 Llama 系列)的设计。

缩放法则最令人惊奇的产物是"能力的涌现"。所谓能力涌现,是指当模型规模达到一定阈值后,会突然展现出在小规模模型中完全不存在或表现不佳的全新能力。例如,链式思考 (Chain-of-Thought)指令遵循 (Instruction Following) 、多步推理、代码生成等能力,都是在模型参数量达到数百亿甚至千亿级别后才显著出现的。这种现象表明,大语言模型不仅仅是简单地记忆和复述,它们在学习过程中可能形成了某种更深层次的抽象和推理能力。对于智能体开发者而言,能力的涌现意味着选择一个足够大规模的模型,是实现复杂自主决策和规划能力的前提。

2 模型幻觉

模型幻觉(Hallucination) 通常指的是大语言模型生成的内容与客观事实、用户输入或上下文信息相矛盾,或者生成了不存在的事实、实体或事件。幻觉的本质是模型在生成过程中,过度自信地"编造"了信息,而非准确地检索或推理。根据其表现形式,幻觉可以被分为多种类型[11],例如:

  • 事实性幻觉 (Factual Hallucinations) : 模型生成与现实世界事实不符的信息。
  • 忠实性幻觉 (Faithfulness Hallucinations) : 在文本摘要、翻译等任务中,生成的内容未能忠实地反映源文本的含义。
  • 内在幻觉 (Intrinsic Hallucinations) : 模型生成的内容与输入信息直接矛盾。

幻觉的产生是多方面因素共同作用的结果。首先,训练数据中可能包含错误或矛盾的信息。其次,模型的自回归生成机制决定了它只是在预测下一个最可能的词元,而没有内置的事实核查模块。最后,在面对需要复杂推理的任务时,模型可能会在逻辑链条中出错,从而"编造"出错误的结论。例如:一个旅游规划 Agent,可能会为你推荐一个现实中不存在的景点,或者预订一个航班号错误的机票。

此外,大语言模型还面临着知识时效性不足和训练数据中存在的偏见等挑战。大语言模型的能力来源于其训练数据。这意味着模型所掌握的知识是其训练数据收集时的最新材料。对于在此日期之后发生的事件、新出现的概念或最新的事实,模型将无法感知或正确回答。与此同时训练数据往往包含了人类社会的各种偏见和刻板印象。当模型在这些数据上学习时,它不可避免地会吸收并反映出这些偏见[12]

为了提高大语言模型的可靠性,研究人员和开发者正在积极探索多种检测和缓解幻觉的方法:

  1. 数据层面 : 通过高质量数据清洗、引入事实性知识以及强化学习与人类反馈 (RLHF) 等方式[13],从源头减少幻觉。
  2. 模型层面: 探索新的模型架构,或让模型能够表达其对生成内容的不确定性。
  3. 推理与生成层面
    1. 检索增强生成 (Retrieval-Augmented Generation, RAG) [14]: 这是目前缓解幻觉的有效方法之一。RAG 系统通过在生成之前从外部知识库(如文档数据库、网页)中检索相关信息,然后将检索到的信息作为上下文,引导模型生成基于事实的回答。
    2. 多步推理与验证: 引导模型进行多步推理,并在每一步进行自我检查或外部验证。
    3. 引入外部工具: 允许模型调用外部工具(如搜索引擎、计算器、代码解释器)来获取实时信息或进行精确计算。

尽管幻觉问题短期内难以完全消除,但通过上述的策略,可以显著降低其发生频率和影响,提高大语言模型在实际应用中的可靠性和实用性。

相关推荐
huazi-J26 分钟前
Datawhale openclaw 课程 task2:clawX本地openclaw使用skill
llm·datawhale·openclaw·龙虾
啊阿狸不会拉杆1 小时前
《现代人工智能基础》个人解读分享
人工智能·ai·llm·aigc·agent·ml·dl
带娃的IT创业者1 小时前
国内主流大模型API调用入门与对比:DeepSeek/智谱GLM/Kimi/千问完整指南
python·大模型·api调用·kimi·千问·deepseek·智谱glm
弗锐土豆1 小时前
使用ollama运行本地大模型
llm·大语言模型·安装·ollama
华农DrLai2 小时前
什么是自动Prompt优化?为什么需要算法来寻找最佳提示词?
人工智能·算法·llm·nlp·prompt·llama
无极低码10 小时前
ecGlypher新手安装分步指南(标准化流程)
人工智能·算法·自然语言处理·大模型·rag
洛阳泰山16 小时前
MaxKB4j Docker Compose 部署指南
java·docker·llm·springboot·rag·maxkb4j
华农DrLai17 小时前
什么是Prompt工程?为什么提示词的质量决定AI输出的好坏?
数据库·人工智能·gpt·大模型·nlp·prompt
cuguanren18 小时前
MuleRun vs OpenClaw vs 网页服务:云端安全与本地自由的取舍之道
安全·大模型·llm·agent·智能体·openclaw·mulerun
马克Markorg19 小时前
OpenClaw架构学习与思考
大模型·agent·openclaw·小龙虾