
去年我接了一个法律文书实体抽取的项目需求方拿来的标注数据里“法规引用依据”这类实体形态五花八门训练集中出现频次不超过三次的样本占了四成。当时团队内部的主流方案还是BERT-CRF我也没多想直接上了RoBERTa-wwm-ext做序列标注。训练完在验证集上跑整体F1在84%上下看着还行。但把指标拆到单个实体类别问题就暴露了——高频的“当事人姓名”和“金额”已经跑到95%而“法规全称”“机构层级”这种长尾实体只有55%出头甚至不如一个基于词典匹配的规则baseline。就是从“长尾实体识别不了”这个突破口开始我尝试把BERT和LLM放进同一条流水线让BERT负责候选召回让LLM负责边界裁决和类别复核。这套混合架构最终把长尾实体的严格F1从55%拉到了76%同时没有牺牲高频实体的精度。这篇文章就把这套架构的设计思路、实现细节和踩过的坑一次说清楚适合正在做NER但卡在指标上、手头又刚好能用上大模型API的团队参考。1. 性能瓶颈不在模型大小而在实体分布的长尾1.1 拆开指标后我才发现传统NER的回升空间全在长尾做NER评测最忌讳只看整体F1。整体数字好看往往是因为高频实体占了大部分样本把长尾问题掩盖了。我那个法律文书项目里整体84分不算差但把每个实体类别单独算差距触目惊心实体类别训练样本数严格F1当事人姓名1200095.6%金额数字980094.2%日期时间760092.8%法院名称120078.4%机构层级32061.2%法规全称15055.3%严格F1的意思是实体边界和类型都完全正确才算对这也是线上业务真正需要的口径。长尾实体样本少模型在微调时根本学不到稳定的特征遇到稍微变化的上下文就漏召。这一步拆解让我意识到继续换更重的BERT变体大概率只能把整体F1从84提升到85对业务最关心的长尾类别帮助甚微。1.2 长尾实体难难在三个层面我复盘了长尾实体的错误样本发现误差来源不是单一的至少有三层第一层是OOV问题。法律文书里的“最高人民法院关于审理建设工程施工合同纠纷案件适用法律问题的解释”这类整段式实体训练集里没出现过完整形态BERT的tokenizer把它切成很多子词每个子词在预训练里见过但组合成这个特定引用名称时token级特征根本不具备“整体是一个实体”的表征能力。第二层是上下文多样。同一实体在不同文书里出现的位置、前后搭配差异极大。比如法院名称前面可能是“原告向”“被告不服”“本院系”等不同触发词表面形式变化多模型很难从少数样本里归纳出规律。第三层是类别边界混淆。机构层级和法院名称在文本里经常相邻出现比如“北京市高级人民法院知识产权庭”这类嵌套结构对序列标注模型是天然挑战模型分不清哪里是上级机构、哪里是层级延伸。BERT的取值范围是单token的上下文窗口对这类需要“全局结构知识”的判断支撑不够。1.3 换更大模型是饮鸩止渴当时团队也讨论过要不要换成更大的底座比如从BERT-base换到BERT-large甚至更进一步。实测下来大参数模型在高频实体上略有提升但长尾实体的F1只涨了1.5个百分点而推理耗时翻了接近三倍。原因也好理解长尾实体的问题是训练信号稀疏不是模型容量不够。换大模型等于给一个没见过几道题的学生发更厚的课本他该不会的还是不会。真正让我改变思路的转折点是重新看待这个问题的分工。长尾实体虽然样本少但文本语义信息是充分的。一个熟悉法律知识的标注员即使没见过某个法规全称也能结合上下文判断“这里大概率引用了法规名称”。这种能力来自“语言理解领域常识”的组合恰好是LLM的强项。那么问题就从“让一个模型学会所有实体”变成了“怎么让两个模型各司其职”BERT用有限的标注样本做高精度的候选召回LLM用世界知识做长尾实体的裁决和修正。2. BERT与LLM的能力边界给两个模型各派各的活2.1 BERT天生适合序列标注但它看不懂“言外之意”BERT是Transformer的Encoder部分预训练目标是Masked Language Model——随机盖住一些token让模型根据双向上下文预测被盖住的词。这个目标决定了它最擅长的是“给每个token打标签”一个词在句子里扮演什么角色它可以通过左右两侧的上下文精准判断。所以NER这种任务BERT加上一层CRF几乎是教科书级别的组合训练稳定、推理快、开源生态成熟。但BERT有一个致命短板它的世界知识非常有限。MLM预训练本质上是在学“词与词的共现规律”它知道“最高人民法院”经常和“判决”一起出现但它不理解法院的层级关系也不具备“根据文号格式推知这是法规引用”的推理能力。在处理完全没见过的长尾实体时它只能靠词表面特征瞎猜。2.2 LLM的零样本抽取能力很强但硬伤同样明显LLM走的是另一条路自回归生成目标函数是预测下一个token。训练数据里包含了海量的人类知识所以它对“什么样的片段像法规名称”“机构之间是什么关系”有远超BERT的理解力。零样本提示词就能做NER这也是很多人直接拿LLM抽实体的原因。但把LLM单独用来做生产级NER会遇到三个绕不开的硬伤格式漂移是最常见的。你让模型输出JSON数组它可能给你返回一段带解释的话或者把实体边界悄悄改掉。我试过让一个模型抽取“开庭日期”原文写的是“2024年3月15日上午9时”模型老老实实返回了这个完整日期但规定边界是具体到日多出的“上午9时”就让严格F1直接归零。幻觉更致命。模型会自动脑补原文中根本不存在的实体。比如原文只写了“本案由本院审判员张铭独任审理”模型居然额外抽出了“合议庭”理由可能是“独任审理也是审判组织的一种”——这种对业务系统来说是完全不可接受的错误。还有边界粗糙的问题。序列标注模型天然对边界敏感BIO标签已经把“开始”和“内部”拆开建模而生成式模型倾向于输出语义完整的片段容易把“北京市高级人民法院”输出成“高级人民法院”少了前缀边界偏差。2.3 两个模型的错误模式恰好互补单独看两个模型做NER都不完美。但把它们放在一起看反而发现了协同的可能性BERT的错误集中在“没见过”LLM的错误集中在“不精确”BERT对高频实体敢给出高置信度LLM对长尾实体有更强的语义判断力BERT输出稳定可控LLM输出灵活但会漂移。我用一个类比来理解这套架构BERT像一个业务熟练但经验有限的审核员能快速处理见过的单据遇到没见过的新情况就拿不准LLM像一个知识面很广但容易自由发挥的新人什么都能说上几句但偶尔会加戏。单独用任何一个都不靠谱让审核员先打草稿新人只负责复核拿不准的条目最后再由审核员确认一遍边界流程就顺了。这就是混合架构的核心思想不用LLM替代BERT而是让LLM给BERT查漏补缺。3. 混合架构设计BERT做召回LLM做裁决3.1 流水线全貌先粗后精分级处理整套流水线分为五个步骤我一步步讲清楚第一步原始文本进入BERT序列标注模型输出BIO标签序列和每个token的置信度。第二步解码器不取唯一的argmax路径而是用维特比或beam search保留Top-N条候选路径生成一组候选实体。第三步把所有候选实体连同各自的上下文窗口打包进一个结构化提示词。第四步LLM对每个候选实体做四选一裁决保留、修正边界、修正类别、删除并返回裁决理由。第五步把BERT置信度和LLM裁决结果做加权融合低于最终阈值的候选丢弃高于阈值的输出为正式标注结果。这套流程的好处是LLM的决策空间被压缩到一个“判断题”而不是开放式抽取格式漂移和幻觉的风险都大幅降低。3.2 BERT召回层不要只留最优路径很多人在序列标注解码时直接取每个token概率最大的标签这条路对简单场景没问题但在长尾实体上会丢太多信息。我改成beam search保留Top-3候选路径之后某些长尾实体的召回率立刻涨了几个点。具体做法是解码时不再只保留全局最优路径而是保留得分最高的N条路径每条路径对应一组不同的实体边界和类型假设。这一步的意义是把“模型拿不准”的选项保留下来交给下游LLM去判断而不是在第一步就武断地丢死。BERT召回层的置信度我用了路径得分归一化path_score sum(log_prob(token_label) for token_label in path) bert_conf exp(path_score / len(path))这个归一化置信度的好处是跟序列长度解耦不会因为实体长导致天然得分低。过滤规则也简单粗暴bert_conf低于0.4的候选直接丢弃高于0.9的直接进入最终结果只有落在0.4到0.9之间的候选才需要LLM介入。这个阈值比例大约让三成左右的候选实体走LLM控制了成本又保证了召回。3.3 把候选实体组装成LLM能处理的结构LLM不会自己去看整篇长文档我们只喂它候选实体相关的局部上下文。每个候选实体我取前后各64个字符作为上下文窗口然后把同一段落里的多个候选实体合并到一个提示词里一次调用处理多个候选节省token。提示词模板大概是这样的请根据给定的上下文对以下候选实体进行裁决。 严格遵循以下规则 1. 实体必须与原文中的连续字符完全一致禁止增删改字。 2. 只能输出JSON数组每项包含entity实体原文、type实体类型、actionkeep/revise/remove、corrected_entity若action为revise填修正后的实体否则填null、reason一句话理由。 上下文 {context} 候选实体 [ {entity: 北京市高级人民法院, type: 法院名称}, {entity: 知识产权庭, type: 机构层级} ]为什么这么设计核心是把LLM的输出空间限制在一个极小的决策集合里。它不需要“想”原文里有哪些实体只需要对给定候选做保留、修正或删除。这从根上抑制了幻觉因为LLM无法凭空造出不在候选列表里的实体——我们明确要求只能对给定实体操作。3.4 置信度融合两个模型都拿不准时怎么取舍LLM裁决返回后需要和BERT置信度融合成一个最终判断。我的融合公式很简单final_score alpha * bert_conf beta * llm_conf其中llm_conf怎么算如果是纯符号判断我用一个经验映射keep且修正后边界未变化记0.9keep但边界扩大记0.75revise且修正边界跨度不超过原实体2个字符记0.65其他情况记0.3。如果用的是支持logprob的LLM接口也可以让模型对“这个候选是否是正确的实体”生成一个“是/否”答案拿“是”token的logprob作为llm_conf但多数场景下符号映射已经够用。最终阈值我设在0.72。高于0.72输出低于则丢弃。这个数字是通过在验证集上扫描不同阈值得到的不同项目可以微调但方向是样本量少的长尾实体能接受更低的阈值换取召回率。4. BERT基座微调的细节想让LLM省力先把候选质量做上去4.1 基座选型从HanLP的单任务和多任务模型说起BERT基座的选择直接影响整个混合架构的上限。LLM再能补漏如果BERT召回层的候选本身就漏掉了正确的实体LLM也巧妇难为无米之炊。我早期用的是HanLP 2.x自带的预训练模型这里正好有个很多人会问的点——HanLP的NER模型单任务和多任务到底有什么区别。单任务模型就是只针对NER一个目标做微调比如只有分词、词性、命名实体等单个标注任务的模型多任务模型则把中文分词、词性标注、依存分析、NER等多个任务放在一个模型里联合训练。多任务的优势是共享底层语义表示几个任务之间互相提供正则化信号尤其在数据量不足的时候不容易过拟合。缺点也很明显模型容量被多个任务分摊如果某个特定领域的NER标注数据量很足单任务微调的上限通常更高。我的经验是小样本场景优先用HanLP的多任务模型作为起点因为多任务带来的泛化能力在长尾实体上尤其有用。等数据积累到几千条后再切回单任务模型专门微调NER效果会更好。如果你的数据量非常少直接用HanLP的预训练多任务模型做零样本推理也比自己从头训练靠谱得多。4.2 序列标注和多标签分类不是一回事别做混了很多对BERT不太熟的同学会问“BERT多标签分类能不能做NER”这俩完全不是一个东西。多标签分类是给整个句子打一个或多个标签比如一句客服对话同时属于“退款”和“投诉”两个类别NER是给句子里的每个token打标签本质上是一个序列标注任务。序列标注更严格标签之间还有依赖关系。我用的标签体系是BIOES比BIO多出E和S两个标签能明确实体结束位置和单字实体边界判定更精细。对嵌套实体我一开始天真地想在一个模型里同时识别外层和内层结果效果并不好。后续的处理是在架构上把嵌套实体拆成多个并列的序列标注任务或者干脆交给LLM修正。还有一个非常容易被忽略的点负样本。序列标注的“O”标签不是免费的它的分布极大模型很容易把所有token都预测成O来压低损失。我在损失函数里对O标签降权到0.3实体标签权重保持1.0train loss曲线马上就健康了。4.3 损失函数、超参数和置信度校准序列标注任务的输出层我最终选了CRF而不是单纯softmax。CRF能建模标签之间的转移概率比如B后面不能直接接I这能消灭大量不合法输出。代价是解码慢一点但对结果稳定性的帮助非常明显。类别不均衡的问题除了给O标签降权我还在实体类别上用了focal loss的思想loss -alpha * (1 - prob) ** gamma * log(prob)gamma取2.0alpha根据类别样本比例反比设置。这让模型更关注那些难分、置信度不高的token对长尾类别尤其友好。微调超参数我踩了几轮之后定了batch size 32learning rate 2e-5warmup比例10%训练轮数5轮。超过5轮就会在长尾上过拟合验证集F1反而往下掉。最后说置信度校准。BERT输出的概率分布往往过于自信softmax之后的0.9并不等于90%的正确率。我用了temperature scaling在验证集上找到一个温度系数T把logits除以T后再输入softmax重新校准后的置信度跟真实正确率匹配得更好。这一步对后面阈值筛选非常关键不然阈值怎么调都是虚的。4.4 少量标注数据下主动学习和数据增强比换模型有用长尾实体样本少与其花力气找更大的预训练模型不如把精力放在怎么用有限的数据上。我做了一个简单的主动学习用已经训好的BERT跑一遍未标注数据统计每个样本中所有token预测置信度之和置信度越低代表模型越“不确定”优先把这些样本送给人标。这个策略比随机挑样本标注效率高很多标注2000条就能带来肉眼可见的指标提升。数据增强方面我没有做太复杂的回译和生成式增强只是对原文做了安全的位置互换和同义替换。比如把“原告诉称”替换为“原告主张”把“判决如下”替换为“裁定如下”保持实体内部不变。做了三倍的增强之后长尾类别的F1提升了2个点左右成本很低。5. LLM协同的落地方式从提示词到Agent的递进5.1 方式一提示词JSON Schema约束最稳定也最先落地上文的提示词模板就是方式一的雏形。直接让LLM返回JSON数组并给出严格的字段约束。为了让输出更稳定我建议在系统提示词里加上一句“只输出JSON不要输出任何解释文字”并在请求参数里打开JSON mode之类的结构化输出选项。如果用的是支持function calling的模型可以用工具调用的方式强制模型返回符合schema的结构这是目前稳定性最高的做法。这个方式的优点是部署最快只要封装一个API调用函数就能接进现有pipeline。缺点是单个候选的裁决能力有限LLM只能对给定的候选做简单判断没法主动发现BERT漏掉的实体。5.2 方式二思维链裁决让LLM先解释边界依据遇到边界模糊的候选简单的keep/revise判断不够用。比如“最高人民法院关于审理建设工程施工合同纠纷案件适用法律问题的解释”这类超长实体BERT输出的候选边界经常差几个字符LLM直接裁决也容易走神。我把提示词升级成思维链模板要求模型先输出裁决依据再输出JSON针对下列候选实体请先简要说明判断依据这是否是一个完整实体、边界在哪里、为什么然后把裁决结果放入JSON。这样做的效果非常明显LLM在写依据的过程中相当于强制自己审了一遍案例“然后输出JSON”时边界会准很多。代价是token消耗翻倍。我实测中只有候选置信度在0.5到0.7之间的“犹豫地带”才用思维链其他候选直接走快速模式成本可控。5.3 方式三Agent化修正查漏、补缺、消歧再往下走一步就是给LLM配上工具变成一个NER Agent。这个方式适合实体类型特别复杂、又要求高精度的场景。我给Agent配置了三个工具领域词典查询、外部知识库检索、规则库校验。当LLM对某些候选拿不准时可以调用领域词典查询确认是否存在这个机构名称可以检索外部知识库对照法规名称的规范写法还可以用正则规则验证日期金额格式。这相当于给了LLM“查资料”的能力裁决不再只依赖参数化记忆大大降低幻觉概率。举个例子法律文书里如果出现“北京市第一中级法院”BERT很可能把它切出来但规范写法应该是“北京市第一中级人民法院”。Agent可以调用规则库里的机构规范词表发现“中级法院”缺失“人民”两个字然后revise成规范名称这就把数据层面的不一致问题也顺带解决了。5.4 三种方式的适用场景方式成本延迟稳定性适用场景提示词JSON低低高有BERT候选只需校验思维链裁决中中很高边界模糊、类别易混淆Agent化修正高高高需要领域知识、规范对齐我的经验是大多数业务场景从方式一入手跑一段时间后分析错误样本如果“边界不准”类错误占比高就升级到方式二如果“实体规范性”问题突出再升级到方式三。不要一上来就把Agent堆上成本和稳定性都会让项目难以为继。6. 实测效果单BERT与混合架构差距到底有多大6.1 评测设置为了验证这套混合架构的价值我在内部数据集上做了对比实验。数据是脱敏后的法律文书标注了6类实体共4200句按8:1:1分训练、验证、测试。评测指标用严格F1即边界和类型都必须完全正确。对比的三套方案方案ARoBERTa-wwm-ext CRF基线方案B同一个BERT基座 知识增强的数据增强微调但不接LLM方案CBERT LLM混合架构即方案B的模型接入LLM裁决6.2 分类别指标变化实体类别方案A F1方案B F1方案C F1当事人姓名95.6%96.1%96.3%金额数字94.2%95.0%95.4%日期时间92.8%93.4%94.1%法院名称78.4%81.2%87.6%机构层级61.2%64.8%73.5%法规全称55.3%58.9%71.2%方案B带来的提升主要来自主动学习和数据增强属于“把数据榨得更干”的效果。方案C的提升则集中在长尾实体上尤其是法规全称从58.9%跳到71.2%这是LLM世界知识真正发挥价值的地方。反观高频实体混合架构几乎没带来太多增量这说明长尾才是这套架构打翻身仗的战场。6.3 成本与时延LLM介入不是免费的混合架构最敏感的问题就是成本和时延。我统计了在测试集上的调用数据每千句原始文本约产生800个候选实体其中需要走LLM裁决的约260个。快速模式每个候选平均消耗约200 token思维链模式约450 token。用每百万token为单位估算单价处理1000句文本增加约0.3元人民币的大模型API支出。时延方面单线程处理每千句增加约15秒。我做了并发调用后实际线上p95时延只增加了300毫秒左右。成本大头其实在思维链裁决。我把阈值卡得很紧只有置信度低于0.7的候选才用思维链占比大约15%整体预算完全在可接受范围。另外我加了一层缓存同一个上下文窗口加候选实体组合sha256哈希后存入本地KV。测试数据里有不少重复表述缓存命中率接近20%进一步摊薄了成本。7. 踩坑记录与工程化建议7.1 LLM会把精确边界改坏混合架构跑通后我第一个踩的坑就是LLM“好心办坏事”。一个法院名称候选“北京市高级人民法院”本来是准确的LLM却把它revise成“高级人民法院”理由写的是“排除行政前缀使实体更规范”直接把边界改错了。这让我意识到必须给LLM立规矩。对策是在提示词里加一句硬性要求“除非原有候选与上下文明显不符否则不要修改候选文本。任何修改必须基于上下文证据不允许基于一般性常识修改边界。”加了这一句之后无意义的边界改判比例下降了很多。7.2 提示词模板的小改动指标能波动两个点LLM对提示词的敏感程度超出预期。我试过把“请严格遵循以下规则”换成“请遵循以下规则”少了个“严格”某类实体的F1直接掉了1.8个点。也试过把JSON字段名从“action”换成“decision”模型的输出格式错误率上升了5%。这说明LLM的提示词工程不是写一次就完事每次改动都得跑一遍完整评测。我后来把提示词模板纳入版本管理和代码一起提交任何改动都自动触发回归测试避免“改了提示词导致线上指标悄悄下滑”的情况。7.3 批量裁决和并发控制是降本关键一开始我是一个候选实体调一次LLM API又慢又贵。后来改成把同一段落里最多10个候选实体合并成一次调用token开销反而略微下降因为上下文窗口只传了一次。并发控制也要注意大批量处理时别一次性把配额打满API会限流。我用了简单的信号量控制并发数比如同时最多跑8个请求单请求超时10秒失败自动重试一次。整个批处理任务从几十分钟压缩到几分钟稳定性也好了很多。7.4 什么时候不要用混合架构不是所有NER项目都适合上混合架构。如果你面对的是相对规范的文本实体类别都是高频紧凑型比如电商评论里的商品名、品牌名BERT单模型跑个95%的F1完全够用混入LLM反而增加成本和时延。如果业务对时延极其敏感比如在线客服实时意图识别每一次API调用都是负担那也不太适合。还有一点如果文本领域非常冷门LLM的预训练知识几乎没有覆盖比如某些高度专有的内部编码那LLM裁决的参考价值会大打折扣不如把精力放在纯规则和词典建设上。我个人的判断标准是长尾实体占比超过三成、文本语义依赖领域常识、硬性F1要求超过90%这三个条件满足两个混合架构就值得上。要是三个都不满足老老实实把BERT微调做好、把数据质量提上去性价比高得多。