我让客服意图识别模型靠50条标注+LoRA转起来,准确率从78%卷到91%——中小团队的数据飞轮实操手记

30秒速览

  • 别一次标几千条数据,先搭起日志采集和主动学习选样的管道,每轮只标50条让模型快速进化;让GPT-4写合成数据没问题,但必须用去重和长度检查狠狠洗一遍,真人再抽检;用LoRA微调成本极低,每周都能上线新模型,业务方还以为你悄悄招了个标注团队。

先把飞轮转起来,别想着一步到位——我就是在屎山上打补丁打怕了

去年公司新上一个客服机器人的项目,要求能自动识别用户意图,把“我要退货”、“怎么退款”之类的问题分到对应的业务线。一开始我们信心很足,花了一周时间标了2000条真实对话,用bert-base-chinese训了个多分类模型,内部测试准确率86%,觉得差不多能用了。结果上线第一周就被业务方狂喷——线上准确率只有78%,一堆退款申请被分到“物流查询”,客服组每天要多处理三四百条错分消息。

我拉出线上badcase一看,傻眼了。用户问“我那个退的钱咋还没到账”,我们的模型没见过这种口语化长句,直接分到了“账户问题”。后台一统计,训练集中“退款到账”这种意图只有8条样本,“修改订单地址”只有5条,全是长尾。但线上天天有人问。我一开始想,再标5000条数据不就行了?但找标注团队报价,5毛钱一条,2500块钱是小事,关键是两周后才能交付,这期间线上模型继续犯错。而且我敢肯定,等标完这批数据,用户又问出什么“我下单时选了自提能不能改送货上门”的新花样,模型还是抓瞎。

那段日子我被业务群@得头皮发麻,突然意识到,靠一次性标注海量数据去覆盖所有意图,是个无底洞。我需要的是一个能持续自我改进的机制:模型上线后,把说不准的消息捞出来,人工只标最难的那一点点,再结合自动生成的变体数据喂回去,快速迭代。这就是数据飞轮。我把飞轮画在会议室白板上:线上服务写日志 → 从日志里用主动学习挑出模型最不确定的样本 → 标注组每周只标50条 → 同时用大模型按意图模板生成一批合成数据做补充 → LoRA微调模型 → 重新部署上线。转完一圈,下一周再转,准确率就会像滚雪球一样上升。

这里最核心的设计决策是每轮只标50条。为什么是50?说实话是拍脑袋定的,但后来实践证明了它的合理性。第一,标注成本压到几乎可以忽略,标注员每周花不到一小时就干完,业务部门不觉得烦。第二,我用peft库做LoRA微调,50条配合200条合成数据足够让模型学到新东西,又不容易过拟合。第三,这个量刚好能逼着我们去精挑细选最有信息量的样本,而不是随便喂一堆容易的。如果你一上来就说每轮标500条,人就会偷懒去标那些模型已经分对的,飞轮就转成死轮。

技术栈我选的全是开源和便宜的组合:基座模型用huggingface上的bert-base-chinese,标注平台我们穷,就直接用飞书多维表格加一个简单的前端页面让客服同学勾选意图。合成数据调用GPT-4 API,虽然花钱,但每次生成200条也就两毛钱左右。微调用peft+transformers,部署就是Flask加个Docker,推到我司的K8s集群上和旧模型做ABtest。整个飞轮一旦搭好,跑起来几乎全自动,每周我只需要花半小时检查脚本日志,再手动触发一次微调。下面我详细说每个环节我踩过的坑。

这里给一段启动飞轮前需要准备的日志收集代码,我是直接从线上服务的日志文件里读出JSON,筛选出模型预测概率低于阈值的那批消息。别小看这个,如果飞轮第一步数据采不上来,后面全白搭。

import json
import numpy as np
from transformers import pipeline, AutoTokenizer, AutoModelForSequenceClassification

model_name = "bert-base-chinese"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=10)
classifier = pipeline("text-classification", model=model, tokenizer=tokenizer, return_all_scores=True)

def read_logs_and_filter(log_file, threshold=0.6):
    with open(log_file, 'r', encoding='utf-8') as f:
        logs = [json.loads(line) for line in f]
    uncertain = []
    for log in logs:
        text = log['user_input']
        scores = classifier(text)[0]
        max_prob = max(s['score'] for s in scores)
        if max_prob < threshold:
            uncertain.append({'text': text, 'max_prob': max_prob})
    return uncertain

