6 评估与版本控制
为什么评估很重要
没有衡量,提示工程就是猜测。 生产AI系统需要系统性的评估、版本控制和持续改进——就像传统软件一样。
评估差距
传统软件: AI/提示开发:
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ ✅ 单元测试 │ │ ❌ "看起来不错" │
│ ✅ 集成测试 │ │ ❌ 手动抽查 │
│ ✅ 覆盖率指标 │ │ ❌ 基于感觉的迭代 │
│ ✅ CI/CD门控 │ │ ❌ 发布后祈祷 │
│ ✅ 性能基准 │ │ ❌ 未知的回归 │
└─────────────────────────────┘ └─────────────────────────────┘
良好的工程实践 vs "提示感觉"
专业方法
系统性提示工程:
┌──────────────────────────────────────────────────────────────────────┐
│ 定义 → 测量 → 迭代 → 验证 → 部署 → 监控 → 重复 │
├──────────────────────────────────────────────────────────────────────┤
│ ✅ 带有真实数据的评估数据集 │
│ ✅ 自动化指标(准确性、相关性、连贯性) │
│ ✅ LLM作为评判者用于主观质量评估 │
│ ✅ A/B测试基础设施 │
│ ✅ 提示词版本控制 │
│ ✅ CI/CD质量门控 │
│ ✅ 生产监控和告警 │
└──────────────────────────────────────────────────────────────────────┘
1. 评估基础
1.1 什么是评估?
评估(evaluation)是一个结构化的测试,用于衡量提示词在特定任务上的性能。它包含:
评估组件:
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ 1. 数据集 2. 指标 3. 阈值 │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │
│ │ 输入: "法国的 │ │ 准确率: 95% │ │ 通过: >90% │ │
│ │ 首都是什么?" │ → │ 相关性: 0.87 │ → │ 失败: <90% │ │
│ │ 期望: 巴黎 │ │ 延迟: 1.2s │ │ │ │
│ └─────────────────┘ └─────────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
1.2 评估类型
| 类型 | 描述 | 何时使用 |
|---|---|---|
| 离线评估 | 在测试数据集上进行批量评估 | 开发、CI/CD |
| 在线评估 | 与真实用户进行A/B测试 | 生产验证 |
| LLM作为评判者 | 另一个LLM评估响应 | 无真实数据可用时 |
| 人工评估 | 专家人工标注 | 黄金标准、校准 |
| 自动化指标 | BLEU、ROUGE、BERTScore | 翻译、摘要 |
1.3 评估数据集设计
数据集大小指南
最小数据集大小因任务复杂度而异。太小=不可靠的指标。太大=浪费资源。
| 任务类型 | 最小样本 | 推荐 | 备注 |
|---|---|---|---|
| 二分类 | 100 | 500+ | 平衡类别 |
| 多分类(5个类别) | 200 | 1000+ | 每个类别40+ |
| 开放式生成 | 50 | 200+ | 多样化场景 |
| RAG评估 | 100 | 300+ | 多样化的查询类型 |
| 摘要 | 50 | 150+ | 不同文档长度 |
| 代码生成 | 100 | 500+ | 涵盖边缘情况 |
数据集结构:
{
"dataset_id": "customer-support-v2",
"created": "2025-01-21",
"task_type": "classification",
"samples": [
{
"id": "cs-001",
"input": "我的订单还没到,已经2周了",
"expected_output": "shipping_delay",
"metadata": {
"category": "shipping",
"difficulty": "easy",
"source": "production_logs"
}
},
{
"id": "cs-002",
"input": "我想退货,但退货按钮不起作用",
"expected_output": "return_technical_issue",
"metadata": {
"category": "returns",
"difficulty": "medium",
"source": "manual_annotation"
}
}
]
}
2. 评估指标深入
2.1 分类指标
public class ClassificationMetrics {
public static double accuracy(List<Prediction> predictions) {
long correct = predictions.stream()
.filter(p -> p.predicted().equals(p.expected()))
.count();
return (double) correct / predictions.size();
}
public static double precision(List<Prediction> predictions, String positiveClass) {
long truePositives = predictions.stream()
.filter(p -> p.predicted().equals(positiveClass) &&
p.expected().equals(positiveClass))
.count();
long predictedPositives = predictions.stream()
.filter(p -> p.predicted().equals(positiveClass))
.count();
return predictedPositives == 0 ? 0 : (double) truePositives / predictedPositives;
}
public static double recall(List<Prediction> predictions, String positiveClass) {
long truePositives = predictions.stream()
.filter(p -> p.predicted().equals(positiveClass) &&
p.expected().equals(positiveClass))
.count();
long actualPositives = predictions.stream()
.filter(p -> p.expected().equals(positiveClass))
.count();
return actualPositives == 0 ? 0 : (double) truePositives / actualPositives;
}
public static double f1Score(double precision, double recall) {
if (precision + recall == 0) return 0;
return 2 * (precision * recall) / (precision + recall);
}
}
2.2 文本生成指标
| 指标 | 公 式/描述 | 最适合 | 局限性 |
|---|---|---|---|
| BLEU | N-gram精确度重叠 | 翻译 | 惩改写 |
| ROUGE-N | N-gram召回率重叠 | 摘要 | 忽略语义 |
| ROUGE-L | 最长公共子序列 | 摘要 | 顺序敏感 |
| BERTScore | 语义嵌入相似度 | 任何生成 | 计算密集 |
| METEOR | 带同义词的调和平均 | 翻译 | 需要资源 |
实现:
# 使用evaluate库
import evaluate
# BLEU分数
bleu = evaluate.load("bleu")
results = bleu.compute(
predictions=["猫坐在垫子上"],
references=[["猫在垫子上"]]
)
print(f"BLEU: {results['bleu']:.3f}")
# ROUGE分数
rouge = evaluate.load("rouge")
results = rouge.compute(
predictions=["AI正在改变医疗保健"],
references=["人工智能正在革命性地改变医疗保健行业"]
)
print(f"ROUGE-L: {results['rougeL']:.3f}")
# BERTScore(语义相似)
bertscore = evaluate.load("bertscore")
results = bertscore.compute(
predictions=["今天天气很好"],
references=["外面是个美好的一天"],
lang="en"
)
print(f"BERTScore F1: {results['f1'][0]:.3f}")
2.3 RAG特定指标
public class RagMetrics {
/**
* 衡量多少检索到的上下文与查询相关
*/
public static double contextRelevance(
String query,
List<Document> retrievedDocs,
EmbeddingModel embeddingModel) {
float[] queryEmbedding = embeddingModel.embed(query);
return retrievedDocs.stream()
.mapToDouble(doc -> {
float[] docEmbedding = embeddingModel.embed(doc.getContent());
return cosineSimilarity(queryEmbedding, docEmbedding);
})
.average()
.orElse(0.0);
}
/**
* 衡量答案在多大程度上基于检索到的上下文
*/
public static double faithfulness(
String answer,
List<Document> context,
ChatClient judgeClient) {
String prompt = """
给定下面的上下文和答案,评估答案在多大程度上
被上下文所支持,评分0-1。
上下文:
%s
答案:
%s
只返回0到1之间的数字。
""".formatted(
context.stream().map(Document::getContent).collect(joining("\n\n")),
answer
);
String score = judgeClient.prompt().user(prompt).call().content();
return Double.parseDouble(score.trim());
}
/**
* 衡量答案是否真正回答了问题
*/
public static double answerRelevance(
String query,
String answer,
ChatClient judgeClient) {
String prompt = """
评估这个答案在多大程度上回答了问题,评分0-1。
问题:%s
答案:%s
只返回0到1之间的数字。
""".formatted(query, answer);
String score = judgeClient.prompt().user(prompt).call().content();
return Double.parseDouble(score.trim());
}
}
2.4 RAG评估框架(RAGAS风格)
RAG评估维度:
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────┐ │
│ │ 上下文 │ │ 忠实性 │ │ 答案 │ │
│ │ 相关性 │ │ │ │ 相关性 │ │
│ │ │ │ │ │ │ │
│ │ "检索到的文档 │ │ "答案是否基于 │ │ "答案是否 │ │
│ │ 是否与查询 │ │ 上下文?" │ │ 回答问题?" │ │
│ │ 相关?" │ │ │ │ │ │
│ └────────┬────────┘ └────────┬────────┘ └───────┬───────┘ │
│ │ │ │ │
│ └───────────────────────┼──────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ 总体RAG分数 │ │
│ │ = 加权平均 │ │
│ └─────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
3. LLM作为评判者的评估
当没有真实数据 或评估是主观的时,使用另一个LLM进行评估。
3.1 单点评分
@Service
public class LlmJudgeService {
private final ChatClient judgeClient;
public EvaluationResult evaluateResponse(
String query,
String response,
List<String> criteria) {
String criteriaList = criteria.stream()
.map(c -> "- " + c)
.collect(Collectors.joining("\n"));
String prompt = """
您是一位专业评估师。请评估以下回答。
## 查询
%s
## 回答
%s
## 评估标准
%s
## 说明
对于每个标准,请提供:
1. 评分(1-5分,5分为优秀)
2. 简要理由
以JSON格式返回您的评估结果:
{
"scores": {
"标准名称": {"score": X, "reason": "..."}
},
"overall_score": X.X,
"summary": "总体评估..."
}
""".formatted(query, response, criteriaList);
String result = judgeClient.prompt()
.user(prompt)
.call()
.content();
return parseEvaluationResult(result);
}
}
3.2 成对比较
public class PairwiseJudge {
private final ChatClient judgeClient;
public ComparisonResult compare(
String query,
String responseA,
String responseB) {
String prompt = """
比较这两个针对同一查询的回答。
## 查询
%s
## 回答 A
%s
## 回答 B
%s
## 说明
哪个回答更好?请考虑:
- 准确性和正确性
- 完整性
- 清晰性和有用性
- 简洁性
返回JSON:
{
"winner": "A" 或 "B" 或 "tie",
"confidence": 0.0-1.0,
"reasoning": "..."
}
""".formatted(query, responseA, responseB);
// 通过反向测试减少位置偏差
String promptReversed = prompt
.replace("回答 A", "回答 X")
.replace("回答 B", "回答 A")
.replace("回答 X", "回答 B");
String result1 = judgeClient.prompt().user(prompt).call().content();
String result2 = judgeClient.prompt().user(promptReversed).call().content();
return reconcileResults(result1, result2);
}
}
3.3 基于参考的评分
public class ReferenceGrader {
private final ChatClient judgeClient;
public GradingResult gradeWithReference(
String query,
String response,
String referenceAnswer) {
String prompt = """
将此回答与参考答案进行比较评分。
## 查询
%s
## 学生回答
%s
## 参考答案
%s
## 评分标准
- 5: 与参考答案相同或更好
- 4: 基本正确,有少量遗漏
- 3: 部分正确,有错误
- 2: 有重大错误或内容缺失
- 1: 错误或不相关
返回JSON:
{
"grade": X,
"correct_elements": ["..."],
"missing_elements": ["..."],
"errors": ["..."],
"feedback": "..."
}
""".formatted(query, response, referenceAnswer);
return parseGradingResult(
judgeClient.prompt().user(prompt).call().content()
);
}
}
3.4 多评判者集成
@Service
public class EnsembleJudge {
private final List<ChatClient> judges; // 不同的模型
public EnsembleResult evaluate(String query, String response) {
List<Double> scores = judges.parallelStream()
.map(judge -> evaluateWithJudge(judge, query, response))
.toList();
double mean = scores.stream().mapToDouble(d -> d).average().orElse(0);
double variance = scores.stream()
.mapToDouble(s -> Math.pow(s - mean, 2))
.average()
.orElse(0);
return new EnsembleResult(
mean,
Math.sqrt(variance), // 标准差
scores,
variance > 0.5 ? "高分歧度 - 需要人工审核" : "一致性良好"
);
}
private double evaluateWithJudge(ChatClient judge, String query, String response) {
// 所有评判者使用相同的评估提示
String prompt = createEvaluationPrompt(query, response);
return Double.parseDouble(judge.prompt().user(prompt).call().content().trim());
}
}
4. A/B测试基础设施
4.1 实验框架
@Component
public class PromptExperimentService {
private final ExperimentRepository experimentRepo;
private final MetricsCollector metricsCollector;
private final Map<String, ChatClient> variants;
public ExperimentResult runExperiment(
String experimentId,
String userId,
String query) {
Experiment experiment = experimentRepo.findById(experimentId)
.orElseThrow(() -> new ExperimentNotFoundException(experimentId));
// 基于用户ID的确定性分配
String variantId = assignVariant(userId, experiment);
ChatClient client = variants.get(variantId);
// 执行并测量
long startTime = System.currentTimeMillis();
String response = client.prompt().user(query).call().content();
long latency = System.currentTimeMillis() - startTime;
// 记录指标
metricsCollector.record(ExperimentMetric.builder()
.experimentId(experimentId)
.variantId(variantId)
.userId(userId)
.query(query)
.response(response)
.latencyMs(latency)
.timestamp(Instant.now())
.build());
return new ExperimentResult(variantId, response, latency);
}
private String assignVariant(String userId, Experiment experiment) {
// 一致性哈希用于稳定分配
int hash = Math.abs(userId.hashCode() % 100);
int cumulative = 0;
for (Variant variant : experiment.getVariants()) {
cumulative += variant.getTrafficPercentage();
if (hash < cumulative) {
return variant.getId();
}
}
return experiment.getVariants().get(0).getId(); // 后备方案
}
}
4.2 实验配置
# experiments/chat-prompt-v2.yaml
experiment:
id: "chat-prompt-v2-test"
name: "测试新系统提示"
description: "比较简洁与详细系统提示"
start_date: "2025-01-21"
end_date: "2025-02-21"
variants:
- id: "control"
name: "当前生产版本"
traffic_percentage: 50
prompt_version: "chat-v1.0"
- id: "treatment"
name: "新简洁提示"
traffic_percentage: 50
prompt_version: "chat-v2.0"
metrics:
primary:
- name: "user_satisfaction"
type: "thumbs_up_rate"
minimum_improvement: 0.05 # 需要5%的改进
secondary:
- name: "response_latency_p95"
type: "latency_percentile"
threshold_ms: 3000
- name: "token_usage"
type: "average_tokens"
- name: "task_completion_rate"
type: "conversion"
guardrails:
min_sample_size: 1000
max_degradation: 0.10 # 如果变差10%则停止
confidence_level: 0.95