CI에서 비결정론적 LLM 파이프라인 테스트하기: 계약 기반 접근 방식
요약
LLM의 비결정론적 특성으로 인해 발생하는 CI 파이프라인 테스트 문제를 해결하기 위한 세 가지 계층적 접근 방식을 제안합니다. 계약 테스트, 재생 테스트, 라이브 스모크 테스트를 통해 모델의 출력 형태와 비즈니스 로직을 안정적으로 검증할 수 있습니다.
핵심 포인트
- 계약 테스트: 정적 JSON을 사용하여 출력 스키마와 데이터 타입을 검증
- 재생 테스트: 실제 모델 응답을 기록(Cassette)하여 네트워크 없이 결정론적 테스트 수행
- 라이브 스모크 테스트: 실제 API를 호출하여 모델의 성능과 품질을 주기적으로 확인
- 계층적 구조를 통해 테스트 비용을 최적화하고 회귀 버그를 방지
대부분의 CI (지속적 통합) 파이프라인은 동일한 입력으로 두 번 호출된 함수가 동일한 출력을 반환한다고 가정합니다. 하지만 LLM (대규모 언어 모델) 호출이 테스트 스위트에 포함되는 순간 그 가정은 깨집니다. GPT-4나 Claude에게 같은 질문을 두 번 던지면, 서로 다르지만 둘 다 정답인 두 가지 답변을 얻을 수 있습니다. LLM 기반 기능을 출시하는 팀들은 종ans종 이 문제에 대응하여 LLM을 건드리는 코드 경로에 대해 테스트를 거의 작성하지 않거나, 정확한 문자열 출력에 대해 단언(assert)한 뒤 테스트가 처음 실패(flake)하면 해당 테스트를 비활성화하는 방식으로 대응합니다. 기능이 프로덕션에 배포된 후 프롬프트 변경이나 모델 업그레이드로 인해 동작이 조용히 망가질 수 있다는 점을 고려하면, 두 방식 모두 지속 가능하지 않습니다.
해결책은 영리한 단언(assertion) 라이브러리를 사용하는 것이 아닙니다. 실제로 테스트하려는 것을 서로 다른 결정론적 보장(determinism guarantees)을 가진 세 가지 계층으로 나누고, 각 계층을 서로 다른 CI 작업에 매핑하는 것입니다.
계층 1: 계약 테스트 (Contract tests) (결정론적, 모든 PR에서 실행)
계약 테스트는 실제 모델을 호출하지 않습니다. 이 테스트는 콘텐츠가 아니라 파이프라인이 생성하는 결과물의 '형태(shape)'를 단언합니다. 만약 오케스트레이터(orchestrator)가 LLM으로부터 {title, tags, price_usd, full_content}를 반환할 것으로 예상한다면, 계약 테스트는 준비된 응답(canned response)을 파싱/검증 레이어에 통과시켜 에러가 발생하지 않는지, 필수 필드가 존재하는지, 그리고 타입이 일치하는지를 확인합니다.
# test_contract.py
import json
import jsonschema
...
이러한 픽스처(fixtures)는 저장소에 체크인된 정적 JSON 파일입니다. 이 테스트는 밀리초 단위로 실행되며, API 키가 필요하지 않고, 실제로 장애를 일으키는 버그 유형을 잡아냅니다. 즉, 모델이 누락한 필드 때문에 파서(parser)가 막히거나, 문자열을 예상한 곳에 null 값이 들어오거나, 프롬프트 수정 후 스키마 드리프트(schema drift)가 발생하는 경우입니다. 이 계층은 모든 커밋에서 실행되어야 하며 머지(merge)를 차단해야 합니다.
계층 2: 재생 테스트 (Replay tests) (결정론적, 모든 PR에서 실행)
"가짜 JSON 픽스처"와 "실제 API 호출" 사이에는 카세트 기반 재생(cassette-based replay) 방식이 있습니다. 실제 모델의 응답을 한 번 기록하여 디스크에 저장한 다음, 이후의 모든 CI 실행 시 네트워크를 호출하는 대신 이를 재생하는 방식입니다. 이는 HTTP 테스트를 위한 VCR과 동일한 개념을 LLM 호출에 적용한 것입니다.
# conftest.py
import json, os, hashlib
...
Cassette(카세트)는 다른 fixture(픽스처)와 마찬가지로 저장소(repo)에 커밋됩니다. 의도적으로 프롬프트(prompt)를 변경할 때는 RECORD_CASSETTES=1을 사용하여 로컬에서 다시 녹화하고, PR(Pull Request)의 일부로서 카세트 파일의 diff(차이점)를 검토합니다(이 과정은 종종 리뷰어가 배포 전 회귀(regression)를 잡아내는 지점이 됩니다). 그 후 새로운 카세트를 커밋합니다. CI(지속적 통합)는 이러한 테스트를 위해 네트워크를 전혀 사용하지 않으므로 빠르고 비용이 들지 않지만, 수동으로 작성된 fixture가 아닌 실제 모델 출력에 대해 실제 파싱(parsing) 및 비즈니스 로직(business logic)을 여전히 실행합니다.
Tier 3: 라이브 스모크 테스트 (비결정론적, 매일 밤 실행, PR 시에는 실행 안 함)
이 단계는 실제 API 키와 실제 비용을 사용하여 실제 모델을 호출합니다. 이 단계의 역할은 단 하나의 질문에 답하는 것입니다: 우리의 replay cassette(재생 카세트)로는 드러나지 않는 방식으로, 마지막 확인 이후 모델의 _행동(behavior)_이 변했는가? 출력값이 가변적이기 때문에, 일치 여부(equality)를 단언(assert)할 수는 없습니다. 대신 속성(properties)에 대해 단언해야 합니다: 응답이 유효한 JSON인가, 스키마(schema)를 충족하는가, 참조 답변에 대한 의미적 유사도(semantic similarity) 점수가 임계값(threshold) 이상인가(문자열 매칭이 아닌 임베딩(embeddings) 사용), 토큰 사용량이 비용 예산 내에 머무는가 등입니다.
def test_live_semantic_similarity(embed_fn, cosine_sim):
reference = load_reference_embedding("golden_answer.json")
live_output = call_real_llm(PROMPT)
...
이 지점에서 CircleCI의 워크플로(workflow) 모델이 제값을 발휘합니다. .circleci/config.yml에 두 개의 워크플로를 정의하십시오. 하나는 모든 PR에 대해 제한(gated)되어 Tier 1과 Tier 2만 실행하는 워크플로이고, 다른 하나는 Tier 3를 매일 밤(또는 승인 작업(approval job)을 통해 요청 시) 실행하는 예약된(scheduled) 워크플로입니다. 이렇게 하면 불안정한(flaky) 라이브 호출이 머지(merge)를 방해하는 일이 절대 발생하지 않습니다.
version: 2.1
jobs:
...
여기에는 두 가지 중요한 세부 사항이 있습니다. 첫째, 라이브 작업(live job)에 --reruns 1을 설정하는 것은 단 한 번의 API 타임아웃(timeout)이 누군가를 비상 호출할 정도의 문제는 아니라는 점을 인정하는 것입니다. 지속적인 2회 실행 실패는 실제 유의미한 신호입니다. 둘째, check_budget.py 단계는 pytest 호출 _전(before)_에 실행되어 고정된 테스트 프롬프트(prompt)의 토큰 수(token count)를 기반으로 비용을 추정합니다. 만약 프롬프트 변경으로 인해 1달러 상한선을 초과할 것으로 예상되면 작업을 즉시 실패 처리합니다. 이는 누군가 프롬프트의 컨텍스트 윈도우(context window)를 확장하여 야간 청구 비용을 10배로 늘리는 상황이 발생했을 때, 청구서가 도착한 후가 아니라 발생하기 전에 이를 잡아냅니다.
단순히 "재시도 횟수를 늘리는 것"이 아닌, 분리해야 하는 이유
이러한 분리 과정을 생략하는 팀들은 대개 두 가지 실패 모드 중 하나를 겪게 됩니다. 하나는 속도 제한(rate-limited)이 걸린 API로의 불안정한(flaky) 네트워크 호출을 기다리느라 PR(Pull Request)이 몇 분 동안 차단되는 경우이고, 다른 하나는 테스트의 단언(assertion)이 너무 느슨하여 파이프라인이 쓰레기 값을 반환하더라도 통과해 버리는 경우입니다. 계약의 정확성(tier 1), 검증된 실제 출력값에 대한 회귀 탐지(tier 2), 그리고 라이브 모델의 드리프트(drift) 탐지(tier 3)를 분리하면, 각 계층(tier)이 자신의 결정론적 보장(determinism guarantee) 수준에 실제로 적합한 단언(assertion) 스타일을 사용할 수 있습니다. 또한 CircleCI가 각 계층의 비용 및 속도 프로필에 맞는 주기로 스케줄링할 수 있게 해줍니다. PR을 차단하는 경로는 빠르고 비용이 들지 않는 상태를 유지하며, 비용이 많이 들고 불안정하며 실제 돈이 드는 경로는 사람이 릴리스(release)를 방해하지 않고 분류(triage)할 수 있도록 하루에 한 번 실행됩니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기