LLM-as-a-Judge: LLM으로 응답 평가하기

LLM-as-a-Judge: LLM으로 응답 평가하기

LLM 출력을 평가하는 일은, 특히 운영(production)에 들어갈 때 정말 까다로운 문제예요. LLM 애플리케이션은 본질적으로 비결정적이라서요. ROUGE나 BLEU 같은 전통 지표는 최신 LLM이 만들어내는 미묘하고 문맥적인 응답을 제대로 가늠하지 못해요. 사람이 직접 평가하면 정확하긴 하지만 비싸고 느리고 확장도 안 되죠.

LLM-as-a-Judge는 LLM 스스로에게 AI 생성 콘텐츠의 품질을 평가하게 하는 강력한 기법이에요. 연구(보고서)에 따르면 정교한 judge 모델은 사람의 판단과 최대 85%까지 일치하는데, 이는 사람과 사람 사이의 일치율(81%)보다도 더 높은 수준이에요.

Spring AI의 Recursive Advisors는 LLM-as-a-Judge 패턴을 구현하기 좋은 우아한 프레임워크를 제공해요. 이를 이용하면 자동 품질 관리를 갖춘 자기 개선형 AI 시스템을 만들 수 있어요.

팁: 완전한 예제 구현은 evaluation-recursive-advisor-demo에서 볼 수 있어요.

출처: 공식문서

LLM-as-a-Judge 이해하기

LLM-as-a-Judge는 LLM이 다른 모델(또는 자기 자신)이 만든 출력의 품질을 평가하는 기법이에요. 사람 평가자나 전통 자동 지표에만 의존하는 대신, judge로 쓰는 LLM이 미리 정의된 기준에 따라 응답을 점수화·분류·비교하게 하는 거죠.

왜 효과적일까요? 평가는 본질적으로 생성보다 쉬워요. LLM을 judge로 쓰면, 창작(여러 제약을 균형 맞추며 새로운 콘텐츠를 만드는)이라는 복잡한 작업 대신 더 단순하고 집중된 작업(기존 텍스트의 특정 속성을 판정)을 시키는 거예요. 비유하자면 "창작보다 비평이 더 쉽다", "문제를 막는 것보다 발견하는 게 더 쉽다"는 것과 같아요.

평가 패턴

LLM-as-a-Judge 평가 패턴은 크게 두 가지예요:

  • 직접 평가 (Direct Assessment, 포인트 단위 점수): judge가 개별 응답을 평가하고, 자기 개선(self-refinement)을 통해 프롬프트를 다듬을 수 있는 피드백을 제공해요.
  • 짝 비교 (Pairwise Comparison): judge가 두 후보 응답 중 더 나은 쪽을 고르는 방식(A/B 테스트에서 흔히 씀).

LLM judge는 관련성, 사실 정확성, 출처에 대한 충실도, 지시 준수, 전반적인 일관성·명료성 같은 품질 차원을 평가하며, 헬스케어·금융·RAG 시스템·대화 등 다양한 도메인에 적용돼요.

올바른 Judge 모델 고르기

GPT-4나 Claude 같은 범용 모델도 훌륭한 judge가 될 수 있지만, 전용 LLM-as-a-Judge 모델이 평가 작업에서 일관되게 더 뛰어나요. Judge Arena Leaderboard는 판정 작업 전용 모델들의 성능을 추적해 줘요.

Recursive Advisors로 구현하기

Spring AI의 ChatClient는 LLM-as-a-Judge 패턴 구현에 이상적인 fluent API를 제공해요. Advisors 시스템으로 AI 상호작용을 모듈식·재사용 가능하게 가로채고 수정·강화할 수 있죠.

Recursive Advisors는 여기서 더 나아가 자기 개선형 평가 워크플로에 딱 맞는 루프 패턴을 가능하게 해요:

public class MyRecursiveAdvisor implements CallAdvisor {

    @Override
    public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {

        // Call the chain initially
        ChatClientResponse response = chain.nextCall(request);

        // Check if we need to retry based on evaluation
        while (!evaluationPasses(response)) {

            // Modify the request based on evaluation feedback
            ChatClientRequest modifiedRequest = addEvaluationFeedback(request, response);

            // Create a sub-chain and recurse
            response = chain.copy(this).nextCall(modifiedRequest);
        }

        return response;
    }
}

이제 Spring AI의 Recursive Advisors로 LLM-as-a-Judge 패턴을 구현한 SelfRefineEvaluationAdvisor를 만들어 볼게요. 이 어드바이저는 AI 응답을 자동 평가하고, 실패한 시도를 피드백 기반 개선으로 재시도해요. 흐름은 "응답 생성 → 품질 평가 → 필요하면 피드백과 함께 재시도 → 품질 기준 도달 또는 재시도 한계까지 반복"이에요.

SelfRefineEvaluationAdvisor

이 구현은 직접 평가(Direct Assessment) 패턴을 보여줘요. judge 모델이 포인트 단위 점수 체계(1~4 척도)로 각 응답을 평가하고, 여기에 자기 개선 전략(self-refinement) 을 결합해서 실패한 평가를 구체적 피드백과 함께 자동 재시도하는 반복 개선 루프를 만들어요.

