ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

BPE解码不简单:从Tokenizer字节还原到流式输出乱码排查

BPE解码不简单:从Tokenizer字节还原到流式输出乱码排查 从零构建大模型这个系列第一篇聚焦 BPE 编码讲文本是怎么被切成一串 token 的。这次接着往前走一步模型推理结束后拿到的是 token id 序列怎么还原成人类可读的文本这就是 decode。很多人写训练代码时 encode 跑得顺一到 decode 就遇到各种奇怪问题比如中文变成乱码、丢空格、甚至直接抛 UnicodeDecodeError: ascii codec cant decode byte 0xe5。这些问题的根源往往不在模型而在 Tokenizer 的字节还原设计。这篇文章会做三件事。第一把 BPE 从编码到解码的完整链路重新过一遍重点讲 decode 不是 encode 的简单反向。第二给出一份可以直接跑的教学实现包含训练、encode 和 decode 三个方法用一个小语料验证编解码往返。第三结合大模型推理场景说清楚 prefill、decode 这两个阶段中 tokenizer 的职责以及流式输出时怎么避免半个字符导致的乱码。本文适合这几类读者准备自己训练或微调大模型、需要从底层理解 Tokenizer 的工程师在本地部署大模型后想手动校验 tokenizer 是否与模型匹配的开发者以及准备把大模型应用接到项目里、需要理清接口输入输出的同学。文章会从头开始写不需要你提前了解 BPE 的细节但需要会一点 Python。1. 核心概念速览先把本文涉及的核心概念和范围列出来后续所有小节都围绕这张表展开。概念项说明领域范围大模型 Tokenizer尤其是 BPE 编码与解码核心对象BPE 词表、merges 规则、字节到 Unicode 的映射表decode 的目标将 token id 序列还原为原始文本最核心难点多字节 UTF-8 字符可能被拆到多个 token 中关键前置理解 UTF-8 编码、subword 切分、Python bytes 与 str 的关系关联的大模型概念prefill 阶段、decode 阶段、KV Cache、流式输出常用实现Hugging Face transformers、tiktoken、sentencepiece典型坑UnicodeDecodeError、乱码、丢空格、special token 残留这张表并不是要把所有细节讲完而是给你一个索引。下面每一节都会对应表中的某几行逐层拆开。2. 为什么 decode 不是 encode 的简单反向先建立一个直觉encode 的工作是把“文本”变成“id 列表”。你很容易想象 decode 就是把“id 列表”倒回去变成“文本”。BPE 在设计上确实保证了可逆但它的可逆不是靠“沿原路返回”实现的而是靠“字节层双射”实现的。2.1 encode 过程有信息合并BPE 在训练结束后得到的是一张词表和一组 merge 规则。对一段新文本做 encode 时算法会把文本中的字节序列不断尝试合并成更长的子词。同一个文本理论上可以有不同的 merge 顺序但最终只取一条路径而这条路径在拿到 token id 之后并不需要还原因为 decode 只需要知道每个 token 代表什么字符串不需要知道它当初是怎么被 merge 出来的。打个比方你把一段话拆成若干词每个词写在小纸条上。encode 就是做好纸条并编号decode 则是把编号换成纸条再把所有纸条内容连起来。你不需要知道当初是用剪刀怎么剪的只需要看每张纸条上写的是什么。2.2 真正的反向是字节级双射标准 BPE 和字节级 BPE 的关键差异就在这里。字符级 BPE 直接把字符或子词作为最小单位遇到不在词表中的生僻字符会直接失败。字节级 BPE 则先把 UTF-8 编码产生的 0~255 字节序列映射成可见的 Unicode 字符再对这个字符序列做 BPE。因此任何一个字节都在映射表里有对应位置decode 时只要把 token 字符串里的字符逐一映射回字节再把字节序列交给 utf-8 解码就能无损还原原文本。这也是为什么 GPT 系列、多个开源模型都采用字节级 BPE 的原因它天然覆盖所有语言的所有字符不需要为每种语言单独扩展词表。2.3 一个直观的反例假设原始文本是 hello world。经过 BPE常见的切分结果是 [hello, Ġworld]。其中第二个 token 的开头并不是字母 G而是空格字节 0x20 经过映射后的 Unicode 字符 Ġ。如果直接把两个 token 字符串拼接得到 helloĠworld这在屏幕上会显示成带点的特殊字符只有把 Ġ 映射回字节 0x20再用 UTF-8 解码才能得到 hello world。这个例子说明decode 的难点不是“拼接”而是“映射与还原”。3. BPE 编码流程快速回顾为了说清楚 decode得先把 encode 的完整链路固定下来。这一节重点给出三件事字节表、训练阶段、编码阶段。3.1 字节到 Unicode 的映射表字节级 BPE 的第一步是准备一张 256 字节到可打印 Unicode 字符的映射表。bytes_to_unicode 这个工具函数在 GPT-2 时期就流行了很多开源仓库至今沿用。设计原则是尽量把空白、控制字符之外的常见可打印字符保留原样其余字节映射到附加区。这样训练出来的词表里能看到 Ġ、Ċ 这类特殊符号。下面是一个可运行的教学版映射函数def bytes_to_unicode(): # 先保留常见的可打印字符 bs list(range(ord(!), ord(~) 1)) bs list(range(ord(¡), ord(¬) 1)) bs list(range(ord(®), ord(ÿ) 1)) cs bs[:] n 0 for b in range(256): if b not in bs: # 不在保留区间的字节映射到 256n 的码点 bs.append(b) cs.append(256 n) n 1 return {b: chr(c) for b, c in zip(bs, cs)}这个表是双射正向是 byte - unicode_char反向是 unicode_char - byte。decode 时就是查反向表。3.2 训练阶段生成词表和 mergesBPE 训练是在语料上不断统计相邻 token 对频率把最高频的一对合并成一个新 token反复执行直到词表大小达到目标。训练产生两个关键产物第一是词表也就是 token 字符串到 id 的映射第二是 merges也就是合并规则它记录哪些相邻 token 被合并成了哪个新 token。3.3 编码阶段文本到 id编码一个新文本时先把文本通过 UTF-8 编码成字节序列再用正向字节表把每个字节映射成 Unicode 字符接着用 merges 里的规则从高优先级开始依次合并最后查词表得到 id 列表。这段过程在《从零构建大模型》系列第一篇里已经展开过这里不再重复代码重点是记住encode 的输入是文本输出是 id 列表中间经过“字节 - Unicode 字符 - 子词 token”两级转换。4. 手写并验证一个可用的 Tokenizer decode现在进入正题。我直接给出一份可运行的教学版 Tokenizer包含训练、encode、decode。代码做了一定简化目的是把链路讲清楚生产环境建议用 transformers 或 tiktoken。4.1 完整教学实现完整代码会拆成两个代码块。第一个是
返回列表