NLP 圈有一个经常被跳过的基础问题:你的文本到底是怎么被切成一串数字喂给模型的?中文 NLP 项目在前期最容易出问题的也是这个环节。字符切、词语切、子词切,三种策略得到的结果完全不同,而后续的 post training(后训练)效果、模型输出质量、显存占用和训练效率都和解码器、词表直接相关。
这次分享的内容来自“东锡 NLP”这一科普系列的思路:从 tokenizer 开始,把自然语言处理里最容易被忽视的底层机制讲清楚。文章同时覆盖三个部分:tokenizer 的核心原理、基于中文语料库的数据清洗、post training 阶段如何处理 tokenizer 和词表。如果你之前只调用现成 API,从来没有自己建过词表,也没看过微调数据长什么样,这篇文章值得收藏后照着跑一遍。
1. 核心概念速览
| 观察项 | 说明 |
|---|---|
| 主题类型 | NLP 基础原理与实战科普 |
| 核心问题 | tokenizer 如何切分文本、post training 如何受词表影响 |
| 常见分词方案 | BPE、WordPiece、SentencePiece、Unigram |
| 语料处理重点 | 中文去重、清洗、过滤脏数据、统计 token 覆盖 |
| 后训练阶段 | 增量预训练、指令微调 SFT、偏好对齐 DPO/RLHF |
| 难度分级 | 入门到进阶,每个部分都配有可执行代码 |
| 是否依赖特殊硬件 | 分词阶段 CPU 即可;模型训练环节需要 NVIDIA GPU 或等效设备 |
| 最小实验环境 | Python 3.9 以上、8G 以上显存可按需缩小模型测试 |
| 是否支持批量处理 | 支持,通过 datasets.map 可实现大规模批量 tokenize |
| 是否涉及 API 调用 | 是,科普案例使用通用 HTTP 推理接口演示 |
2. 适用场景与使用边界
这篇文章适合需要做以下工作的读者:
- 想自己训练 BPE 或 SentencePiece 词表,而不是永远直接用开源模型的 tokenizer。
- 正在搭建中文 NLP 语料库,需要一套清晰的数据清洗清单。
- 准备做 post training,但不知道 instruction 数据、对话模板和特殊 token 之间是什么关系。
- 被“词表扩容”“embedding resize”“增量预训练”这些词困扰过。
- 遇到长文本截断、输出截断、无法识别生僻词等实际问题。
它不适合谁?如果你只是调用 API 做简单文本分类,且不关心训练细节,看本文前两章即可,不需要强迫自己全部读完。
使用边界需要注意:任何数据清洗和模型训练都应当使用有合法授权的语料。爬取公开网页时,要符合目标网站的服务条款和版权规定;涉及真实人物姓名、聊天记录、音视频素材时,必须获得授权,不能直接用来训练和发布。开源模型本身也有对应许可证,商用前要逐条核对模型 license、训练数据来源和衍生模型发布条件。
3. 环境准备与工具链选择
3.1 本地部署的通用检查清单
下面这套环境不是某个项目的唯一要求,而是 NLP 训练任务开始前的通用配置。你可以根据手上的 GPU 型号调整。
| 项目 | 推荐配置 | 用途 |
|---|---|---|
| 操作系统 | Windows 11 / Ubuntu 20.04 / macOS(M系列) | 开发环境 |
| Python | 3.9 或 3.10 | HuggingFace 生态依赖 |
| 显卡 | NVIDIA 显卡,显存 ≥ 8G | 后训练实验,建议先跑 1B 以下模型 |
| CPU | 8 核以上 | 批量 tokenization、数据预处理 |
| 内存 | 16G 以上 | 加载中等规模语料 |
| 磁盘 | 至少 20G 空闲空间 | 存放预训练模型、词表、中间文件 |
| 框架 | PyTorch、Transformers、Tokenizers、Datasets | 核心依赖 |
如果机器上没有独立显卡,tokenizer 训练和数据清洗可以正常跑完,但模型后训练阶段速度会非常慢。更实际的做法是在线 Colab 或租借按小时计费的 GPU 实例先做小规模验证。
3.2 安装依赖库
建议先创建独立的 Python 虚拟环境,避免和已有项目冲突。典型的安装命令如下:
python -m venv venv source venv/bin/activate # Windows 下改为 venv\Scripts\activate pip install --upgrade pip pip install torch transformers tokenizers datasets accelerate如果后续要跑微调,还需要补充安装:
pip install peft trl3.3 验证基础环境
安装完成后,在 Python 里运行下面这段代码,判断 HuggingFace 生态是否可用:
from transformers import AutoTokenizer # 这里只是验证工具链,模型路径可按实际环境替换 tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese") text = "自然语言处理中的tokenizer到底在做什么" ids = tokenizer.encode(text) print(ids) print(tokenizer.decode(ids))如果看到一段不会报错的 id 序列,就说明环境基本正常。不同网络环境下模型下载速度差异较大,可以提前设置HF_ENDPOINT镜像,但具体镜像地址请查阅你所在网络环境可用的官方或可信配置。
4. 从 tokenizer 开始:自建 BPE 词表实战
4.1 为什么要反复强调 tokenizer
很多人以为 tokenizer 只是“分词工具”,感觉调包就能解决,实际不是这样。tokenizer 决定了一句话会被映射成多少个 token,也决定了模型能认识哪些字符形态。
举个例子,直接按照字切分的 tokenizer,遇到“自然语言处理”会输出 6 个 token。使用 Byte-Pair Encoding 的模型可能会把高频词“自然语言”合并成一个 token。token 数量越少,模型单次前向计算能“看到”的文本信息越多,训练和推理的 token 成本也越低。与此同时,如果词表切分过粗,模型对低频词的泛化能力会下降。
核心结论:tokenizer 不是模型外部的查表工具,它直接决定了模型阅读文本的粒度。
4.2 准备一份小型中文语料
为了快速实验,不需要一开始就准备海量数据。可以把自己的博客文章、新闻语料片段或公开版权文本整理成一个 TXT 文件。
假设这是一个简化样例文件:
NLP自然语言处理基础 tokenizer是NLP项目的第一层抽象 数据清洗决定语料质量 模型后训练需要关注词表覆盖 批量任务需要稳定的分词器配置实际训练时请使用版权清晰、来源合规的语料,比如自己生产的内容、明确授权开放的数据集。不需要等待全部数据收集完成,先用 100MB 左右语料就能看出词表和分词规律。
4.3 使用 tokenizers 库训练 BPE
这里以最能体现细节的 BPE 为例。BPE 的核心思想是从字符级别开始,不断统计相邻单元的出现频次,把最高频的相邻对合并成一个新单元,直到词表大小满足要求。优点是既能覆盖常见词,又能保留单字符兜底,不容易出现整个词都为 UNK 的情况。
from tokenizers import Tokenizer, models, pre_tokenizers, decoders, trainers # 使用 BPE 作为基础模型 tokenizer = Tokenizer(models.BPE()) # 中文场景常用预切分规则:按字符切,同时保留英文子词 tokenizer.pre_tokenizer = pre_tokenizers.Metaspace() tokenizer.decoder = decoders.Metaspace() # 定义 trainer,词表大小先设 5000,方便观察 trainer = trainers.BpeTrainer( vocab_size=5000, special_tokens=["[PAD]", "[UNK]", "[BOS]", "[EOS]", "[SEP]", "[CLS]", "[MASK]"], min_frequency=2, # 低于 2 次的片段不参与合并 show_progress=True ) files = ["data/nlp_corpus.txt"] tokenizer.train(files, trainer) tokenizer.save("my_tokenizer.json") # 加载并测试 from tokenizers import Tokenizer t = Tokenizer.from_file("my_tokenizer.json") text = "tokenizer是NLP项目的第一层抽象" enc = t.encode(text) print(enc.tokens) print(enc.ids) print(t.decode(enc.ids))这段代码跑完后,你可以观察自己的语料里出现了哪些中文片段。例如“自然语言”是否被切成一个 token,标点符号是否被保留,英文单词是否被拆分。
注意,这里演示的 tokenizer 还缺少 huggingface 框架需要的normalizer和post_processor。要和 Transformers 模型一起使用,还要用PreTrainedTokenizerFast包装。官方封装方案是先把自定义 tokenizer 转换成 SentencePiece 风格或直接保存为分词器配置,这是一整套工程流程,也是很多人自建词表后踩坑最多的地方。
4.4 中文数据清洗实战
分词器训练前,语料里往往混着大量无效信息。中文 NLP 数据清洗的核心不是追求“绝对干净”,而是在不破坏语义的前提下降低信息污染。常见的清洗对象包括:
| 要处理的噪声 | 清洗策略 |
|---|---|
| HTML 标签 | 用解析库去除标签,只保留可见文本 |
| 连续重复标点 | 替换为单个标点 |
| 大量空行、空白字符 | 统一规范为单空格或直接合并 |
| 无意义乱码 | 正则过滤非法字符 |
| 重复段落 | 基于 MinHash 或前缀哈希做近似去重 |
| 过短句子 | 过滤长度小于 5 个字符的行 |
| 敏感信息和个人隐私 | 先识别手机号、身份证号,再做掩码或删除 |
下面是一段简单但有效的清洗函数:
import re def clean_text(text: str) -> str: text = text.replace("\u3000", "") # 去掉全角空格 text = re.sub(r"<[^>]+>", "", text) # 去 HTML text = re.sub(r"[\r\n]+", "\n", text) # 合并换行 text = re.sub(r"[ \t]+", " ", text) # 合并空格 text = re.sub(r"([。!?;;!?])\1+", r"\1", text) # 压缩重复标点 return text.strip()清洗之后,最好再做一次质量抽样检查:随机打印 500 条,人工判断连续性、语义完整度、语言风格是否正常。中文文章标题、正文、列表混在一起时,要根据来源添加text_type字段,方便后续 post training 构造不同任务。
5. tokenizer 质量的可视化复盘
分词器训练完,不是看 loss,而是看“token 覆盖率和切分稳定性”。这里提供一套简单验证方法。
5.1 检查编码结果
针对中文文本,重点看两个现象:
- 是否把词语拆得过于零碎?例如“人工智能”被拆成“人”、“工”、“智”、“能”,说明词表规模太低或 min_frequency 太高。
- 是否又过度合并?例如把“全国人民”合并成单一 token,这种词在下一个任务里并不一定复用。
5.2 统计 token 覆盖率
把测试语料逐条编码,统计平均 token 数、字符数和 token 数的比例。这个比例高,说明每 token 携带信息量足。
total_chars = 0 total_tokens = 0 for line in test_lines: enc = tokenizer.encode(line.strip()) total_chars += len(line.strip()) total_tokens += len(enc.ids) print("平均中文字符/token 比例:", round(total_chars / total_tokens, 3))不同模型环境得到的结果差异极大,不需要照抄任何标准值,而是用同一份语料比较不同 tokenizer,优先选 token 数量更少且未登录词更少的方案。如果字符 token 比例太高,说明模型吃进去的每段文本可能被切碎,模型需要付出更多计算量才能理解“词边界”。
5.3 特殊 token 是否齐全
在 post training 阶段,特殊 token 的作用会立刻放大。模型需要[PAD]做 batch padding;需要[SEP]区分句子边界;对话模型需要<|im_start|>、<|im_end|>控制角色轮次。自建词表时,特殊 token 必须一开始就加入,不要等训练完再往词表尾巴上追加,否则容易导致 embedding 矩阵尺寸不匹配。
6. post training 科普:tokenizer 在这里做什么
6.1 先理清后训练的几个阶段
后训练是把“通用预训练模型”变成“可用模型”的统称,常见阶段包括:
| 阶段名称 | 目标 | 主要数据 |
|---|---|---|
| Continue Pretraining | 让模型继续学习领域知识 | 领域文档、新闻、论文 |
| SFT 指令微调 | 让模型学会回答问题 | 指令和回答对 |
| DPO / RLHF | 让模型输出符合人类偏好 | 偏好排序数据或奖励模型 |
很多新手理解不了 tokenizer 在 post training 里的作用,是因为他们以为微调只改权重,和分词无关。实际上,每次微调时训练数据都要经历“文本 → token id → embedding lookup”的过程。如果模型词表里没有某些领域高频词,文本就会被切得更碎,模型学习效率下降。最典型的例子是代码模型遇到中文语料,化学模型遇到专业英文缩写,金融模型遇到证券业务黑话。
6.2 指令数据的 tokenizer 处理
现在最通用的 post training 做法,是把用户问题、系统提示词和模型回答拼成一个序列,然后用 tokenizer 加 chat template 处理。以常见的对话数据结构为例:
{ "messages": [ { "role": "system", "content": "你是一个中文 NLP 助手,回答需要简洁准确。" }, { "role": "user", "content": "请解释 tokenizer 和后训练的关系。" }, { "role": "assistant", "content": "tokenizer 负责把文本切成子词单元,后训练的损失函数在 token 级别计算。" } ] }在 Transformers 生态中,代码可以写成下面这种形式:
from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("your-base-model") def tokenize_example(example): text = tokenizer.apply_chat_template( example["messages"], tokenize=False, add_generation_prompt=False ) enc = tokenizer( text, truncation=True, max_length=2048, padding=False, return_tensors=None ) enc["labels"] = enc["input_ids"].copy() return enc需要注意:指令微调时,labels通常要求对用户输入部分做 mask,只保留 assistant 回答部分参与 loss。上面代码只是让 assistant 回答和用户输入都参与训练,适合快速实验,但并不严谨。商业级指令微调要考虑“只计算答案 token 的 loss”。
6.3 为什么 tokenizer 截断会影响后训练
很多 post training 失败案例不是模型权重出问题,而是数据太长被截断了。tokenizer 默认从右侧截断,如果模型回答必须出现在末尾,截断可能把回答直接砍掉。解决办法通常有两种:左侧截断或居中截断,本质是把想要保留的内容保留在有效长度内。
这段代码展示如何在训练前设置左侧截断,保证输入 prompt 的完整性更可能被保留:
def tokenize_with_left_truncation(examples): result = tokenizer( examples["text"], max_length=1024, truncation=True, padding=False ) return result严格来说,最稳妥还是先按文本语义拆分,再让每条样本的长度服从一个合理的 token 分布,而不是单一截断。
7. 批量 Tokenization 与本地接口服务
7.1 为什么要用批量任务
自建语料和微调之间隔着一层“批量 tokenization”。处理几万条 JSONL 数据时,不能在循环里一条一条地编码,效率很低。正确做法是借助 HuggingFace Datasets 库实现多进程批量转换:
from datasets import load_dataset from transformers import AutoTokenizer from functools import partial dataset = load_dataset("json", data_files="my_train_data.jsonl", split="train") tokenizer = AutoTokenizer.from_pretrained("your-base-model") def tokenize_batch(examples): texts = examples["text"] enc = tokenizer( texts, max_length=1024, truncation=True, padding=False ) enc["labels"] = enc["input_ids"].copy() return enc dataset = dataset.map( tokenize_batch, batched=True, num_proc=8, remove_columns=dataset.column_names, desc="批量 tokenize" ) dataset.save_to_disk("data/tokenized_train")处理大批量数据时,建议把预处理缓存保存下来,这样下次调参数时不需要重复分词。另外,num_proc不要盲目设成 CPU 核心数,因为多个 worker 同时加载 tokenizer 也会占用内存。
7.2 推理接口的通用调用方式
训练完模型或加载开源基座模型后,最常见的部署方式是把模型封装成兼容 OpenAI 的 HTTP 接口。这里给出一段通用调用示例,端口和模型名需要按你实际启动的推理服务调整:
import requests url = "http://127.0.0.1:8000/v1/chat/completions" payload = { "model": "your-chat-model", "messages": [ {"role": "user", "content": "解释中文NLP语料清洗的关键步骤"} ], "temperature": 0.3, "max_tokens": 200 } resp = requests.post(url, json=payload, timeout=60) print(resp.status_code) print(resp.json())如果你只是需要定期批量推理,建议把输入写入 JSONL 或通过任务队列发送,避免在主线程里同步等待长文本生成。接口层只负责短请求转发,数据清洗和图像语音解码等耗时任务应放在后台执行。
8. 资源占用与性能观察方法
8.1 哪些环节吃 CPU,哪些吃 GPU
| 环节 | 主要资源 | 瓶颈 |
|---|---|---|
| 语料收集和 HTML 清洗 | CPU | 单线程正则慢 |
| 重复数据去除 | CPU + 内存 | 数据量过大 |
| BPE 训练 | CPU | vocab_size 与迭代轮数 |
| 批量 tokenize | CPU 多核 | 单核速度受限制 |
| 模型后训练 | GPU 显存 | batch size、序列长度、模型参数规模 |
| API 推理 | GPU 显存 | 并发请求数和 KV Cache |
很多人把显卡视为一切,忘记 tokenizer 训练和数据处理阶段如果只用单线程,几 GB 语料也能跑几个小时。建议重度清洗操作都改成多进程,或使用 Spark 等分布式引擎处理超大语料。
8.2 如何降低显存占用
后训练实验如果 8G 显存显存不足,优先做这几件事:
- 使用
float16或bfloat16混合精度,而不是默认 float32。 - 使用 LoRA / QLoRA 微调,冻结主干模型权重,减少优化器显存。
- 降低 batch size,梯度累积补足更新步数。
- 降低
max_length,从 2048 降到 1024,观察效果变化。 - 使用 4-bit 量化加载基座模型。
显存占用必须用当前机器实际跑一次才能确定,不要直接根据别人的截图制定训练计划。观察工具直接用nvidia-smi或watch -n 1 nvidia-smi,要重点关注“Processes”里当前进程的显存占用,而不是全局显存剩余。
8.3 端口冲突和进程残留
本地推理服务启动后,如果第二次运行报端口被占用,先查端口占用进程:
netstat -ano | findstr :8000 taskkill /PID <进程号> /FLinux 或 macOS 下使用:
lsof -i :8000 kill -9 <PID>如果启动的是 Python 脚本,也经常因为 Ctrl+C 没有彻底终止残留 GPU 进程。启动前可以先用nvidia-smi看一下 GPU 占用,确保没有残留训练进程。
9. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 自建 tokenizer 无法加载到 transformers | 缺少 PreTrainedTokenizerFast 封装 | 打印 tokenizer_config 文件 | 重写 tokenizer 示例并用save_pretrained存储 |
| 中文被切得过碎 | BPE 语料太少、min_frequency 过高 | 观察训练语料 token 分布 | 增大语料规模、降低 min_frequency |
| 模型输出全是 UNK | 词表未包含生僻字符 | 检查 tokenizer 的 special token 表 | 把缺的字符合并进去并重训练 embedding |
| 微调显存 OOM | 序列太长、batch 太大 | nvidia-smi 查看占用 | 降低 batch size 或 max_length,启用 LoRA |
| 指令微调后模型“答非所问” | labels 没有对输入部分 mask | 检查 labels 长度是否等于 input_ids | 对用户 prompt 部分设 -100 |
| 批量 tokenize 后数据尺寸异常 | 自定义字段丢失 | 检查 remove_columns 参数 | 保留需要的原始字段 |
| 推理服务启动后调用超时 | 模型加载后未完成预热 | 打印日志确认启动完成 | 增加健康检查接口或等待就绪再发请求 |
| 输出长度总是被截断 | max_tokens设置过小或 tokenizer 右侧截断 | 检查生成结果末尾是否缺失 | 调大 max_tokens,设置更长上下文 |
| 多个 worker 分词内存爆炸 | num_proc设置过高 | 看系统内存占用 | 调低 num_proc,比如 4 或 6,分批处理 |
10. 从数据清洗到模型训练的最佳实践
10.1 数据管理要分目录
NLP 项目最常见的灾难是某个目录里堆了几百个语料文件,但根本没有版本管理。建议至少分成四个目录:
data/ raw/ 原始收集文件 clean/ 去 HTML、去重后的干净语料 tokenized/ 已经 tokenize 完毕的二进制数据 logs/ 统计报告和失败样本原始数据不要轻易覆盖,清洗过程要生成一个新的 clean 文件。
10.2 先小参数跑通再全量执行
第一次训练 tokenizer 时,不要直接设置 5 万词表跑 100G 语料。先用 10 万行文本、3000 词表验证代码逻辑,确认输出 token 质量后,再逐步扩大语料和词表。同理,post training 建议先用 1B 以内的小模型配合 1000 条 SFT 数据测试,等找到合适的学习率和训练步骤再放大资源。
10.3 训练前确认特殊 token 顺序
训练 tokenizer 时,special_tokens的排序会影响它们被分配到的 ID。在微调和推理时,如果模板里使用的 token 和词表里的 ID 不对应,就会出现回复结尾没有自动停止、模型不会生成 user 等问题。建议训练后打印一遍特殊 token 和对应 id,保存成special_tokens_map.json。
10.4 版权、隐私与合规
这里再次强调:NLP 语料数据很可能包含个人信息、受版权保护文章和未公开对话。爬虫抓取的公共数据并非都能自由用于训练。尤其涉及声音、人脸、聊天记录、评论区和私有文档时,必须明确是否获得了数据主体的授权。
建议在每一个数据集目录旁边添加 README,标注数据来源、授权状态、清洗时间、用途。训练完成后,如果对外发布模型权重,还要说明基座模型的许可限制。不同的开源模型对商用、衍生物发布和 API 服务的要求不同,不能因为是开源就默认没有限制。
11. 总结与下一步
这篇内容没有停留在“tokenizer 是分词工具”这种表面解释,而是把文本切分、数据清洗、词表构造、批量 tokenize 和 post training 串成了一条完整链路。对刚开始做中文 NLP 训练的人来说,最有价值的一步是先动手跑一次 BPE 训练,用几百条真实语料观察 token 输出,再进入语料清洗和指令微调环节。完成这一遍,你对“文本如何变成 id、模型如何理解词边界”会有完全不同的感知。
最容易踩的坑仍然集中在三个位置:清洗阶段过度删除数据和信息主体导致语言风格失真;BPE 词表设置过小造成中文语义切分过碎;指令微调阶段把 padding token 和 label mask 处理错造成 loss 数值看着正常但模型输出一塌糊涂。
如果这篇文章能帮你节省半天排错时间,建议收藏备用。下一篇可以继续沿着这个方向,展开讲增量预训练如何做,如何把 1 万条领域语料变成可用的继续训练集,以及 post training 阶段对 tokenizer 做适配判断时的具体调参方法。