0%

理解 LLM 的 Tokenizer

本文使用 Hugging Face Transformers 库和 Qwen3-0.6B 模型,通过实际代码来深入理解大语言模型(LLM)中的 Tokenizer、BPE 分词、特殊 Token、Chat Template 等核心概念。

1. 为什么需要 Tokenizer?

大语言模型(LLM)本质上是一个巨大的数学概率模型,它看不懂文字,只能处理数字(向量)。Tokenizer 的核心任务,就是把我们输入的自然语言文本,切分并转换成模型能听得懂的数字序列。Tokenizer 会建立一本词表(Vocabulary),给每个常见字、词或者符号编上号(Token ID):

  • 编码(Encode):把人类可读的文本 → 一串数字 ID
  • 解码(Decode):把数字 ID → 人类可读的文本

没有 Tokenizer,你无法把自然语言的 你好 喂给模型,也无法把模型输出的数字变成你能读懂的文字。

基本概念

文本(Text)

文本(Text)是人类世界的语言,是人类日常交流所使用的原始字符串。它可以是一个词、一个句子、一篇文章,甚至是一整本书。特点:

  • 人类友好: 充满语义、逻辑、情感和标点符号。
  • 计算机不友好: 文本长度不固定,且直接看文字,计算机无法进行加减乘除等数学运算。

词元(Token)

词元(Token)则是文本被 Tokenizer(分词器)切分之后的语义碎片。Token 是模型处理语言时的最小语义单元。它的特点是:

  • 承上启下: 它是文本到数字的中间过渡状态。
  • 不一定是单词:在英文中,一个 Token 可能是完整单词(如 cool),也可能是前缀或子词(如 un、helpful);在中文中,它可能是一个词,也可能是不是一个合法的汉字
  • 带有特殊标记:很多模型的分词器会用特殊符号来标记 Token 的空间结构

Token 不是自然语言的 ,也不是"字"。它是分词算法拆分出的最小单位。一个 token 可能是:

类型 例子 说明
完整词 Hello, the, is 高频词直接作为 token
子词(subword) un, belie, vable 低频词被拆成多个子词 token
单字符 a, Z 极罕见的字符
字节级编码 ä½łå¥½ byte-level BPE 对 UTF-8 字节的特殊编码
特殊标记 <|im_start|>, <|im_end|> 用于标记对话结构

词表(Vocabulary)

词表(Vocabulary)就是模型 认识 的所有词元(Token)的字典,是所有合法 token 的集合,每个 token 有一个唯一数字 ID。词表大小会影响模型 embedding 的大小。从下文的 Demo 可以看出,Qwen3-0.6B 的词表大小是 151643

词元 ID(Token ID)

词元 ID(Token ID)则是计算机世界的数学代号,它是 Token 在模型的词表(Vocabulary)中所对应的整数索引(Index):

  • 每一个唯一的 Token 字符串,在字典里都有一个唯一固定的整数编号。大模型内部所有的矩阵乘法和概率计算,最初都是基于这些数字开始的
  • 数字无实际语义: ID 本身的大小(比如数字 100 和数字 10000)没有任何高低贵贱之分,只是普通的字典页码。真正的语义是通过后面的 Embedding(嵌入向量)学到的

Tokenizer 的工作流程

从高层次来看,Tokenizer 的工作并不是孤立的,它是人类世界与大模型神经网络之间的 安检通道翻译桥梁。我们可以把完整流程分为 输入(Encode) 和 输出(Decode) 两个宏观方向。

对于文本输入到模型(Encoding 流程):

  • 规范化 (Normalization):统一大小写(有些模型需要)、将繁体转简体、标准化各种奇怪的标点符号或隐形空格
  • 预切分 (Pre-tokenization):给出一个粗略的切分边界,通常是按空格和标点切分
  • 子词切分 (Subword Tokenization):核心步骤,根据模型自带的词表算法,把上一步切开的词进一步细化,得到 Token 列表
  • 编码 + 特殊 token:对照词表,把这些 Token 字符串变成数字 ID,同时添加特殊 Token

对于模型输出到人类(Decoding 流程):

  • ID 查表:把数字 ID 映射回 Token
  • 拼接与清理(Stream Decoding):Tokenizer 将其换回人类可读的文字,顺便清理掉不再需要的特殊符号
  • 渲染输出:最后输出人类可读的文字

Demo:基本分词操作

