Artículo

Construyendo LLM como Juez usando Asesores Recursivos en Spring AI

Construyendo LLM como Juez usando Asesores Recursivos en Spring AI

1. Visión general

Con Spring AI, podemos integrar LLMs en nuestras aplicaciones Spring. Esto resulta útil, por ejemplo, para procesar datos no estructurados como páginas wiki, correos electrónicos o mensajes de chat, extrayendo información estructurada para almacenarla en una base de datos. Sin embargo, las respuestas de los LLM no son determinísticas y, por lo tanto, ni testeables ni manejables por nuestro código de aplicación. La mejor manera de implementar un tipo de puerta de calidad para las respuestas de LLM es usar un LLM nuevamente para la evaluación**.** Este patrón se llama LLM-as-a-judge.

En este tutorial, aprenderemos cómo implementar el patrón LLM-as-a-Judge en Spring AI.

2. Dependencias de Maven

Añadimos el iniciador Spring AI OpenAI a nuestro pom.xml:

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

La última versión de spring-ai-starter-model-openai está disponible en Maven Central.

3. El patrón LLM-as-a-Judge

El patrón LLM-as-a-Judge es similar a una revisión de código. Un desarrollador (el generador) escribe código, y un ingeniero senior (el juez) lo revisa y brinda retroalimentación estructurada. Si el juez decide que el código no cumple con los requisitos, el desarrollador debe mejorarlo y el juez debe revisarlo de nuevo.

Aplicado a los LLM, el proceso es:

  • El generador produce una respuesta inicial a la pregunta del usuario
  • El juez evalúa esa respuesta según una rúbrica y devuelve una puntuación numérica y retroalimentación escrita
  • Si la puntuación es demasiado baja, el generador recibe la pregunta original más la crítica del juez y produce una respuesta refinada.
  • Esto se repetirá hasta que la respuesta cumpla con los requisitos. Para evitar repeticiones infinitas en caso de que nunca se alcance la calidad necesaria, es una buena práctica limitar el número de bucles.

Este patrón mejora la calidad del resultado sin ajustar el modelo, sin intervención humana y sin cambiar una sola línea de código llamador.

Es más valioso en aquellos lugares donde una respuesta débil tiene consecuencias reales, por ejemplo, en bots de soporte al cliente, donde una respuesta vaga a una pregunta de facturación deteriora la confianza directamente. En tales casos, el patrón actúa como una puerta de calidad invisible entre el modelo y el usuario.

3.1. Por qué los asesores recursivos son la opción adecuada

Los asesores en Spring AI son interceptores que envuelven el ciclo de solicitud/respuesta de un ChatClient. Un CallAroundAdvisor puede inspeccionar una solicitud antes de que llegue al modelo y modificar la respuesta antes de que llegue al llamador, pero solo realiza una llamada al modelo por invocación.

Los asesores recursivos van más allá: mantienen una referencia interna de ChatClient y pueden hacer llamadas adicionales al modelo desde dentro del propio asesor.

Podemos aplicar esa capacidad a un problema específico: usar la segunda llamada al modelo como juez, no solo como extensión. El bucle generar-juzgar-refinar se convierte en un componente único y autónomo que es completamente transparente para el llamador.

El llamador simplemente invoca chatClient.prompt().user("...").call().content() como de costumbre. La lógica del juez es invisible.

4. Implementación del Asesor LLM-as-a-Judge

Construyamos la funcionalidad en tres partes: un objeto de valor para el veredicto, una plantilla de prompt para el juez y el propio asesor.

4.1. El Registro de Veredicto

El juez devuelve un veredicto estructurado. Lo modelamos como un registro Java simple:

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

En nuestro ejemplo, el score es un valor entre 0.0 (pobre) y 1.0 (excelente). El feedback explica lo que falta o es débil, y es lo que alimentaremos al generador durante la refinación.

4.2. La Plantilla de Prompt del Juez

