主动学习驱动的命名实体识别:用最少标注训练高精度NER模型

发布时间:2026/10/20 9:22:10

主动学习驱动的命名实体识别:用最少标注训练高精度NER模型 1. 项目概述从标注瓶颈到主动学习闭环做 NLP 相关项目的朋友应该都有过这样的经历手里的模型在公开数据集上表现尚可一落到自己业务场景里立刻被那些“只有行业内才懂的叫法”和“千奇百怪的实体写法”打得没脾气。我这次接到的需求很典型——原本的命名实体识别模型只认识人名、地名、机构名但业务方真正关心的是对话文本里的“产品型号”和“行业术语”。这批数据量不大却极其考验标注的一致性如果靠传统方式先批量标注再统一训练光是整理标注指南、协调标注口径、处理低质量标注就要耗掉大半时间。这个项目最终确定用主动学习Active Learning驱动的思路基于 Prodigy 标注工具完成新实体的训练闭环。主动学习的核心思想是让模型主动挑出“最不确定”或“最具代表性”的样本交给人工标注而不是让人对着随机样本盲目标注。这样做的好处在于能用更少的标注样本换来更好的模型效果——对那种“标注成本高、业务时间紧、数据量不大”的项目尤其适用。在具体实现上我用 Prodigy 作为标注前台配合自定义 recipe 做样本筛选和标注流程控制底层模型基于 spaCy 训练通过 multi-task 的方式把原有实体类型和新实体类型一起纳入模型更新。整个项目从初始化数据到产出新版模型实际标注样本量约 2000 条但模型在新实体上的 F1 值达到了 0.86 以上较之前用 8000 条随机样本盲标训练出来的结果还高出不少。这个项目非常适合那些正在做垂直领域信息抽取、客服对话分析、文档结构化等工作的团队参考也适合个人开发者想快速搭建一套低成本、可迭代的实体识别方案。2. 核心思路拆解为什么非要用主动学习不可2.1 传统标注方式的三个隐性成本在展开实操之前先聊聊为什么我坚持不用“先批量标注、后一次性训练”的常规路径。你可能觉得主动学习无非就是边标边训多几轮迭代而已但传统方式真正的成本往往不在标注本身而是藏在三个很隐蔽的地方。第一是样本分布偏差。业务数据里真正有价值的实体——比如某个新型号的产品名——可能只出现在 5% 的文本里随机采样 8000 条真正命中目标实体类型的可能不足 300 条。你以为标注了 8000 条实际上模型学到的新实体类别样本少得可怜训练出来的模型自然对低频实体无感。第二是标注一致性难以保证。人工标注持续两小时之后注意力下降带来的漂移非常明显同一个实体前面标成 PRODUCT后面就标成了 ORG。这种标签噪声传给模型之后模型就会学到错误的边界判断。第三是模型不知道边界在哪。传统方式下标注完成之前模型完全没有参与标注人员也不清楚模型到底在哪些样本上会犯错只能凭感觉补样本。主动学习把这三块成本一次性压缩了。模型在每次迭代之后会给出不确定度排序标注界面里优先展示的是那些模型“拿不准”的样本标注人员花同样的时间产出的却是信息密度高得多的训练信号。而标注口径漂移的问题也因为在循环中反复校准而得到缓解。2.2 主动学习三要素的落地形态主动学习在 Prodigy 里的实现其实不是一套复杂的算法框架而是三个环节的有序组合。第一个环节是查询策略也就是如何选出“最值得标的样本”。Prodigy 的 ner.teach 等 recipe 背后用的是不确定性采样它会计算模型对样本预测的置信度然后把低置信度的样本优先推送。实践中我不只依赖内部默认的采样逻辑还会在自定义 recipe 里结合“模型分歧度”——用两个不同随机种子的模型对同一批未标注数据做预测挑出两个模型预测结果不一致的样本。这个策略对发现标注盲区特别有效。第二个环节是人工标注虽然是人在标注但 Prodigy 提供了一个非常关键的机制叫做“标注动作引导”。在标注界面中系统会把模型当前的预测结果当作预标注直接呈现标注人员只需要修改有问题的部分而不是从零开始画实体边界。这样既提升了标注速度也保证了标注结果和模型预测在格式上的一致性。第三个环节是模型更新。每次标注会话结束通过训练脚本对模型做增量更新把新标注的样本和原有样本混合在一起重新训练。注意这里的“增量更新”不是简单地只拿新数据训练而是要把旧数据回放replay否则模型很容易出现灾难性遗忘——学会新实体之后老实体反而不会认了。这一点在后面实操部分我会详细展开。2.3 Prodigy 在闭环里的位置Prodigy 在整套流程里扮演的是“标注流控制器”和“数据、模型之间的铰链”这两个角色。它不替代你用 spaCy 或 PyTorch 写训练逻辑但它帮你把模型预测、样本展示、人工标注、数据导出这几件事组合成了一个可以不断转动的飞轮。有一个点很多人容易忽略Prodigy 的 recipe 机制非常灵活它不是只能跑官方预置的 ner.teach、ner.make 这些现成命令而是可以写自己的 recipe 来调整采样逻辑、数据过滤规则甚至标注界面。比如我在这个项目里就自定义了一个 recipe专门过滤掉“已经标过但模型已经记得很牢”的高置信度样本避免重复劳动。3. 环境准备与初始配置3.1 安装与依赖选型项目第一步自然是把环境搭好。我用的 Python 版本是 3.10虚拟环境通过 conda 管理。Prodigy 本身是商业授权的标注工具安装时需要用官方渠道提供的授权文件激活之后就可以通过 pip 安装。依赖方面核心就两个spacy 和 prodigy。版本匹配比较重要我建议的版本组合是 spaCy 3.5.x 配 Prodigy 1.11.x这套组合在训练 pipeline 和 recipe 兼容性上比较稳定。如果版本不对很可能遇到 recipe 加载失败或者向量维度不匹配之类的坑。conda create -n prodigy-ner python3.10 conda activate prodigy-ner pip install -U pip setuptools wheel pip install spacy3.5.4 pip install prodigy1.11.6这里有一个细节值得说安装完成后先跑一下prodigy stats命令确认安装正常同时检查 spaCy 模型是否能正常加载。我在调试过程中遇到过一次非常隐蔽的问题——conda 环境里残留了一个旧版本的 spaCy导致 Prodigy recipe 加载时一直在报TypeError浪费了不少时间。如果装完以后发现行为异常第一时间检查环境中是否有多个 spaCy 版本残留。3.2 初始模型的选择新实体训练的第一个关键决策是直接从一个预训练模型开始还是用 blank 模型从零练起我的建议是如果你的目标实体类型在预训练模型里已有部分语义基础——比如你要扩展 PERSON、ORG 之外的人名变体那么用预训练模型做起点收益明显。但如果你的实体类型是高度业务化的比如“产品型号”“行业黑话”预训练模型里根本没有对应知识反而会因为原有权重干扰导致新实体学习不稳定。这个项目里我选择的是zh_core_web_trf作为基础模型原因是目标文本以中文为主且部分实体类型虽然新但周围的语言结构比如量词、动词搭配跟已有实体类型有重叠预训练模型的上下文表示能给新实体的边界判断提供不错的先验。如果是从零开始则建议用zh_core_web_sm或者直接spacy.blank(zh)搭配自定义的 word vectors 或 char n-gram 特征。不过从我试过的经验来看blank 模型从零起步不仅需要更多标注样本而且在早期迭代中实体边界会非常不稳定标注界面上预标注几乎不可用标注人员会非常痛苦。3.3 标注指南与种子样本整理很多人觉得标注就是画框框其实标注指南的颗粒度直接决定模型上限。我在这次项目里先花了半天时间整理了一份内部标注规范内容包括实体定义什么算产品型号什么不算。比如型号里包含数字和字母组合时哪些要连在一起标哪些可以分开。边界规则修饰词是否包含在实体内部。“新款X-100”应该标成 X-100 还是新款X-100我们定的是不包含修饰词。歧义处理遇到“A公司推出B型号”这种句子公司和型号在同一上下文出现如何防止标注人员把公司名误标进产品型号里。这份规范不需要写得多精美但必须具体到能直接执行。之后我整理了大约 200 条种子数据覆盖了几个主要的实体上下文模板用于第一轮模型热启动。种子数据本身不追求数量追求的是多样性确保各类边界情况都有体现。4. Recipe 设计与主动学习循环的搭建4.1 自定义 sample 逻辑Prodigy 内置的ner.teach已经带了不确定性采样但直接用它有一个问题它默认只对“模型当前认为的边界附近”做采样容易陷入局部不确定区。比如模型已经对“X-100”这种形态学特征显著的实体很确定之后它就不太会推送这类样本了但实际业务里“X-100”和“X2000”这种变体你依然需要标注数据来覆盖。所以我写了一个自定义 recipe核心思路是“不确定度采样 多样性惩罚”。具体实现是先用当前模型对未标注样本做预测计算每个样本的模型置信度然后对置信度低于阈值0.6的样本按向量相似度去重保证推送出来的样本在语义空间上分布足够分散。这样既保留了主动学习“优先标不确定样本”的核心逻辑又避免了样本扎堆。import prodigy from prodigy.components.loaders import JSONL from prodigy.components.preprocess import add_label from spacy.util import minibatch import numpy as np prodigy.recipe(ner.teach_diverse) def ner_teach_diverse(dataset, source, label, model): nlp spacy.load(model) def get_uncertainty(eg): doc nlp(eg[text]) if not doc.ents: return 0.0 # 用 top entity 的 score 近似整体置信度 return max(ent._.get(score, 0.0) if hasattr(ent._, get) else 0.0 for ent in doc.ents) def get_vector(eg): return nlp(eg[text]).vector # 加载未标注集合并过滤 def select_stream(): examples list(JSONL(source)) uncertain [eg for eg in examples if get_uncertainty(eg) 0.6] uncertain.sort(keylambda eg: get_uncertainty(eg)) # 向量去重采样 chosen [] seen_vecs [] for eg in uncertain: vec get_vector(eg) if len(seen_vecs) 0 or min( np.dot(vec, v) / (np.linalg.norm(vec) * np.linalg.norm(v) 1e-8) for v in seen_vecs ) 0.85: chosen.append(eg) seen_vecs.append(vec) if len(chosen) 500: break return chosen stream select_stream() return { dataset: dataset, stream: stream, view_id: ner_manual, config: {labels: [label]}, }这段代码里get_uncertainty用每个样本中实体最大置信度作为整体置信度。这个近似的逻辑是如果一个样本里模型最有把握的实体都不够确信那这个样本大概率是需要标注的。当然这只是一个粗糙的近似如果你需要更精确的查询策略可以用模型输出的 token-level 概率做聚合但在早中期迭代里这个近似已经够用。4.2 标注会话的启动与管理写好 recipe 之后启动标注的命令如下prodigy ner.teach_diverse my_new_entities ./seed_data.jsonl --label PRODUCT --model zh_core_web_trf这里有几个参数值得说明。--label指定当前标注的实体类型--model用于加载预训练模型。如果要一次性标多个实体类型可以在 recipe 里用 label 列表但我在这个项目里选择了分类型标注每个类型单独跑一轮标注会话。原因是不同实体类型的边界准则差异较大混在一起标容易让标注人员思维来回切换降低一致性。会话启动之后Prodigy 会在浏览器里打开标注界面。操作非常简单鼠标划选实体边界选择对应类型按确认键。如果预标注正确直接按确认即可。边标的时候边看右下角的进度统计当模型推送的样本越来越集中到某几个类型上时说明当前实体类型的覆盖已经趋于饱和可以结束这轮会话。实际标注时我发现一个问题Prodigy 默认保存的标注结果包含accept和reject两个动作对应的样本但很多人只把accept当训练数据用忽略了reject的价值。其实reject样本对训练模型避免假阳性非常有帮助我建议导出数据时不要过滤掉而是保留作为负样本。4.3 训练脚本的调参与策略标注完成后用prodigy train直接训练也可以但我更推荐把数据导出来用 spaCy 原生训练脚本做精细化控制。导出的数据文件是一个 JSONL每条记录包含text、spans和accept字段。通过prodigy db-out导出的格式可以直接喂给 spaCy 的训练脚本prodigy db-out my_new_entities ./data_round1.jsonl然后我写了一个训练脚本里面有几个值得分享的参数选择逻辑。import spacy from spacy.training import Corpus from spacy.util import minibatch, compounding import random def train_ner(train_data, dev_data, base_model, output_dir, n_iter30): nlp spacy.load(base_model) # 保留原有实体类型让模型同时学习旧实体和新实体 if ner in nlp.pipe_names: ner nlp.get_pipe(ner) else: ner nlp.add_pipe(ner, lastTrue) # 确保旧实体类型不在训练中丢失 for label in [PERSON, ORG, GPE, PRODUCT]: ner.add_label(label) # 将训练数据分包每轮迭代都做样本混合 train_corpus Corpus().from_jsonl(train_data) dev_corpus Corpus().from_jsonl(dev_data) optimizer nlp.resume_training() optimizer.learn_rate 0.001 optimizer.beta1 0.9 optimizer.beta2 0.999 optimizer.grad_clip 5.0 for itn in range(n_iter): random.shuffle(train_corpus) losses {} batches minibatch(train_corpus, sizecompounding(4.0, 64.0, 1.1)) for batch in batches: for text, annotations in batch: doc nlp.make_doc(text) example spacy.training.Example.from_dict(doc, annotations) nlp.update([example], sgdoptimizer, losseslosses) print(fIteration {itn}: {losses}) # 每5轮在dev集上评估一次 if itn % 5 0: score nlp.evaluate(dev_corpus) print(fDev accuracy: {score[ents_p]:.3f}, {score[ents_r]:.3f}, {score[ents_f]:.3f}) # 简单早停连续两次F1下降就停止 if hasattr(train_ner, prev_f1) and score[ents_f] train_ner.prev_f1: break train_ner.prev_f1 score[ents_f] nlp.to_disk(output_dir)这个脚本里有几个细节值得展开说。第一resume_training()与begin_training()的区别。resume_training会保留模型已有的权重只调整 NER 层begin_training则从零开始。对新实体训练来说几乎总是应该用resume_training除非你已经明确要推翻重来。第二迭代次数 30 轮看起来不多但因为主动学习每轮注入的数据量不大每次 200-500 条训练轮数过多反而容易在小样本上过拟合。我建议先用小的n_iter试跑一轮观察 dev F1 的上升趋势再决定是否加大轮数。第三ner.add_label把原有实体类型重新加进去是必须的即使加载的模型已经包含这些 label。这可以确保优化器在更新时不会因为标签缺失而破坏原有分类器的参数结构。5. 迭代训练与效果评估5.1 多轮迭代的数据流动主动学习的一个重要特征是数据不是一次性静止的而是随着轮次动态增长。我这次项目的迭代流程大致如下第 1 轮种子数据 200 条模型快速学会最常见的实体写法。第 2 轮模型推送 300 条不确定样本主要集中在长尾边界样本比如带修饰词的复合实体、跨行文本中的实体。第 3 轮加入负样本错误历史标注修正150 条重点解决假阳性问题。第 4 轮模型推送的样本已经很难再让 F1 明显提升识别精度进入平台期。每一轮的数据都会与上一轮合并确保训练集是累积的而不是替代的。这里有一个容易被忽视的坑如果你每轮只拿新标注的几百条数据做训练模型会逐渐丢失对早期样本的记忆。合并训练看起来简单却是主动学习训练中最重要的“保险丝”。5.2 评估指标的选取与分析在实体识别任务里只看整体 F1 是不够的。我建议至少从两个维度拆解评估按实体类型拆解分别看新旧实体的 Precision、Recall、F1。如果新实体 F1 上升但旧实体 F1 下降说明灾难性遗忘已经开始需要加大旧数据回放比例。按实体长度拆解短实体2-4 字和长实体10 字以上的错误模式完全不同。短实体问题通常出在边界混淆长实体问题往往在内部漏标。下面是我在训练过程中记录的一组典型指标变化轮次新实体 F1旧实体 F1假阳性样例数备注第 1 轮0.620.9134新实体大量漏召回第 2 轮0.780.9221新增多样本后召回提升明显第 3 轮0.830.9212负样本缓解假阳性第 4 轮0.860.939趋于平台期表格里最值得关注的是假阳性样例数的变化。第 1 轮时模型会把“某型号产品的中文别名”误识别为人名这个 bug 如果只看 F1 是看不出来的需要打开模型预测结果逐条检查。所以我每次迭代结束都会随机抽出 50 条预测结果人工过目作为除指标之外的另一个质量关卡。5.3 针对业务场景的效果调优在指标达到可接受范围之后我还会做一轮专门针对业务场景的调优。这个项目的实际使用场景是客服对话文本特点是口语化严重、指代多样、经常出现“那个X-100”“你们家的新型号”这类表达。模型虽然在新实体上 F1 不低但在这类口语场景里还是暴露出一个典型问题人称代词“它”和“她”会被误标为 PRODUCT原因是上下文里频繁出现产品词模型被带偏了。解决思路有两种一种是在标注数据里特意补充这类“混淆样本”让模型看到更多“代词 产品上下文”的负例另一种是在 post-processing 里加规则过滤比如纯代词不可能是产品实体。我最后选择了两者结合因为光靠规则过滤会损失泛化能力光靠加数据见效又慢。先加规则兜底挡住 80% 的错误再用数据训练让模型逐渐学会自己判断。这种“规则兜底 数据微调”的组合思路在主动学习项目里其实很有用。你可以在标注循环中加入一个exclude函数把满足规则条件的样本直接过滤掉不参与采样从而把人力集中在更有价值的边界样本上。6. 常见问题与排查技巧实录主动学习项目里的坑不少我在这个项目里也踩了一些挑几个典型问题整理成表方便你对照排查。问题现象可能原因排查思路与解法标注界面一直不推送新样本未标注样本被 recipe 里的过滤条件全部排除了检查阈值是否过于严格把置信度阈值放宽或关闭向量去重模型训练后旧实体完全不识别了使用了 begin_training 而不是 resume_training或者训练数据里只有新实体改用 resume_training并在训练脚本里显式添加旧实体 label模型在 dev 集上 F1 很高但业务数据上一团糟训练数据的采样分布与业务分布不一致检查标注数据来源确保种子数据和主动学习推送到样本的分布尽量接近真实场景训练过程越来越慢loss 波动很大学习率设置过大或 batch size 过小把 learn_rate 降到 0.0005batch size 调大或者用 compounding 方式逐步增大同一实体在相邻样本里标法不一致标注指南对边界情况定义不清晰重新整理边界样例把它们单独收集并在标注会话中多次推送Prodigy 导出数据后训练时标签报错标注结果里存在空 spans 或非法字符写一个简单的数据校验脚本过滤掉没有实体标注的样本和超长实体这些问题的共同点是表面上是工具或代码出错深层原因往往出在数据流设计上。主动学习是一个闭环任何一个环节的“手感不对”都可能让整个飞轮转不起来。再分享一个排查技巧每次迭代训练完之后保存一份模型预测结果到独立文件跟上一轮的预测做 diff。这个 diff 能直接告诉你模型这轮“学到了什么、忘了什么”。如果 diff 显示大量样本的实体类型在新旧版本之间翻转说明模型非常不稳定通常需要降低学习率并增加训练轮数来稳定收敛。7. 几点实际操作后的体会这个项目做完我自己对主动学习驱动的新实体训练有了更深的理解。以前总觉得标注是体力活模型才是技术活但实际跑下来发现标注流程设计的好坏对模型效果的影响一点不比模型结构小。主动学习真正厉害的地方不在于它帮你省了多少标注量而在于它让模型的“无知”和“疑点”变成了可操作的标注优先级人和模型在每一轮迭代里都在互相反馈。我的体会是不要一开始就想着把所有东西自动化、智能化先把一个 500 条样本的小闭环跑通理解数据是怎么流动的模型是怎么反馈的再逐步扩大规模。很多团队追求花哨的采样策略和复杂的模型结构反而忽略了最基础的训练数据质量和标注一致性。先保证闭环转得起来再优化每一圈的效率。最后再分享一个小技巧在你的 recipe 或训练脚本里加入数据统计日志把每一轮的实体类型分布、实体长度分布、置信度分布都记录下来。这些日志在后续排查问题时非常有用。很多问题不是你当时能发现的而是在几次迭代之后回头看数据分布时才恍然大悟。把日志做扎实等于给自己的模型装了一个黑匣子任何时候都能回放和分析。
延伸阅读