接下来我们通过一个实际 Demo 来了解 Tokenizer 的基本用法,对分词操作有个直观的认识:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
"""
Demo 1: Basic Tokenization
Demonstrates core Tokenizer functionality: converting text to tokens and IDs
"""
from transformers import AutoTokenizer

MODEL_NAME = "Qwen/Qwen3-0.6B"


def section(title):
print("\n" + "=" * 60)
print(title)
print("=" * 60)


tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)

section("1. Tokenizer Basic Info")
print(f"Vocab size: {tokenizer.vocab_size}")
print(f"Model max length: {tokenizer.model_max_length}")
print(f"Pad token: {tokenizer.pad_token} (id={tokenizer.pad_token_id})")
print(f"EOS token: {tokenizer.eos_token} (id={tokenizer.eos_token_id})")
print(f"UNK token: {tokenizer.unk_token} (id={tokenizer.unk_token_id})")

section("2. Text -> Token IDs (Encoding)")
text = "Hello, world! How are you?"
print(f"Original text: {text}")

encoded = tokenizer.encode(text, add_special_tokens=False)
tokens = tokenizer.tokenize(text)
print(f"Token IDs: {encoded}")
print(f"Token strings: {tokens}")
print(f"(add_special_tokens=False ensures encode() and tokenize() produce the same tokens)")

print("\nToken-to-ID mapping:")
for token, token_id in zip(tokens, encoded):
print(f" '{token}' -> {token_id}")

section("3. Token IDs -> Text (Decoding)")
decoded = tokenizer.decode(encoded)
print(f"Decoded result: {decoded}")

print("\nProgressive decoding (watch tokens merge):")
for i in range(len(encoded)):
partial = tokenizer.decode(encoded[:i+1])
print(f" IDs[:{i+1}] = {encoded[:i+1]} -> '{partial}'")

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
python 01_basic_tokenization.py

============================================================
1. Tokenizer Basic Info
============================================================
Vocab size: 151643
Model max length: 131072
Pad token: <|endoftext|> (id=151643)
EOS token: <|im_end|> (id=151645)
UNK token: None (id=None)

============================================================
2. Text -> Token IDs (Encoding)
============================================================
Original text: Hello, world! How are you?
Token IDs: [9707, 11, 1879, 0, 2585, 525, 498, 30]
Token strings: ['Hello', ',', 'Ġworld', '!', 'ĠHow', 'Ġare', 'Ġyou', '?']
(add_special_tokens=False ensures encode() and tokenize() produce the same tokens)

Token-to-ID mapping:
'Hello' -> 9707
',' -> 11
'Ġworld' -> 1879
'!' -> 0
'ĠHow' -> 2585
'Ġare' -> 525
'Ġyou' -> 498
'?' -> 30

============================================================
3. Token IDs -> Text (Decoding)
============================================================
Decoded result: Hello, world! How are you?

Progressive decoding (watch tokens merge):
IDs[:1] = [9707] -> 'Hello'
IDs[:2] = [9707, 11] -> 'Hello,'
IDs[:3] = [9707, 11, 1879] -> 'Hello, world'
IDs[:4] = [9707, 11, 1879, 0] -> 'Hello, world!'
IDs[:5] = [9707, 11, 1879, 0, 2585] -> 'Hello, world! How'
IDs[:6] = [9707, 11, 1879, 0, 2585, 525] -> 'Hello, world! How are'
IDs[:7] = [9707, 11, 1879, 0, 2585, 525, 498] -> 'Hello, world! How are you'
IDs[:8] = [9707, 11, 1879, 0, 2585, 525, 498, 30] -> 'Hello, world! How are you?'
  • 通过 Hugging Face 的 AutoTokenizer 自动去下载或加载本地的 Qwen(通义千问)分词器配置文件。
  • tokenizer.vocab_size 用于查看词表大小
  • tokenizer.model_max_length 用于查看模型能够支持的最大上下文长度,这里是 128k。上下文是输入(Prompt)和输出(Response)的总和
  • tokenizer.pad_token_id:使用 <|endoftext|> 作为 Pad Token
  • tokenizer.eos_token:使用 <|im_end|> 来标识聊天结束

我们也可以通过 tokenizer.convert_ids_to_tokens() 来将 id 转换为 token,使用 tokenizer.get_vocab() 获取整个词表

1
2
3
4
5
6
7
8
9
10
11
>>> tokenizer.convert_ids_to_tokens(0)
'!'

