ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

NLP基础:从分词、嵌入到注意力机制,构建大模型的坚实基石

NLP基础:从分词、嵌入到注意力机制,构建大模型的坚实基石

1. 项目概述:为什么我们总在“地基”上栽跟头?

最近和几个刚入行大模型的朋友聊天,发现一个挺有意思的现象。他们拿到一个开源LLM,第一件事就是去跑几个对话,看看效果酷不酷;第二件事可能就是去微调,想让它干点私活。但当问到“这个模型的词表是怎么构建的?”或者“为什么你的输入文本要先做分词,再做嵌入?”时,很多人就卡壳了,回答往往是“框架自动处理的”、“照着教程做的”。这让我想起自己刚入门时踩的那些坑:精心调参但效果死活上不去,最后发现是数据预处理时标点符号全被删了,导致模型根本学不会句子的边界信息;又或者,在尝试做领域适配时,盲目往词表里加新词,结果不仅没提升效果,反而让模型产生了严重的退化。

所以,当我把这个学习笔记系列命名为“Happy-LLM”并决定从“NLP基础”开始时,我是认真的。这不是在炒冷饭,也不是在说前置废话。恰恰相反,我认为跳过这些“基础”去直接摆弄大模型,就像在沙地上盖高楼。你可能很快能搭出一个看起来不错的架子,但任何一点风吹草动(比如数据分布稍有变化、任务稍微复杂一点),整个系统就可能摇摇欲坠。理解分词(Tokenization)、词向量(Embedding)、注意力机制(Attention)这些“老生常谈”的概念,不是为了应付考试,而是为了让你真正拥有“拆解”和“构建”LLM的能力。当模型输出一堆乱码时,你能想到可能是BPE分词在罕见词上出了问题;当微调效果不佳时,你会去检查输入表示的维度是否对齐。这份笔记,就是希望能和你一起,把这些看似枯燥的地基,一寸一寸打扎实。

2. 核心基石一:从“词”到“Token”——分词的艺术与陷阱

当我们把一句话“Hello, happy learning!”喂给模型时,它看到的并不是我们眼中的单词序列。这个转换过程,就是分词(Tokenization),它是所有文本处理的第一步,也是决定模型“世界观”的关键。

2.1 主流分词策略的底层逻辑与选择

目前,LLM领域主要有三大分词器流派:Word-based(词级)、Character-based(字符级)和Subword-based(子词级)。Word-based分词最简单,但面对未登录词(OOV)直接傻眼,且词表会膨胀到无法管理。Character-based最彻底,把一切拆成字母,词表极小,但序列长度爆炸,模型难以捕捉语义单元。因此,Subword(子词)分词成为了绝对的主流,它完美地在“词义”和“灵活性”之间取得了平衡。

Subword分词的王者是Byte Pair Encoding (BPE)及其变种(如GPT系列用的BPE,BERT用的WordPiece)。它们的核心思想是:从字符开始,通过不断合并最高频的相邻符号对,来逐步构建词表。

举个例子,假设我们的语料里“happy”和“learning”出现很多。BPE算法会:

  1. 初始词表就是所有字符:h, a, p, p, y, l, e, a, r, n, i, n, g
  2. 统计所有相邻符号对频率,发现pp经常连在一起,于是合并成pp,加入词表。
  3. 继续统计,ha可能合并成happy合并成ppy
  4. 最终,“happy”可能被编码为h app yha ppy等子词单元。

这里的关键不是算法本身,而是其带来的深刻影响:

  • 词表是数据分布的浓缩:你的分词器在《华尔街日报》语料上训练,和在网络小说语料上训练,得到的词表天差地别。前者会有大量金融术语子词,后者则可能包含“yyds”、“蚌埠住了”这样的网络流行语编码。用错词表,相当于让一个只学过文言文的人去理解现代白话文。
  • 分词结果直接影响模型效率:一个长词被切分成多个子词,意味着模型需要处理更长的序列,计算量和内存消耗随之增加。同时,碎片化的分词也会增加模型理解整体语义的难度。

实操心得:分词器的“冷启动”问题很多新手会直接使用预训练模型(如bert-base-uncased)自带的分词器来处理自己的数据,这通常没问题。但当你处理极度垂直的领域(如医学论文、法律条文、程序代码)时,预训练词表可能严重不够用。这时,你需要考虑在自己的领域语料上重新训练一个BPE分词器。工具(如Hugging Face的tokenizers库)很简单,但关键在于语料的选择要有代表性,并且要仔细评估新词表的大小(通常在3万到10万之间),过大会浪费,过小则覆盖不足。

