Ohhnews

分类导航

$ cd ..
Baeldung原文

在Spring AI中使用递归顾问构建LLM评判器

#spring ai#llm评判器#递归顾问#质量门控#生成优化

1. 概述

借助 Spring AI,我们可以将 LLM 集成到 Spring 应用中。这对于处理非结构化数据(如维基页面、电子邮件或聊天消息)非常有用,例如通过提取结构化信息并将其存储到数据库中。然而,LLM 的响应具有不确定性,因此既无法测试,也无法被应用代码轻松处理。实现 LLM 响应质量门控的最佳方式是再次使用 LLM 进行评估。这种模式被称为 LLM-as-a-Judge

在本教程中,我们将学习如何在 Spring AI 中实现 LLM-as-a-Judge 模式。

2. Maven 依赖

我们在 pom.xml 中添加 Spring AI OpenAI 启动器:

$ xml
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-openai</artifactId>
    <version>${spring-ai.version}</version>
</dependency>

最新版本的 spring-ai-starter-model-openai 可在 Maven Central 上获取。

3. LLM-as-a-Judge 模式

LLM-as-a-Judge 模式类似于代码评审。开发者(生成器)编写代码,高级工程师(评审者)进行审查并给出结构化反馈。如果评审者认为代码不符合要求,开发者需要改进,然后评审者再次审查。

应用到 LLM 上,流程如下:

  • 生成器 对用户问题生成初始回答。
  • 评审者 根据评分规则对该回答进行评估,返回数值评分和书面反馈。
  • 如果评分过低,生成器将收到原始问题以及评审者的批评,并生成改进后的回答。
  • 这个过程会重复,直到回答满足要求。为避免因始终无法达到所需质量而无限循环,最佳实践是限制循环次数。

这种模式无需微调模型、无需人工介入、无需修改调用代码,即可提升输出质量。

该模式在弱回答会造成实际影响的场景中价值最大,例如客服机器人——若对账单问题的回答含糊不清,会直接损害信任。在这种情况下,该模式充当了模型和用户之间无形的质量门控。

3.1. 为什么递归顾问是合适的选择

Spring AI 中的 Advisors(顾问) 是拦截器,包裹了 ChatClient 的请求/响应周期。CallAroundAdvisor 可以在请求到达模型前进行检查,并在响应返回调用方前修改响应,但每次调用只执行一次模型调用。

递归顾问(Recursive Advisors) 更进一步:它们持有一个内部的 ChatClient 引用,可以在顾问自身内部发起额外的模型调用。

我们可以将此能力应用于一个特定问题:将第二个模型调用用作评审者,而不仅仅是扩展。 生成-评审-改进循环成为一个自包含的组件,对调用方完全透明。

调用方像往常一样调用 chatClient.prompt().user("...").call().content(),评审逻辑完全不可见。

4. 实现 LLM-as-a-Judge 顾问

我们将该功能分为三部分构建:用于判决的值对象、评审者提示模板,以及顾问本身。

4.1. 判决记录

评审者返回一个结构化的判决。我们将其建模为一个简单的 Java 记录:

$ java
public record Verdict(double score, String feedback) {}

在本示例中,score 的取值范围是 0.0(差)到 1.0(优秀)。feedback 解释缺失或薄弱之处,在改进阶段会反馈给生成器。

4.2. 评审者提示模板

评审者需要一个清晰、结构化的系统提示。我们将其定义为顾问内部的常量:

$ java
private static final String JUDGE_SYSTEM_PROMPT = """
    You are a strict quality evaluator for AI-generated answers.
    
    Given a user question and an AI-generated answer, rate the answer quality.
    
    Use this rubric:
    - 1.0: Complete, accurate, and clearly explained
    - 0.7: Mostly correct but missing details or clarity
    - 0.4: Partially correct or overly vague
    - 0.0: Incorrect, irrelevant, or harmful
    
    Respond ONLY with a valid JSON object. Do not add any explanation outside the JSON.
    Format: {"score": <0.0 to 1.0>, "feedback": "<one concise sentence>"}
    """;

声明明确的评分规则和严格的 JSON 输出格式,对于从评审模型获取可解析且一致的答案至关重要。

4.3. 顾问类及其依赖

递归顾问的关键设计决策是:它持有自己的 ChatClient 实例,以便独立于顾问链发起额外的模型调用。 我们还注入了可配置的质量阈值:

$ java
public class LlmJudgeAdvisor implements CallAdvisor {
    private final ChatClient judgeClient;
    private final double scoreThreshold;
    private final int maxRefinements;
    public LlmJudgeAdvisor(
      ChatClient judgeClient,
      double scoreThreshold,
      int maxRefinements
    ) {
        this.judgeClient = judgeClient;
        this.scoreThreshold = scoreThreshold;
        this.maxRefinements = maxRefinements;
    }
    @Override
    public int getOrder() {
        return 0;
    }
    @Override
    public String getName() {
        return "LlmJudgeAdvisor";
    }
    // ...
}

ChatClient.Builder 接收与应用其余部分相同的自动配置模型设置。我们也可以在此处接入一个不同的、专门的评审模型。官方 Spring AI 文档 特别建议这样做,以避免模型对自己的输出评价过于宽松。

4.4. 评估和改进响应

adviseCall() 是顾问链调用的唯一入口点。我们不直接转发请求一次,而是调用 chain.copy(this).nextCall(request),这会创建一个包含我们顾问的子链。这使得循环能够跨多次尝试重新评估和重新生成:

$ java
@Override
public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
    for (int attempt = 1; attempt <= maxRefinements + 1; attempt++) { 
        ChatClientResponse response = chain.copy(this).nextCall(request);
        if (attempt > maxRefinements) {
          return response;
        }
        Verdict verdict = evaluate(request, response); 
        if (verdict.score() >= scoreThreshold) {
            return response;
        }
        request = addFeedback(request, verdict.feedback());
    }
    return chain.copy(this).nextCall(request);
}

