왜 일반 테스트가 에이전트에 통하지 않는가
단위 테스트의 전제는 결정론입니다. 같은 입력 → 항상 같은 출력. 그런데 LLM 에이전트는 temperature, 컨텍스트 노이즈, 모델 업데이트에 따라 다른 결과를 냅니다. 이번 편에서는 비결정 시스템을 체계적으로 검증하는 테스트 전략을 다룹니다.
에이전트 테스트의 4 레이어
Layer 4: End-to-End 시나리오 테스트
Layer 3: 툴 호출 정확도 테스트
Layer 2: 프롬프트 회귀 테스트
Layer 1: 출력 형식 / 스키마 검증하위 레이어부터 안정화해야 상위 레이어 테스트가 의미 있습니다.
Layer 1: 출력 스키마 검증
from pydantic import BaseModel, ValidationError
class AgentResponse(BaseModel):
action: str
parameters: dict
confidence: float # 0.0 ~ 1.0
def test_schema(raw_output: str):
try:
r = AgentResponse.model_validate_json(raw_output)
assert 0.0 <= r.confidence <= 1.0
return True
except ValidationError as e:
print(f"Schema error: {e}")
return FalseStructured Output(JSON mode)을 강제하면 이 레이어 실패율을 0에 가깝게 만들 수 있습니다.
Layer 2: 확률적 어설션
동일 입력으로 N번 실행 후 통계적으로 검증합니다.
async def probabilistic_assert(agent, prompt, expected, n=20, threshold=0.8):
results = await asyncio.gather(*[agent.run(prompt) for _ in range(n)])
rate = Counter(r.action for r in results)[expected] / n
assert rate >= threshold, f"통과율 {rate:.0%} < 목표 {threshold:.0%}"임계값 가이드:
- 크리티컬 경로: 0.95 이상
- 일반 기능: 0.80 이상
- 창의적 생성: 0.60 이상 (정성 평가 병행)
Layer 3: 툴 호출 정확도 테스트
class ToolCallRecorder:
def __init__(self): self.calls = []
def __call__(self, tool_name, **kwargs):
self.calls.append({"tool": tool_name, "params": kwargs})
return mock_response(tool_name, **kwargs)
def test_tool_selection():
recorder = ToolCallRecorder()
agent = build_agent(tools=recorder)
agent.run("고객 ID 12345의 최근 주문을 조회해줘")
assert recorder.calls[0]["tool"] == "get_orders"
assert recorder.calls[0]["params"]["customer_id"] == "12345"Layer 4: Replay 테스트 (LangSmith)
from langsmith import Client
client = Client()
def export_golden_tests(project: str, limit=100):
# 긍정 피드백을 받은 트레이스를 골든 데이터셋으로
runs = client.list_runs(
project_name=project,
filter="eq(feedback_score, 1)",
limit=limit
)
return [{"input": r.inputs, "expected": r.outputs} for r in runs]사용자 긍정 피드백을 golden dataset으로 축적하면 모델 업데이트 시 자동 회귀 테스트가 가능합니다.
CI 파이프라인 통합
# .github/workflows/agent-test.yml
on: [pull_request]
jobs:
test:
steps:
- run: pytest tests/test_schema.py # 항상, 빠름
- run: pytest tests/test_tool_calls.py # PR마다
- run: pytest tests/test_replay.py # 야간 스케줄3편에서는 테스트를 통과한 에이전트가 프로덕션에서 실패할 때 자동 복구하는 Circuit Breaker·Fallback 패턴을 다룹니다.
실패 감지 시그널 (최소 세트)
| 시그널 | 임계 예 | 조치 |
|---|---|---|
| 툴 오류율 | 5분 창 10%↑ | 해당 툴 차단·폴백 |
| 스텝 수 | max_steps 80%↑ | 루프 강제 종료 |
| 지연(p95) | SLA 초과 | 큐잉·축소 모델 |
| 환각/정책 위반 | 필터 히트 | 응답 차단·재생성 |
# 의사코드: 스텝 가드레일
if state.steps >= MAX_STEPS or state.tool_errors >= 3:
return escalate_to_human(state) # 티켓/대기열자동 복구 패턴
- 재시도: 멱등 툴만, 지수 백오프 + 지터
- 폴백 모델/툴: primary → secondary provider
- 보상(compensate): 부분 성공 시 롤백 핸들러
- HITL: 금액·삭제·외부 발송은 사람 승인 게이트
Replay·확률적 테스트가 들쭉날쭉 깨질 때
에이전트 테스트를 CI에 넣으면 코드 변경이 없는데도 통과와 실패가 번갈아 나오는 "flaky" 문제가 먼저 옵니다.
원인
- 모델 별칭(예: 최신 버전을 가리키는 이름)이 공급사 측에서 새 스냅샷으로 바뀜
- 도구 응답이 시간·외부 데이터에 의존해 매 실행마다 달라짐
- temperature가 0이 아니거나, 0이어도 공급사 특성상 출력이 완전히 고정되지 않음
재현: 같은 테스트를 N회 반복해 통과율 측정
for i in $(seq 1 20); do pytest -q tests/agent/test_refund_flow.py >/dev/null && echo pass || echo fail; done | sort | uniq -c통과율이 100%가 아니면 해당 테스트는 결정적 테스트가 아니라 확률적 테스트로 다뤄야 합니다.
해결
- 모델은 날짜가 붙은 스냅샷 이름으로 고정하고, 버전 변경은 별도 PR로 진행합니다.
- 도구 응답은 녹화·재생(record/replay) 방식으로 고정합니다. 첫 실행 때 외부 응답을 파일로 저장하고 이후에는 파일을 반환하게 하면, 모델 출력 외의 변동 요인이 사라집니다.
- 확률적 테스트는 "20회 중 18회 이상 통과"처럼 임계값으로 판정하고, 임계값은 테스트 코드에 명시합니다.
재발 방지
- 통과율이 임계값 아래로 떨어진 테스트는 격리(quarantine) 목록으로 옮기고 담당자와 기한을 붙입니다. 격리 목록이 커지면 모델 교체나 프롬프트 변경의 품질 신호로 봅니다.
참고 자료
검증 환경·편집자 주
- 2026-09 편집. 임계값은 트래픽·도메인에 맞게 조정하세요.
자주 묻는 질문 (FAQ)
Q. 모든 실패를 자동 복구할까요?
아니요. 비가역 작업은 자동 재시도보다 격리·알림이 안전합니다.
글마다 출처와 확인 범위가 다릅니다. 아래에 확인일·확인 범위·출처가 기록된 글만 그 범위에서 확인된 것입니다. 기록이 없는 글은 적용 전 사용 중인 버전의 공식 문서로 직접 확인하고, 오류는 이메일로 알려주세요. 편집 정책 · 이메일로 제보
이 글에는 사람이 확인한 날짜·범위 기록이 아직 없습니다.
댓글
첫 번째 댓글을 남겨보세요.