【分词:中文分词】BPE字节级分词算法实现汉字表达!

https://www.bilibili.com/video/BV1AYdiYpE65/?spm_id_from=333.788.videopod.sections\&vd_source=173edcc8f6052bd44ad224e1284119c3

一、汉字的表达:UTF-8编码机制(完全详解版)

大家好,我们继续深入讲解UTF-8编码机制。这一节我们将完全通过实际例子可运行的代码,把"0开头、10开头是什么意思"、"如何解析字节流"以及"为什么天然兼容ASCII"这三个问题讲透。

1. 先认识"字节的长相"(二进制视角)

计算机里的1个字节是8位(8个0或1)。我们先看几个真实的字节,把它们写成二进制形式:

  • 字节A:01000001
  • 字节B:10110001
  • 字节C:11001110
  • 字节D:11100100

UTF-8的核心规则是:只看每个字节的前几位,就能立刻知道它的"身份"

2. UTF-8编码规则:用例子讲"开头位"的含义

我们直接给上面4个字节"验明正身",通过例子理解规则:

规则1:以 0 开头的字节 ------ 单字节符号(自己就是完整字符)

看字节A:01000001

它的第1位是0 ,这说明:这1个字节自己就是一个完整的字符 ,不需要和其他字节拼接。

它对应的就是ASCII码里的英文字母 A(ASCII码65,二进制就是01000001)。

规则2:以 10 开头的字节 ------ 后续字节(不能单独用,必须找"头")

看字节B:10110001

它的前两位是10 ,这说明:它是一个"跟班",不能单独表示字符。它必须找到前面的"前导字节",拼在一起才有用。

规则3:以 110 开头的字节 ------ 双字节前导(后面跟1个"10开头"的跟班)

看字节C:11001110

它的前三位是110 ,这说明:它是一个"头",后面必须跟1个"10开头"的后续字节,两个拼在一起才是一个完整字符。

规则4:以 1110 开头的字节 ------ 三字节前导(后面跟2个"10开头"的跟班)

看字节D:11100100

它的前四位是1110 ,这说明:它是一个"头",后面必须跟2个"10开头"的后续字节,三个拼在一起才是一个完整字符(绝大多数汉字都是这种情况)。

3. 完整实战:用一个字节流演示解析过程

假设现在计算机收到了一串字节流(按顺序排列,全是二进制):

010000011100111010110001111001001011110110100000

我们像计算机一样,从左到右一个一个"读"

  1. 读第1个字节:01000001

    一看开头是0------单字节符号,直接解析为字母 A。搞定,下一个。

  2. 读第2个字节:11001110

    一看开头是110------双字节的"头",后面得跟1个"10开头"的跟班。不着急解析,先记住它,继续读下一个。

  3. 读第3个字节:10110001

    一看开头是10------正好是跟班!把第2个和第3个拼在一起:11001110 + 10110001

    解码后是希腊字母 α。搞定,继续下一个。

  4. 读第4个字节:11100100

    一看开头是1110------三字节的"头",后面得跟2个"10开头"的跟班。记住它,继续读。

  5. 读第5个字节:10111101

    开头是10------第一个跟班,凑齐1个,还缺1个,继续读。

  6. 读第6个字节:10100000

    开头是10------第二个跟班!把第4、5、6个拼在一起:11100100 + 10111101 + 10100000

    解码后是中文汉字

至此,整串字节流解析完了:A α

4. 为什么天然兼容ASCII?(用例子证明)

ASCII码是计算机最早的字符集,规定了128个字符(英文字母、数字、标点等),范围是0到127

我们看ASCII码的二进制长相:

  • 最小的ASCII码:0 → 二进制 00000000
  • 最大的ASCII码:127 → 二进制 01111111

关键发现 :所有ASCII码的二进制,第1位都是0

而UTF-8的规则里,"以0开头的字节,就是单字节字符"------这正好和ASCII码一模一样!

举个例子:

  • ASCII码里的字母 a 是97,二进制 01100001
  • 把它放到UTF-8里看:01100001 以0开头,单字节,解析出来还是 a

