尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用便宜大模型打标:CLI+Perl+传统机器学习实战

用便宜大模型打标:CLI+Perl+传统机器学习实战 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比如C1.0、penaltyl2、alpha1.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_weightbalanced让模型自动调整权重。朴素贝叶斯没有这个参数但你可以通过重采样来平衡或者用ComplementNB它对不平衡数据更鲁棒。3. 实操过程与核心环节实现3.1 环境准备与工具链搭建我先把整个流程需要的工具列一下你可以照着装PerlmacOS 和 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 # 安装 jqmacOS brew install jq # 安装 jqUbuntu 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_FILEseed_2000.txt OUTPUT_FILElabeled_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, linesTrue) # TF-IDF 特征 tfidf TfidfVectorizer(ngram_range(1,2), max_features50000, min_df2) 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(C1.0, class_weightbalanced, max_iter1000) scores cross_val_score(clf, X, y, cv5, scoringf1_macro) print(f逻辑回归 F1: {scores.mean():.4f}) # 训练朴素贝叶斯 nb ComplementNB(alpha1.0) scores_nb cross_val_score(nb, X, y, cv5, scoringf1_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, headerNone, 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(axis1) # 保存 df_all.to_csv(final_labels.csv, indexFalse)这里有个技巧保留置信度。对于置信度低于某个阈值比如 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_weightbalanced特征维度太高过拟合看特征数量减小 max_features增大 min_df我踩过最大的坑是种子样本的类别分布和真实分布差异太大。有一次我随机抽了 2 万条结果某个稀有类只抽到 3 条训出来的模型完全识别不了这个类。后来改成分层抽样每个类至少抽 200 条问题就解决了。4.3 大模型 API 调用失败或超时怎么处理调 API 最怕的就是网络抖动或者配额超限。我的做法是在脚本里加重试机制retry_count0 max_retries3 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_KEYyour_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_weightComplementNB 较鲁棒特征独立性假设无有但实际影响不大调参难度中等低小样本表现一般较好我的经验是如果种子量少于 1 万条优先用 ComplementNB因为它对小样本更友好。如果种子量超过 2 万条逻辑回归通常效果更好而且可以通过class_weight处理不平衡。两个都跑一下选 F1 高的那个花不了多少时间。6. 一些踩坑之后的个人体会这个项目我前后迭代了三四版踩过的坑真的不少。最大的体会是别一上来就追求完美。我一开始总想着把提示词写到极致把特征工程做到最全结果拖了两周还没跑通全流程。后来我换了个思路先用最粗糙的方式跑通端到端哪怕 F1 只有 0.7至少流程是通的。然后再一步步优化每次只改一个变量看效果变化。这样迭代速度反而快得多。另一个体会是种子样本的质量比数量重要。我有一次为了省事直接拿了一批历史人工标注数据当种子结果那批数据的标注标准和现在的需求不一致训出来的模型完全跑偏。后来老老实实重新用大模型标了一遍虽然多花了 40 块钱但省下了后面反复调试的时间。最后说一个实用小技巧如果你不确定学生模型靠不靠谱可以拿一批人工标注的测试集来评估。我一般会留 500 条人工标注数据作为“金标准”每次训完学生模型都在这上面跑一遍。如果 F1 和交叉验证的结果差距很大说明种子样本和真实分布有偏差需要调整抽样策略。这个方案后续还可以扩展。比如你可以把学生模型换成一个小型预训练模型用蒸馏的方式进一步压缩或者把整个流程封装成一个 CLI 工具输入原始数据输出带标签的结果一键跑完。我现在正在尝试把 Perl 预处理和 Python 训练串成一个 Makefile这样换台机器只需要make all就能跑通全流程。等跑通了再跟大家分享。
返回列表