AI 에이전트는 유닛 테스트(Unit-Test)를 할 수 없습니다. 회귀 게이트(Regression-Gate)를 설정해야 합니다.
요약
비결정론적인 AI 에이전트의 출력을 기존의 결정론적 유닛 테스트 방식으로 검증할 때 발생하는 문제점과 해결책을 제시합니다. 단순 문자열 일치 검사 대신 불변량(Invariants)을 확인하는 회귀 게이트(Regression-gate) 도입의 필요성을 강조합니다.
핵심 포인트
- 기존의 assert output == golden 방식은 AI의 비결정론적 특성 때문에 실패하기 쉽습니다.
- 텍스트 유닛 테스트 대신 반드시 포함/제외되어야 할 불변량을 검증하는 루브릭 게이트를 사용해야 합니다.
- 회귀 게이트는 완벽한 증명이 아닌, 오류를 잡아내는 지뢰(Tripwire) 역할을 수행합니다.
- 표준 라이브러리만을 활용하여 결정론적이고 재현 가능한 테스트 환경을 구축할 수 있습니다.
저는 32개의 공개된 스크래퍼(Scraper)를 운영하고 있습니다. 이들 사이에는 2,190번의 프로덕션 실행이 있습니다. 이 모든 것은 결정론적 코드(Deterministic code)이며, 저는 결정론적 코드를 테스트하는 방식 그대로 테스트합니다. 피스처(Fixture)를 입력하고, assert parsed == expected를 실행하면 끝입니다. 동일한 입력에는 영원히 동일한 출력이 나옵니다.
그런นั้น 끝에 LLM 단계를 결합합니다. 스크랩된 스레드에 대한 요약 단계나 페이지에 대한 추출 단계 같은 것 말이죠. 그리고 이를 위해 작성한 첫 번째 테스트는 첫 실행부터 실패(Red)합니다. 에이전트가 틀렸기 때문이 아닙니다. 당신이 고정해둔 문자열과 답변의 표현 방식이 다르기 때문입니다.
이것이 바로 함정의 전부입니다. 결정론적 코드에서 비롯된 반사적인 습관인 assert output == golden은 비결정론적(Non-deterministic) 출력에 잘못된 도구이며, 대부분의 팀은 테스트를 작성하고, 테스트가 불안정하게 작동(Flake)하는 것을 지켜보다가, 결국 테스트를 삭제함으로써 이를 깨닫게 됩니다. 그러고 나서 그들은 테스트가 전혀 없는 상태로 에이전트를 배포합니다.
텍스트를 유닛 테스트(Unit-test)할 수는 없습니다. 대신 그 안에 포함된 불변량(Invariants)을 회귀 게이트(Regression-gate)로 관리할 수 있습니다. 이 글의 핵심은 그 차이점에 있으며, 글 하단에는 그 차이점과 그것이 깨지는 지점을 보여주는 60줄짜리 스크립트가 있습니다.
요약 (TL;DR)
- 엄격한
assert agent_output == golden은 단지 단어가 바뀌거나 순서가 변경되었을 뿐인 정답에 대해서도 실패합니다. 아래 데모에서는 6번의 실행 중 2번의 정상적인 실행을 포함하여 단 한 번도 통과하지 못합니다. - 루브릭 게이트(Rubric gate)는 대신 **불변량(Invariants)**을 단언합니다. 즉, 반드시 포함되어야 하는 사실(must-include), 절대 나타나서는 안 되는 문자열(must-exclude, 그리고 비밀 패턴) 등을 확인합니다. 이는 6번 중 3번을 통과하며, 3번의 실제 회귀(Regression)를 모두 잡아냅니다.
- 이것은 지뢰(Tripwire)이지 증명(Proof)이 아닙니다. 데모에서는 의미론적으로는 틀렸지만 모든 규칙을 만족하는 6번째 실행 결과가 출력되며, 게이트는 이를 통과시킵니다. 의도적으로 말이죠.
- 표준 라이브러리(stdlib)만 사용하며, 결정론적이고, 피스처(Fixtures)는 합성되었으며 라벨이 붙어 있습니다. 두 번 실행하면 동일한 바이트를 얻습니다.
정답에 대해 유닛 테스트가 실패(Red)하는 이유
다음은 하나의 작업(Trustpilot 환불 분쟁 스레드 요약)에 대한 한 에이전트의 캡처된 출력 결과입니다. 두 번의 실행 모두 정답입니다:
실행 1: 스레드는 고객이 받지 못한 환불에 관한 것입니다. 고객은 14일 반품 기간이 종료되었다는 말을 들었으며, 배송 문제가 쟁점이었습니다.
실행 2: 배송이 핵심 불만 사항입니다. 고객은 14일 기간이 적용되어서는 안 되며, 여전히 환불을 받아야 한다고 말합니다.
사실 관계는 동일합니다. 환불 미지급, 14일 기간, 배송 문제라는 마찰 지점. 이를 채점하는 사람이라면 둘 다 통과시킬 것입니다. 하지만 문자열 일치 검사(string equality check)는 둘 다 실패합니다. 왜냐하면 두 결과 모두 당신이 테스트를 작성하던 날 우연히 캡처했던 정답 문자열(golden string)과 일치하지 않기 때문입니다.
이것은 설정을 통해 해결할 수 있는 온도(temperature) 0의 문제가 아닙니다. 프롬프트를 다시 작성하거나, 모델 버전을 높이거나, 컨텍스트 내의 상위 토큰 하나만 바꿔도 표면적인 텍스트는 변합니다. OpenAI의 자체 평가 가이드에서도 이를 명확히 밝히고 있습니다: "생성형 AI (Generative AI)는 가변적입니다. 모델은 때때로 동일한 입력에 대해 서로 다른 출력을 생성하며, 이는 전통적인 소프트웨어 테스트 방법이 AI 아키텍처에는 불충분하게 만듭니다." International AI Safety Report 2026 (arXiv 2602.21012, Yoshua Bengio 의장, 2026년 2월)은 더 넓은 관점에서 이 점을 지적합니다: 배포 전 테스트의 성능이 실제 세계의 동작을 신뢰성 있게 예측하지 못하는 '평가 격차 (evaluation gap)'가 존재한다는 것입니다. 규모는 다르지만 근본 원인은 같습니다. 당신이 검증 대상으로 삼고 있는 출력값은 바이트 단위(byte-for-byte)로 제어할 수 있는 대상이 아닙니다.
따라서 == 연산자는 사용할 수 없습니다. 그 뒤에 이어지는 본능적인 생각은 보통 "그렇다면 테스트를 할 수 없겠네"이며, 바로 이 지점에서 게이트(gate)가 삭제됩니다.
해결책: 문자열이 아닌 불변량(invariants)을 단언(assert)하라
당신은 문구(wording)를 제어할 수 없습니다. 하지만 정답이 반드시 포함해야 하는 내용과 절대로 포함해서는 안 되는 내용은 제어할 수 있습니다. 이것들이 바로 불변량(invariants)이며, 불변량은 주변 텍스트가 가변적일지라도 결정론적(deterministic)입니다.
환불 스레드의 경우, 다음 세 가지 사실이 모든 재표현(rephrasing) 과정에서 살아남아야 하며, 그렇지 않다면 회귀(regression)로 간주합니다:
- refund (환불)라는 단어
- 14 days (14일) 기간
- shipping (배송)
그리고 모델이 어떻게 표현하든 상관없이, 특정 문자열은 절대로 나타나서는 안 됩니다:
- "I cannot assist" (도움을 드릴 수 없습니다)와 같은 거절 문구 (모델이 답변 대신 회피함)
- sk- 또는 AKIA와 같은 비밀 형태 (컨텍스트 외부의 키가 답변으로 유출됨)
이제 판정은 다시 결정론적(deterministic)입니다. "텍스트가 일치하는가"가 아니라, "필요한 사실이 존재하는가, 그리고 금지된 문자열이 부재하는가"를 따집니다. 대상(subject)은 비결정론적(non-deterministic)이지만, 그 위의 게이트(gate)는 결정론적입니다.
이것은 유닛 테스트(unit test)가 아니라 회귀 게이트(regression gate)입니다. 이것은 에이전트가 옳다고 단언하는 것이 아닙니다. 에이전트가 당신이 이미 알고 있는 규칙을 어기지 않았음을 단언하는 것입니다. 프롬프트 버전 간의 회귀(regression)를 포착하기 위해서는, 이것이 바로 당신이 답을 얻고자 하는 질문입니다.
60줄의 게이트
오직 stdlib(표준 라이브러리)만 사용하며, re 외에는 아무것도 사용하지 않습니다. 네트워크도, 시계(clock)도, 무작위성(randomness)도 없습니다. 6번의 실행은 정적 문자열(static strings)이므로, 스크립트는 매번 동일한 바이트를 출력합니다. 이것들은 실제 에이전트의 덤프(dump)가 아니라, 정답 라벨(ground-truth labels)이 포함된 합성 픽스처(synthetic fixtures)입니다. 저는 데모가 당신의 머신에서 재현 가능하기를 원하며, 이는 라이브 샘플링(live sampling)이 없음을 의미합니다.
"""regression_gate.py — 비결정론적 에이전트에 대한 결정론적 게이트.
LLM 단계에서는 `assert agent_output == golden`을 수행할 수 없습니다: 텍스트가
...
python3 -I regression_gate.py로 실행하세요. 다음은 편집되지 않은 정확한 출력 결과입니다:
================================================================
regression gate: one task, six captured agent runs
================================================================
...
결과 읽기
단순한(naive) 게이트는 6개 중 0점을 기록합니다. 골든 스트링(golden string)이 캡처된 실행 중 단 하나와도 일치하지 않았으며, 그 실행 중 두 개는 정답입니다. 이것이 바로 삭제되는 취약한(brittle) 테스트입니다. 다만 이번 경우에는 아무것도 통과시키지 못함으로써 스스로를 삭제해 버렸습니다.
루브릭(rubric) 게이트는 6개 중 3점을 기록하며, 세 번의 실패는 테스트가 실제로 포착하기를 원하는 세 가지 사항입니다:
- run3는 14일(14-day) 기간을 누락했습니다. 프롬프트 버전 간에 필수 사실이 사라졌습니다. 이것은 실제 회귀(regression)이며, 게이트가 이를 명명했습니다:
missing required: '14 days'. - run4는 거부(refusal)입니다. 모델이 포기하고 요약 대신 일반적인 문구(boilerplate)를 반환했습니다. 게이트가 금지된 구문을 포착했습니다.
- run5는 키(key)를 유출했습니다. 컨텍스트에서 나온
sk-live-...토큰이 답변에 새어 나왔습니다. 게이트가 비밀스러운 형태(secret shape)를 포착했습니다.
그리고 통과한 두 개의 run, 즉 run1과 run2는 순진한 테스트가 걸려 넘어졌던 재작성되고 재배열된 정확한 답변입니다. 이것이 당신이 원했던 트레이드오프입니다: 단어 선택 오류로 실패하는 것을 멈추고, 깨진 불변성(broken invariants)으로 실패하기 시작하세요.
코드의 한 세부 사항이 그 자리를 차지합니다. 비밀 확인은 거부 구문 목록과는 별도의 목록이며, 단어 경계(word-bounded)가 아닌 **접두사(prefix)**로 일치됩니다. 저는 이것을 만들면서 힘든 방법으로 배웠습니다: 만약 제가 단어 경계 필수 제외 목록에 sk-를 넣었다면, run5는 _통과_했을 것입니다. 뒤따르는 단어 경계가 결코 적용되지 않았기 때문입니다. 왜냐하면 sk-가 깨끗한 오른쪽 끝 없이 바로 live-7Qd2mZ로 이어지기 때문입니다. 유출된 키는 깔끔한 단어가 아닙니다. 따라서 비밀스러운 형태들은 자체 접두사 일치를 얻게 되었고, 이 분리가 누출을 포착하는 것과 놓치는 것 사이의 차이를 만듭니다. 왼쪽 끝은 여전히 비알파벳-숫자 경계(non-alphanumeric boundary)를 요구하므로, apikeysk-live...처럼 선행 문자에 바로 융합된 키는 여전히 빠져나갈 것입니다; 프로덕션 환경에서는 접두사 목록에서도 왼쪽 룩비하인드(left lookbehind)를 제거해야 합니다. 작은 버그이지만, 루브릭 게이트가 표면화하기 위해 존재하는 정확한 종류의 것이며, 그 뒤에는 더 작은 것이 기다리고 있습니다.
놓치는 케이스와 그것을 출력합니다
run6은 통과했습니다. run6은 틀렸습니다.
이것은 환불이 이미 발행되었고 배송비가 완전히 처리되었으며 고객에게 불만이 없다고 말합니다. 스레드는 정반대를 말합니다: 환불은 결코 발행되지 않았습니다. 요약이 의미를 뒤집었습니다. 하지만 이것은
이것은 해당 방법론의 최저선(floor)이며, 의도적으로 성공 사례와 동일한 stdout(표준 출력)에 출력됩니다. 루브릭(Rubric, 평가 기준)은 의미(meaning)가 아니라 토큰의 **존재 여부(presence)**를 확인합니다. 필수 단어들을 언급하기만 한 오답도 그대로 통과해 버립니다. 만약 제가 포착된 3개의 회귀(regression) 사례만 보여드렸다면, 저는 이 게이트(gate)를 정답을 보장하는 증거로 판매하고 있었을 것입니다. 하지만 이것은 그렇지 않습니다.
따라서 이것이 무엇을 얻어다 주는지 정확히 인지해야 합니다. 불변 게이트(invariant gate)는 당신이 선언한 규칙에 대한 회귀(regression)를 포착합니다. 이것이 답변이 정확하다는 것을 증명하지는 않습니다. 루브릭을 만족하는 의미론적 오류(semantic errors)는 통과합니다. 그 문장이 이 도구의 정직한 전체 범위이며, 이것이 바로 위의 판정 결과가 이를 숨기지 않고 출력하는 이유입니다.
이것을 배포하기 전에 언급할 가치가 있는 세 가지 예외 상황(edges)이 더 있습니다:
- 루브릭은 수동으로 관리됩니다. 필수 포함 목록을 누가 작성하며, 제품이 변경됨에 따라 그 목록도 변하나요? 만약 작업에 네 번째 필수 사실이 추가되었는데 아무도 루브릭을 업데이트하지 않는다면, 게이트는 조용히 노후화(stale)됩니다. 이것은 해결된 문제가 아니라 여전히 남아있는 질문입니다.
- 부분 문자열 매칭(Substring matching)은 오탐(false-positive) 위험이 있습니다. 저는 "refund"가 "refundable" 내부에서 작동하지 않고, "cannot"이 "cannot be refunded" 내부에서 작동하지 않도록 포함 관계를 단어 경계(word-bounded)로 설정했습니다. 이는 명백한 사례들을 다룹니다. 하지만 모든 것을 다루지는 못하며, 부주의한 규칙은 정당한 답변을 잘못 표시할 수 있습니다.
- 이것은 에이전트 주변의 통합 테스트(integration tests)를 대체하는 것이 아닙니다. 파싱(parsing), 재시도(retries), I/O, 도구 호출(tool calls): 이것들은 결정론적(deterministic) 코드이며, 그에 걸맞은 엄격한
assert ==를 적용받아야 합니다. 루브릭 게이트는 마지막에 나오는 단 하나의 비결정론적(non-deterministic) 텍스트 출력만을 위한 것입니다.
이 게이트 위에는 게이트가 할 수 없는 의미론적 검사(semantic check)가 존재합니다: LLM-as-a-judge 방식의 검토나 샘플에 대한 인간의 점검(human spot-check)이 그것입니다. 루브릭은 누출된 키(leaked key)나 누락된 사실(dropped fact)이 발생했을 때 밀리초 단위로 빌드를 실패시키는, 저렴하고 빠르며 결정론적인 첫 번째 레이어입니다. 값비싼 judge는 살아남은 것들을 대상으로 실행됩니다. '이것 아니면 저것'이 아니라 레이어(layered) 구조로 가야 합니다.
월요일에 이 내용을 어떻게 적용할 것인가
만약 명백한 테스트가 불안정(flaked)하여 삭제되는 바람에 테스트가 없는 에이전트 단계(agent step)가 있다면, 아직 판사(judge)가 필요한 것은 아닙니다. 당신에게 필요한 것은 트립와이어(tripwire, 함정)입니다.
한 가지 태스크를 선택하세요. 답변이 항상 포함해야 하는 3~4가지 사실과, 절대 포함해서는 안 되는 몇 가지 문자열(거절 문구(refusal boilerplate), 비밀 접두사(secret prefixes), 경쟁사 이름 등 당신의 도메인이 금지하는 무엇이든)을 적으세요. 그것이 당신의 루브릭(rubric, 평가 기준)입니다. 실제 출력물 몇 개를 캡처하여 게이트(gate)를 실행하고, 문구는 바뀌었지만 정답인 것들은 통과하고 망가진 것들은 실패하는 것을 관찰하세요. 이는 60줄 내외의 코드이며 오프라인에서 실행됩니다.
이것이 에이전트가 옳다고 말해주지는 않을 것입니다. 대신 에이전트가 _이전에 하던 일을 멈춘 순간_을 알려줄 것입니다. 이것이 회귀 테스트(regression test)의 핵심 역할이며, 대부분의 에이전트 단계가 테스트가 전혀 없는 상태로 배포되는 것보다는 훨씬 나은 결과입니다.
이는 제가 다른 문제를 위해 작성했던 검증기(validator)와 같은 형태입니다. 즉, 당신이 제어할 수 없는 대상에 대한 결정론적 판결(deterministic verdict)입니다. 당시의 대상은 본문에 쓰레기 데이터가 포함된 채 200 OK를 반환하는 HTTP 응답이었고, 여기서는 에이전트의 최종 텍스트입니다. 본능은 같지만 레이어(layer)가 다를 뿐입니다.
다음 실행 배치에서 나오는 수치들을 확인하려면 팔로우해 주세요. 그리고 저에게 알려주세요. 당신의 에이전트를 위한 '반드시 제외해야 할 목록(must-exclude list)'에 넣을 단 하나의 불변량(invariant), 즉 답변에 절대, 절대로 나타나서는 안 되는 문자열은 무엇인가요? 모든 댓글을 읽고 있습니다.
_AI의 도움을 받아 작성되었습니다 (데모, 피스처(fixtures), 그리고 산문은 LLM의 초안을 바탕으로 작성되었습니다.)
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기