Homura's Blog TECHNICAL JOURNAL

2026.10.18

从零开始的学AI生活:2-Token、词表与 Embedding

第 2 章:Token、词表与 Embedding


1. 为什么不直接把字符串交给模型

神经网络的基本运算对象是数值张量。它不理解:

  • 字符
  • 单词
  • 中文词
  • 英文词根

它只理解固定维度的向量。因此第一步永远是:

\[\text{text} \rightarrow \text{token sequence} \rightarrow \text{integer ids} \rightarrow \text{vectors}\]

现代 LLM 的“原子单位”叫 token。它可能是:

  • 一个英文字符
  • 一个英文子词,例如 transform、##er
  • 一个常见汉字
  • 一个标点
  • 一个控制符号,例如 <|begin_of_text|>

2. 字符级、词级、子词级

字符级

优点:

  • 词表极小
  • 不存在未登录词
  • 适合教学和小型 demo

缺点:

  • 序列很长
  • 同样语义被拆得很散

词级

优点:

  • 序列短
  • 每个 token 语义完整

缺点:

  • 词表巨大
  • 遇到新词、错拼、罕见语言会很差

子词级

现代 LLM 通常采用子词,例如 BPE、WordPiece、Unigram、SentencePiece。

核心思想是:

  1. 从字符开始
  2. 统计相邻符号共现频率
  3. 把高频对合并成新 token
  4. 重复直到达到目标词表大小

这样:

  • 常见词保持完整
  • 罕见词拆成子词
  • 永远可以退回到字节或字符

3. 词表和 token id

假设词表是:

["<pad>", "你", "好", "transform", "er"]

那么文本 "你好 transformer" 可能被编码成:

[1, 2, 3, 4]

注意三点:

  1. token id 只是词表下标,本身没有语义
  2. 语义主要来自训练后的 embedding
  3. 不同模型的词表不能混用

4. One-hot 与 Embedding:token id 如何变成向量


4.1 一个句子的处理顺序

假设句子是:

I love transformers

第一步,tokenizer 先切成 token:

["I", "love", "transformers"]

第二步,查词表,把 token 变成 id:

"I"             -> 40
"love"          -> 952
"transformers"  -> 37167

得到:

[40, 952, 37167]

第三步,Embedding 层用这些 id 查表。

如果:

vocab_size = 50257
embedding_dim = 768

那么 embedding 表 (E) 的形状是:

(50257, 768)

含义是:

  • 表有 50257 行
  • 每一行对应一个 token
  • 每一行是一个 768 维向量

查表结果是:

id 40      -> E[40]      -> 768 维向量
id 952     -> E[952]     -> 768 维向量
id 37167   -> E[37167]   -> 768 维向量

最终整句话不是变成一个 768 维向量,而是变成一个矩阵:

(3, 768)

也就是:

3 个 token,每个 token 一个 768 维向量

4.2 为什么叫“查找表”

可以把 embedding 想象成一张二维表:

token id 第 0 维 第 1 维 第 2 维 第 3 维 …
0 0.12 -0.43 0.08 0.77 …
1 -0.25 0.31 -0.19 0.04 …
2 0.66 0.02 -0.51 0.23 …
3 -0.17 0.58 0.29 -0.36 …
4 0.41 -0.12 0.63 0.09 …

如果 token id 是 3,Embedding 就取:

第 3 行

得到:

[-0.17, 0.58, 0.29, -0.36, ...]

注意:

  • 这里的 3 是行号
  • 行号从 0 开始
  • E[3] 表示第 4 行,因为 0、1、2、3 中 3 是第 4 个

4.3 为什么 one-hot 乘矩阵等价于查表

假设词表大小是 5:

0, 1, 2, 3, 4

token id 3 的 one-hot 表示是:

\[[0,0,0,1,0]\]

只有第 3 个位置是 1。

令 embedding 矩阵为 \(E\),形状是:

\[(5, embedding_dim)\]

那么:

\[[0,0,0,1,0]E\]

会选中 \(E\) 的第 3 行,其他行全部乘 0,被过滤掉。

所以:

\[[0,0,0,1,0]E=E[3]\]

这就是“one-hot 乘法”和“查表”的等价关系。

区别在于:

  • one-hot 乘法要把整个 50257 维向量都乘一遍
  • 直接查表只需要取第 id 行

因此实际实现中一定用查表。


4.4 一个具体小例子

为了方便手算,把维度缩小。

假设:

vocab_size = 5
embedding_dim = 4

构造 embedding 表:

\[E= \begin{bmatrix} 0.1 & 0.2 & 0.3 & 0.4\\ -0.1 & 0.5 & 0.2 & -0.3\\ 0.7 & -0.2 & 0.1 & 0.6\\ -0.4 & 0.3 & 0.8 & 0.2\\ 0.5 & 0.1 & -0.6 & 0.4 \end{bmatrix}\]

