更经济的LLM标签方法
摘要
本文描述了一种经济有效的方法,使用像gpt5.6 Luna这样的廉价LLM为提交打标签,并结合更快的朴素贝叶斯分类器,以减少基于Perl工作流程中的延迟和开支。
<p><a href="https://lobste.rs/s/9zjyto/cheaper_llm_labelling">评论</a></p>
查看缓存全文
缓存时间: 2026/09/23 14:50
# 更低成本的LLM标签标注
来源:https://entropicthoughts.com/cheaper-llm-labeling
cheaper-llm-labeling.jpg
我有一个小型项目需要将提交信息标记为“维护”或“新开发”。最直接的方法是使用一个相对便宜但功能足够强的LLM,比如GPT-5.6 Luna。我在一小批提交信息上测试了它,并手动验证了它的标注结果,发现对于整个测试集它都给出了与我相同的标签。这已经足够让我扩大应用范围了。
如果我们安装了Simon Willison的`llm`命令行工具(你应该装一个——它很棒!),我们就能通过管道从Perl中调用它,并读取其响应。我的脚本包含一个循环,会重试请求几次①。不过即使没有这些重试逻辑,实现代码也非常简单。
In\[1\]:
``
sub classify {
my ($msg) = @_;
my $pid = open2(
my $output,
my $prompt,
'llm -m openrouter/openai/gpt-5.6-luna'
);
print $prompt prompt_template($msg);
close $prompt;
# 读取全部响应并移除末尾的换行符。
chomp(my $result = do { local $/; <$output> });
waitpid($pid, 0);
return $result;
}
``
这种方法的缺点在于,我想标注大约21,000个提交,而它却要为每一个提交都调用一次LLM。由于提示词设计主要包含输入token而输出token不多(详情见附录C),这些调用在金钱上很便宜,但每次调用约1.5秒的延迟可不太有趣。②
①我遇到过模型有时不遵守输出格式指令的情况,通常重试一两次就能解决。我的循环在尝试五次后会停止,不过我不确定是否真的需要这么多次。Luna是一个比那些在遵循输出格式指令方面有困难的模型更强大的模型。
②如果我真的在意延迟,就不会用Perl去调用一个Python CLI,再由它请求OpenRouter将我的请求发送给OpenAI了,而是会直接向OpenAI发送请求。在这篇文章中,我会假装在意延迟,但这更多是为了分享这个很酷的技术。
## 使用更快的分类器进行短路处理
一些提交显然是新的开发工作,或者显然是维护工作。如果我们能用一种更原始、更快捷的方式对这些提交进行分类,只对那些更困难的情况询问LLM,那会很理想。以下是这种算法的伪代码:
In\[2\]:
``
sub labeling {
my (msg) = @_;
# 取提交信息的词频作为快速分类器的特征。
my $features = bag_of_words($msg);
# 当快速分类器有足够多的训练样本时使用它,
# 但有时会跳过它,以防止因连续出现单一标签
# 而意外地学习到概率p=0或p=1。
if ($fast_classifier->{samples} > 50 && rand() < 0.95) {
my $p = $fast_classifier->predict($features);
# 只在快速分类器非常自信时才使用其预测的标签。
return 'maintenance' if $p < 0.08;
return 'new development' if $p > 0.92;
}
# 如果样本不足或条件不满足,
# 使用LLM分类器获取标签。
my $label = classify($msg);
# 既然我们得到了一个真实的标签,就用它来训练
# 快速分类器。
$fast_classifier->refine($label, $features);
return $label;
}
``
问题是,什么对象实现了我们为快速分类器假设的接口。我们在它上面调用了两个非平凡的方法:
- `predict($features)`:返回特征被标记为新开发的预测概率。
- `refine($label, $features)`:将特征与标签的关联添加到分类器的训练集中。
一个显而易见的选择是朴素贝叶斯。其缺点是它假设特征之间相互独立,而这通常不适用于我们的情况。另一个选择是逻辑回归。
## 用于逻辑回归的流式训练
在这个项目中,我了解到逻辑回归*可以*通过每次流式输入一个标签,并使用梯度下降法来微调权重,使其更好地拟合来进行训练。我还没有完全推导出这个过程,但就这类事情而言,它并不那么复杂。③
③我的意思是,显然,是的,它很复杂。看看那个页面上的数学公式吧!但在闭式梯度下降推导的范畴内,这可能算是比较简单的一种了。最终结果根据你的计算方式,只有3-10行代码。我真希望我多年前就知道这个!它太优雅了。
In\[3\]:
``
package LR {
# 构造函数创建一个新的逻辑回归,默认学习率,
# 无正则化,以及一个空的初始权重集。
# 这三项都可以通过传递参数给构造函数来覆盖。
sub new {
my ($class, %opt) = @_;
my $self = bless {%opt}, $class;
$self->{weights} //= {};
$self->{learning} //= 0.1;
$self->{regularisation} //= 0;
$self->{samples} = 0;
return $self;
}
# 计算正标签的对数几率。
sub score {
my ($self, $features) = @_;
my $z = 0;
# 为未见过的特征默认使用零权重。
$z += ($self->{weights}{$_} // 0) * $features->{$_}
for keys %$features;
return $z;
}
# 计算正标签的概率。
sub predict {
my ($self, $features) = @_;
# 将对数几率转换为概率。
return 1 / (1 + exp(-$self->score($features)));
}
# 对一个标签执行梯度下降。
sub refine {
my ($self, $label, $features) = @_
# 对数似然对权重w_i的偏导数的系数。
# 该系数的负值用于计算对数损失的梯度。
my $dlldw =
($label eq $self->{positive} ? 1 : 0) -
$self->predict($features);
# 以学习率速度在对数损失的梯度上下降,
# 未见权重默认为零。
$self->{weights}{$_} =
($self->{weights}{$_} // 0) -
$self->{learning} * -$dlldw * $features->{$_}
for keys %$features;
# 通过将所有权重拉向零来进行正则化。
$self->{weights}{$_} -=
$self->{regularisation} * $self->{weights}{$_}
for keys %{$self->{weights}};
# 记录我们看到了更多的样本。
$self->{samples}++;
}
};
``
就是这样!一旦我们实例化这个类并将其存储在`$fast_classifier`中,之前的伪代码就变成了真实、可工作的代码。
## 所有代码都放在40行纯Perl中
让我们回顾一下发生了什么:
1. 我们有一个能正确得到标签的昂贵分类器。
2. 我们有一个廉价的分类器,它站在昂贵分类器的前面,抢先调用标签。
3. 当廉价分类器很有信心时,我们接受它的标签。
4. 否则,我们向昂贵分类器询问标签。
5. 然后我们也用这个标签来训练廉价分类器。
这一切,包括逻辑回归在内,总共花了不到40行代码。没有使用任何高级数学库。我没料到用纯Perl能这么简洁地实现。
## 改善校准
逻辑回归在充分训练后通常是校准良好的,但有些东西会破坏其校准,比如正则化(我的实例就有),或者小样本量(在过程早期也是如此)。④
④当我检查时,这表现为对正标签有0.2对数几率的偏差,以及对数几率比期望的极端10%。
这些都是整体上的小影响,我们可能本不该在意,但既然我们这么开心,不妨看看能做些什么。
改善分类模型校准的常用方法是通过*普拉特缩放*,这意味着在第一个逻辑回归的输出之上再拟合另一个逻辑回归,以消除偏差和噪声。这样做会让标签循环变得有点复杂,因为逻辑回归会在其训练数据上过拟合,所以我们需要一个独立的评判者(一个单独的数据流)来训练缩放器。我们可以通过转移,比如10%的训练样本给普拉特缩放器来实现这一点。
In\[4\]:
``
sub labeling {
my ($msg) = @_;
my $features = bag_of_words($msg);
my $z = $fast_classifier->score($features);
my $p = $platt_scaler->predict({_INTERCEPT => 1, z => $z});
# 使用普拉特缩放器样本的较低阈值,
# 因为到缩放器看到这么多样本时,
# 分类器已经看过更多了。
if ($platt_scaler->{samples} > 10 && rand() < 0.95) {
return 'maintenance' if $p < 0.08;
return 'new development' if $p > 0.92;
}
# 如果样本不足或条件不满足,
# 使用LLM分类器获取标签。
my $label = classify($msg);
# 起初只将训练数据喂给快速分类器。
# 当它看过一些样本后,每十个标签用一个
# 来训练普拉特缩放器。
if ($fast_classifier->{samples} > 10 && rand() < 0.1) {
$platt_scaler->refine($label, {_INTERCEPT => 1, z => $z});
} else {
$fast_classifier->refine($label, $features);
}
return $label;
}
``
`$fast_classifier`和`$platt_scaler`都是我们创建的那个`LR`类型的实例!普拉特缩放器观察的特征是快速分类器输出的原始对数几率。(这就是为什么在那个类中单独暴露了`score()`函数。)
## 评估快速分类器
如上所述,它运行得像个黑盒子,所以当脚本运行时,我们可能想添加一个例程,定期打印关于进行情况的统计数据。当我们这么做时,会发现它表现得很好。我添加的一些诊断输出将预测分配到十个等宽的桶中,并打印每个桶的误差,即每个桶中平均分配概率与实际平均概率之间的差异。⑤
⑤不幸的是,这会让我们损失一些训练数据,因为我们不想在任一回归的训练数据上评估最终的预测结果!
``
0.05: -0.02
0.15: 0.03
0.25: 0.10
0.35: 0.04
0.45: 0.04
0.55: 0.01
0.65: 0.01
0.75: -0.08
0.85: 0.00
0.95: 0.02
``
例如,这告诉我们,20%到30%范围内的预测平均高了10个百分点,意味着相应事件发生的实际频率略低于预测。另一方面,0%到10%范围内的预测则相反,平均低了2个百分点,即相应事件发生的实际频率比预测略高。然而,所有这些数字都在预期的误差范围内(取决于这些校准数字所来自的样本数量),这意味着在校准方面没有显著偏差。⑥
⑥如果我不那么懒,我本可以在诊断打印中也显示p值之类的东西,分别针对每个桶以及它们的组合。
在我的案例中,快速分类器大约处理了46%的分类任务,因此它大致将分类运行的速度提高了一倍。不算惊人,但构建它无论如何都很有趣。当有更容易分类的东西,或者当昂贵分类器变得更贵时,这绝对是一个值得保留的技术。它可以轻松地将过程加速(并降低成本)一个数量级甚至更多。
## 附录A:快速分类器学习到的特征值
我觉得看看快速分类器如何区分维护和新开发提交很有趣。它是用提交信息的词袋进行训练的,所以它基本上将单个单词与该单词在新开发工作中出现在提交信息中的概率相关联,反之亦然。⑦
⑦尽管与朴素贝叶斯不同,逻辑回归考虑了单词之间的一些相互依赖性。
以下是维护提交中常用的单词:
- fix (或 fixes, fixed)
- use
- move (或 refactor)
- cleanup (或 remove)
- bug
另一方面,这些单词经常出现在新开发的提交中:
- add (或 implement)
- support
- allow
- command (或 directive)
- parameter (或 option, api)
在我的项目中,我在多个开源仓库上单独训练了分类器,它为所有仓库学习到了这些相同的单词。看起来,即使在不同的项目中,开发者在指示某物是新开发还是维护时,也会使用许多相同的单词。这也意味着在所有仓库上联合训练会给我一个更好、更稳健的分类器,但这需要重新设计一些我已有的代码,而我没有时间这么做。
## 附录B:标签缓存与非随机性
另一个优化措施(可能比本文中的快速分类器影响更大)是,代码中所有这些`rand()`调用实际上都是对一个名为`commit_rand($commit)`的自定义函数的调用,该函数将提交哈希转换为一个均匀概率。这意味着每个提交的随机性始终相同。这对于从每个仓库中采样要标注的提交的循环特别有用,因为它每次运行时都会采样相同的提交。
这反过来意味着缓存昂贵分类器生成的标签变得有意义。标签循环中执行此操作的部分将调用`classify($features)`替换为:
In\[5\]:
``
my $label = $cached_labels{$commit->{hash}};
if (! $label) {
$label = classify($commit->{msg});
open(my $cache, '>>', "labels.csv");
printf $cache "%s,%s\n", $commit->{hash}, $label;
close($cache);
}
``
这会在检索到提交的标签时立即将其存储。当脚本启动时,它会将之前所有缓存的标签读入一个字典。
In\[6\]:
``
my %cached_labels = ();
if (-r "labels.csv") {
open(my $cache, '<', "labels.csv");
while (<$cache>) {
$cached_labels{$1} = $2 if /^(.*),(.*)$/
}
close($cache);
}
printf STDERR "恢复了 %d 个缓存标签。\n",
scalar(keys %cached_labels);
``
再简单不过了!
## 附录C:用于语言模型的提示词
这是我用于LLM分类的提示词。它代表了我典型的提示风格,我没有对不同的提示词变体进行单独的评估,因为我想出的第一个就够好了。
> 你将看到一个git提交信息。你的任务是确定该提交是新开发工作还是维护工作。被视为新开发的事项:添加功能、添加CLI标志、添加API端点、移除对功能使用的限制。新开发是指任何因用户请求而开发的内容,旨在帮助他们使用该工具。维护是指错误修复、清理、移除死代码、代码重构、改进测试、文档更新或构建/流程更改。请仅回复“new development”或“maintenance”。
相似文章
探索小型语言模型作为分类器与Jev竞争(分享我的发现)
作者通过分析高温度下的对数概率和校准,探索使用如Gemma 4等小型语言模型作为分类器,并在GitHub上分享代码与发现。
@h100envy: 前vLLM核心贡献者用34分钟解释如何将LLM推理成本降低10倍——比$3000的推理优化训练营更有效
一位前vLLM核心贡献者解释了如何通过LMCache将KV缓存卸载到CPU/SSD/远程存储,从而使LLM推理成本降低10倍,这一技术已被彭博等生产环境采用。
大语言模型还是朴素贝叶斯?经典方法还是新途径?
本文对朴素贝叶斯与大语言模型在文本分类任务上进行了基准测试,发现在有标注数据的情况下,朴素贝叶斯仍然与大语言模型具有竞争力,并在资源受限环境中提供更高的吞吐量和更低的能耗。
不要让LLM说话,直接探测它(8分钟阅读)
本文介绍了一种技术,该技术从LLM的最后一个提示标记处提取隐藏状态,无需文本生成即可进行分类,使用一个小型MLP读取模型的内部决策,从而实现快速且廉价的零样本分类器。
使用LLMs在本地自动标注您的数据集
作者介绍了一个名为'llmog'的工具,该工具使用LLMs自动标注数据集,可以通过llama.cpp或vllm在本地运行,或使用外部API,适合偏好界面操作而非编码的用户。