Workshop

实战工坊:花 9 美元让 Gemini 标注数据,把 459M 的 GLiNER 微调成能替代它的本地 NER

DOC
N°299
DATE
Sep 18, 2026
READ
7 min read
TAGS
NER, GLiNER, FineTuning, KnowledgeDistillation, Gemini, HuggingFaceTrainer

把一个每请求都调用 API 的功能改成自托管小模型,最大的阻力从来不是训练本身,而是标注。尤其当你的任务看起来「很简单」——比如从文本里挑出产品名——却不得不为每一次推理付一次费用。

这个项目来自一个刀具爱好者的真实需求:他爬取 Reddit 上讨论厨刀的帖子,想抽出每条评论里提到的品牌、型号和钢材,看看什么在被买、什么在被争论。

抽取产品名这件事叫命名实体识别,小模型已经做了十年。但他最初用的是 Gemini 3.1 Pro,一条评论一次付费调用。效果确实好:从「picked up a Mazaki in white #2, way better than my old Fibrox」里,它能准确返回 Mazaki 是品牌、Fibrox 是型号、white #2 是钢材,不多不少。问题是爬虫会持续抓取新评论,账单就跟着发帖量上涨,唯一能控制成本的手段是跳过评论。

显然的替代方案是把开源 NER 模型 GLiNER 零样本跑起来:成本归零,但准确率掉到约 0.65 F1。这个差距就是整件事的主题——能不能让 Gemini 标一次数据,把 GLiNER 教会,从此不再调用 Gemini?

一、先算清楚这笔账

方案只有三步,逻辑非常朴素:

  1. 让 Gemini 把几千条评论标一遍,标出每个品牌、型号、钢材;
  2. 用这些标签微调 GLiNER;
  3. 之后所有新评论都在自己机器上跑,不再调用 Gemini。

结果是 Gemini 用 9 美元标了 4290 条评论,折合每条 0.0021 美元。这意味着只要后续处理的评论超过约 4291 条,这笔投资就回本——前提是后续评论长度相近,而且推理跑在你本来就有的 GPU 上。

最终指标:在一个模型从未见过的 225 条验证集上,微调后的 GLiNER 达到 0.83 F1

这里有一个必须提前说清的坑,它影响后文所有数字的可信度:没有人手工核对过 Gemini 的标签。所以模型的得分衡量的是「与 Gemini 的一致率」,不是「与真值的一致率」。Gemini 标错的地方,模型照抄算对,模型把它改对了反而算错。这个口径在自举式蒸馏里无法避免,但必须明确说出来,否则数字会被误读。

二、prompt 设计:要字符串,不要偏移

整个流程里最重要的一个 prompt 决策只有一句话:永远不要向模型索要字符偏移量。

原因很直接:语言模型数不清字符。它会返回偏两三个位置的 span,而且这种错误不会报错,只会静默地指向错误的文本区间。正确做法是让模型只给出「原文中确实出现的精确子串」加上类别标签,偏移量交给代码计算:

{
  "entities": [
    { "text": "Benchmade", "label": "knife brand" },
    { "text": "940", "label": "knife model" },
    { "text": "S30V", "label": "knife steel" }
  ]
}

TypeScript 拿到这些子串后在原始评论文本里查找位置。如果某个子串在原文中根本不存在,就丢弃这个实体并写一条日志——这条日志很有价值,它是模型幻觉的直接计数器。

标注通过 OpenRouter 调用,temperature 设为 0,25 分钟跑完 4290 条。

三、分词:产品名和通用分词器天生不合

还有一个容易被忽略但会持续污染数据的细节:产品名里全是标点。

VG-10、CPM-154、1.4116 这些字符串在通用分词器眼里会被切成碎片。如果让模型返回的 span 落在 token 边界之外,训练时就没法对齐。处理办法是用正则先做一层保护:把已知模式的钢号整体保留,其余每个非空格字符各自成为一个 token。如果某个 span 仍然跨不到 token 边界,就丢弃它,而不是猜一个最近的边界。 宁可少一条样本,也不要一条错的对齐。

四、负样本:模型必须学会「这里什么都没有」

