大模型领域词表扩充与Token效率优化落地

日期:2026-08-10

一、项目背景

这是一家做工程机械零部件的制造企业,在内网跑了一套私有化的知识问答,给一线技术支持和售后工程师用。模型是开源基座做过一轮领域微调的版本,部署在新普的 AI 私有化部署底座上,推理层走 vLLM,向量库用的 Milvus。

上线三个月,用户反馈里排第一的抱怨是"问长一点就答不全"。我们查下来不是模型能力问题,是上下文塞不下。

原因挺具体。他们的物料编码长这样:GJ-3F27-08A-N2,这一串在分词器里能切成十几个 token。技术文档里满篇都是这种编码,加上一堆行业缩写(液压系统的、传动系统的、故障代码),一段两百字的故障描述,token 数能到七百多。检索出来的三段文档拼进上下文,加上系统提示词和历史对话,很容易就顶到窗口上限,只能截断,一截断答案就残缺。

顺带的问题是成本。虽然是私有化部署不按 token 付费,但显存和吞吐是实打实的,同样并发下 KV 缓存占用高出一大截,单卡能扛的并发数上不去。

所以这个事的目标很明确:让同样的中文和编码,用更少的 token 表示。

二、落地场景

整件事拆成四步走。

领域语料统计与候选词挖掘。我们把企业内部能拿到的语料都收了一遍:技术手册、故障工单历史、售后对话记录、零部件主数据,去重后大概两亿字。在这批语料上跑统计,找出高频但被现有分词器切得很碎的字符串。判断标准不是单纯看词频,是看"词频乘以切分代价",也就是这个词出现多少次、每次多花几个 token,两者相乘排序,收益最大的排前面。

词表增量扩充。挑出来的候选做人工过滤,把明显是脏数据的、只在某一份文档里出现的删掉,最终定了大约六千个新 token,主要是三类:物料编码的固定前缀段、行业专有名词和缩写、常用的长中文短语(比如"液压油缸内泄"这种在故障描述里反复出现的)。

embedding 初始化与继续预训练。新增 token 的向量不能随机初始化,那样模型等于要重新学。我们用原分词器把新词切开,取子词 embedding 的加权平均作为初始值,权重按子词的位置和频率调。之后在领域语料上做了一轮轻量的继续预训练,让模型适应新的切分方式。

推理端兼容与回归。词表一改,模型文件、分词器配置、以及所有依赖旧分词结果的下游模块都要跟着换。这块最容易出事,后面细说。

三、关键技术挑战与解决思路

新增 token 的冷启动。 这是最核心的技术点。直接随机初始化的话,模型看到新 token 就是看到噪声,前期效果会明显掉。我们试了三种初始化方式:随机、子词均值、子词加权均值。做了个小规模对照实验,在同一批领域测试集上,随机初始化在继续预训练的前两千步 loss 明显高出一截,收敛也慢;子词均值好很多;加权均值(按子词 IDF 加权,让信息量大的子词占更大比重)又比简单均值好一点点,但差距不算大。最后用了加权均值,主要是实现成本也没高多少。

词表膨胀带来的显存开销。 增加六千个 token,输出层的参数量跟着涨,embedding 矩阵和 lm_head 都要扩。粗算下来隐层维度四千多的模型,多出来的参数是几千万级别,显存增加百分之一点几,可以接受。我们本来想加两万个词,算完账砍到六千,取的是收益曲线开始变平的那个点。这个取舍我建议每个项目都自己算一遍,别照抄别人的数字。

分词一致性与老模型兼容。 这里踩了一个坑,而且是上线后才发现的。向量库里的文档是用旧分词器编码的 embedding 生成的,模型换了词表之后,新查询的语义空间和老索引其实有细微偏移,检索召回质量掉了几个点,一开始我们完全没往这儿想,还在查检索参数。后来意识到必须全量重建索引。两亿字的语料重新向量化,跑了一个通宵。教训是:动词表这件事,影响半径远超模型本身,凡是消费模型输出或者依赖同一分词器的组件,都要列进变更清单。

