第2012篇:训练数据构建工程——从业务日志提取高质量微调样本
2026/4/30大约 4 分钟
第2012篇:训练数据构建工程——从业务日志提取高质量微调样本
适读人群:需要构建领域微调数据集的AI工程师 | 阅读时长:约19分钟 | 核心价值:系统化地从业务数据中提取微调样本,避免数据质量问题导致的训练失败
做了几个微调项目之后,我有一个强烈的感受:微调的难点不在训练,在数据。
训练过程有成熟工具,调参也有规律可循。但"找到足够多的高质量数据",每个项目都是一场硬仗。
最惨的一次:花了两周时间爬取和整理了5000条数据,训练出来的模型反而比基础模型差。后来分析发现,数据里充满了格式不一致、回答质量参差不齐的噪声,模型"学坏了"。
这篇文章就是从那些教训中总结的数据工程方法论。
数据来源的优先级
最佳路径通常是:从业务日志中挖掘→GPT-4生成补充→人工专家审核→清洗过滤。
从客服日志挖掘训练数据
客服系统积累了大量真实的问答对话,是绝佳的数据来源:
@Service
@Slf4j
@RequiredArgsConstructor
public class ServiceLogMiner {
private final ServiceLogRepository logRepository;
private final ChatClient qualityFilter; // 用LLM过滤低质量数据
private final EmbeddingModel embeddingModel;
/**
* 从客服日志中挖掘微调样本
*/
public List<TrainingSample> mine(MiningConfig config) {
log.info("开始挖掘客服日志数据,时间范围: {} - {}",
config.getStartDate(), config.getEndDate());
// 1. 获取候选对话(评分高、已解决的对话)
List<ServiceSession> candidates = logRepository.findHighQualitySessions(
config.getStartDate(),
config.getEndDate(),
config.getMinCustomerSatisfactionScore(), // 用户满意度>=4分
SessionStatus.RESOLVED
);
log.info("候选对话数量: {}", candidates.size());
// 2. 转换为训练格式
List<TrainingSample> rawSamples = candidates.stream()
.flatMap(session -> extractSamples(session).stream())
.collect(Collectors.toList());
// 3. 质量过滤
List<TrainingSample> filteredSamples = filterByQuality(rawSamples);
// 4. 去重(语义去重,不只是精确匹配)
List<TrainingSample> deduped = semanticDeduplicate(filteredSamples);
log.info("数据挖掘完成: 候选{}条 -> 过滤后{}条 -> 去重后{}条",
rawSamples.size(), filteredSamples.size(), deduped.size());
return deduped;
}
private List<TrainingSample> extractSamples(ServiceSession session) {
List<TrainingSample> samples = new ArrayList<>();
List<Message> messages = session.getMessages();
for (int i = 0; i < messages.size() - 1; i++) {
Message userMsg = messages.get(i);
Message agentMsg = messages.get(i + 1);
if (userMsg.getRole().equals("user") && agentMsg.getRole().equals("agent")) {
// 构建上下文(包含前几条消息作为context)
String context = buildContext(messages, i);
samples.add(TrainingSample.builder()
.instruction(userMsg.getContent())
.input(context.isEmpty() ? "" : context)
.output(agentMsg.getContent())
.sourceSessionId(session.getId())
.sourceMsgIndex(i)
.build());
}
}
return samples;
}
/**
* 用LLM过滤低质量样本
*/
private List<TrainingSample> filterByQuality(List<TrainingSample> samples) {
return samples.parallelStream()
.filter(sample -> {
// 规则过滤(快速)
if (sample.getOutput().length() < 20) return false; // 回答太短
if (sample.getOutput().contains("不知道") &&
sample.getOutput().length() < 50) return false; // 无效回答
if (sample.getInstruction().length() < 5) return false; // 问题太短
// LLM质量评估(慢但准确,只对通过规则过滤的做)
return isHighQualityByLlm(sample);
})
.collect(Collectors.toList());
}
private boolean isHighQualityByLlm(TrainingSample sample) {
String evaluationPrompt = """
评估以下客服问答对的质量,返回分数1-5:
问题:%s
回答:%s
评分标准:
5 - 回答准确、完整、专业,直接解决了问题
4 - 回答基本正确,有一些小瑕疵
3 - 回答部分正确,但不完整
2 - 回答与问题关联性不强
1 - 回答错误或完全无关
只返回数字分数,不要任何解释:
""".formatted(sample.getInstruction(), sample.getOutput());
try {
String response = qualityFilter.prompt()
.user(evaluationPrompt).call().content().trim();
int score = Integer.parseInt(response);
return score >= 4;
} catch (NumberFormatException e) {
return true; // 解析失败默认保留
}
}
/**
* 语义去重:删除语义相似度过高的样本
*/
private List<TrainingSample> semanticDeduplicate(List<TrainingSample> samples) {
List<float[]> embeddings = samples.stream()
.map(s -> embeddingModel.embed(s.getInstruction()))
.collect(Collectors.toList());
List<TrainingSample> result = new ArrayList<>();
Set<Integer> removed = new HashSet<>();
for (int i = 0; i < samples.size(); i++) {
if (removed.contains(i)) continue;
result.add(samples.get(i));
for (int j = i + 1; j < samples.size(); j++) {
if (removed.contains(j)) continue;
double similarity = cosineSimilarity(embeddings.get(i), embeddings.get(j));
if (similarity > 0.95) { // 相似度超过95%认为是重复
removed.add(j);
}
}
}
return result;
}
}数据集的格式验证与统计
在送去训练前,先做全面的数据集检查:
@Service
@RequiredArgsConstructor
public class DatasetValidator {
public ValidationReport validate(List<TrainingSample> samples) {
ValidationReport.Builder report = ValidationReport.builder();
report.totalSamples(samples.size());
List<String> issues = new ArrayList<>();
// 1. 基础统计
IntSummaryStatistics instructionLengths = samples.stream()
.mapToInt(s -> s.getInstruction().length())
.summaryStatistics();
IntSummaryStatistics outputLengths = samples.stream()
.mapToInt(s -> s.getOutput().length())
.summaryStatistics();
report.avgInstructionLength(instructionLengths.getAverage())
.avgOutputLength(outputLengths.getAverage());
// 2. 检测极端值
long tooShortOutputs = samples.stream()
.filter(s -> s.getOutput().length() < 30).count();
if (tooShortOutputs > samples.size() * 0.05) {
issues.add(String.format("警告:%d条样本回答过短(<30字),占比%.1f%%",
tooShortOutputs, 100.0 * tooShortOutputs / samples.size()));
}
// 3. 检测格式一致性
long inconsistentFormats = samples.stream()
.filter(s -> !isWellFormatted(s.getOutput())).count();
if (inconsistentFormats > samples.size() * 0.1) {
issues.add("警告:超过10%的样本输出格式不一致,建议统一格式");
}
// 4. 检测标签分布(分类任务)
Map<String, Long> labelDist = samples.stream()
.filter(s -> s.getLabel() != null)
.collect(Collectors.groupingBy(TrainingSample::getLabel, Collectors.counting()));
if (!labelDist.isEmpty()) {
long maxCount = labelDist.values().stream().mapToLong(Long::longValue).max().orElse(0);
long minCount = labelDist.values().stream().mapToLong(Long::longValue).min().orElse(0);
if (maxCount > minCount * 10) {
issues.add("警告:标签分布严重不平衡(最多:最少=" + maxCount + ":" + minCount + "),可能导致模型偏向多数类");
}
}
report.issues(issues);
report.isValid(issues.stream().noneMatch(i -> i.startsWith("错误")));
return report.build();
}
}数据质量比数量重要得多。与其花时间扩大数据集,不如先把已有的数据清理干净。1000条高质量样本,胜过10000条参差不齐的样本。