只教模型「什么该标」是不够的,还必须教它「什么不该标」。

作者的做法是把包含已知假阳性触发词、但不含任何真实产品的评论作为空标签负样本加进去——触发词包括 gyuto、carbon、handle、patina 这类刀具圈高频但对本任务无意义的词。这类负样本大约占训练集的 30%。

30% 这个比例不是随便定的。它反映的是真实数据的分布:大部分评论在聊使用体验、开箱感受、保养方法,真正在提具体产品名的只是少数。

第二批运行时,作者提前划出 225 条评论锁成验证集,训练全程再也没碰过它们。这一步在事后被证明是整篇里最有价值的方法论决定——早期有一次随机切分得到了 0.879 的漂亮数字,但后续复现时发现,这个 0.879 的变化其实来自验证集里混进了不同的评论,而不是来自任何一次改动。锁验证集不是流程洁癖,它是区分「模型变好了」和「恰好抽到了简单样本」的唯一手段。

五、五次失败:三个配置坑和一个张量坑

十次运行里,有五次完全没有产出可用模型。这个比例很值得警惕,因为它说明小规模微调的失败率天然很高。

前三次死在配置,而且是任何用 HF Trainer 配 GLiNER 的人都会在一个下午内撞上的三个坑:

运行出了什么问题修法
1GLiNER 默认 max_steps=10000 覆盖了 num_train_epochs=3,实际训了 39 个 epoch显式设置 max_steps
2load_best_model_at_end 没配 eval_strategy 直接抛错设置 eval_strategy="steps"
3Trainer 保存的 state-dict key 缺少 model. 前缀,GLiNER 的加载器认不出来保存时把前缀补回去
4负样本缺少 ner_labels 字段给每个样本都设好标签列表
4、5words_mask 按二值掩码构造,loss 平在 70 到 130改成递增的词索引

第四和第五次是真正昂贵的两次。

GLiNER 的 tokenize_inputs 在遇到残缺的 Reddit emoji 时会崩,作者打了补丁,而补丁需要填充一个叫 words_mask 的张量。它紧挨着 attention_mask,形状完全一样,而作者见过的每一个注意力掩码都是「真实 token 为 1,padding 为 0」。他就这么填了。

从名字和形状上,没有任何线索提示它不是掩码。

训练完整跑完了。loss 从约 130 起步,飘到约 70 就停住。没有崩溃,没有警告,没有 NaN,梯度大小正常,checkpoint 按时保存,eval F1 接近零。作者先怀疑标签列表,因为第四次运行的负样本也缺少标签配置;修完重跑,loss 一模一样。

第五次运行唯一的问题就是 words_mask——一个他从未正眼看过的张量。

修法不是调参,而是去读 GLiNER 的训练循环源码。words_mask 根本不是掩码,它是词索引:特殊符号、prompt 和 padding 位置填 0,每个真实单词的第一个 sub-token 依次填 1、2、3。span 评分头靠它把 sub-token 池化回词级别。

# 作者写的                  # GLiNER 期望的
words_mask = [1,1,1,1,1]    words_mask = [0,1,2,2,3]
                            #  [CLS] Mazaki wh ##ite #2

填成全 1,等于告诉模型整条评论是一个巨大的单词,然后要求它在这个单词内部找出品牌和钢材跨度。它找不到,所以 loss 平稳地卡住——而一个平稳的 loss 不会告诉你是哪个输入错了

索引修好之后,第六次运行一次就学会了。剩下的工作才是调参。

六、阈值和规模:两个反直觉的结论

结论一:per-class 阈值显著优于全局阈值。

钢材类别的召回率从 0.787 提升到 0.911。原因是三类实体的置信度分布天然不同:品牌名常见、模型有把握;MagnaCut、S35VN、HAP40 这些钢号罕见且模糊,打分系统性偏低。用一个全局 cutoff,要么放低阈值引入大量误报,要么放高阈值把整个钢材类别漏掉。拆成 per-class 阈值,等于承认这三类的可分性本来就不一样。

结论二:负样本不是越多越好。