>>> tokenizer.convert_ids_to_tokens(11)
','

>>> tokenizer.convert_ids_to_tokens(9707)
'Hello'

>>> tokenizer.get_vocab()['Hello']
9707

分词算法

大模型每次处理或生成文本时,都是以 Token 为单位计费和消耗算力的。词表的大小直接决定了模型吃进一段话时,要把这段话切得多碎

  • 词表太小:如果词表过小,模型遇到复杂词汇时就需要将其进行拆分成多个 Token,这导致文本对应的 Token 序列变得极其漫长。因为大模型的 Attention(注意力机制)计算复杂度与序列长度的平方成正比,序列越长,模型不仅运行越慢,能容纳的上下文窗口(Context Window)也会缩水

  • 词表太大:大模型的入口 Embedding 层和出口(LM Head 输出层)的大小是直接与词表大小(Vocab Size)挂钩的。这两层的矩阵面积等于:词表大小×模型维度\text{词表大小} \times \text{模型维度}。如果词表过大,会导致模型 Embedding 层和输出层参数会显著增加

工程上一般取中间平衡点:兼顾序列长度、模型体积与语义表达。目前在整个大模型和自然语言处理(NLP)领域,主流的子词分词算法有:BPE、WordPiece、Unigram。它们出现的初衷,都是为了解决传统分词 词表太大未知词(UNK)太多 的痛点。但它们的解题思路和数学逻辑截然不同。

BPE (Byte-Pair Encoding,双核字节编码)

BPE 最初是一种数据压缩算法,后来被引入到 NLP 分词中。它的逻辑非常像堆乐高积木:一开始手里全是零散的小方块,谁跟谁经常挨在一起,就把它们粘成一个稍大一点的组合积木。

工作流程:

  • 初始化:把训练集里的所有文本拆成最基础的单个字符,这些字符组成初始词表
  • 统计频率: 统计语料库中,哪两个相邻的 Token 同时出现的次数最多(频率最高)
  • 合并: 把这个最高频的 双子星组合 合并成一个新的 Token,并塞进词表
  • 循环: 重复步骤 2 和 3,直到词表大小达到了你预设的上限(比如 5 万或 15 万)

该算法存在一种变体 Byte-level BPE (BBPE),连字符都不作为基础了,直接把文本转成 UTF-8 字节(Byte)。基础词表里雷打不动先躺着 0~255 这 256 个字节代号。因为世界万物皆可转字节,所以它彻底消除了 UNK(未知词)。

这就是 BPE 算法的精妙之处在于:

  • 常用词保持完整:高频出现的单词经过几次迭代直接变成一个 Token,保证了模型的计算效率
  • 罕见词拆成碎片:不常用的词也能被拆成基础碎片的组合,保证了模型不会遇到不认识的词

WordPiece 算法(基于最大概率合并)

WordPiece 的整体流程和 BPE 非常像,也是从单个字符开始,一步步把高频的片段合并成大词元。但它在决定“合并哪两个碎片”时,引入了更高级的统计语言模型概率:

  • BPE 只看两个片段在一起出现的绝对次数:这会导致像 of the、in the 这种本身基数就大的词被强行合并
  • WordPiece 看的是:合并这两个词,对整体语言模型的互信息(Mutual Information)提升有多大。如果两个词各自出现的概率很高,但凑在一起的概率并没有显著飙升,那么其 Score 就会很低。相反,如果两个词单独出现概率一般,但合在一起的概率却很高,算法就会优先合并它们

Unigram 算法

Unigram 算法与前两种(BPE 和 WordPiece)的思路完全不同。BPE 和 WordPiece 是自底向上地从小字符开始合并,而 Unigram 是自顶向下地从一个庞大的初始词汇表里,不断删掉 贡献不大 的子词。它的核心逻辑很简单,就两步:

  • 评估每个人(子词)的不可或缺性:具体来说,就是计算删除某个子词后,模型解释整个语料库的损失会增加多少
  • 留下骨干,裁掉冗余:一轮评估完成后,算法会给所有子词的重要性排序,一次性裁掉那些排名靠后、对效率影响最小的子词

不断重复这个 评估-裁员 的过程,直到团队规模精简到我们设定的目标大小。

查看模型的分词算法

我们可以使用如下示例代码查看模型所使用的分词算法信息:

1
2
3
4
5
>>> type(tokenizer)
<class 'transformers.models.qwen2.tokenization_qwen2.Qwen2Tokenizer'>

