I had Gemini train its own replacement for $9

发布时间 2026-09-17 13 天前
来源 Hacker News
字数 4,002 字
查看原文

AI智能总结

为替代付费调用 Gemini 3.1 Pro 做厨刀评论命名实体识别,作者用 Gemini 给 4,290 条 Reddit 评论一次性标注品牌、型号和钢材,花费 9 美元,再用这些数据微调 GLiNER large v2.5(约 4.59 亿参数)。

我喜欢烹饪,不知从何时起这变成了一种对高端厨师刀的痴迷。所以我会抓取人们讨论厨刀的 Reddit 帖子,把里面提到的每一个品牌、型号和钢材都提取出来,看看大家究竟在买什么、争论什么。

从文本中识别出产品名称是一项叫做命名实体识别(named-entity recognition)的工作,小模型做这件事已经十年了。我之前一直在用 Gemini 3.1 Pro 做这件事,每条评论都要调用一次付费 API。虽然是大材小用,但确实管用:比如从「picked up a Mazaki in white #2, way better than my old Fibrox」这条评论里,它能正确返回 Mazaki 是品牌、Fibrox 是型号、white #2 是钢材,其他一概不返回。但抓取器会拉取每一条新评论,所以费用会随着人们发帖量水涨船高,而控制成本的唯一办法就是跳过某些评论。

最显而易见的替代方案是一个叫 GLiNER 的开源 NER 模型,它可以零样本(zero-shot)运行,把成本降到了零,但相对 Gemini 的答案,准确率也降到了大约 0.65 的 F1。这个差距就是本文接下来要讨论的核心问题:能否让 Gemini 给 4,290 条评论标一次注,然后教 GLiNER 把这道差距补上?

  • 做了什么: 微调(Fine-tune)了 GLiNER large v2.5(4.59 亿参数),用 Gemini 3.1 Pro 一次性产出的标注,识别 Reddit 评论里的品牌、型号和材料。
  • 为什么做: 零样本 GLiNER 的 F1 大约 0.65(估算值)。Gemini 表现不错,但只要抓取器在跑,每条评论都要计费。
  • 方法: 让 Gemini 输出字符串,而不是字符偏移量。偏移量在代码里计算。把没有提到产品的评论作为负样本加入。在第二轮训练前锁定 225 条评论作为验证集,之后不再触碰。
  • 遇到的问题: 十次训练中有五次没能产出可用模型。其中三次因为配置失败,两次因为一个叫 words_mask 的张量(tensor)——我按填充注意力掩码(attention mask)的方式去填了它。
  • 结果: 在 Tesla T4 上跑了 24 分钟后,相对 Gemini 的标注取得了 0.83 的 F1。9 美元的标注费、约 2.50 美元的 GPU 时长,外加几天的调试时间。

我打算怎么做

计划分三步:让 Gemini 给几千条 Reddit 评论标一次注,标出每一条品牌、型号和钢材;用这些标注训练 GLiNER;之后在自己的机器上用 GLiNER 处理每一条新评论,不再调用 Gemini。

Gemini 给 4,290 条评论做了标注,花了 9 美元,平均每条 0.0021 美元。也就是说,只要后续评论长度差不多、跑在我自己已有的 GPU 上,训练出来的模型大约在第 4,291 条评论时就能回本。成功的标准很简单:在模型从未见过的 225 条评论上,它标出的词和 Gemini 标出的词重合度有多高。关于本文每一个分数都要注意一件事:没有人手工核对过 Gemini 的标注,所以模型是相对 Gemini 打分,而不是相对真实答案。Gemini 标错的地方,模型复刻了错误算对,纠正了错误反而算错。

方法

Gemini 通过 OpenRouter 以 temperature 0 给评论做标注,耗时 25 分钟。最关键的一个提示词决策是:永远不要让模型返回字符偏移量。它数字符数得很糟,返回的区间经常偏两到三个位置。提示词里只要求返回精确的子字符串和标签,偏移量交给 TypeScript 来找。如果字符串不在评论里,这条实体就会被丢弃并记录日志。

// 模型返回字符串,偏移量由代码计算。
{ "entities": [
    { "text": "Benchmade", "label": "knife brand" },
    { "text": "940", "label": "knife model" },
    { "text": "S30V", "label": "knife steel" }
] }

产品名里充斥着通用分词器(tokenizer)会切开的标点,所以用了一段正则把 VG-10、CPM-154 和 1.4116 这类词保留为整体,其余每个非空格字符单独作为一个 token(词元)。仍然没落在 token 边界上的区间会被直接丢弃,而不是猜测。训练集中大约 30% 是含有已知误报触发词(gyuto、carbon、handle、patina)但没有产品的评论,标注为空。第二轮训练前,我预留了 225 条评论作为验证集,之后再没动过。训练在 Modal 平台上的 Tesla T4 上用 HF Trainer 跑。

per_device_train_batch_size = 2
gradient_accumulation_steps = 8
learning_rate = 1e-5
threshold = 0.45

出过什么问题

有五次训练,模型什么也没学到。前三次是配置出错,任何用 HF Trainer 跑 GLiNER 的人一个下午都会踩到这些坑。