效果回归。 词表改了不能只看 token 压缩比,得确认模型没变笨。我们准备了一套三百多条的领域问答测试集,加上一套通用能力的抽样题(防止领域适配把通用能力打坏),每轮继续预训练后都跑一次。中间有一版通用题的分数掉了四个点,回查发现是继续预训练的领域语料配比过高,混了两成通用语料进去之后就恢复了。

案例片段(已脱敏):候选词收益排序(节选)

rank  token候选          freq       old_tok  new_tok  saved_tok    note 1     "GJ-"             1,842,301   4        1        5,526,903    物料前缀 2     "液压油缸"          412,880    6        1        2,064,400    高频名词 3     "内泄"             388,204    4        1        1,164,612    故障描述 4     "-08A-"           301,455     6        1        1,507,275    规格段 ... 6001  "回油滤芯堵塞"       9,120      9        1        72,960      收益临界 截断阈值:saved_tok < 70,000 不纳入

案例片段(已脱敏):同一条故障描述扩表前后的编码对比

``` 原文:GJ-3F27-08A-N2 液压油缸内泄,压力保持不住,回油滤芯堵塞报警 E-2207

扩表前:  token 数 = 78  ['G','J','-','3','F','2','7','-','0','8','A','-','N','2',' ','液','压','油',   '缸','内','泄',...]

扩表后:  token 数 = 19  ['GJ-','3F27','-08A-','N2',' ','液压油缸','内泄',',','压力','保持不住',',',   '回油滤芯','堵塞','报警',' ','E-','2207']

压缩比 4.1x(该样本),全量测试集平均 2.6x ```

四、效果数据

整个改造从语料统计到全量上线跑了六周,效果如下。

平均 token 压缩比达到二点六倍,这是在两千条真实历史问答上测的加权平均。纯编码密集的文本能到四倍以上,普通对话文本大概一点三倍,混起来是二点六。

上下文实际容纳量随之上去了。原来一次检索最多塞三段文档还经常截断,现在稳定能塞七到八段,检索召回的 top-k 从三提到八之后,答案完整度的人工评分从三点四提到四点二(五分制,三十人抽样评了两百条)。用户那句"问长一点就答不全"的抱怨基本没了。

推理侧的收益是连带的。同样的并发压力下,KV 缓存占用降了约百分之五十八,单卡并发从十六路提到二十九路,P95 首字延迟从一点九秒降到一点一秒。原本规划要再采购两台推理服务器,这轮之后暂时不买了,这笔账客户算得比我们还清楚。

效果回归方面,领域测试集的准确率从百分之七十六点四微升到百分之七十八点九,通用抽样题从百分之八十一点二降到百分之八十点五,在可接受的波动范围内。文中数字均为脱敏后的复盘口径。

五、可复用经验总结

词表扩充这件事,收益的大头来自那些"高频且被切得特别碎"的字符串,而这类字符串在制造、医药、金融这些有编码体系的行业里到处都是。判断值不值得做,最快的方法是拿一千条真实业务文本跑一遍分词,看看编码类内容占了多少 token。如果超过三成,这事基本就有得做。

收益曲线一定要自己画。我们最初拍脑袋要加两万词,画完曲线发现六千之后新增的每个词平均只省几万个 token,而显存和训练成本是线性涨的。找到拐点比追求覆盖率重要。

最大的风险不在模型,在周边。向量索引要重建,缓存要失效,日志里存的 token 统计口径变了,计费如果按 token 算的话历史对比会失真。我们这次就是因为漏了索引重建,白白折腾了两天去查检索参数。动分词器之前,先把依赖清单列全,这一步花半天,能省两天。

继续预训练的语料配比要留通用的口子。全领域语料喂进去,模型在专业问题上确实更准,但一问点常识性的东西就开始胡说。混两成通用语料的成本很低,保险作用挺大。

这套东西目前还有一个没打磨好的地方:新增词表之后,模型偶尔会在不该用编码的地方硬生成一个编码格式的 token,看着像幻觉。频率不高,大概千分之几,我们在解码端加了格式校验兜底,但根子上还没解决,后面打算在继续预训练里加点负样本试试。