>>> tokenizer.backend_tokenizer.model
BPE(dropout=None, unk_token=None, continuing_subword_prefix="", end_of_word_suffix="", fuse_unk=False, byte_fallback=False, ignore_merges=False, vocab={"!":0, """:1, "#":2, "$":3, "%":4, "&":5, "'":6, "(":7, ")":8, "*":9, "+":10, ",":11, "-":12, ".":13, "/":14, "0":15, "1":16, "2":17, "3":18, "4":19, "5":20, "6":21, "7":22, "8":23, "9":24, ":":25, ";":26, "<":27, "=":28, ">":29, "?":30, "@":31, "A":32, "B":33, "C":34, "D":35, "E":36, "F":37, "G":38, "H":39, "I":40, "J":41, "K":42, "L":43, "M":44, "N":45, "O":46, "P":47, "Q":48, "R":49, "S":50, "T":51, "U":52, "V":53, "W":54, "X":55, "Y":56, "Z":57, "[":58, "\":59, "]":60, "^":61, "_":62, "`":63, "a":64, "b":65, "c":66, "d":67, "e":68, "f":69, "g":70, "h":71, "i":72, "j":73, "k":74, "l":75, "m":76, "n":77, "o":78, "p":79, "q":80, "r":81, "s":82, "t":83, "u":84, "v":85, "w":86, "x":87, "y":88, "z":89, "{":90, "|":91, "}":92, "~":93, "¡":94, "¢":95, "£":96, "¤":97, "¥":98, ...}, merges=[("Ġ", "Ġ"), ("ĠĠ", "ĠĠ"), ("i", "n"), ("Ġ", "t"), ("ĠĠĠĠ", "ĠĠĠĠ"), ("e", "r"), ("ĠĠ", "Ġ"), ("o", "n"), ("Ġ", "a"), ("r", "e"), ("a", "t"), ("s", "t"), ("e", "n"), ("o", "r"), ("Ġt", "h"), ("Ċ", "Ċ"), ("Ġ", "c"), ("l", "e"), ("Ġ", "s"), ("i", "t"), ("a", "n"), ("a", "r"), ("a", "l"), ("Ġth", "e"), (";", "Ċ"), ("Ġ", "p"), ("Ġ", "f"), ("o", "u"), ("Ġ", "="), ("i", "s"), ("ĠĠĠĠ", "ĠĠĠ"), ("in", "g"), ("e", "s"), ("Ġ", "w"), ("i", "on"), ("e", "d"), ("i", "c"), ("Ġ", "b"), ("Ġ", "d"), ("e", "t"), ("Ġ", "m"), ("Ġ", "o"), ("ĉ", "ĉ"), ("r", "o"), ("a", "s"), ("e", "l"), ("c", "t"), ("n", "d"), ("Ġ", "in"), ("Ġ", "h"), ("en", "t"), ("i", "d"), ("Ġ", "n"), ("a", "m"), ("ĠĠĠĠĠĠĠĠ", "ĠĠĠ"), ("Ġt", "o"), ("Ġ", "re"), ("-", "-"), ("Ġ", "{"), ("Ġo", "f"), ("o", "m"), (")", ";Ċ"), ("i", "m"), ("č", "Ċ"), ("Ġ", "("), ("i", "l"), ("/", "/"), ("Ġa", "nd"), ("u", "r"), ("s", "e"), ("Ġ", "l"), ("e", "x"), ("Ġ", "S"), ("a", "d"), ("Ġ", """), ("c", "h"), ("u", "t"), ("i", "f"), ("*", "*"), ("Ġ", "}"), ("e", "m"), ("o", "l"), ("ĠĠĠĠĠĠĠĠ", "ĠĠĠĠĠĠĠĠ"), ("t", "h"), (")", "Ċ"), ("Ġ{", "Ċ"), ("Ġ", "g"), ("i", "g"), ("i", "v"), (",", "Ċ"), ("c", "e"), ("o", "d"), ("Ġ", "v"), ("at", "e"), ("Ġ", "T"), ("a", "g"), ("a", "y"), ("Ġ", "*"), ("o", "t"), ...])

从模型文件中查看分词配置

我们可以从模型文件的 tokenizer_config.json 获取所使用的分词器类,这里是 Qwen2Tokenizer

