正在获取所在地区…
正在读取天气
音乐
暂未播放
llama.cpp 源码完全拆解(二十):分词器与词表——llama_vocab 如何把字节、BPE 与 SPM 收进同一套编解码接口
从 llama.cpp 的源码地图里补上一块地基
前面十九篇沿着一次前向和一条请求把 llama.cpp 走了一遍:图构建、KV 缓存、采样、server 的 slot 调度、量化、LoRA、RPC,乃至 ggml-cpu 与 ggml-cuda 的量化内核。所有这些路径共用一个起点——一串文本必须先被切成一串整数 token,模型与 GPU 才开始干活;生成结束后,吐出来的整数还得被拼回人能读的文字。这正是 src/llama-vocab.cpp 的工作。
这一篇专门拆词表层。它看起来像「外围工具」,但恰恰是很多线上问题最集中的地方:把 UTF-8 文本切成 token 时多出一个空格、chat template 拼出来的 BOS 被加了两次、某个 emoji 被拆成四个字节 token 后模型开始胡言乱语、工具调用里模型只吐 JSON 片段却拿不到整句。这些问题的答案都在词表里。模板那一层如何把消息拼成字符串,可参考 llama.cpp 源码完全拆解(十二):对话模板与消息解析;采样器最终输出的是 token id,它依赖的属性定义就在本文这一层,可对照 llama.cpp 源码完全拆解(五):采样管线与语法约束。
先把这篇要建立的主线摆出来:
- llama_vocab 不是一个分词算法,而是一个「类型 + 属性 + 编解码接口」的容器。模型文件里存一个
tokenizer.ggml.model字符串,加载时被映射成llama_vocab_type枚举;所有编码、解码入口都只认这一层接口。 - 不同分词器实例被收进同一个虚基类
llm_tokenizer,但真正干活的是每次调用新建的*_session对象。所谓「同一套接口」,统一的是入口函数llama_vocab::impl::tokenize()与detokenize(),而不是内部算法。 - 字节是三种主流分词器之间的公共地基:SPM 用
<0xNN>字节级回退,BPE 用 GPT-2 字节到 Unicode 映射表(或同样的<0xNN>)保证任意字节都能表示,WPM 则干脆在词表外吐 UNK。理解了字节这一层,才能看懂解码为什么比编码绕。

