一、从“看懂文字”到“理解语义”——NLP 在 Android 上的落地

说实话,很多做 Android 的同学一听到“自然语言处理”,第一反应就是“那是后端和算法团队的事”。我以前也这么想。直到有一次,我需要在 App 里做一个实时评论审核功能——用户发一条评论,本地就要判断它是不是违规,不能等网络请求回来。嗯,那时候我才意识到,移动端 NLP 不是“能不能做”的问题,而是“怎么做才又快又准”。

这一章,我们就来实战一个典型的 NLP 任务:文本分类。我会用 BERT Tiny 这个轻量级模型,带你走通 Tokenization、序列处理、模型推理的完整链路。你想想看,一个能离线判断“好评/差评”的模型,跑在你的手机上,是不是挺酷的?

核心知识点:
  • BERT Tiny 模型结构 & 为什么选它
  • Tokenization:从文字到数字 ID
  • 序列处理:Padding、Truncation、Attention Mask
  • Android NNAPI 推理实战
文本分类 (BERT Tiny) ① Tokenization WordPiece 分词 [CLS] [SEP] 特殊标记 ② 序列处理 Padding / Truncation Attention Mask ③ NNAPI 推理 加载模型 → 设置输入 执行推理 → 解析输出 分类结果 (0/1)

二、为什么选 BERT Tiny?

标准的 BERT Base 有 1.1 亿参数,在手机上跑一次推理可能要好几秒,而且内存占用感人。我曾在项目里试过直接部署 BERT Base,结果 App 启动时加载模型就 OOM 了……后来换成了 BERT Tiny,参数量只有 440 万,推理速度提升了 10 倍以上,准确率只下降了不到 3 个百分点。说白了,在移动端,速度就是体验

模型 参数量 推理时间 (Pixel 4) 准确率 (IMDb)
BERT Base 110M ~3200ms 93.5%
BERT Tiny 4.4M ~280ms 90.8%
DistilBERT 66M ~1800ms 92.1%
我的建议:如果你的任务对延迟要求不高(比如后台批量处理),可以考虑 DistilBERT。但如果是用户交互场景,比如实时输入预测,BERT Tiny 是更稳妥的选择。

三、Tokenization:把文字变成模型能懂的“数字”

模型不认识“这部电影真好看”,它只认识数字。Tokenization 就是做这个翻译工作的。BERT 用的是 WordPiece 分词,它会先把句子拆成单词或子词,然后映射到词表中的 ID。

举个例子:

输入: "这部电影真好看"
分词后: ["这", "部", "电影", "真", "好", "看"]
加上特殊标记: ["[CLS]", "这", "部", "电影", "真", "好", "看", "[SEP]"]
Token IDs: [101, 6821, 4692, 3125, 2535, 1963, 3341, 102]

这里 [CLS] 是分类标记,[SEP] 是句子分隔符。BERT 会用 [CLS] 对应的输出向量来做分类。我在项目中遇到过一个问题:中文分词时,有些生僻字不在词表里。WordPiece 会把它拆成更小的子词,比如“魑”可能被拆成“##魑”或者 UNK(未知标记)。所以,一定要检查你的词表覆盖度

避坑指南:我曾经因为词表里没有“哔哩哔哩”这个词,导致模型把“哔”识别为 UNK,分类结果直接跑偏。后来我手动扩充了词表,才解决问题。如果你做的是垂直领域(比如游戏、医学),建议先统计一下领域高频词。

四、序列处理:让所有输入“整齐划一”

模型要求输入是固定长度的张量。但句子有长有短,怎么办?两个操作:Padding(填充)Truncation(截断)

假设我们设定最大序列长度为 128:

  • 句子长度 < 128:在末尾补 0(Padding),直到长度达到 128
  • 句子长度 > 128:从末尾截断,只保留前 128 个 token

但这里有个坑:模型怎么知道哪些是真实 token,哪些是填充的? 这就需要 Attention Mask 了。它是一个和输入等长的 0/1 数组,真实 token 的位置为 1,填充位置为 0。模型在计算注意力时,会忽略 mask 为 0 的位置。