1
2
3
4
5
6
7
8
......
"eos_token": "<|im_end|>",
"errors": "replace",
"model_max_length": 131072,
"pad_token": "<|endoftext|>",
"split_special_tokens": false,
"tokenizer_class": "Qwen2Tokenizer",
"unk_token": null

模型的 tokenizer.json 则是 tokenizer 的完整定义文件。这个 JSON 文件包含了 tokenizer 运行所需的全部信息,不需要任何 Python 代码就能重建 tokenizer。它的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"model": {
"type": "BPE",
"vocab": {"!": 0, ",": 11, "Hello": 9707, ...}, // token -> ID 映射
"merges": [["Ġ", "Ġ"], ["i", "n"], ...] // BPE 合并规则(按优先级排序)
},
"normalizer": {"type": "NFC"}, // 文本标准化
"pre_tokenizer": { // 分词前的切分规则
"type": "Sequence",
"pretokenizers": [
{"type": "Split", "pattern": {"Regex": "..."}}, // 正则切分
{"type": "ByteLevel"} // 转字节
]
},
"decoder": {"type": "ByteLevel"}, // 解码方式
"added_tokens": [ // 特殊 token
{"id": 151643, "content": "<|endoftext|>", "special": true},
{"id": 151644, "content": "<|im_start|>", "special": true},
{"id": 151645, "content": "<|im_end|>", "special": true}
......
]
}

每个字段的作用:

字段 作用
model.vocab 151643 个 token 到 ID 的映射,即词表
model.merges 151387 条 BPE 合并规则,按优先级排列
normalizer 分词前先做 Unicode 标准化(NFC)
pre_tokenizer 先正则切分、再转字节,决定分词粒度
decoder 编码还原为文本的逆过程
added_tokens 特殊 token,不在 BPE 词表内,额外插入

tokenizer.json 是 tokenizer 的 配方。HuggingFace 的 tokenizers 库(Rust 编写)读取这个 JSON 就能构建完整的 tokenizer,不需要任何 Python 逻辑。Qwen2Tokenizer 这个 Python 类只是在 tokenizers.Tokenizer 外面包了一层易用的 API。

1
2
3
4
5
AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B")
1. 读 tokenizer_config.json → 确定 Python 类 (Qwen2Tokenizer)
2. 创建 Qwen2Tokenizer 实例
3. 读 tokenizer.json → 加载到 backend_tokenizer (Rust 层)
4. 读 tokenizer_config.json → 设置 chat_template、pad_token、eos_token 等 Python 层属性

所以:

  • tokenizer.json 定义 怎么分词(Rust 层,算法+词表+合并规则)
  • tokenizer_config.json 定义 怎么用这个 tokenizer(Python 层,类名+chat_template+特殊token声明)

聊天模版

大模型(LLM)其实是个纯文本续写机器,它根本不知道什么是 用户、什么是 AI。为了让模型明白这是一场对话,安全地分清谁说了什么,训练时会使用特殊的控制标记(Special Tokens)把对话包装成一段特定格式的纯文本。聊天模版Chat Template 的作用是:

把结构化的多轮对话消息 → 格式化为一个扁平的文本字符串 → 再 tokenize 喂给模型

不同模型的训练数据使用不同的对话格式,所以它们的 Chat Template 也不同。用错了 template,模型的表现会大幅下降。Chat Template 把每个模型独特的拼接规则写成一段 Jinja2 脚本,存在分词器(Tokenizer)里。你只需要给它标准的数据结构,它自动帮你拼好。

从模型文件的 tokenizer_config.json chat_template 配置里可以看到 Qwen3-0.6B 所使用的聊天模版 。或者通过 transformers 库,我们可以直接查看模型的 Chat Template

1
2
3
4
5
6
7
8
>>> from transformers import AutoTokenizer

>>> MODEL_NAME = "Qwen/Qwen3-0.6B"
>>> tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)

>>> tokenizer.chat_template
'{%- if tools %}\n {{- \'<|im_start|>system\\n\' }}\n {%- if messages[0].role == \'system\' %}\n {{- messages[0].content + \'\\n\\n\' }}\n {%- endif %}\n {{- "# Tools\\n\\nYou may call one or more functions to assist with the user query.\\n\\nYou are provided with function signatures within <tools></tools> XML tags:\\n<tools>" }}\n {%- for tool in tools %}\n
......

我们可以使用 apply_chat_template() 方法查看渲染后的实际文本内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
>>> messages = [
... {"role": "user", "content": "你好,请介绍一下你自己"}
... ]