2.2 中英文分词的巨大差异与实战处理

英文分词相对简单,空格是天然的分隔符。但中文完全不同,句子是连续的字符串:“今天天气很好”。应该分成“今天/天气/很/好”还是“今天/天/气/很好”?这对模型来说是根本性的差异。

主流的中文LLM(如ChatGLM、Qwen、Baichuan)通常采用基于字的分词混合分词

  • 字分词:将每个汉字作为一个独立的Token。优点是完全避免分词错误,词表稳定(约7000常用字),能很好地处理新词和专名。缺点是序列长度长,且模型需要从零学习词语、短语的语义组合,对算力和数据要求更高。
  • 词分词/混合分词:先用传统分词工具(如jieba)切词,再对长词应用BPE。优点是能利用词汇的先验知识,序列更短。但严重依赖分词工具的质量,分词错误会直接传导给模型,且对未登录词不友好。

在我们的实践中,对于通用中文大模型,更倾向于从“字”级别开始,让模型自己学习组合规律。但对于某些特定任务,例如专业文献处理,可以引入领域词典来辅助生成更优的子词单元。

# 一个简单的对比示例:使用Hugging Face Transformers库 from transformers import AutoTokenizer # 加载不同的中文分词器 tokenizer_char = AutoTokenizer.from_pretrained(“bert-base-chinese”) # 基本是字分词 tokenizer_word = AutoTokenizer.from_pretrained(“hfl/chinese-bert-wwm-ext”) # 使用全词掩码,分词粒度更粗 text = “大语言模型正在改变世界。” tokens_char = tokenizer_char.tokenize(text) # 输出:[‘大’, ‘语’, ‘言’, ‘模’, ‘型’, ‘正’, ‘在’, ‘改’, ‘变’, ‘世’, ‘界’, ‘。’] print(“字分词:”, tokens_char) tokens_word = tokenizer_word.tokenize(text) # 输出:[‘大’, ‘语言’, ‘模型’, ‘正在’, ‘改变’, ‘世界’, ‘。’] print(“词/混合分词:”, tokens_word)

可以看到,不同的分词策略直接导致了不同的Token序列,这必将影响模型对句子结构的理解。

3. 核心基石二:让Token拥有意义——嵌入(Embedding)的维度世界

分词之后,我们得到了一串Token ID(数字)。但计算机无法直接理解数字的“含义”。Embedding层的作用,就是为每个Token ID赋予一个高维空间中的向量表示,这个向量能够编码该Token的语义和语法信息。

3.1 嵌入层的本质:一个可查找的语义数据库

你可以把Embedding层想象成一个巨大的表格(矩阵)。矩阵的行数就是词表大小(V),列数就是嵌入维度(D)。当输入Token ID为k时,Embedding层就简单地“查找”这个表格的第k行,取出那个D维的向量。

import torch import torch.nn as nn vocab_size = 50000 # 词表大小 embedding_dim = 768 # 嵌入维度,也是Transformer的隐藏层大小 embedding_layer = nn.Embedding(vocab_size, embedding_dim) # 假设输入一个批次的两个句子,每个句子3个token input_ids = torch.LongTensor([[101, 2054, 102], [101, 1996, 102]]) # [batch_size, seq_len] embedded_output = embedding_layer(input_ids) # [batch_size, seq_len, embedding_dim] print(embedded_output.shape) # torch.Size([2, 3, 768])

这个查找操作本身没有计算量,但这个巨大的矩阵(50000*768 ≈ 3800万参数)里的每一个数值,都是在预训练过程中,从海量文本里学出来的。语义相近的Token(如“猫”和“狗”),它们的向量在空间中的距离会更近。

3.2 维度选择:768、1024还是4096?

嵌入维度D是一个超参数,它直接决定了模型表示能力的上限。为什么常见的是768、1024、4096这些数字?

  • 模型容量与计算成本的权衡D越大,每个Token携带的信息越丰富,模型理论表达能力越强。但随之而来的是,后续所有全连接层、注意力层的参数都会成平方级增长(特别是注意力机制,计算复杂度与D相关),对显存和算力要求急剧上升。
  • 经验值BERT-base选择768作为一个在效果和效率上较好的平衡点。GPT-3等超大模型为了容纳更复杂的知识,使用了4096甚至更高的维度。对于我们自己的项目,如果资源有限,从256或384开始尝试是合理的;如果追求更好效果,768或1024是更稳妥的起点。
  • 与后续层对齐:嵌入维度通常与Transformer的隐藏层维度保持一致,以确保信息流在通过网络时维度匹配。