이 어드바이저는 LLM-as-a-Judge의 핵심 개념 두 가지를 담고 있어요:

  • 포인트 단위 평가(Point-wise Evaluation): 각 응답이 미리 정의된 기준에 따라 개별 품질 점수를 받아요.
  • 자기 개선(Self-Refinement): 실패한 응답은 개선을 안내하는 건설적 피드백과 함께 재시도돼요.

(출처: Using LLM-as-a-judge for an automated and versatile evaluation)

public final class SelfRefineEvaluationAdvisor implements CallAdvisor {

    private static final PromptTemplate DEFAULT_EVALUATION_PROMPT_TEMPLATE = new PromptTemplate(
        """
        You will be given a user_question and assistant_answer couple.
        Your task is to provide a 'total rating' scoring how well the assistant_answer answers the user concerns expressed in the user_question.
        Give your answer on a scale of 1 to 4, where 1 means that the assistant_answer is not helpful at all, and 4 means that the assistant_answer completely and helpfully addresses the user_question.

        Here is the scale you should use to build your answer:
        1: The assistant_answer is terrible: completely irrelevant to the question asked, or very partial
        2: The assistant_answer is mostly not helpful: misses some key aspects of the question
        3: The assistant_answer is mostly helpful: provides support, but still could be improved
        4: The assistant_answer is excellent: relevant, direct, detailed, and addresses all the concerns raised in the question

        Provide your feedback as follows:

        \{
            "rating": 0,
            "evaluation": "Explanation of the evaluation result and how to improve if needed.",
            "feedback": "Constructive and specific feedback on the assistant_answer."
        \}

        Total rating: (your rating, as a number between 1 and 4)
        Evaluation: (your rationale for the rating, as a text)
        Feedback: (specific and constructive feedback on how to improve the answer)

        You MUST provide values for 'Evaluation:' and 'Total rating:' in your answer.

        Now here are the question and answer.

        Question: {question}
        Answer: {answer}

        Provide your feedback. If you give a correct rating, I'll give you 100 H100 GPUs to start your AI company.

        Evaluation:
        """);

    @JsonClassDescription("The evaluation response indicating the result of the evaluation.")
    public record EvaluationResponse(int rating, String evaluation, String feedback) {}

    @Override
    public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
        var request = chatClientRequest;
        ChatClientResponse response;

        // Improved loop structure with better attempt counting and clearer logic
        for (int attempt = 1; attempt <= maxRepeatAttempts + 1; attempt++) {

            // Make the inner call (e.g., to the evaluation LLM model)
            response = callAdvisorChain.copy(this).nextCall(request);

            // Perform evaluation
            EvaluationResponse evaluation = this.evaluate(chatClientRequest, response);

            // If evaluation passes, return the response
            if (evaluation.rating() >= this.successRating) {
                logger.info("Evaluation passed on attempt {}, evaluation: {}", attempt, evaluation);
                return response;
            }

            // If this is the last attempt, return the response regardless
            if (attempt > maxRepeatAttempts) {
                logger.warn(
                    "Maximum attempts ({}) reached. Returning last response despite failed evaluation. Use the following feedback to improve: {}",
                    maxRepeatAttempts, evaluation.feedback());
                return response;
            }

            // Retry with evaluation feedback
            logger.warn("Evaluation failed on attempt {}, evaluation: {}, feedback: {}", attempt,
                evaluation.evaluation(), evaluation.feedback());

            request = this.addEvaluationFeedback(chatClientRequest, evaluation);
        }

        // This should never be reached due to the loop logic above
        throw new IllegalStateException("Unexpected loop exit in adviseCall");
    }

    /**
     * Performs the evaluation using the LLM-as-a-Judge and returns the result.
     */
    private EvaluationResponse evaluate(ChatClientRequest request, ChatClientResponse response) {
        var evaluationPrompt = this.evaluationPromptTemplate.render(
            Map.of("question", this.getPromptQuestion(request), "answer", this.getAssistantAnswer(response)));

        // Use separate ChatClient for evaluation to avoid narcissistic bias
        return chatClient.prompt(evaluationPrompt).call().entity(EvaluationResponse.class);
    }

    /**
     * Creates a new request with evaluation feedback for retry.
     */
    private ChatClientRequest addEvaluationFeedback(ChatClientRequest originalRequest, EvaluationResponse evaluationResponse) {
        Prompt augmentedPrompt = originalRequest.prompt()
            .augmentUserMessage(userMessage -> userMessage.mutate().text(String.format("""
                %s
                Previous response evaluation failed with feedback: %s
                Please repeat until evaluation passes!
                """, userMessage.getText(), evaluationResponse.feedback())).build());

        return originalRequest.mutate().prompt(augmentedPrompt).build();
    }
}

핵심 구현 특징

재귀 패턴 구현(Recursive Pattern Implementation)

