Code › tail-villain

인터뷰 평가 시스템 만들기

전체 인터뷰 대화를 기준으로 LLM 응답 품질을 평가하는 시스템 구현

v1을 열고 나니 다음 문제는 인터뷰가 실제로 잘 진행되고 있는지 확인하는 쪽으로 옮겨갔다.

사용자는 빌런과 대화를 하고, 빌런은 답변의 빈틈을 찔러야 하지만 서버가 200을 반환하고 화면에 질문이 보인다고 해서 좋은 인터뷰가 된 것은 아니며, 중간에 페르소나가 풀릴 수도 있고 앞에서 했던 말을 잊은 채 같은 질문을 반복하거나 사용자가 말을 바꿨는데도 그냥 맞장구를 칠 수도 있다.

로그를 한두 개 눈으로 읽는 방식은 오래 버틸 수 없었고, 제품이 실제 사용자 세션을 쌓기 시작하면 인터뷰 품질도 제품 안에서 반복적으로 측정할 수 있어야 했다.


먼저 관리자용 평가 흐름을 만들었다.

세션을 골라 대화 전체를 읽고, 별도의 LLM에게 인터뷰어가 얼마나 잘했는지 판단하게 했다. 흔히 말하는 LLM-as-a-Judge 구조지만, 여기서 평가 대상은 사용자의 실력이 아니라 빌런 인터뷰어의 품질이었다. 질문이 맥락을 기억했는지, 답변의 모순을 놓치지 않았는지, 페르소나를 유지했는지, 마지막 평가가 대화 내용과 맞는지를 봐야 했다.

처음에는 컨텍스트를 줄이는 쪽을 자연스럽게 생각했고, 대화가 길어지면 중간 내용이 흐려질 수 있고 비용도 커지기 때문이다. 그런데 인터뷰어 평가에서는 중간을 자르는 순간 중요한 증거를 버리게 된다. 페르소나가 무너지는 장면이나 사용자가 말을 바꾸는 장면은 대화의 중간에 숨어 있는 경우가 많다.

그래서 판단을 바꿨다. 평가자는 전체 대화를 받는다.

Gemini Flash Lite의 긴 컨텍스트와 낮은 비용을 활용하면, 대화를 요약하거나 잘라내지 않고 그대로 넣을 수 있었다. 각 메시지에는 Turn 번호를 붙여 평가자가 특정 시점의 흐름을 다시 짚을 수 있게 했다. 인터뷰는 마지막 답변만 보고 판단할 수 없고, 다섯 턴 전에 한 말을 지금 어떻게 다루는지도 봐야 하기 때문이다.


평가 프롬프트도 점수 하나만 뱉게 만들 수는 없었다.

초기 평가에서는 같은 세션에 4점과 5점이 섞여 나오거나, 5점을 주면서 동시에 개선점이 있다고 말하는 식의 모순이 생겼다. 점수와 설명이 모순되면 평가 결과를 관리 지표로 사용할 수 없다. 점수와 근거가 어긋나면 이후에 프롬프트를 고쳐도 좋아졌는지 나빠졌는지 판단하기 어렵다.

그래서 평가 JSON의 순서를 강하게 잡았다. 먼저 단계별 분석을 쓰고, 그 다음에 모순 검사와 치명 실패 여부를 판단한 뒤, 마지막에 점수를 고르게 했다. 5점은 개선점이 하나도 없을 때만 가능하며, 개선점이 있으면 4점 이하로 내려가야 했고, 인터뷰어가 반복하거나 페르소나를 잃거나 앞선 맥락을 기억하지 못하면 치명 실패로 보고 점수 상한도 낮췄다.

아첨 현상도 별도로 다뤘다. 사용자가 중간에 말을 바꾸거나 앞뒤가 맞지 않는 답변을 했을 때 빌런은 그 모순을 잡아야 하는데, LLM 인터뷰어는 종종 사용자의 말을 너무 쉽게 받아준다. 평가자에게 inconsistency check를 의무화했고, 인터뷰어 프롬프트에도 모순을 적극적으로 확인하라는 지시를 넣었다.

