LLM 中的文本 入门
到现在为止,每一篇 primer 都是在「干净的、定长的数字数据」上做事 —— 图片里的像素、 CSV 里那几列、线性代数 primer 里的 2D / 3D 向量。 但真实世界的 ML、特别是 LLM,跑的是文本。 文本是 ML 处理过最难的数据类型之一,有三个具体的原因: 它是变长的、顺序很重要、 而且每个 token 的意思都依赖上下文。 这三条合在一起,打趴了 2017 年之前的所有方法。Transformer 就是那个终于把三件事 一次性解决的架构。这一篇 primer 是「为什么需要 Transformer」的铺垫; 下一篇就是 Transformer 本身。
文本是变长的
「好的。」是 2 个 token。一篇维基百科文章是几百万个。它们都得塞进同一个模型。
前面所有 primer 里出现过的几乎每一个神经网络原语 —— 矩阵乘法、卷积、归一化 —— 都是在「形状固定」的张量上做事。图片分类器要 224 × 224 个像素。 房价回归要一个 4 维特征向量。神经网络说到底就是「定形输入 + 定形输出」的函数, 矩阵代数里到处都默认了这一点。
但文本不配合。同一个模型既要处理「好的。」(2 个 token), 也要处理一篇维基百科文章(几百万个 token),还要处理中间所有可能的长度。 既没有自然的上限,也没有自然的下限。
深度学习之前的时代有三个丑陋的修法,直到现在还偶尔在用:
- Pad(补齐)。挑一个最大长度
L, 所有比L短的输入,就一直追加一个特殊的[PAD]token, 直到长度到L。现在所有输入形状一致了 —— 但如果你的 batch 里 有一篇维基文章和一百条短推文,你就把 99% 的算力浪费在补出来的 token 上了。 - Truncate(截断)。挑一个最大长度
L, 超出的部分一刀砍掉。这样模型永远看不到长文档的尾巴。 翻译会半句没翻;摘要会丢掉真正的笑点;代码补全会看不到函数签名。 - Bag of features(词袋)。把序列结构干脆扔掉。 把每篇文档当成「词频统计」(或 TF-IDF 分数)—— 一个由词表决定、跟输入长度 完全无关的定长向量。又快又简单,但 §2 和 §3 要说的所有东西都丢了。
现代基于 Transformer 的系统在「边缘」会组合用这些技巧 (一个 mini-batch 内部还是要把所有样本补成同长度,因为 GPU 想要矩形张量), 但是核心架构对输入的每一个 token 用的是完全相同的处理方式, 不管 token 总数是多少。Transformer 里没有「写死的输入大小」; 长度 10 和长度 10000 都能跑。每个位置的计算长得一样; 总的工作量和显存才会随长度增加(臭名昭著的「平方级」—— 稍后再说)。
变长输入曾经是个稀罕事。现在它就是全部。 近几年「上下文窗口」从 1k → 4k → 32k → 128k → 1M 的每一次进步, 都是关于「怎么把这个原语推到十年前没人敢想的规模」的故事。
顺序很重要
「狗追猫」和「猫追狗」—— 同样的词,意思相反。
一旦你有办法处理变长文本,下一个想偷懒的做法很自然:把顺序丢掉、只数哪些词出现过。 这就是「词袋」(bag of words)。它是既能处理变长、又能给定长输出的最简单表示。 对某些任务(垃圾邮件检测、主题分类),这差不多就够了。 但对我们对语言模型的大多数期望来说,这是灾难性的。
词序编码的是「谁对谁做了什么」。经典例子:
A 句:The dog chased the cat.
B 句:The cat chased the dog.
BoW(A) = { the:2, dog:1, chased:1, cat:1, .:1 }
BoW(B) = { the:2, dog:1, chased:1, cat:1, .:1 } ← 完全相同同样的一组词。完全相同的词袋表示。但两句话描述的是相反的事件。 任何只看词袋的模型都分不清它们。同一个问题更大的版本遍地都是:
- 「人咬狗」(Man bites dog)—— 之所以上新闻, 正是因为「咬」的施动者和受动者顺序不寻常。
- 「我没说她偷了那笔钱」—— 强调哪个词,意思就有 7 种不同的解读。 没有词序,这一切都谈不上。
- 翻译。英语的主谓宾、日语的主宾谓、阿拉伯语的谓主宾。词序就是语法的一半。
- 代码。
x = a / b和x = b / atoken 完全一样; 只有顺序决定谁除以谁。
Transformer 之前的折中:
- n-grams。不数单个词,而是数相邻的
n元组。 Bigram 捕捉两个词的顺序,trigram 三个,以此类推。它在局部有效 —— 一个 bigram 模型知道「chased the cat」跟「the cat chased」是不一样的 —— 但是特征空间会爆炸(词表大小的 n 次方),长距离的顺序依然抓不住。 A 和 B 之间隔了一整段「if A then B」,任何合理n的 n-gram 都看不见。 - RNN。从左往右读 token,带一个隐藏状态总结至今看到的东西。 顺序是构造上就被尊重的。聪明 —— 但是是顺序的,所以训练慢, 而且隐藏状态容量有限,远处的 token 会被挤掉。
- 文本上的 1D CNN。用卷积核沿着 token 序列滑动。 能捕捉感受野内的局部顺序。并行友好。但感受野有界 —— 堆层数它线性增长, 段落级的依赖如果没堆到荒唐的深度,根本盖不住。
Transformer 的答案:位置编码(positional encodings)。每个 token 的 embedding 会在任何 attention 或 FFN 跑之前,先被打上「我在序列里的位置」这个信息 —— 位置 1 的 token 看起来跟「位置 47 的同一个 token」是不一样的。如果没有位置编码, Transformer 的 attention 本身是顺序无关的;加上之后,架构就知道每个 token 住在哪。 不同的具体方案(sinusoidal、learned、RoPE、ALiBi)在「具体怎么注入位置」上互相竞争, 但每个值得跑的 Transformer 都会以某种形式做这件事。
每个 token 都依赖上下文
「bank」紧挨「deposit」时是银行,紧挨「river」时是河边。
就算变长和顺序两件事你都搞定了,还有第三个问题在等你:一个 token 并不只有一个意思。 它的意思要由周围的 token决定。
- 多义词。「bank」可以是金融机构,也可以是河岸。 「bat」可以是夜行哺乳动物,也可以是球棒。「Apple」可以是公司,也可以是水果。 光英语里这种词就上千个。
- 代词和指代。「Alice 问了 Bob 一个问题。他不知道答案。」 「他」是谁?任何懂英语的人都能答「Bob」 —— 但模型得从上下文里推出来, 而那个上下文常常隔了很多个 token。
- 省略。「我去了商店。Bob 也去了。」Bob 做了什么?去了商店。 第二句省略了动词「去了」;你得从前一句把它拉过来。
- 语气和反讽。「哦,太棒了。」意思可以是「棒」, 也可以是「这是我人生中最糟的一天」,由周围的一切决定。
Transformer 之前,「一个词是什么意思」的答案是 embedding: 每个词训练一个定长向量。Word2Vec(2013)和 GloVe(2014)给词表里的每个 token 学了一个稠密向量。「king」靠近「queen」,「Paris」靠近「France」。 相对于 one-hot 是巨大的飞跃, 现代大多数 NLP 流水线还是从「embedding 查表」之类的东西开始的。
但 Word2Vec 给「bank」的每次出现都同一个向量。 不管句子是「she sat on the river bank」还是「she walked into the bank」, bank 这个 token 一开始的表示是一模一样的。任何意义都得在下游、 在 embedding 之上的某个东西里再恢复出来 —— RNN 是可以做的,慢慢做, 边读边把上下文混进去。但 embedding 本身是上下文无关的。
修法事后看很自然:不要用定向 embedding,而是算上下文化的(contextualized)embedding —— 让向量本身依赖于周围的 token。ELMo(2018)用双向 LSTM 做了这件事。 BERT 和 GPT(2018)用 Transformer 做了,然后基本上现在所有人都这么做。 在现代模型里,「river bank」里的「bank」和「money bank」里的「bank」, 过了第一个 Transformer 层之后,向量就已经完全不一样了。Transformer 就是干这个的。
上下文依赖不仅仅是「多义词」这一件事 —— 它在语言里无处不在。 每个 token 的意思都是「序列里另一个任意子集」的函数。 相关上下文有时离你 2 个 token(「not good」); 有时离 200 个(「他」,指段落开头出现的某个角色); 有时是整个之前的对话。变距离依赖是常态,不是特例。 架构必须允许任何 token 关注任何其它 token,而且代价要小到每一层都能做。 这就是 attention。
Transformer 之前的图景
每个旧方法都只能搞定三个性质中的一部分。
三个要求都摆出来之后 —— 变长、顺序、上下文 —— 自然的问题是「Transformer之前大家是怎么办的?」答案是「一连串部分解决方案,每个都搞定一部分,代价是丢另一部分」。 按大致的时间顺序列一下菜单:
- Bag of words / TF-IDF —— 处理变长(输出大小是词表决定的、固定的), 但是顺序和上下文都丢光。在浅层分类任务上(垃圾邮件、情感、主题)居然还挺能打。
- n-grams —— 数相邻的
n元组,抓到局部顺序。 特征空间爆炸(词表的 n 次方),长距离的顺序仍然不可见。 - RNN / LSTM / GRU —— 从左到右(双向 RNN 是双向)读 token, 带一个隐藏状态。变长和顺序原生支持。上下文原则上是有的,但隐藏状态容量有限, 长序列上的梯度流脆弱(反向传播 primer §3 —— 梯度消失),而且训练是顺序的、不能并行。
- 文本上的 1D CNN —— 沿序列轴做卷积。并行友好。 能抓到感受野内的局部顺序和局部上下文,出了感受野就抓不到。 堆层数,感受野按深度线性增长,远不够覆盖文档级别的推理。
- RNN 上加 attention —— 中间的桥。 Bahdanau 等(2014)和 Luong 等(2015)给 encoder-decoder RNN 加上了 attention 机制,让解码器能看到任意一个编码器隐藏状态,而不只是最后一个。 翻译质量大幅提升。也埋下了那个伏笔:也许 RNN 本身根本就不需要?
Vaswani 等 2017 年的那篇,标题就是 「Attention Is All You Need」, 是那个伏笔的正面回答。RNN 干脆扔掉。只用 attention, 在每个位置上并行执行,堆成多层,搭在位置编码之上。
Transformer 三项全过:
- 变长 —— 行。每个位置的计算长得一样,跟序列长度无关; 只有内存和算力随长度增长。现代 128k 或 1M token 的上下文窗口, 是这个性质的延伸,不是新架构。
- 顺序 —— 行,靠显式的位置编码(原论文用的是 sinusoidal, 新模型用的是 learned / RoPE / ALiBi)。Attention 本身顺序无关; 位置编码在 attention 跑之前给每个 token 盖一个「我的位置」的章。
- 上下文 —— 行,而且是完整的。每个 token 在每一层都关注其它所有 token。 过了 L 层之后,每个 token 的表示都是所有其它 token 的函数, 权重高低由模型自己学。
代价:attention 是序列长度的平方级 —— 要让每个 token 关注每个其它 token, 就要算 n × n 对相似度。n = 1024 没事;n = 1,000,000 就是一层 「百万乘百万」次操作。整个「高效 attention」研究的产业(sparse、linear、flash、ring 等) 都是为了把这个开销削下来。但在中等长度上,这个平方代价就是「终于把三件事都搞定」的票价。
这就是铺垫。变长、顺序、上下文 —— Transformer 就是为解决这三件事而生的。 下一篇 primer 把它一层一层拆开看。