如果 token id 是 3,则它的向量是:

\[E[3]=[-0.4,0.3,0.8,0.2]\]

如果一个句子是:

token ids = [4, 0, 3]

那么查表结果是:

\[\begin{bmatrix} 0.5 & 0.1 & -0.6 & 0.4\\ 0.1 & 0.2 & 0.3 & 0.4\\ -0.4 & 0.3 & 0.8 & 0.2 \end{bmatrix}\]

形状:

(3, 4)

也就是:

3 个 token,每个 token 4 维向量

4.5 nn.Embedding 做了什么

PyTorch 代码:

embedding = nn.Embedding(50257, 768)

等价于创建:

50257 行
768 列

的可学习参数表。

调用时:

input_ids = torch.tensor([[40, 952, 37167]])
vectors = embedding(input_ids)

形状变化:

input_ids: (1, 3)
vectors:   (1, 3, 768)

含义:

  • 1:批里有 1 个句子
  • 3:句子有 3 个 token
  • 768:每个 token 的向量维度

如果是批处理:

input_ids: (batch_size, seq_len)
vectors:   (batch_size, seq_len, embedding_dim)

4.6 为什么 embedding 可学习

初始时,embedding 表里的数值通常是随机初始化的。

例如:

token "love" -> [0.12, -0.33, 0.08, ...]

这些数字刚开始没有语义。

训练时,模型会根据损失函数反传梯度,不断更新这张表:

训练前:love -> [0.12, -0.33, 0.08, ...]
训练后:love -> [-0.21, 0.65, 0.14, ...]

经过大量语料训练后,语义相近的 token 往往会得到相近的向量。

例如:

king  -> [...]
queen -> [...]

可能在某些维度上呈现相似的Pattern。

所以:

Embedding 的语义不是人工写进去的,而是训练出来的。


4.7 为什么不直接用 token id 本身

如果直接把 token id 40 当成数值输入模型,会出问题:

  1. 数值大小没有语义
  2. 40 和 41 不代表更相似
  3. 模型难以处理离散符号

Embedding 的作用是:

把一个离散符号,映射成连续空间中的一个点。

这样模型才能在这个连续空间里做矩阵乘法、加法、距离计算和注意力。


4.8 小结

Tokenizer 先把文本切成 token 并映射成整数 id;Embedding 把每个 id 当作行号,从可学习矩阵中取出对应行,得到该 token 的稠密向量。

5. 简化 BPE

本章代码实现了一个非常小的 BPE:

  1. 输入一段文本
  2. 先切成字符
  3. 统计相邻 pair
  4. 合并出现最多的 pair
  5. 重复 --merges 次
#!/usr/bin/env python
"""Chapter 2: character tokenizer, a tiny BPE, and embeddings."""

from __future__ import annotations

import argparse
from collections import Counter
from pathlib import Path

import torch
from torch import nn


class CharacterTokenizer:
    def __init__(self) -> None:
        self.tokens: list[str] = []
        self.token_to_id: dict[str, int] = {}

    def fit(self, text: str) -> None:
        self.tokens = sorted(set(text))
        self.token_to_id = {token: index for index, token in enumerate(self.tokens)}

    def encode(self, text: str) -> list[int]:
        return [self.token_to_id[token] for token in text]

    def decode(self, ids: list[int]) -> str:
        return "".join(self.tokens[index] for index in ids)

    @property
    def vocab_size(self) -> int:
        return len(self.tokens)