이때 중요한 것은 부정문을 많이 쓰는 게 아니었다. 하지 말라는 말을 늘릴수록 모델은 오히려 그 단어를 더 강하게 의식하기 때문에, 5점 조건처럼 긍정 조건을 썼다. 개선점이 0개일 때만 5점이고, 치명 실패가 있으면 낮은 점수로 제한하는 식으로 판단 규칙을 직접 연결했다.


관리자 화면에서는 최신 세션 몇 개를 그냥 가져오지 않았다.

사용자 행동은 한쪽으로 쏠릴 수 있고, 특정 세션 종류만 최근에 많이 쌓이면 최신 10개는 제품 전체의 품질을 보여주지 못하기 때문에 세션 종류별로 골고루 가져오는 샘플링을 넣었다. 평가 요청도 순차로 보내지 않고 병렬로 묶었다. 관리자 화면에서 평가 버튼을 눌렀을 때 한참 기다리는 구조는 운영 도구로 쓰기 어렵기 때문이다.

화면도 평가 결과를 추적할 수 있는 감사 로그 구조로 정리했다. 점수에 따라 색을 다르게 주고, 치명 실패는 따로 보이게 했으며, 낮은 점수의 Critical Gaps와 높은 점수의 Improvement Points도 같은 말로 묶지 않았다. 평가가 벌점표처럼 보이면 좋은 세션의 작은 개선점까지 심각한 실패처럼 읽히기 때문이다.

여기까지 만들고 나서야 인터뷰 프롬프트를 바꿀 수 있는 기본 장치가 생겼다. 이제 프롬프트를 고치고, 같은 기준으로 다시 평가하고, 무엇이 나아졌는지 볼 수 있었다.


다음 날에는 전혀 다른 곳에서 같은 종류의 경계 문제가 나왔다.

백엔드 로그에는 stamina 값이 준비되어 있었는데, 프론트에서는 stamina가 undefined로 들어왔다. 처음에는 응답 매핑이나 상태 저장을 의심했지만, 실제 원인은 공유 타입 패키지였다. 소스의 Zod 스키마는 바뀌었지만, 런타임에서 쓰는 빌드 산출물이 오래된 상태였고, Zod parse가 모르는 필드를 조용히 제거하고 있었다.

타입스크립트가 IDE에서 맞아 보인다고 해서 런타임 스키마가 맞는 것은 아니다. 특히 모노레포에서는 공유 패키지의 소스와 빌드된 dist가 서로 다른 시간을 살 수 있다. 이번에는 stamina, maxStamina, reportStatus 같은 새 필드가 그 틈에서 사라졌다.

그래서 shared-types 빌드를 각 앱의 dev, start, build 흐름 앞에 붙였다. 프론트와 백엔드가 같은 DTO를 본다는 말은 선언만으로 성립하지 않고, 실제 실행되는 스키마까지 같은 버전이어야 한다.

이 일은 평가 시스템과도 연결되어 있었다. LLM이 만든 평가도, 서버가 만든 세션 상태도, 프론트가 읽는 UI 상태도 결국 경계를 지난다. 그 경계에서 스키마가 오래되면 데이터는 에러를 내지 않고 사라질 수도 있다.


인터뷰 평가자를 만들면서 가장 크게 바뀐 것은 LLM을 보는 위치였다.

빌런 인터뷰어는 질문을 만들고, 평가자는 그 질문이 제대로 된 질문이었는지 본다. 제품은 평가자의 점수와 근거를 저장하며, 관리자 화면은 그것을 다시 사람이 읽을 수 있는 형태로 보여준다. 어느 한 단계도 그냥 믿고 넘기면 안 된다.

LLM이 인터뷰를 진행한다고 해서 품질 관리까지 감으로 하면 안 된다. 전체 대화를 평가자에게 넘기고, 샘플링을 고르게 하고, 점수 규칙을 타입처럼 강하게 잡고, 런타임 스키마가 데이터를 떨어뜨리지 않는지 확인해야 한다.

인터뷰 품질은 좋은 프롬프트 하나로 고정되지 않는다.

좋은 프롬프트를 계속 고칠 수 있게 만드는 측정 장치가 먼저 필요했다.