모델 운영

MLOPS / 65번째 글

평가 회귀 테스트 — 좋아졌다는 말과 안 깨졌다는 말은 다르다

프롬프트 한 줄을 고쳤는데 평균 점수는 그대로입니다. 그런데 안에서는 되던 것이 깨지고 안 되던 것이 되고 있습니다. 짝지어 비교하는 회귀 테스트를 만드는 방법을 정리합니다.

PALDYN Team10 MIN READ

지난 글에서 우리 손으로 만든 평가 세트를 갖고 있어야 한다는 이야기를 했다. 그 세트가 생기면 곧바로 다음 질문이 온다. 언제 돌리고, 결과를 무엇과 견주는가.

프롬프트 한 줄을 고쳤다고 하자. 다시 돌렸더니 72점, 고치기 전에도 72점이었다. 아무 일도 없었으니 배포해도 되겠다고 생각하기 쉽다. 그런데 안을 열어 보면 되던 문제 다섯 개가 깨졌고, 안 되던 문제 다섯 개가 새로 풀렸다. 평균이 같은 것은 두 수가 우연히 상쇄됐기 때문이다.

평균이 감추는 것

회귀(regression)는 예전에 되던 동작이 변경 때문에 깨지는 것을 말한다. 평균 점수는 회귀를 잡지 못한다. 잡으려면 문제 하나하나를 이전 실행과 짝지어 봐야 한다.

평균은 그대로인데 안에서 열 문제가 뒤집혔다

같은 100문제를 두 번 돌려 네 칸으로 나눈 표다. 대각선 위쪽 칸 둘은 아무 일도 없었던 문제고, 나머지 두 칸이 이번 변경이 실제로 한 일이다. 오른쪽 위 5건이 회귀, 왼쪽 아래 5건이 개선이다.

이 표를 보고 나면 판단이 달라진다. 「72점 유지」는 배포해도 좋다는 뜻이었지만, 「되던 것 다섯 개가 깨졌다」는 그 다섯 개가 무엇인지 보고 나서 정하자는 뜻이다. 깨진 다섯이 자주 안 오는 질문이면 넘어갈 만하고, 결제 관련 질문이면 못 넘어간다.

코드 회귀 테스트와 다른 점

「그냥 테스트를 짜면 되지 않나」 싶지만, 익숙한 단위 테스트를 그대로 옮기면 며칠 안에 아무도 안 보는 빨간 불이 된다. 성질이 셋 다르다.

갈래 코드 테스트 모델 평가
같은 입력의 결과 항상 같다 매번 조금씩 다르다
채점 같으냐 다르냐 얼마나 맞았느냐
통과 기준 전부 통과 기준선 대비

첫째, 같은 입력에 같은 출력이 안 나온다. temperature를 0으로 두어도 배치 구성과 커널 선택 때문에 토큰이 달라질 수 있다. 그래서 한 번 실패했다고 곧바로 회귀로 세면 안 된다.

둘째, 채점이 참·거짓이 아니다. 「서울입니다」와 「서울시입니다」를 어떻게 셀 것인지가 매번 문제가 된다. 채점 방식을 바꾸면 점수가 통째로 움직이므로, 채점기도 프롬프트만큼이나 조심해서 버전을 붙여야 한다.

셋째, 전부 통과가 목표가 아니다. 100점을 받는 평가 세트는 이미 쉬운 세트다. 목표는 만점이 아니라 어제보다 나빠지지 않는 것이라서, 기준선을 어딘가에 저장해 두는 일이 테스트 자체보다 중요하다.

비교가 성립하려면 무엇을 고정하나

두 실행을 견주려면 달라진 것이 하나뿐이어야 한다. 실무에서 가장 흔한 사고가 프롬프트도 고치고 모델 별칭도 그대로 둔 채 며칠 뒤에 돌려, 그 사이에 바뀐 모델 때문에 생긴 차이를 프롬프트 탓으로 읽는 것이다.

고정할 것 안 고정하면
모델 버전 제공사가 별칭 뒤 모델을 갈아 끼우면 원인이 섞인다
디코딩 설정 temperature·top_p가 다르면 흔들림 폭이 달라진다
평가 세트 문제를 더하면서 비교하면 어느 쪽 효과인지 모른다
채점기 채점 프롬프트를 고치면 점수 전체가 움직인다
실행 회수 한 번과 세 번 평균은 서로 다른 자다

넷을 고정하고 하나만 바꾼다. 굳이 둘을 한꺼번에 바꿔야 한다면, 적어도 어느 쪽 때문인지 모른다는 사실을 기록에 남긴다.

흔들리는 문제를 골라낸다

첫째 성질 때문에 판정 절차가 한 겹 필요하다. 떨어진 문제를 바로 회귀로 세지 않고 몇 번 더 돌려 본다.

떨어진 문제를 세 갈래로 가르는 순서

