LLM 에이전트 출력 검증: 할루시네이션이 JSON 스키마를 통과하는 이유와 3레이어 방어 설계
LLM 기반 에이전트를 프로덕션에 올리고 나면 생각보다 빨리 이상한 장애를 마주치게 됩니다. 로그를 뒤져보면 JSON 파싱은 성공했고, 스키마 검증도 통과했는데, 다운스트림 서비스에서 엉뚱한 값이 들어왔다며 에러가 납니다. 저도 처음엔 "스키마를 맞췄는데 왜?" 하고 한참을 헤맸는데, 알고 보면 구조적으로 올바른 JSON 안에 할루시네이션된 값이 멀쩡히 앉아있었던 겁니다.
이런 "조용한 오류(silent failure)"는 에이전트 파이프라인에서 가장 잡기 어려운 유형입니다. 겉으로는 성공처럼 보여서 모니터링에도 안 걸리다가, 실제 사용자 트래픽이 붙고 나서야 고객 클레임으로 드러납니다. 정규식 기반 필터 하나로는 LLM 출력의 오염을 60~70% 수준에서 막는 데 그치지만, LLM 기반 분류기를 출력 검증과 조합한 멀티레이어 접근을 쓰면 99.1%까지 차단율이 올라간다는 연구 결과(Kalvium Labs, 2025)가 있을 만큼, 검증 레이어를 쌓는 것의 실용적 효과는 큽니다.
구조적으로 올바른 JSON이 곧 올바른 출력을 의미하지 않습니다. 에이전트 출력 검증의 진짜 과제는 이 간격을 — 구조·의미·규칙 세 레이어로 — 체계적으로 좁히는 아키텍처를 설계하는 것입니다.
핵심 개념
검증은 단일 시점의 이벤트가 아닙니다
출력 검증을 "응답 받고 나서 파싱하는 것" 정도로 생각하면 레이어 1에서 멈추게 됩니다. 실제로는 파이프라인의 세 지점에서 검증이 이루어져야 합니다.
생성 전(Pre-generation): 프롬프트에 JSON 스키마를 명시적으로 주입하고, 가능하면 structured output 기능을 활성화해 모델이 포맷 오류를 낼 확률 자체를 낮춥니다.
import json
from pydantic import BaseModel
from anthropic import Anthropic
class AnalysisResult(BaseModel):
summary: str
confidence: float
sources: list[str]
client = Anthropic()
# 시스템 프롬프트에 JSON 스키마 직접 주입
schema_json = json.dumps(AnalysisResult.model_json_schema(), indent=2)
system_prompt = f"""항상 다음 JSON 스키마를 정확히 따르는 응답만 반환하세요:
{schema_json}
JSON 객체 외의 텍스트는 절대 포함하지 마세요."""
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system=system_prompt,
messages=[{"role": "user", "content": "최신 AI 트렌드를 분석해줘"}]
)생성 직후(Post-generation): JSON 파싱, 스키마 유효성 검사, 검증 실패 시 자동 재시도. 여기가 레이어 1의 영역입니다.
소비 전(Pre-consumption): 다운스트림으로 넘기기 전에 "내용이 사실인가"를 묻는 의미 검증. LLM-as-Judge, 다중 검증자 표결 같은 패턴이 여기 들어옵니다.
왜 세 지점이 필요한가? 생성 전 제약만으로는 포맷 오류 일부만 잡을 수 있고, 생성 직후 검사는 구조는 보지만 의미는 보지 못합니다. 소비 전 검증까지 있어야 "내용이 맞는가"라는 질문에 답할 수 있습니다.
3레이어 방어 구조
┌─────────────────────────────────────────────┐
│ 레이어 3: 규칙 검증 │ ← 도메인 정책, 외부 사실 검증 API
├─────────────────────────────────────────────┤
│ 레이어 2: 의미 검증 │ ← LLM-as-Judge, 다중 검증자 표결
├─────────────────────────────────────────────┤
│ 레이어 1: 구조 검증 │ ← Pydantic, JSON Schema, 재시도 루프
└─────────────────────────────────────────────┘작년까지 팀 대부분이 레이어 1에서 멈추는 경우가 많았습니다. ZenML이 1,200개 프로덕션 배포를 분석한 결과에서도 "스키마 검증까지만 적용"한 케이스가 여전히 다수를 차지했습니다. 그러다가 "스키마는 통과했는데 내용이 틀렸다"는 장애가 반복되면서 레이어 2, 3에 대한 관심이 빠르게 높아졌습니다.
요즘 눈에 띄는 흐름도 몇 가지 있습니다. Andrew Ng이 제시한 4대 에이전트 패턴 중 하나인 Reflection Pattern이 출력 품질 향상의 핵심 기제로 다시 주목받고 있고(GPT-4 HumanEval 기준 단일 호출 80% → Reflection 적용 후 91%), 2025년 2월 arXiv에 게재된 논문(2502.20379)에서는 복수의 독립 verifier를 병렬로 구동해 표결로 최종 판정하는 다중 검증자 표결(MAV) 패턴이 테스트 타임 스케일링의 새 축으로 제시됐습니다. 단일 Judge의 편향을 여러 모델이 서로 교차 검증하며 희석시키는 방식입니다.
실전 적용
이론을 코드로 옮겨볼 차례입니다. 레이어 1부터 시작해서 위로 쌓아올리는 방식으로 살펴보겠습니다.
예시 1: Schema-First 강제 + 자동 재시도 (레이어 1)
가장 먼저 도입할 수 있는 패턴입니다. Pydantic AI나 Instructor를 쓰면 스키마 정의와 재시도 루프를 거의 공짜로 얻을 수 있습니다. Pydantic AI는 에이전트 런타임 자체를 제공해 복잡한 에이전트 로직과 함께 다룰 때 유리하고, Instructor는 기존 OpenAI/Anthropic 클라이언트를 패치하는 경량 방식이라 기존 코드에 최소 변경으로 붙일 수 있습니다. 솔직히 이것만 해도 구조 오류의 대부분은 처리됩니다.
# pydantic-ai >= 0.0.30 기준
from pydantic import BaseModel, field_validator
from pydantic_ai import Agent
class ResearchSummary(BaseModel):
title: str
key_points: list[str]
confidence: float
@field_validator("confidence")
@classmethod
def check_range(cls, v: float) -> float:
if not 0.0 <= v <= 1.0:
raise ValueError("confidence must be between 0 and 1")
return v
# 모듈 레벨에서 초기화 — 함수 안에서 매 호출마다 생성하면 불필요한 초기화 비용 반복 발생
research_agent = Agent(
"anthropic:claude-sonnet-4-6",
result_type=ResearchSummary, # 스키마 자동 주입 + 유효성 검사
retries=3, # 검증 실패 시 실패 이유를 담아 자동 재질의
)
async def summarize(topic: str) -> ResearchSummary:
result = await research_agent.run(topic)
return result.outputresult_type에 Pydantic 모델을 지정하면 프롬프트에 스키마가 자동으로 주입되고, 응답이 스키마에 맞지 않으면 실패 이유를 담아 재질의합니다. @field_validator로 confidence 범위를 잡는 것은 레이어 1.5 정도 — 구조 검증과 의미 검증의 중간 어딘가입니다.
예시 2: Reflection (자기 비평 루프, 레이어 2)
생성과 검토를 분리하는 패턴입니다. 같은 프롬프트 안에서 "잘 썼니?"라고 묻는 것과, 별도의 비평 에이전트가 체크리스트로 검토하는 것은 품질 차이가 꽤 납니다.
from pydantic_ai import Agent
# 모델을 분리하는 것이 핵심 — 같은 모델은 같은 편향을 공유합니다
generator = Agent("anthropic:claude-sonnet-4-6")
critic = Agent("anthropic:claude-opus-4-8") # Judge는 더 강력한 모델 권장
async def reflection_loop(task: str, max_rounds: int = 3) -> str:
draft = (await generator.run(task)).output
for _ in range(max_rounds):
critique = await critic.run(
f"다음 초안을 아래 항목별로 검토해줘:\n"
f"① 사실 오류 ② 논리 비약 ③ 누락된 근거\n\n"
f"초안:\n{draft}\n\n"
f"문제가 없으면 'APPROVED'를, 있으면 구체적 수정 지시를 반환해줘."
)
if "APPROVED" in critique.output:
break
draft = (await generator.run(
f"원래 과제: {task}\n\n"
f"이전 초안의 문제점:\n{critique.output}\n\n"
f"위 문제를 반영해서 개선된 버전을 작성해줘."
)).output
return draft비평 단계의 지시는 반드시 구체적이어야 합니다. 처음에 "잘못된 부분 찾아줘"로 뭉뚱그렸다가, 비평 에이전트가 내용 오류보다 문체나 어조를 피드백하는 상황을 여러 번 겪었습니다. "① 사실 오류 ② 논리 비약 ③ 누락 근거"처럼 체크리스트 형식으로 주면 훨씬 날카로운 피드백이 나옵니다. generator와 critic에 서로 다른 모델을 쓰는 것도 중요한데, 같은 모델은 같은 편향을 공유하기 때문에 주 에이전트가 틀렸을 때 critic도 같은 이유로 놓치는 경우가 생깁니다.
예시 3: LLM-as-Judge + 임계값 게이트 (레이어 2)
레이어 2의 또 다른 핵심 패턴입니다. 강력한 Judge 모델로 출력의 품질을 점수화하고, 기준 미달 시 파이프라인을 차단합니다.
from pydantic import BaseModel
from pydantic_ai import Agent
class JudgeVerdict(BaseModel):
score: int # 0-10
reason: str
passed: bool
class OutputValidationError(Exception):
pass
# 싱글턴으로 관리 — 함수 안에서 매번 생성하면 초기화 비용이 반복 발생
judge_agent = Agent("anthropic:claude-opus-4-8", result_type=JudgeVerdict)
main_agent = Agent("anthropic:claude-sonnet-4-6")
async def judge_output(output: str, criteria: str) -> JudgeVerdict:
verdict = await judge_agent.run(
f"다음 출력을 '{criteria}' 기준으로 평가해줘.\n\n출력:\n{output}"
)
return verdict.output
async def validated_pipeline(task: str) -> str:
result = await main_agent.run(task)
verdict = await judge_output(
result.output,
criteria="사실 정확성, 논리적 완결성, 주제 적합성"
)
if not verdict.passed or verdict.score < 7:
raise OutputValidationError(
f"품질 기준 미달 (score={verdict.score}): {verdict.reason}"
)
return result.outputJudge로 Opus를 쓰는 이유는 판정 정확도 때문입니다. 주 에이전트와 Judge가 같은 모델이면 같은 편향을 공유하는 "공모(colluding) 오류"가 생길 수 있습니다. 처음 도입할 때는 if not verdict.passed 게이트를 걸기 전에 score만 로그로 남겨서 현재 출력 품질의 베이스라인을 먼저 확인해보는 것을 권장합니다 — 어떤 유형에서 점수가 낮은지 패턴을 보고 나서 임계값을 정하면 거짓 양성을 많이 줄일 수 있습니다.
LLM-as-Judge: LLM이 다른 LLM의 출력을 평가하는 패턴. 빠른 자동화 품질 평가가 가능하지만, 두 모델이 같은 편향을 공유하는 "공모 오류" 위험이 있습니다. 가능하면 다른 공급자나 다른 모델 조합을 권장합니다.
예시 4: 다중 검증자 표결, MAV (레이어 2)
단일 Judge의 한계를 여러 독립 verifier로 희석시키는 패턴입니다. MAV의 핵심은 단순히 수를 늘리는 게 아니라 편향이 다른 모델들을 쓰는 것입니다. 같은 모델을 3개 써봤자 같은 실수를 3번 반복할 뿐입니다.
import asyncio
from pydantic import BaseModel
from pydantic_ai import Agent
class VerifierResult(BaseModel):
refuted: bool
reason: str
# 편향 다양성을 위해 다른 모델/공급자 조합 — 이것이 MAV의 실질적 가치
VERIFIER_MODELS = [
"anthropic:claude-sonnet-4-6",
"anthropic:claude-haiku-4-5", # 같은 공급자라도 모델 특성이 다름
"openai:gpt-4o-mini", # 공급자를 달리하면 편향 다양성 극대화
]
async def multi_verifier_vote(claim: str) -> bool:
async def verify(model: str) -> bool:
verifier = Agent(model, result_type=VerifierResult)
result = await verifier.run(
f"다음 주장에서 반박할 수 있는 오류나 사실 불일치가 있으면 "
f"refuted=True로 답해줘. 확실하지 않으면 refuted=True가 안전한 선택입니다.\n\n"
f"주장: {claim}"
)
return result.output.refuted
votes = await asyncio.gather(*[verify(model) for model in VERIFIER_MODELS])
refuted_count = sum(votes)
# 과반 미만이 반박 → 유효한 출력으로 판정
return refuted_count < (len(VERIFIER_MODELS) / 2)verifier 수는 홀수(3 또는 5)로 설정해 동률을 방지하는 것이 좋습니다. 고위험 도메인(의료·금융·법률)이라면 "1명이라도 반박하면 실패"로 기준을 보수적으로 잡을 수도 있습니다.
예시 5: 도메인 규칙 검증 (레이어 3)
레이어 3은 종종 다이어그램에만 등장하고 "Automated Reasoning"이라는 모호한 단어로 남는 경우가 많은데, 실제로는 꽤 현실적인 패턴입니다. SMT solver나 Z3 같은 형식 검증 도구가 필요한 극단적인 케이스도 있지만, 대부분은 도메인 정책에서 파생된 규칙 엔진 형태로 충분합니다.
from dataclasses import dataclass
import httpx
class PolicyViolationError(Exception):
pass
@dataclass
class MedicalSummary:
diagnosis: str
recommended_dosage_mg: float
patient_age: int
# 도메인 정책 규칙 — LLM이 아닌 코드로 결정론적으로 검증
def validate_medical_rules(summary: MedicalSummary) -> None:
# 규칙 1: 소아 환자(18세 미만)에게 성인 용량 상한 초과 금지
if summary.patient_age < 18 and summary.recommended_dosage_mg > 500:
raise PolicyViolationError(
f"소아 환자에게 {summary.recommended_dosage_mg}mg 처방은 정책 위반"
)
# 규칙 2: 외부 데이터베이스로 진단명 실재 여부 확인
if not _verify_diagnosis_exists(summary.diagnosis):
raise PolicyViolationError(
f"'{summary.diagnosis}'는 검증된 질병 데이터베이스에 없는 진단명"
)
def _verify_diagnosis_exists(diagnosis: str) -> bool:
# ICD-10 공개 API 조회
response = httpx.get(
"https://clinicaltables.nlm.nih.gov/api/icd10cm/v3/search",
params={"terms": diagnosis, "maxList": 1}
)
return response.json()[0] > 0레이어 3의 핵심은 LLM에게 "이 규칙이 맞는지"를 묻지 않고, 코드로 직접 체크한다는 점입니다. LLM이 틀릴 수 있는 부분을 결정론적인 규칙으로 보완하는 방식이어서 감사(audit)도 훨씬 용이합니다.
장단점 분석
개념과 코드를 살펴봤으니, 이제 현실적인 트레이드오프를 정리해볼 차례입니다.
장점
| 항목 | 내용 |
|---|---|
| 할루시네이션 억제 | 구조 + 의미 검증 결합 시 스키마만 강제할 때보다 오류율 대폭 감소 |
| 파이프라인 안정성 | 상위 단계 오류가 하위 단계로 전파되기 전에 차단 |
| 자동 회복 | 재시도 루프로 일시적 생성 실패를 사람 개입 없이 처리 |
| 감사 가능성 | 판정 이유(reason)를 로그로 남기면 실패 분석과 규제 대응이 용이 |
| 보안 강화 | 정규식 + LLM 분류기 조합으로 프롬프트 인젝션 차단율 99.1%까지 향상 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 지연 시간 증가 | Reflection 1회 추가 시 LLM 재호출 최소 1회 발생, P99 지연 급증 가능 | 레이어별 타임아웃 설정, 비동기 병렬 처리, 지연 허용 범위 사전 정의 |
| 비용 폭발 위험 | 재시도 상한 없으면 검증 루프가 무한 반복, 토큰 비용 기하급수 증가 | max_retries + fallback 정책 필수 설계 |
| 거짓 양성 문제 | LLM-as-Judge가 내부 일관성 높지만 사실은 틀린 내용을 옳다고 판정 가능 | MAV 패턴 도입, 도메인 전문 검증 레이어 추가 |
| 중간 단계 오류 전파 | 에이전트 실패의 17%는 단계 반복, 14%는 추론-행동 불일치로 최종 출력만 봐서는 탐지 불가 | 중간 단계별 검증 로깅, Arize Phoenix 같은 에이전트 트레이싱 도구 활용 |
| 스키마 할루시네이션 | 유효한 JSON 안에 할루시네이션 값이 존재하는 silent failure | 의미 검증 레이어를 선택이 아닌 필수로 설계 |
직접 도입해보면서 가장 뼈저리게 느낀 건 지연 시간 문제입니다. "95% 정확도 + 5초 지연"보다 "90% 정확도 + 100ms 지연"이 실제 서비스에서 더 쓸 만한 시스템인 경우가 많습니다. 검증 레이어를 추가하기 전에 P99 지연 허용 범위를 먼저 정해두는 것을 권장합니다.
silent failure (조용한 오류): 스키마 검증은 통과했지만 실제 값이 잘못된 상태. 에러가 즉각 발생하지 않아 다운스트림에서 뒤늦게 발견되는 것이 특징입니다. 에이전트 파이프라인에서 가장 위험한 오류 유형 중 하나입니다.
실무에서 가장 흔한 실수
-
max_retries를 설정하지 않는 것: 재시도 루프를 만들었으면 상한선도 반드시 함께 만들어야 합니다. 검증 기준이 너무 엄격하거나 프롬프트에 모순이 있으면 루프가 멈추지 않고 토큰 비용이 폭발합니다. -
Judge와 주 에이전트를 같은 모델로 쓰는 것: 같은 모델은 같은 편향을 공유합니다. 주 에이전트가 틀렸을 때 Judge도 같은 이유로 틀렸다고 판정 못 하는 경우가 생깁니다. 가능하면 다른 모델, 이상적으로는 다른 공급자의 모델을 권장합니다.
-
레이어 1만 구축하고 프로덕션 배포하는 것: "일단 스키마 검증까지만 하고 나중에 추가하자"가 결국 나중에 안 되는 경우가 많습니다. 구조 검증만으로는 조용한 오류를 막을 수 없고, 이 오류는 실제 사용자 트래픽이 붙고 나서야 드러납니다.
마치며
에이전트 출력 검증의 핵심은 "구조적으로 올바른 JSON이 곧 올바른 출력을 의미하지 않는다"는 사실을 설계에 반영하는 것입니다. 구조·의미·규칙의 세 레이어가 모두 살아있어야 비로소 프로덕션 수준이라 부를 수 있습니다.
지금 바로 시작해볼 수 있는 3단계:
-
레이어 1 먼저 도입:
pip install pydantic-ai또는pip install instructor후 기존 에이전트의 응답 타입을 Pydantic 모델로 교체해볼 수 있습니다.retries=3하나로 구조 오류의 상당 부분이 자동 처리됩니다. -
Judge 에이전트를 파이프라인 끝에 붙이기: 위 예시의
judge_output()함수를 가장 중요한 에이전트 하나에만 적용해보시면 좋습니다. 처음에는 게이트로 막지 말고score만 로그로 남겨서 현재 출력 품질의 베이스라인을 먼저 확인하는 것을 권장합니다 — 어떤 유형에서 점수가 낮은지 패턴을 보고 나서 임계값을 정하면 거짓 양성을 많이 줄일 수 있습니다. -
Guardrails AI또는Lakera Guard추가: 구조·의미 검증까지 갖췄다면 보안 레이어를 붙여볼 수 있습니다. Lakera Guard는 35ms 미만의 오버헤드로 프롬프트 인젝션·PII를 차단해줘서, 외부 사용자 입력을 받는 에이전트라면 특히 도입해볼 만합니다.
지금 여러분의 에이전트는 몇 번째 레이어까지 구축되어 있나요?
참고 자료
- Building an AI Agent Evaluation Pipeline: 2026 Methodology
- 5 Agent Design Patterns Every Developer Needs to Know in 2026
- Stop LLMs from Lying: Build Self-Correcting Agents with the Reflection Pattern
- LLM Guardrails in Production: Input, Output, and Runtime Checks
- Mastering LLM Guardrails: Complete 2026 Guide
- GitHub - guardrails-ai/guardrails
- Multi-Agent Verification: Scaling Test-Time Compute with Multiple Verifiers (arXiv 2502.20379)
- When AIs Judge AIs: The Rise of Agent-as-a-Judge Evaluation for LLMs (arXiv 2508.02994)
- How to build LLM-as-a-Judge evaluators that hold up in production — Arize AI
- Structured Output Isn't Reliable Output — Rotascale
- AI Agent Guardrails & Output Validation in 2026: Tools, Patterns & Best Practices
- Self-reflection enhances large language models towards substantial academic response — npj Artificial Intelligence
- What 1,200 Production Deployments Reveal About LLMOps in 2025 — ZenML