Spring AI 实战——评估生成结果

本章内容

  • 初识 Spring AI 的评估器(evaluators)
  • 检查相关性(relevancy)
  • 判断回答的正确性(correctness)
  • 在运行时应用评估器

为你的代码编写测试是一种重要实践。自动化测试不仅能确保应用没有被破坏,还能提供有助于设计与实现的反馈。对应用中的生成式 AI 组件进行测试,丝毫不比其他部分的测试不重要。

只有一个问题:如果你把相同的提示多次发送给一个 LLM,你很可能每次都会得到不同的答案。生成式 AI 的非确定性意味着测试中无法采用"断言相等(assert equals)"的套路。

在第 1 章里,你看到如何使用 WireMock 来模拟 API 的响应,从而在测试中获得确定性的 结果。这种测试方式非常适合测试"围绕向生成式 AI API 发起请求"的代码,但它并不能测试提示本身 以及模型如何对该提示作出响应。幸运的是,Spring AI 提供了另一种判断"生成响应是否可接受"的方式:评估器(Evaluators)

一个评估器会拿到提交给 LLM 的提示中的用户文本,以及模型返回的内容,然后据此判断这份响应内容是否通过某些准则。从内部机制看,评估器可以用任何适合其评估类型的方式实现。但如图 2.1 所示,评估器通常会**借助一个 LLM(通过 ChatClient)**来判断生成的响应与所提交提示之间的契合度。

图 2.1 Spring AI 评估器把提示与生成的响应一并发送给 LLM,以评估响应质量。

接下来我们看看如何使用评估器,为 BoardGameService (Board Game Buddy 应用中使用生成式 AI 的那个组件)编写一份集成测试

2.1 确保答案相关(Ensuring relevant answers)

最基础的评估形式,就是判断 LLM 是否回答了所提的问题 。也就是:生成的响应至少与提示同一话题吗?

例如,用户问 "Why is the sky blue?",LLM 回答 "Because of Rayleigh scattering"(或类似回答),那么这个答案是相关的 。反之,若 LLM 回答 "The moon is approximately 239,900 miles from Earth.",尽管它可能是正确事实,但与天空为何是蓝色 这一问题不相关

判断答案是否与给定问题相关,正是 Spring AI 的 RelevancyEvaluator 要做的事。为了理解它如何工作,让我们用它来为 BoardGameService 写一个测试。下面的清单展示了该测试类。

清单 2.1 测试 LLM 的响应是否与问题相关

typescript 复制代码
package com.example.boardgamebuddy;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.evaluation.RelevancyEvaluator;
import org.springframework.ai.evaluation.EvaluationRequest;
import org.springframework.ai.evaluation.EvaluationResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class SpringAiBoardGameServiceTests {

  @Autowired
  private BoardGameService boardGameService;

  @Autowired
  private ChatClient.Builder chatClientBuilder;

  private RelevancyEvaluator relevancyEvaluator;

  @BeforeEach
  public void setup() {
    this.relevancyEvaluator = new RelevancyEvaluator(chatClientBuilder);
  }

  @Test
  public void evaluateRelevancy() {
    String userText = "Why is the sky blue?";
    Question question = new Question(userText);
    Answer answer = boardGameService.askQuestion(question);

    EvaluationRequest evaluationRequest = new EvaluationRequest(
        userText, answer.answer());

    EvaluationResponse response = relevancyEvaluator
        .evaluate(evaluationRequest);

    Assertions.assertThat(response.isPass())
        .withFailMessage("""
          ========================================
          The answer "%s"
          is not considered relevant to the question
          "%s".
          ========================================
          """, answer.answer(), userText)
        .isTrue();
  }

}

阅读 SpringAiBoardGameServiceTests 可以看到,测试前几行在做评估所需的准备 。类上标注了 @SpringBootTest,表明这是集成测试 :会创建一个 Spring 应用上下文(包含应用中的所有 bean)。从该上下文中,通过 @Autowired 注入了 BoardGameServiceChatClient.Builder。随后在 setup() 方法里,用 ChatClient.Builder 创建了一个 RelevancyEvaluator,以供测试方法使用。

evaluateRelevancy() 测试方法首先创建一个 Question,并将其发送给注入的 BoardGameService#askQuestion() 来获得 Answer。接着用原始用户文本与答案创建 EvaluationRequest,传给 RelevancyEvaluator#evaluate()。其内部会向 LLM 发送一个提示,要求它判断答案与问题的相关性

evaluate() 返回 EvaluationResponse,随后调用 isPass() 判断评估是否通过。若返回 true,说明答案被认为与问题相关;否则返回 false,表示不相关,断言失败。

有了这个测试,你就能快速、自动 检查:通过 SpringAiBoardGameService#askQuestion() 提出的一个问题是否得到契合话题 的回答。当然,相关 并不等于正确 。我们再把测试提升一档,检查答案是否正确

