实体抽取这个活儿,我做了好几年,从最早的词典加正则硬扛,到后来上序列标注模型,再到现在隔三差五有人问我"能不能直接丢给大模型抽一下"。每次讨论到最后,问题往往不在模型选型,而在最开始那几句话没说清楚:抽什么、边界怎么算、错了会怎样。这篇就按我自己做项目的顺序,把实体抽取从需求拆解、标注体系、方案选型、训练实操到线上排障整套流程摊开讲一遍,适合刚接触信息抽取的同学,也适合已经在调模型但指标卡住不动的人对照着看。
1. 先想明白:实体抽取到底在解决什么问题
很多人一上来就找模型,我建议先花半天把业务口径聊透。同一个"实体抽取"四个字,落到不同场景里,要求能差出十万八千里。有人要的是把一段文本里的机构、人名、产品型号全部拎出来做检索索引,漏一两个无所谓;有人要的是从合同里抽金额和期限,错一个数字就是事故。这两种场景,前者可以容忍 85% 的召回,后者必须把精度顶到 99% 以上,模型方案、阈值策略、人工复核环节完全不同。
1.1 从业务口径反推任务定义
我习惯先问三件事。第一,抽出来的实体给谁用,是给人看还是给下游系统做结构化入库。给人看的,标错一个边界用户没什么感觉;入数据库的,边界错了就是脏数据,后续对账要疯。第二,漏和错哪个更贵。风控场景里漏一个风险主体代价极大,那就得把召回压到最高,宁可多报让人工筛;而知识图谱构建里,错的实体合并进去会污染整个图谱,精度优先。
第三,文本长什么样。短文本(搜索 query、商品标题)和长文本(公告、报告、工单)完全是两个难度。短文本缺上下文,模型只能靠字面和词表猜;长文本有上下文但也有截断问题,还容易出现跨句指代。我做过一个工单场景,实体平均长度只有 3 个字,但同一个词在不同工单里可能是人名也可能是产品名,最后是靠引入工单分类字段作为辅助特征才把歧义压下去的。这些判断如果不在开工前定下来,后面模型调到天荒地老也不会有人满意。
1.2 四条技术路线的适用边界
技术路线这块,我一般给团队摆一张表,把成本、效果、维护难度摊开,让大家自己选,选完就别反复横跳。
| 路线 | 冷启动成本 | 需要标注量 | 典型实体级 F1 | 维护成本 | 适合什么阶段 |
|---|---|---|---|---|---|
| 词典 + 正则 + 规则 | 极低,半天能出结果 | 0 | 看词表覆盖,60~85 波动大 | 随场景线性增长 | 验证可行性、兜底召回 |
| 传统序列标注(特征工程 + CRF) | 中,要设计特征模板 | 1000~3000 句 | 75~85 | 特征维护重 | 算力受限、样本少 |
| 预训练编码器 + 序列标注头 | 中,框架成熟 | 2000~10000 句 | 85~93 | 中,主要维护数据 | 主流生产方案 |
| 生成式大模型(提示词或微调) | 低,写提示词就能跑 | 0~500 句 | 零样本 60~80,微调后接近上一行 | 高,版本漂移、成本高 | 长尾类别、复杂嵌套、冷启动 |
我个人的判断标准很简单:如果这个实体类别每天调用量在百万级以上、延迟要求 100ms 以内,老老实实做序列标注;如果只是内部工具、一天几千次调用、类别还经常变,那用大模型加结构化输出反而省事。中间地带就看团队有没有人能维护训练管线——没人维护的话,再好的模型三个月后也会退化成一堆没人敢动的脚本。
至于表里那个"预训练编码器 + 序列标注头",为什么它能成为主力,背后的道理值得说清楚。实体抽取本质是给每个字(或词)打一个标签,属于典型的序列标注任务。预训练模型在海量文本上已经学到了"哪些字经常连在一起出现"这种语感,微调阶段只需要教会它"这种连续出现的模式对应哪一类实体"。这就好比招了个读过很多书的新人,你不需要教他认字,只需要给他看几百个例子告诉他"这类东西叫机型、那类叫故障码",他上手很快。相比之下,纯 CRF 加人工特征相当于从零教起,特征设计得好也能用,但换个领域就得重来一遍。
1.3 先把评估口径钉死,再谈模型
这一步最容易被跳过,也是后面扯皮最多的地方。实体抽取的评估有三套口径,算出来的数字能差十几个点:
- Token 级 F1:按每个字打标签算对错。数字最好看,但和业务感受最脱节。
- 实体级严格 F1:实体的起止位置和类别全部正确才算对。这是我最推荐的默认口径。
- 实体级宽松 F1:只要实体文本有重叠就算命中。适合评估召回能力,不适合验收。
我踩过的一次坑:早期用 token 级指标验收,模型报 96% 准确率,业务方上线后说"十个里面错三个"。原因就是实体边界错一个字,token 级只扣一个小分,但业务上整个实体就废了。后来统一改成实体级严格匹配,指标从 96 掉到 87,团队一开始不适应,但这个 87 才是真实水平。
除了口径,还要按类别和实体长度分层看。八类实体里,如果有一类的 F1 只有 60,整体却报 90,那这个 90 是骗人的。我一般要求输出一张分层表:每个类别的 P/R/F1,再按实体长度分 1~2 字、3~5 字、6 字以上三档各看一遍。长短实体的错误模式完全不同,混在一起看等于没看。
2. 数据是天花板:标注体系设计与语料构建
模型再花哨也顶不过标注质量。我见过太多项目,模型换了三轮,最后发现问题出在标注规范上——两个人对"某市分公司"到底算不算一个完整机构名有分歧,标出来的数据自相矛盾,模型学得一脸懵。
2.1 标签集怎么定:BIO 还是 BIOES
序列标注的标签体系,最常见的是 BIO 和 BIOES 两套。假设有 8 类实体,BIO 的标签总数是2 × 8 + 1 = 17,BIOES 是4 × 8 + 1 = 33。
BIO 的问题在于无法表达"实体结束"。举个例子,"甲公司和乙公司签约",如果两个机构名紧挨着,BIO 序列是B-ORG I-ORG B-ORG I-ORG,模型还能靠 B 区分;但如果是单字实体连着另一个实体,比如"张王两家",B-PER B-PER虽然能分开,可一旦中间没有明确边界信号,模型就容易把两个实体连成一个。
BIOES 多了 E(实体结束)和 S(单字实体),边界信息是显式的。我用 BIOES 的实测收益大概在 0.5~1.5 个点,实体越长、相邻实体越多的场景收益越明显。代价是标签数翻倍,输出层参数变多,小样本下收敛稍慢。
我的建议是:样本量在 3000 句以上、实体边界容易混淆的场景,直接上 BIOES;样本很少(几百句)或者实体类别特别多(30 类以上),先用 BIO 把流程跑通,标签空间小一点更好收敛。另外提醒一句,标签体系一旦定了就别中途改,改一次等于全量数据重标,代价极大。
2.2 标注规范文档必须写死的五件事
我写标注规范一般会覆盖这些点,每一条都要配正反例:
- 边界规则:实体包含不包含修饰成分。比如"某市某某科技有限公司",前面带不带"某市"?我一般要求带完整注册名,因为下游要做工商匹配。但如果下游只关心品牌,那就只标品牌部分。这个必须一刀切。
- 嵌套实体怎么办:一个长实体里包含短实体,是都标还是只标最外层。标准序列标注模型一次只能输出一层,标了嵌套就没法直接训,得拆成多条样本或者上 span 抽取方案。
- 否定与假设语境:像"未发现某某公司存在违规"这种,实体要不要标?我的做法是照标,否定判断交给下游的句子级分类,别让实体抽取模型承担逻辑推理。
- 代词和省略:文本里写"该公司""上述机构",标不标?一般是不标,因为没有字面实体。但如果下游需要做共指消解,就要单独标一层。
- 歧义优先级:一个词既能是人名又能是产品名,怎么定?我的规则是看上下文语境,实在判断不了就按出现频率高的那类标,同时在规范里记录这类 case 供后续复查。
这份文档不需要写得多漂亮,但要能回答标注员 90% 的疑问。我一般还会附一个"疑难 50 例"清单,把实际语料里最容易吵起来的例子列出来定调,新人上手先看这个,比看长篇规范有效得多。
2.3 一致性校验与标注质量量化
标注质量不能靠感觉。我的做法是:正式标注前先让两个人各标 200 句同样的数据,然后算一致性。用实体级 F1 比较直观——把 A 的标注当标准答案,B 的当预测,算出来的 F1 低于 0.85 就说明规范有歧义,得回去补文档重新对齐,直到能稳定在 0.9 以上再铺开。
正式标注阶段,抽 10% 做双标复核,剩下的单标加抽检。抽检比例我一般定 15%,抽检不合格率超过 5% 就整批退回。另外要留一份 500 句左右的"黄金测试集",这批数据由最熟悉业务的人标,双人交叉校验,训练过程中绝对不碰,只在最终验收时用。我见过不止一个项目,训练时反复在测试集上调参,最后模型漂亮得像刷出来的,一上新数据就露馅。
还有一个容易被忽略的点:负样本比例。真实语料里大部分句子是没有任何实体的,如果标注集里全是"有实体"的句子,模型会学成"每句话都必须标点什么",上线后误报爆炸。我的经验是把无实体句子控制在总样本的 15%~25%,太少模型学不会 O 标签,太多又会让模型倾向于全标 O。这个比例可以在训练时通过采样动态调整,不必死磕标注集。
3. 模型方案:从基线到上线的三段式推进
我不太喜欢一上来就搭最复杂的方案。更稳妥的做法是分三段走,每一段都留下可回滚的资产。
3.1 第一段:两天能跑通的规则基线
别小看规则。我每个项目都会先花一两天做一版词典加正则的基线,目的有三个:一是验证数据可用性,看看人工筛出来的实体长什么样;二是给后续模型提供词典特征和对比基线;三是万一模型迟迟上不了线,好歹有个能用的兜底。
具体做法不复杂:把业务方提供的实体清单整理成词典,用多模式匹配算法(比如 AC 自动机)做一遍扫描,再针对有强格式信号的类别(编号、日期、金额、型号代码)补正则。这套东西在格式规整的类别上召回能到 90% 以上,唯一的问题是误报——短词在长文本里到处命中。解决办法是加前后缀约束和停用词黑名单,比如匹配到"某某公司"后面紧跟"的"且前面是动词时,降权处理。
这版基线的另一个用途是预标注。把规则跑一遍,人工只需要改错,效率能提升 3 倍以上。这是我用过最划算的加速标注手段,尤其适合类别多、语料量大的项目。
3.2 第二段:预训练编码器加序列标注头
主力方案就这个。结构上很直白:编码器把每个字映射成一个向量,接一层线性分类头输出每个位置在各类标签上的分数,最后用 CRF 层做全局解码。
为什么要加 CRF 而不是直接对每个位置取 argmax?因为标签之间有强约束。比如 BIO 体系下,I-ORG前面必须跟B-ORG或I-ORG,不可能凭空出现在O后面。逐位置 argmax 会产出这种非法序列,得靠后处理修,修起来规则一大堆还容易误伤。CRF 把这层约束放进转移矩阵里,训练时一起学,解码时用维特比算法找全局最优路径,一步到位。
代价是什么?CRF 的正向计算要在每个时间步做一次标签数 × 标签数的矩阵运算,标签数 33 时是 1089 次小运算,训练速度会慢 15%~30%。如果标签数特别大(比如 50 类实体、200 个标签),这个开销会很显著,这时候可以考虑用 softmax 加简单的合法性后处理,实测 F1 差距在 1~2 个点,速度能快一截。我的默认选择还是 CRF,除非训练资源实在紧张。
3.3 第三段:生成式方案什么时候值得上
大模型做实体抽取,最大的优势是零样本可用和能处理嵌套。写一段提示词,把实体类别定义和几个例子给进去,直接就能出结果,适合需求还在探索期、标签体系天天变的阶段。遇到嵌套实体,序列标注模型只能标一层,生成式方案可以直接输出 JSON 结构,内外层都拿到。
但它的问题也很实在。第一是格式稳定性,输出偶尔会带解释文字、字段名拼错、位置索引算错,得写校验和重试逻辑。第二是成本,长文本一次推理的费用是序列标注模型的几十倍,日调用量大根本不现实。第三是延迟,几百毫秒到几秒,交互场景很难接受。第四是一致性,同样的输入两次结果可能不同,做批量处理时很难对齐。
我的实践是把两者组合起来:用生成式方案处理长尾类别和复杂文本(占比低但难度高),用序列标注模型扛主力流量。线上按置信度分流,序列标注模型输出置信度低的样本走大模型兜底,再把大模型的结果回流成新标注数据,形成一个闭环。这套组合拳我用了两个项目,效果比单押一边稳得多。
4. 实操:从零训一个中文实体抽取模型
这部分给到能直接抄的程度。代码用 Python,依赖主流深度学习框架和 transformers 系列开源库,结构是编码器加 CRF,评估用实体级严格匹配。
4.1 数据格式与标签转换
原始标注数据我用 JSONL,一行一条:
{"text": "某市甲公司的设备编号为 A-1024,故障码 E05。", "entities": [{"start": 0, "end": 6, "label": "ORG"}, {"start": 15, "end": 21, "label": "DEVICE_ID"}, {"start": 25, "end": 28, "label": "ERROR_CODE"}]}转 BIOES 的时候要注意,end是左闭右开的,也就是text[start:end]才是实体文本。这里最容易出 off-by-one 错误,我建议在转换函数里加断言,转换完立刻回读校验:把标签序列还原成实体列表,和原始 entities 比对,不一致就报错。这个断言救过我至少三次。
def to_bioes(text, entities, label2id): tags = ["O"] * len(text) for ent in sorted(entities, key=lambda x: x["start"]): s, e, lb = ent["start"], ent["end"], ent["label"] if e - s == 1: tags[s] = f"S-{lb}" else: tags[s] = f"B-{lb}" for i in range(s + 1, e - 1): tags[i] = f"I-{lb}" tags[e - 1] = f"E-{lb}" return [label2id[t] for t in tags]4.2 模型结构:编码器加 CRF 层
先看 CRF 层的精简实现,包含前向配分函数、金标路径打分和维特比解码三块。生产环境我一般直接用成熟的开源 CRF 实现,这里贴出来是为了看清原理,方便出问题时定位。
import torch import torch.nn as nn from transformers import AutoModel class CRFLayer(nn.Module): def __init__(self, num_tags): super().__init__() self.num_tags = num_tags self.transitions = nn.Parameter(torch.randn(num_tags, num_tags) * 0.1) self.start = nn.Parameter(torch.randn(num_tags) * 0.1) self.end = nn.Parameter(torch.randn(num_tags) * 0.1) def forward_alg(self, emissions, mask): # emissions: (B, L, T) mask: (B, L) alpha = self.start + emissions[:, 0] for t in range(1, emissions.size(1)): scores = alpha.unsqueeze(2) + self.transitions.unsqueeze(0) \ + emissions[:, t].unsqueeze(1) new_alpha = torch.logsumexp(scores, dim=1) m = mask[:, t].unsqueeze(1).bool() alpha = torch.where(m, new_alpha, alpha) return torch.logsumexp(alpha + self.end, dim=-1) def gold_score(self, emissions, tags, mask): score = self.start[tags[:, 0]] + emissions[:, 0].gather( 1, tags[:, :1]).squeeze(1) for t in range(1, tags.size(1)): emit = emissions[:, t].gather(1, tags[:, t:t + 1]).squeeze(1) trans = self.transitions[tags[:, t - 1], tags[:, t]] score = score + (emit + trans) * mask[:, t] lengths = mask.sum(1).long() - 1 last = tags.gather(1, lengths.unsqueeze(1)).squeeze(1) return score + self.end[last] def neg_log_likelihood(self, emissions, tags, mask): log_z = self.forward_alg(emissions, mask) gold = self.gold_score(emissions, tags, mask) return (log_z - gold).mean() @torch.no_grad() def viterbi_decode(self, emissions, mask): B, L, T = emissions.shape score = self.start + emissions[:, 0] history = [] for t in range(1, L): scores = score.unsqueeze(2) + self.transitions.unsqueeze(0) \ + emissions[:, t].unsqueeze(1) best_score, best_tag = scores.max(dim=1) m = mask[:, t].unsqueeze(1).bool() score = torch.where(m, best_score, score) history.append(best_tag) score = score + self.end best_last = score.argmax(dim=-1) paths = [] for b in range(B): length = int(mask[b].sum().item()) path = [int(best_last[b])] for t in range(length - 2, -1, -1): path.append(int(history[t][b][path[-1]])) paths.append(path[::-1]) return paths外面套一层模型:
class NerModel(nn.Module): def __init__(self, model_name, num_tags, dropout=0.1): super().__init__() self.encoder = AutoModel.from_pretrained(model_name) hidden = self.encoder.config.hidden_size self.drop = nn.Dropout(dropout) self.classifier = nn.Linear(hidden, num_tags) self.crf = CRFLayer(num_tags) def forward(self, input_ids, attention_mask, token_type_ids=None, tags=None, label_mask=None): out = self.encoder(input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids) logits = self.classifier(self.drop(out.last_hidden_state)) if tags is not None: return self.crf.neg_log_likelihood(logits, tags, label_mask) return self.crf.viterbi_decode(logits, label_mask)有个细节必须强调:子词对齐。中文虽然大多是一字一 token,但模型的分词器对英文型号、数字会切成多个子词。如果一个实体片段被切成 3 个 token,只在第一个 token 上打真实标签,后面两个设成-100让损失函数忽略掉。这一步做错,loss 会算出奇怪的值,或者干脆全预测成 O。我排查过两次"模型训完全标 O"的问题,根因都是对齐写错,标签全打在 padding 位置上了。
4.3 关键超参和资源估算
超参这块我有一套默认值,基本能覆盖 80% 的场景:
| 参数 | 默认值 | 调整建议 |
|---|---|---|
| 学习率 | 3e-5 | 样本少于 2000 句降到 2e-5,避免过拟合 |
| batch size | 16 或 32 | 显存够就上 32,梯度更稳 |
| 最大长度 | 128 | 按语料长度 95 分位设,别盲目上 512 |
| 训练轮数 | 5 | 看验证集 F1,连续 2 轮不涨就停 |
| 预热比例 | 10% | 小样本可以提到 15%,训练更平滑 |
| dropout | 0.1 | 数据少于 3000 句提到 0.2 |
资源估算给个具体算法。假设语料 8000 句,平均长度 45 字,max_len设 128,batch 16,那么每个 epoch 的步数是8000 / 16 = 500步,5 个 epoch 共 2500 步。单卡训练吞吐按每秒 3000 个 token 算,每步处理16 × 128 = 2048个 token,单步约 0.68 秒,一个 epoch 约 5.7 分钟,5 个 epoch 半小时以内能跑完。显存占用上,12 层 768 隐藏维的编码器,batch 16、长度 128,混合精度下大约 6~8 GB,24G 的卡可以开 batch 32。
如果语料只有 2000 句,步数降到 625 步(5 epoch),这个规模很容易过拟合。我的应对是:冻结编码器底部 6 层只训顶部,学习率降到 2e-5,同时把 dropout 提到 0.2,验证集上用早停。
4.4 训练循环与评估
评估直接用实体级的精确率、召回率和 F1,别用 token 级。我用 seqeval 这类现成库,它内部也是按实体 span 严格比对的。
from seqeval.metrics import classification_report, f1_score def evaluate(model, dataloader, id2label, device): model.eval() preds, golds = [], [] for batch in dataloader: batch = {k: v.to(device) for k, v in batch.items()} paths = model(input_ids=batch["input_ids"], attention_mask=batch["attention_mask"], label_mask=batch["attention_mask"]) gold = batch["tags"].tolist() for p, g, m in zip(paths, gold, batch["attention_mask"].tolist()): length = sum(m) preds.append([id2label[i] for i in p]) golds.append([id2label[i] for i in g[1:length - 1]]) return f1_score(golds, preds, average="micro")注意g[1:length-1]这个切片,是因为编码器输入通常带[CLS]和[SEP],要掐头去尾再和预测对齐。这个偏移量算错,评估结果会莫名其妙地低,我第一次写的时候就在这里卡了半天。
训练时我一般会盯三个数:训练 loss、验证集实体级 F1、以及预测为 O 的比例。第三个数很关键,如果验证集上 O 的占比突然飙到 98% 以上,基本可以断定模型退化成全 O 预测了,赶紧停下来查数据对齐或者学习率。正常情况下 O 的占比应该和标注集里 O 的占比接近,偏差超过 5 个点就有问题。
5. 效果调优:指标不涨但业务喊差的那些场景
模型训完 F1 报 89,业务方却说不好用,这种情况太常见了。往下拆基本都能落到下面几个点上。
5.1 边界错误和长实体被切碎
实体级严格匹配下,边界错一个字整个实体就不算对,所以边界错误是拉低指标的最大来源。我的排查方法是把错误样本按实体长度分层统计:短实体(1~2 字)错得多,往往是类别歧义,得靠上下文或者外部知识;长实体(6 字以上)错得多,基本是边界扩展不足,模型只标了核心部分,把前缀后缀丢了。
长实体的问题有个针对性做法:在训练时加一个辅助的 token 级损失,权重设 0.2~0.3,让模型在字符层面也学到"这个字属于实体内部"。这样主损失(CRF 的序列级损失)负责整体结构,辅助损失负责细粒度边界,两者叠加实测能涨 1~2 个点。另一个土办法是把长实体在训练数据里做片段增强——把同一类长实体的不同前缀后缀组合造一批样本,让模型见过更多边界形态,代价是要小心别造出不符合语言习惯的假样本。
还有一种情况是实体被切碎成多段。比如"某某技术有限公司"被标成"某某技术"和"有限公司"两个实体。这通常是训练数据里存在同类不一致标注导致的,模型在两种标法之间摇摆。解决办法是回到数据里查一致性,把同类实体的标注统一,比调模型有效得多。
5.2 类别不均衡和负样本配比
八类实体,主流类占 70%,长尾类每类不到 3%,模型自然会把长尾类学废。我试过几种手段,效果排序大概是:分层采样 > 类别加权损失 > focal loss。
分层采样最直接,构造 batch 的时候保证每个 batch 里长尾类样本占比不低于某个阈值,比如 10%。实现上按"包含长尾实体的句子"和"不含的句子"分两个池子,按比例混合采样。这个改动很小,但对长尾类召回提升明显,我做过一个场景,长尾类 F1 从 52 提到了 71。
类别加权损失也好用,但权重要小心设。我一般按类别频次的反平方根给权重,再裁剪到[0.5, 3.0]区间,避免长尾类权重过大导致主流类效果崩掉。focal loss 我在实体抽取上试过两次,效果不如前两种稳定,而且超参难调,不太推荐作为首选。
负样本配比前面提过,这里补一个数据:无实体句子占比从 10% 提到 20% 时,我在一个工单场景里看到误报率下降了近 4 个点,召回基本不掉。但提到 40% 以上,召回开始明显下滑,因为模型变得过于保守。这个比例建议在自己数据上做个小网格,[15%, 20%, 25%]三档跑一下就能定。
5.3 推理加速和工程落地
模型训好了,落地还有一堆工程问题。我总结几条实用的:
第一,别在长文本上整段推理。超过max_len的文本要切窗,切的时候留 30~50 字的滑窗重叠,避免实体被切断。重叠区里的实体做去重合并,合并规则是位置重叠且类别相同就保留分数高的那个。
第二,批处理要按长度排序。一个 batch 里混着 10 字和 128 字的样本,padding 到 128 会浪费大量算力。按长度分桶,同桶内组 batch,实测吞吐能提升 30%~50%。这个优化几乎零成本,强烈建议做。
第三,导出静态图跑推理。训练用动态图方便调试,推理阶段导出成静态图格式,关掉梯度计算,再开半精度,单条延迟能压到原来的 40%~60%。注意导出后要和原模型做一轮结果对齐,我在 CRF 解码部分遇到过精度差异导致个别样本结果不同的情况,得提前发现。
第四,加一层结果缓存。实体抽取的输入重复率往往比想象的高,尤其是搜索 query 和商品标题这类场景,去重后缓存命中率能到 40% 以上。用输入文本的哈希做 key,缓存实体的位置和类别,命中直接返回,能省下不少算力。
6. 常见问题排查速查表
线上出问题时,最怕的是没有排查路径。我把踩过的坑整理成一张表,按现象倒查原因,基本能覆盖 80% 的故障。
6.1 典型故障与定位路径
| 现象 | 最可能原因 | 排查动作 |
|---|---|---|
| 训练 loss 不降,一直在高位 | 标签对齐错位、学习率过大 | 打印一个 batch 的 input_ids 和 tags,人工核对前 3 条 |
| 模型全预测 O | 正样本比例过低、标签全打在 padding 上 | 统计训练集 O 占比,检查 label_mask |
| 训练 F1 高,线上骤降 | 数据分布不一致、文本长度超限 | 抽样对比线上线下文本长度分布和前 20 高频词 |
| 某类实体 F1 为 0 | 该类样本数为 0 或标签拼写不一致 | 统计每类实体数量,检查标签字典里有没有近义重复项 |
| 实体边界普遍偏短 | 训练数据标注偏保守、长实体样本少 | 按长度分层看 F1,补长实体样本 |
| 同一句两次推理结果不同 | 未切 eval 模式、batch 内 padding 影响 | 确认model.eval(),单条和批量结果对比 |
| 推理延迟突然翻倍 | 输入长度暴涨、batch 未分桶 | 看 P99 输入长度和实际 batch 组成 |
其中"训练 F1 高、线上骤降"这个我遇到最多,八成是数据分布问题。有个具体案例:训练数据来自历史归档文档,格式规整、句子完整;线上是用户实时输入,全是短句、错别字、夹杂符号。模型在训练集上 91,线上实测只有 68。解决办法是从线上采样 500 条重新标一遍做验证集,再针对性补充线上风格的训练数据,两周才把差距拉回到 5 个点以内。这件事之后我养成了一个习惯:验证集必须来自线上分布,哪怕量少一点,也比用训练集切分出来的靠谱。
6.2 上线后的监控与数据回流
模型上线不是终点。我一般会配三路监控:一是输入长度和字符分布,偏离训练分布超过阈值就告警;二是预测标签分布,某一类的占比突然翻倍或归零,说明模型行为异常;三是置信度分布,低置信度样本占比持续上升,通常意味着输入在漂移。
数据回流这块,我的做法是把线上置信度在中间区间(比如 0.4~0.7)的样本自动收集起来,每周人工过一遍,改对了就进训练集。这个区间是模型的"犹豫区",信息量最大,标 200 条的效果抵得上随机标 1000 条。同时保留一个全量抽样通道,每周随机抽 100 条看看,防止模型在谁都没注意的角落里悄悄退化。
最后分享一个我踩过比较深的坑:别把实体抽取模型当成一劳永逸的东西。业务方加了一个新实体类别,你以为只需要改标签重训,实际上原来的标注数据里所有出现这个新类别的句子都变成了"标注不全",模型会把这些位置学成 O,反过来拉低其他类的效果。我的做法是每次扩类别时,先用规则或大模型在历史数据里扫一遍候选,人工确认后补标,再整体重训。这个流程多花两天,但能避免模型整体效果倒退,值。