上面代码里的阈值0.6是后来调出来的经验值,太低会漏掉有价值样本,太高又会混进一堆垃圾。你可能会问,那模型一开始不准,预测概率能信吗?确实不能全信,但飞轮第一圈的目的就是先用这个粗糙的筛选把最头痛的样本挑出来让人工修正,第二圈开始模型变强了,筛选也会更准,所以这是个良性循环。

主动学习选样本:我照着教材写了个不确定性采样——结果翻车了

飞轮的第二环是最让开发者上头的地方:怎么从一堆模型没信心的消息里,挑出真正值得标注的那50条。按照主动学习的标准教案,应该是计算每条样本的不确定性,然后选不确定性最高的。不确定性通常用熵来衡量,熵越大说明模型对各类别预测概率越平均,也就是模型最不知道该分哪一类。我照着公式写了个脚本,把线上日志里所有prob小于0.6的样本计算熵值,倒序取前50条,还洋洋得意觉得这下稳了。结果第一周标注员就跟我抱怨:你选的这都是啥?一半是用户乱打的无意义文字,比如“呃呃呃客服在吗”,或者直接是语音识别转文本转错的乱码,根本没法标意图。

我才反应过来,模型对垃圾输入当然也没信心,但那些样本对提升模型毫无价值,标注了反而是噪音。于是我在计算熵之前加了两层过滤:第一层,长度过滤,text长度小于6个字的直接丢弃,因为有效的意图句子一般不会太短;第二层,设置一个黑名单正则,把纯数字、纯标点、只包含“在吗”“你好”这类泛泛问候的句子排除。这过滤完,剩下的“不确定样本”才真正是有歧义的、长尾的、或者口语化严重的表达,比如“我买的东西到了发现盒子压烂了但是东西没坏我到底要不要申请换货”,这种复杂表述正是模型急需学习的。

接下来我又在选取策略上做了一点微调。单纯按熵排序会倾向于选出最最不确定的那些样本,但往往这一类样本彼此相似度极高,比如用户换着花样问“退款多久到账”,你选了20条其实都是同一种语义变体,标注一条就能增益其他,全标了浪费人力。我借鉴了密度权重采样,在top100候选里用bert的sentence embedding计算余弦相似度,尽量使选出的50条样本彼此不相似。但是后来发现这个步骤虽然理论上很优雅,工程实现却要额外加载一个sentence-transformers模型,推理太慢了,而且我们标注量少,影响有限,我就直接简化成:按熵倒序取top200,然后随机等间隔采样50条,简单粗暴,实际效果也不差。

下面是我最终落地的那版主动学习采样代码,去掉了复杂的密度部分,保留过滤和熵计算。你可以看到 compute_entropy 函数的细节,特别注意加了一个极小值防止log(0)。这个函数从候选集中挑出熵最高的若干条,但前提是这些候选已经做了输入清洗。

def compute_entropy(probs):
    return -np.sum(probs * np.log(probs + 1e-10))