(图源:自制图,依据 llama.cpp 仓库 src/llama-vocab.cpp、src/llama-vocab.h 的结构整理)
llama_vocab 的角色边界:模型里存了什么,llama.cpp 补了什么
GGUF 里的词表元数据
一个 llama.cpp 能加载的模型文件是 GGUF 格式,词表相关的 KV 元数据由 src/llama-arch.cpp 中的 kv_llm_keys 表定义,键名大致如下。不同导出工具会写不同的字段,llama.cpp 加载时按需读取,缺省字段按内置默认值补齐:
| GGUF 元数据键 | 类型 | 用途 |
|---|---|---|
tokenizer.ggml.model | string | 分词器类型字符串:llama、gpt2、bert、t5、rwkv、plamo2 等,加载时映射到 llama_vocab_type |
tokenizer.ggml.pre | string | 预分词器名字(如 llama3、qwen2、deepseek-v3、starcoder),决定 BPE 用哪组切词正则 |
tokenizer.ggml.tokens | string 数组 | token 的「字面形式」,不一定是一段可读文字,也可能是 <0xNN> 这样的字节标记 |
tokenizer.ggml.scores | float 或 int 数组 | SPM 用作 unigram 分数,T5 的 UGM 也用它;BPE 不靠它 |
tokenizer.ggml.merges | string 数组 | BPE 的合并规则,每个元素形如 "Ġ t",以下标作为合并优先级 |
tokenizer.ggml.token_type | int 数组 | 每个 token 的类型:normal / unknown / control / user defined / unused / byte |
tokenizer.ggml.bos_token_id / eos_token_id / eot_token_id / eom_token_id | int | 特殊 token 编号 |
tokenizer.ggml.unknown_token_id / padding_token_id / seperator_token_id | int | 其余特殊 token(注意 GGUF 里分隔符键名拼写是 seperator) |
tokenizer.ggml.add_bos_token / add_eos_token / add_sep_token | bool | 编码时是否自动加 BOS/EOS/SEP |
tokenizer.ggml.add_space_prefix / remove_extra_whitespaces / clean_spaces | bool | 与空格相关的开关 |
tokenizer.ggml.precompiled_charsmap | uint8/int8 数组 | SPM(以及 T5 的 UGM)用的编译版 NFKC 归一化表 |
tokenizer.ggml.normalizer.nfc / lowercase / strip_accents | bool | WPM 归一化选项 |
tokenizer.ggml.metaspace.prepend_scheme | string | SentencePiece 风格的 metaspace 前置空格策略 |
tokenizer.ggml.fim_pre_token_id / fim_suf_token_id / fim_mid_token_id / fim_pad_token_id / fim_rep_token_id / fim_sep_token_id | int | 代码补全(fill-in-the-middle)相关 token |
tokenizer.ggml.suppress_tokens | int 数组 | 建议在采样阶段压制的 token |
tokenizer.chat_template | string | 对话模板,见第十二篇 |
注意 scores 在较新版本的加载代码里允许 int32 或 float32 两种数组类型,读取后统一转成 float 存进 token_data.score。这一点和第(五)篇采样器里的分数不是一回事:这里的 score 只参与分词算法,不会进入 logits。
类型判定:加载时一次性完成
llama_vocab::impl::load() 的第一件事是读 tokenizer.ggml.model 并映射类型。源码里的分支很直白:
1ml.get_key(LLM_KV_TOKENIZER_MODEL, tokenizer_model);2ml.get_key(LLM_KV_TOKENIZER_PRE, tokenizer_pre, false);3
4if (tokenizer_model == "no_vocab" || tokenizer_model == "none") {5 type = LLAMA_VOCAB_TYPE_NONE;6 // ...7 return;8}9
10if (tokenizer_model == "llama") {11 type = LLAMA_VOCAB_TYPE_SPM;12 special_bos_id = 1; special_eos_id = 2; special_unk_id = 0;13 // ...14} else if (tokenizer_model == "bert") {15 type = LLAMA_VOCAB_TYPE_WPM;16 special_bos_id = 101; special_eos_id = LLAMA_TOKEN_NULL;17 special_unk_id = 100; special_sep_id = 102;18 special_pad_id = 0; special_mask_id = 103;19 add_sep = true;20} else if (tokenizer_model == "gpt2" || tokenizer_model == "hybriddna" || tokenizer_model == "whitespace") {21 type = LLAMA_VOCAB_TYPE_BPE;22 // 读 merges,建立 bpe_ranks23} else if (tokenizer_model == "t5") {24 type = LLAMA_VOCAB_TYPE_UGM;25 // 读 precompiled_charsmap26} else if (tokenizer_model == "rwkv") {27 type = LLAMA_VOCAB_TYPE_RWKV;28} // ...这张映射表有几个值得说的地方。
第一,特殊 token 的默认编号是「按类型」给的,而不是通用的。llama 默认 BOS=1、EOS=2、UNK=0;bert 默认 BOS=101、SEP=102、UNK=100、PAD=0、MASK=103;gpt2 默认 BOS=EOS=11、UNK 为空;t5 默认 EOS=1、UNK=2、PAD=0。这些默认值是给转换工具「忘记写显式 id」时兜底用的,真实模型一般会在 GGUF 里显式写入 bos_token_id 等键覆盖它们。这解释了为什么「同一个 llama.cpp、不同模型、BOS 位置不一样」——不是 llama.cpp 在猜,而是模型文件的元数据说了算。
第二,字符串到类型的映射和公开枚举不完全一一对应。公开头文件 include/llama.h 里 llama_vocab_type 目前有 NONE、SPM、BPE、WPM、UGM、RWKV、PLAMO2、TEST、PLAMO3 九个值;而 tokenizer.ggml.model 字符串还有 gemma4 这样的特例,它落到 BPE 枚举值上,但在分支里顺手把 tokenizer_pre 预设成 "gemma4"。所以读源码时不要假设「字符串名 = 枚举名」,要看 load() 里的分支。
第三,BPE 分支在加载阶段就把所有 merges 读进 bpe_ranks,后续分词时只做哈希查找。bpe_ranks 的键是 std::pair<std::string, std::string>,值是该条 merge 在数组里的下标(下标越小优先级越高)。这是一个 unordered_map,源码还专门写了 pair_hash 把两个字符串的哈希异或起来(第二个左移一位)作为组合键。这个写法不是最严谨的抗冲突哈希,但对 BPE merges 这种键空间基本不碰撞是实用的。
token 数组的两套索引
加载时会把 tokenizer.ggml.tokens 一次性展开成两套结构:
1std::unordered_map<std::string, llama_token> token_to_id; // 字面文本 -> id2std::vector<token_data> id_to_token; // id -> {text, score, attr}token_data 定义在 llama_vocab 内部:
1struct token_data {2 std::string text;3 float score;4 llama_token_attr attr;5};token_to_id 用于「文本查 token」(编码路径里每次合并尝试都要做;text_to_token() 就是它的薄封装);id_to_token 用于「token 查文本和属性」(解码路径和采样辅助都要做)。两者在建表时保证 size() 相等,源码还有一条 GGML_ASSERT(id_to_token.size() == token_to_id.size()) 守着这个不变量。
attr 由 tokenizer.ggml.token_type 数组决定,默认是 LLAMA_TOKEN_ATTR_NORMAL。GGUF 里的 LLAMA_TOKEN_TYPE_* 一共七种:UNDEFINED、NORMAL、UNKNOWN、CONTROL、USER_DEFINED、UNUSED、BYTE。llama.cpp 把它们翻译成位掩码形式的 llama_token_attr(LLAMA_TOKEN_ATTR_NORMAL、LLAMA_TOKEN_ATTR_CONTROL 等),后面解码时用 attr & (attr_special) 这种按位判断判断「这个 token 要不要输出到用户可见文本」。这一步是后面「解码为什么会多/少空格」的伏笔。
分词器的统一入口:session 对象与 fragment buffer
为什么不是「一个虚函数 tokenize()」
直觉上分词器应该是一个 class llm_tokenizer,里面有一个纯虚函数 tokenize()。llama.cpp 实际上只把 tokenizer 当作配置容器:
1struct llm_tokenizer {2 llm_tokenizer() {}3 virtual ~llm_tokenizer() = default;4};llm_tokenizer_spm、llm_tokenizer_bpe、llm_tokenizer_wpm 等都只比它多存一些构造期算好的数据(BPE 存 regex_exprs 和 byte_encode 标志,UGM 存 precompiled_charsmap)。真正的分词逻辑在另一组 *_session 对象里,每次调用新建一个:
1llm_tokenizer_spm_session session(vocab);2session.tokenize(text, output);这个拆分不是纯粹的美学选择。session 每次编码都会构建自己的一套临时数据结构:
- SPM session:
symbols(字符级符号链表)、work_queue(bigram 优先队列)、rev_merge(文本到合并对的映射); - BPE session:
symbols、symbols_final(跨词维持顺序用)、work_queue; - WPM session:没有持久状态,但内部按词循环。
把这些状态做成 session 成员,而不是 tokenizer 成员,可以保证多线程并发调用 llama_vocab::tokenize() 时互不干扰。tokenizer 对象是只读的共享配置,session 是每次调用私有的工作区——这是 llama.cpp 能安全地在多线程里并发分词的关键。
fragment buffer:先把特殊 token 切出来
统一的入口 llama_vocab::impl::tokenize() 做的第一件事不是分词,而是特殊 token 分区:
1std::vector<llama_token> output;2std::forward_list<fragment_buffer_variant> fragment_buffer;3
4if (!raw_text.empty()) {5 fragment_buffer.emplace_front(raw_text, 0, raw_text.length());6 tokenizer_st_partition(fragment_buffer, parse_special);7}fragment_buffer 是一个前向链表,每个节点要么是「原始文本片段」(带 offset 和 length 指向源字符串),要么是一个已确定的「特殊 token id」。tokenizer_st_partition() 会遍历 cache_special_tokens,对每个特殊 token 的文本做一次 std::string_view::find,命中的位置被切出来变成 TOKEN 节点,左右两侧保留为 RAW_TEXT 节点。
这个设计有三个细节值得深挖。
第一,特殊 token 的匹配是「按字符串字面」的,不是正则匹配。 比如 <|im_start|>、<|endoftext|>、<|tool_call_begin|>,都是精确匹配。这保证了 chat template 渲染出来的特殊字符串一定能被识别,前提是模型的 chat template 和 tokenizer 的特殊 token 表一致。模板写错一个字符(比如把 <|im_end|> 多写一个空格),这个 token 就退化成普通文本,被 BPE 拆成一堆碎片 token,模型行为会明显异常。这就是第十二篇讲模板时强调「模板与 tokenizer 必须来自同一个模型」的原因。
第二,parse_special == false 时会跳过 CONTROL 和 UNKNOWN 属性。 源码:
1if (!parse_special && (data.attr & (LLAMA_TOKEN_ATTR_CONTROL | LLAMA_TOKEN_ATTR_UNKNOWN))) {2 continue;3}4// User-defined tokens are still pre-tokenized before everything else默认情况下 llama_tokenize() 的 parse_special 参数是 false,也就是说用户直接传进来的文本里就算写了 <|im_start|>,也会被当作普通文本切碎,不会变成特殊 token。这是安全设计:普通用户输入不应该能伪造特殊 token 来影响对话角色。对话模板渲染的结果则通过 parse_special = true 传入,让模板生成的标记真正成为特殊 token。
第三,LSTRIP / RSTRIP 属性会吃掉相邻空格。 源码在切出特殊 token 后,根据 token_data.attr 中的 LSTRIP 去掉左侧空白、RSTRIP 去掉右侧空白。这对应 SentencePiece 里 <0x0A> 这类 token 的语义,避免「换行字节 token + hello」之后 hello 前面多出一个本不属于它的空格。
入口分派:一次 switch 决定走谁
分区完成后,tokenize() 用一个大的 switch (get_type()) 分派到各自 session:
1switch (get_type()) {2 case LLAMA_VOCAB_TYPE_SPM: {3 // add_bos 由 vocab 配置决定,is_prev_special 控制要不要前置空格4 llm_tokenizer_spm_session session(vocab);5 session.tokenize(text, output);6 } break;7 case LLAMA_VOCAB_TYPE_BPE: {8 // 按 tokenizer_model 再细分 hybriddna / whitespace / 普通 BPE9 const llm_tokenizer_bpe * tok_bpe = static_cast<const llm_tokenizer_bpe *>(tokenizer.get());10 std::unique_ptr<llm_tokenizer_bpe_session> session;11 if (vocab.get_tokenizer_model() == "hybriddna") {12 session = std::make_unique<llm_tokenizer_hybriddna_session>(vocab, *tok_bpe);13 } else if (vocab.get_tokenizer_model() == "whitespace") {14 session = std::make_unique<llm_tokenizer_whitespace_session>(vocab, *tok_bpe);15 } else {16 session = std::make_unique<llm_tokenizer_bpe_session>(vocab, *tok_bpe);17 }18 session->append_bos(output);19 for (const auto & fragment : fragment_buffer) {20 // RAW_TEXT: 可选 escape_whitespaces,然后 session->tokenize21 // TOKEN: session->append(fragment.token, output)22 }23 session->append_eos(output);24 session->check_double_bos_eos(output);25 } break;26 case LLAMA_VOCAB_TYPE_WPM: {27 // 前面加 BOS,后面加 SEP28 } break;29 // UGM / RWKV / PLAMO2 / PLAMO3 / TEST / NONE30}这个 switch 里藏着 SPM 与 BPE 的第一个关键差异:SPM 和 BPE 对「片段前后的空格」处理方式不同。
SPM 分支用一个局部变量 is_prev_special 跟踪「上一个片段是不是特殊 token」:
1bool is_prev_special = true; // 首个片段当作「前一个特殊」处理2
3if (add_special && add_bos) {4 output.push_back(special_bos_id);5 is_prev_special = true;6}7
8for (const auto & fragment : fragment_buffer) {9 if (fragment.type == FRAGMENT_BUFFER_VARIANT_TYPE_RAW_TEXT) {10 std::string text;11 if (add_space_prefix && is_prev_special) {12 text = ' '; // 开头补一个空格13 }14 text += fragment.raw_text.substr(fragment.offset, fragment.length);15 llama_escape_whitespace(text);16 session.tokenize(text, output);17 is_prev_special = false;18 } else {19 output.push_back(fragment.token);20 is_prev_special = true;21 }22}也就是说,SPM 在每个特殊 token 之后、每段文本之前,只要 add_space_prefix 为真,就补一个空格,再被 llama_escape_whitespace() 替换成 ▁(U+2581)。这对应 SentencePiece 的 metaspace 约定:每段文本的开头相当于前面有一个空格。add_space_prefix 是从 GGUF 的 tokenizer.ggml.add_space_prefix 读来的,SPM 默认 true;BPE 默认 false(BPE 有独立的前置空格策略)。
BPE 分支则完全不在入口处理这个前置空格,而是交给 session 外层的 metaspace_prepend_scheme 判断(只有 prepend_scheme == "first" 且是第一个 fragment 时才补 ▁)。这一段代码位于 BPE 分支的循环里:
1static const std::string escaped_space = "\xE2\x96\x81";2if (metaspace_prepend_scheme == LLAMA_METASPACE_PREPEND_SCHEME_FIRST &&3 &fragment == &fragment_buffer.front() && text.rfind(escaped_space, 0) != 0) {4 text.insert(0, escaped_space);5}它只在「整个输入的第一个 fragment」补一次,而不是在每个特殊 token 之后补。这是 SentencePiece 风格的 BPE(比如 Gemma 系列)和 GPT-2 风格的 BPE 混在同一个类型下的后果:BPE 的「前置空格」语义现在变成由 metaspace_prepend_scheme 额外控制,而不是像 SPM 那样无条件补。
第二个关键差异是 BPE 有 check_double_bos_eos() 这个警告:如果 add_bos 为真、而且输出里第二个 token 已经等于 BOS,说明模板自己写了 BOS、llama.cpp 又自动加了一次,日志里会打印 Added a BOS token to the prompt ... 2 BOS tokens。这条警告非常有用:chat template 和 add_bos 配置冲突时,这就是唯一能看到的信号。SPM 分支有同样功能的警告,只是写在循环结束之后。
第三个差异是 WPM 自动加 SEP 而不是 EOS:add_special 为真时,WPM 在头部加 BOS,尾部加 SEP。这与 BERT 的训练格式一致。注意 BERT 的「token type / segment id」概念:n_token_types 字段也是从 tokenizer.ggml.token_type_count 读的,但目前它只在加载记录里,编码时并没有生成 BERT 的 segment id。换句话说,llama.cpp 对 BERT 类模型的支持只覆盖「输入 token 化」,下游需要 segment 时得由调用方自己处理。
naive_trie:名字比用途更显眼的数据结构
文章里最常见的说法是「llama.cpp 用 trie 做最长前缀匹配」。读源码会发现实际结构叫 naive_trie,而且全文件只被两处使用:
- UGM session 里的
user_defined_token_matcher和token_matcher(T5 的 Unigram 分词); - RWKV session 里的
token_matcher(RWKV 的贪婪分词)。
SPM、BPE、WPM 的分词路径一个都没用到 naive_trie。SPM 的合并尝试直接在 token_to_id 里查整段文本;BPE 的前缀匹配靠预分词正则保证边界,再对每个片段做合并;WPM 的「最长匹配」是在 text_to_token() 里用 max_token_len 限制窗口长度、从长到短试子串。
naive_trie 的定义很短:
1struct naive_trie {2 naive_trie() : has_value(false), value(0) {}3 void insert(const char * key, size_t len, int32_t value = 0) {4 if (len == 0) { has_value = true; this->value = value; return; }5 char c = key[0];6 auto res = children.find(c);7 if (res != children.end()) {8 res->second.insert(key + 1, len - 1, value);9 } else {10 auto res = children.insert(std::make_pair(c, naive_trie()));11 res.first->second.insert(key + 1, len - 1, value);12 }13 }14 // get_longest_prefix / traverse ...15 std::map<char, struct naive_trie> children;16 bool has_value;17 llama_token value;18};它叫 naive 是有原因的:
- 每个节点一个
std::map<char, naive_trie>,插入/查找都是O(log 子节点数)的树查找;整棵 trie 的节点数等于所有词的总字符数,内存放大相当可观,构造时还有大量递归。 get_longest_prefix()只沿着「有子节点」往下走,不记录沿途的has_value,因此「最长前缀」是「最长的、被插进树里作为路径的字符串」,而不是「最长的、在词表里作为 token 的前缀」。如果前缀树里出现「路径 A 长于 token A’」,它返回的是路径末端,而不是中途某个 token 终点。traverse()用std::map::find做逐字符匹配,而不是哈希表或数组。
那为什么 UGM 和 RWKV 还要用它?因为这两个分词器的需求是「从当前位置,尽可能长地吃掉一个词」,而它们本身没有像 BPE 那样的正则预分词兜底边界。get_longest_prefix() 正适合这种场景:输入整段文本,从 offset 0 开始沿树走到第一个断点,返回「已匹配前缀」的位置和长度,然后对剩余部分继续。UGM session 用 token_matcher 对归一化后的文本分段,在每个分段内再进入自己基于 Viterbi 的动态规划;RWKV 则直接用每段最大匹配的结果查 token。
另一个值得注意的点是 get_longest_prefix() 的返回类型:std::pair<const char *, size_t>,第一个元素是「原指针」,第二个是「已匹配字节数」,不是「一个结束指针」,也不是「匹配到的字符串」。调用方需要知道「原指针 + 返回值.second」才是匹配终点。如果把它当成「返回匹配字符串本身」,很容易误判。
SPM:SentencePiece 的分数驱动合并
符号链表而不是字符串
SPM session 的第一步是把文本切成 UTF-8 字符级 symbol:
1int index = 0;2size_t offs = 0;3while (offs < text.size()) {4 llm_symbol sym;5 size_t len = unicode_len_utf8(text[offs]);6 sym.text = text.c_str() + offs;7 sym.n = std::min(len, text.size() - offs);8 offs += sym.n;9 sym.prev = index - 1;10 sym.next = offs == text.size() ? -1 : index + 1;11 index++;12 symbols.emplace_back(sym);13}注意 sym.n = std::min(len, text.size() - offs):遇到「看起来是 N 字节 UTF-8 首字符、但实际剩余字节不够」的畸形输入,就把它截到剩余长度,不越界。这种防御在网络输入里很重要——上游可能截断一个 emoji,只剩前两个字节。
llm_symbol 里存的是 const char * text 和 size_t n,合起来才是当前符号覆盖的字节区间。prev / next 是下标,-1 表示结束。合并时不是真的把字符串拼起来,而是把右符号的长度加到左符号上、把右符号长度置零:
1left_sym.n += right_sym.n;2right_sym.n = 0;3left_sym.next = right_sym.next;4if (right_sym.next >= 0) {5 symbols[right_sym.next].prev = bigram.left;6}这一步非常关键。由于 left_sym.text 指向源字符串,left_sym.n 增加后,left_sym.text + left_sym.n 恰好覆盖到 right_sym.text + right_sym.n(因为两个符号在源字符串里相邻且连续)。所以合并全程零字符串拷贝,只有长度和链表指针在变。这是 SPM 实现里性能最好的部分。
双向合并与得分
合并的候选是一个 bigram 优先队列,比较器是:
1bool operator()(llm_bigram_spm & l, llm_bigram_spm & r) {2 return (l.score < r.score) || (l.score == r.score && l.left > r.left);3}也就是说:分数高的先合并;分数相同则左侧位置靠前的先合并。std::priority_queue 默认是大顶堆(按比较器「更小」的排后面),所以这个比较器实现的是「score 大优先,left 小优先」。
初始化时把所有相邻字符对都尝试加入队列:
1for (int i = 1; i < (int) symbols.size(); ++i) {2 try_add_bigram(i - 1, i);3}try_add_bigram(left, right) 做的是:
1const std::string text = std::string(symbols[left].text, symbols[left].n + symbols[right].n);2auto token = vocab.text_to_token(text);3if (token == LLAMA_TOKEN_NULL) return;4if (static_cast<uint32_t>(token) >= vocab.n_tokens()) return;5const auto & tok_data = vocab.get_token_data(token);6bigram.score = tok_data.score;7bigram.size = text.size();8work_queue.push(bigram);9rev_merge[text] = std::make_pair(left, right);这里有几个细节:
第一,text_to_token 查的是 token_to_id 哈希表,不是 trie。所以 try_add_bigram 的性能瓶颈是「拼接两个字符串 + 哈希查找」,而不是前缀匹配。
第二,rev_merge 记录「文本 → 合并对」,它不参与合并过程,只在最后 resegment() 时用来递归拆分没在词表里的中间合并产物。这是 SPM 实现里最容易让人困惑的结构:合并过程中产生的中间字符串(比如 ▁He 合成了但 ▁Hel 没在词表里)也要记录下来,因为最终「整段文本若不在词表」时需要能回溯出更小的 token 组合。
第三,score 是 token_data.score,来源于 GGUF 的 tokenizer.ggml.scores。在 SentencePiece 的 unigram 模型里,这个分数与「对候选词打分的概率」有关:分数高的词更可能出现、优先合并。这和 BPE 的「merges 下标即优先级」完全不同:SPM 的优先级是学习出来的分数,BPE 的优先级是合并文件里的顺序。
合并循环
1while (!work_queue.empty()) {2 auto bigram = work_queue.top();3 work_queue.pop();4
5 auto & left_sym = symbols[bigram.left];6 auto & right_sym = symbols[bigram.right];7
8 if (left_sym.n == 0 || right_sym.n == 0 ||9 left_sym.n + right_sym.n != bigram.size) {10 continue; // 已被合并或长度不一致,跳过11 }12
13 left_sym.n += right_sym.n;14 right_sym.n = 0;15 left_sym.next = right_sym.next;16 if (right_sym.next >= 0) {17 symbols[right_sym.next].prev = bigram.left;18 }19
20 try_add_bigram(left_sym.prev, bigram.left); // 左边新相邻21 try_add_bigram(bigram.left, left_sym.next); // 右边新相邻22}这个循环有两个不能省的检查。left_sym.n == 0 || right_sym.n == 0:优先队列里可能还留着「已经被合并过的对」,例如 [a, b] 合并后 b 长度为 0,队列里原本的 [b, c] 就失效了。left_sym.n + right_sym.n != bigram.size:即使两边长度都不为零,合并后的总长度也可能和当初入队时的长度不同,说明这对在排队期间两侧发生过合并,不能合并。这两条保证了队列里的 bigram 总是对当前符号链有效。
合并后立即对新产生的两个相邻对调用 try_add_bigram:合并后的符号左边和后一个、合并后的符号右边和前一个。不重新扫描整条链,这是「合并次数 × 常数」的关键。
resegment:回退拆解
合并结束后,遍历链表:
1for (int i = 0; i != -1; i = symbols[i].next) {2 auto & symbol = symbols[i];3 resegment(symbol, output);4}resegment() 递归处理一个符号:
1void resegment(llm_symbol & symbol, std::vector<llama_token> & output) {2 auto text = std::string(symbol.text, symbol.n);3 auto token = vocab.text_to_token(text);4
5 if (token != LLAMA_TOKEN_NULL) {6 output.push_back(token);7 return;8 }9
10 const auto p = rev_merge.find(text);11 if (p == rev_merge.end()) {12 // 逐字节回退13 for (int j = 0; j < (int) symbol.n; ++j) {14 llama_token id = vocab.byte_to_token(symbol.text[j]);15 output.push_back(id);16 }17 return;18 }19
20 resegment(symbols[p->second.first], output);21 resegment(symbols[p->second.second], output);22}三种情况:
- 整段文本在词表里:直接输出;
- 不在词表,但记录过是怎么合出来的:按
rev_merge记录的两半递归拆分,直到每一半都能变成 token; - 不在词表,也没有合并记录:这是「从未形成过合法 bigram」的单个字符(或合并结束后遗留的片段),逐字节回退——每个字节都走
vocab.byte_to_token()变成一个<0xNN>字节 token。
第 3 种情况就是 SPM 的「字节级回退(byte fallback)」。它的意义在于:SentencePiece 词表本身是完整的 Unicode 子集,但任何 Unicode 字符都能用它的 UTF-8 字节序列表示。比如词表里没有 😀(U+1F600),它的 UTF-8 是 F0 9F 98 80,回退后会产出 <0xF0> <0x9F> <0x98> <0x80> 四个 token。解码时这四个字节又拼回 😀。这条链路是 SPM 能「任意输入、无损输出」的根本保证。
不过回退只保证不丢字节,不保证语义稳定:如果一个字符经常被拆成四字节,模型在这个字符上的表现会变差,因为训练时它很可能作为单个 token 出现过。这也解释了为什么某些 emoji 或生僻字在 llama.cpp 里表现差:不一定是模型不会,而是 tokenization 把它拆碎了。

(图源:自制图,依据 llama.cpp 仓库 src/llama-vocab.cpp 中 llm_tokenizer_spm_session 的结构整理)
一个容易误读的细节:symbols 成员「不跨调用清空」
llm_tokenizer_spm_session::tokenize() 在开头并没有 symbols.clear()。看起来像 bug,但要看调用方:SPM 分支每次遇到一个 RAW_TEXT fragment 就新建一个 llm_tokenizer_spm_session:
1llm_tokenizer_spm_session session(vocab);2session.tokenize(text, output);所以 symbols 的「累积」只在同一个 session 的同一个 tokenize() 调用内有效,跨 fragment 的 session 是全新的。BPE session 则是同一个 session 处理所有 fragment,所以它显式管理 symbols 和 symbols_final 的切换(见下文)。读源码时如果不注意这个生命周期差异,会误以为 SPM 的符号链会跨 fragment 串起来。
BPE:正则预切词 + 排名合并
预分词正则决定一切
BPE 的第一步不是合并,而是把文本切成「词」。这一步不是「按空格切」,而是一组正则表达式,按模型类型不同而不同。llm_tokenizer_bpe 的构造函数里保存 regex_exprs,每条正则都是一条候选规则,实际执行由 unicode_regex_split() 按顺序尝试。
以 LLAMA3 预分词器为例:
1regex_exprs = {2 "(?:'[sS]|'[tT]|'[rR][eE]|'[vV][eE]|'[mM]|'[lL][lL]|'[dD])"3 "|[^\\r\\n\\p{L}\\p{N}]?\\p{L}+"4 "|\\p{N}{1,3}"5 "| ?[^\\s\\p{L}\\p{N}]+[\\r\\n]*"6 "|\\s*[\\r\\n]+"7 "|\\s+(?!\\S)"8 "|\\s+",9};这一串规则的顺序就是优先级,从左到右:
- 英文缩写
's、't、're、've、'm、'll、'd,大小写都覆盖; - 字母串,允许前面带一个非字母非数字字符(用来把标点与后面的词绑定,比如
(hello); - 数字串,最多 3 位(
\p{N}{1,3}); - 标点/符号串,允许前面带一个空格;
- 换行串;
- 行尾空白(后面没有非空白的空白);
- 其它空白。
为什么数字是 {1,3} 而不是任意长度?因为 GPT-2/BPE 词表里预训练时常把数字切成 3 位一组,保证大数也能被稳定表示。中文呢?\p{L} 在 Unicode 分类里包含中日韩文字,所以「你好世界」会被一条 \p{L} 规则整段吃成「一个词」,再对这一个词做 BPE 合并。没有单独的 CJK 规则时,中文合并效果完全取决于词表里有多少中文 token。
不同模型的差异往往就在这组正则里:
- QWEN2 是
[^\\r\\n\\p{L}\\p{N}]?\\p{L}+|\\p{N}| ...:数字只切单个,不做 3 位合并; - DEEPSEEK3_LLM 增加了
[一-龥-ゟ゠-ヿ]+这一条,把中日文单独成段; - STARCODER 加了
\\p{N}这一条,保证每个数字单独切,配合它的「代码数字不合并」的词表; - LLAMA3 把缩写统一成大小写显式列出(
(?i:)大小写不敏感组在 C++ 的 std::regex 里支持不佳,所以展开成显式列表); - GEMMA4、SARVAM_MOE、MMBERT 使用
[^\\n]+|[\\n]+这种粗粒度规则,并把byte_encode置为 false,走「SPM 风格 BPE」(空白先变▁,不做 GPT-2 字节映射)。
byte_encode 这个标志很关键。BPE 默认使用「GPT-2 字节到 Unicode 映射表」:把 256 个字节映射到 256 个「看起来可打印」的 Unicode 字符,保证每个字节都能在 UTF-8 文本里表示。这个映射的构造方式是先把「可打印的 33–126、161–172、174–255」原样保留,剩下的字节按顺序分配到 U+0100 之后的空洞里(代码在 src/unicode.cpp)。所以 GPT-2 token Ġ 实际对应空格字节 0x20,Ċ 对应换行 0x0A。解码时 llama_decode_text() 反向查表把 Unicode 字符还原成字节。
如果模型用的是 <0xNN> 字节 token 而不是 GPT-2 映射,那么 BPE 就必须走非 byte_encode 路径,byte_encode = false 的预分词器类型正是为了这个。
还有一个值得单独说的类型:SOPRANO 在 regex_exprs 之前先做一遍自定义归一化——把连续空白压成一个空格,并把字符做 Unicode 小写(unicode_tolower()),且 Unicode 全大写无点 I(U+0130)被特判展开成 i 加组合点。这一步不是 SPM 的 NFKC,而是针对特定模型的定制逻辑,位置写死在 llm_tokenizer_bpe_session::tokenize() 的 tok_pre == LLAMA_VOCAB_PRE_TYPE_SOPRANO 分支。
符号链与合并队列
预分词产生的每个「词」单独构建符号链:
1for (const auto & word : word_collection) {2 work_queue = llm_bigram_bpe::queue();3 symbols.clear();4
5 int index = 0;6 size_t offset = 0;7
8 if (vocab.get_ignore_merges() && vocab.text_to_token(word) != LLAMA_TOKEN_NULL) {9 // 整词已在词表,跳过合并10 symbols.emplace_back(llm_symbol{-1, -1, word.c_str(), word.size()});11 offset = word.size();12 }13
14 while (offset < word.size()) {15 llm_symbol sym;16 size_t char_len = std::min(word.size() - offset, (size_t) unicode_len_utf8(word[offset]));17 sym.text = word.c_str() + offset;18 sym.n = char_len;19 offset += sym.n;20 sym.prev = index - 1;21 sym.next = offset == word.size() ? -1 : index + 1;22 index++;23 symbols.emplace_back(sym);24 }25 for (int i = 1; i < (int) symbols.size(); ++i) {26 add_new_bigram(i - 1, i);27 }28 // ... 合并循环 ...29}ignore_merges 是 BPE 特有的「整词直通」优化:如果预分词后的整词已经在词表里(比如常见的单个词 token),就不做逐字符合并,直接用这个词的 token。llama3、qwen2 等预分词器都开启 ignore_merges。这个优化对应 Hugging Face tokenizer 里「整词已在词表则直接命中」的快路径。
add_new_bigram() 通过 vocab.find_bpe_rank(left_token, right_token) 查 bpe_ranks 哈希表:
1int rank_found = vocab.find_bpe_rank(left_token, right_token);2if (rank_found < 0) return;3bigram.left = left;4bigram.right = right;5bigram.text = left_token + right_token;6bigram.size = left_token.size() + right_token.size();7bigram.rank = rank_found;8work_queue.push(bigram);BPE 的合并循环与 SPM 几乎一样,但有一个额外检查:
1std::string left_token = std::string(left_symbol.text, left_symbol.n);2std::string right_token = std::string(right_symbol.text, right_symbol.n);3if (left_token + right_token != bigram.text) {4 continue; // Skip this bigram if it's outdated5}BPE 用 bigram.text 存「这对合并后的文本」,再和当前左右两边的文本拼起来比较,防止「符号已经发生变化但 bigram 还是旧的」被错误合并。SPM 用 bigram.size 比较总长度,BPE 用完整文本比较更严格,代价是每次比较都要拼字符串。

(图源:自制图,依据 llama.cpp 仓库 src/llama-vocab.cpp 中 llm_tokenizer_bpe、llm_tokenizer_bpe_session 的结构整理)
跨词顺序:symbols_final
BPE session 处理多个「词」时,每个词的符号链单独存进 symbols,合并结束后再按顺序追加到 symbols_final:
1for (auto & sym : symbols) {2 if (sym.n > 0) {3 sym.prev = final_prev_index;4 sym.next = -1;5 if (final_prev_index != -1) {6 symbols_final[final_prev_index].next = symbols_final.size();7 }8 symbols_final.emplace_back(sym);9 final_prev_index = symbols_final.size() - 1;10 }11}注意这里只遍历 symbols(当前词的符号),而 symbols 在每个词开头被 clear(),所以追加进 symbols_final 的是「当前词合并后的符号」。sym.prev / sym.next 被重写为指向 symbols_final 整体的下标。所有词处理完后:
1symbols = symbols_final;从这里开始,符号链是一条跨所有词的完整链,后面的输出循环沿 next 走:
1for (int i = 0; i != -1; i = symbols[i].next) {2 const std::string str = std::string(symbol.text, symbol.n);3 const auto token = vocab.text_to_token(str);4 if (token == LLAMA_TOKEN_NULL) {5 // 逐字节回退(下面展开)6 } else {7 output.push_back(token);8 }9}这个「先每词合并、再拼链」的设计,比让所有词共享一条大符号链更省事:合并队列只维护一个词的边界,不会跨词合并。BPE 的语义本来就不允许跨词合并,所以这个边界是算法要求,不是优化。
BPE 的字节回退
BPE 在「合并后整词不在词表」时的回退与 SPM 不同:
1if (token == LLAMA_TOKEN_NULL && tok_pre == LLAMA_VOCAB_PRE_TYPE_SOPRANO) {2 output.push_back(vocab.token_unk());3} else if (token == LLAMA_TOKEN_NULL) {4 for (auto j = str.begin(); j != str.end(); ++j) {5 llama_token token_multibyte = LLAMA_TOKEN_NULL;6 if (tokenizer.byte_encode) {7 std::string byte_str(1, *j);8 token_multibyte = vocab.text_to_token(byte_str);9 } else {10 // byte tokens use <0xXX> format11 static const char * hex = "0123456789ABCDEF";12 const uint8_t ch = (uint8_t)*j;13 const char buf[7] = { '<', '0', 'x', hex[ch >> 4], hex[ch & 15], '>', 0 };14 token_multibyte = vocab.text_to_token(buf);15 }16 if (token_multibyte != LLAMA_TOKEN_NULL) {17 output.push_back(token_multibyte);18 }19 }20}这里 byte_encode 为真时,str.begin() 到 str.end() 遍历的是已经经过 GPT-2 字节映射的字节(unicode_regex_split 在前一步已经做了映射)。所以直接拿单个字符查 text_to_token 就能命中 Ġ、Ċ 这类 token。
byte_encode 为假时,构造 <0xNN> 字符串查词表。注意这种模式下 str 是原始 UTF-8 字节,查的是逐字节的 <0xNN> token——这也是 Gemma4 等「SPM 风格 BPE」模型能处理任意字节的原因。
SOPRANO 的回退是「一个词对不上就吐 UNK」,不做字节级回退。它的预训练约定里,无法表示的字符直接丢弃(作为 UNK),这是特定模型的选择,不是通用规则。
WPM:WordPiece 的贪婪最长匹配
预归一化
WPM session 的第一步是 preprocess():按 Unicode 码点处理归一化、大小写、重音折叠,并按空白、标点、CJK 字符切词。
1std::vector<uint32_t> cpts = unicode_cpts_from_utf8(text);2if (normalizer_opts.strip_accents) {3 cpts = unicode_cpts_normalize_nfd(cpts);4}normalizer_opts 的默认值来自 llama_vocab::normalizer_options:nfc = false、lowercase = true、strip_accents = true。WPM(BERT)使用的分词约定正是:
- 全部小写;
- 去除重音(NFD 后再去掉组合标记);
- 中文每个字单独切;
- 标点符号单独切。
具体实现里,每个码点先根据 unicode_cpt_flags_from_cpt() 得到标志位(空白、标点、控制、重音标记等),然后:
1if (flags.is_whitespace) {2 if (words.back().size()) words.emplace_back();3 continue;4}5if (cpt == 0 || cpt == 0xFFFD || flags.is_control) continue;6if (normalizer_opts.strip_accents && flags.is_accent_mark) continue;7
8const std::string s = unicode_cpt_to_utf8(normalizer_opts.lowercase ? unicode_tolower(cpt) : cpt);9if (flags.is_punctuation || (cpt < 0x7F && flags.is_symbol) || is_chinese_char(cpt)) {10 if (words.back().size()) words.emplace_back();11 words.back() = s;12 words.emplace_back();13} else {14 words.back() += s;15}is_chinese_char() 是 BERT 原版 Rust 实现的直译,覆盖 CJK 统一表意文字及其扩展区、CJK 兼容区等区间。源码里留了一句注释 this should be 0x2B820 but in hf rust code it is 0x2B920,说明 llama.cpp 刻意跟随 Hugging Face 的实现,即使它和 Unicode 官方区间有偏差——分词器要复现的是训练时的行为,而不是「最正确的」行为。这种细节是分词器移植里最容易出错的地方:任何一处和参考实现不一致,都会导致同一段文本在不同框架里切出不同 token。
仿 SentencePiece 的前空格
切词后,每个词前加一个 ▁:
1const std::string word1 = "\xe2\x96\x81" + word;2const int n = word1.size();为什么用 ▁ 而不是 BERT 原版的 ##?因为 GGUF 转换时已经把词表里的 ## 前缀转换成了 SentencePiece 风格的 ▁。这样做的好处是 WPM 与 SPM 可以共用「空格 = ▁」的解码约定,减少解码分支。
最长匹配 + 回退
每个词从当前位置开始,尝试所有长度:
1for (int i = 0; i < n; ++i) {2 bool match = false;3 for (int j = std::min(n, i + vocab.max_token_len() + 1); j > i; j--) {4 auto id = vocab.text_to_token(word1.substr(i, j - i));5 if (id != LLAMA_TOKEN_NULL) {6 output.push_back(id);7 match = true;8 i = j - 1;9 break;10 }11 }12 if (!match) {13 output.resize(current_tokens);14 break;15 }16}17if (current_tokens == output.size()) {18 output.push_back(vocab.token_unk());19}这里有几个细节:
第一,长度上限是 max_token_len + 1。max_token_len 是加载时对所有 token 文本的 str.size() 取最大值。+1 是因为 j 是「结束位置(不含)」,当 j - i = max_token_len 时 substr 长度正好是 max_token_len;但如果 i 不是 0,word1.substr(i, j - i) 最长也只能取到 max_token_len,所以 +1 是冗余但无害的上界。
第二,j > i 而不是 j >= i,保证最小子串长度是 1,不会出现空字符串查找。
第三,整个词必须能切完,否则回滚到词开始前的长度并吐一个 UNK。output.resize(current_tokens) 就是回滚:如果前面几个子词匹配上了、中间某个位置没有匹配,就把这个词的全部输出丢弃。这对应 WordPiece 的「一个词要么全部可分解,要么整体 UNK」语义。
第四,为什么 WPM 比另外两种慢:内层循环对每个起始位置 i 都尝试最多 max_token_len 种长度,每种长度都要 substr(一次拷贝)加一次哈希查找。对长词(比如很长的英文标识符或 URL)代价明显高于 SPM/BPE 的合并队列。llama.cpp 没有为 WPM 提供 trie 或前缀哈希的加速,这在实际使用中通常可接受,因为 BERT 的输入长度一般远小于生成类模型。
WPM 是最「简单」的分词器,也最「保守」:它没有 SPM 的字节级回退,没有 BPE 的合并优先级,一个词对不上就 UNK。这对应 BERT 的训练方式:BERT 的输入是短文本分类/检索,UNK 的代价可以接受;而生成模型必须能输出任意文本,所以 SPM/BPE 都必须有字节兜底。
解码:比编码多出来的三层逻辑
第一层:特殊/控制 token 过滤
detokenize() 和 token_to_piece() 共享一个属性判断:
1static const int attr_special = LLAMA_TOKEN_ATTR_UNKNOWN | LLAMA_TOKEN_ATTR_CONTROL;2const llama_token_attr attr = token_get_attr(token);3if (!special && (attr & attr_special)) {4 return 0;5}意思是:UNKNOWN 和 CONTROL 属性的 token 默认不输出。llama_tokenize() 生成 <|im_end|> 这类控制 token 时,add_special 为真时把它们加入序列;但用户读文本时不需要看到它们。special = true 的调用场景是「需要保留完整序列」——比如调试、日志、或者再喂给模型之前想看原始形式。
token_data.score 不参与解码;attr 决定输出与否。这是解码的第一层语义。
第二层:UTF-8 跨 token 拼接
编码时 😀 被拆成四个字节 token;解码时四个 <0xNN> 字节 token 必须拼回去。token_to_piece() 对 BYTE 属性 token 的处理是:
1char byte = (char) token_to_byte(token);2return _try_copy((char*) &byte, 1);SPM/UGM 的 token_to_byte() 从 <0xNN> 文本的第 3、4 个字符解析十六进制:
1auto buf = token_data.text.substr(3, 2);2return strtol(buf.c_str(), NULL, 16);然后 detokenize() 把每个 token 的输出依次追加到同一个 buffer:
1int32_t n_chars = token_to_piece(tokens[i], text, avail, remove_space || remove_first_space, unparse_special);注意 token_to_piece() 每次只输出「一个 token 的字节」。四个字节 token 就是四次追加四个单字节到同一缓冲区——跨 token 的 UTF-8 拼接完全靠「调用方把它们写进同一段文本」来实现。llama.cpp 不需要知道 😀 是一个字符,它只知道「这些字节应该连续」。如果上游按 token 逐个取字符串、再各自显示,就会看到四个乱码替换符。
这就是为什么「流式输出」对字节 token 要特别小心:如果服务端在每个 token 后立即 flush 一个 chunk,客户端就会收到四个不完整的 UTF-8 片段。llama.cpp 的 server 在 SSE 流式输出阶段是按 token 粒度输出的,并不保证每个 chunk 落在字符边界。想要字符边界安全的流式输出,调用方需要自己缓冲字节 token,等凑成一个完整 UTF-8 字符再发出。
第三层:▁ 空格还原与 lstrip
token_to_piece() 对 NORMAL 属性 token 的处理有三条不同的分支,取决于分词器类型:
1case LLAMA_VOCAB_TYPE_WPM:2case LLAMA_VOCAB_TYPE_SPM:3case LLAMA_VOCAB_TYPE_UGM: {4 if (attr & (attr_special | LLAMA_TOKEN_ATTR_USER_DEFINED)) {5 return _try_copy(token_text.data(), token_text.size());6 }7 if (attr & LLAMA_TOKEN_ATTR_NORMAL) {8 std::string result = token_text;9 llama_unescape_whitespace(result);10 return _try_copy(result.data(), result.size());11 }12 // ...13}14case LLAMA_VOCAB_TYPE_BPE: {15 if (attr & LLAMA_TOKEN_ATTR_NORMAL) {16 if (pre_type == LLAMA_VOCAB_PRE_TYPE_SOPRANO) {17 return _try_copy(token_text.data(), token_text.size());18 }19 if (escape_whitespaces) {20 std::string result = token_text;21 llama_unescape_whitespace(result);22 return _try_copy(result.data(), result.size());23 }24 std::string result = llama_decode_text(token_text);25 return _try_copy(result.data(), result.size());26 }27 // ...28}SPM/UGM/WPM 一律把 ▁ 替换回空格(llama_unescape_whitespace 就是 replace_all(word, "\xe2\x96\x81", " "))。BPE 分三种:
- SOPRANO:原样输出,不做替换;
escape_whitespaces == true(SPM 风格 BPE,如 Gemma):▁替换成空格;escape_whitespaces == false(GPT-2 风格 BPE):调用llama_decode_text(),把每个 Unicode 字符按 GPT-2 字节映射表还原成字节。Ġ变成0x20(空格),Ċ变成0x0A(换行),普通 ASCIIa保持a。
llama_decode_text() 的异常处理也值得注意:
1try {2 decoded_text += unicode_cpt_to_byte(cpt);3} catch (const std::out_of_range & /*e*/) {4 const auto utf8 = unicode_cpt_to_utf8(cpt);5 decoded_text += "[UNK_BYTE_0x";6 for (const auto c : utf8) {7 decoded_text += format("%02x", (uint8_t) c);8 }9 decoded_text += text + "]";10}如果某个字符不在 GPT-2 映射表里(理论上不应该,因为 GPT-2 映射覆盖全部 256 字节),输出一个可读的错误标记,而不是崩溃或静默丢弃。这对排查损坏的词表很有用。
lstrip 与前导空格
detokenize() 传入的 lstrip 参数最初来自 add_space_prefix:
1bool remove_space = add_space_prefix;然后在 token_to_piece() 的 _try_copy 里,遇到 NORMAL token 且只输出一个 token 时,会跳过 lstrip 个前导空格:
1for (int32_t i = 0; i < lstrip && size && *token == ' '; ++i) {2 token++;3 size--;4}这个逻辑对应 SPM 的 metaspace 约定:编码时每段文本开头补了空格(变成 ▁),解码时要把这个额外的前导空格去掉。remove_space 只在第一个 token 生效(之后置为 false),所以只会去掉整个序列开头的那个空格。
注意 remove_space = false 的特殊情况:remove_special && add_bos && tokens[0] == bos 时,BOS 被跳过,remove_space 强制为假。这个判断的意图是「BOS 后面紧跟的文本开头空格是用户真实文本的一部分,不应该被 lstrip 吃掉」。但如果模板生成的序列是 BOS + ▁Hello,那么 BOS 被删掉后、▁Hello 的前导空格不删,结果是多出一个前导空格而不是 Hello。这个行为容易被误以为 bug,实际上是「BOS 已消费了开头的 metaspace 语义」的有意设计。
对应地,BPE 的 metaspace_prepend_scheme == "first" 用另一个变量 remove_first_space 处理:只对第一个被渲染的 piece 去掉前导空格,一旦第一个 NORMAL token 被渲染就置为 false。这是 SentencePiece 风格 BPE 在解码时补齐同一个语义。
clean_spaces:后处理三段清洗
BPE 默认 clean_spaces = true(源码里 LLAMA_VOCAB_TYPE_BPE 分支统一设置),对应 Hugging Face tokenizer 的 clean_up_tokenization_spaces。detokenize() 拼完所有 piece 后,如果 clean_spaces 为真,做三遍原地压缩:
第一遍:去掉标点前的空格。
1if (text[i - 1] == ' ') {2 if (x == '?' || x == '!' || x == '.' || x == ',') { // " ?", " !", " .", " ,"3 total--; // remove space4 }5}"Hello !" 变成 "Hello!"。这一遍只处理 ? ! . , 四个 ASCII 标点,不处理中文全角标点。
第二遍:去掉「夹在两个空格中间的单引号」的多余空格。
1if (x == '\'' && i + 1 < total2 && text[i - 1] == ' ' && text[i + 1] == ' ') {2 total--;3 text[++i] = '\0';4}"he ' hello" 变成 "he'hello"。
第三遍:处理 's、'm、're、've 这类缩写的空格。
1if (text[i - 1] == ' ') {2 if (x == '\'') {3 if (x1 == 's' || x1 == 'm') total--;4 else if (x1 == 'l' && x2 == 'l') { /* 不删 */ }5 else if ((x1 == 'r' && x2 == 'e') || (x1 == 'v' && x2 == 'e')) total--;6 }7}"it 's" 变成 "it's"。注意 't 和 'd 是故意不删空格的:"he 'd" 保留成 "he 'd" 而不是 "he'd"。代码里的注释 //NOTE: this makes sense? 也承认这个规则有些可疑。这个不一致来自 Hugging Face tokenizer 的历史实现,为了保持「llama.cpp 和 Python 端输出一致」,只能照抄。
这三遍清洗都是原地压缩:text -= total 回退到缓冲区开头,再用 text[total++] = x 边读边写。因为写指针永远不超过读指针,原地压缩是安全的。这里也是「解码只按字节操作、不解析 UTF-8」的延续:三遍清洗把空格当成单字节 ' ',不关心上下文是不是 UTF-8 多字节字符的中间部分。对 BPE 的 clean_spaces 来说这不影响正确性,因为这些清洗规则只影响 ASCII 标点前的空格。

(图源:自制图,依据 llama.cpp 仓库 src/llama-vocab.cpp 中 llama_vocab::impl::token_to_piece()、detokenize() 的结构整理)
三种分词器的横向对比
| 维度 | SPM(SentencePiece) | BPE(GPT-2 风格) | WPM(WordPiece) |
|---|---|---|---|
| 初切 | UTF-8 字符 | 正则预分词成词,再逐字符 | 归一化后按空白/标点/CJK 切词 |
| 合并依据 | tokenizer.ggml.scores 分数 | merges 数组下标 | 无(贪婪最长匹配) |
| 合并方式 | 双向 bigram 优先队列 | 双向 bigram 优先队列(带完整文本校验) | 每个起点从长到短试子串 |
| 空格约定 | ▁ 表达前导空格 | escape_whitespaces 控制,GPT-2 走字节映射 | ▁(转换时由 ## 改写) |
| 字节兜底 | 逐字节 <0xNN> | GPT-2 字节映射或 <0xNN> | 无,整词 UNK |
| 解码 | ▁ 替换回空格;BYTE 直接输出 | 字节映射反查;BYTE 直接输出 | ▁ 替换回空格 |
| 特殊 token | BOS/EOS 可选 | BOS/EOS 可选 + 双 BOS 警告 | BOS/SEP 必加 |
这张表最值得记住的是编码和合并的优先级完全不同:SPM 靠分数,BPE 靠顺序(merges 文件里的行号),WPM 靠长度(贪婪)。同一个文本在这三种分词器下切出的 token 序列没有任何关系。很多「模型 A 的 tokenizer 换成模型 B 的」实验失败,原因就是把这个优先级表混用了。
与 llama.cpp 其它层的衔接
编码:llama_tokenize 的两个参数
llama_vocab::impl::tokenize() 有两个重载:一个返回 std::vector,一个写进调用方 buffer。两者都需要调用方传入 add_special 和 parse_special:
add_special = true:SPM/BPE 加 BOS/EOS,WPM 加 BOS/SEP;再由tokenizer.ggml.add_bos_token等配置进一步控制;parse_special = false(默认):普通用户文本里的特殊 token 字符串被当作普通文本;true:模板渲染结果里识别特殊 token。
这两个参数由 llama_tokenize() 公共 API 暴露,server 的 completions 与 chat completions 接口分别选择「parse_special=false」和「parse_special=true」,这就是为什么 chat 接口里 <|im_start|> 能变成特殊 token、而 completions 接口里同样的字符串被当普通文本。
解码:llama_token_to_piece 与 llama_detokenize
token_to_piece() 的输出是「单个 token 的字节串」,detokenize() 的输出是「整个序列的文本」。两者的区别不仅是批量与单条:
token_to_piece()不做跨 token 的字节拼接,所以对字节 token 会输出不完整 UTF-8;detokenize()也不做「按 Unicode 字符对齐」的切分,但它保证clean_spaces与lstrip后处理作用于整个序列;- 两者都只按
attr过滤 UNKNOWN/CONTROL。
流式输出时应该用 token_to_piece(),但要做自己的字节缓冲;需要一段完整文本时用 detokenize()。这是两个 API 的使用边界。
与采样层:特殊 token 的抑制
tokenizer.ggml.suppress_tokens 会被加载成 vocab.suppress_tokens,在采样阶段作为候选集过滤的参考。token_data.score 在采样阶段不参与 logits 计算,分词器的分数只用于分词。这一点和第(五)篇的采样器设计保持一致:分词器的分数是「文本表示」的分数,模型的分数是「下一个 token 的概率」的分数,两者不能混。
与对话模板:<|im_start|> 的双重身份
第十二篇讲过,模板把消息渲染成 <|im_start|>system、正文、<|im_end|> 这样的字符串。这一层字符串进入词表后:
tokenizer_st_partition()识别出<|im_start|>和<|im_end|>,切成特殊 token;- 中间的
system和正文作为 RAW_TEXT 进入对应分词器; - 如果
add_bos为真、模板又以 BOS 开头,check_double_bos_eos()会打印警告。
所以「BOS 被加了两次」这类线上问题,可以在分词器日志里直接看到,不需要去猜模板。
小结
llama_vocab 的设计可以浓缩成四句话:
- 类型是加载时定的:
tokenizer.ggml.model一次映射到llama_vocab_type,之后所有路径都按类型分派。tokenizer.ggml.pre再细分 BPE 的预分词正则。 - 统一的是入口,不是算法:
tokenize()/detokenize()是公共接口,内部的 SPM、BPE、WPM session 各自独立,各自有自己的队列、符号链、回退规则。 - 字节是兜底:SPM 靠
<0xNN>字节 token,BPE 靠 GPT-2 字节映射或<0xNN>,WPM 干脆不兜底。编码能「任意输入」,解码必须「无损拼接」,所以解码比编码多出三层逻辑。 naive_trie不是主流路径:它只服务 UGM 和 RWKV 这类需要「从当前位置最长匹配」的分词器,SPM/BPE/WPM 都用哈希查表或正则边界。把它说成「BPE 的前缀匹配树」是不准确的。
如果要把这一层和 llama.cpp 其它层串起来看,可以按「文本 → token → logits → token → 文本」走一遍:文本先到这里变成 token;模型前向在 llama.cpp 源码完全拆解(四):llama_context 与记忆系统 讲的 KV 缓存和图里处理;采样在第(五)篇把 logits 变回 token;生成的 token 又回到这里 detokenize() 成文本。这一圈里最容易被忽略、又最容易出错的,恰恰是本文这一层。
参考资料
- llama.cpp 仓库 llama-vocab.cpp 源码
- llama.cpp 仓库 llama-vocab.h 源码
- llama.cpp 公开 API 头文件 llama.h
- SentencePiece 论文:Subword Regularization: Improving Neural Network Translation Models with Multiple Subword Candidates
- Hugging Face tokenizers 文档
- GPT-2 论文:Language Models are Unsupervised Multitask Learners
- BERT 论文:BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding
- llama.cpp PR #6920:BPE 预分词正则的讨论
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
分享你的想法,与大家交流讨论
正在获取所在地区…
正在读取天气
音乐
暂未播放