注意事项:嵌入层的“冷”与“热”

  • 冷启动(随机初始化):当你从头训练一个模型时,嵌入矩阵是随机初始化的。这意味着模型需要从零开始学习每个词的语义,需要大量的数据和迭代。
  • 热启动(加载预训练权重):这是最常见的做法。使用在通用语料上预训练好的嵌入矩阵,你的模型已经拥有了一个相对成熟的“语义世界”。即使在你的垂直领域数据上微调,这个起点也远比随机初始化要高得多。永远不要低估一个高质量预训练嵌入层的力量。

4. 核心基石三:注意力机制——模型如何“聚焦”

有了Token的向量表示,模型如何理解它们之间的关系呢?比如在“猫追老鼠”这句话里,模型需要知道“追”这个动作的主体是“猫”,客体是“老鼠”。这就是注意力机制(Attention)要解决的核心问题:动态地、有区分地整合序列中所有位置的信息。

4.1 自注意力(Self-Attention)的三步拆解

自注意力允许序列中的任意一个位置直接“看到”并“吸收”其他所有位置的信息。其计算过程可以拆解为三个直观的步骤:

  1. 生成问题、答案和身份牌(Q, K, V):对于每个Token的输入向量,我们通过三个不同的线性变换,生成三个新的向量:Query(查询向量)、Key(键向量)、Value(值向量)。你可以理解为,每个Token都提出了自己的“问题”(Q),亮出了自己的“身份牌”(K),并准备好了自己要提供的“信息内容”(V)。

  2. 计算亲疏关系(注意力分数):用当前Token的Q去和序列中所有Token(包括自己)的K做点积。点积值越高,说明两者的“相关性”或“匹配度”越高。例如,“追”的Q与“猫”的K点积可能很高,因为“谁在追?”这个问题,“猫”这个身份牌给出了强烈回应。然后将所有分数通过Softmax归一化,得到一组权重(和为1),这组权重就代表了当前Token应该“关注”其他Token的程度。

  3. 加权求和得到新表示:用上一步得到的权重,对所有Token的V进行加权求和。这样,“追”这个Token的新向量表示,就包含了大量来自“猫”的信息,以及少量来自“老鼠”和其他词的信息。它不再是孤立的“追”字,而是“猫追某物”这个语义单元的一部分。

# 一个极简的自注意力代码示意,忽略批次和头维度 import torch import torch.nn.functional as F def self_attention(x, W_q, W_k, W_v): # x: [seq_len, dim] Q = torch.matmul(x, W_q) # [seq_len, dim] K = torch.matmul(x, W_k) # [seq_len, dim] V = torch.matmul(x, W_v) # [seq_len, dim] attn_scores = torch.matmul(Q, K.T) # [seq_len, seq_len] attn_weights = F.softmax(attn_scores / (x.size(-1) ** 0.5), dim=-1) # 缩放点积注意力 output = torch.matmul(attn_weights, V) # [seq_len, dim] return output, attn_weights # 假设输入序列长度为3,维度为4 x = torch.randn(3, 4) W_q = torch.randn(4, 4) W_k = torch.randn(4, 4) W_v = torch.randn(4, 4) output, weights = self_attention(x, W_q, W_k, W_v) print(“注意力权重矩阵(显示每个词对其它词的关注度):”) print(weights)

4.2 多头注意力:多视角的智慧

为什么需要“多头”(Multi-Head)?单一套Q、K、V只能学习到一种类型的依赖关系(比如主谓关系)。多头注意力相当于让模型拥有多套并行的“注意力感官”。每一套都有自己的W_q, W_k, W_v参数,可以独立地学习不同子空间中的关系。例如,一个头可能专门学习语法结构(主谓宾),另一个头可能专门学习指代关系(“它”指代谁),还有一个头可能捕捉语义搭配(“剧烈运动”)。

最后,所有头的输出被拼接起来,再经过一个线性变换,融合成最终的表示。这种设计极大地增强了模型的表征能力。

5. 从基石到建筑:Transformer架构的宏观视角

理解了Tokenization、Embedding和Attention,我们就能像搭积木一样理解Transformer的编码器(Encoder)和解码器(Decoder)结构了。

5.1 编码器:理解输入的“阅读理解专家”

