평가셋과 회귀 테스트 — 프롬프트를 고쳤는데 다른 게 깨질 때
시리즈 프롬프트를 코드처럼 다루기의 마지막 글.
터진 문제
근거 인용이 부실하다는 피드백을 받고 프롬프트에 규칙 한 줄을 추가했다. 인용은 좋아졌다. 그리고 분량 규칙이 깨졌다.
코드였다면 테스트가 잡았을 일이다. 프롬프트에는 테스트가 없었다. 그래서 매번 몇 개 돌려보고 “괜찮은 것 같다”로 배포했다.
프롬프트 수정은 국소 변경처럼 보이지만 전역 변경이다. 한 줄을 넣으면 모든 입력에 대한 동작이 바뀐다.
Before — 체감으로 판단하기
프롬프트 수정 → 내가 가진 샘플 2~3개 돌려봄 → 괜찮아 보임 → 배포
이 흐름의 문제는 샘플을 내가 고른다는 것이다. 나는 내가 방금 고친 부분이 잘 되는지만 본다. 다른 게 깨졌는지는 안 본다.
After — 평가셋을 만들고 전후를 비교하기
1단계: 평가셋을 실제 입력에서 만든다
중요한 건 어려운 입력을 일부러 포함시킨 것이다. 평균적인 입력만 모으면 평가셋이 항상 통과한다. 통과만 하는 테스트는 테스트가 아니다.
- 활동이 거의 없는 생활기록부
- 활동이 여러 영역에 흩어진 경우
- 특정 학과로 쏠려 있어 추천이 뻔한 경우
- 형식이 깨진 PDF에서 추출된 텍스트
2단계: 사람이 안 봐도 되는 것부터 자동화한다
평가를 전부 자동화할 수는 없었다. 그래서 나눴다.
CHECKS = [
("스키마 통과", lambda out, src: validates(out)),
("추천 3개", lambda out, src: len(out.recommendations) == 3),
("인용 실재", lambda out, src: all(r.evidence in src for r in out.recommendations)),
("분량 준수", lambda out, src: all(3 <= count_sentences(r.reason) <= 4 for r in out.recommendations)),
("금지 표현", lambda out, src: not BANNED.search(out.model_dump_json())),
]
이 다섯 개는 규칙 위반 여부라 기계가 판정할 수 있다. 02번 글에서 프롬프트에 적은 규칙이 그대로 테스트 항목이 됐다. 규칙을 쓸 때부터 검사 가능하게 쓰는 게 이득이었다.
반면 “추천이 타당한가”는 자동 판정이 어렵다. 이건 샘플을 따로 떼서 직접 봤다.
3단계: 수정 전후를 같은 입력으로 돌린다
def compare(prompt_a: str, prompt_b: str, dataset: list[Case]) -> Diff:
"""같은 입력에 두 프롬프트를 돌려 체크 항목별 통과율을 비교한다."""
...
보고 싶은 건 평균 점수가 아니었다. 어떤 케이스가 A에서는 통과했는데 B에서 실패했는가. 총점은 올랐는데 특정 유형만 망가지는 경우가 실제로 있었고, 평균만 봤으면 못 봤을 것이다.
이 기법이 안 통하는 경우
- 평가셋이 작을 때. 20건으로는 1~2건 차이가 노이즈인지 개선인지 구분이 안 된다. 작은 평가셋은 “좋아졌다”보다 **“깨지진 않았다”**를 확인하는 용도로만 썼다.
- 평가셋이 고정될 때. 같은 셋만 계속 쓰면 거기에 맞춰 프롬프트를 깎게 된다. 실제 입력에서 주기적으로 새 케이스를 넣어야 했다.
- 자동 체크만 믿을 때. 다섯 항목을 다 통과하면서 추천 자체는 엉뚱한 출력이 나온다. 통과율은 하한선이지 품질이 아니다.
시리즈를 마치며
여섯 편을 관통하는 건 기법 자체가 아니다.
구조화는 지시를 읽기 쉽게 만들었고, 스키마는 형식을 프롬프트 밖으로 내보냈고, 예시는 말로 못 하는 기준을 전달했고, 분해는 틀린 지점을 보이게 만들었다.
전부 프롬프트를 관측 가능하고 수정 가능한 물건으로 만드는 작업이었다. 그리고 그게 가능해진 건 마지막에 측정이 붙었기 때문이다.
측정할 수 없으면 관리할 수 없고, 관리할 수 없으면 개선할 수 없다. 프롬프트도 예외가 아니었다.