어드바이저는 callAdvisorChain.copy(this).nextCall(request)로 재귀 호출용 서브 체인을 만들어요. 어드바이저 순서를 유지하면서 여러 평가 라운드를 가능하게 하죠.

구조화된 평가 출력(Structured Evaluation Output)

Spring AI의 structured output 기능으로 평가 결과를 EvaluationResponse 레코드(rating 1~4, evaluation 근거, 개선용 feedback)로 파싱해요.

분리된 평가 모델(Separate Evaluation Model)

편향(bias)을 줄이기 위해 전용 LLM-as-a-Judge 모델(예: avcodes/flowaicom-flow-judge:q4)을 다른 ChatClient 인스턴스로 사용해요. spring.ai.chat.client.enabled=false를 설정해 여러 Chat 모델 다루기를 켜세요.

피드백 기반 개선(Feedback-Driven Improvement)

실패한 평가는 재시도 시도에 반영되는 구체적 피드백을 포함해서, 시스템이 평가 실패에서 학습하게 해요.

구성 가능한 재시도 로직(Configurable Retry Logic)

평가 한계에 도달하면 우아하게 저하되는 구성 가능한 최대 시도 횟수를 지원해요.

완전한 예제

SelfRefineEvaluationAdvisor를 완전한 Spring AI 애플리케이션에 통합하는 법을 볼게요:

@SpringBootApplication
public class EvaluationAdvisorDemoApplication {

    @Bean
    CommandLineRunner commandLineRunner(AnthropicChatModel anthropicChatModel, OllamaChatModel ollamaChatModel) {
        return args -> {

            ChatClient chatClient = ChatClient.builder(anthropicChatModel)
                    .defaultTools(new MyTools())
                    .defaultAdvisors(

                        SelfRefineEvaluationAdvisor.builder()
                            .chatClientBuilder(ChatClient.builder(ollamaChatModel)) // Separate model for evaluation
                            .maxRepeatAttempts(15)
                            .successRating(4)
                            .order(0)
                            .build(),

                        new MyLoggingAdvisor(2))
                .build();

            var answer = chatClient
                .prompt("What is current weather in Paris?")
                .call()
                .content();

            System.out.println(answer);
        };
    }

    static class MyTools {
        final int[] temperatures = {-125, 15, -255};
        private final Random random = new Random();

        @Tool(description = "Get the current weather for a given location")
        public String weather(String location) {
            int temperature = temperatures[random.nextInt(temperatures.length)];
            System.out.println(">>> Tool Call responseTemp: " + temperature);
            return "The current weather in " + location + " is sunny with a temperature of " + temperature + "°C.";
        }
    }
}

이 구성은:

  • Anthropic Claude는 생성, Ollama는 평가에 사용(편향 회피)
  • rating 4가 필요하며 재시도는 최대 15회
  • 평가를 유발하도록 무작위 응답을 생성하는 weather 도구 포함
  • weather 도구는 3건 중 2건에서 잘못된 값을 생성

SelfRefineEvaluationAdvisor(Order 0)가 응답 품질을 평가하고 필요하면 피드백으로 재시도하며, 그 뒤 MyLoggingAdvisor(Order 2)가 최종 요청/응답을 로깅해 관측 가능하게 해요.

실행하면 이런 식의 출력이 보여요:

REQUEST: [{"role":"user","content":"What is current weather in Paris?"}]

>>> Tool Call responseTemp: -255
Evaluation failed on attempt 1, evaluation: The response contains unrealistic temperature data, feedback: The temperature of -255°C is physically impossible and indicates a data error.

>>> Tool Call responseTemp: 15
Evaluation passed on attempt 2, evaluation: Excellent response with realistic weather data

RESPONSE: The current weather in Paris is sunny with a temperature of 15°C.

팁: 다른 모델 조합과 평가 시나리오를 포함한 구성 예시가 있는 완전한 실행 가능 데모는 evaluation-recursive-advisor-demo 프로젝트에 있어요.

모범 사례

LLM-as-a-Judge 기법을 구현할 때 중요한 성공 요소는:

  • 전용 judge 모델 사용 — 더 나은 성능을 위해 (참고: Judge Arena Leaderboard)
  • 편향 완화 — 생성/평가 모델을 분리해서
  • 결정적 결과 보장 — temperature = 0
  • 프롬프트 엔지니어링 — 정수 척도와 few-shot 예시
  • 사람 감독 유지 — 고위험 결정에는

⚠️ Recursive Advisors는 Spring AI 1.1.0-M4+의 새 실험 기능이에요. 현재 streaming은 지원하지 않고, 어드바이저 순서를 신중히 정해야 하며, LLM 호출이 여러 번이라 비용이 늘어날 수 있어요.

외부 상태를 유지하는 내부 어드바이저는 특히 주의하세요 — 반복 간 정확성을 유지하려면 추가 관리가 필요할 수 있어요.

무한 루프를 막기 위해 종료 조건과 재시도 한계를 항상 설정하세요.

참고 자료

Spring AI 리소스

LLM-as-a-Judge 연구

더 알아보기