换句话说:所有ASCII码文件,直接当成UTF-8文件读,内容完全不会变------这就叫"天然兼容",不需要任何转换。

5. Python代码验证:亲手跑一遍解析流程

我们用Python复现上述解析过程,直接验证结果。这段代码包含了完整的二进制输入、转换、解码和输出过程:

python 复制代码
def verify_utf8_parsing():
    # 1. 定义我们例子中的二进制字节流(字符串形式)
    binary_strings = [
        '01000001',   # 第1个字节:单字节 A
        '11001110',   # 第2个字节:双字节前导
        '10110001',   # 第3个字节:双字节后续
        '11100100',   # 第4个字节:三字节前导
        '10111101',   # 第5个字节:三字节后续1
        '10100000'    # 第6个字节:三字节后续2
    ]

    # 2. 将二进制字符串转换为 Python 的字节对象 (bytes)
    # int(b, 2) 表示把二进制字符串 b 转换为整数
    # bytes() 把整数列表转换为原始字节串
    byte_list = [int(b, 2) for b in binary_strings]
    byte_data = bytes(byte_list)

    # 3. 打印解析过程,可视化每一步
    print("-" * 60)
    print("【输入】二进制字节流(带十六进制对照):")
    for i, b in enumerate(binary_strings, 1):
        hex_value = f"0x{int(b,2):02X}"  # 转成十六进制,方便查看
        print(f"  字节 {i}: {b}  →  {hex_value}")
    
    print("-" * 60)
    print(f"【合成】原始字节序列(十六进制): {byte_data.hex()}")
    
    # 4. 核心步骤:Python 内置 UTF-8 解码
    # 这一步内部执行的就是我们上面讲的"看开头、拼字节"逻辑
    try:
        decoded_result = byte_data.decode('utf-8')
        print("-" * 60)
        print(f"【结果】✅ UTF-8 解码成功:  {decoded_result}")
        print("-" * 60)
    except UnicodeDecodeError as e:
        print(f"【结果】❌ 解码失败: {e}")

if __name__ == "__main__":
    verify_utf8_parsing()

运行结果预览

当你运行这段代码时,会看到以下输出,完美验证我们的解析过程:

text 复制代码
------------------------------------------------------------
【输入】二进制字节流(带十六进制对照):
  字节 1: 01000001  →  0x41
  字节 2: 11001110  →  0xCE
  字节 3: 10110001  →  0xB1
  字节 4: 11100100  →  0xE4
  字节 5: 10111101  →  0xBD
  字节 6: 10100000  →  0xA0
------------------------------------------------------------
【合成】原始字节序列(十六进制): 41ceb1e4bda0
------------------------------------------------------------
【结果】✅ UTF-8 解码成功:  Aα你
------------------------------------------------------------

二、GPT的中文分词:字节级BPE算法(完全详解+代码验证)

2.1 先搞懂:BPE到底是什么?

BPE全称Byte Pair Encoding(字节对编码) ,是GPT系列大模型唯一使用的分词算法。它的核心思想极其简单:从最基础的单元开始,不断把出现频率最高的相邻两个单元合并成一个新单元,直到达到预设的词汇表大小

GPT和传统分词最大的区别是:它不认识"字",只认识"字节"。所有语言的所有文字,在它眼里都只是一堆0-255的数字(字节),它用完全相同的逻辑处理中英文、日文、阿拉伯语等所有语言。

关于utf-8的256个字节:

字节类型 二进制前缀 完整二进制范围 十进制范围 十六进制范围 核心功能 我们之前用过的例子
单字节字符 (ASCII兼容) 0 0xxxxxxx 0 ~ 127 0x00 ~ 0x7F 自己就是一个完整字符,完全兼容ASCII码 0x41 → 字母A
后续字节 (跟班字节) 10 10xxxxxx 128 ~ 191 0x80 ~ 0xBF 不能单独使用,必须跟在某个前导字节后面 0xB1 → 希腊字母α的第二个字节 0xBD → 汉字的第二个字节 0xA0 → 汉字的第三个字节
双字节前导字节 110 110xxxxx 192 ~ 223 0xC0 ~ 0xDF 多字节字符的"头",后面必须跟1个后续字节 0xCE → 希腊字母α的第一个字节
三字节前导字节 (绝大多数汉字) 1110 1110xxxx 224 ~ 239 0xE0 ~ 0xEF 多字节字符的"头",后面必须跟2个后续字节 0xE4 → 汉字的第一个字节
四字节前导字节 (生僻字/Emoji) 11110 11110xxx 240 ~ 247 0xF0 ~ 0xF7 多字节字符的"头",后面必须跟3个后续字节 0xF0 → Emoji😀的第一个字节
保留未用 (UTF-8禁止使用) 11111 11111xxx 248 ~ 255 0xF8 ~ 0xFF UTF-8标准中永久保留,不用于表示任何字符

