做RAG应用最容易被低估的问题,往往不是检索器,也不是生成模型,而是入口处的判断——用户的问题到底该走哪条路。我在这类项目里反复被问到:为什么不直接在Prompt里让大模型判断一下意图?为什么非要单独训一个模型?这篇文章就围绕我在实际项目里做的一个“前置路由”组件展开——用微调后的意图分类模型接管了客服问答场景里的入口判断,最终把整体答非所问率降了近一半。整个过程从数据准备写到模型评估和工程接入,适合对RAG已经有基础、但还没认真做过意图路由的读者,也适合准备把路由作为独立模块接入现有系统的团队参考。
1. 为什么要在RAG前面加一道“前置路由”:不这么做会吃哪些亏
1.1 没有路由的RAG,会变成“有问必答”的复读机
先还原一个真实场景。客服知识库接入了RAG之后,用户问“你们发货用哪家快递”,系统检索知识库、拼上下文、生成回答,效果不错。但紧接着用户来一句“今天心情不好”,系统同样走了一次检索——知识库里当然没有关于心情的文档,于是它从最相近的“售后安抚话术”里捞了一段,生成了“非常理解您的心情,请问您遇到了什么问题”之类的回应。看着没毛病,实际等于把闲聊问题硬塞进了业务管道,消耗了两次大模型调用,还污染了会话状态。
更麻烦的是那些“需要查订单但用户没说订单号”的情况。没有路由时,系统把问题直接送进RAG,检索器满库找“订单号”,找到一堆无关条款,生成模型再把这些内容揉进回答里。用户听了一堆“请您提供订单号以便核实”的变体。你会发现,检索越努力,答案越离谱——因为问题本身就不适合走RAG这条路。
没有前置路由还有一个隐性成本:你很难统计用户到底在问什么。所有请求都混在同一个管道里,你不知道有多少比例是闲聊、多少比例是售后、多少比例是报价咨询。等到想优化知识库时,翻日志全是一锅粥。加了路由之后,每一类请求有了明确归属,后面无论是优化检索、调整话术还是做运营分析,都有数据支撑。
1.2 三种常见做法:规则、Prompt判断、微调模型
既然路由是必须的,接下来就是怎么实现的问题。我最先看到的是三种方案,各有各的适用场景。
第一种是纯规则匹配,用关键词列表把问题分到几个预设桶里。这个方案在小规模演示场景很够用:“运费”开头的进物流意图,“退款”开头进售后意图。问题是自然语言变体太多,同一个“怎么退货”可以有几十种说法。规则表会越写越长,最后变成一个没人敢动的怪物。更麻烦的是规则之间会冲突,用户一句“退款怎么还没到账”同时命中了“退款”和“到账”两类规则,你都不知道该让哪条规则赢。
第二种是让RAG链路里的大模型直接判断意图,在Prompt里加一句“请先判断用户意图,再决定是否检索”。这个方案实现起来几乎零成本,但有两个绕不开的问题。一是延迟和成本:多一次大模型调用,而且这调用还发生在每次用户请求的最前面,等于把所有流量都先过了一遍大模型。二是稳定性:大模型在Prompt里判断意图经常受到上下文干扰,用户问“你这个东西怎么用”,它有时候判断成“产品咨询”,有时候判断成“售后问题”,同一句话在不同会话里的路由结果可能不一样。
第三种就是这篇文章真正要讲的:微调一个小模型,让它专门做意图分类。它的模型体积比RAG链路里的大模型小一两个数量级,推理一次成本可以忽略,延迟在几十毫秒这个量级。而且它是针对你自己的业务数据微调出来的,不会像通用大模型那样“见多识广”反而在细分类目上摇摆。这个方案在工程上多了一步训练和部署,但换来的收益是稳定、便宜、可控。
1.3 前置路由到底是什么:它决定的不只是“要不要检索”
把“前置路由”叫路由,容易让人觉得它就是个开关。实际操作下来,我建议把它理解成一个独立的意图分类服务,它的输出不是简单的是/否,而是一个结构化的路由结果。
举个例子。一个客服问答RAG系统,前置路由的输出可能是这样:
{ "intent": "after_sales", "confidence": 0.92, "action": "retrieve", "query_type": "order_status", "slots": { "need_order_id": true } }这个结果里至少有四层信息。intent决定了后续走哪条业务链路,action告诉RAG这段请求要不要触发检索,query_type可以用来选择检索策略(比如查订单知识就走订单库,查产品信息就走产品手册库),slots则给下游提供了一个提示,比如“这个请求缺订单号”,那么生成答案之前可以先追问一句而不是盲目检索。
所以我更愿意把它描述为一个“决策前置”的组件,分类只是它的第一步,它真正影响的是RAG系统对每个请求的处理策略。这个认知非常重要,因为它决定了你后面做数据、做训练、做评估时,是以“分类准确率”为标准,还是以“整条RAG链路的效果”为标准。我的答案是:两者都要看,但端到端效果更重要。
2. 手搓训练数据:意图分类的成败有八成在这里
2.1 意图体系该怎么设计:不是越细越好,3到6个是黄金区间
我见过很多团队在第一步就翻车:意图分类做得像一棵决策树,一级意图十来个,二级意图几十个,每个意图下面还分场景。训练一个分类模型之前先问自己一句话:下游RAG系统真的需要区分这么多分支吗?如果两个意图在后续处理策略上完全一样,只是说法不同,那它们就应该合并。
以客服问答为例,一个合理的意图体系大概是这样的:
| 意图 | 含义 | 下游动作 |
|---|---|---|
| product_info | 产品参数、功能、使用方法咨询 | 检索产品知识库,返回结构化产品说明 |
| after_sales | 退换货、维修、退款进度 | 检索售后政策库,同时提示用户补充订单信息 |
| logistics | 物流、发货、配送时间 | 检索物流知识库,按单号状态查询 |
| order_status | 订单状态、支付状态查询 | 触发订单系统查询,不依赖向量检索 |
| chitchat | 闲聊、情绪表达、无关话题 | 不触发RAG,走固定话术或转人工 |
| out_of_scope | 业务范围之外的请求,比如投诉、合作、招聘 | 转人工或返回兜底提示 |
这套体系只有6个意图,但已经覆盖了绝大多数客服场景。分类粒度到了这个程度,下游每个分支都有明确的处理策略,再细的意图反而是给标注和训练添乱。设计意图体系的核心原则是:意图的粒度等于下游处理策略的粒度,下游怎么分叉,意图就怎么分类,多一分都浪费。
2.2 正样本、难负样本和OOS样本:训练数据的三个零件
意图分类的训练数据不是随便收集一堆问题就完事,它由三部分构成,缺一块模型都会“瘸腿”。
第一部分是正样本:每个意图各几百条真实提问。这部分主要来源于线上日志,我强烈建议优先挖历史客服对话记录,哪怕只有几百条原始记录,也比网上找的公开数据强十倍。真实问题长什么样?用户不会说“我要查询产品信息”,而是说“你们这个能不能支持无线充电”或者“带不带的动4K显示器”。这种自然表达里的口语、省略、错别字,是公开数据集训练不出来的。
第二部分是难负样本。所谓难负样本,是那种“看起来像A意图但其实是B意图”的句子。比如“这个退了之后还能买吗”——前半句像售后,后半句是新品咨询,实际意图是product_info。再比如“运费多少钱”,这可能是logistics,也可能是product_info(如果用户在问某件商品的运费政策)。难负样本的价值在于逼着模型去抓真正的判别特征,而不是靠几个关键词猜。
第三部分是OOS样本,即out-of-scope,也就是那些不属于任何一个预设意图的输入。“你们公司加班多吗”“怎么投简历”“你这个客服是不是机器人”,这类问题多到超乎想象。OOS样本的目的不是教会模型认识业务,而是教会模型“这里没有你要的东西,别硬答”。模型如果没见过OOS样本,上线后会把一切输入强行归到6个意图里,准确率反而很难看。
这三类样本的比例我推荐正样本:难负样本:OOS样本大致按6:3:3来配。不要迷信绝对数量,更关键的是每一类意图的正样本分布要尽量均匀,别让某个意图占了全部数据的一半。
2.3 数据扩写与清洗:用LLM生成变体,但必须人工抽检
真实数据永远不够,尤其是冷启动阶段。这时候用一个大模型来做数据扩写是性价比很高的做法——注意,这里说的是扩写,不是凭空生成。我会从已有的真实样本里挑一批作为种子,让大模型生成“不同的人会怎么表达同一个意思”的变体,比如:
- 把“你们发什么快递”扩写成“常用哪家物流”“发货走什么渠道”“一般用什么快递送”
- 把“怎么申请退款”扩写成“退货流程是什么”“我想退掉怎么操作”“申请售后的入口在哪里”
扩写提示词里我会明确要求:保持意图不变,但改变说法、替换口语词、增减语气词。生成的候选数据要人工抽检,抽检比例至少20%,我遇到过扩写模型把“怎么退款”扩写成了“退款到不了账”,这就从售后意图悄悄变成了投诉,属典型的标签污染。
清洗环节有几个容易忽略的坑。第一个是去重,尤其用LLM扩写出来的数据,表面句子不同但实际结构高度雷同,模型学到的是模板而不是语义,得用文本相似度做一遍粗筛。第二个是长度过滤,超过100个字的“问题”在真实场景里大多是用户在描述背景,直接截断或丢弃都可以,不要让模型在长尾句子上耗费学习能力。第三个是标签核对,建议至少两个人各自抽一遍,对不一致的样本重新讨论,这个环节虽然费时,但能避免整个训练集带上系统性偏差。
3. 基座模型与微调方案选型:为什么我不选大参数量模型
3.1 意图分类是判别任务,不是生成任务
很多第一次做这个事的朋友上来就问:要不要用最新的大模型微调?我的答案通常是:如果你只需要类别标签,就用一个小而专的判别式模型,或者一个小参数量的生成式模型加LoRA,不要用几十B的大模型。
原因是意图分类本质上是一个判别任务:给定一句话,输出它属于哪个类别。判别任务不需要背下全世界的知识,它需要的是抓住这句话里的“区分性特征”。一个几十B的大模型在微调时当然能做得更好,但它的优势主要体现在语义理解很复杂、类别边界很模糊的场景。如果你的意图分类只需要区分七八个业务类目,一个轻量模型就完全够用,而且推理快、部署省、成本低。
我试过的方案里有两条路都走得通。一条是传统的BERT类模型做分类头微调,另一条是用小的生成式模型加LoRA做序列分类。前者训练快、推理极快,模式非常稳定,适合意图类别固定、结构简单的场景;后者对复杂语义的容忍度更高,即使输入里混着繁体、英文、错别字也不容易崩,适合意图边界模糊、输入噪声大的场景。两条路各有拥趸,我自己的经验是:先从BERT类模型起步,跑通之后再考虑要不要换生成式模型,千万别一上来就上重武器。
3.2 轻量模型选哪个:给一个对比参考
这一类模型的选择其实没有标准答案,不同团队环境和中文语料的适配度也不同。我列一个横向对比,给没有头绪的读者一个起点:
| 方案 | 训练成本 | 推理延迟 | 中文鲁棒性 | 适用场景 |
|---|---|---|---|---|
| BERT类中文底座(约1亿参数) | 低,单卡几十分钟 | 低,10ms级 | 中,对口语变体偏弱 | 类别稳定、输入规范 |
| 轻量生成式底座(约1-3B参数)+LoRA | 中,单卡数小时 | 中低,几十ms级 | 高,对口语/错别字容忍度高 | 类别模糊、输入噪声大 |
| 大参数底座(10B以上)+LoRA | 高,多卡或大显存 | 高,百ms级 | 高,但成本收益低 | 极少使用,除非类别极端复杂 |
关键是要理解,参数量的提升带来的收益不是线性的,你先要明确自己的瓶颈是语义理解还是标注质量。我做过一个对比实验:同一套训练数据,BERT类模型和轻量生成式模型的准确率都在90%左右,差距不超过2个点。那2个点的差距到底值不值额外的推理延迟?我的结论是不值。在意图分类这个任务里,大部分错误来自标注噪声和OOS样本覆盖不足,而不是模型容量不够。
3.3 LoRA不是唯一方案,但它是最省事的路径
微调一个轻量模型,实际上有三种路子:全量微调、LoRA、蒸馏。全量微调在数据量小的情况下很容易过拟合,而且每次调整都要保存一份完整权重,工程上很笨重。蒸馏则需要一个更强大的教师模型做先导,成本前置,不适合快速迭代。LoRA是这批方案里最平衡的:训练时只更新低秩适配矩阵,显存占用小,训练速度快;推理时可以把LoRA权重合并进底座模型,不影响线上延迟;换业务场景的时候只需要训练不同的LoRA分支,底座模型可以复用。
有人担心LoRA会损失效果,我实测下来的结论是:在数据量不超过几万条的意图分类场景里,LoRA和全量微调的差距非常小,甚至没有明显差距。它最让我舒服的一点是模型文件管理,一次业务微调产出一个几十MB的适配器文件,换业务就等于换文件,回滚也只需要切回旧的适配器,不用重训整个模型。所以后面的实操部分我会默认用LoRA这个方案来讲,如果你确实有资源做全量微调,思路完全一样,只是训练脚本里少一步冻结参数而已。
4. 从数据到权重:LoRA微调完整实操过程
4.1 准备环境:一本账算清楚需要什么
微调一套意图分类模型需要的资源比想象中低很多。我用BERT类模型做例子,一张消费级显卡就能完成训练;如果用轻量生成式模型加LoRA,一张24G显存级别的显卡也足够应付十几万条数据的训练。下面是要装的核心依赖:
transformers>=4.30.0 datasets>=2.12.0 peft>=0.5.0 accelerate>=0.22.0 torch>=2.0.0这里要说明一下:peft负责LoRA注入,datasets负责数据加载和预处理,accelerate配合单机多卡或混合精度训练。整个环境用一个Python虚拟环境装就行,不要让这些依赖和线上服务混在同一套环境里,线上环境保持干净,训练环境随便折腾。
4.2 数据格式与转换:从JSONL到模型输入
训练数据标准格式是一份JSONL文件,每一行一个样本,字段就两个:text和label。label可以直接用意图字符串,也可以用ID,我建议在原始文件里保留字符串,可读性好,后面做人工检查方便。
{"text": "你们这个能支持无线充电吗", "label": "product_info"} {"text": "退款什么时候才能到账", "label": "after_sales"} {"text": "发的什么快递", "label": "logistics"} {"text": "你们招人吗", "label": "out_of_scope"}训练之前要把这份JSONL读进内存,转成模型能处理的输入格式。BERT类模型的关键步骤是tokenizer分词、padding、截断。截断长度这里有个小讲究:意图分类的问题句子普遍不超过64个字,我直接设成64,太长的问题本来就少见,强行保留反而会让模型学到某些无关的长尾特征。生成式模型的准备方式略有不同,它的输入格式是你是一个意图分类助手,请判断下面问题的意图:{text},标签是意图名称,这属于"IE"式构造,但原理一样,都是把文本映射到标签空间。
4.3 超参数与训练脚本:抄作业级别的参数组合
训练脚本的核心逻辑分四步:加载底座模型、注入LoRA配置、定义训练参数、启动训练。我直接给一段核心代码,按你的底座模型调整路径和类别数就能跑:
from transformers import AutoTokenizer, AutoModelForSequenceClassification from peft import LoraConfig, get_peft_model from transformers import TrainingArguments, Trainer from datasets import load_dataset model_path = "your-base-model-path" num_labels = 6 tokenizer = AutoTokenizer.from_pretrained(model_path) model = AutoModelForSequenceClassification.from_pretrained( model_path, num_labels=num_labels ) lora_config = LoraConfig( r=8, lora_alpha=16, target_modules=["query", "value"], lora_dropout=0.05, bias="none", task_type="SEQ_CLS" ) model = get_peft_model(model, lora_config) training_args = TrainingArguments( output_dir="./intent_router", per_device_train_batch_size=32, learning_rate=2e-4, num_train_epochs=5, logging_steps=50, save_steps=200, evaluation_strategy="epoch", fp16=True, remove_unused_columns=False, ) trainer = Trainer( model=model, args=training_args, train_dataset=train_dataset, eval_dataset=eval_dataset, tokenizer=tokenizer, ) trainer.train() model.save_pretrained("./intent_router_lora") tokenizer.save_pretrained("./intent_router_lora")几个超参数我展开说一下。learning_rate=2e-4在LoRA微调里是个安全的起点,比全量微调常用的5e-5多了近一个数量级,原因在于LoRA训练的参数很少,学习率太小收敛太慢。r=8是常见的低秩维度,lora_alpha=16是缩放因子,两者保持2:1的比例关系通常表现稳定。num_train_epochs=5针对几千条的意图分类数据基本够用,如果你的数据量超过五万条,可以适当减到3轮。
训练过程中主要盯两个东西:训练loss曲线的下降斜率,以及eval集合的准确率是否不再上升。如果loss在首个epoch之后就急剧下降但eval准确率反而退化,说明学习率偏大,可以回退到1e-4再试。如果loss下降平缓但eval准确率不错,那就按当前配置跑完,不必强求loss追求到极小——注意,意图分类的loss降到非常低,往往意味着模型开始死记训练集了。
4.4 训练完成之后:合并权重与快速验证
训练结束后得到一个LoRA适配器目录,里面通常是adapter_config.json和adapter_model.bin。上线之前有两个动作要做。
第一个动作是把LoRA权重合并回底座模型。不合并也能用,但需要在加载时额外显式调用PeftModel.from_pretrained,多一层依赖,而且线上框架不一定支持。合并的好处是产出一个标准的、自包含的模型目录,部署时当成普通模型加载就行。合并代码就一行:
from peft import PeftModel model = PeftModel.from_pretrained(base_model, "./intent_router_lora") merged_model = model.merge_and_unload() merged_model.save_pretrained("./intent_router_merged")第二个动作是拿一批线上真实问题做冒烟测试,这个测试集必须是训练时从未见过的。我习惯准备20条当前线上日志里出现频率最高的问法,加上10条明显不该命中任何意图的闲聊,看模型的输出分布是否合理。这一步能发现的最经典问题是所有样本都涌向同一个意图,那就是数据分布出了大问题,回去看训练集,八成是那个意图的样本量过多或者OOS样本过少,需要重新平衡数据再做一轮。
5. 不是只看准确率:意图分类模型的线上评估方法
5.1 离线评估:分意图看指标,别被总准确率骗了
很多团队评估意图分类模型只看一个“总准确率”,我强烈建议把这个指标拆开看。总准确率很容易被“高频意图”带节奏:假设6个意图里product_info占了50%的样本,模型把其他意图全分错,只要有product_info分对,总准确率也有50%,看了等于没看。
正确的姿势是分意图计算精确率(Precision)、召回率(Recall)和F1,用macro平均而不是micro平均。我放一个小小的评估样例:
| 意图 | Precision | Recall | F1 |
|---|---|---|---|
| product_info | 0.94 | 0.91 | 0.92 |
| after_sales | 0.89 | 0.85 | 0.87 |
| logistics | 0.90 | 0.93 | 0.91 |
| order_status | 0.87 | 0.82 | 0.84 |
| chitchat | 0.78 | 0.80 | 0.79 |
| out_of_scope | 0.75 | 0.69 | 0.72 |
看这个表格,总准确率可能是85%,看起来很光鲜,但out_of_scope的召回率只有69%,意味着约三分之一的闲聊和无关问题没有被拦下,会被错误地送进RAG。这类问题往往比某个业务意图分错影响更大。所以我每次评估的时候,除了算平均指标,还会单独给out_of_scope设一个最低阈值:召回率低于80%就不允许上线。
除此之外,要专门检查“混淆矩阵”里对角线之外的密集区。如果product_info经常被分进after_sales,说明这两个意图的边界本身定义不清晰,或者标注人员自己都分不清,那就不是模型的问题,是意图体系或者标注规范的问题。这种情况要回到数据层面去修正,不要指望模型自己顿悟。
5.2 端到端评估:路由模型最终的KPI要看整条RAG链路
离线指标只是中间关卡。模型放在RAG前面,最终要看的是尼玛整条链路的业务效果。我在项目里主要用三个指标评估路由改造前后的差异:答非所问率、首轮解决率、检索调用量。
答非所问率是核心指标。统计方式是给一批用户问题打标注,用规则判断回答里是否包含了用户要的信息。RAG链路加了前置路由后,明显受益的场景有两个:一是闲聊类请求不再被硬塞进检索管道,系统返回固定话术,答非所问率直接降;二是订单状态类请求被路由到订单查询分支,不再走容易在知识库里“近义替换”的向量检索,答非所问率也降。
首轮解决率关注的是用户在第一轮交互里有没有拿到想要的答案。路由对它的影响比较微妙:路由决策正确,把不同问题分到对应知识域,首轮解决率会上升;路由决策错误,把用户问题分到一个不相干的分支,首轮解决率会下降。这个指标是路由效果的真实投影,因为它是纯粹的端到端结果。
检索调用量这个指标可能被忽视。没有路由前,每个请求都触发检索;有路由之后,只有真的需要检索的请求才会去查向量库。这一个指标直接转换成延迟和成本,如果chitchat和out_of_scope类请求的占比超过20%,整个链路的检索调用量会下降20%以上,省下来的就是真金白银。
5.3 一个始终要提醒自己的原则:路由的保守偏差比激进偏差更安全
意图分类模型不可避免地会出错,关键是你希望它往哪个方向错。我在实验里对比过两种错误模式:
- 激进路由:模型倾向于把模糊问题分为某个业务意图,结果是闲聊被当成业务问题处理,RAG强行回答,答非所问率高。
- 保守路由:模型倾向于把模糊问题分为chitchat或out_of_scope,结果是部分真实业务问题被转人工或兜底回答,用户多一次点击,但不会得到完全错误的信息。
从业务体验看,保守路由的代价远小于激进路由。一个真实业务问题被拦到人工,用户会损失一点便利,但客服接手后问题能解决;一个闲聊问题被塞进RAG,用户收获的是一段莫名其妙的回答,信任损失更严重。
要让模型偏向保守,两个常用手段:一是在数据层面,把chitchat和out_of_scope类样本的多样性做足,让模型见过更多“边界地带”的表达;二是在推理层面,给每个意图设置信度阈值,低于阈值的统一落到“低置信度”分支,不走RAG,走通用话术或转人工。后者实施起来更简单,可以快速止损。
6. 把模型接进RAG服务:工程改造与降级策略
6.1 部署方式:单独开一个微服务比什么都省心
意图分类模型体积小,推理快,部署方式有很多种。简单做法是把它和RAG主服务放在同一个进程里,加载模型进内存,函数内直接调用。问题在于耦合:模型加载占用显存,RAG主服务可能本身就部署在大模型集群里,一旦模型更新或出问题,整个RAG服务的发布和回滚都被牵制。
我更偏向的做法是单独开一个“意图路由微服务”,模型只在这个服务里加载,对外暴露一个HTTP接口,RAG主服务通过内部网络调用它。这样有几个好处:路由服务可以独立扩缩容,请求量大了单独给它加副本;模型更新不影响RAG主服务发布;路由服务挂了可以通过降级开关跳过,不会拖垮整个链路。这套架构牺牲了一点点服务间调用的延迟,但在稳定性和运维便捷性上赢回来太多。
接口设计上,我建议不要只返回一个意图标签,而是像前面提到的,返回一个结构化的路由决策。接口大致长这样:
POST /v1/route Body: {"text": "你们发什么快递"} Response: {"intent": "logistics", "confidence": 0.91, "action": "retrieve"}字段不需要太多,但confidence绝对不能省。这个值在降级策略和运营分析里都要用,它是“模型知道自己在说什么”的程度,比单纯一个标签信息量大得多。
6.2 超时、缓存与降级:路由服务挂了不能让RAG跟着挂
路由服务虽然是轻量模型,但任何服务都可能出问题。我见过几个系统在这里踩坑,路由调用超时设得特别短,5毫秒没回就直接报错,RAG主服务没做任何降级,硬是把这个错误暴露给了用户。工程上一定要记住一个原则:路由服务是RAG链路的增强组件,不是RAG链路的依赖项。
降级策略可以这样设计:当路由请求超时或返回非200状态码,RAG主服务直接走默认策略——按普通RAG流程处理。因为绝大多数业务请求本来就需要检索,默认走RAG的损失很小,只是少了分类优化而已。这比“用户请求直接报错”强十倍,也符合前面说的保守原则。
缓存也是提升路由效率的关键手段。真实用户问题高度重复,同一个“怎么退款”能在一个月里出现几百次。在路由服务前面加一层按文本hash的本地缓存,命中率通常在20%以上。对缓存命中率影响最大的是计算hash前统一对文本做归一化:去掉首尾空格、全角转半角、繁体转简体,这些简单处理能让缓存命中率再高一截。
6.3 监控与数据回流:路由上线只是开始
路由服务上线之后,要建立一套针对它的监控面板。核心指标有四个:路由请求量、各意图占比、平均延迟、低置信度比例。其中各意图占比特别值得关注,它能直接反映用户需求的变化趋势。如果out_of_scope占比在某天突然上升,很可能是有异常流量打进来了;如果某个业务意图占比骤降,可能是知识库或产品层面有调整,也可能是模型出现了偏差。
更重要的数据回流机制:每天把路由服务判错的样本抽出来,人工复核后进入下一轮训练集。这个闭环是整个系统持续变好的核心。我在项目里设了一个每天自动任务,从线上日志中随机抽取三类样本——低置信度样本、被人工客服改判的样本、以及RAG链路A/B测试反馈为差评的样本——定时汇入训练数据仓库。两周做一次增量微调,每次只需要跑几个epoch,路由准确率就能肉眼可见地稳步提升。
7. 踩过的坑与我的建议
7.1 公开数据训练的模型上线后水土不服
我最早一版训练数据主要来自开源意图数据集,当时图省事,觉得大规模、多领域,模型泛化能力肯定强。结果上线不到一周就被用户教育了:公开数据集里“怎么退货”是标准书面语,真实用户写的是“退不了货怎么办”“七天无理由咋退”,模型对这类口语变体的识别率低得吓人。后来我花了一个下午手动从客服历史会话里挖了三百条真实问法加进训练集,效果立刻回升。
这个教训让我后来定了一个规矩:训练数据必须以真实线上日志为底座,公开数据集只能做冷启动的补充。原因是公开数据集覆盖的是“通用表达”,而你的业务有自己特有的词汇、缩写和常见错别字,这些是公开数据永远覆盖不到的。
7.2 OOS样本不够,闲聊全被塞进业务意图
有一版训练数据里out_of_scope样本只占总量的5%,直觉是“闲聊不多,少放一点没关系”。结果上线后chitchat和out_of_scope的召回率双双崩到60%以下。原因很好理解,模型没见过足够多的“其他东西”,它会倾向于把所有输入都强行塞进它见过的几个业务桶里。
修复方式不是简单加几条闲聊样本,而是把OOS样本的多样性做足。我从公开的多轮闲聊数据集里抽了一批,又从真实日志里抽了一批“看起来像业务但实际不是”的样本,把这部分数据的比例拉到总体的25%左右,模型对无关输入的拒识能力才明显改善。OOS样本是路由模型最容易忽略、但对线上体验影响最大的部分,分配训练数据时请务必给足占比。
7.3 对prompt模板和截断长度过于敏感
换成生成式模型做意图分类后,我也踩过一个坑。同一个样本,我把prompt模板从“判断下面句子的意图”改成“请对下面这句话进行分类”,模型输出的类别居然完全变了。这让我意识到,小模型的输出对prompt模板的敏感度比大模型高得多,模板里的标点、语气、甚至空格都可能影响结果。
对策是把prompt模板当作超参数的一部分来对待:先固定一版模板,做完一轮数据验证再考虑要不要微调模板措辞,不要频繁改动。截断长度同理,训练时用128字截断,上线推理时也一定要用128字,我曾经因为训练和推理截断长度不一致导致分类结果在线上出现诡异的偏移,排查了好久才意识到是这个参数造成的。
7.4 意图体系不是一锤子买卖
最后一个有点反直觉的经验:意图体系本身也要迭代。我最初的意图清单是产品、售后、物流、其他四个,上线后发现“查订单”类请求量很大,而且这类请求完全不需要检索知识库,才拆出了order_status这个独立意图。后来“投诉”类请求比重上升,又把它从out_of_scope里单独剥离了出来。
但迭代要克制,每次新增意图之前都先问:这个意图在下游有没有对应策略?如果没有,宁可继续让它在其他意图里混着。意图体系是一个工程组件,不是学术分类学,它服务于系统对用户请求的差异化处理。这套思路在后续几次微调迭代里被反复验证,也让我在项目复盘时确认了一件事:前置路由最难的部分从来不是训练脚本,而是持续的数据维护和对业务意图边界的理解。