第2141篇:LLM应用的测试策略——如何对"不确定性"做自动化测试
第2141篇:LLM应用的测试策略——如何对"不确定性"做自动化测试
适读人群:负责AI应用质量保障的工程师和测试工程师 | 阅读时长:约18分钟 | 核心价值:掌握LLM应用的测试方法论,解决"AI输出不确定,如何做自动化回归测试"的工程难题
"LLM的输出是不确定的,怎么写单元测试?"
这是新手做AI应用时最常遇到的困惑。传统软件测试的核心假设是:给定相同的输入,总是得到相同的输出。LLM打破了这个假设——同一个问题,今天的回答和昨天可能不同,更新了模型后可能又不同。
这不代表AI应用不能测试,而是需要换一套测试思维:从"精确匹配"转向"质量评估",从测试固定输出转向测试输出质量。
我们团队在两个AI项目上跑了相对完整的测试体系,踩了不少坑,这篇文章分享我们总结出来的可落地方案。
AI应用测试的层次结构
/**
* AI应用测试体系
*
* ===== 层次一:单元测试(非LLM部分)=====
*
* 这部分和传统测试一样,可以做精确断言:
* - Prompt模板渲染是否正确
* - 输出解析器是否正确处理各种格式
* - 工具/函数执行逻辑
* - 上下文管理(截断、压缩逻辑)
* - 路由逻辑(哪类问题走哪个处理流程)
*
* ===== 层次二:集成测试(使用Mock LLM)=====
*
* 测试各组件的协作,但不实际调用LLM:
* - RAG流程:检索→拼装→[Mock LLM]→解析
* - Agent工具调用流程
* - 错误处理和降级流程
*
* 用Mock LLM:速度快、无API费用、可控输出
*
* ===== 层次三:质量评估测试(用LLM评判LLM)=====
*
* 真实调用LLM,用另一个LLM评估输出质量:
* - Accuracy:回答是否准确
* - Completeness:回答是否完整
* - Relevance:回答是否相关
* - Safety:回答是否安全
*
* 这是AI测试特有的层次
*
* ===== 层次四:回归测试(黄金数据集)=====
*
* 维护一组"标准问答对",每次发布前对比:
* - 和上次结果比,质量没有下降
* - 和人工标注的答案比,质量达标
*
* ===== 层次五:线上评估(生产流量采样)=====
*
* 对生产请求进行抽样评估:
* - 用户满意度信号(点赞/点踩)
* - 自动化质量打分
* - A/B测试不同版本
*/Prompt模板的单元测试
/**
* Prompt模板测试
*
* 不涉及LLM调用,可以做精确断言
*/
@SpringBootTest
class PromptTemplateTest {
@Autowired
private PromptService promptService;
@Test
void testVariableRendering() {
// 测试Prompt变量是否正确渲染
PromptTemplate template = PromptTemplate.builder()
.templateKey("customer-service-greeting")
.content("你好,{{userName}}!我是AI助手,今天是{{date}},很高兴为您服务。{{#if hasOrder}}您有{{orderCount}}个待处理订单。{{/if}}")
.variablesJson("[\"userName\", \"date\", \"hasOrder\", \"orderCount\"]")
.build();
Map<String, String> variables = Map.of(
"userName", "张三",
"date", "2024年1月15日",
"hasOrder", "true",
"orderCount", "2"
);
String rendered = promptService.render(template, variables);
assertThat(rendered).contains("张三");
assertThat(rendered).contains("2024年1月15日");
assertThat(rendered).contains("2个待处理订单");
}
@Test
void testMissingRequiredVariable() {
// 测试缺少必填变量时的行为
PromptTemplate template = PromptTemplate.builder()
.templateKey("test-template")
.content("用户 {{userId}} 的查询:{{query}}")
.variablesJson("[\"userId\", \"query\"]")
.build();
// 只提供了一个变量
Map<String, String> variables = Map.of("userId", "123");
// 应该检测到缺失变量
List<String> missing = promptService.checkMissingVariables(template, variables);
assertThat(missing).contains("query");
}
@Test
void testTokenCountEstimation() {
// 测试Token估算是否在合理范围内
String longText = "这是一段很长的文本,".repeat(100);
int estimatedTokens = promptService.estimateTokens(longText);
// 中文每个字大约0.5-1个token,1000字大概500-1000 tokens
assertThat(estimatedTokens).isBetween(400, 1500);
}
}输出解析器的测试
/**
* 输出解析器测试
*
* 测试各种LLM可能返回的格式,确保解析器健壮
*/
@SpringBootTest
class OutputParserTest {
@Autowired
private RobustJsonParser jsonParser;
@Test
void testCleanJsonParsing() {
String response = """
{"name": "张三", "age": 30, "city": "北京"}
""";
Optional<Map<String, Object>> result = jsonParser.parseObject(response.trim());
assertThat(result).isPresent();
assertThat(result.get()).containsEntry("name", "张三");
}
@Test
void testJsonInMarkdownBlock() {
// LLM经常把JSON包在markdown代码块里
String response = """
好的,这是分析结果:
```json
{"status": "success", "count": 5}
```
以上就是分析。
""";
Optional<Map<String, Object>> result = jsonParser.parseObject(response);
assertThat(result).isPresent();
assertThat(result.get()).containsEntry("status", "success");
}
@Test
void testJsonWithTrailingComma() {
// LLM有时会生成带尾随逗号的JSON(严格JSON不允许)
String response = """
{
"items": ["a", "b", "c",],
"total": 3,
}
""";
Optional<Map<String, Object>> result = jsonParser.parseObject(response);
// 我们的解析器应该能处理这种情况
assertThat(result).isPresent();
}
@Test
void testMalformedJsonFallback() {
// 完全无法解析的情况,应该返回empty,不抛异常
String response = "这不是JSON,这是一段普通的中文文本。";
Optional<Map<String, Object>> result = jsonParser.parseObject(response);
assertThat(result).isEmpty();
}
@Test
void testListParsing() {
String response = """
["建议1:提高响应速度", "建议2:优化界面", "建议3:增加功能"]
""";
Optional<List<String>> result = jsonParser.parseList(response.trim(), String.class);
assertThat(result).isPresent();
assertThat(result.get()).hasSize(3);
assertThat(result.get().get(0)).contains("响应速度");
}
}用Mock LLM做集成测试
/**
* Mock LLM的实现
*
* 用于集成测试,模拟LLM的各种行为:
* - 正常回答
* - 超时
* - API错误
* - 特定格式的输出
*/
public class MockChatLanguageModel implements ChatLanguageModel {
// 预设的响应映射:输入特征 → 输出
private final Map<String, String> responseMap = new LinkedHashMap<>();
// 是否模拟延迟
private long simulatedDelayMs = 0;
// 是否模拟失败
private boolean shouldFail = false;
private String failureReason = "";
// 失败计数(用于测试重试逻辑)
private int failCount = 0;
private int failForFirstN = 0;
/**
* 添加预设响应
*/
public MockChatLanguageModel whenContains(String inputKeyword, String response) {
responseMap.put(inputKeyword, response);
return this;
}
/**
* 设置模拟延迟
*/
public MockChatLanguageModel withDelay(long delayMs) {
this.simulatedDelayMs = delayMs;
return this;
}
/**
* 设置前N次调用失败(用于测试重试)
*/
public MockChatLanguageModel failFirstNTimes(int n) {
this.failForFirstN = n;
return this;
}
@Override
public String generate(String userMessage) {
if (simulatedDelayMs > 0) {
try { Thread.sleep(simulatedDelayMs); } catch (InterruptedException e) { /* ignore */ }
}
// 模拟前N次失败
if (failCount < failForFirstN) {
failCount++;
throw new RuntimeException("模拟API失败(第" + failCount + "次)");
}
// 查找匹配的预设响应
for (Map.Entry<String, String> entry : responseMap.entrySet()) {
if (userMessage.contains(entry.getKey())) {
return entry.getValue();
}
}
// 默认响应
return "{\"status\": \"ok\", \"message\": \"mock response\"}";
}
// 其他方法的Mock实现...
@Override
public String generate(List<ChatMessage> messages) {
String lastMessage = messages.isEmpty() ? "" :
messages.get(messages.size() - 1).text();
return generate(lastMessage);
}
}
/**
* RAG流程的集成测试
*/
@SpringBootTest
class RagServiceIntegrationTest {
@Autowired
private VectorStore vectorStore;
@Autowired
private EmbeddingModel embeddingModel;
private TwoStageRagService ragService;
@BeforeEach
void setUp() {
// 用Mock LLM替换真实LLM
MockChatLanguageModel mockLlm = new MockChatLanguageModel()
.whenContains("退款",
"""
根据提供的资料,退款流程如下:
1. 登录账户,进入订单列表
2. 找到要退款的订单,点击"申请退款"
3. 填写退款原因
4. 等待审核(1-3个工作日)
""")
.whenContains("发货时间",
"通常在下单后24小时内发货,节假日可能延迟。");
ragService = new TwoStageRagService(vectorStore, embeddingModel,
mockReranker(), mockLlm);
// 准备测试数据
setupTestDocuments();
}
@Test
void testRetrievalAndGeneration() {
// 测试RAG能否检索到相关文档并生成回答
var result = ragService.retrieveAndGenerate("如何退款", 20, 5);
assertThat(result.getAnswer()).isNotBlank();
assertThat(result.getUsedContexts()).isNotEmpty();
// 应该检索到退款相关的文档
assertThat(result.getUsedContexts().stream()
.anyMatch(ctx -> ctx.contains("退款"))).isTrue();
}
@Test
void testNoResultsFallback() {
// 测试当知识库里没有相关内容时的处理
var result = ragService.retrieveAndGenerate(
"量子力学中的薛定谔方程", 20, 5);
// 没有相关内容时,应该返回无法回答的说明
assertThat(result.getUsedContexts()).isEmpty();
assertThat(result.getAnswer()).contains("无法回答");
}
private void setupTestDocuments() {
// 在向量库里插入测试文档
String refundContent = "退款流程:用户可以在购买后7天内申请退款...";
float[] vector = embeddingModel.embed(refundContent).content().vector();
vectorStore.add(VectorStore.Document.builder()
.id("test-refund-doc")
.content(refundContent)
.vector(vector)
.metadata(Map.of("type", "faq", "category", "退款"))
.build());
}
private LocalRerankerService mockReranker() {
// Mock Reranker:直接按输入顺序返回,不改变顺序
return new LocalRerankerService(null) {
@Override
public List<RerankResult> rerank(String query, List<String> candidates) {
List<RerankResult> results = new ArrayList<>();
for (int i = 0; i < candidates.size(); i++) {
results.add(new RerankResult(i, candidates.get(i), 0.8f - i * 0.1f));
}
return results;
}
};
}
}LLM质量评估测试
/**
* LLM评判LLM的质量测试
*
* 用一个LLM作为"裁判",评估另一个LLM的输出质量
*
* 关键:裁判LLM和被测LLM要用不同的模型,
* 避免系统性偏差(自己给自己打高分)
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class LlmQualityEvaluator {
// 裁判模型(通常用更强的模型)
@Qualifier("judge-llm")
private final ChatLanguageModel judgeLlm;
/**
* 评估回答质量
*
* 从多个维度打分,给出0-10的分数
*/
public QualityEvaluation evaluate(
String question, String answer, String referenceAnswer) {
String evalPrompt = """
你是一个客观的质量评估助手。评估以下AI回答的质量。
用户问题:%s
参考答案(标准答案):%s
AI的回答:%s
请从以下维度评分(0-10分):
1. 准确性:回答是否准确、有没有错误信息
2. 完整性:是否回答了问题的所有方面
3. 相关性:回答是否紧扣问题,没有跑题
4. 可读性:回答是否清晰易懂
返回JSON:
{
"accuracy": 分数,
"completeness": 分数,
"relevance": 分数,
"readability": 分数,
"overallScore": 综合分(加权平均),
"feedback": "简短的评价说明",
"issues": ["问题1", "问题2"]
}
只返回JSON。
""".formatted(question, referenceAnswer, answer);
try {
String response = judgeLlm.generate(evalPrompt);
String json = extractJson(response);
ObjectMapper mapper = new ObjectMapper();
JsonNode node = mapper.readTree(json);
return new QualityEvaluation(
node.path("accuracy").asDouble(),
node.path("completeness").asDouble(),
node.path("relevance").asDouble(),
node.path("readability").asDouble(),
node.path("overallScore").asDouble(),
node.path("feedback").asText(),
mapper.convertValue(node.path("issues"), new TypeReference<List<String>>() {})
);
} catch (Exception e) {
log.error("质量评估失败: {}", e.getMessage());
return QualityEvaluation.evaluationFailed();
}
}
/**
* 批量评估,生成整体质量报告
*/
public QualityReport batchEvaluate(List<TestCase> testCases,
Function<String, String> answerGenerator) {
List<QualityEvaluation> evaluations = new ArrayList<>();
int passed = 0, failed = 0;
for (TestCase tc : testCases) {
try {
String generatedAnswer = answerGenerator.apply(tc.question());
QualityEvaluation eval = evaluate(tc.question(), generatedAnswer, tc.expectedAnswer());
evaluations.add(eval);
// 综合分 >= 7 视为通过
if (eval.overallScore() >= 7.0) {
passed++;
} else {
failed++;
log.warn("测试用例未通过: question={}, score={}, issues={}",
tc.question(), eval.overallScore(), eval.issues());
}
} catch (Exception e) {
failed++;
log.error("测试用例执行异常: question={}", tc.question(), e);
}
}
double avgScore = evaluations.stream()
.mapToDouble(QualityEvaluation::overallScore)
.average().orElse(0);
return new QualityReport(testCases.size(), passed, failed, avgScore, evaluations);
}
private String extractJson(String s) {
int start = s.indexOf('{'); int end = s.lastIndexOf('}');
return (start >= 0 && end > start) ? s.substring(start, end + 1) : "{}";
}
public record QualityEvaluation(double accuracy, double completeness, double relevance,
double readability, double overallScore,
String feedback, List<String> issues) {
public static QualityEvaluation evaluationFailed() {
return new QualityEvaluation(0, 0, 0, 0, 0, "评估失败", List.of("评估过程出错"));
}
public boolean isPassed() { return overallScore >= 7.0; }
}
public record TestCase(String id, String question, String expectedAnswer,
String category) {}
public record QualityReport(int total, int passed, int failed, double avgScore,
List<QualityEvaluation> evaluations) {
public double passRate() { return (double) passed / total; }
}
}回归测试框架
/**
* 回归测试框架
*
* 每次发布前,对黄金数据集做全量测试,
* 确保新版本的质量不比旧版本差
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class RegressionTestRunner {
private final LlmQualityEvaluator evaluator;
private final GoldenDatasetRepository datasetRepo;
private final RegressionResultRepository resultRepo;
/**
* 运行回归测试
*
* @param version 当前版本号
* @param answerGenerator 被测系统的回答生成函数
* @param baselineVersion 基准版本(和这个版本比较)
*/
public RegressionTestReport runRegression(
String version,
Function<String, String> answerGenerator,
String baselineVersion) {
log.info("开始回归测试: version={}, baseline={}", version, baselineVersion);
List<LlmQualityEvaluator.TestCase> goldenDataset = datasetRepo.findAll();
// 运行当前版本的测试
LlmQualityEvaluator.QualityReport currentReport =
evaluator.batchEvaluate(goldenDataset, answerGenerator);
// 保存当前版本的结果
saveResults(version, goldenDataset, currentReport);
// 和基准版本对比
RegressionTestReport report;
if (baselineVersion != null) {
List<RegressionResult> baselineResults =
resultRepo.findByVersion(baselineVersion);
report = compareWithBaseline(version, currentReport, baselineResults);
} else {
report = RegressionTestReport.firstRun(version, currentReport);
}
log.info("回归测试完成: version={}, passRate={:.1f}%, avgScore={:.1f}",
version, report.passRate() * 100, report.avgScore());
return report;
}
/**
* 与基准版本对比,找出质量下降的用例
*/
private RegressionTestReport compareWithBaseline(
String version, LlmQualityEvaluator.QualityReport currentReport,
List<RegressionResult> baselineResults) {
Map<String, Double> baselineScores = baselineResults.stream()
.collect(Collectors.toMap(
RegressionResult::testCaseId,
RegressionResult::overallScore
));
List<QualityRegression> regressions = new ArrayList<>();
List<QualityImprovement> improvements = new ArrayList<>();
// 比较每个测试用例的分数变化
for (int i = 0; i < currentReport.evaluations().size(); i++) {
LlmQualityEvaluator.QualityEvaluation eval = currentReport.evaluations().get(i);
// 假设顺序和测试用例一一对应
// 实际实现中需要通过ID关联
String caseId = "case-" + i; // 简化
Double baselineScore = baselineScores.get(caseId);
if (baselineScore != null) {
double delta = eval.overallScore() - baselineScore;
if (delta < -1.0) { // 下降超过1分
regressions.add(new QualityRegression(caseId, baselineScore,
eval.overallScore(), delta, eval.issues()));
} else if (delta > 1.0) { // 提升超过1分
improvements.add(new QualityImprovement(caseId, baselineScore,
eval.overallScore(), delta));
}
}
}
boolean hasSignificantRegression = !regressions.isEmpty() ||
currentReport.passRate() < 0.8; // 通过率低于80%视为显著退步
return new RegressionTestReport(
version, currentReport, regressions, improvements, hasSignificantRegression
);
}
private void saveResults(String version,
List<LlmQualityEvaluator.TestCase> cases,
LlmQualityEvaluator.QualityReport report) {
for (int i = 0; i < cases.size(); i++) {
LlmQualityEvaluator.TestCase tc = cases.get(i);
LlmQualityEvaluator.QualityEvaluation eval = report.evaluations().get(i);
resultRepo.save(RegressionResult.builder()
.version(version)
.testCaseId(tc.id())
.overallScore(eval.overallScore())
.isPassed(eval.isPassed())
.testedAt(LocalDateTime.now())
.build());
}
}
@Builder
public record RegressionResult(String version, String testCaseId, double overallScore,
boolean isPassed, LocalDateTime testedAt) {}
public record QualityRegression(String caseId, double baselineScore, double currentScore,
double delta, List<String> issues) {}
public record QualityImprovement(String caseId, double baselineScore,
double currentScore, double delta) {}
public record RegressionTestReport(String version,
LlmQualityEvaluator.QualityReport qualityReport,
List<QualityRegression> regressions,
List<QualityImprovement> improvements,
boolean hasSignificantRegression) {
public static RegressionTestReport firstRun(String version,
LlmQualityEvaluator.QualityReport report) {
return new RegressionTestReport(version, report, List.of(), List.of(), false);
}
public double passRate() { return qualityReport.passRate(); }
public double avgScore() { return qualityReport.avgScore(); }
public boolean shouldBlockRelease() {
return hasSignificantRegression || passRate() < 0.75;
}
}
}实践建议
黄金数据集是整个测试体系的核心资产,要认真维护
黄金数据集就是一组"问题+标准答案"的测试用例,它是判断AI质量好坏的基准。很多团队随便写几条就完事了,这是最常见的错误。好的黄金数据集要有:覆盖产品核心功能的代表性问题(不能全是简单问题)、边界情况(模糊问题、复杂问题)、系统不擅长的问题类型(帮助识别盲区)。数据集要定期更新:把线上用户投诉的问题加进去,把发现的高频问题加进去。我们的经验是:100条高质量测试用例,比1000条随意凑的用例更有价值。
LLM评判LLM存在偏差,要校准
用GPT-4评判另一个AI的输出质量,会有偏差:同公司模型(比如都是OpenAI的)可能互相打高分;评判模型也会受被评判内容的影响(verbose回答可能被评为更完整)。校准方法:随机抽取10%的样本,让人工也评估一遍,计算LLM评估和人工评估的相关系数。如果相关系数低于0.7,说明LLM评分器不可信,需要改进评估Prompt。我们通过这个方法发现评估Prompt里缺了重要的说明,修复后相关系数从0.62提升到了0.84。
把回归测试加入CI/CD流水线,但要考虑成本
每次PR都跑完整的回归测试太贵(100条用例 × 2次LLM调用/用例 × API费用),可能每次花几十块钱。实用方案:每次PR只跑快速版(不调LLM,只做规则检查和Mock测试);每天凌晨自动跑一次完整的质量评估;发布前必须手动触发一次完整回归,结果达标才能放行。这样在成本可控的前提下,保证了关键节点的质量检查。
