1. 项目缘起与核心思路拆解
1.1 为什么我会盯上“便宜的大模型打标”这件事
做数据标注这行的朋友应该都有体会,过去两年最大的成本项已经从“找人标”变成了“用模型标”。尤其是做文本分类、意图识别、情感极性这类任务,直接调一个商用大模型 API 跑一遍,效果确实好,但账单也真的吓人。我手上有个项目,大概 40 万条用户反馈需要打上 12 个类别的标签,如果全部走大模型 API,按当时的报价算下来要小几万块,而且这还只是跑一轮,后面迭代还得再跑。
“Cheaper LLM Labelling”这个标题,说白了就是一句话:怎么用更少的钱,拿到质量够用的标签。它不是让你完全不用大模型,而是把大模型当成“老师”,让它去教一个“便宜的学生”——这个学生可以是逻辑回归、朴素贝叶斯,也可以是一个小到能在本地 CPU 上跑起来的小模型。核心逻辑是:大模型只标注一小部分高价值样本,然后用这些样本训练一个轻量分类器,去覆盖剩下的大头。
这个思路在学术上叫“弱监督”或者“蒸馏式标注”,但在工程里我更愿意叫它“花小钱办大事”。适合谁来参考?我觉得三类人最需要:一是预算有限但数据量大的中小团队;二是做个人项目、不想在 API 上烧钱的独立开发者;三是需要频繁迭代标签体系、每次重跑都心疼钱的算法工程师。如果你正好在这三类里,那下面的内容应该能帮你省下不少真金白银。
1.2 整体方案选型:为什么是 CLI + Perl + 传统机器学习
很多人一听“大模型打标”,第一反应就是写个 Python 脚本,调 API,存结果。这当然没问题,但我在实际项目里发现,真正卡脖子的往往不是模型本身,而是流程的稳定性和可复现性。你写一个 Python 脚本,依赖一堆库,换台机器可能就跑不起来;你想把流程拆成几步,每步单独调试,脚本里又得加一堆参数判断。所以我最后选了一套看起来有点“复古”但极其稳的组合:CLI 工具链 + Perl 做胶水 + 逻辑回归/朴素贝叶斯做学生模型。
先说 CLI。大模型相关的 CLI 工具这两年冒出来不少,像 codex cli、claude cli 这些,本质上都是把模型调用封装成命令行接口。用 CLI 的好处是,你可以用 shell 脚本把整个流程串起来,每一步的输入输出都是文件,出了问题直接看文件,不用去翻 Python 的堆栈。而且 CLI 天然适合做批处理,你写个 for 循环就能把几千条样本喂进去,比在 Python 里写异步请求简单得多。
再说 Perl。我知道很多人看到 Perl 就头大,觉得这是上古语言。但在文本处理这个场景里,Perl 的正则表达式能力依然是顶级的,而且它处理大文件、做流式过滤非常顺手。我实际用下来,用 Perl 写一个“从原始日志里抽样本、清洗、去重、分片”的预处理脚本,代码量比 Python 少一半,跑起来还更快。当然,如果你对 Perl 实在不熟,用 awk 或者 Python 的 csv 模块也能替代,但 Perl 的-ne一行流是真的香。
最后是学生模型的选择。逻辑回归和朴素贝叶斯是经典中的经典,很多人觉得它们太简单,但在文本分类任务上,只要特征工程做得不太差,效果往往能到 85% 到 92% 之间,足够覆盖大部分业务场景。而且这两个模型训练极快,40 万条样本在单机 CPU 上几分钟就能训完,预测更是秒级。相比之下,你如果用一个 BERT 级别的小模型,训练和推理成本虽然比大模型低,但依然需要 GPU,部署也麻烦。所以我的策略是:先用逻辑回归/朴素贝叶斯打底,如果效果不够再考虑升级。
1.3 成本账怎么算:大模型标注 vs 蒸馏标注
为了让你有个直观感受,我拿自己项目的数据算了一笔账。假设你有 40 万条样本,大模型 API 按每百万 token 收费,每条样本平均 200 token,那么一轮标注的成本大约是:
| 方案 | 标注量 | 单价(每百万 token) | 总成本 | 耗时 |
|---|---|---|---|---|
| 全量大模型 | 40 万条 | 假设 10 元 | 约 800 元 | 数小时 |
| 蒸馏方案 | 2 万条大模型 + 38 万条本地 | 10 元 + 电费 | 约 40 元 + 忽略不计 | 大模型 20 分钟 + 本地 5 分钟 |
这里的关键是,你不需要让大模型标全部数据。你只需要标一个有代表性的子集,比如 2 万条,然后用这个子集训练学生模型,再去预测剩下的 38 万条。成本直接降到原来的二十分之一。而且学生模型一旦训好,后面新增数据可以直接用,边际成本几乎为零。
当然,这里有个前提:你的子集必须有代表性。如果子集全是某一类样本,学生模型就会学偏。所以怎么选子集,是后面要重点讲的一个环节。
2. 核心细节解析与实操要点
2.1 样本选择:怎么挑出那 2 万条“种子”
选种子样本是整个流程里最容易被忽视、但影响最大的环节。我见过不少人直接随机抽 2 万条,结果训出来的模型在少数类上表现一塌糊涂。正确的做法是分层抽样 + 主动学习结合。
分层抽样好理解,就是按你已知的类别分布,从每个类别里按比例抽。但问题是你一开始并不知道类别分布,这时候就需要先跑一轮小规模的大模型标注,比如先随机抽 2000 条,让大模型标一遍,看看类别分布大概是什么样。然后根据这个分布,再决定每个类别抽多少条。
主动学习稍微复杂一点,但效果更好。核心思想是:让大模型优先标注那些“学生模型最不确定”的样本。具体操作是,先用少量种子训一个初始学生模型,然后让它去预测未标注数据,挑出预测概率接近 0.5 的样本(也就是模型最纠结的),再让大模型标这些。这样每一轮标注都能最大化提升学生模型的能力。
我实际用下来,分层抽样 + 两轮主动学习,2 万条种子就能达到全量随机抽样 5 万条的效果。这里有个细节要注意:主动学习的第一轮种子不能太少,我建议至少 500 条,否则初始模型太弱,挑出来的“不确定样本”可能全是噪声。
2.2 大模型标注的提示词设计:别让模型“自由发挥”
很多人调大模型打标,提示词写得特别随意,比如“请给这条文本分类”,然后模型就给你输出一堆五花八门的标签。这种做法在蒸馏场景里是致命的,因为学生模型需要的是稳定、一致的标签体系。
我的做法是,提示词里必须包含三样东西:类别定义、输出格式约束、少量示例。类别定义要写清楚每个类别的边界,比如“投诉”和“咨询”的区别是什么。输出格式约束就是强制模型只输出类别编号,不要解释。少量示例就是给每个类别配 2 到 3 条典型样本,让模型照着学。
这里有个坑:示例不能太多,否则会占用大量 token,成本上去了。我一般每个类别给 2 条,12 个类别就是 24 条示例,加上类别定义,整个提示词大概 800 token。虽然比裸提示词贵一点,但标注一致性提升非常明显。实测下来,加了示例之后,模型输出格式错误率从 15% 降到了 2% 以下。
另外,我强烈建议在提示词里加一句“如果不确定,输出‘未知’”。这样你可以在后续处理中把“未知”样本挑出来,要么人工看一眼,要么再让大模型用更详细的提示词重标。千万别让模型硬猜,硬猜出来的标签会污染训练集。
2.3 特征工程:逻辑回归和朴素贝叶斯的“燃料”
逻辑回归和朴素贝叶斯都是线性模型,它们对特征的质量非常敏感。在文本分类里,最常用的特征是 TF-IDF 和 n-gram。我一般会用TfidfVectorizer配合ngram_range=(1,2),也就是同时考虑单个词和相邻两个词的组合。这样能捕捉到一些短语级别的信息,比如“不 满意”和“不满意”的区别。
但光有 TF-IDF 还不够。我在实际项目里发现,加入一些元特征能显著提升效果。比如文本长度、是否包含问号、是否包含感叹号、是否包含特定关键词(如“退款”“投诉”)。这些特征用scipy.sparse.hstack拼到 TF-IDF 矩阵后面就行,代码很简单,但效果提升很明显。我做过对比,纯 TF-IDF 的 F1 是 0.86,加上元特征之后到了 0.91。
还有一个细节是停用词处理。中文文本里,“的”“了”“是”这些词确实没什么信息量,但有些停用词表把“不”“没”也去掉了,这就出问题了。否定词在情感分类里是关键特征,绝对不能去。我的做法是,用一份自定义的停用词表,只去掉那些真正无意义的虚词,保留所有否定词和程度副词。
2.4 模型训练与调参:别一上来就网格搜索
逻辑回归的主要参数是正则化强度C和惩罚类型penalty。朴素贝叶斯主要是平滑参数alpha。很多人一上来就GridSearchCV,跑半天,其实没必要。我的经验是,先用手动设定的参数跑一版 baseline,比如C=1.0、penalty='l2'、alpha=1.0,看看效果。如果效果还行,再在小范围内调一下。
对于逻辑回归,C越大正则化越弱,容易过拟合;C越小正则化越强,容易欠拟合。我一般会试[0.1, 0.5, 1.0, 2.0, 5.0]这几个值。朴素贝叶斯的alpha一般试[0.1, 0.5, 1.0, 2.0]。用 5 折交叉验证,几分钟就能跑完。
这里有个坑:类别不平衡。如果你的标签分布很不均匀,比如某个类只占 1%,逻辑回归会倾向于预测多数类。解决办法是设置class_weight='balanced',让模型自动调整权重。朴素贝叶斯没有这个参数,但你可以通过重采样来平衡,或者用ComplementNB,它对不平衡数据更鲁棒。
3. 实操过程与核心环节实现
3.1 环境准备与工具链搭建
我先把整个流程需要的工具列一下,你可以照着装:
- Perl:macOS 和 Linux 自带,Windows 建议装 Strawberry Perl。主要用来做文本预处理和文件切分。
- Python 3.8+:用来训模型和调大模型 API。需要装
scikit-learn、pandas、numpy、scipy、requests。 - 大模型 CLI 工具:我用的是 codex cli,安装方式参考官方文档。如果你用 claude cli 也行,原理一样。
- jq:用来解析 JSON 输出,命令行处理 JSON 的神器。
安装命令大概是这样:
# 安装 Python 依赖 pip install scikit-learn pandas numpy scipy requests # 安装 jq(macOS) brew install jq # 安装 jq(Ubuntu) sudo apt-get install jqPerl 不需要额外装,系统自带。如果你在 Windows 上,去 Strawberry Perl 官网下载安装包,一路下一步就行。
3.2 数据预处理:用 Perl 做流式清洗
原始数据往往是脏的,有 HTML 标签、多余空格、乱码字符。我用 Perl 写了一个清洗脚本,核心逻辑是:
#!/usr/bin/perl use strict; use warnings; while (my $line = <STDIN>) { chomp $line; # 去掉 HTML 标签 $line =~ s/<[^>]+>//g; # 去掉多余空白 $line =~ s/\s+/ /g; # 去掉首尾空格 $line =~ s/^\s+|\s+$//g; # 跳过空行 next if $line eq ''; print "$line\n"; }这个脚本可以从标准输入读,写到标准输出,配合管道就能串起来:
cat raw_data.txt | perl clean.pl > cleaned_data.txt为什么用 Perl 而不是 Python?因为 Perl 的-ne一行流太方便了,而且处理大文件时内存占用极低。你如果不想学 Perl,用sed和awk也能做,但正则表达式写起来会麻烦一些。
3.3 种子样本标注:调大模型 CLI 的完整流程
假设你已经清洗好了 40 万条数据,现在要抽 2 万条种子。我一般分两步:先随机抽 2000 条,让大模型标一遍,看看类别分布;然后根据分布,分层抽 18000 条,再标一遍。
调大模型 CLI 的脚本大概长这样:
#!/bin/bash INPUT_FILE="seed_2000.txt" OUTPUT_FILE="labeled_2000.jsonl" while IFS= read -r line; do # 构造提示词 PROMPT="请给以下文本分类,类别有:投诉、咨询、建议、表扬、其他。只输出类别名称,不要解释。\n\n文本:$line" # 调 CLI,假设命令是 codex-cli RESULT=$(codex-cli --prompt "$PROMPT" --max-tokens 10) # 输出 JSONL echo "{\"text\": \"$line\", \"label\": \"$RESULT\"}" >> "$OUTPUT_FILE" # 限速,避免触发 API 限制 sleep 0.1 done < "$INPUT_FILE"这里有几个细节要注意。第一,sleep 0.1是限速用的,具体值看你的 API 配额。第二,输出用 JSONL 格式,每行一个 JSON,方便后续用jq处理。第三,如果 CLI 返回的结果包含多余字符,比如“类别:投诉”,你需要在脚本里做一次清洗,只保留类别名称。
标注完之后,用jq统计一下类别分布:
cat labeled_2000.jsonl | jq -r '.label' | sort | uniq -c | sort -rn这样你就能看到每个类别有多少条,然后按比例抽剩下的 18000 条。
3.4 学生模型训练:从 TF-IDF 到逻辑回归
种子标注完之后,就可以训学生模型了。我用 Python 写了一个完整的训练脚本,核心步骤如下:
import pandas as pd import numpy as np from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.naive_bayes import ComplementNB from sklearn.model_selection import cross_val_score from sklearn.metrics import classification_report from scipy.sparse import hstack # 读数据 df = pd.read_json('labeled_2000.jsonl', lines=True) # TF-IDF 特征 tfidf = TfidfVectorizer(ngram_range=(1,2), max_features=50000, min_df=2) X_tfidf = tfidf.fit_transform(df['text']) # 元特征 df['length'] = df['text'].str.len() df['has_question'] = df['text'].str.contains('?').astype(int) df['has_exclamation'] = df['text'].str.contains('!').astype(int) X_meta = df[['length', 'has_question', 'has_exclamation']].values # 拼接特征 X = hstack([X_tfidf, X_meta]) y = df['label'] # 训练逻辑回归 clf = LogisticRegression(C=1.0, class_weight='balanced', max_iter=1000) scores = cross_val_score(clf, X, y, cv=5, scoring='f1_macro') print(f"逻辑回归 F1: {scores.mean():.4f}") # 训练朴素贝叶斯 nb = ComplementNB(alpha=1.0) scores_nb = cross_val_score(nb, X, y, cv=5, scoring='f1_macro') print(f"朴素贝叶斯 F1: {scores_nb.mean():.4f}") # 选效果好的,全量训练 clf.fit(X, y)这里我用了ComplementNB而不是MultinomialNB,因为前者对不平衡数据更鲁棒。实测下来,在类别分布不均的情况下,ComplementNB的 F1 通常比MultinomialNB高 2 到 3 个百分点。
3.5 全量预测与结果合并
学生模型训好之后,就可以预测剩下的 38 万条了。预测脚本很简单:
# 读全量数据 df_all = pd.read_csv('cleaned_data.txt', header=None, names=['text']) # 同样的特征处理 X_all_tfidf = tfidf.transform(df_all['text']) df_all['length'] = df_all['text'].str.len() df_all['has_question'] = df_all['text'].str.contains('?').astype(int) df_all['has_exclamation'] = df_all['text'].str.contains('!').astype(int) X_all_meta = df_all[['length', 'has_question', 'has_exclamation']].values X_all = hstack([X_all_tfidf, X_all_meta]) # 预测 df_all['predicted_label'] = clf.predict(X_all) df_all['confidence'] = clf.predict_proba(X_all).max(axis=1) # 保存 df_all.to_csv('final_labels.csv', index=False)这里有个技巧:保留置信度。对于置信度低于某个阈值(比如 0.6)的样本,你可以挑出来让大模型重新标,或者人工抽检。这样能在不增加太多成本的情况下,进一步提升整体质量。
4. 常见问题与排查技巧实录
4.1 大模型输出格式不稳定怎么办
这是最常见的问题。你明明在提示词里写了“只输出类别名称”,但模型有时候会输出“类别:投诉”,有时候会输出“这条文本属于投诉类别”,甚至有时候会输出一段解释。解决办法有三个层次:
第一层,在提示词里加更强的约束,比如“只输出一个词,不要任何其他字符”。第二层,在脚本里做后处理,用正则表达式提取类别名称。第三层,如果模型还是乱输出,就换一个更听话的模型,或者降低temperature参数。
我一般会写一个 Perl 或 Python 的后处理脚本,把输出映射到标准类别上。比如:
import re def normalize_label(raw): raw = raw.strip() # 去掉常见前缀 raw = re.sub(r'^(类别|标签|分类)[::]\s*', '', raw) # 只保留第一个词 raw = raw.split()[0] if raw.split() else '未知' # 映射到标准类别 mapping = {'投诉': '投诉', '咨询': '咨询', '建议': '建议', '表扬': '表扬'} return mapping.get(raw, '未知')4.2 学生模型效果不达标怎么排查
如果学生模型的 F1 低于预期,我一般按这个顺序排查:
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 整体 F1 低 | 种子样本太少 | 看学习曲线 | 增加种子到 3 万或 5 万 |
| 某个类别 F1 特别低 | 该类别种子太少 | 统计各类别种子数 | 对该类别过采样或补标 |
| 训练集 F1 高但验证集低 | 过拟合 | 对比训练/验证曲线 | 增大正则化,减小 C |
| 所有类别都预测成多数类 | 类别不平衡 | 看混淆矩阵 | 设置 class_weight='balanced' |
| 特征维度太高 | 过拟合 | 看特征数量 | 减小 max_features,增大 min_df |
我踩过最大的坑是种子样本的类别分布和真实分布差异太大。有一次我随机抽了 2 万条,结果某个稀有类只抽到 3 条,训出来的模型完全识别不了这个类。后来改成分层抽样,每个类至少抽 200 条,问题就解决了。
4.3 大模型 API 调用失败或超时怎么处理
调 API 最怕的就是网络抖动或者配额超限。我的做法是在脚本里加重试机制:
retry_count=0 max_retries=3 while [ $retry_count -lt $max_retries ]; do RESULT=$(codex-cli --prompt "$PROMPT" --max-tokens 10 2>/dev/null) if [ $? -eq 0 ] && [ -n "$RESULT" ]; then break fi retry_count=$((retry_count + 1)) sleep 2 done if [ $retry_count -eq $max_retries ]; then echo "{\"text\": \"$line\", \"label\": \"未知\"}" >> "$OUTPUT_FILE" fi另外,我建议把已经标注好的结果存成 JSONL,每标一条就追加一行。这样即使脚本中途挂了,重启之后可以从断点继续,不用从头再来。这个习惯帮我省过好几次时间。
4.4 怎么防止密钥泄露
调大模型 API 肯定要用密钥,但密钥绝对不能硬编码在脚本里。我的做法是用环境变量:
export LLM_API_KEY="your_key_here"然后在脚本里读:
API_KEY=$LLM_API_KEY如果你用 CLI 工具,很多工具支持从环境变量读密钥,具体看文档。另外,不要把密钥提交到 Git 仓库,.gitignore里加上.env文件。如果是团队协作,用密钥管理服务,别在聊天群里发密钥。
4.5 迭代更新:标签体系变了怎么办
业务需求会变,标签体系也可能调整。比如原来有 12 个类,现在要合并成 8 个。这时候你不需要重新标全部数据,只需要:
- 把旧标签映射到新标签(写个映射表就行)。
- 用映射后的数据重新训学生模型。
- 如果新增了类别,只需要对新类别补标少量种子,然后重新训。
我一般会保留一份“标签映射表”的版本记录,每次调整都记下来,方便回溯。这个习惯在项目交接的时候特别有用。
5. 成本与效果对比:我的实际数据
5.1 不同种子量下的效果与成本
我拿自己的 40 万条数据做了几组对比实验,结果如下:
| 种子量 | 大模型成本 | 学生模型 F1 | 总耗时 |
|---|---|---|---|
| 5000 | 约 10 元 | 0.82 | 大模型 5 分钟 + 训练 2 分钟 |
| 10000 | 约 20 元 | 0.87 | 大模型 10 分钟 + 训练 3 分钟 |
| 20000 | 约 40 元 | 0.91 | 大模型 20 分钟 + 训练 5 分钟 |
| 50000 | 约 100 元 | 0.93 | 大模型 50 分钟 + 训练 8 分钟 |
| 全量 | 约 800 元 | 0.95 | 大模型 数小时 |
可以看到,2 万条种子是一个性价比很高的点。再往上加种子,F1 提升有限,但成本线性增长。所以我的建议是,先用 2 万条跑一版,如果效果不够,再针对性补标。
5.2 逻辑回归 vs 朴素贝叶斯:谁更适合你
这两个模型我都用过,简单对比一下:
| 维度 | 逻辑回归 | 朴素贝叶斯 |
|---|---|---|
| 训练速度 | 中等 | 极快 |
| 预测速度 | 快 | 极快 |
| 不平衡数据 | 支持 class_weight | ComplementNB 较鲁棒 |
| 特征独立性假设 | 无 | 有(但实际影响不大) |
| 调参难度 | 中等 | 低 |
| 小样本表现 | 一般 | 较好 |
我的经验是,如果种子量少于 1 万条,优先用 ComplementNB,因为它对小样本更友好。如果种子量超过 2 万条,逻辑回归通常效果更好,而且可以通过class_weight处理不平衡。两个都跑一下,选 F1 高的那个,花不了多少时间。
6. 一些踩坑之后的个人体会
这个项目我前后迭代了三四版,踩过的坑真的不少。最大的体会是:别一上来就追求完美。我一开始总想着把提示词写到极致,把特征工程做到最全,结果拖了两周还没跑通全流程。后来我换了个思路,先用最粗糙的方式跑通端到端,哪怕 F1 只有 0.7,至少流程是通的。然后再一步步优化,每次只改一个变量,看效果变化。这样迭代速度反而快得多。
另一个体会是,种子样本的质量比数量重要。我有一次为了省事,直接拿了一批历史人工标注数据当种子,结果那批数据的标注标准和现在的需求不一致,训出来的模型完全跑偏。后来老老实实重新用大模型标了一遍,虽然多花了 40 块钱,但省下了后面反复调试的时间。
最后说一个实用小技巧:如果你不确定学生模型靠不靠谱,可以拿一批人工标注的测试集来评估。我一般会留 500 条人工标注数据作为“金标准”,每次训完学生模型都在这上面跑一遍。如果 F1 和交叉验证的结果差距很大,说明种子样本和真实分布有偏差,需要调整抽样策略。
这个方案后续还可以扩展。比如你可以把学生模型换成一个小型预训练模型,用蒸馏的方式进一步压缩;或者把整个流程封装成一个 CLI 工具,输入原始数据,输出带标签的结果,一键跑完。我现在正在尝试把 Perl 预处理和 Python 训练串成一个 Makefile,这样换台机器只需要make all就能跑通全流程。等跑通了再跟大家分享。