更多相关文章

2026/10/20 9:17:08

抽样调查课后题全攻略:五种设计公式、陷阱与R/Python验证

简介:《抽样调查理论与方法》(第二版)课后答案,面向统计学、数学类专业学生以及需要运用抽样理论进行数据推断的学习者。文档依据冯士雍教材章节整理了参考答案,涵盖随机原则、概率估计、总体与样本、抽样误差与置信区…

2026/10/20 9:17:08

UVM组件家族全解析:从组件树到核心角色职责

最早接触UVM那会儿,最让我头疼的不是sequence,也不是phase,而是面对一整个components目录时的那种茫然。五六个文件来回引用,每个类里都是一堆宏和括号,我对着代码库看了一下午,还是分不清谁在干活、谁在管…

2026/10/20 13:34:02

一文搞懂 React Context Mode:跨组件状态共享与性能优化实战

做前端时间久了,你会发现一个挺有意思的现象:凡是涉及跨组件共享状态的地方,绕来绕去最后都会落在同一个话题上——context-mode。上下文模式,说简单也简单,无非是造一个 Provider,把数据往下一抛&#xff…

2026/10/20 13:34:02

大模型推理调优实战:从指标拆解到关键参数配置

做推理服务的人,一定经历过这种局面:模型在测试环境怎么压都稳,一上生产并发稍微上来就超时;或者本地单条请求响应快得飞起,线上看着GPU利用率不到一半,吞吐却死活上不去。更恼火的是,网上到处是…