def select_uncertain_samples(candidates, topk=50):
    scored = []
    for item in candidates:
        text = item['text']
        scores = classifier(text)[0]
        probs = np.array([s['score'] for s in scores])
        entropy = compute_entropy(probs)
        scored.append((text, entropy))
    scored.sort(key=lambda x: x[1], reverse=True)
    # 随机等间隔抽取topk,增加多样性
    step = max(1, len(scored) // topk)
    selected = [scored[i][0] for i in range(0, len(scored), step)][:topk]
    return selected

这里有一点要坦白,就是主动学习选出来的样本,标注员在标的时候经常也会遇到困难。比如“我想把上周那个订单和今天这个一起退了”这种一句话包含两个意图,怎么标?我后来要求标注员标出多个意图标签,然后在训练时把这样的样本拆成两条分别作为正例,这反而成了提升模型多标签能力的一个意外收获。所以说,主动学习不仅帮模型,还倒逼你完善标注规范。

整体上我花了两个迭代才把选择策略磨合到能持续产出高价值样本的状态。现在每周跑一圈,标注的50条几乎条条都是模型短板,飞轮转得飞快。最直观的感受是,原来78%的准确率在第一轮加了50条长尾样本后就蹦到82%,后面几轮慢慢爬到91%,而人力总投入不到200条标注,这比最初想的一次性标5000条高明太多了。

合成数据生成:我让GPT-4写了200条,手动洗了一天,发现自己写可能更快

主动学习解决了真实数据的质量问题,但50条样本毕竟太少,尤其是那些长尾意图,光靠人工标注可能两周都覆盖不全。我需要用合成数据来快速扩充每个意图的表达变体。思路是这样:对于每个意图,我们已经有一些种子句子(可能是初始训练集里的,也可能就是上一轮标出来的新句子),我把这些种子扔给GPT-4,让它生成几十条意思相同但说法不同的句子。听起来简单,做起来差点没把我手洗断。

第一版脚本很简单,就是构造一条prompt:“你是客服对话专家,请为意图‘退货申请’生成10个不同但同义的句子,例如‘我要退货’”,然后调用API拿回结果。第一次跑完后,200条数据里有一半是“我要退货”“我要退货退款”“申请退货”这种基本没差别的变体,剩下的一半里有几条居然是“退货需要什么流程”这种完全不同意图的句子——GPT-4把意图都给改了。我意识到我太小看这个任务了,prompt必须写得非常限制性。后来我把prompt设计成要求输出JSON数组,并且明确列出风格指令:“请生成多样化的句子:包含口语化、省略主语、反问句、含错别字(模拟真实用户)、长句式等不同风格,确保每条语义与原意图一致,不引入额外意图。” 温度设到0.8增加随机性。这一改确实生出了“我那个退的货咋还不给我退钱”“你们退款的按钮在哪我找半天”这样的鲜活表达。

但是生成的质量仍然参差不齐,我需要自动过滤。我写了个质量检查脚本,核心用fuzzywuzzy计算生成的句子和已有数据的相似度,如果和任何一条已有句子相似度超过80%就丢弃,避免重复。然后检查长度,小于5个字或大于80个字也丢掉,因为太短没信息,太长一般是GPT乱编。最后检查是否包含明显的不相关词,比如生成了一句“我要退款和顺便买个新手机”,这已经混了两个意图,也丢弃。这么一轮筛选下来,200条生成数据能剩下120条左右。接着我还得人工抽检这120条,快速扫一遍,把那些读起来拗口或者语义模糊的勾掉。最终能用到训练里的合格合成数据大概100条。

下面这个生成脚本片段是我现在稳定跑在飞轮里的。注意prompt里我会把种子句子以JSON数组的形式放进去,让模型理解样例格式,这样返回的JSON解析成功率比较高。

import openai, json
from fuzzywuzzy import fuzz

openai.api_key = "your-key"

def generate_variants(intent, seeds, num=10):
    prompt = f"""你是客服对话生成器。意图:{intent}。
请生成{num}个同义但表达多样的句子,覆盖口语、反问、错别字、长句等风格。
严格保持意图不变,不要引入新意图。直接输出一个JSON数组,不要解释。
种子示例:{json.dumps(seeds, ensure_ascii=False)}"""
    resp = openai.ChatCompletion.create(
        model="gpt-4", messages=[{"role":"user","content":prompt}],
        temperature=0.8
    )
    try:
        return json.loads(resp['choices'][0]['message']['content'])
    except:
        return []

def filter_generated(generated, existing, sim_th=80):
    clean = []
    for s in generated:
        if len(s)  80: continue
        if any(fuzz.ratio(s, e) > sim_th for e in existing): continue
        if any(fuzz.ratio(s, c) > sim_th for c in clean): continue
        clean.append(s)
    return clean

合成数据这个环节最花时间不是写代码,是人工质检。我记得有一次让GPT-4生成“物流投诉”意图的变体,生成了“你们快递员是乌龟吗,爬也爬到了”,猛一看挺像真实用户,但这句话其实包含了辱骂,如果直接拿去训练,模型可能对其他正常表达也学出负面倾向。所以我的质检标准里多加了一条敏感词过滤,简单用个词表把脏话滤掉。虽然粗暴,但能避免大翻车。

这里我特别想强调一点:合成数据绝对不能替代真实标注数据。它只是帮你在真实样本太少的时候撑开表达空间,相当于给模型看更多的同义改写,防止它死记住种子句子的字面。经过实验,我发现如果把合成数据的比例提到80%以上,模型在线上对一些常见意图的准确率反而会降,因为它过度拟合了GPT的语言风格,对真实用户的错别字和乱序句反而变生疏了。所以我每一轮都严格控制在真实:合成≈1:2,也就是50条真实配100条合成,这个比例是我试出来的平衡点,你可以根据任务自己调。

LoRA微调上线:每次只花5分钟,我的老板以为我雇了一个标注团队

每轮攒齐了50条新标注的真实样本和100条合格的合成数据,就到了我最喜欢的环节:用LoRA给基座模型打补丁,然后立即上线看效果。之所以迷恋LoRA,是因为它太适合这种小而快的迭代了。全量微调一个bert模型得花十几分钟,显存吃紧,而LoRA只需要在attention的q/v矩阵上挂两个低秩矩阵,训练参数缩减到原来的0.5%不到,一张T4显卡3分钟就能跑完3个epoch,而且保存的adapter只有几MB,部署时和基础模型一合并,轻飘飘上线。

具体的做法是:加载bert-base-chinese的分类头模型,用peft的LoraConfig配置r=8、lora_alpha=32,target_modules指定[“query”,”value”]。然后把标注好的数据转成Dataset,map上tokenizer,开始Trainer训练。这里我踩的唯一一个坑是,刚开始忘了设置模型为train模式,直接导致adapter参数没被更新。peft的get_peft_model会自动处理,但要确保传入的模型正确。还有一个细节,由于每轮数据很少,我关闭了evaluation,用固定epoch数3轮,经验表明不会过拟合。如果数据更少,你甚至可以只跑2个epoch。

微调完我习惯先在上一轮留出的几十条测试样本上验证一下准确率,确认没有退化再上线。上线流程就是重新构建Docker镜像,把adapter和基模一起打包,推到镜像仓库,然后更新K8s Deployment。我们线上跑了两套服务,新模型和老模型各50%流量,通过AB测试对比意图识别准确率。飞轮第一圈转完,新模型就把线上准确率从78%提到了82%,三圈之后稳定在90%以上。因为每周都更新,业务侧甚至以为我有个专门的标注团队在背后狂标数据,其实每周就一个人干一两小时。

下面是我每次运行的那段LoRA微调核心代码,删减了一些细节,但保留关键流程。假设labeled_data是一个列表,每条是{‘text’: ‘用户句子’, ‘label’: 0},其中label是数字ID。

from transformers import AutoTokenizer, AutoModelForSequenceClassification, Trainer, TrainingArguments
from peft import LoraConfig, get_peft_model, TaskType
import torch
from datasets import Dataset

model_name = "bert-base-chinese"
model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=10)
tokenizer = AutoTokenizer.from_pretrained(model_name)