训练轮次 问题 解决方法
1 GLiNER 默认的 max_steps=10000 覆盖了 num_train_epochs=3;实际跑了 39 个 epoch 显式设置 max_steps
2 启用 load_best_model_at_end 但没有设置 eval_strategy 会报错 设置 eval_strategy="steps"
3 Trainer 保存的 state-dict 键缺少 GLiNER 加载器期望的「model.」前缀 保存时把前缀加回去
4 负样本缺少 ner_labels 在每个样本上设置标签列表
4–5 words_mask 被构造成二值掩码;损失值平坦地停留在 70–130 输出递增的词索引

第 4 和第 5 轮是代价最大的两次。GLiNER 的 tokenize_inputs 在处理出错的 Reddit 表情符号时会崩溃,所以我打过补丁,而补丁要填充一个叫 words_mask 的张量。它挨着 attention_mask,形状一模一样,我过去构造的所有注意力掩码都是真实 token 为 1、填充(padding)为 0。我就这么填了。从名字到形状都没看出来它和注意力掩码有任何区别。

训练顺利跑完了。损失值从 130 左右开始,漂移到大约 70 就停在那里。没有崩溃、没有警告、没有 NaN,梯度大小正常,checkpoint 按时保存,验证集 F1 接近零。我先怀疑标签列表,因为第 4 轮也确实存在负样本没设置标签的问题。修好之后重跑,得到了同样的平坦损失。第 5 轮唯一出错的只有 words_mask,一个我从来没仔细看过的张量。

我是怎么修好的

我去读 GLiNER 的训练循环代码,而不是看它的文档字符串。words_mask 不是掩码,而是词索引:特殊 token、prompt 和填充 token 为 0,然后从 1、2、3 开始,每个真实词的第一个子 token 占一个索引。区间打分头(span-scoring head)用它把子 token 重新汇聚成词。如果全填 1,效果等于告诉模型整条评论就是「一个词」,模型要在一个巨大 token 里找品牌和材料区间。它做不到,损失自然就平了;而损失平坦时根本看不出是哪一路输入出了问题。

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

索引修正之后,第 6 轮一次就跑通了。之后就是相对锁定的验证集做调参。2.09 亿参数的中等模型达到了 0.800;4.59 亿参数的大型模型在 T4 上只能靠梯度累积才能放下,达到了 0.83。把对抗性负样本增加到十倍(510 个而不是 51 个)后,F1 反而掉到了 0.799,所以第 10 轮又回到了 51 个。把全局阈值改成每个类别一个阈值,材料召回率从 0.787 提升到了 0.911,因为 MagnaCut、S35VN 和 HAP40 这类钢材名的置信度比品牌低,单一阈值会把它们一起丢掉。每个大模型训练都在第 2 个 epoch 见底,之后就开始过拟合;2,000 条样本的规模下这是数据量问题,早停(early stopping)就是解法。

我学到了什么

它成功了。模型在本地运行,在从未见过的评论上以 0.83 的 F1 匹配了 Gemini 的标注,并且知道「carbon steel」是一个类别而不是某一种钢材,也知道 PM2 有时指的是 Spyderco Paramilitary 2、有时只是两个字母。早期有一轮在随机切分上拿到了 0.879 的分数。我不把它算作最终结果:在随机切分下,我之前归咎于自己改动的两次 F1 下跌,最后发现其实是验证集里落进了哪些评论造成的。

账面上看,这个项目的成本还不到一顿午饭:9 美元的标注费,2.50 美元的 GPU 费用。而我实际付出的代价,是花在一个张量上的好几天——它通过了代码里所有检查,却仍然是错的。我觉得这种浪费的时间在小规模微调任务里是常态。模型和数据很少是问题;夹在它们中间的代码会出错,而错误输入张量导致的平坦损失,和难数据导致的平坦损失看起来一模一样。如果让我在「更好的标签集」和「对每个我手工构造的张量加一条断言(assert)」之间选,我会选断言。

这个模型驱动着 New Knife Day,用来追踪 Reddit 上的人在买什么刀、争论什么刀。那边的刀类侧文章里有完整的运行日志。两个链接都放在下面。

一览

问题在 Reddit 评论里找出品牌、型号和材料名称,同时跳过周围无关的泛词,又不能每条评论都花钱调用大语言模型(LLM)。方法让 Gemini 给评论标一次注,然后基于这些标注微调 GLiNER large v2.5(一个 DeBERTa-v3-large 编码器),并在本地运行。结果在训练前锁定的 225 条评论验证集上取得 0.83 F1;按类别设阈值后材料召回率达到 0.911。成本Gemini 标注费 9 美元,加上跨十次训练约 2.50 美元的 T4 时长。技术栈抓取和标注部分用 TypeScript 和 MongoDB;训练部分在 Modal 上用 Python 和 PyTorch;模型服务用 FastAPI。

New Knife Day,本模型所运行的网站 →

knife.day 上的刀类侧文章,含完整运行日志 →

个人网站。观点仅代表本人,与我的雇主无关,也未获得雇主认可。