以BERT为代表的编码器模型,其核心任务是为输入的每个Token生成一个融合了全局上下文信息的“深度表示”。它的工作流程是一个多层堆叠的过程:

  1. 输入嵌入:Token经过分词、嵌入,并加上位置编码(告诉模型每个词的位置信息)。
  2. 编码器层堆叠:数据通过N个(例如BERT-base是12个)相同的编码器层。每一层都包含:
    • 多头自注意力子层:让每个Token纵观全局,与其他所有Token进行信息交互。
    • 前馈神经网络子层:一个简单的全连接网络,对每个Token的表示进行独立、非线性的变换,增强其表达能力。
    • 残差连接与层归一化:每个子层周围都包裹着残差连接和层归一化。这是训练深层网络的关键技巧,能有效缓解梯度消失,让模型可以堆叠得很深。
  3. 输出:经过所有层后,每个输入Token都对应一个富含上下文信息的向量。这个向量可以用于各种下游任务,比如文本分类(取[CLS]位的向量)、问答(用问题与文本所有Token的向量进行匹配)等。

编码器的特点是“双向”的,在自注意力中,每个词都能同时看到前后文,因此它非常擅长“理解”任务。

5.2 解码器:生成文本的“写作大师”

以GPT系列为代表的解码器模型,其核心任务是自回归地生成文本,即根据已经生成的所有词,预测下一个词。它的结构与编码器类似但有关键区别:

  1. 掩码自注意力:这是与编码器最核心的区别。在解码器的自注意力层中,每个Token只能“看到”它自己以及它左边的Token(过去的Token),而不能“看到”右边的Token(未来的Token)。这通过一个注意力掩码矩阵来实现,确保了生成过程在时间上的因果性——模型在预测下一个词时,无法“作弊”看到答案。
  2. 编码器-解码器注意力:在像T5、BART这样的Seq2Seq架构中,解码器还会有第二层注意力。这一层的Query来自解码器自身,而Key和Value则来自编码器的最终输出。这允许解码器在生成每一个词时,有选择地“回顾”输入序列的信息,非常适合翻译、摘要等需要理解源文本的任务。
  3. 输出与循环:解码器最后一个Token的输出向量,会通过一个线性层映射到词表大小的向量上,再经过Softmax得到下一个词的概率分布。我们根据这个分布采样(或取最大概率)得到下一个词,然后将这个词作为输入的一部分,再次送入解码器,循环往复,直到生成结束标记。

解码器的特点是“单向”和“自回归”的,它像我们人类写作一样,一个字一个字地构思和创作。

6. 实战避坑:NLP基础不牢引发的“血案”

理论懂了,代码会写了,但一到实际项目还是处处碰壁。下面分享几个我亲身经历或见同行踩过的坑,它们都根源于对基础环节的忽视。

6.1 分词不一致导致的“语义撕裂”

场景:你训练了一个客服机器人,使用SentencePiece在客服对话数据上训练了BPE分词器。上线后,用户输入“我想了解一下iPhone14ProMax的以旧换新政策”。你的分词器可能将其切分为[‘我’, ‘想’, ‘了解’, ‘一下’, ‘iPhone’, ‘14’, ‘Pro’, ‘Max’, ‘的’, ‘以旧换新’, ‘政策’]。看起来不错。

问题:但你的知识库里的相关条目,标题是“iPhone 14 Pro Max 以旧换新细则”。如果知识库的索引或检索系统使用了另一个不同的分词器(比如一个简单的空格分词),那么它可能被切分为[‘iPhone 14 Pro Max’, ‘以旧换新’, ‘细则’]。尽管人类看来两者完全一样,但在向量空间里,由于Token序列的差异,两者的相似度计算可能会严重失真,导致检索失败。

解决方案

  1. 全链路分词统一:确保从数据预处理、模型训练、到线上服务(检索、匹配)的整个流水线,使用完全相同的分词器。最好将分词器对象和词表文件作为项目资产一起保存和部署。
  2. 对输入进行规范化:在分词前,增加文本清洗和规范化步骤,例如统一全半角、繁体转简体、纠正拼写错误等,减少不必要的分词差异。

6.2 嵌入维度不匹配的“维度灾难”

场景:你找到了一个在相似任务上表现很好的预训练模型(比如一个文本分类模型),想把它作为特征提取器用到你的新任务中。该模型的嵌入维度是768。但你的新模型架构中,出于某些原因(比如想和其他模块对齐),隐藏层维度设计为512。

问题:如果你直接加载预训练模型的嵌入层权重,会发现形状是[V, 768],无法放入你维度为512的新嵌入层。强行截断或填充都会严重破坏预训练学到的语义空间,导致效果大幅下降。

解决方案

  1. 优先调整新模型架构:最推荐的做法是修改你的新模型,使其嵌入层和后续层的维度与预训练模型保持一致(768)。这样可以完整利用预训练知识。
  2. 使用投影层:如果确实无法修改维度,可以加载预训练嵌入层后,在其后添加一个可训练的线性投影层(nn.Linear(768, 512)),将768维向量映射到512维。在微调时,这个投影层和模型其他部分一起学习。虽然会损失一些信息,但比随机初始化要好。
  3. 部分初始化:如果词表有很大不同,可以只加载公共词汇部分的嵌入向量,新词的嵌入随机初始化。