El juez necesita un prompt del sistema claro y estructurado. Para ello, lo definimos como una constante dentro del asesor:

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>"}
    """;

Es importante declarar una rúbrica explícita y una salida JSON estricta para obtener una respuesta analizable y consistente del modelo juez.

4.3. La Clase del Asesor y Sus Dependencias

La decisión de diseño clave de un asesor recursivo es que mantiene su propia instancia de ChatClient para realizar llamadas adicionales al modelo de forma independiente de la cadena de asesores. También inyectamos los umbrales de calidad configurables:

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";
    }

    // ...
}

El ChatClient.Builder recibe los mismos ajustes de modelo auto-configurados que el resto de la aplicación. Podríamos conectar un modelo juez diferente y especializado aquí. La documentación oficial de Spring AI recomienda esto específicamente para evitar que el modelo juzgue su propia salida demasiado permisivamente.

4.4. Evaluar y Refinar la Respuesta

El método adviseCall() es el único punto de entrada que llama la cadena de asesores. En lugar de reenviar la solicitud una vez, llamamos a chain.copy(this).nextCall(request), lo que crea una subcadena que incluye nuevamente nuestro asesor. Eso permite que el bucle vuelva a evaluar y vuelva a generar en múltiples intentos:

@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);
}

El bucle necesita un límite superior explícito para evitar la recursión ilimitada. En el último intento permitido, el método devuelve la respuesta de inmediato, omitiendo la evaluación. No tiene sentido puntuar una respuesta sobre la que no podamos actuar. En caso contrario, si la puntuación alcanza scoreThreshold, devuelve temprano. Si no, la solicitud se amplía con la retroalimentación del juez y continúa con la siguiente iteración.

4.5. Evaluar la Respuesta

El método evaluate() envía la pregunta original y la respuesta generada al modelo juez. Podemos usar .entity(...) para renderizar el JSON de respuesta directamente en nuestro registro, sin ningún análisis JSON manual:

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 inyecta un esquema JSON derivado del registro Verdict en la llamada al modelo y gestiona la deserialización. Esto elimina la necesidad de cualquier bloque try-catch alrededor del análisis JSON y mantiene el código de evaluación centrado en el prompt, no en el manejo del formato.

4.6. Ampliar la Solicitud con Retroalimentación

Cuando la calificación es insuficiente, no reiniciamos con el prompt original. En su lugar, lo ampliamos, es decir, agregamos la crítica del juez al mensaje del usuario para que el modelo tenga el contexto completo en su próximo intento. El asistente addFeedback() hace esto usando ChatClientRequest.mutate():

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() nos proporciona una copia mutable del mensaje del usuario sin tocar el resto de la solicitud. El prompt del sistema, las definiciones de herramientas y el historial de conversación permanecen intactos. El ChatClientRequest actualizado se pasa a la siguiente iteración del bucle.

5. Configuración de la Aplicación

Declararemos ambos beans en una sola clase @Configuration. El bean LlmJudgeAdvisor recibe su propia instancia de ChatClient, construida a partir del mismo ChatClient.Builder auto-configurado, junto con los umbrales de calidad de application.properties:

@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
        );
    }

}

Luego podemos establecer los umbrales en application.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

Un umbral de puntuación de 0.75 significa que el juez debe calificar la respuesta con al menos un 75% de calidad antes de que la aceptemos. Configurar el número máximo de refinamientos a 2 limita el bucle recursivo a dos intentos de mejora, evitando un uso descontrolado de la API.

6. Pruebas del Asesor

Implementemos un @SpringBootTest con un único ChatModel simulado. Tanto el generador como el juez utilizan la misma instancia subyacente, por lo que un solo mock cubre toda la secuencia de llamadas. No se necesita una clave API.

El test simula cuatro respuestas secuenciales de chatModel que coinciden con el flujo esperado:

@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))));
    }
}

Los cuatro stubs se mapean directamente al ciclo generar-evaluar-refinar-evaluar: la llamada uno es la respuesta débil del generador, la llamada dos es el veredicto bajo del juez, la llamada tres es la respuesta refinada del generador y la llamada cuatro es el veredicto de aprobación del juez. El chatClient con el asesor es real. Solo el modelo subyacente está simulado. Esto significa que toda la lógica del asesor se ejecuta como lo haría en producción.

7. Conclusión

En este artículo, implementamos el patrón LLM-as-a-Judge en Spring AI utilizando un CallAdvisor recursivo. Vimos cómo el bucle generar-evaluar-refinar se mapea naturalmente sobre el modelo de asesor: la primera llamada al modelo produce una respuesta, la salida estructurada de Spring AI convierte la respuesta del juez en un Verdict tipado, y el bucle amplía la solicitud original con retroalimentación hasta que la puntuación sea suficiente o se alcance el límite de intentos.

El resultado es un componente reutilizable que mejora la calidad del resultado automáticamente y permanece completamente transparente para cualquier llamador que utilice el ChatClient.

El código de este tutorial está disponible en GitHub.

Newsletter Semanal de Java

Cada viernes recibe lo más nuevo del ecosistema Java: frameworks, herramientas y mejores prácticas.

Sin spam. Cancela cuando quieras.

Compartir artículo