lora_config = LoraConfig(
    task_type=TaskType.SEQ_CLS, r=8, lora_alpha=32, lora_dropout=0.1,
    target_modules=["query", "value"]
)
model = get_peft_model(model, lora_config)

def tokenize(example):
    return tokenizer(example["text"], truncation=True, padding="max_length", max_length=64)

dataset = Dataset.from_list(labeled_data).map(tokenize)
dataset = dataset.rename_column("label", "labels")
dataset.set_format("torch", columns=["input_ids", "attention_mask", "labels"])

training_args = TrainingArguments(
    output_dir="./lora_out", num_train_epochs=3, per_device_train_batch_size=8,
    logging_steps=5, save_total_limit=1, report_to="none"
)
trainer = Trainer(model=model, args=training_args, train_dataset=dataset)
trainer.train()
model.save_pretrained("./lora_adapter")
tokenizer.save_pretrained("./lora_adapter")

把adapter保存下来后,线上推理代码可以直接用PeftModel加载合并,或者用merge_and_unload()把adapter权重融进基座模型导出完整pytorch模型。我选择后者,这样线上推理时不需要额外加载peft库,避免依赖冲突。合并的操作就一行:merged_model = model.merge_and_unload(),然后正常保存。

整个飞轮跑起来之后,我的工作节奏就变成了:每周四下午拉日志跑主动选择脚本,周五上午标完50条,下午生成合成数据并清洗,晚上提交合并微调任务,下周一早上灰度上线新模型。这个节奏持续了快两个月,模型一直很健康。期间我们还经历过一次大促,突然涌入大量“怎么用券”、“满减规则”之类的问题,这些意图之前完全没有。飞轮迅速发挥了作用:第一轮标注了30条大促相关,再加上合成数据补充,第二轮模型就学会了,没让客服被海量重复问题淹没。

我反思这一整套下来,数据飞轮的精髓根本不在算法多高级,而是在于把流程跑顺、成本压到最低、让迭代成为习惯。50条标注+合成数据+LoRA是一个中小团队完全吃得消的组合,没有理由再忍受那个一成不变的78%模型。如果你也有个意图识别模型正在线上挨骂,强烈建议你明天就开始画自己的飞轮。

发表评论