2.2 测试事实正确性(Testing for factual accuracy)

假设当被问到"天空为什么是蓝色"时,LLM 回答:"The sky is blue because there are a gazillion tiny bubbles filled with blueberry jam floating in the atmosphere."(天空之所以蓝,是因为大气中漂浮着无数装满蓝莓酱的小气泡。)这个回答看起来相关 ,但显然不正确 。它也许能"躲过" RelevancyEvaluator 的法眼,但并不适合直接展示给你的用户。

Spring AI 的 FactCheckingEvaluatorRelevancyEvaluator 类似,但它不是让 LLM 判断相关性 ,而是让 LLM 判断这个回答是否正确地回答了问题。

在把事实正确性测试加进 SpringAiBoardGameServiceTests 之前,需要稍微调整一下 setup() 方法,创建一个 FactCheckingEvaluator 并赋给实例变量:

typescript 复制代码
private FactCheckingEvaluator factCheckingEvaluator;

  @BeforeEach
  public void setup() {
    this.relevancyEvaluator = new RelevancyEvaluator(chatClientBuilder);
    this.factCheckingEvaluator = new FactCheckingEvaluator(
        chatClientBuilder);
  }

现在编写事实核查测试方法:

ini 复制代码
@Test
  public void evaluateFactualAccuracy() {
    var userText = "Why is the sky blue?";
    var question = new Question(userText);
    var answer = boardGameService.askQuestion(question);

    var evaluationRequest =
            new EvaluationRequest(userText, answer.answer());

    var response =
            factCheckingEvaluator.evaluate(evaluationRequest);

    Assertions.assertThat(response.isPass())
        .withFailMessage("""

          ========================================
          The answer "%s"
          is not considered correct for the question
          "%s".
          ========================================
          """, answer.answer(), userText)
        .isTrue();
  }

evaluateFactualAccuracy() 与之前的 evaluateRelevancy() 很相似。与相关性测试一样,仍然通过 EvaluationResponse#isPass() 进行断言:若生成的答案被判定为不正确,断言就会失败。

isPass() 返回 false 导致断言失败时,失败信息会解释原因。举例来说,如果生成的答案是 "There are tiny bubbles filled with blueberry jam high in the atmosphere.",那么 FactCheckingEvaluator 会判定该回答不正确,断言失败,输出的失败信息可能如下:

csharp 复制代码
========================================
The answer "The sky is blue because there are a gazillion tiny bubbles filled
with blueberry jam floating in the atmosphere."
is not considered correct for the question
"Why is the sky blue?".
========================================

在通读本书的过程中,你会对发送给 LLM 的提示 做出许多改动。借助 evaluateRelevancy()evaluateFactualAccuracy() 这样的测试,你可以确保:无论提示如何调整 ,依然能获得恰当且正确的回答。

即便如此,还有一点可能不那么显而易见:你也许会希望在测试之外(运行时)使用评估器,以确保实际运行中返回给用户的回答同样相关正确 。接下来我们看看如何在应用代码中直接应用评估器

2.3 在运行时应用自评估(Applying self-evaluation at runtime)

即使在构建应用时基于评估器的测试全部通过,运行时 仍可能出现不相关不正确 的回答。生成式 AI 的非确定性意味着:测试阶段也许"运气不错"拿到了好回答,但到生产环境时情况却可能"跑偏"。在运行时应用评估器,可以帮助避免把糟糕答案返回给应用用户。

事实证明,评估器并不限于 用于集成测试。下面的清单展示了如何把 RelevancyEvaluatorSpring Retry 结合,避免返回与用户问题不相关的答案。

清单 2.2 在运行时代码中校验相关性(Verifying relevancy in runtime code)

java 复制代码
package com.example.boardgamebuddy;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.evaluation.RelevancyEvaluator;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.evaluation.EvaluationRequest;
import org.springframework.ai.evaluation.EvaluationResponse;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;

import java.util.List;

@Service
public class SelfEvaluatingBoardGameService implements BoardGameService {

  private final ChatClient chatClient;
  private final RelevancyEvaluator evaluator;

  public SelfEvaluatingBoardGameService(ChatClient.Builder chatClientBuilder) {
    var chatOptions = ChatOptions.builder()
        .model("gpt-4o-mini")
        .build();

    this.chatClient = chatClientBuilder
        .defaultOptions(chatOptions)
        .build();

    this.evaluator = new RelevancyEvaluator(chatClientBuilder);
  }

  @Override
  @Retryable(retryFor = AnswerNotRelevantException.class)
  public Answer askQuestion(Question question) {
    var answerText = chatClient.prompt()
        .user(question.question())
        .call()
        .content();

    evaluateRelevancy(question, answerText);

    return new Answer(answerText);
  }

