LLM 회귀 테스트: 모델 버전 고정으로 어제와 다른 답 잡기

LLM 회귀 테스트

결론부터 말씀드리면, 어제 잘 나오던 답이 오늘 갑자기 달라졌다면 첫 번째로 의심할 곳은 프롬프트가 아니라 모델 버전입니다. gpt-4oclaude-3-5-sonnet-latest처럼 별칭(alias)으로 모델을 호출하고 있다면, 제공사가 내부적으로 스냅샷을 교체하는 순간 코드 한 줄 안 바꿨는데도 출력이 흔들립니다. 이 글에서는 모델 버전을 고정하는 구체적인 방법과, 그 위에 얹을 수 있는 최소 단위의 LLM 회귀 테스트 구성을 코드와 함께 정리합니다.

어제와 오늘 답이 달라지는 원인은 모델 버전입니다

OpenAI와 Anthropic 모두 사용자가 별도로 지정하지 않으면 “가장 최신에 가까운” 스냅샷으로 요청을 라우팅하는 별칭을 제공합니다. gpt-4o, claude-3-5-sonnet-latest 같은 이름이 여기 해당합니다.

문제는 이 별칭이 가리키는 실제 가중치가 언제든 바뀔 수 있다는 점입니다. 프롬프트도, temperature 설정도, 코드도 전혀 손대지 않았는데 답변 형식이나 톤이 달라졌다면, 십중팔구 별칭 뒤의 스냅샷이 교체된 경우입니다.

같은 맥락에서 소프트웨어 개발 쪽에서 말하는 회귀 테스트 개념도 이 상황에 그대로 적용됩니다. 이전에는 정상이던 동작이 변경 이후 깨졌는지를 확인하는 절차라는 점에서, 위키백과의 회귀 테스트 정의를 LLM 응답 검증에도 그대로 가져올 수 있습니다.

모델 버전을 고정하는 방법

방법은 단순합니다. 별칭 대신 날짜가 붙은 스냅샷 이름을 직접 코드에 박아 넣으면 됩니다. 두 제공사의 표기 방식은 아래처럼 다릅니다.

LLM 모델 버전 관리와 소프트웨어 테스트 개념 이미지

제공사 별칭(자동 최신화) 버전 고정 예시
OpenAI gpt-4o gpt-4o-2024-08-06
Anthropic claude-3-5-sonnet-latest claude-3-5-sonnet-20241022

어떤 스냅샷이 존재하고 언제까지 지원되는지는 각 제공사 공식 문서에 나와 있습니다. OpenAI는 공식 모델 문서에서 스냅샷별 지원 종료 시점을 공지하므로, 프로덕션에 쓰는 스냅샷은 이 페이지에서 주기적으로 확인해야 합니다.

버전을 고정하면 “어제는 됐는데 오늘은 안 된다” 유형의 문제 중 상당수가 사라집니다. 다만 이건 시작점일 뿐이고, 그 고정된 버전 위에서 실제로 원하는 답이 계속 나오는지 확인하는 절차가 따로 필요합니다.

temperature를 0으로 둬도 답이 흔들리는 이유는 무엇인가요?

버전을 고정하고 temperature를 0으로 설정해도 완전히 동일한 답이 매번 나온다는 보장은 없습니다. GPU 연산 과정에서 부동소수점 처리 순서가 배치 상황에 따라 미세하게 달라질 수 있고, 이 차이가 토큰 확률 경계에 걸린 경우 다른 단어를 고르게 만들기 때문입니다.

그래서 회귀 테스트를 짤 때는 “문자열이 완전히 같은가”보다 “핵심 답이 포함되어 있는가”, “형식(JSON 키, 숫자 범위 등)이 맞는가”를 기준으로 검증하는 편이 실용적입니다. 완전 일치를 기준으로 삼으면 테스트가 사소한 흔들림에도 계속 실패해서 신호보다 잡음이 커집니다.