2026/10/20 13:34:02

零基础系统学习网络安全:Web安全、逆向工程与漏洞挖掘路线图

网络安全这行,我算是从零基础一步步摸爬滚打过来的。早些年想学Web安全,网上资料东一块西一块,今天看个注入教程,明天又去研究工具用法,学了大半年还是云里雾里。后来带过不少新人,发现大家踩的坑都差不多—…

2026/10/20 13:34:02

N_m3u8DL-RE 完整指南:如何下载与录制加密流媒体视频

N_m3u8DL-RE 完整指南:如何下载与录制加密流媒体视频 【免费下载链接】N_m3u8DL-RE Cross-Platform, modern and powerful stream downloader for MPD/M3U8/ISM. English/简体中文/繁體中文. 项目地址: https://gitcode.com/GitHub_Trending/nm3/N_m3u8DL-RE …

2026/10/20 13:34:02

MacOS上使用Ollama运行Qweb3:8b本地大模型实践指南

先交代一下背景。我最近在MacOS上折腾Ollama,把Qweb3:8b模型跑了起来,整个流程从下载模型到调参到实际用起来,踩了不少坑,也摸出了一些门道。这篇文章不打算写成说明书式的流水账,而是把我实际操作的完整路径、每一步背…

2026/10/20 13:29:01

人工势场法避障实战:从仿真到实车的参数整定与避坑指南

简介:这份资源面向无人车路径规划方向的学习者与开发者,聚焦基于人工势场法(APF)的避障路径规划实现,适合已具备一定MATLAB基础、希望理解算法落地细节的中高级读者。压缩包共4个文件,均为m脚本文件&#x…

2026/10/19 18:06:35

金融AI落地指南:从调研报告到技术选型与工程实践

简介:这份PDF报告聚焦2025年金融服务业中国AI现状与趋势,面向金融科技从业者、AI战略规划人员及关注行业数字化转型的研究者,帮助读者把握生成式AI在银行、支付、交易等场景中的落地路径与投资方向。资源包内含1个PDF文件,大小约1…

2026/10/20 1:05:37

单片机C运行时从启动到多任务:中断安全与共享资源冲突

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:05:37

GCC编译四步详解:预处理、编译、汇编、链接实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:05:37

FIW线是什么?FEP注塑线与普通氟塑料线的区别及应用指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