用 Java 从零手搓 MiniGPT 的第二步
很多刚接触大模型底层实现的朋友可能会觉得 Tokenizer 也就是个简单的分词工具,但实际上在写代码之前,得先想清楚要把文本切分到什么粒度。神经网络本质上是在做线性代数运算,它根本不认识什么“单词”,只认向量和矩阵。所以 Tokenizer 实际上就是一座桥,把我们能看懂的自然语言翻译成模型能处理的整数序列。
为了让这个 MiniGPT 项目能快速跑通,不被复杂的 BPE 合并算法卡住,我这次实操选择了最简单的字符级方案。虽然它不是生产环境的最优解,但对于理解“编码-解码”这个闭环来说足够了。
下一篇
用 1B 的小模型去跑那些简单的分类任务 →
在实际开发中,处理文本粒度通常有三种方案,每种的权衡点完全不同:
- 字符级 (Char-level): 每个字母、空格甚至标点都算一个 token。比如 "the cat sleeps" 会被切成 ['t', 'h', 'e', ' ', 'c', 'a', 't'...]。这种方式词表极小(英文也就几十个字符),实现起来最简单,但缺点是序列长度会爆炸,模型处理起来压力大。
- 单词级 (Word-level): 整个单词作为一个 token。比如 "the cat sleeps" 直接变成 ['the', 'cat', 'sleeps']。虽然序列短了,但词表会迅速膨胀到几万个,而且最头疼的是 OOV(Out of Vocabulary)问题,只要出现一个训练集没见过的词,模型就直接卡死。
- 子词级 (Subword): 比如 GPT-2 用的 BPE 算法。它在字符和单词之间找平衡,把出现频率高的字符组合合并成一个 token。像 "sleeping" 可能会被切成 "sleep" 和 "ing"。这解决了 OOV 问题,也控制了序列长度,是目前工业界的标准做法,但实现复杂度最高。
为了让这个 MiniGPT 项目能快速跑通,不被复杂的 BPE 合并算法卡住,我这次实操选择了最简单的字符级方案。虽然它不是生产环境的最优解,但对于理解“编码-解码”这个闭环来说足够了。
在代码架构上,我把职责分成了两个类:Vocabulary 专门负责字符和 ID 的双向映射,而 Tokenizer 则调用词表来完成实际的转换。这样设计的好处是以后如果我想把字符级升级到 BPE,只需要更换 Tokenizer 的实现,而不需要重构整个流程。
具体的类结构逻辑大概是这样:
// Vocabulary 类负责维护字符到 ID 的 Map
public class Vocabulary {
private Map<Character, Integer> charToId = new HashMap<>();
private Map<Integer, Character> idToChar = new HashMap<>();
public int encode(char c) {
return charToId.getOrDefault(c, 0); // 0 作为未知字符
}
public char decode(int id) {
return idToChar.getOrDefault(id, ' ');
}
}
// Tokenizer 类负责处理字符串序列
public class Tokenizer {
private Vocabulary vocab;
public List<Integer> tokenize(String text) {
return text.chars()
.mapToObj(vocab::encode)
.collect(Collectors.toList());
}
}