class TinyBPE:
    """A deliberately small byte/character-level BPE implementation."""

    def __init__(self, num_merges: int = 50) -> None:
        self.num_merges = num_merges
        self.merges: list[tuple[str, str]] = []
        self.vocab: list[str] = []
        self.token_to_id: dict[str, int] = {}

    @staticmethod
    def _count_pairs(tokens: list[str]) -> Counter[tuple[str, str]]:
        return Counter(zip(tokens[:-1], tokens[1:]))

    @staticmethod
    def _merge(tokens: list[str], pair: tuple[str, str], replacement: str) -> list[str]:
        output: list[str] = []
        index = 0
        while index < len(tokens):
            if index + 1 < len(tokens) and (tokens[index], tokens[index + 1]) == pair:
                output.append(replacement)
                index += 2
            else:
                output.append(tokens[index])
                index += 1
        return output

    def fit(self, text: str) -> None:
        tokens = list(text)
        base_vocab = sorted(set(tokens))
        for _ in range(self.num_merges):
            pairs = self._count_pairs(tokens)
            if not pairs:
                break
            best_pair = max(pairs.items(), key=lambda item: (item[1], item[0]))[0]
            replacement = best_pair[0] + best_pair[1]
            tokens = self._merge(tokens, best_pair, replacement)
            self.merges.append(best_pair)
            if replacement not in base_vocab:
                base_vocab.append(replacement)
        self.vocab = base_vocab
        self.token_to_id = {token: index for index, token in enumerate(self.vocab)}

    def encode(self, text: str) -> list[str]:
        tokens = list(text)
        for pair in self.merges:
            replacement = pair[0] + pair[1]
            tokens = self._merge(tokens, pair, replacement)
        return tokens

    def encode_ids(self, text: str) -> list[int]:
        return [self.token_to_id[token] for token in self.encode(text)]

    @property
    def vocab_size(self) -> int:
        return len(self.vocab)


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--text-file", type=Path, default=None)
    parser.add_argument("--merges", type=int, default=50)
    parser.add_argument("--embedding-dim", type=int, default=16)
    parser.add_argument("--seed", type=int, default=1337)
    args = parser.parse_args()

    torch.manual_seed(args.seed)
    if args.text_file is not None:
        text = args.text_file.read_text(encoding="utf-8")[:50000]
    else:
        text = (
            "The transformer architecture uses self-attention. "
            "Self-attention lets each token attend to other tokens. "
            "Tokens become vectors, and vectors become context-aware representations. "
            "现代大语言模型把文本切成 token,再把 token 映射成向量。"
        )

    char_tokenizer = CharacterTokenizer()
    char_tokenizer.fit(text)

    bpe = TinyBPE(num_merges=args.merges)
    bpe.fit(text)

    char_ids = char_tokenizer.encode(text)
    bpe_ids = bpe.encode_ids(text)
    compression = len(char_ids) / max(1, len(bpe_ids))

    print(f"characters:              {len(text)}")
    print(f"character vocab size:    {char_tokenizer.vocab_size}")
    print(f"BPE vocab size:          {bpe.vocab_size}")
    print(f"character token count:   {len(char_ids)}")
    print(f"BPE token count:         {len(bpe_ids)}")
    print(f"compression ratio:       {compression:.3f}x")
    print(f"first 10 BPE merges:     {bpe.merges[:10]}")

    embedding = nn.Embedding(bpe.vocab_size, args.embedding_dim)
    sample = torch.tensor([bpe_ids[:12]], dtype=torch.long)
    vectors = embedding(sample)
    print(f"sample ids:              {sample.tolist()}")
    print(f"embedding output shape:  {tuple(vectors.shape)}")
    print(f"embedding table shape:   {tuple(embedding.weight.shape)}")


if __name__ == "__main__":
    main()

输出:

characters:              212
character vocab size:    46
BPE vocab size:          96
character token count:   212
BPE token count:         91
compression ratio:       2.330x
first 10 BPE merges:     [('e', 'n'), ('t', 'o'), ('k', 'en'), ('e', 'c'), ('e', ' '), (' ', 'to'), ('s', ' '), ('o', 'n'), ('a', 't'), (' to', 'ken')]
sample ids:              [[5, 12, 50, 22, 20, 79, 21, 11, 18, 20, 16, 77]]
embedding output shape:  (1, 12, 16)
embedding table shape:   (96, 16)

6. 和 Transformer 的关系

Transformer 不直接处理字符串。它处理的是:

\[X=[e_{t_1}, e_{t_2}, \dots, e_{t_n}]\]

其中每个 \(e_{t_i}\) 是 embedding 向量。下一章的核心问题是:

每个位置应该如何利用其他位置的信息?

7. 符号与名词详解

符号 / 名词 详细含义
token 模型处理文本的基本单位,可以是字符、子词、词或标点。
tokenizer 把字符串切成 token 序列,并把 token 映射成整数 id 的组件。
vocabulary / 词表 所有可表示 token 的集合。词表大小通常记作 vocab_size。
token id token 在词表中的下标,本身只是整数,不包含语义。
子词 介于字符和完整词之间的单位,例如 transform + er。
BPE Byte Pair Encoding,从高频相邻符号开始逐步合并的子词算法。
merge BPE 中一次合并操作,把两个相邻 token 变成一个新 token。
byte-level 以字节为最小单位,保证任何字符串都可以编码,不出现未登录字符。
one-hot 向量 只有一个位置为 1、其余为 0 的离散表示。
\(E\) embedding 矩阵,形状为 (vocab_size, embedding_dim)。
\(e_t\) 第 \(t\) 个 token 对应的 embedding 向量。
embedding 把离散 token id 映射成稠密实数向量的可学习查找表。
embedding dimension 每个 token 向量的维度,常记作 n_embd 或 d_model。
lookup 查表操作。embedding[id] 等价于 one-hot 向量乘 embedding 矩阵。
未登录词 OOV Out-of-Vocabulary,词表中不存在的词。
特殊 token 例如 <pad>、<bos>、<eos>,用于控制、填充或标记边界。
压缩率 字符数除以 token 数,用来衡量 tokenizer 缩短序列的能力。
nn.Embedding PyTorch 中可学习的 embedding 查找层。
文本向量化 把字符串转换成数值张量的过程。