// 假设原始 token IDs: [101, 6821, 4692, 102]
// 最大长度: 8
// 经过 Padding 后:
input_ids:     [101, 6821, 4692, 102, 0, 0, 0, 0]
attention_mask: [1,   1,    1,    1,  0, 0, 0, 0]
关键点:Attention Mask 不是可选项,是必选项。如果不加,模型会把填充的 0 也当成有效输入,推理结果会完全错误。我见过有人踩过这个坑,调试了一整天才发现是 mask 没传对。

五、Android NNAPI 推理实战

好了,前面都是准备工作。现在我们来写代码。假设你已经把 BERT Tiny 模型转换成了 .tflite 格式(用 TensorFlow Lite Converter),并且导入了 NNAPI 的依赖。

5.1 加载模型并创建解释器

// 加载模型文件
ByteBuffer modelBuffer = loadModelFile(context, "bert_tiny.tflite");
Interpreter interpreter = new Interpreter(modelBuffer);

// 启用 NNAPI 加速
Interpreter.Options options = new Interpreter.Options();
options.setUseNNAPI(true);  // 关键:告诉解释器使用硬件加速
Interpreter nnapiInterpreter = new Interpreter(modelBuffer, options);

5.2 准备输入数据

// 假设我们已经完成了 Tokenization 和 Padding
// inputIds: int[1][128]   —  token IDs
// attentionMask: int[1][128] —  attention mask

int[][] inputIds = new int[1][128];
int[][] attentionMask = new int[1][128];

// 填充数据(这里省略 Tokenization 细节)
// ... 

// 创建输入张量
TensorBuffer inputFeature0 = TensorBuffer.createFixedSize(
    new int[]{1, 128}, DataType.FLOAT32);
inputFeature0.loadArray(inputIds);

TensorBuffer inputFeature1 = TensorBuffer.createFixedSize(
    new int[]{1, 128}, DataType.FLOAT32);
inputFeature1.loadArray(attentionMask);

5.3 执行推理并解析结果

// 输出张量:二分类,shape 为 [1, 2]
float[][] output = new float[1][2];
Object[] inputs = {inputIds, attentionMask};
Map outputs = new HashMap<>();
outputs.put(0, output);

// 执行推理
nnapiInterpreter.runForMultipleInputsOutputs(inputs, outputs);

// 解析结果
float positiveProb = output[0][1];  // 正面概率
float negativeProb = output[0][0];  // 负面概率

if (positiveProb > negativeProb) {
    // 分类为正面
    Log.d("NLP", "好评,置信度: " + positiveProb);
} else {
    // 分类为负面
    Log.d("NLP", "差评,置信度: " + negativeProb);
}
性能提示:NNAPI 在第一次推理时会有初始化开销(约 100-200ms),后续推理会快很多。如果你的 App 需要频繁推理,建议在后台线程预加载模型并做一次“热身推理”。

六、完整流程总结

来,我们捋一遍整个流程:

  1. 输入文本 → 比如“这部电影太棒了”
  2. Tokenization → 拆成 token,加上 [CLS] 和 [SEP]
  3. 序列处理 → Padding 到固定长度,生成 Attention Mask
  4. NNAPI 推理 → 加载模型,传入 input_ids 和 attention_mask
  5. 输出解析 → 取 softmax 后的概率,判断分类

整个过程,从用户输入到拿到分类结果,在 Pixel 4 上实测大约 280ms。如果你用 NNAPI 的 GPU 加速,还能再快 30% 左右。嗯,这个速度已经可以满足大多数实时场景了。

最后说一句:NLP 在移动端的潜力远不止文本分类。情感分析、意图识别、关键词提取……这些都可以用同样的思路落地。关键是先把 Tokenization 和序列处理的基础打牢,后面的路就好走了。

公众号:蓝海资料掘金营,微信deep3321