LLM은 유닛 테스트(Unit-Test)를 할 수 없습니다. 대신 제가 만든 것은 다음과 같습니다.
요약
LLM의 비결정론적 특성으로 인해 발생하는 테스트의 어려움을 해결하기 위해, 시스템의 대부분을 결정론적 코드로 구성하고 LLM의 역할을 최소화하는 아키텍처 설계 방식을 제안합니다.
핵심 포인트
- LLM의 비결정론적 출력은 기존의 유닛 테스트 방식(assertEqual)으로 검증하기 어려움
- 시스템의 핵심 로직(라우팅, 상태 관리 등)은 일반 코드로 작성하여 결정론적 중추를 구축해야 함
- LLM은 텍스트 초안 작성과 같은 비결정론적 작업에만 제한적으로 사용해야 함
- 비결정론적 부분에 대해서는 별도의 평가 하네스(eval harness)를 구축하여 관리해야 함
LLM 기능을 출시하는 모든 팀은 결국 동일한 벽에 부딪힙니다. 당신이 만든 것은 비결정론적(non-deterministic)인 반면, 당신의 전체 테스트 문화는 그것이 결정론적이라고 가정하기 때문입니다. 출력이 다음에 실행할 때 약간 달라질 생성된 산문(prose)의 한 단락이라면, assertEqual(output, expected)는 의미가 없습니다.
일반적인 대응 방식은 둘 다 좋지 않습니다. 하나는 어깨를 으쓱하며 아무런 검증 없이 출시하는 것입니다: "내가 테스트했을 때는 괜찮아 보였어." 다른 하나는 모델 자체를 테스트하는 것인데, 이는 프롬프트(prompt)나 가중치(weights)가 바뀔 때마다 변하는 움직이는 목표를 쫓는 것과 같습니다.
저는 최근에 정확히 단 한 단계에서만 LLM에 의존하는 내부 도구를 만들었습니다. 그리고 저는 "이것이 제대로 작동한다는 것을 어떻게 아는가?"라는 질문에 대해 당당하게 내놓을 수 있는 진짜 답변을 원했습니다. 그 답변은 두 가지 단계로 이루어졌습니다. 첫째, LLM의 역할을 가능한 한 최소한으로 줄여서 시스템의 대부분이 결정론적(deterministic)으로 유지되고 일반적인 유닛 테스트(unit tests)가 여전히 적용될 수 있도록 합니다. 둘째, 남겨진 줄일 수 없는 비결정론적(non-deterministic)인 부분에 대해서는 실제 평가 하네스(eval harness)를 구축하고, 그 각 계층이 무엇을 포착할 수 있고 없는지에 대해 솔직해지는 것입니다.
그 과정이 어떻게 진행되었는지 설명하겠습니다.
도구가 해결하는 문제
포트폴리오 온보딩(onboarding) 과정 중에, 상위 플랫폼(upstream platform)이 데이터 공백을 감지합니다: 건물의 누락된 공공요금 고지서, 유틸리티 피드에는 나타나지만 알려진 공간과 매칭되지 않는 계량기, 불완전한 장비 인벤토리, 혹은 "이것이 세입자 부담인가 아니면 소유자 부담인가?"와 같은 모호한 사항들입니다. 플랫폼은 이상 징후 보고서(anomaly report)를 생성합니다.
그러면 내부 팀의 누군가가 감지된 각 공백을 고객 측의 적절한 담당자(이 건물의 부동산 관리자, 저 계량기의 자산 관리자 등)에게 전달될 구체적이고 정확하게 작성된 요청으로 변환해야 하며, 30일의 온보딩 시간이 종료되기 전에 모든 요청이 해결될 때까지 추적해야 합니다. 이는 보고서를 읽고, 담당자가 누구인지 파악하고, 이메일을 초안하고, 누가 응답했는지 추적하는 과정을 수십 개의 건물에 대해 반복하는 작업입니다.
이 도구는 이상 보고서(anomaly report)를 가져와 각 공백(gap)에 대해 다음 작업을 수행합니다: 라우팅(건물, 계정, 소유자, 심각도), 추적 기록 생성, 연락 초안 작성, 그리고 아무것도 발송되기 전에 인간의 승인을 위해 해당 초안을 대기시킵니다. 전문가는 빈 페이지에서 시작하는 대신 내용을 검토하고 승인합니다.
이 과정 중 얼마나 많은 부분이 LLM의 문제가 아닌지 주목하십시오.
첫 번째 단계: LLM의 작업 범위를 축소하라
라우팅(Routing)은 조회(lookup) 작업입니다. 심각도(Severity)는 상류 신호(upstream signal)에서 직접 가져옵니다. 추적(Tracking)은 상태 머신(state machine)입니다. 승인(Approval)은 보호된 전이(guarded transition)입니다. 이 중 어느 것도 모호하지 않으며, 창의성을 발휘할 수도 있는 모델에 위임해서는 안 됩니다.
따라서 아키텍처는 깔끔하게 두 부분으로 나뉩니다:
- 결정론적 중추 (Deterministic spine): 라우팅, 추적, 상태 전이, 그리고 모든 발송 안전 점검은 일반적인 코드(plain code)로 작성됩니다.
- LLM 지원 단계 (LLM-assisted step): 정확히 한 가지, 즉 연락 메시지의 초안 작성 단계입니다. 이 부분만이 "이 구조화된 공백을 인간이 실제로 보낼 법한 정중하고 구체적인 문단으로 변환하라"는 작업에서 언어 모델(language model)의 이점을 진정으로 누릴 수 있는 유일한 부분입니다.
라우팅은 딕셔너리(dictionary)에 대한 순수 함수(pure functions)이며, 모델 I/O가 근처에도 가지 않습니다:
ROLE_BY_GAP_TYPE = { GapType.MISSING_UTILITY_BILL: OwnerRole.PROPERTY_MANAGER, GapType.UNMATCHED_METER: OwnerRole.ASSET_MANAGER, GapType.INCOMPLETE_EQUIPMENT_INVENTORY: OwnerRole.BUILDING_ENGINEER, GapType.TENANT_OWNER_PAID_AMBIGUITY: OwnerRole.ASSET_MANAGER, }
심각도는 이러한 규율을 보여주는 좋은 사례입니다. 자유 형식의 상세 필드(free-text detail field)는 종종 긴급성을 암시하며, 모델이 이를 추론하도록 유혹할 수도 있습니다. 하지만 저는 그렇게 하지 않습니다. 심각도는 명시적인 상류 신호로부터 정규화(normalized)되며, 만약 신호가 누락되었거나 유효하지 않다면 추측된 값을 갖는 대신 이상 탐지(anomaly)가 명확하게 실패하도록 처리합니다. 규칙은 간단합니다: 결정론적 신호(deterministic signal)를 신뢰하고, 모델이 신호를 만들어내게 두지 마십시오.
LLM은 프로바이더 인터페이스(provider interface, 단일 메서드 프로토콜) 뒤에 위치하므로, 비즈니스 로직이 SDK에 직접 닿는 일은 절대 없습니다:
class LLMProvider(Protocol): def complete(self, prompt: str) -> str: ...
해당 인터페이스는 경계(boundary)이자 교체 지점(swap point)입니다. 로컬 환경에서는 Claude를 직접 호출하고, 프로덕션(production) 환경에서는 단 하나의 팩토리 함수(factory function)를 변경함으로써 Bedrock 호출로 전환됩니다. 그리고 테스트 시에는 정해진 문자열을 반환하는 페이크(fake)가 되어, API 키나 네트워크 호출 없이도 전체 결정론적 중추(deterministic spine)를 검증할 수 있음을 의미합니다.
이것이 첫 번째 단계(move one)의 결실입니다. LLM의 영역은 작고 격리되어 있기 때문에, 시스템의 대부분은 단순한 소프트웨어입니다. 덕분에 28개의 일반적인 유닛 테스트(unit tests)를 수행할 수 있습니다. 예를 들어, 특정 이상 징후(anomaly)가 주어졌을 때 라우팅(routing)이 정확히 이 레코드를 생성하는지, 라우팅할 수 없는 이상 징후가 발생했을 때 에러를 발생시키는지, 잘못된 상태에서의 간극(gap)에 대한 승인이 거절되는지 등을 확인합니다. 코드에 의해 체크되는 합격/불합격(Pass/fail) 여부는 CI(지속적 통합)의 모든 푸시(push) 시점에 실행됩니다. 별도의 영리한 기교는 필요하지 않습니다.
두 번째 단계: 남은 한 단계는 여전히 비결정론적(non-deterministic)입니다
초안 작성(drafting) 단계를 결정론적으로 만들 수는 없습니다. 그렇게 한다면 모델을 사용하는 목적 자체를 무너뜨리는 것이기 때문입니다. 따라서 이곳에 평가 하네스(eval harness)가 위치하며, 의도적인 적대적(adversarial) 예시를 포함하여 수동으로 라벨링된 골든 데이터셋(golden dataset)을 기준으로 점수를 매깁니다. 이는 세 가지 차원을 체크하며, 흥미로운 점은 이 차원들이 동일하게 신뢰할 수 있는 것은 아니라는 점이며, 저 또한 그것이 그렇다고 주장하지 않습니다.
근거성 평가(Groundedness Eval): 결정론적(deterministic)
가장 중요한 속성은 초안이 오직 해당 초안이 작성된 단 하나의 간극(gap)만을 참조해야 한다는 것입니다. 다른 건물의 ID를 언급하거나 완전히 다른 간극을 언급하는 초안은 심각한 실패(hard failure)입니다. 이는 컨텍스트 누출(leaked context)이며, 결코 올바른 것처럼 인간에게 전달되어서는 안 됩니다.
이 부분은 결정론적으로 확인할 수 있는데, 누출된 식별자(identifiers)는 구조를 가지고 있기 때문입니다. 초안에 대해 정규 표현식(regex)을 실행하면, 해당 간극에 허용되지 않은 모든 G-#### 또는 BLD-### 토큰을 찾아낼 수 있습니다:
실제로 포착하지 못하는 것: ID가 첨부되지 않은 산문 수준의 범위 위반(prose-level scope violation)입니다. 예를 들어, 설명만으로 언급된 건물이나 출처에 있는 추측성 메모를 확정된 사실처럼 재진술하는 경우 등이 해당됩니다. 그러한 것은 의미론적 판단(semantic judgment)을 필요로 합니다. 이 가드레일은 구조화된 누출(structured leaks)을 포착하며, 모든 것을 포착한다고 주장하기보다는 그 범위에 대해 정확하게 설명합니다.
결정론적(deterministic)이기 때문에, 동일한 함수가 두 가지 역할을 수행합니다. 요청 경로 상의 실시간 가드레일이자 평가 하니스(eval harness) 내 점수화된 차원인 것입니다.
환각 평가 (Hallucination Eval): 휴리스틱
이 레이어는 초안에서 출처 데이터에 근거가 없는 숫자 및 날짜 유사 토큰을 플래그합니다. 예를 들어, 조작된 kWh 수치, 용량 숫자, 명시된 기간 외의 날짜 등이 해당됩니다.
저는 이 평가의 한계에 대해 매우 명확히 하고 싶습니다. 왜냐하면 이곳에서 스스로를 속이기 쉽기 때문입니다. 이것은 의미론적 탐지(semantic detection)가 아니라 문자열 일치(string matching)입니다. 조작된 수치를 포착할 수는 있지만, 숫자가 첨부되지 않은 조작된 주장(fabricated assertion)은 포착할 수 없습니다. 전형적인 사례는 '이 계량기가 임차인 부담인지 소유주 부담인지?'라는 질문을 제기하는 간극과, 그에 대해 조용히 답하는 초안입니다. 플래그를 달 숫자가 없으므로, 환각은 주장이지 숫자가 아닙니다. 데이터셋의 여러 함정들이 정확히 이러한 형태이며, 의도적으로 이 평가의 범위를 벗어나 있습니다. 여기서 통과했다는 것은 부분적인 신호(partial signal)일 뿐 증거가 아닙니다.
**어조 평가 (Tone Eval): LM judge
마지막 차원은 진정으로 주관적입니다(이것이 기관 자산 운용사의 목소리인가? 구체적이며 사과하는 태도가 없는가? 하나의 명확한 요청을 담고 있는가?). 따라서 이는 모델에 의해 점수가 매겨집니다. 각 예시에는 고유의 톤 루브릭 (tone rubric)이 포함되어 있으며, 그중 일부는 단순히 스타일을 설명하는 것이 아니라 피해야 할 함정 (traps)을 설명하기도 합니다. 판사 (judge)는 구체성 (specificity), 전문성 (professionalism), 단일 명확 요청 (single-clear-ask), 그리고 루브릭 준수 (rubric adherence)에 대해 1~5점 사이의 점수를 반환하며, 통과 기준은 4점입니다.
LM judge는 도구 상자 내에서 가장 비결정론적 (least deterministic)인 도구이며, 바로 그렇기 때문에 가장 주관적인 차원에 국한되며 전송 안전 점검 (send-safety checks) 근처에는 가지 않습니다.
판단은 데이터셋에 존재합니다
하네스 (harness)는 무엇을 기준으로 점수를 매기느냐에 따라 성능이 결정됩니다. 골든 세트 (golden set)는 작지만 (직접 라벨링한 11개의 예시), 각 예시는 must_mention, forbidden_references, must_not_invent, 그리고 tone_notes 라벨을 포함하고 있으며, 그중 여러 개는 함정으로 구축되었습니다.
임차인/소유자 지불 모호성 (tenant/owner-paid ambiguity) 사례는 제가 가장 좋아하는 사례입니다. 올바른 초안은 질문을 표면화하고 수신자에게 명확히 해달라고 요청합니다. 잘못된 초안은 원문이 지원하지 않은 답변을 임의로 선택하여 문제를 해결해 버립니다. 이것이 전체 시스템이 반드시 맞혀야 하는 가장 가치 있는 단 한 가지이며, 휴리스틱 환각 평가 (heuristic hallucination eval)가 포착할 수 없는 바로 그 실패 지점이라는 점에 주목하십시오. 의미론적 판단 (semantic judgment)이 이루어져야 하는 곳이 바로 데이터셋과 톤 루브릭이기 때문에, 판단은 그곳에 존재합니다. 하네스는 마법이 아닙니다. 그것은 당신이 이미 이해하고 있는 함정들을 인코딩하는 장소입니다.
보여주지 말고 거절하십시오
제가 만족하는 설계 선택 중 하나는 다음과 같습니다. 라이브 경로 (live path)에서 초안이 근거성 가드레일 (groundedness guardrail)을 통과하지 못할 경우, 초안 텍스트는 절대 저장되지 않으며 반환되지도 않습니다. 라우팅이 성공했고 초안 시도가 실패했다는 것을 사람이 확인할 수 있도록 갭 레코드 (gap record)는 여전히 존재하지만, 근거가 없는 텍스트 자체는 표시되지 않습니다. 이는 그럴듯해 보이는 잘못된 초안이 초안이 없는 것보다 더 위험하다는 이론에 근거합니다. 실패는 제안이 아니라 실패로서 보여야 합니다.
평가(Evals)가 CI의 게이트 역할을 하지 않는 이유
유닛 테스트(Unit tests)는 모든 푸시(push) 시점에 실행됩니다. 하지만 평가(evals)는 그렇지 않습니다. 여기에는 두 가지 이유가 있습니다. 첫째, 평가 과정에서 라이브 API 호출이 발생하여 비용(cost)이 들고 불안정성(flakiness)이 생깁니다. 둘째, 어조(tone) 차원은 비결정론적(non-deterministic)인 판사 역할을 하므로, 회귀(regression)와 전혀 상관없는 이유로 파이프라인을 실패(red) 상태로 만들 수 있습니다. 따라서 결정론적(deterministic)인 중추는 CI 게이트를 통과하도록 구성하고, 평가 실행은 로컬 단계로 둡니다. 대신 그 결과(전체 모델 I/O, 지연 시간(latency), 토큰 사용량(token usage), 점수)를 실행별 관찰 가능성(observability) 디렉토리에 기록합니다. 이렇게 하면 주관적인 판사가 빌드 게이트(build gate)인 척하지 않으면서도 이력을 확인할 수 있습니다.
다르게 시도해 볼 점
이 하네스(harness)는 시작 단계일 뿐이며, 과장하기보다는 솔직하게 말씀드리고 싶습니다. 병렬 처리(parallelism)나 재시도(retries), 평가 세트 버전 관리(eval-set versioning) 없이 11개의 예시를 순차적으로 실행하는 것은 프롬프트를 개발하는 동안 회귀를 포착하기에는 충분합니다. 하지만 이것은 지속적으로 유지 관리되는 평가 세트(eval set)는 아니며, 마치 그런 것처럼 배포되어서는 안 됩니다. 환각 휴리스틱(hallucination heuristic)은 단순히 뒷받침되지 않는 숫자를 넘어, 뒷받침되지 않는 주장(unsupported claims)에 대한 의미론적 검사(semantic checking)로 확장되어야 합니다. 또한 어조 임계값(tone threshold)은 실제 초안이 실제 사람에 의해 검토되고, 제 직관이 아닌 그들의 판단에 따라 숫자가 조정될 때까지는 추측에 불과합니다.
추출된 패턴
오류가 발생했을 때 비용이 발생하는 시스템에 LLM을 도입하려 한다면, 제가 효과를 보았던 형태는 다음과 같습니다:
- 모델의 역할을 진정으로 모호한 단계에만 국한될 때까지 축소하고, 나머지 모든 것(라우팅(routing), 상태(state), 안전 검사(safety checks))은 유닛 테스트가 여전히 작동하는 코드 내에서 결정론적(deterministic)으로 유지하십시오.
- 모델을 인터페이스(interface) 뒤에 배치하여 교체 가능하게 만들고, 더 중요한 것은 테스트 시 가짜(fakeable)로 만들 수 있도록 하십시오.
- 신뢰도가 점차 낮아지는 계층 구조를 통해 하나의 비결정론적(non-deterministic) 단계를 평가하십시오. 결정론적 검사(deterministic checks)를 첫 번째로, 휴리스틱(heuristics)을 두 번째로, 그리고 진정으로 주관적인 부분에 대해서만 언어 모델 판사(LM judge)를 사용하십시오. 그리고 각 계층이 포착할 수 없는 것이 무엇인지 명시하십시오.
- 데이터셋에 함정(traps)을 인코딩하십시오. 왜냐하면 그곳에 당신의 실제 도메인 판단(domain judgment)이 담겨 있기 때문입니다.
- 잘못된 출력을 표시하기보다는 거부(reject)하십시오. 그리고 주관적인 판사를 빌드 게이트(build gate)에서 제외하십시오.
모델을 유닛 테스트(Unit-test)할 수는 없습니다. 하지만 모델이 전혀 필요하지 않은 부분이 대부분인 시스템을 구축하고, 모델이 필요한 부분 주변에 정직한 한계(honest limits)를 가진 하네스(harness)를 배치할 수는 있습니다.
코드는 GitHub에 있습니다: github.com/amirmarcel/partner-strategy-copilot. Django REST Framework, 프로바이더 인터페이스(provider interface) 뒤의 Claude, evaluations/ 폴더의 평가 하네스(eval harness), 그리고 함정(traps)이 포함된 golden_dataset/ 폴더의 골든 데이터셋(golden dataset)으로 구성되어 있습니다.
저는 AI 지원 시스템의 아키텍처, 즉 결정론적 경계(deterministic boundaries)가 어디에 위치해야 하는지, 그리고 그 경계를 넘나드는 부분들을 어떻게 검증할지에 관심이 있는 소프트웨어 엔지니어입니다. 피드백과 반론을 환영합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기