파라미터 크기가 작은 LLM을 사용하여 AI 에이전트를 만들다 보면 이런 상황이 반드시 생겨요.
"분명히 JSON으로 답하라고 했는데 왜 마크다운 코드블록으로 감싸서 오지? 왜 필드가 빠져 있지?"
LLM 출력을 믿고 그냥 json.loads() 하면 언젠가 반드시 터져요. 이번 글에서는 Pydantic으로 LLM 출력을 안정적으로 검증하는 방법을 정리해 드릴게요.
왜 LLM 출력 파싱이 어려운가
LLM은 확률적으로 텍스트를 생성해요. "JSON으로만 답해"라고 해도 이런 일이 생겨요.
문제 1: 마크다운 펜스가 붙어서 옴
```json
{"result": "success"}
**문제 2: 필드가 빠져 있음**
```json
{"result": "success"}
// reasoning 필드가 없음
문제 3: 타입이 틀림
{"count": "5"} // 숫자여야 하는데 문자열로 옴
문제 4: 아예 JSON이 아닌 텍스트가 옴
네, 결과는 success입니다.
이걸 전부 try-except로 방어하면 코드가 엉망이 돼요. Pydantic을 쓰면 스키마 정의 하나로 전부 해결할 수 있어요.
Pydantic 기본 — 스키마 정의
Pydantic은 Python의 데이터 검증 라이브러리예요. BaseModel을 상속해서 스키마를 정의하면 타입 검증, 필드 누락 체크, 기본값 처리를 자동으로 해줘요.
from pydantic import BaseModel
from typing import Literal
class StepRouterOutput(BaseModel):
next_task: str
reasoning: str
class SQLGeneratorOutput(BaseModel):
query: str
explanation: str
confidence: float # 0.0 ~ 1.0
class ClassificationOutput(BaseModel):
category: Literal["기술", "비즈니스", "일반"]
confidence: float
tags: list[str] = [] # 기본값 있으면 없어도 됨
Literal로 허용 값을 제한할 수 있고, = []처럼 기본값을 설정하면 LLM이 해당 필드를 빠뜨려도 에러 없이 처리돼요.
LLM 출력에서 JSON 파싱하기
LLM이 마크다운 펜스를 붙여서 오는 경우를 처리해야 해요.
import json
import re
from pydantic import BaseModel, ValidationError
def parse_llm_output(raw: str, schema: type[BaseModel]) -> tuple[bool, BaseModel | None, str]:
"""
LLM raw 출력을 받아서 Pydantic 모델로 검증
반환: (성공여부, validated model or None, 에러 메시지)
"""
# 1. 마크다운 코드블록 제거
cleaned = raw.strip()
cleaned = re.sub(r"```json\s*", "", cleaned)
cleaned = re.sub(r"```\s*", "", cleaned)
cleaned = cleaned.strip()
# 2. JSON 파싱
try:
parsed = json.loads(cleaned)
except json.JSONDecodeError as e:
return False, None, f"JSON 파싱 실패: {str(e)}"
# 3. Pydantic 검증
try:
validated = schema.model_validate(parsed)
return True, validated, ""
except ValidationError as e:
error_messages = []
for error in e.errors():
field = " -> ".join(str(loc) for loc in error["loc"])
error_messages.append(f"{field}: {error['msg']}")
return False, None, "\n".join(error_messages)
사용 예시예요.
raw = """
```json
{
"next_task": "search_agent",
"reasoning": "검색이 먼저 필요합니다"
}
"""
success, result, error = parse_llm_output(raw, StepRouterOutput)
if success: print(result.next_task) # "search_agent" print(result.reasoning) # "검색이 먼저 필요합니다" else: print(f"파싱 실패: {error}")
---
## 실패 시 LLM에 피드백 주기
파싱이 실패하면 에러 메시지를 LLM에 다시 넘겨서 재시도하게 해야 해요. 그냥 "다시 해"가 아니라 **뭐가 틀렸는지 구체적으로** 알려줘야 LLM이 제대로 고쳐요.
```python
def get_error_feedback(error_msg: str, schema: type[BaseModel]) -> str:
schema_json = schema.model_json_schema()
return (
f"이전 응답이 올바른 JSON 형식이 아닙니다.\n"
f"오류 내용: {error_msg}\n\n"
f"반드시 아래 스키마를 따르는 JSON만 반환하세요. 다른 텍스트 포함 금지.\n"
f"스키마: {json.dumps(schema_json, ensure_ascii=False, indent=2)}"
)
재시도 루프와 연결하면 이렇게 돼요.
async def invoke_with_retry(
messages: list[dict],
schema: type[BaseModel],
llm_client,
max_retries: int = 3
) -> BaseModel:
retry_count = 0
current_messages = messages.copy()
while retry_count < max_retries:
raw = await llm_client.generate(current_messages)
success, result, error = parse_llm_output(raw, schema)
if success:
return result
# 실패 시 피드백 추가
retry_count += 1
feedback = get_error_feedback(error, schema)
current_messages.append({"role": "assistant", "content": raw})
current_messages.append({"role": "user", "content": feedback})
raise Exception(f"최대 재시도 횟수 초과: {max_retries}회 모두 실패")
중첩 스키마 처리
실제 에이전트에서는 중첩된 구조가 많이 나와요. Pydantic은 중첩 구조도 자동으로 검증해줘요.
from pydantic import BaseModel
from typing import Optional
class CostSchema(BaseModel):
estimated_tokens: int
estimated_cost_usd: float
class MetadataSchema(BaseModel):
tables_used: list[str]
complexity: Literal["low", "medium", "high"]
cost: CostSchema # 3단계 중첩
class SQLOutputSchema(BaseModel):
query: str
explanation: str
metadata: MetadataSchema # 2단계 중첩
is_safe: bool = True
raw = """
{
"query": "SELECT * FROM users WHERE active = true",
"explanation": "활성 사용자 전체 조회",
"metadata": {
"tables_used": ["users"],
"complexity": "low",
"cost": {
"estimated_tokens": 150,
"estimated_cost_usd": 0.002
}
}
}
"""
success, result, error = parse_llm_output(raw, SQLOutputSchema)
print(result.metadata.cost.estimated_tokens) # 150
Pydantic이 중첩 구조 전체를 한 번에 검증해줘요. 3단계 중첩이어도 model_validate() 한 번으로 끝납니다.
JSON schema를 LLM 프롬프트에 주입하기
LLM이 처음부터 올바른 형식으로 답하게 하려면 스키마를 프롬프트에 넣어줘야 해요.
def build_system_prompt(schema: type[BaseModel]) -> str:
schema_json = json.dumps(
schema.model_json_schema(),
ensure_ascii=False,
indent=2
)
return (
"당신은 JSON만 반환하는 어시스턴트입니다.\n"
"반드시 아래 스키마를 따르는 JSON만 반환하세요.\n"
"다른 텍스트, 설명, 마크다운 코드블록 포함 금지.\n\n"
f"스키마:\n{schema_json}"
)
실제로 생성되는 프롬프트 예시예요.
당신은 JSON만 반환하는 어시스턴트입니다.
반드시 아래 스키마를 따르는 JSON만 반환하세요.
다른 텍스트, 설명, 마크다운 코드블록 포함 금지.
스키마:
{
"type": "object",
"properties": {
"next_task": {"type": "string"},
"reasoning": {"type": "string"}
},
"required": ["next_task", "reasoning"]
}
sglang / OpenAI의 structured output 활용
서빙 프레임워크에서 JSON schema를 직접 강제할 수 있으면 파싱 실패율이 거의 0에 가까워져요.
OpenAI
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
response_format={
"type": "json_schema",
"json_schema": {
"name": "output",
"schema": StepRouterOutput.model_json_schema()
}
}
)
sglang
response = client.chat.completions.create(
model="Qwen/Qwen3-8B",
messages=messages,
response_format={
"type": "json_schema",
"json_schema": {
"name": "output",
"schema": StepRouterOutput.model_json_schema()
}
}
)
이렇게 하면 LLM이 스키마를 벗어난 JSON을 물리적으로 생성하지 못해요. 토큰 생성 단계에서 스키마를 강제하는 방식이라 마크다운 펜스도, 필드 누락도 나올 수가 없어요.
전체 흐름 정리
1. Pydantic으로 스키마 정의
2. 스키마를 프롬프트에 주입 (처음부터 올바른 형식 유도)
3. 가능하면 서빙 레이어에서 JSON schema 강제 (sglang / OpenAI)
4. LLM 출력 → 마크다운 펜스 제거 → json.loads() → model_validate()
5. 실패 시 에러 피드백 추가 후 재시도 (최대 3회)
6. 재시도 초과 시 Exception
마무리
LLM 출력 파싱 실패는 에이전트 시스템에서 가장 흔한 버그 유형이에요.
Pydantic 스키마를 정의하고, 프롬프트에 주입하고, 실패 시 구체적인 피드백으로 재시도하는 구조를 만들면 파싱 실패율을 거의 0으로 낮출 수 있어요. 서빙 레이어에서 JSON schema를 강제할 수 있는 환경이라면 그게 제일 확실한 방법이고요. 😄
'AI Agent' 카테고리의 다른 글
| AI 에이전트 성능을 어떻게 측정하나 — Evals와 평가 방법론 완전 정리 (0) | 2026.03.25 |
|---|---|
| AI 에이전트 오케스트레이션 패턴 3가지 — Pipeline, Supervisor, Swarm 실전 비교 (0) | 2026.03.25 |
| AI 에이전트가 긴 작업을 끝까지 해내는 법 — 컨텍스트 압축 전략 완전 정리 (0) | 2026.03.25 |
| Vercel이 툴을 줄여서 성능을 올린 방법 — AI 에이전트 툴 설계 가이드 (0) | 2026.03.25 |
| Thought, Action, Observation을 코드로 — LangGraph + ReAct 완전 정리 (0) | 2026.03.24 |