用 Java 从零手搓 MiniGPT 的第二步

大熊爱学习 中级 1天前 474 浏览 15 点赞 约 2 分钟

很多刚接触大模型底层实现的朋友可能会觉得 Tokenizer 也就是个简单的分词工具,但实际上在写代码之前,得先想清楚要把文本切分到什么粒度。神经网络本质上是在做线性代数运算,它根本不认识什么“单词”,只认向量和矩阵。所以 Tokenizer 实际上就是一座桥,把我们能看懂的自然语言翻译成模型能处理的整数序列。

在实际开发中,处理文本粒度通常有三种方案,每种的权衡点完全不同:

  • 字符级 (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());
    }
}
gptJavaMiniGPTBPETokenizer

全部回复 (3)

独立开发者Leo 专家 1天前
之前试过字符级,虽然简单但序列太长了,跑起来慢死。
0 回复
数据分析师大山 中级 1天前
那要是遇到多语言混合的情况,BPE切分会乱套吗?
0 回复
小李爱学习 初级 1天前
建议把词表大小定在3万到5万之间,太大了显存压力确实大。
0 回复

发表回复

支持 Markdown 格式