先说那个把我半夜叫起来的告警
时间记不清了,大概是去年十一月的一个周三。凌晨两点多,企业微信响个不停,做的是本地生活平台的商户评论意图分类,五分类:口味、服务、环境、价格、配送。监控面板上,负向情绪召回率三天内从0.81掉到0.69,但离线评测报告是绿的,甚至比上一版高。
我第一反应是数据漂移,捞了500条线上badcase看,发现大量「不戳」「绝绝子」「一整个无语住」被分到了正向,还有些新出现的奶茶品牌名被整体切碎当成噪声。模型没换,训练数据没换,唯一动过的是上线前那次版本升级——推理服务的tokenizer从自研的词表换成了开源的BPE词表,理由是「统一技术栈」。
那次之后我养成了一个习惯:看到效果掉点,先别打开模型代码,先打开分词结果。这个顺序救过我至少三次。
我前后经手过7个中文NLP落地项目,电商评论、客服工单、楼盘问答、招投标文件抽取都做过。最大的那个数据集是43.7万条商户评论,带emoji的占31%,纯表情的2.8万条,人工标注是用5个人交叉标的。下面这些数字都是从这些项目的实验记录里翻出来的,不一定通用,但至少是真跑过的。
同一批数据,换特征比换模型的收益大得多
我先在那43.7万条上做了组对照,训练集38万,测试集5.7万,全部按时间切分,不用随机切分——随机切分在评论场景里会虚高3到5个点,因为同一个用户的评论会被分到两边。
| 方案 | 特征/模型 | macro-F1 | 单条延迟(4核CPU, batch=1) |
|---|---|---|---|
| A | jieba精确模式 + TF-IDF(1-2gram) + LinearSVC | 0.743 | 0.9ms |
| B | char_wb(1-4gram) + LinearSVC | 0.789 | 1.2ms |
| C | jieba + FastText (dim=100, epoch=25) | 0.771 | 0.6ms |
| D | BERT-base-chinese 微调 (lr=2e-5, bs=32, 3 epoch) | 0.841 | 38ms |
| E | Qwen2-1.5B LoRA (r=8, alpha=16, lr=1e-4) | 0.868 | 210ms |
A到B,模型一个字没改,就是把jieba换成字粒度char n-gram,macro-F1涨了4.6个点,比C换FastText的收益大得多。原因是评论区里错别字、谐音、缩写太多,jieba的词典切不准,「不戳」被切成「不/戳」,「绝绝子」被切成「绝绝/子」,词级别的TF-IDF根本抓不到这个组合。
D到E的2.7个点,代价是延迟涨了5.5倍。上线的时候我们算过账,QPS峰值1200,用E要12张卡,用D只要3张,这2.7个点值不值,得业务说了算。但没人讨论B到D这5.2个点里,有多少是「语义理解」带来的,有多少只是「特征粒度终于对了」。
tokenizer吃的亏,比模型结构大
中文和英文在tokenizer这件事上完全不是一回事。英文一个单词基本对应一两个token,中文不一样,一个汉字在BPE词表里可能单独成token,也可能和邻居合并,取决于词表怎么训的。
我拿一篇1000字左右的《人民日报》评论做过去测,同一段文本,不同tokenizer切出来的长度:
- GPT-2的tokenizer:1672个token,平均1.67 token/字
- Llama-2的:2031个token,平均超过2
- BERT-base-chinese:约1040个,基本一字一token
- Qwen2的:约780个,常见的双字词能合成一个token
这意味着什么?一个标称4096上下文的中文模型,如果tokenizer是GPT-2那套,实际只能塞进去大概2400个汉字。你按「4096字」去做长文本切分,一定会爆。我做工单摘要的时候踩过这个坑,切了3900字的文档进去,报错说超长,查了半天才反应过来是token不是字。
更隐蔽的问题是词表覆盖率。Llama-2词表32000,中文词占比不到1%,很多中文字符会被拆成2到4个byte级别的token。这不只是慢的问题,还影响模型对中文词的边界感知。反过来,Qwen那套151936的词表,中文占比高,同一段文本token数只有GPT-2的不到一半,长文本场景下等于上下文翻倍。
所以选中文模型的时候,我会先做一件事:拿自己业务里最有代表性的2000字文本,跑一遍候选模型的tokenizer,看token/字比。这个数字比参数量的参考价值大多了。
你的F1天花板,可能被标注一致性锁死了
再讲一个更扎心的事。
做客服工单分类的时候,我们5个标注员交叉标了500条,算下来Cohen's kappa只有0.61。什么意思?就是「口味」和「服务」这两类,标注员自己都经常吵,有人觉得「上菜慢」是服务问题,有人觉得是口味体验的一部分。
这种情况下模型的macro-F1跑到0.84,其实已经在人类天花板附近了,因为剩下16%的错,一半以上是人类自己都不一致的样本。你再换更大的模型,只是在拟合标注员的随机噪声。
我们的做法是先把标签定义改了一版,把「上菜慢」明确归到服务,把「菜品凉了」归到口味,重新标了3000条,kappa提到0.78,然后同样用方案D重训,F1从0.841涨到0.873。模型一行代码没改。
怎么快速判断自己是不是撞到天花板?从测试集里抽300条,你自己不看模型预测,只按标签定义标一遍,然后和原始标注比。如果一致率低于85%,先别碰模型,先改标注规范。
我现在的排查顺序
写下来给同样卡住的同行参考,不一定对,但顺序是这么个顺序:
- 抽100到200条线上badcase,人肉看分词结果,统计有多少条是切分导致的。如果超过20%,别犹豫,先换粒度。
- 拿2000字业务文本测候选tokenizer的token/字比,中文场景低于0.8才算合格,1.5以上的慎重。
- 抽300条自己重标一遍,算和原标注的一致率,低于85%先改规范。
- 都用时间切分而不是随机切分做评测,评论、工单这种有用户维度的数据尤其要注意。
- 最后才轮到调模型、换模型。
这套流程听起来很笨,但比我一开始那种「效果不好就换BERT、再不好就换大模型」的打法省了太多时间。
有个事我一直没想明白:现在大家都在说大模型把中文分词问题「解决」了,但我觉得只是把它从预处理阶段挪进了tokenizer里,问题还在,只是换了个地方藏。什么时候能把tokenizer和标注一致性这两件事拿出来正经讨论,中文NLP的落地才会少踩一半坑。