모델의 출력을 그대로 믿고 쓰면 언젠가 깨집니다. 이 수업에서는 형식을 강제하는 방법과, 받은 결과를 방어적으로 다루는 코드를 다룹니다.
형식을 강제하는 수단
프로그램이 소비할 출력이라면 형식이 지켜져야 합니다. 강제하는 수단은 여러 층이 있고, 위로 갈수록 확실합니다.
- 지시로 요청 — "JSON으로 출력하세요". 가장 약합니다. 설명이 앞뒤로 붙는 경우가 흔합니다.
- 응답 미리 채우기 — 답의 시작을
{로 지정해 형태를 고정합니다. - 도구 호출로 받기 — 스키마를 정의하고 그 도구를 호출하게 만들면 인자가 구조화되어 옵니다.
- 구조화된 출력 기능 — 스키마를 직접 지정합니다.
# 도구로 받는 방식 — 스키마가 곧 계약이 된다
tools = [{
"name": "record_classification",
"description": "분류 결과를 기록합니다.",
"input_schema": {
"type": "object",
"properties": {
"category": {"type": "string", "enum": ["billing", "technical", "other"]},
"confidence": {"type": "string", "enum": ["high", "low"]},
},
"required": ["category", "confidence"],
},
}]
층을 올릴수록 안정되지만, 어떤 수단을 써도 검증은 필요합니다. 열거형을 벗어난 값이나 빠진 필드가 올 수 있고, 무엇보다 출력이 잘렸을 가능성이 남습니다.
방어적으로 파싱하기
파싱 코드는 잘못된 입력이 온다는 전제로 씁니다. 순서가 있습니다.
def parse(resp) -> Result:
# 1) 잘렸는지 먼저 본다 — 파서를 고칠 문제가 아니다
if resp.stop_reason == "max_tokens":
raise Truncated("출력이 한도에서 잘렸습니다")
text = text_of(resp)
# 2) 파싱
try:
data = json.loads(text)
except json.JSONDecodeError:
log.warning("parse_failed", extra={"head": text[:200], "tail": text[-200:]})
raise
# 3) 스키마 검증 — 형식이 맞아도 값이 틀릴 수 있다
if data.get("category") not in {"billing", "technical", "other"}:
raise SchemaViolation(data)
return Result(**data)
세 단계를 갈라 두면 실패했을 때 원인이 바로 나옵니다. 하나로 묶으면 "파싱 실패"만 남아 잘림인지 형식 이탈인지 알 수 없습니다.
실패했을 때의 대응도 정해 둬야 합니다. 재시도할지, 기본값으로 갈지, 사람에게 넘길지입니다. 재시도할 때는 무엇이 잘못됐는지 알려 주는 편이 효과가 큽니다. 그냥 다시 요청하면 같은 실수를 반복할 확률이 높습니다.
자신 있는 오답
모델은 틀린 내용도 확신에 찬 문장으로 씁니다. 형식이 완벽해도 내용이 사실이 아닐 수 있고, 형식 검증은 이것을 잡아내지 못합니다.
대응 수단이 몇 가지 있습니다.
- 근거를 함께 요구하기. 원문의 어느 부분에서 나온 판단인지 인용하게 합니다. 인용이 원문에 실제로 있는지 코드로 확인할 수 있습니다.
- 확신도 필드 두기. 애매할 때 낮은 확신을 표시할 자리를 주면 억지 단정이 줄어듭니다.
- 모른다고 할 자리 주기. 답이 없을 때 넣을 값이 없으면 지어냅니다.
- 교차 확인. 중요한 값은 다른 방법으로 다시 확인합니다.
# 인용이 원문에 실제로 존재하는지 검증
def verify_quote(quote: str, source: str) -> bool:
return bool(quote) and quote.strip() in source
이 검증이 강력한 이유는 코드로 판정 가능하기 때문입니다. 사실 여부를 코드가 알 수는 없지만, 인용이 원문에 있는지는 알 수 있습니다. 없으면 지어낸 것입니다.
소비자를 보호하기
출력이 다른 시스템으로 흘러갈 때, 그 경계에서 한 번 더 막아야 합니다.
- 화면에 그릴 때 — 모델 출력을 그대로 HTML로 넣지 않습니다. 링크나 스크립트가 섞여 들어올 수 있습니다.
- 명령으로 쓸 때 — 출력을 셸이나 질의문에 그대로 넣지 않습니다. 값으로 전달하고 형식을 검증합니다.
- 다른 API로 보낼 때 — 그쪽이 기대하는 형식으로 다시 검증합니다.
특히 위험한 것이 모델 출력을 그대로 실행하는 경로입니다. 코드나 명령을 생성하게 하는 기능이라면, 실행 전에 사람이 확인하거나 격리된 환경에서 돌려야 합니다.
출력 계약이 바뀔 때의 절차도 정해 둡니다. 필드 이름이나 형식을 바꾸면 소비하는 쪽이 깨지므로, 소비자를 먼저 양쪽 호환으로 만든 뒤 생산자를 바꿉니다. 이 순서를 거꾸로 하면 배포 사이에 깨진 구간이 생깁니다.
시험 함정
- JSON으로 출력하라는 지시만으로 형식이 보장된다는 서술 — 앞뒤에 설명이 붙는 경우가 흔합니다.
- 구조화된 출력을 쓰면 검증이 필요 없다는 선택지 — 잘림과 값 범위 이탈이 남습니다.
- 파싱 실패를 한 덩어리로 처리하는 구현 — 잘림인지 형식 이탈인지 가릴 수 없습니다.
- 형식 검증을 통과하면 내용도 믿을 수 있다는 가정 — 자신 있는 오답은 형식이 완벽합니다.
- 재시도할 때 무엇이 잘못됐는지 알려 주지 않는 처리 — 같은 실수를 반복합니다.
- 모델 출력을 그대로 화면이나 명령에 넣는 경로 — 경계에서 검증과 이스케이프가 필요합니다.
실습 시나리오
실제 시험과 같은 형식의 시나리오 문제입니다.
문서에서 금액을 추출하는 기능이 있습니다. 스키마 검증은 통과하는데, 원문에 없는 금액이 반환되는 사례가 보고되었습니다. 가장 효과적인 대응은 무엇입니까?
빌드 연습 · 근거 검증이 붙은 추출 파이프라인 만들기
약 30분1.스키마 정의
추출 값과 근거 구절, 확신도를 담는 스키마를 만듭니다.
기대 결과 · 근거가 없을 때 넣을 값이 정의되어 있습니다.
2.단계별 파싱
잘림 확인·파싱·스키마 검증을 분리해 구현합니다.
기대 결과 · 실패 시 어느 단계였는지 로그로 구분됩니다.
3.근거 대조
반환된 구절이 원문에 있는지 코드로 확인합니다.
기대 결과 · 지어낸 사례가 검출됩니다.
4.재시도 피드백
검증 실패 시 무엇이 잘못됐는지 알려 다시 요청합니다.
기대 결과 · 재시도에서 같은 실수가 줄어듭니다.
5.경계 방어
결과를 화면에 그릴 때 이스케이프 처리를 넣습니다.
기대 결과 · 출력에 섞인 태그가 그대로 실행되지 않습니다.