Python 에이전트 실전 09: 프롬프트 변경 평가용 오프라인 채점 구축
프롬프트를 수정할 때 개선인지 악화인지 판단하기 위해 점수를 매기는 오프라인 평가 체계를 구축한 과정을 다룬다.
중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.
「치밍 영어(启明英语)」의 작은 AI 조교 '샤오E'의 첨삭 프롬프트를 조정하던 시기는 이 프로젝트를 하면서 가장 찔리는 시간이었다.
나는 문장 하나를 고쳤다. "아주 확실한 오류만 지적하고, 확신이 없으면 일단 보고하지 마라." 본뜻은 첨삭 어조를 부드럽게 해서 이제 막 시작한 학생을 겁먹지 않게 하려는 것이었다. 고친 뒤 대충 예시 세 개를 시험해 보고 느낌이 괜찮아서 바로 배포했다. 그런데 다음 날, 운영팀이 학부모 메시지 하나를 전달해 왔다. "샤오E가 요즘 문제가 있는 건가요? 아이 작문에 뻔히 보이는 오류를 하나도 지적하지 않아요."
돌아가서 확인해 보니 이 "확신이 없으면 일단 보고하지 마라"라는 문장 때문에 모델이 "중영 혼용" "초단문" 같은, 원래부터 확신이 없던 문장들을 전부 놓치고 있었다. 그리고 내가 그때 "대충 시험한 예시 세 개"는 마침 모델이 가장 잘하는 평범한 시제 문제들이었다. 나는 다시 말해 가장 잘 푸는 예시 세 개로 스스로에게 눈가림을 한 셈이었다.
그날부터 나는 한 가지를 깨달았다. 프롬프트를 고치는 일에 평가 세트가 없다면 그것은 도박이라는 것이다. 이번 글에서는 "느낌"을 "점수"로 바꿔 보자.
一. 먼저 기초 다지기: 왜 "느낌"은 믿을 수 없는가
1. 당신이 시험해 본 그 예시들은 언제나 가장 좋은 것들이다
이것은 인간의 본성이다. 코드를 고친 뒤 당신은 무의식적으로 가장 익숙하고 통과할 가능성이 가장 높은 예시 몇 개를 골라 검증한다. 그것들은 공교롭게도 모델이 가장 틀리기 어려운 부류다. 결과는 "예시 세 개를 시험했더니 다 맞았다"이지만, 배포 후 틀리는 것은 모두 당신이 시험해 보지 않은 부류의 문장이다.
그래서 평가 세트에는 반드시 반례가 섞여 들어가야 한다. 원래부터 틀린 곳이 없는 문장 말이다. 만약 평가 세트가 전부 "오류가 있는 문장"이라면, 모델은 그저 무턱대고 "시제 오류"라고 보고하기만 해도 높은 점수를 받을 수 있어서 그것이 함부로 보고하는지조차 측정할 수 없다.
2. "좋음"은 숫자 하나가 아니라 여러 층이다
첨삭 결과가 좋은지는 적어도 세 층이고, 층마다 지표 하나씩이다.
층위 | 답하는 질문 | 대응 지표 결과가 맞는가 | 오류를 찾았는가, 잘못 판정했는가 | exact_match, set_f1 형식이 규격에 맞는가 | 프로그램이 파싱할 수 있는가 | schema_pass_rate 경험이 좋은가 | 길지 않은가, 답변을 거부하지 않았는가, 어조는 어떤가 | avg_length, refusal_rate, judge_score
"맞고 틀림"이라는 한 층만 보면 가장 치명적인 문제를 놓친다. 바로 거부 응답이다. 모델이 "죄송합니다만 답변할 수 없습니다"라는 한마디를 하면 학부모 눈에는 "샤오E가 오늘 나를 무시한다"로 보인다. 그것은 엉뚱한 답변보다 신뢰를 더 상하게 하는데도 흔히 "맞고 틀림" 통계에는 들어가지 않는다.
3. 모델을 심판으로 세우는 데는 네 가지 함정이 있다
어떤 차원(설명이 명확한가, 어조가 선생님다운가)은 기계가 계산할 수 없어서 모델을 하나 더 불러 판정하게 해야 한다. 이것을 LLM as judge라 한다. 쓰기 좋지만 함정이 네 개 있고, 함정마다 진짜 돈과 시간을 치르며 얻은 것이다.
- 점수가 불안정함: 같은 답변에 두 번 점수를 매기면 다르다 → 심판의 temperature를 0으로 낮춘다.
- 긴 답변 편애: 길게 쓰면 높은 점수를 준다 → rubric에 "길다고 좋은 것은 아니다"라고 명시한다.
- 기준 표류: 오늘은 이 척도, 내일은 저 척도 → rubric을 네 단계로 고정해 코드에 하드코딩한다.
- 위치 편향: 심판이 이것이 "새 버전"임을 알면 봐준다 → 프롬프트에 절대 버전명이 나타나지 않게 한다.
4. 결론 한마디: 평가에 필요한 것은 "재현 가능, 비교 가능, 반례 존재"
이 세 가지를 명확히 해 두면 이후 모든 모듈에 자리가 잡힌다. 평가 세트는 "반례 존재"를, 지표는 "재현 가능"을, 버전 비교는 "비교 가능"을 담당한다.
二. 엔지니어링 설계: 여섯 개 모듈, 하나의 파이프라인
09-评测体系/ ├── src/ │ ├── settings.py # 설정(1편 재사용) │ ├── llm.py # 모델 팩토리(1편 재사용) │ ├── dataset.py # 평가 세트: 샘플 + 예측 결과 구조 │ ├── metrics.py # 결과층 지표(순수 함수) │ ├── judge.py # LLM as judge(심판) │ ├── runner.py # 배치 실행: 샘플 × 프롬프트 → 지표 │ ├── report.py # 결과를 markdown 보고서로 작성 │ └── compare.py # 버전 비교 + 퇴행 판정 ├── data/cases.jsonl # 평가 세트(JSONL, diff에 용이) ├── tests/ # 42개 테스트 케이스, 오프라인 전부 통과 └── demo.py
핵심 사상 한마디: "시험지"(평가 세트), "채점 규칙"(지표), "또 한 명의 채점 선생님"(심판), "성적표 출력"(보고서), "두 시험 비교"(비교)를 완전히 분리한다. 프롬프트를 고칠 때는 "시험지의 답안 작성자"만 바뀌고 나머지는 한 줄도 바뀌지 않는다.
三. 시험지: 평가 세트(dataset.py)
먼저 구조부터. 하나의 샘플은 "작문 + 사람이 라벨링한 기대 결과 + 태그"다.
class EvalCase ( BaseModel ): id: str essay: str # 학생이 쓴 원문 문장 expect_error_types: list [ str ] # 사람이 라벨링한 오류 유형; 오류 없으면 [] expect_needs_rewrite: bool # 문장 전체를 다시 쓸지 여부 tags: list [ str ] # 태그별 그룹화, 취약 항목 파악
오류 유형 어휘 목록은 지난 편의 구조화 출력과 일치시킨다. 평가 세트, 예측, 지표 세 쪽이 반드시 같은 언어를 써야 하며, 그렇지 않으면 F1은 영원히 제대로 계산되지 않는다.
진짜 요령은 구성 원칙에 있다. 이 네 가지가 샘플 수보다 훨씬 중요하다.
- 정례가 있어야 한다: 실제로 오류가 있는 문장, 각 오류 유형을 아우른다.
- 반례가 있어야 한다: 원래부터 맞는 문장, 모델이 함부로 보고하는지 시험한다.
- 경계 사례가 있어야 한다: a/an, arrive at/in, 완료 시제에 yesterday를 붙이는 것처럼 틀리기 쉬운 것들.
- 지저분한 데이터가 있어야 한다: 초단문, 중영 혼용은 온라인 실제 비중이 적지 않으니, 평가 세트에 없으면 사각지대다.
내장 세트는 16개를 만들었고, "평범한 시제 문제"부터 "昨天我和同学去了公园 I go to park with my classmate" 같은 혼용 문장까지 아우른다. 통짜 JSON 대신 JSONL로 저장하는 이유는 평가 세트를 리뷰하는 것이 일상이기 때문이다. git diff에서 "어느 항목을 추가했는지" 한눈에 볼 수 있다.
읽기, 쓰기, 필터링은 모두 독립된 작은 함수라 필요에 따라 가져다 쓰기 편하다.
def filter_by_tag ( cases: Iterable[EvalCase], tag: str ) -> list [EvalCase]: """태그로 샘플을 필터링한다. filter_by_tag(builtin_cases(), "반례")라고 하면 반례만 따로 돌릴 수 있다.""" return [ case for case in cases if tag in case .tags] def load_cases ( path: str | Path = DEFAULT_CASES_PATH ) -> list [EvalCase]: """JSONL에서 평가셋을 읽는다. 빈 줄은 그냥 건너뛴다 — 누군가 편집기로 끝에 줄바꿈을 남겼다고 해서 거기서 터져선 안 된다.""" file_path = Path(path) cases: list [EvalCase] = [] for line_no, line in enumerate (file_path.read_text(encoding= "utf-8" ).splitlines(), start= 1 ): if not line.strip(): continue try : cases.append(EvalCase.model_validate_json(line)) except ValueError as exc: raise ValueError( f" {file_path} {line_no}번째 줄이 올바른 샘플이 아니다: {exc} " ) from exc return cases
두 가지 디테일: filter_by_tag는 "반례"라는 범주만 따로 돌릴 수 있게 해 준다 — "모델이 함부로 오류를 보고하고 있지는 않은가"를 살필 때 특히 유용하다. load_cases는 잘못된 줄을 만나면 구체적인 줄 번호를 알려 주지, 파일 전체 파싱이 실패하지는 않는다. 잘못된 데이터는 한 줄만 오염시킨다.
4. 채점 규칙: 결과층 지표(metrics.py)
이 층은 프로젝트 전체에서 가장 단위 테스트로 못 박아 둬야 하는 곳이다 — 지표가 틀리면 뒤따르는 모든 결론이 다 틀리는데, 그것도 아주 조용히 틀린다. 그래서 여기 있는 모든 함수는 순수 함수다: 입력은 데이터, 출력은 숫자이고, 부수 효과를 일으키지 않으며, 빈 입력에 대한 정의가 명확하다.
먼저 가장 핵심적인 두 가지를 보자:
def exact_match ( case : EvalCase, pred: Prediction ) -> float : """오류 유형 집합이 같고, "다시 쓸지 말지"도 일치해야 1.0이다.""" if pred.refused: return 0.0 types_ok = set ( case .expect_error_types) == set (pred.error_types) rewrite_ok = case .expect_needs_rewrite == pred.needs_rewrite return 1.0 if types_ok and rewrite_ok else 0.0
왜 이렇게 깐깐할까? "유형이 틀린 것"과 "재작성 판단이 틀린 것"은 서로 다른 두 가지 일을 말하기 때문이다: 전자는 학생이 엉뚱한 곳을 고치게 만들고, 후자는 프런트엔드가 재작성을 아예 안 해 주거나 공연히 한 번 재작성을 해 주게 만든다. 느슨한 지표는 "반은 맞고 반은 틀린" 상태를 덮어 버리는데, 그런 버전을 출시하는 게 가장 위험하다.
그다음, 내가 실서비스에서 겪은 함정 하나가 바로 set_f1에 숨어 있다:
def set_f1 ( expected, predicted ) -> float : exp, got = set (expected), set (predicted) if not exp and not got: return 1.0 # "원래 틀린 게 없고 모델도 틀린 게 없다고 했다"는 건 맞게 판정한 것이지, "아무것도 판정하지 않았다"는 게 아니다 ...
두 빈 집합이 1.0을 반환하는 것은 의도된 정의다. 초기에는 이것을 0으로 정의했는데, 반례가 많아지자 평가 점수가 일제히 곤두박질쳤고 나는 모델이 나빠졌다고 생각했다 — 사실은 지표 정의가 거꾸로여서 "오류 없음을 올바르게 식별한 것"을 "일을 안 한 것"으로 취급한 것이었다. 이걸 막아 두지 않으면 모델은 "차라리 함부로 보고하는 쪽"을 배우게 된다.
거절 응답 판정도 따로 짚을 만하다. 여기서는 "살아 있는" 단어 목록을 쓴다:
REFUSAL_MARKERS = ( "죄송합니다" , "죄송하지만" , "저는 할 수 없습니다" , "저는 할 수 없어요" , "불가능합니다" , "답변할 수 없습니다" , "AI로서" , "AI로서는" )
실서비스에서 새로운 거절 화법을 보면 여기에 추가하고, 추가할 때는 김에 테스트도 하나 보태서 아무도 건드리기 두려워하는 블랙박스가 되지 않게 하라.
마지막으로 "방향표"가 하나 더 있는데, 이것은 지표와 "퇴화 판정" 사이의 다리다 — 각 지표가 클수록 좋은지, 작을수록 좋은지, 아니면 보기만 하고 판정하지는 않는지:
METRIC_DIRECTION = { "exact_match" : 1 , # 클수록 좋음 "set_f1" : 1 , "schema_pass_rate" : 1 , "judge_score" : 1 , "refusal_rate" : - 1 , # 작을수록 좋음 "avg_latency_ms" : - 1 , "avg_length" : 0 , # 보기만 하고 판정 안 함 "judge_parse_fail_rate" : - 1 , }
avg_length는 왜 "보기만 하고 판정하지 않는가"? 첨삭은 길수록 좋은 게 아니지만, "자세히 설명해 주세요"라는 문장을 일부러 덧붙인 결과일 수도 있기 때문이다. 이것만 보면 좋고 나쁨이 없고, 봐야 할 것은 두 버전 사이의 차이다. 그래서 방향표에서 이것은 판정에 들어가지 않고 보고서에만 표시된다.
5. 또 한 명의 채점 교사: LLM as judge(judge.py)
결과층은 "유형이 맞는지"만 판정할 수 있고, "설명이 명확한지" "없는 오류를 지어내지는 않았는지"는 판정하지 못한다. 이 부분은 심판 모델에게 맡긴다.
심판의 rubric은 코드에 하드코딩되어 있고, 네 단계(1-4점)다. 단계가 더 많으면 심판 자신도 6과 7의 차이를 분간하지 못한다:
4점 · 완전히 사용 가능: 오류 유형이 참고 요점과 일치하고, 지어낸 것이 없으며, 설명이 한 문장으로 명확하다 3점 · 기본적으로 사용 가능: 주요 오류는 잡았으나 사소한 오류 하나를 놓쳤거나, 설명이 다소 장황하다 2점 · 문제 있음: 참고 요점의 오류를 놓쳤거나, 없는 오류를 과다 보고했다 1점 · 사용 불가: 오류를 찾지 못했거나, 오류가 있는데 없다고 판정했거나, 질문과 동떨어진 답을 했거나, 아예 거절했다
네 가지 강성 요구 중 가장 핵심은 이것이다 — "많이 쓰는 것이 잘 쓰는 것과 같지 않다"이며, 텍스트가 길다는 이유로 높은 점수를 주어서는 안 된다. 이것은 "긴 답변 편향"이라는 함정에 대한 직접적인 대응이다.
위치 편향은 어떻게 방지하는가? 이 함수의 시그니처를 보자:
def build_judge_messages ( case : EvalCase, pred: Prediction ) -> list : """심판에게 보여줄 메시지를 렌더링한다. 시그니처에 버전 번호 파라미터가 없다는 점에 주목하라——이것은 의도된 것이다. 심판에게 "이건 새 버전"이라고 넣어주고 싶다면, 먼저 이 함수 시그니처를 고쳐야 하고, 그 변경은 테스트의 "프롬프트에 버전명이 나타나면 안 된다"는 규칙과 충돌하게 된다. """
함수 시그니처로 위치 편향을 막는 것이 스스로의 각오에 기대는 것보다 믿을 만하다. 심판 응답 파싱에는 3단계 폴백을 두어야 하며, 단계가 내려갈수록 느슨해진다:
def parse_judge_reply ( text: str ) -> JudgeResult: raw = _strip_fence(text or "" ) # 먼저 ```json 펜스 제거 try : data = json.loads(raw) # 1. 전체가 곧 JSON except (json.JSONDecodeError, TypeError): data = None if isinstance (data, dict ): score = data.get( "score" ) if isinstance (score, ( int , float )) and 1 <= int (score) <= 4 : return JudgeResult(score= int (score), reason= str (data.get( "reason" ) or "" ), ok= True ) hit = re.search( r'"score"\s*[::]\s*([1-4])' , raw) # 2. 산문 속에서 점수 추출 if not hit: hit = re.search( r"[给打]?\s*分?\s*[::]?\s*([1-4])\s*分" , raw) if hit: return JudgeResult(score= int (hit.group( 1 )), reason= "형식은 규정에 맞지 않지만 점수는 사용 가능" , ok= True ) return JudgeResult(score= 0 , reason= "심판 출력을 파싱할 수 없어 0점으로 계상" , ok= False ) # 3. 포기
심판 스스로 설명을 못 할 때는 조용히 버리지 않고, 0점을 주고 별도로 judge_parse_fail_rate 를 하나 세운다. 심판이 불안정하면 이번 점수 묶음은 신뢰해서는 안 된다.
六、배치 실행: 한 번의 평가는 어떻게 착지하는가 (runner.py)
배치 실행 로직에는 두 가지 설계 결정이 있고, 둘 다 주석에 적혀 있다:
- 동시성은 1로 고정. 오프라인 평가가 원하는 것은 재현성이지 속도가 아니다. 동시성은 순서를 흐트러뜨리고(순서 기반 가짜 모델은 어긋나 버린다), 소요 시간을 왜곡시키며, 실패를 찾기 어렵게 만든다.
- prompt_builder 는 입력 파라미터다. 배치 실행 로직은 프롬프트가 어떻게 생겼는지 알아서는 안 된다. "글 → 메시지 목록" 함수를 하나 넘겨받으면, 같은 코드로 A버전과 B버전을 모두 돌릴 수 있다.
모델 출력을 파싱할 때 특히 중요한 규율이 하나 있다: 파싱 실패는 예외가 아니라, 정보량이 극히 높은 결과다 —— schema_pass_rate 는 바로 이것에 기대어 계산된다. 그래서 parse_prediction 은 schema_ok=False 를 반환하고, 절대 예외를 던져 배치 실행을 중단하지 않는다:
try : data = json.loads(payload) except (json.JSONDecodeError, TypeError): return Prediction(case_id=case_id, raw=raw, schema_ok= False , ...)
또한 오류 유형은 반드시 어휘 목록 안에 있어야 한다——모델이 "문법 오류" 같은 유형을 스스로 창조해 내면, 프런트엔드에서 이를 가져다 그룹 통계를 낼 때 유형이 하나 더 늘어나게 되며, 이는 조용한 오염에 해당한다.
배치 실행의 메인 루프는 아주 소박해서, 딱 세 단계다: 예측 → 결과 지표 계산 → 선택적으로 심판에게 채점 요청:
for case in selected: started = time.perf_counter() reply = llm.invoke(prompt_builder( case .essay)) # 1) 예측 latency_ms = int ((time.perf_counter() - started) * 1000 ) raw = getattr (reply, "content" , None ) pred = parse_prediction( case . id , raw, latency_ms=latency_ms) result = CaseResult( # 2) 결과 계층 지표 case = case , pred=pred, exact=exact_match( case , pred), f1=set_f1( case .expect_error_types, pred.error_types), ) if judge_llm is not None : # 3) 심판(선택) result.judge_result = judge(judge_llm, case , pred) results.append(result)
latency_ms 는 예측 전후로 시간을 재서 얻는다는 점에 주목하라. 오프라인 가짜 모델에서는 0에 가깝고, 실제 기기에서 돌려야 의미가 있다——그래서 보고서에는 이를 있는 그대로 표시하며, 성능을 측정한 척하지 않는다.
七、버전 비교: 변경이 퇴화인가 아닌가 (compare.py)
이것이 전체 체계에서 가장 "독한" 동시에 가장 값진 한 고리다. 판정 규칙은 단 한 문장이다:
어떤 핵심 지표든 나쁜 방향으로 임계치를 넘어 떨어지면, 퇴화로 판정한다.
"총점이 떨어져야만 퇴화"가 아니고, "세 항목 중 두 항목이 떨어져야만 퇴화"도 아니다. 왜 이렇게 단호한가? 프롬프트 변경의 가장 흔한 실패 양상이 바로 拆东墙补西墙 ——정확도는 2포인트 오르는데 거부 응답률은 5포인트 오르는 식이기 때문이다. 총점은 보기 좋은데, 학부모 쪽은 터져 나간다.
CORE_METRICS = ( "exact_match" , "set_f1" , "schema_pass_rate" , "judge_score" , "refusal_rate" ) def _delta_is_bad ( delta, direction, threshold ) -> bool : if direction > 0 : # 越大越好 return delta < -threshold return delta > threshold # 越小越好
또 하나, 제가 많은 수고를 덜어준 부분이 있습니다. 바로 태그별로 약점을 보는 것입니다. 총점이 떨어지지 않았다고 해서 각 유형의 문장이 모두 안 떨어졌다는 뜻은 아닙니다. 비교 결과에서 태그별 차잇값을 따로 계산해 "가장 심하게 떨어진 태그"를 골라냅니다. 그것이 바로 학부모 민원에 나오는 그런 유형의 문장인 경우가 많아서, 총점만 보면 쉽게 지나치기 쉽습니다. "가장 심한 것"을 고를 때는 반직관적인 디테일이 하나 더 있습니다. 차잇값이 같으면 표본 수가 많은 쪽으로 계산합니다. 왜냐하면 표본 2건인 태그가 1.0 떨어진 것이 표본 1건인 것보다 설득력이 있기 때문입니다. 차잇값만으로 정렬하면 표본이 한 건뿐인 카테고리를 먼저 가리키게 되어, 그걸 붙들고 한참 고쳐도 진짜 문제는 여전히 다른 곳에 있게 됩니다.
八、성적표 내기: 리포트(report.py)
지표를 계산했으니 이제 사람이 볼 수 있는 것으로 바꿔야 합니다. 리포트 모듈은 한 번의 평가를 markdown으로 렌더링합니다. 전체 지표는 표 하나, 실패 사례는 목록 하나, 태그별로 묶은 약점은 한 단락으로 만듭니다. 배치를 돌리고 나면 reports/ 에 파일을 하나 남기는데, 파일명에 날짜와 버전 번호가 들어가서 평가 결과에 자연스럽게 시간선이 붙습니다. 석 달 뒤에 "그때 이 버전은 몇 점이었지" 하고 다시 보고 싶으면 디렉터리를 뒤지면 됩니다.
여기에도 정직 원칙이 하나 있습니다. 심판을 돌리지 않았다면 채점한 척하지 않습니다. aggregate는 실제로 심판이 전달된 경우에만 judge_score와 judge_parse_fail_rate 두 항목을 출력합니다. 하나가 빠질지언정 0을 채워 거짓말하지 않습니다. 평가 체계에서 가장 두려운 것은 "점수가 안 좋아 보이는 것"이 아니라 "점수가 가짜인 것"입니다.
九、돌려 보기: 실제 비교
python demo.py , 전 과정 오프라인. 두 버전은 같은 16개 케이스를 공유하고, 유일한 차이는 system prompt 안의 "확신이 안 서면 우선 보고하지 말 것"이라는 문장 하나입니다.
【기준 리포트】엄격 버전 | 엄격 일치율 | 1.000 | | 오류 유형 집합 F1 | 1.000 | | 구조화 통과율 | 1.000 | | 심판 평균 점수(1-4점) | 3.938 | | 거절 응답률 | 0.000 | 【후보 리포트】완화 버전 | 엄격 일치율 | 0.812 | | 오류 유형 집합 F1 | 0.875 | | 구조화 통과율 | 0.938 | | 심판 평균 점수(1-4점) | 3.188 | | 거절 응답률 | 0.062 |
버전 비교의 결론은 핵심을 찌릅니다.
| exact_match | 1.000 | 0.812 | -0.188 | 퇴화 ⚠️ | | set_f1 | 1.000 | 0.875 | -0.125 | 퇴화 ⚠️ | | schema_pass_rate | 1.000 | 0.938 | -0.062 | 퇴화 ⚠️ | | refusal_rate | 0.000 | 0.062 | +0.062 | 퇴화 ⚠️ | | judge_score | 3.938 | 3.188 | -0.750 | 퇴화 ⚠️ | 결론: 퇴화, 이번 변경은 출시를 권하지 않습니다. 태그별로 약점 보기: | 중국어 혼용 | 2 | -1.000 | | 초단문 | 1 | -1.000 | | 경계 | 7 | -0.429 | 가장 심하게 떨어진 것은 「중국어 혼용」입니다. 총점만 보면 쉽게 지나칠 수 있는데, 바로 이것이 학부모 민원에 나오는 그런 유형의 문장입니다.
애초에 제게 이 시스템이 있었다면, "확신이 안 서면 우선 보고하지 말 것"이라는 문장은 출시 전에 이 표 하나로 막혔을 것입니다. 이건 제가 사후에 지혜를 짜내는 것이 아니라, 사전에 결론을 낼 수 있었다는 것입니다.
테스트 결과, 42개 케이스 전부 통과, 전 과정 오프라인입니다.
tests/test_dataset.py 내장 집합에 반례 포함, tag 필터링, JSONL 저장·읽기 일치 tests/test_metrics.py 각 지표, 거절 어휘 목록, F1 빈 집합 정의 tests/test_judge.py rubric 네 단계, 파싱 3단계 폴백, 프롬프트에 버전명 없음 tests/test_runner.py 배치 실행, schema 관용 처리, limit 적용 tests/test_report.py 리포트 행 수, 지표 표 반영 tests/test_compare.py 퇴화 판정, 임계값, worst_tag 정렬 42 passed in 2.01s
이 테스트 묶음에서 제가 가장 신경 쓰는 두 가지는 test_judge의 "심판 프롬프트에 버전명이 없어야 한다"와 test_metrics의 "두 빈 집합의 F1은 1과 같다"입니다. 전자는 평가의 공정성을 지키고, 후자는 지표의 정의를 지킵니다. 평가 체계에서 가장 비싼 버그는 코드 에러가 아니라, 정의가 슬며시 뒤집히는 것입니다.
十、함정 체크리스트
- 평가 집합에 반례가 없음 : 전부 틀린 문장이면 모델이 무턱대고 오류를 보고해도 높은 점수를 받습니다. "원래 맞는" 문장을 반드시 섞어 넣어야 합니다.
- "두 빈 집합 F1" 정의가 뒤집힘 : "정확히 무오류로 식별"을 "아무 일도 안 함"으로 취급하면, 모델이 억지로 마구 보고하도록 몰리게 됩니다.
- 거절 응답이 "체면 있는 실패"로 취급됨 : 이것은 엉뚱한 답보다 신뢰를 더 상하게 하고, 대개 정오 통계에 들어 있지도 않습니다. exact_match에서는 거절 응답을 바로 0으로 판정합니다.
- 심판 temperature를 0으로 눌러두지 않음 : 같은 답안에 두 번 점수를 매기면 결과가 달라져서, 평가가 주사위 던지기로 퇴화합니다.
- 심판이 버전명을 앎 : 봐주게 됩니다. 함수 시그니처로 위치 편향을 막고, 자의식에 기대지 마세요.
- 총점만 보고 태그는 안 봄 : 이쪽을 뜯어 고쳐 저쪽을 메우는 것이 총점에 가려집니다. 태그별 차잇값을 보고, 가장 심하게 떨어진 유형을 골라내세요.
- 오프라인 평가를 동시성으로 실행 : 순서가 흐트러지고, 소요가 실제와 달라지고, 실패를 짚기 어려워집니다. 오프라인 평가의 동시성은 1로 고정합니다.
十一、이 편을 마치며
이제 당신에게는 반례가 있는 시험지, 단위 테스트로 못 박을 수 있는 채점 규칙 한 줄, 편견 없는 심판 한 명, "퇴화"를 자동으로 판정하는 성적표 한 장이 생겼습니다. 프롬프트를 고치는 일이 "세 가지 예시로 해보니 괜찮아 보인다"에서 "16개를 한 번 돌려 보고, 표 하나를 보면 2분 만에 결론이 난다"로 바뀌었습니다.
마지막으로 실무 조언 하나를 남깁니다. 언제 평가 집합에 표본을 추가할까요? 온라인에서 문제가 생길 때마다 그 "빠져나간 문장"을 집어넣으면 됩니다. 평가 집합은 한 번 만들어 놓으면 끝나는 자산이 아니라, 매 사고마다 키워지는 것입니다. 자주 보충할수록 그것은 당신의 진짜 온라인 트래픽에 더 가까워집니다. 제 습관은, 학부모 민원에 등장하는 문장은 당일 반드시 평가 집합에 넣는 것입니다.
이것 역시 이 시리즈의 끝에서 두 번째 글이다. 다음 편에서 마무리하며, 앞선 여덟 편에서 쌓아온 모든 역량, 즉 구조화 출력, 컨텍스트 관리, 도구 호출, 안정성 보루를 하나의 FastAPI 서비스에 담고 스트리밍 출력을 더해 실제로 온라인에 배포한다.
마지막 한마디: 평가 세트는 당신이 석 달 뒤의 자신에게 쓰는 편지다. 그때쯤이면 당신은 오늘 무엇을 바꿨는지 이미 잊었겠지만, 점수는 아직 기억하고 있을 테니까.
威哥爱编程
AI 풀스택, 공식 계정: 威哥爱编程
436
글
330k
조회
572
팬