가운데 갈래가 실무에서 제일 성가시다. 세 번 중 한두 번만 통과하는 문제는 회귀도 아니고 통과도 아니다. 이런 문제를 격리 목록으로 옮긴다 — 회귀 수에서 빼되 지우지는 않고, 흔들리는 문제가 몇 개인지 따로 센다. 이 수가 늘어나면 그것 자체가 신호다. 보통은 정답이 여럿인데 채점기가 하나만 맞다고 우기는 자리다.

전체를 흔들림이 없는 쪽으로 만들려는 시도는 대체로 실패한다. 대신 평가 세트 전체를 세 번씩 돌려 다수결로 채점하면 흔들림이 크게 줄고, 비용은 세 배가 된다. 세 배를 낼 만한 자리인지는 세트 크기가 정한다.

기준선을 저장한다

기준선은 점수 하나가 아니라 문제별 결과 목록이어야 한다. 짝지어 세려면 그것이 필요하기 때문이다. 파일 하나로 저장소에 넣어 두면 충분하다.

def compare(baseline, current):
    """문제별 통과 여부 두 벌을 짝지어 회귀와 개선을 나눈다."""
    regressed, fixed = [], []
    for case_id, was_ok in baseline.items():
        if case_id not in current:
            continue
        now_ok = current[case_id]
        if was_ok and not now_ok:
            regressed.append(case_id)
        elif not was_ok and now_ok:
            fixed.append(case_id)
    return regressed, fixed

case_id가 실행마다 같아야 짝이 맞으므로, 문제에 순번이 아니라 바뀌지 않는 식별자를 붙인다. 순번을 쓰면 세트 가운데에 문제 하나를 끼워 넣는 순간 뒤가 전부 밀려 모든 문제가 회귀로 보인다.

기준선을 언제 갱신하느냐도 정해 두어야 한다. 회귀를 확인하고 받아들이기로 한 순간에만 갱신한다. 실행할 때마다 자동으로 덮어쓰면 매번 직전 실행과 비교하게 되어, 하루에 1점씩 열흘을 떨어져도 아무 경고가 안 뜬다.

CI에 붙일 때

전부를 막는 관문으로 세우면 곧 무시된다. 두 단계로 나누는 편이 오래간다.

  • 막는 것: 회귀가 임계값을 넘거나, 절대 깨지면 안 되는 문제 몇 개가 깨진 경우
  • 알리는 것: 그 밖의 회귀, 흔들리는 문제 수의 증가, 평균 점수의 하락

「절대 깨지면 안 되는 문제」를 따로 표시해 두는 것이 특히 값을 한다. 열 개쯤이면 충분하다. 안전 관련 거절, 가격을 지어내지 않는지, 사내 규정을 어기지 않는지 같은 것들이다. 이 목록은 회귀 임계값과 무관하게 하나만 깨져도 세운다.

평가 세트가 커지면 매 커밋마다 전부 돌리기 어려워진다. 그때는 커밋마다 부분 집합, 배포 전에 전체로 나눈다. 부분 집합은 무작위로 뽑지 말고 층을 나눠 뽑는다 — 무작위로 50개를 뽑으면 실행마다 세트가 달라져 짝이 안 맞는다.

정리

회귀 테스트가 하는 일은 좋아졌는지 말해 주는 것이 아니라 무엇이 나빠졌는지 세어 보여 주는 것이다. 평균은 그 질문에 답하지 못한다. 짝지어 세고, 흔들리는 것을 갈라 내고, 받아들이기로 한 순간에만 기준선을 옮긴다.

다음 자리는 그 짝의 왼쪽에 놓이는 것 — 무엇을 문제로 삼을 것인가다.


읽어주셔서 감사합니다. 😊

LATEST

모델 운영의 최신 글

모델 운영2026.09.04

KV 캐시 양자화 — 가중치보다 이쪽이 먼저 넘친다

긴 문맥에서 GPU 메모리를 실제로 잡아먹는 것은 가중치가 아니라 KV 캐시입니다. 캐시 크기를 계산하는 법, K와 V를 다르게 다뤄야 하는 이유, 어디까지 줄여도 되는지를 정리합니다.

16 MIN
모델 운영2026.09.04

양자화 보정 — 데이터 128개가 모델 품질을 정한다

양자화에서 스케일을 정하는 절차가 보정입니다. 무엇을 재는지, 데이터를 어디서 몇 개 뽑아야 하는지, 자르는 지점을 어떻게 고르는지, 그리고 잘못된 보정이 어떤 모양으로 드러나는지를 정리합니다.

21 MIN
모델 운영2026.09.03

과제 특화 증류 — 큰 모델의 답을 작은 모델에 옮긴다

범용 성능이 아니라 우리 과제 하나만 잘하는 작은 모델을 만드는 방법입니다. 교사에게 무엇을 받아야 하는지, 데이터를 어떻게 모으고 거르는지, 손익 분기가 어디인지를 정리합니다.

15 MIN