2.2 用两个完整例子,一步步演示BPE的工作过程

例子1:英文BPE(原文提到的unfit

我们用unfit这个单词,完整走一遍BPE的合并流程:

初始状态(最基础单元)

所有单词先拆成单个字母(英文一个字母对应一个UTF-8字节)

unfitu n f i t

第1轮合并

统计所有相邻字母对的出现频率,假设在大量语料中un出现的频率最高

合并u+n → 得到新单元un

现在序列变成:un f i t

第2轮合并

继续统计相邻单元对的频率,假设it出现频率最高

合并i+t → 得到新单元it

现在序列变成:un f it

第3轮合并

假设fit出现频率最高

合并f+it → 得到新单元fit

现在序列变成:un fit

最终分词结果un fit(两个token)

这就是BPE的全部逻辑:永远合并出现次数最多的相邻对


例子2:中文BPE(原文提到的"你")

中文BPE和英文完全一样,唯一的区别是:中文一个字对应3个UTF-8字节,所以初始拆分是拆成字节,而不是拆成字。

我们用"你"这个字来演示:

  1. 先把"你"转成UTF-8字节:0xE4 0xBD 0xA0(三个字节,对应二进制11100100 10111101 10100000,就是我们第一节验证过的)
  2. 初始状态:0xE4 0xBD 0xA0(三个独立的字节单元)
  3. 第1轮合并:假设在中文语料中0xE4+0xBD出现频率极高(因为很多汉字的前两个字节都是这个组合)
    合并0xE4+0xBD → 得到新单元0xE4BD
    现在序列变成:0xE4BD 0xA0
  4. 第2轮合并:继续统计,0xE4BD+0xA0出现频率极高(因为这三个字节合起来就是"你")
    合并0xE4BD+0xA0 → 得到新单元0xE4BDA0(也就是完整的"你"字)
  5. 最终分词结果:(一个token)

关键结论 :中文BPE和英文BPE的算法代码一行都不用改,只是输入从"字母"变成了"字节"。这就是它能支持多语言的根本原因。


2.3 字节级BPE的两大核心优势(用数字说话)

优势1:彻底解决词汇表膨胀问题

分词方式 基础单元数量 最终词汇表大小(GPT-3)
基于汉字 约50000个汉字 至少需要10万+(还要加词)
字节级BPE 256个字节 50257个(固定大小)
  • 结巴分词的逻辑是"字+词":先有5万个汉字,再往上加常用词、成语,词汇表只会越来越大
  • GPT的逻辑是"字节往上拼":从256个字节开始,一点点合并出字、词、短语,最终词汇表固定在5万左右,不会膨胀

优势2:天生支持所有语言

  • 结巴分词:只能处理中文,处理日文需要另一个库,处理阿拉伯语再换一个
  • 字节级BPE:所有语言的所有字符,最终都能转成UTF-8字节。所以同一个分词器,不加任何修改就能同时处理中文、英文、日文、德文、阿拉伯语等所有语言

这就是为什么所有主流大模型(GPT、Claude、Llama、Qwen)都用字节级BPE,而不用结巴的根本原因。


2.4 代码验证:亲手跑一个简化版字节级BPE

下面是一个纯Python实现、无任何依赖的简化版字节级BPE,你可以直接运行,亲眼看到字节是如何被合并成字和词的:

python 复制代码
def flatten_token(token):
    """
    辅助函数:把任意嵌套的token(可能是int,也可能是元组)展平成原始字节列表
    """
    if isinstance(token, int):
        return [token]
    elif isinstance(token, (tuple, list)):
        result = []
        for t in token:
            result.extend(flatten_token(t))
        return result
    else:
        return []


def format_token(token):
    """
    辅助函数:把token格式化成可读的字符串(优先尝试解码成中文)
    """
    bytes_list = flatten_token(token)
    try:
        # 尝试解码
        return f"'{bytes(bytes_list).decode('utf-8')}'"
    except:
        # 如果解码失败,显示十六进制
        return str([hex(b) for b in bytes_list])


def robust_byte_level_bpe(corpus, num_merges=10):
    """
    最终健壮版字节级BPE
    """
    # 第一步:初始化
    all_tokens = []
    print("=" * 80)
    print("【初始化】语料库转成字节:")
    for text in corpus:
        byte_seq = list(text.encode('utf-8'))
        all_tokens.append(byte_seq)
        print(f"  '{text}' -> {[hex(b) for b in byte_seq]}")
    print("=" * 80)

    for merge_round in range(num_merges):
        # 第二步:统计频率(核心修改:使用展平后的逻辑来处理嵌套)
        pair_counts = {}
        # 我们需要遍历当前的token序列,找到所有相邻的"原子单元"对
        # 这里的简化逻辑:不管嵌套,只看当前列表层面的相邻元素
        for seq in all_tokens:
            if len(seq) < 2:
                continue
            for i in range(len(seq) - 1):
                # 为了能作为dict的key,我们把pair转成扁平字节的tuple
                flat_a = tuple(flatten_token(seq[i]))
                flat_b = tuple(flatten_token(seq[i + 1]))
                pair = (flat_a, flat_b)
                pair_counts[pair] = pair_counts.get(pair, 0) + 1

        if not pair_counts:
            break

        # 第三步:找到最高频对
        best_pair_flat = max(pair_counts, key=pair_counts.get)
        freq = pair_counts[best_pair_flat]

        # 我们需要在当前的token列表中找到对应的原始元素对,而不是扁平后的
        # 这里简化处理:直接在当前序列中找相邻元素,如果它们展平后等于best_pair_flat,就合并
        new_all_tokens = []
        merged_anything = False

        for seq in all_tokens:
            new_seq = []
            i = 0
            while i < len(seq):
                if i < len(seq) - 1:
                    a_flat = tuple(flatten_token(seq[i]))
                    b_flat = tuple(flatten_token(seq[i + 1]))
                    if (a_flat, b_flat) == best_pair_flat:
                        # 合并!
                        new_seq.append((seq[i], seq[i + 1]))
                        i += 2
                        merged_anything = True
                        continue
                new_seq.append(seq[i])
                i += 1
            new_all_tokens.append(new_seq)

        if not merged_anything:
            break

        all_tokens = new_all_tokens

        # 第四步:打印结果
        print(f"【第 {merge_round + 1} 轮合并】")
        # 把best_pair_flat转成可读的
        readable_a = [hex(b) for b in best_pair_flat[0]]
        readable_b = [hex(b) for b in best_pair_flat[1]]
        print(f"  最高频字节对: {readable_a} + {readable_b} (出现 {freq} 次)")
        print(f"  合并结果:")
        for original_text, seq in zip(corpus, all_tokens):
            parts = [format_token(t) for t in seq]
            print(f"    '{original_text}' -> [ {', '.join(parts)} ]")
        print("-" * 80)

    print("\n✅ 训练完成!")


# ---------------- 测试 ----------------
if __name__ == "__main__":
    training_corpus = [
        "你好", "你好", "你好吗",
        "世界", "世界", "你好世界"
    ]

    robust_byte_level_bpe(training_corpus, num_merges=8)

运行结果预览

text 复制代码
================================================================================
【初始化】语料库转成字节:
  '你好' -> ['0xe4', '0xbd', '0xa0', '0xe5', '0xa5', '0xbd']
  '你好' -> ['0xe4', '0xbd', '0xa0', '0xe5', '0xa5', '0xbd']
  '你好吗' -> ['0xe4', '0xbd', '0xa0', '0xe5', '0xa5', '0xbd', '0xe5', '0x90', '0x97']
  '世界' -> ['0xe4', '0xb8', '0x96', '0xe7', '0x95', '0x8c']
  '世界' -> ['0xe4', '0xb8', '0x96', '0xe7', '0x95', '0x8c']
  '你好世界' -> ['0xe4', '0xbd', '0xa0', '0xe5', '0xa5', '0xbd', '0xe4', '0xb8', '0x96', '0xe7', '0x95', '0x8c']
================================================================================
【第 1 轮合并】
  最高频字节对: ['0xe4'] + ['0xbd'] (出现 4 次)
  合并结果:
    '你好' -> [ ['0xe4', '0xbd'], ['0xa0'], ['0xe5'], ['0xa5'], ['0xbd'] ]
    '你好' -> [ ['0xe4', '0xbd'], ['0xa0'], ['0xe5'], ['0xa5'], ['0xbd'] ]
    '你好吗' -> [ ['0xe4', '0xbd'], ['0xa0'], ['0xe5'], ['0xa5'], ['0xbd'], ['0xe5'], ['0x90'], ['0x97'] ]
    '世界' -> [ ['0xe4'], ['0xb8'], ['0x96'], ['0xe7'], ['0x95'], ['0x8c'] ]
    '世界' -> [ ['0xe4'], ['0xb8'], ['0x96'], ['0xe7'], ['0x95'], ['0x8c'] ]
    '你好世界' -> [ ['0xe4', '0xbd'], ['0xa0'], ['0xe5'], ['0xa5'], ['0xbd'], ['0xe4'], ['0xb8'], ['0x96'], ['0xe7'], ['0x95'], ['0x8c'] ]
--------------------------------------------------------------------------------
【第 2 轮合并】
  最高频字节对: ['0xe4', '0xbd'] + ['0xa0'] (出现 4 次)
  合并结果:
    '你好' -> [ '你', ['0xe5'], ['0xa5'], ['0xbd'] ]
    '你好' -> [ '你', ['0xe5'], ['0xa5'], ['0xbd'] ]
    '你好吗' -> [ '你', ['0xe5'], ['0xa5'], ['0xbd'], ['0xe5'], ['0x90'], ['0x97'] ]
    '世界' -> [ ['0xe4'], ['0xb8'], ['0x96'], ['0xe7'], ['0x95'], ['0x8c'] ]
    '世界' -> [ ['0xe4'], ['0xb8'], ['0x96'], ['0xe7'], ['0x95'], ['0x8c'] ]
    '你好世界' -> [ '你', ['0xe5'], ['0xa5'], ['0xbd'], ['0xe4'], ['0xb8'], ['0x96'], ['0xe7'], ['0x95'], ['0x8c'] ]
--------------------------------------------------------------------------------
【第 3 轮合并】
  最高频字节对: ['0xe4', '0xbd', '0xa0'] + ['0xe5'] (出现 4 次)
  合并结果:
    '你好' -> [ ['0xe4', '0xbd', '0xa0', '0xe5'], ['0xa5'], ['0xbd'] ]
    '你好' -> [ ['0xe4', '0xbd', '0xa0', '0xe5'], ['0xa5'], ['0xbd'] ]
    '你好吗' -> [ ['0xe4', '0xbd', '0xa0', '0xe5'], ['0xa5'], ['0xbd'], ['0xe5'], ['0x90'], ['0x97'] ]
    '世界' -> [ ['0xe4'], ['0xb8'], ['0x96'], ['0xe7'], ['0x95'], ['0x8c'] ]
    '世界' -> [ ['0xe4'], ['0xb8'], ['0x96'], ['0xe7'], ['0x95'], ['0x8c'] ]
    '你好世界' -> [ ['0xe4', '0xbd', '0xa0', '0xe5'], ['0xa5'], ['0xbd'], ['0xe4'], ['0xb8'], ['0x96'], ['0xe7'], ['0x95'], ['0x8c'] ]
--------------------------------------------------------------------------------
【第 4 轮合并】
  最高频字节对: ['0xe4', '0xbd', '0xa0', '0xe5'] + ['0xa5'] (出现 4 次)
  合并结果:
    '你好' -> [ ['0xe4', '0xbd', '0xa0', '0xe5', '0xa5'], ['0xbd'] ]
    '你好' -> [ ['0xe4', '0xbd', '0xa0', '0xe5', '0xa5'], ['0xbd'] ]
    '你好吗' -> [ ['0xe4', '0xbd', '0xa0', '0xe5', '0xa5'], ['0xbd'], ['0xe5'], ['0x90'], ['0x97'] ]
    '世界' -> [ ['0xe4'], ['0xb8'], ['0x96'], ['0xe7'], ['0x95'], ['0x8c'] ]
    '世界' -> [ ['0xe4'], ['0xb8'], ['0x96'], ['0xe7'], ['0x95'], ['0x8c'] ]
    '你好世界' -> [ ['0xe4', '0xbd', '0xa0', '0xe5', '0xa5'], ['0xbd'], ['0xe4'], ['0xb8'], ['0x96'], ['0xe7'], ['0x95'], ['0x8c'] ]
--------------------------------------------------------------------------------
【第 5 轮合并】
  最高频字节对: ['0xe4', '0xbd', '0xa0', '0xe5', '0xa5'] + ['0xbd'] (出现 4 次)
  合并结果:
    '你好' -> [ '你好' ]
    '你好' -> [ '你好' ]
    '你好吗' -> [ '你好', ['0xe5'], ['0x90'], ['0x97'] ]
    '世界' -> [ ['0xe4'], ['0xb8'], ['0x96'], ['0xe7'], ['0x95'], ['0x8c'] ]
    '世界' -> [ ['0xe4'], ['0xb8'], ['0x96'], ['0xe7'], ['0x95'], ['0x8c'] ]
    '你好世界' -> [ '你好', ['0xe4'], ['0xb8'], ['0x96'], ['0xe7'], ['0x95'], ['0x8c'] ]
--------------------------------------------------------------------------------
【第 6 轮合并】
  最高频字节对: ['0xe4'] + ['0xb8'] (出现 3 次)
  合并结果:
    '你好' -> [ '你好' ]
    '你好' -> [ '你好' ]
    '你好吗' -> [ '你好', ['0xe5'], ['0x90'], ['0x97'] ]
    '世界' -> [ ['0xe4', '0xb8'], ['0x96'], ['0xe7'], ['0x95'], ['0x8c'] ]
    '世界' -> [ ['0xe4', '0xb8'], ['0x96'], ['0xe7'], ['0x95'], ['0x8c'] ]
    '你好世界' -> [ '你好', ['0xe4', '0xb8'], ['0x96'], ['0xe7'], ['0x95'], ['0x8c'] ]
--------------------------------------------------------------------------------
【第 7 轮合并】
  最高频字节对: ['0xe4', '0xb8'] + ['0x96'] (出现 3 次)
  合并结果:
    '你好' -> [ '你好' ]
    '你好' -> [ '你好' ]
    '你好吗' -> [ '你好', ['0xe5'], ['0x90'], ['0x97'] ]
    '世界' -> [ '世', ['0xe7'], ['0x95'], ['0x8c'] ]
    '世界' -> [ '世', ['0xe7'], ['0x95'], ['0x8c'] ]
    '你好世界' -> [ '你好', '世', ['0xe7'], ['0x95'], ['0x8c'] ]
--------------------------------------------------------------------------------
【第 8 轮合并】
  最高频字节对: ['0xe4', '0xb8', '0x96'] + ['0xe7'] (出现 3 次)
  合并结果:
    '你好' -> [ '你好' ]
    '你好' -> [ '你好' ]
    '你好吗' -> [ '你好', ['0xe5'], ['0x90'], ['0x97'] ]
    '世界' -> [ ['0xe4', '0xb8', '0x96', '0xe7'], ['0x95'], ['0x8c'] ]
    '世界' -> [ ['0xe4', '0xb8', '0x96', '0xe7'], ['0x95'], ['0x8c'] ]
    '你好世界' -> [ '你好', ['0xe4', '0xb8', '0x96', '0xe7'], ['0x95'], ['0x8c'] ]
--------------------------------------------------------------------------------

✅ 训练完成!

三、字节级BPE中文分词的问题与解决方法(完全详解+代码验证)

3.1 核心问题:字节断开导致的乱码

这是字节级BPE处理中文时最特殊、也最容易出问题的地方。我们用实际的例子+OpenAI官方分词器来演示这个问题。

问题演示:用OpenAI官方的tiktoken分词器

首先安装tiktoken:

bash 复制代码
pip install tiktoken -i https://pypi.tuna.tsinghua.edu.cn/simple --trusted-host pypi.tuna.tsinghua.edu.cn

然后运行这段代码,看看GPT-3.5的分词器是怎么处理"你"字的:

python 复制代码
import tiktoken

# 加载GPT-3.5使用的分词器
enc = tiktoken.get_encoding("cl100k_base")

# 编码"你"字
tokens = enc.encode("你")
print(f"\"你\"字被编码成token: {tokens}")
print(f"token数量: {len(tokens)}")

# 解码第一个token(只解码前两个字节)
print(f"只解码第一个token: {enc.decode([tokens[0]])}")

运行结果

text 复制代码
"你"字被编码成token: [57668]
token数量: 1
只解码第一个token: 你

看起来没问题?那我们换一个稍微生僻一点的字,比如"犇"(三个牛):

python 复制代码
import tiktoken

# 加载GPT-3.5使用的分词器
enc = tiktoken.get_encoding("cl100k_base")

tokens = enc.encode("犇")
print(f"\"犇\"字被编码成token: {tokens}")
print(f"token数量: {len(tokens)}")

# 分别解码每个token
for i, token in enumerate(tokens):
    print(f"解码第{i+1}个token: {enc.decode([token])}")

惊人的结果

text 复制代码
"犇"字被编码成token: [163, 232, 229]
token数量: 3
解码第1个token: �
解码第2个token: �
解码第3个token: �

发生了什么?

  • "犇"字的UTF-8字节是0xE7 0x89 0x87
  • GPT的分词器没有把这三个字节合并成一个token,而是把它们当成了三个独立的token
  • 单独解码任何一个token,得到的都是空字符串或者乱码,因为单个字节无法组成一个合法的UTF-8字符

预测时的灾难:为什么会输出乱码

在训练过程中,这三个字节总是一起出现,所以模型知道它们应该连在一起。但在预测生成过程中,模型是一个token一个token生成的:

  1. 模型先生成第一个token 230(对应字节0xE7
  2. 然后它需要根据前面的内容,预测下一个token应该是什么
  3. 如果此时模型认为下一个token不是115,而是其他什么东西,它就会生成别的内容
  4. 最终结果就是:只输出了0xE7这一个字节,后面两个字节没生成,变成了乱码

这就是为什么早期的GPT模型在生成中文时,偶尔会输出一些奇怪的乱码字符的根本原因。


3.2 解决方法

方法1:最有效------增加中文训练语料

这是解决这个问题的根本方法

  • 为什么"你"字能被合并成一个token?因为它在中文语料中出现的频率极高,BPE在合并过程中早就把它合并了
  • 为什么"犇"字被拆成三个token?因为它在训练语料中出现的频率极低,BPE从来没有机会把它合并

如果我们在训练语料中加入足够多的中文内容,让所有常用汉字的三个字节都能被BPE合并成一个token,那么这个问题就基本解决了。这也是为什么现在的中文大模型(如Qwen、DeepSeek)在中文生成上几乎不会出现乱码的原因------它们用了海量的中文语料进行训练。

方法2:退而求其次------特殊乱码符号处理

如果有些字实在太生僻,即使在大量语料中也很少出现,无法被合并成一个token,那么还有一个补救方法:

  • 在词汇表中预留一个特殊的token(比如<0xE7>
  • 当模型生成了一个无法解码的字节时,就用这个特殊token来表示
  • 这样至少不会输出不可读的乱码,用户能知道这里有一个无法显示的字符

3.3 代码验证:模拟乱码问题

我们用代码来模拟一下预测时字节断开导致的乱码:

python 复制代码
def simulate_malformed_generation_fixed():
    print("=== 修正版:模拟字节断开导致的乱码 ===")

    # 关键修正:让Python自己把"犇"字转成UTF-8字节
    # 这样就永远不会错了
    character = "犇"
    full_bytes = character.encode('utf-8')

    print(f"目标汉字: {character}")
    print(f"完整的UTF-8字节: {[hex(b) for b in full_bytes]}")
    print(f"完整字节数: {len(full_bytes)}")
    print(f"完整解码结果: {full_bytes.decode('utf-8')}")
    print("-" * 60)

    # 模拟各种断开情况
    scenarios = [
        ("缺少最后1个字节", full_bytes[:-1]),
        ("缺少最后2个字节", full_bytes[:-2]),
        ("只有第1个字节", full_bytes[:1]),
    ]

    for desc, partial_bytes in scenarios:
        print(f"【模拟场景】{desc}")
        print(f"  字节序列: {[hex(b) for b in partial_bytes]}")
        try:
            result = partial_bytes.decode('utf-8')
            print(f"  解码结果: {result} (这居然成功了?这不科学!)")
        except UnicodeDecodeError as e:
            print(f"  ❌ 解码失败(正如预期):")
            print(f"     错误信息: {e}")
        print("-" * 60)


if __name__ == "__main__":
    simulate_malformed_generation_fixed()

运行结果

text 复制代码
=== 修正版:模拟字节断开导致的乱码 ===
目标汉字: 犇
完整的UTF-8字节: ['0xe7', '0x8a', '0x87']
完整字节数: 3
完整解码结果: 犇
------------------------------------------------------------
【模拟场景】缺少最后1个字节
  字节序列: ['0xe7', '0x8a']
  ❌ 解码失败(正如预期):
     错误信息: 'utf-8' codec can't decode bytes in position 0-1: unexpected end of data
------------------------------------------------------------
【模拟场景】缺少最后2个字节
  字节序列: ['0xe7']
  ❌ 解码失败(正如预期):
     错误信息: 'utf-8' codec can't decode byte 0xe7 in position 0: unexpected end of data
------------------------------------------------------------
【模拟场景】只有第1个字节
  字节序列: ['0xe7']
  ❌ 解码失败(正如预期):
     错误信息: 'utf-8' codec can't decode byte 0xe7 in position 0: unexpected end of data
------------------------------------------------------------

这就是字节断开导致乱码的直观演示。


总结

  1. GPT用的是字节级BPE算法:从256个字节开始,不断合并高频相邻对,最终形成词汇表
  2. 两大核心优势:词汇表小(固定5万左右)、天生支持所有语言
  3. 核心问题:生僻汉字会被拆成多个字节token,预测时可能断开导致乱码
  4. 根本解决方法:增加中文训练语料,让常用汉字都能被合并成单个token
相关推荐
3DVisionary1 小时前
aero-engine-blade-thermal-fatigue-dic-inspection
人工智能·算法·机器学习·航空发动机·高温dic·涡轮叶片·热疲劳
asdfg12589631 小时前
一文理解Java中的泛型
java·开发语言
飞翔中文网1 小时前
Java学习笔记之反射
java·笔记·学习
河阿里1 小时前
Spring Boot:整合Quartz集群部署指南
java·spring boot·后端
Kurisu5752 小时前
深度拆解:从二进制切片到并发控制,大文件断点续传的底层工程设计
算法
小肥君2 小时前
gpu安装milvus问题解决
java·eureka·milvus
砍材农夫2 小时前
物联网实战:Spring Boot MQTT | 模拟器Paho客户端拆解高性能
java·javascript·spring boot·后端·物联网·struts
电商API_180079052472 小时前
免 TOP 入驻,第三方淘宝商品详情 API 快速接入与代码示例
java·大数据·开发语言·数据库·爬虫·数据分析
IT空门:门主2 小时前
Java AI 开发框架终极对比:Spring AI vs Spring AI Alibaba vs AgentScope-Java
java·人工智能·spring·spring ai·ai alibaba·agentscope-java