>>> tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
'<|im_start|>user\n你好,请介绍一下你自己<|im_end|>\n<|im_start|>assistant\n'


>>> messages = [
... {"role": "user", "content": "1+1等于几?"},
... {"role": "assistant", "content": "1+1等于2。"},
... {"role": "user", "content": "那2+2呢?"},
... ]
>>> tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
'<|im_start|>user\n1+1等于几?<|im_end|>\n<|im_start|>assistant\n1+1等于2。<|im_end|>\n<|im_start|>user\n那2+2呢?<|im_end|>\n<|im_start|>assistant\n'

>>> messages_with_system = [
... {"role": "system", "content": "你是一个有帮助的AI助手,请用中文回答。"},
... {"role": "user", "content": "什么是量子计算?"},
... ]
>>> tokenizer.apply_chat_template(
... messages_with_system, tokenize=False, add_generation_prompt=True
... )
'<|im_start|>system\n你是一个有帮助的AI助手,请用中文回答。<|im_end|>\n<|im_start|>user\n什么是量子计算?<|im_end|>\n<|im_start|>assistant\n'

>>> messages_thinking = [
... {"role": "user", "content": "计算 17 * 23"},
... ]
>>> tokenizer.apply_chat_template(
... messages_thinking,
... tokenize=False,
... add_generation_prompt=True,
... enable_thinking=False,
... )
'<|im_start|>user\n计算 17 * 23<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n'
>>> tokenizer.apply_chat_template(
... messages_thinking,
... tokenize=False,
... add_generation_prompt=True,
... enable_thinking=True,
... )
'<|im_start|>user\n计算 17 * 23<|im_end|>\n<|im_start|>assistant\n'
  • add_generation_prompt=True 在末尾添加助手前缀,引导模型开始生成
  • 如果 enable_thinking=True(或默认未定义):模型会正常走 <think> 流程,边思考边输出,最后再给答案
  • 如果 enable_thinking=False:模板通过提前闭合标签,欺骗并强行制止了大模型的思考行为,让其直接输出结果

端到端推理测试

最后我们来通过实际一个端到端推理测试实例,来看下整个推理过程是如何一步一步完成的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM

MODEL_NAME = "Qwen/Qwen3-0.6B"

print("加载模型和 tokenizer...")
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
torch_dtype=torch.float16,
device_map="auto",
)
model.eval()
print("加载完成!")

print("\n" + "=" * 60)
print("1. 完整推理流程 (逐步拆解)")
print("=" * 60)

# Step 1: 构建对话
messages = [
{"role": "user", "content": "请用一句话介绍什么是人工智能"}
]
print(f"Step 1 - 用户输入: {messages[0]['content']}")

# Step 2: 应用 chat template
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
print(f"\nStep 2 - Chat Template 格式化:")
print(f" {repr(text)}")

# Step 3: Tokenize
inputs = tokenizer(text, return_tensors="pt").to(model.device)
print(f"\nStep 3 - Tokenize:")
print(f" input_ids shape: {inputs['input_ids'].shape}")
print(f" input_ids: {inputs['input_ids'].tolist()[0]}")
print(f" token 数: {inputs['input_ids'].shape[1]}")

# Step 4: 模型前向推理
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=1000,
do_sample=False,
temperature=None,
top_p=None,
)
print(f"\nStep 4 - 模型生成:")
print(f" 输出 ids shape: {outputs.shape}")
print(f" 输出 ids: {outputs.tolist()[0]}")

# Step 5: 解码
generated_ids = outputs[0][inputs['input_ids'].shape[1]:]
generated_text = tokenizer.decode(generated_ids, skip_special_tokens=True)
print(f"\nStep 5 - 解码生成部分:")
print(f" 生成 token 数: {len(generated_ids)}")
print(f" 生成文本: {generated_text}")

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
============================================================
1. 完整推理流程 (逐步拆解)
============================================================
Step 1 - 用户输入: 请用一句话介绍什么是人工智能

Step 2 - Chat Template 格式化:
'<|im_start|>user\n请用一句话介绍什么是人工智能<|im_end|>\n<|im_start|>assistant\n'

Step 3 - Tokenize:
input_ids shape: torch.Size([1, 14])
input_ids: [151644, 872, 198, 14880, 11622, 105321, 100157, 106582, 104455, 151645, 198, 151644, 77091, 198]
token 数: 14
The following generation flags are not valid and may be ignored: ['top_k']. Set `TRANSFORMERS_VERBOSITY=info` for more details.