6.3 忽略位置编码的“顺序迷失”

场景:你正在实现一个简易的Transformer模型。为了省事,你觉得注意力机制已经让词与词之间能互相看到,位置信息可能没那么重要,于是跳过了位置编码(Positional Encoding)。

问题:Transformer的自注意力本质上是置换不变的(Permutation Invariant)。也就是说,打乱输入句子的词序,得到的输出(不考虑位置编码)在向量空间中的集合是相同的,只是顺序变了。模型完全丧失了理解“词序”的能力。“猫追老鼠”和“老鼠追猫”对它来说可能没有区别,这显然是灾难性的。

解决方案

  • 必须添加位置信息:绝对不要省略位置编码。最常用的是正弦余弦位置编码(Sinusoidal Positional Encoding),它为每个位置生成一个独特的、固定的向量,与词嵌入相加。现在也有可学习的位置嵌入(Learned Positional Embedding),效果类似。
  • 注意长度外推:正弦编码理论上可以处理比训练时更长的序列,但效果会下降。可学习的位置嵌入则完全无法处理超长序列。对于需要处理超长文本的任务,需要关注ALiBi、RoPE等更先进的位置编码方案,它们能更好地外推。

6.4 注意力掩码使用不当的“信息泄露”

场景:在训练一个因果语言模型(如GPT)时,你需要构造注意力掩码,确保解码时看不到未来信息。你正确地处理了训练数据。但在模型部署后,进行序列生成(推理)时,你一次性将整个生成的序列输入模型来计算下一个词的概率。

问题:这造成了推理阶段的信息泄露!因为在生成第t个词时,你的输入已经包含了第t-1步及之前生成的所有词。如果你没有在推理时动态地应用因果掩码,模型在计算第t个位置的注意力时,可能会“看到”它自己(在自回归生成中,这通常意味着看到了未来的信息,因为当前步的输出会成为下一步的输入的一部分),导致生成结果出现重复、退化或逻辑错误。

解决方案

  • 推理时使用自回归解码:在生成每一个新Token时,只将历史已生成的序列输入模型,并确保注意力掩码是严格下三角的因果掩码。
  • 利用KV缓存加速:为了避免重复计算,可以使用KV缓存技术,将之前步骤中计算好的Key和Value向量缓存起来,在生成新Token时只计算当前步的Q与缓存的所有K、V的注意力。但即使使用缓存,注意力掩码的逻辑也必须正确维护,确保每一步只能看到历史信息。

7. 延伸思考:基础如何影响大模型的进阶能力?

当我们扎实地理解了这些NLP基础,再回头看大模型的许多高级特性和问题,就会有一种豁然开朗的感觉。

  • 长文本建模的瓶颈:为什么模型处理长文本困难?核心之一在于注意力机制的计算复杂度是序列长度的平方。当序列长度从512增加到8192时,计算量和显存占用会增长256倍!这就是为什么需要Flash Attention、环形注意力等优化技术。而这一切的起点,就是你对注意力计算过程有清晰的认识。
  • 上下文学习(In-Context Learning):为什么GPT-3给几个例子就能学会新任务?这依赖于模型强大的注意力机制能够在前向传播中,动态地建立示例与问题之间的关联。模型并没有更新参数,而是通过注意力权重,在给定的上下文(示例)中“即时”找到了解决问题的模式。如果你不理解注意力是如何工作的,就很难理解这种“魔法”般的现象。
  • 灾难性遗忘与持续学习:当我们用新数据微调一个大模型时,它经常会在新任务上表现很好,却忘了旧任务。从嵌入和注意力的角度看,微调过程是在调整整个模型的参数空间,包括那个巨大的嵌入矩阵和注意力权重。新数据的梯度会覆盖掉参数中存储的旧知识。理解这一点,你就会明白为什么需要像LoRA(低秩适配)这样的技术——它通过冻结原模型、只训练少量新增参数来避免直接扰动宝贵的预训练知识。

地基的深度,决定了建筑的高度和稳固性。在追逐更酷的模型架构、更炫的应用场景时,时不时回头审视一下这些基础概念,总能带来新的收获。在后续的“Happy-LLM”笔记中,我们会带着对地基的深刻理解,继续向上探索,拆解大模型的训练、微调、评估与应用。希望这篇笔记能帮你把脚下的路踩得更实一些。

返回列表