  @Recover
  public Answer recover(AnswerNotRelevantException e) {
    return new Answer("I'm sorry, I wasn't able to answer the question.");
  }

  private void evaluateRelevancy(Question question, String answerText) {
    var evaluationRequest =
        new EvaluationRequest(question.question(), answerText);
    var evaluationResponse = evaluator.evaluate(evaluationRequest);
    if (!evaluationResponse.isPass()) {
      throw new AnswerNotRelevantException(question.question(), answerText);
    }
  }

}

SelfEvaluatingBoardGameServiceSpringAiBoardGameService 很相似,只是 askQuestion() 方法上添加了 @Retryable 注解。这个来自 Spring Retry 的注解表示:如果该方法抛出了 AnswerNotRelevantException,则重试该方法。

askQuestion() 中,会调用 evaluateRelevancy() 来进行相关性评估。evaluateRelevancy() 使用构造器里创建的 RelevancyEvaluator。若 isPass() 返回 falseevaluateRelevancy() 会抛出 AnswerNotRelevantException,该异常向外传播至 askQuestion(),从而触发重试。

至于 AnswerNotRelevantException,它是一个简单的非受检异常,类似如下:

scala 复制代码
package com.example.boardgamebuddy;

public class AnswerNotRelevantException extends RuntimeException {
    public AnswerNotRelevantException(String question, String answer) {
        super("The answer '" + answer + "' is not relevant to the question '" + question + "'.");
    }
}

默认情况下,带有 @Retryable 的方法会最多重试三次 。你可以通过 maxAttempts 属性进行调整。例如,将重试上限设为 5 次:

python 复制代码
@Retryable(retryFor = AnswerNotRelevantException.class, maxAttempts=5)

尽管可以通过 maxAttempts 增加重试次数,但要注意:每次重试都意味着对 LLM 多发送一次提示,同时评估器也会反复向 LLM 发送评估提示。这会增加费用 (因为在得到相关答案前可能发送了更多 token),并且如果在短时间内连续发送同样的提示,也可能触发限流 。因此把 maxAttempts 设得较低可以避免这些问题。

如果在三次(或你通过 maxAttempts 指定的次数)尝试后仍未生成相关答案,控制流程会进入 recover() 方法。recover()@Recover 注解标注,是 Spring Retry 提供的兜底 机制:当重试持续失败时调用。在 SelfEvaluatingBoardGameService 中,recover() 只是返回一个表示无法回答Answer

关于 Spring AI 的评估器,还有最后一点:虽然开箱即用的只有 RelevancyEvaluatorFactCheckingEvaluator,但它们都是基于 Spring AI 的如下 Evaluator 接口构建的:

java 复制代码
package org.springframework.ai.evaluation;

import java.util.List;
import java.util.stream.Collectors;

import org.springframework.ai.document.Document;
import org.springframework.util.StringUtils;

@FunctionalInterface
public interface Evaluator {

    EvaluationResponse evaluate(EvaluationRequest evaluationRequest);

    default String doGetSupportingData(EvaluationRequest evaluationRequest) {
        List<Document> data = evaluationRequest.getDataList();
        return data.stream()
            .map(Document::getText)
            .filter(StringUtils::hasText)
            .collect(Collectors.joining(System.lineSeparator()));
    }

}

RelevancyEvaluatorFactCheckingEvaluator 不能满足 你的评估需求,你可以实现 Evaluator 接口自定义评估器

小结(Summary)

  • 生成式 AI 的非确定性使测试变得棘手。
  • Spring AI 提供评估器 ,可用于对生成的响应进行断言
  • 评估器通过向 LLM 发送提示,来判断响应的相关性事实正确性
  • 评估器可在运行时 应用;若返回不满意的结果,可以重试提示。
相关推荐
行思理3 小时前
IntelliJIdea 工具新手操作技巧
java·spring·intellijidea
该用户已不存在3 小时前
免费的 Vibe Coding 助手?你想要的Gemini CLI 都有
人工智能·后端·ai编程
大模型教程4 小时前
从 RAG 到 CAG:AI 正在超越“检索”,学会“融会贯通”!
程序员·llm·agent
大模型教程4 小时前
传统RAG的局限被打破!三个轻量级智能体分工协作,如何让问答系统更精准?
程序员·llm·agent
AI大模型4 小时前
大模型入门第一课:彻底搞懂Token!
程序员·llm·agent
AI大模型4 小时前
大模型入门第二课:初识Embedding——让文字拥有"位置"的魔法
程序员·llm·agent
一 铭4 小时前
Claude Agent Skills:一种基于 Prompt 扩展的元工具架构
人工智能·大模型·llm·prompt
一只柠檬新5 小时前
当AI开始读源码,调Bug这件事彻底变了
android·人工智能·ai编程
常先森5 小时前
【解密源码】 RAGFlow 切分最佳实践- naive parser 语义切块(markdown 篇)
架构·llm·agent