Step 4 - 模型生成:
输出 ids shape: torch.Size([1, 223])
输出 ids: [151644, 872, 198, 14880, 11622, 105321, 100157, 106582, 104455, 151645, 198, 151644, 77091, 198, 151667, 198, 99692, 3837, 20002, 104029, 11622, 105321, 100157, 106582, 104455, 1773, 101140, 3837, 35946, 85106, 60610, 20002, 104378, 102021, 1773, 99650, 87267, 101219, 100134, 99896, 101290, 3837, 100631, 85106, 101098, 99794, 15469, 9370, 91282, 1773, 20002, 87267, 80443, 101319, 102193, 3837, 99999, 85106, 110485, 30858, 34187, 9370, 104136, 3407, 104326, 3837, 35946, 49828, 101118, 20002, 87267, 9370, 114246, 100354, 1773, 99650, 87267, 99880, 99794, 15469, 105166, 91282, 3837, 100631, 99172, 81167, 99283, 32664, 15469, 108894, 64471, 88991, 1773, 101886, 3837, 102104, 85106, 102188, 100136, 116336, 86744, 100272, 3407, 101889, 3837, 35946, 85106, 103944, 109949, 100166, 88991, 3837, 27369, 100011, 1773, 99730, 100630, 15469, 104867, 101290, 3837, 101912, 20074, 54542, 5373, 100134, 99788, 3837, 101034, 99892, 100650, 1773, 91572, 3837, 101153, 37029, 99878, 116925, 106071, 3837, 100662, 113113, 32108, 3407, 104019, 101071, 107189, 119256, 88683, 105427, 3837, 103944, 105321, 101447, 110485, 1773, 87267, 85106, 101921, 11622, 99689, 3837, 101912, 2073, 100168, 72448, 854, 56006, 2073, 104455, 854, 33126, 101536, 3837, 100631, 2073, 100842, 100134, 854, 33126, 102188, 1773, 100161, 3837, 81167, 109949, 110205, 3837, 80443, 117206, 32100, 8997, 151668, 271, 104455, 9909, 15469, 7552, 104442, 67338, 107018, 33108, 20074, 54542, 3837, 32555, 104564, 100006, 75117, 85106, 103971, 100168, 108530, 3837, 29524, 100134, 5373, 113272, 5373, 102041, 49567, 1773, 151645]

Step 5 - 解码生成部分:
生成 token 数: 209
生成文本: <think>
好的,用户让我用一句话介绍什么是人工智能。首先,我需要确定用户的需求是什么。他们可能是在学习基础概念,或者需要快速了解AI的定义。用户可能没有太多背景,所以需要简洁明了的解释。

接下来,我得考虑用户可能的深层需求。他们可能希望了解AI的基本定义,或者想确认自己对AI的理解是否正确。因此,回答需要准确且通俗易懂。

然后,我需要确保句子结构正确,信息全面。应该包括AI的核心概念,比如数据处理、学习能力,以及应用领域。同时,避免使用专业术语过多,保持口语化。

还要检查是否有冗余的信息,确保一句话足够简洁。可能需要调整用词,比如“智能系统”比“人工智能”更常见,或者“自主学习”更准确。最后,确认句子流畅,没有语法错误。
</think>

人工智能(AI)是指通过算法和数据处理,使计算机能够执行需要人类智能的任务,如学习、推理、决策等。

整个示例的代码还是非常简单,唯一值得注意的代码就是 generated_ids = outputs[0][inputs['input_ids'].shape[1]:],它的作用是:把大模型输出的完整 Token 序列进行 切片,切掉前面的 问题(Input),只保留模型新生成的 回答(Output)

  • 大模型(AutoRegressive LM)的推理逻辑是文字续写,模型返回的 outputs 并不是纯粹的答案,而是 原始问题 + 答案
  • 如果不做切片直接解码,打印出来的文本就会包含原始问题,显得非常冗余

另外需要注意,解码时需要通过 skip_special_tokens=True 来跳过特殊 token。

小结

这篇文章结合可实际运行的 Demo,帮助我们直观且深入地理解 LLM 分词器的核心原理,掌握了 token、各种分词算法以及 Chat Template 等关键知识点,为我们进一步理解 LLM 的工作原理打下了基础。