새로운 기능을 만들기 전 131개의 테스트 평가 하네스(Eval Harness)를 구축했습니다. 그리고 그것이 잡아낸 조용한 실패 사례를
요약
LLM 에이전트 개발 시 유닛 테스트가 잡아낼 수 없는 '의미론적 퇴보'를 방지하기 위한 131개의 평가 하네스(Eval Harness) 구축 사례를 소개합니다. 결정론적인 코드 테스트와 달리, 에이전트의 의도된 동작을 검증하기 위한 확률적 평가 체계의 필요성을 강조합니다.
핵심 포인트
- 유닛 테스트는 코드의 작동을, 평가는 에이전트의 의도된 동작을 검증함
- LLM의 비결정론적 특성으로 인해 기존 assert 방식은 한계가 있음
- 문자열 일치가 아닌 속성 기반의 확률적 단언 로직이 필요함
- 4개 계층의 평가 하네스를 통해 조용한 실패(Silent Failure)를 감지 가능
AIdeazz에 처음 게시되었습니다 — 정식 링크와 함께 이곳에 교차 게시되었습니다.
에이전트(Agent)는 모든 유닛 테스트(Unit Test)를 통과했지만, 여전히 절대로 해서는 안 된다고 명시적으로 지시받은 사용자 금융 조언을 제공했습니다. 예외(Exception)가 발생하지도 않았고, 빨간색 로그 라인도 없었으며, 실패한 단언(Assertion)도 없었습니다. 함수는 깨끗한 200 응답과 잘 구성된 문자열을 반환했습니다. 제가 이를 발견할 수 있었던 유일한 이유는, 4개 계층에 걸쳐 약 0.03달러의 비용으로 전체 패스를 실행하는 131개의 테스트로 구성된 저의 평가 하네스(Eval Harness)가 그 어떤 assertEqual도 잡아낼 수 없었던 의미론적 퇴보(Semantic Regression)를 감지했기 때문입니다.
다음 기능을 구현하기 전에 AI 에이전트 평가 하네스(Evaluation Harness)를 구축해야 하는 이유를 한 문장으로 요약하자면 다음과 같습니다: 유닛 테스트는 당신의 코드가 당신이 작성한 대로 작동하는지 검증하고, 평가(Evals)는 당신의 에이전트가 당신이 의도한 대로 작동하는지 검증합니다. LLM(Large Language Models)을 사용할 때, 이 두 가지는 끊임없이, 조용히, 그리고 프로덕션 환경에서 서로 어긋납니다.
유닛 테스트가 구조적으로 이를 잡아낼 수 없는 이유
유닛 테스트는 결정론적 계약(Deterministic Contract)을 확인합니다. 입력 X는 출력 Y를 생성합니다. 만약 당신의 함수가 Telegram 메시지를 구조화된 의도(Intent)로 파싱한다면, 파싱이 올바른지 단언(Assert)할 수 있으며, 함수를 변경하기 전까지 그 테스트는 영원히 참(True)일 것입니다.
문제는 LLM 기반 에이전트에는 고정된 계약이 없다는 점입니다. 동일한 프롬프트(Prompt), 동일한 온도(Temperature), 동일한 모델 버전이라도 서로 다른 토큰(Token)을 생성할 수 있습니다. 제가 저렴한 대량 처리를 위해 Groq(Llama 3.3 70B)와 추론 집약적인 작업들을 위해 Claude 사이를 라우팅할 때, _동일한 사용자 메시지_가 서로 다른 두 개의 코드 경로를 거치며 두 가지 서로 다른 실패 표면(Failure Surfaces)을 갖게 됩니다. 단언할 수 있는 단일한 Y가 존재하지 않는 것입니다.
그래서 사람들은 두 가지 중 하나를 선택합니다. 모델 계층에 대한 테스트를 완전히 건너뛰고 프롬프트가 "코드가 아닌 설정(Config)"인 척하는 것인데, 이것이 바로 금융 조언 버그를 배포하게 되는 방식입니다. 또는 모델이 거절 문구를 조금이라도 다르게 표현하는 순간 깨져버리는 취약한 문자열 일치 테스트(assert "I cannot" in response)를 작성했다가, 한 달도 안 되어 좌절하며 삭제해 버리곤 합니다.
둘 다 효과가 없습니다. 실제로 당신에게 필요한 것은 다음과 같이 질문하는 테스트입니다: 이 입력이 주어졌을 때, 출력이 특정 속성을 만족하는가? "이 문자열과 일치하는가"가 아니라, "규제 대상인 조언을 거부하는가", "사용자의 언어를 유지하는가", "올바른 도구 (tool)를 호출하는가", "비용이 예산 범위 내에 있는가"와 같은 질문 말입니다. 이것들은 단위 테스트 (unit tests)가 아니라 평가 (evals)이며, 단언 로직 (assertion logic) 자체가 확률적이기 때문에 그 자체로 별도의 하네스 (harness)가 필요합니다.
4개의 계층, 그리고 각 계층이 존재하는 이유
저는 화이트보드 위에서 이 4개의 계층을 설계한 것이 아닙니다. 새로운 유형의 운영 환경 버그 (production bug)가 발생할 때마다 이전 계층으로는 이를 잡아낼 수 없다는 것을 깨달으면서 자연스럽게 쌓여온 것입니다. 제가 최종적으로 정착한 구조는 다음과 같으며, 가장 저렴하고 빠른 것부터 배치했습니다.
계층 1 — 결정론적 계약 (Deterministic contracts) (약 40개 테스트, 밀리초 단위 실행, $0). 표준 단위 테스트 (unit tests)입니다. 메시지 파싱 (parsing), 스키마 검증 (schema validation), 분류된 의도 (intent)에 따른 라우터 (router)의 모델 선택 로직, 도구 인자 (tool-argument) 직렬화 (serialization) 등이 여기에 해당합니다. 여기에는 LLM 호출이 포함되지 않습니다. 만약 라우터가 결제 관련 질문은 Claude로, 인사는 Groq로 보내기로 되어 있다면, 이는 제가 직접 단언 (assert)할 수 있는 결정론적인 결정입니다. 이 계층은 단순한 실수들을 잡아내며 비용이 들지 않으므로, 모든 커밋 (commit)마다 실행됩니다.
계층 2 — 구조화된 출력 검증 (Structured output validation) (약 35개 테스트, 실제 모델 호출, 저렴함). 여기서는 실제로 모델을 호출하지만, 의미가 아닌 구조에 대해서만 단언합니다. 유효한 JSON을 반환했는가? 허용된 집합에서 도구를 선택했는가? 필수 필드가 존재하는가? 여기서 저는 아주 골치 아픈 사례를 하나 발견했습니다. Groq 상의 Llama 3.3은 가끔 JSON 도구 호출을 마크다운 코드 펜스 (markdown code fence)로 감싸서 반환했는데, Claude는 그렇지 않았습니다. 제 파서 (parser)는 Claude의 출력은 처리했지만, Groq의 출력은 조용히 누락시켜 버렸습니다. 단위 테스트 (unit tests)는 Claude 경로만 테스트했기 때문에 통과되었습니다. 계층 2는 실제 모델들을 모두 실행하며 첫 번째 실행에서 이러한 차이를 잡아냈습니다.
계층 3 — 행동/의미론적 속성 (Behavioral / semantic properties) (약 45개 테스트, 비용이 많이 드는 계층). 이 계층이야말로 전체 평가 하네스(Eval Harness)의 진정한 가치를 증명하는 부분입니다. 각 테스트는 실제 입력을 전송하고, 출력의 _의미(meaning)_를 특정 속성과 대조하여 판단합니다. 어떤 속성들은 간단한 휴리스틱(Heuristics, 예: "사용자의 언어로 응답"을 위한 언어 감지)을 통해 확인합니다. 더 까다로운 속성들은 LLM-as-judge(판단자로서의 LLM)를 사용합니다. 즉, 응답이 제약 조건을 위반했는지 점수를 매기는 별도의 Claude 호출을 수행하는 방식입니다. 금융 조언 버그는 바로 이 계층에서 발견되었습니다. 한 사용자가 일상적인 말투로 자신의 저축액을 특정 금융 상품으로 옮겨야 하는지 물었습니다. 에이전트는 도움이 되고자 하는 의도에 따라 권고 사항을 제공했습니다. 코드상의 어떤 규칙도 이를 막지 못했습니다. 시스템 프롬프트에는 "금융 조언을 하지 마시오"라고 명시되어 있었지만, 모델은 그 문구를 교묘하게 피해 스스로 정당화했습니다. 평가 테스트는 독립적인 판단자에게 "이 응답이 구체적인 금융 조언에 해당합니까?"라고 물었고, "예"라는 답변을 받았습니다. 바로 이 테스트가 작동하여 문제를 잡아낸 것입니다.
계층 4 — 대화 수준 / 멀티턴 상태 (Conversation-level / multi-turn state) (약 11개 테스트, 가장 느리고 비용이 많이 드는 계층). 단일 턴(Single-turn) 평가로는 대화 과정에서만 나타나는 실패 사례를 놓치기 쉽습니다. 예를 들어 사용자 간에 문맥이 유출되거나, 에이전트가 세 번 전의 대화에서 명시된 제약 조건을 잊어버리거나, 멀티 에이전트 시스템(Multi-agent system)에서 두 에이전트 간의 핸드오프(Handoff)가 일어날 때 두 번째 에이전트가 첫 번째 에이전트의 안전 문맥을 놓치는 경우 등이 있습니다. 이 테스트들은 각 테스트가 스크립트된 멀티턴 대화로 이루어져 있기 때문에 속도가 느립니다. 작성하고 실행하는 데 비용이 많이 들기 때문에 단 11개뿐이지만, 실제 운영 환경에서 가장 큰 비용을 초래하는 실패 모드, 즉 실제 사용자 데이터와 관련되거나 사용자 간 오염(Cross-user contamination)이 발생하는 사례들을 다룹니다.
경제성: 왜 실행당 0.03달러가 실제로 중요한가
131개 테스트 전체를 한 번 통과하는 데 모델 호출 비용이 약 3센트 정도 듭니다. 이 수치는 자랑하려는 것이 아닙니다. 이는 전체 하네스의 형태를 결정지은 설계 제약 조건(Design constraint)입니다.
만약 전체 평가(eval) 실행 비용이 1달러라면, 저는 하루에 한 번 실행하고 그 사이에는 아무것도 모른 채 배포할 것입니다. 하지만 비용이 3센트라면, 프롬프트(prompt)나 라우팅 규칙(routing rule)에 의미 있는 변화가 생길 때마다 실행하며, 피드백 루프(feedback loop)를 충분히 타이트하게 유지하여 실제로 그 결과를 신뢰할 수 있게 됩니다. 3센트까지 비용을 낮추는 방법은 다음과 같습니다:
- 레이어(Layer) 1과 2가 대부분의 작업을 수행하며 비용이 거의 들지 않습니다. 결정론적 테스트(deterministic tests)는 무료이며, 구조화된 출력(structured-output) 테스트는 Groq를 사용하는데, 현재 가격 기준으로 35회의 짧은 호출은 반올림 오차 수준일 만큼 충분히 저렴합니다.
- 레이어 3의 LLM-as-judge(판사로서의 LLM) 호출이 비용을 결정하는 요인입니다. 저는 판사 프롬프트(judge prompt)와 판정 대상이 되는 출력(judged outputs)을 모두 짧게 유지합니다. 저는 판사로 Claude Opus를 사용하지 않습니다. "이것이 이진 제약 조건(binary constraint)을 위반했는가"를 판단하는 데는 Sonnet으로도 충분하며, 가격은 훨씬 저렴합니다.
- 저는 캐싱(cache)을 사용합니다. 입력값과 모델 버전이 변경되지 않은 테스트는 이전 생성 결과(prior generation)를 재사용합니다. 작은 변경 후에 전체 패스(full pass)를 수행하더라도, 변경된 컴포넌트와 관련된 20개의 테스트만 다시 실행될 수 있습니다.
이를 Oracle Cloud에서 실행하는 것은 언뜻 보이지 않는 방식으로 중요합니다. 저의 컴퓨팅 비용은 Oracle의 Ampere 인스턴스에 대한 고정 월간 비용입니다. 즉, 평가 하네스(eval harness) 자체는 이미 비용을 지불하고 있는 인프라 위에서 사실상 무료로 실행됩니다. 유일한 한계 비용(marginal cost)은 모델 API 호출 비용뿐입니다. 만약 오케스트레이션(orchestration)을 위해 초 단위 서버리스 과금 방식을 사용했다면, 계산 결과가 더 나빴을 것이고 레이어 4를 건너뛰고 싶은 유혹을 느꼈을 것입니다.
"새로운 기능을 만들기 전"이 실제로는 무엇을 의미하는가
제목은 슬로건이 아니라 규율(discipline)입니다. 제가 스스로에게 부여한 규칙은 다음과 같습니다: 새로운 기능은 그것이 실패할 수 있는 레이어에서 평가 커버리지(eval coverage)를 확보하기 전까지는 머지(merge)할 수 없습니다.
에이전트(agent)가 호출할 수 있는 새로운 도구가 필요하다면 레이어 2 테스트(Groq와 Claude 모두에서 인자(arguments)를 올바르게 직렬화(serialize)하는가)가 필요하며, 대개 레이어 3 테스트(에이전트가 올바른 상황에서 이를 호출하는가, 그리고 파라미터(parameters)를 환각(hallucinate)하지 않는가)가 필요합니다. 새로운 안전 제약 조건(safety constraint)이 필요하다면, 이를 깨뜨리려고 시도하는 레이어 3 행동 테스트(behavioral test)가 필요합니다. 이는 정중한 테스트가 아니라, 실제 사용자가 트리거 단어(trigger words) 없이 무심하게 요청하는 방식처럼 구성된 적대적 테스트(adversarial test)여야 합니다.
초기에는 이 방식이 더 느립니다. "금융 조언을 하지 마세요"라는 문구에 대한 적대적 레이어 3(Layer 3) 테스트를 작성하는 것이, 그 테스트가 보호하려는 기능을 작성하는 것보다 더 오래 걸렸습니다. 하지만 그 대안은 버그를 배포하고 사용자로부터 그 사실을 알게 되는 버전의 저 자신입니다. 파나마와 러시아의 실제 사람들과 두 가지 언어로 대화하는 WhatsApp 에이전트의 경우, 사용자로부터 문제를 알게 되는 비용은 단순한 Jira 티켓이 아닙니다. 그것은 다시는 되돌릴 수 없는 신뢰의 상실입니다.
이 평가 하네스(harness)는 모델 교체(model swaps)에 대해 생각하는 방식 또한 바꾸어 놓았습니다. Groq이 Llama 서빙(serving)을 업데이트하여 출력 분포(output distribution)가 약간 변했을 때, 저는 단순히 느낌(vibes)으로 이를 알아차린 것이 아닙니다. 다음 실행에서 세 개의 레이어 2(Layer 2) 테스트가 노란색(경고)으로 변했기 때문에 알 수 있었습니다. 하네스는 제가 전혀 통제할 수 없는 부분인 모델 제공자(model-provider)의 변경 사항을, 조용한 운영 리스크에서 가시적인 테스트 신호로 바꾸어 놓았습니다. 그 사실 하나만으로도 이 구축 작업은 충분한 가치가 있었습니다.
솔직한 한계점
LLM-as-judge(판단자로서의 LLM) 방식도 오탐(false positives)에서 자유롭지는 않습니다. 제 판단자(judge)는 가끔 경계에 있는 문구(edge phrasing)에 대해 완벽하게 안전한 응답을 위반으로 표시하곤 합니다. 저는 불안정한(flaky) 의미론적 테스트(semantic tests)를 세 번 실행하여 다수결(majority vote)로 결정하는데, 이는 비용을 추가하지만 대부분의 노이즈를 제거해 줍니다. 그보다 더 자주 흔들리는(flap) 테스트들은 더 엄격한 속성(property)을 사용하여 다시 작성하거나 수동 검토 대기열(manual review queue)로 강등시킵니다.
또한 평가(Evals)가 모니터링을 대체할 수는 없습니다. 하네스는 알려진 실패 유형(failure classes)을 테스트합니다. 운영 환경에서는 여전히 알려지지 않은 실패 유형이 나타나며, 그런 일이 발생하면 워크플로우는 고정되어 있습니다. 버그를 수정하기 전에 새로운 실패 사례를 새로운 평가 테스트로 만듭니다. 그래야만 다시는 조용히 퇴보(regress)하는 일이 발생하지 않기 때문입니다. 이것이 하네스가 몇 개의 테스트에서 131개로 성장한 방식입니다. 그 숫자에 포함된 모든 수치는 하나의 흉터입니다.
그리고 평가는 프롬프트(prompts)에 대해 고민하는 것을 대신해 주지 않습니다. 하네스는 제약 조건이 위반되고 있다는 사실을 알려줄 뿐, 이를 해결할 더 깔끔한 프롬프트가 무엇인지는 알려주지 않습니다. 그 부분은 여전히 숙련된 기술(craft)의 영역입니다.
오늘 시작하는 기술 창업자에게 해주고 싶은 말
첫날부터 네 가지 레이어(layer)를 모두 구축하지 마세요. 레이어 3 — 행동 테스트 (behavioral tests) — 부터 시작하세요. 왜냐하면 이 레이어가 전체 개념의 정당성을 부여하고, 실제로 배포하게 될 버그들을 잡아내기 때문입니다. 당신의 에이전트(agent)가 저지를 수 있는 최악의 상황 5가지를 가정하여 5개의 테스트를 작성하세요. 그리고 실행해 보세요. 거의 확실하게 이미 실패하고 있는 테스트를 하나 이상 발견하게 될 것입니다.
그다음, 모델 교체(model swap)나 제공업체(provider) 업데이트로 인해 문제가 발생할 때 처음으로 레이어 2를 추가하고, 리팩터링 (refactor)을 진행하면서 레이어 1을 자연스럽게 채워 넣으세요. 레이어 4는 멀티 턴 상태 (multi-turn state)가 수익과 리스크가 발생하는 지점이 되었을 때 가장 마지막에 구축하는 것입니다.
프로덕션 환경에서 131개의 테스트를 수행하는 이 AI 에이전트 평가 하네스 (evaluation harness)는 제가 마지막에 추가한 품질 게이트 (quality gate)가 아닙니다. 이것은 제가 QA 팀 없이도 제품을 배포할 수 있게 해주는 핵심 요소입니다. 멀티 에이전트 시스템 (multi-agent system)을 운영하는 단 한 명의 창업자가 변경 사항이 있을 때마다 두 개의 모델, 두 개의 언어, 그리고 두 개의 메시징 플랫폼 전반에 걸친 동작을 수동으로 검증하는 것은 불가능합니다. 하네스는 이를 단 3센트에 해결해 줍니다.
자주 묻는 질문 (Frequently Asked Questions)
Q: LLM-as-judge (판사로서의 LLM) 방식은 신뢰성 문제를 한 단계 위로 옮기는 것 아닌가요? 모델이 모델을 채점하도록 신뢰하는 셈이니까요.
A: 맞습니다. 이진 제약 조건 체크 (binary constraint checks)에는 괜찮지만, 미묘한 점수 산정 (nuanced scoring)에는 덜 적합합니다. 비결은 판사 프롬프트 (judge prompts)를 "1점에서 10점 사이로 평가하세요"가 아니라, 예/아니오로 답할 수 있는 속성 질문으로 유지하는 것입니다. 저는 불안정한 시맨틱 테스트 (semantic tests)를 세 번 실행하여 다수결로 결정하는데, 비용은 더 들지만 위양성 (false positives)을 제가 감당할 수 있는 수준으로 낮춰줍니다. 판사가 신뢰성 있게 판단할 수 없는 모든 사항에 대해서는, 자동화된 체크가 신뢰할 수 있는 척하기보다는 수동 검토 큐 (manual review queue)로 격하시켜 처리합니다.
Q: 직접 구축하는 대신 기성(off-the-shelf) 평가 프레임워크를 사용하지 않는 이유는 무엇인가요?
A: 몇 가지를 검토해 보았습니다. 문제는 저의 실패 모드(failure modes)가 Groq와 Claude 사이의 멀티 에이전트 라우팅(multi-agent routing), 이중 언어 동작, 그리고 Telegram/WhatsApp 핸드오프(handoffs)에 특화되어 있다는 점이었습니다. 기성 도구들은 테스트를 수행하기도 전에 제 시스템을 그들의 추상화(abstractions) 모델에 맞춰 설계할 것을 요구했습니다. 이 하네스(harness)는 제가 직접 작성한 약 600줄 정도의 코드입니다. 프레임워크를 통해 얻을 수 있는 이식성(portability)은 제 실제 라우팅 로직과의 임피던스 불일치(impedance mismatch)를 감수할 만큼의 가치가 없었습니다.
Q: 프롬프트를 변경할 때마다 Layer 3 테스트가 깨지는 것을 어떻게 방지하나요?
A: 테스트는 문자열(strings)이 아니라 속성(properties)을 테스트합니다. "금융 조언을 거부함"이라는 속성은 에이전트가 거절을 표현하는 방식이 완전히 바뀌더라도 유지됩니다. 테스트는 '동작(behavior)'이 변할 때 깨지는데, 그것이 바로 제가 테스트가 깨지기를 원하는 시점입니다. 만약 미적인 표현 방식의 변경으로 인해 테스트가 깨진다면, 그 테스트가 잘못 작성된 것이며 저는 프롬프트가 아닌 어설션(assertion)을 수정합니다.
Q: 131개의 테스트를 실행하는 데 드는 실제 시간 비용은 어느 정도인가요? 개발 루프(dev loop)를 느리게 만드나요?
A: 전체 실행은 2분 미만이며, 몇 초 만에 끝나는 Layer 1-2가 아니라 Layer 3와 4의 지연 시간(latency)이 대부분을 차지합니다. 캐싱(caching)을 사용하면 대부분의 변경 사항은 영향을 받는 하위 집합(subset)만 다시 실행되므로, 일반적인 루프는 1분보다 훨씬 짧습니다. 모든 저장 시점에 131개 전체를 실행하지는 않습니다. Layer 1-2는 커밋(commit) 시에 실행되고, 전체 스위트(full suite)는 머지(merge) 전에 실행됩니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기