循环需要显式上界以避免无限制递归。在最后一次允许的尝试中,方法直接返回响应,跳过评估。如果评分满足 scoreThreshold,则提前返回。否则,使用评审者的反馈增强请求,并继续下一次迭代。

4.5. 评估回答

evaluate() 方法将原始问题和生成的回答发送给评审模型。 我们可以使用 .entity(...) 直接将响应 JSON 渲染到我们的记录中,无需手动解析 JSON:

$ java
private Verdict evaluate(ChatClientRequest request, ChatClientResponse response) {
    String question = request.prompt().getUserMessage().getText();
    String answer = response.chatResponse().getResult().getOutput().getText();
    return judgeClient.prompt()
      .system(JUDGE_SYSTEM_PROMPT)
      .user("Question: " + question + "\n\nAnswer: " + answer)
      .call()
      .entity(Verdict.class);
}

Spring AI 会将从 Verdict 记录派生的 JSON schema 注入到模型调用中,并处理反序列化。这消除了任何 try-catch 解析 JSON 的需要,使评估代码专注于提示,而非格式处理。

4.6. 使用反馈增强请求

当评分不达标时,我们不会使用原始提示重新开始,而是进行增强——即将评审者的批评附加到用户消息中,以便模型在下次尝试时拥有完整上下文。addFeedback() 辅助方法使用 ChatClientRequest.mutate() 实现:

$ java
private ChatClientRequest addFeedback(ChatClientRequest original, String feedback) {
    Prompt augmented = original.prompt()
      .augmentUserMessage(msg -> msg.mutate()
          .text(msg.getText()
              + "\n\nYour previous answer was insufficient. Feedback: " + feedback
              + "\nPlease provide an improved answer.")
          .build());
    return original.mutate().prompt(augmented).build();
}

augmentUserMessage() 为我们提供了用户消息的可变副本,而不影响请求的其余部分。 系统提示、工具定义和对话历史均保持不变。更新后的 ChatClientRequest 被传递到下一个循环迭代。

5. 配置应用程序

我们在一个 @Configuration 类中声明两个 Bean。LlmJudgeAdvisor Bean 接收自己的 ChatClient 实例(从相同的自动配置 ChatClient.Builder 构建),以及来自 application.properties 的质量阈值:

$ java
@Configuration
public class ChatConfig {
    @Bean
    public ChatClient chatClient(
      ChatClient.Builder builder, 
      LlmJudgeAdvisor judgeAdvisor
    ) {
        return builder
          .defaultAdvisors(judgeAdvisor)
          .build();
    }
    @Bean
    public LlmJudgeAdvisor llmJudgeAdvisor(
      ChatClient.Builder builder,
      @Value("${judge.score-threshold:0.7}") double scoreThreshold,
      @Value("${judge.max-refinements:2}") int maxRefinements
    ) {
        return new LlmJudgeAdvisor(
          builder.build(),
          scoreThreshold,
          maxRefinements
        );
    }
}

然后在 application.properties 中设置阈值:

$ properties
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini
judge.score-threshold=0.75
judge.max-refinements=2

评分阈值设为 0.75 意味着评审者必须对回答给出至少 75% 的质量评分,我们才接受。将最大改进次数设为 2 限制递归循环最多进行两次改进尝试,防止 API 使用失控。

6. 测试顾问

我们实现一个使用单一模拟 ChatModel@SpringBootTest 生成器和评审者使用相同的底层实例,因此一个模拟即可覆盖整个调用序列。无需 API 密钥。

测试模拟了四个连续的 chatModel 响应,匹配预期的流程:

$ java
@SpringBootTest
class LlmJudgeAdvisorTest {
    @MockitoBean
    ChatModel chatModel;
    @Autowired
    ChatClient chatClient;
    @Test
    void givenLowQualityAnswer_whenAdvisorRuns_thenAnswerIsRefined() {
        when(chatModel.call(any(Prompt.class)))
          .thenReturn(buildChatResponse("It runs Java."))
          .thenReturn(buildChatResponse("{\"score\": 0.3, \"feedback\": \"Too vague.\"}"))
          .thenReturn(buildChatResponse(
            "The JVM executes Java bytecode, manages memory, and enables platform independence."))
          .thenReturn(buildChatResponse("{\"score\": 0.9, \"feedback\": \"Complete and accurate.\"}"));
        String result = chatClient.prompt()
          .user("Explain what a JVM is.")
          .call()
          .content();
        assertThat(result).contains("bytecode");
    }
    private ChatResponse buildChatResponse(String content) {
        return new ChatResponse(List.of(new Generation(new AssistantMessage(content))));
    }
}

四个模拟响应直接对应生成-评估-改进-评估循环:第一个是生成器的弱响应,第二个是评审者的低分判决,第三个是生成器的改进响应,第四个是评审者的通过判决。带有顾问的 chatClient 是真实的,只有底层的模型是模拟的。这意味着完整的顾问逻辑会像在生产环境中一样运行。

7. 结论

在本文中,我们使用递归 CallAdvisor 在 Spring AI 中实现了 LLM-as-a-Judge 模式。我们看到了生成-评估-改进循环如何自然地映射到顾问模型上:第一个模型调用产生回答,Spring AI 的结构化输出将评审者的响应映射为类型化的 Verdict,循环使用反馈增强原始请求,直到评分足够或达到尝试上限。

结果是一个可重用的组件,能自动提升输出质量,并且对任何使用 ChatClient 的调用方完全透明。

本教程的代码可在 GitHub 上获取。