作者把对抗性负样本从 51 条加到 510 条,F1 从 0.83 掉到 0.799。在约 2000 条样本的规模下,负样本比例抬高会稀释正样本密度,模型转向保守的「什么都不标」策略。第 10 次运行退回了 51 条。

同时,所有 large 模型的运行都在第 2 个 epoch 见顶,之后开始过拟合。2000 条样本就这么大容量,early stopping 是必需项而不是可选项

规模对比也很清晰:medium 版本(209M)达到 0.800,large 版本(459M)达到 0.83——但在 Tesla T4 上跑 large 必须开梯度累积。

七、成本与最后的教训

总账单:9 美元标注费,十次运行加起来约 2.50 美元的 GPU 时间。获胜的那次在 Tesla T4 上训练了 24 分钟。技术栈是 TypeScript 加 MongoDB 做爬虫与标签存储,Python 加 PyTorch 在 Modal 上训练,FastAPI 提供推理服务。

账面上这个项目比一顿午饭还便宜。真正的成本是那些天。

作者自己的总结值得原样引用:如果在「更好的标签集」和「给每一个手工构建的张量加断言」之间二选一,他会选断言。

这个判断很难被反驳。模型和数据很少是问题所在;它们之间的代码才是。而一个由错误输入张量导致的平稳 loss,和一个由数据太难导致的平稳 loss,从外部看起来完全一样。

Frequently asked questions

为什么不让模型直接输出字符偏移量?
因为大模型数不清字符。实测里 Gemini 返回的 span 位置经常偏两三个字符,而这种误差是静默的——你拿到一个看起来合法的区间,但它指向的文本是错的。正确做法是让模型只返回原文里出现的精确子串加类别,偏移量由 TypeScript 在代码里用字符串查找计算;如果子串在原文中找不到,就丢弃该实体并写日志。这样模型只负责它擅长的语义判断,代码负责它擅长的精确匹配。
words_mask 到底是什么,为什么会造成五天无解的调试?
它名字像掩码、形状和 attention_mask 完全一样,但它其实是词索引:特殊符号、prompt 与 padding 位置填 0,每个真实单词的第一个 sub-token 依次填 1、2、3。span 评分头靠它把 sub-token 池化回词级别。如果按注意力掩码的思路全填 1,模型就等于被告知整条评论只是一个巨大的单词,自然找不到跨度,loss 会稳稳停在 70 到 130 之间,没有 NaN、没有梯度异常、checkpoint 正常保存、eval F1 接近零。发现它的唯一办法是去读 GLiNER 的训练循环源码,而不是读文档字符串。
为什么加了更多对抗负样本,F1 反而下降了?
作者把对抗性负样本从 51 条加到 510 条,F1 从 0.83 掉到 0.799。原因是训练集本身只有约 2000 条样本,负样本比例一旦提高,正样本的相对密度就被稀释,模型在数据量不足的情况下开始偏向「什么都不标」这个保守策略。这是一个典型的数据规模约束:负采样比例的调优前提是总样本量足够大,样本量小的时候放大负样本只会让任务更不平衡。最终回到 51 条。
per-class 阈值相比全局阈值带来了什么收益?
钢材类别的召回率从 0.787 提升到 0.911。原因是不同类别的置信度分布差别很大:MagnaCut、S35VN、HAP40 这类钢材名称在语言模型眼里比品牌名更少见也更模糊,天然打分偏低。用一个全局 cutoff 时,要么阈值放低引入大量误报,要么阈值放高直接漏掉整个钢材类别。给每个类别单独标定阈值,等于承认三类的可分性本来就不同,这是低成本且立刻见效的改动。
这个模式能直接迁移到我自己的项目吗?
能,但有一个前提必须先算清楚:你的推理量是否足够大。这个项目的回本点是第 4291 条评论,也就是说只有持续处理数千条以上的数据,一次性标注成本才划算。如果你的总量只有几百条,直接调用前沿模型更简单也未必更贵。另一个前提是标签可以被程序化生成——这里靠 Gemini 的语义判断能力,如果你的任务需要人工标注才能得到可靠标签,那这套流程省下的只是推理成本,不是标注成本。
// next.txt ›

Some outbound links in this post are affiliate links — see disclosure.