회귀 테스트, 최소 구성으로 시작해 보세요

거창한 평가 파이프라인부터 만들 필요는 없습니다. pytest와 openai 파이썬 SDK만으로도 최소 구성은 충분히 만들 수 있습니다. 핵심은 두 가지, 모델 이름을 상수로 고정하는 것과 판정 기준을 완전 일치가 아닌 부분 조건으로 두는 것입니다.

import openai

MODEL = "gpt-4o-2024-08-06"  # 별칭 대신 버전 고정

def ask(prompt: str) -> str:
    resp = openai.chat.completions.create(
        model=MODEL,
        messages=[{"role": "user", "content": prompt}],
        temperature=0,
    )
    return resp.choices[0].message.content

def test_arithmetic_case():
    answer = ask("사과 5개 중 2개를 먹으면 몇 개가 남나요? 숫자만 답하세요.")
    assert "3" in answer

def test_format_case():
    answer = ask('아래 형식으로만 답하세요: {"result": 숫자}\n7 더하기 8은?')
    assert '"result"' in answer and "15" in answer

터미널에서 실행 중인 자동화 회귀 테스트 코드 화면

이 두 테스트를 CI에서 매일, 혹은 스냅샷을 바꾸기 전마다 돌리면 됩니다. 실패 사례가 쌓이면 그 케이스를 그대로 테스트 세트에 편입시키는 방식으로 늘려가면, 별도 프레임워크 없이도 회귀 방지 체계가 자연스럽게 굵어집니다. promptfoo나 DeepEval 같은 전용 도구는 테스트 케이스 수가 수십 개를 넘어가고 스코어링을 다양화하고 싶을 때 넘어가면 됩니다.

버전 고정만으로 끝나지 않는 이유

버전을 고정해도 영구히 안전하지는 않습니다. 제공사들은 오래된 스냅샷을 일정 기간이 지나면 폐기(deprecate)하고, 폐기 이후에는 강제로 다음 스냅샷으로 옮겨야 합니다. 즉 고정은 “당장의 흔들림을 멈추는 것”이지 “영원히 관리를 안 해도 된다는 것”은 아닙니다.

또 하나의 트레이드오프는 보안 패치나 안전성 개선이 최신 스냅샷에만 반영되는 경우가 있다는 점입니다. 오래된 버전을 계속 고정해두면 이런 개선을 놓치게 되므로, 고정하되 주기적으로 최신 스냅샷과 나란히 회귀 테스트를 돌려보고 문제가 없으면 갈아타는 절차를 병행하는 편이 현실적입니다.

비용 구조가 스냅샷마다 다를 수 있다는 점도 챙겨야 합니다. 오래된 스냅샷이 더 비싸게 유지되는 경우도 있어서, 고정 자체를 목적이 아니라 회귀 테스트로 안전을 확인한 뒤 옮기기 위한 수단으로 보는 편이 맞습니다.

스냅샷을 갈아탈 때는 이 순서로 준비하세요

정리하면 순서는 이렇습니다. 먼저 별칭을 날짜 붙은 스냅샷 이름으로 바꿔 고정하고, 지금까지 겪은 실패 사례를 최소 몇 개라도 pytest 테스트로 옮겨 둡니다. 이후 제공사 문서에서 폐기 예정일을 확인하면서, 새 스냅샷이 나올 때마다 같은 테스트 세트를 새 스냅샷에도 돌려서 통과 여부를 비교합니다.

이 최소 구성만으로도 “어제와 답이 다르다”는 문제의 원인을 프롬프트 탓으로 돌리며 헤매는 시간을 크게 줄일 수 있습니다. 테스트 케이스는 완벽하게 갖추고 시작할 필요 없이, 실제로 문제가 됐던 질문부터 하나씩 추가해 나가는 편이 유지 부담도 적고 오래갑니다.

프롬프트 A/B 테스트, 운영 중 트래픽 분리와 품질 지표부터 정하세요

Leave a Comment