Hugging Face中的Tokenizer:原理与Java使用方法
1. 总览
处理Hugging Face的NLP模型时,第一步始终是分词 —— 将原始文本转换成模型理解的数字序列。本文解释分词器的工作原理、常见的分词策略(BPE、WordPiece、SentencePiece),以及如何直接从Java通过DJL库调用HuggingFaceTokenizer而不需要运行Python。读完后,你将能够在Spring Boot中构建TokenizerService来进行encode/decode文本、控制填充/截断,并编写相应的测试。
2. 什么是Tokenizer,为什么重要
Tokenizer有两个作用:将句子分解成token(可能是词、子词或字符),然后将每个token映射到模型vocabulary中的一个ID整数。模型永远不会“看到”文字 —— 它只看到这个ID序列。
例如,对于句子"Order service xử lý thanh toán",使用bert-base-uncased的分词器可能输出:
tokens: ["order", "service", "xu", "##ly", "thanh", "toan"]
ids: [2344, 2326, 1045, 2135, 7899, 8821]
符号##ly表示这个token与前面的token相连(子词)。这就是为什么token数通常多于词数 —— 计算API调用成本或设置模型的max_length限制时这很重要。
注意: 每个预训练模型都配备自己的分词器,在与模型一起在相同的vocabulary上训练。使用错误的分词器(例如为BERT的模型使用GPT分词器)会产生无意义的ID,导致模型预测完全错误,而不会报告任何错误。
3. 常见的分词策略
3.1. BPE(Byte-Pair Encoding)
用于GPT-2、RoBERTa。从字符开始,重复将出现最频繁的字符对合并成新token,直到达到所需的vocabulary大小。
3.2. WordPiece
用于BERT。与BPE相似,但根据统计可能性而非原始频率选择要合并的字符对。子词用##标记,表示它连接到前面的词。
3.3. SentencePiece
用于T5、XLNet、ALBERT。将空格视为普通字符(用▁符号表示),因此对于词边界不明确的语言(如日语、中文)处理更好。
你无需自己实现这些算法 —— Hugging Face已将其打包到每个模型附带的tokenizer.json文件中,你只需加载并调用即可。
4. 添加依赖
要在Java中直接使用分词器(不通过Python服务器),添加DJL Hugging Face Tokenizers库:
<dependency>
<groupId>ai.djl.huggingface</groupId>
<artifactId>tokenizers</artifactId>
<version>0.28.0</version>
</dependency>
该库直接读取标准的Hugging Face tokenizer.json文件,无需通过JNI运行复杂的JVM。
5. 基础文本编码
import ai.djl.huggingface.tokenizers.HuggingFaceTokenizer;
import ai.djl.huggingface.tokenizers.Encoding;
import java.util.Arrays;
public class TokenizerDemo {
public static void main(String[] args) throws Exception {
HuggingFaceTokenizer tokenizer = HuggingFaceTokenizer.newInstance("bert-base-uncased");
Encoding encoding = tokenizer.encode("Order service handles payment");
System.out.println(Arrays.toString(encoding.getIds()));
System.out.println(Arrays.toString(encoding.getTokens()));
}
}
HuggingFaceTokenizer.newInstance(...): 按模型名称从Hugging Face Hub(或本地缓存)加载分词器。encode(...): 返回包含ids、tokens、attentionMask的Encoding对象。- 第一次运行会将
tokenizer.json文件下载到~/.djl.ai/cache,之后使用缓存,不需要网络。
参考输出:
[101, 2344, 2326, 16024, 7909, 102]
[[CLS], order, service, handles, payment, [SEP]]
注意两个特殊token [CLS](句子开始)和[SEP](句子结束)被自动插入 —— 这是BERT的约定,不是错误。
6. 反向解码
long[] ids = encoding.getIds();
String decoded = tokenizer.decode(ids);
System.out.println(decoded);
[CLS] order service handles payment [SEP]
decode在你需要调试时很有用:精确查看模型在分词后“看到”了什么,或处理生成式模型的文本输出时。
7. 填充、截断和批处理
将多个句子一起发送到模型时,这些句子必须长度相同(填充)且不能超过模型的限制(截断)。
import java.util.Map;
HuggingFaceTokenizer tokenizer = HuggingFaceTokenizer.newInstance(
"bert-base-uncased",
Map.of(
"padding", "true",
"truncation", "true",
"maxLength", "16"
));
Encoding[] batch = tokenizer.batchEncode(new String[] {
"Order placed successfully",
"Payment failed due to insufficient balance"
});
for (Encoding e : batch) {
System.out.println(Arrays.toString(e.getIds()));
System.out.println(Arrays.toString(e.getAttentionMask()));
}
padding=true:较短的句子被添加[PAD]token(ID 0),使其与batch中最长句子长度相同。truncation=true+maxLength=16:超过16个token的句子会被截断。attentionMask:0/1序列,标记哪些token是有效的(1)还是填充(0)—— 模型使用此掩码在计算attention时忽略填充部分。
[101, 2344, 2872, 4652, 102, 0, 0, 0]
[1, 1, 1, 1, 1, 0, 0, 0]
注意: 如果在传入模型时忘记设置
attentionMask,填充部分可能被计入attention,导致输出错误,但不会报告任何runtime错误。
8. 集成到Spring Boot
将分词器封装成bean供全局使用,避免每次请求时重新加载(从Hub/缓存加载分词器需要100–300ms,不应在request线程中执行)。
import ai.djl.huggingface.tokenizers.HuggingFaceTokenizer;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.Map;
@Service
public class TokenizerService {
private final HuggingFaceTokenizer tokenizer;
public TokenizerService() throws IOException {
this.tokenizer = HuggingFaceTokenizer.newInstance(
"bert-base-uncased",
Map.of("padding", "true", "truncation", "true", "maxLength", "128"));
}
public long[] encode(String text) {
return tokenizer.encode(text).getIds();
}
public String decode(long[] ids) {
return tokenizer.decode(ids);
}
public int countTokens(String text) {
return tokenizer.encode(text).getTokens().length;
}
}
- 构造器在bean创建时仅初始化一次分词器,Spring管理singleton的生命周期。
countTokens:在调用模型前检查输入限制时很有用(例如Order描述不能超过512个token)。
在其他service中使用:
@RestController
@RequestMapping("/tokens")
public class TokenController {
private final TokenizerService tokenizerService;
public TokenController(TokenizerService tokenizerService) {
this.tokenizerService = tokenizerService;
}
@GetMapping("/count")
public int count(@RequestParam String text) {
return tokenizerService.countTokens(text);
}
}
调用GET /tokens/count?text=Order service xử lý thanh toán会返回BERT分词器为该句子生成的token数。
9. 编写测试
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class TokenizerServiceTest {
@Test
void shouldEncodeAndDecodeRoundTrip() throws Exception {
TokenizerService service = new TokenizerService();
String original = "payment failed";
long[] ids = service.encode(original);
String decoded = service.decode(ids);
assertThat(ids).isNotEmpty();
assertThat(decoded).contains("payment").contains("failed");
}
@Test
void shouldCountMoreTokensThanWordsForSubwordSplit() throws Exception {
TokenizerService service = new TokenizerService();
int tokenCount = service.countTokens("unbelievable circumstances");
// "unbelievable" 通常被拆分成多个子词
assertThat(tokenCount).isGreaterThan(2);
}
}
- 测试1验证encode → decode往返过程保留原始内容(由于
uncased分词器可能丢失大小写/标点)。 - 测试2确认子词行为:长词或罕见词会被分成多个token,比用空格分割的词数多。
10. 常见错误和何时不应使用
- 在不同模型间混淆分词器:
bert-base-uncased的分词器与gpt2不兼容。始终加载与用于推理的模型同名的分词器。 - 填充时不计算attention mask:导致模型结果错误但不抛出异常,非常难debug。始终与
inputIds一起将attentionMask传给模型。 - 每次请求加载分词器:浪费不必要的时间和I/O。始终声明像上面的
TokenizerService这样的singleton bean。 - 生产环境无网络:首次加载分词器需要互联网从Hugging Face Hub拉取文件。对于离线环境,需要事先下载
tokenizer.json并通过HuggingFaceTokenizer.newInstance(Path.of(...))从本地路径加载。 - 为非Hugging Face模型计数token(例如通过OpenAI API的GPT-4):GPT的BPE分词器与标准Hugging Face分词器不同。这种情况应使用供应商提供的专用token计数库,而不是
HuggingFaceTokenizer。 - 带重音符号的越南文本:某些主要在英文上训练的分词器可能以不理想的方式拆分越南文(拆分过多陌生的子词)。如果主要处理越南文,应选择为越南文微调的模型/分词器(例如
vinai/phobert-base)而不是原始BERT。
11. 总结
分词器是确定模型如何“看待”文本的基础步骤 —— 理解BPE/WordPiece/SentencePiece的机制有助于避免难以调试的错误,如vocabulary mismatch或错误的attention mask。使用DJL库,你可以直接在Java中encode/decode,无需建立单独的Python服务器,可以作为通常的bean集成到Spring Boot中。记住:始终为模型使用正确的分词器,将分词器缓存为singleton,在将数据传入模型前仔细检查填充/截断。