10장. MCP 와 Prompt·Context Engineering
실습 6 — 실패에 강한 호출
LLM 에게 문의 분류를 JSON 으로 받아 검증하고, 틀리면 까닭을 붙여 다시 묻습니다. Ollama 의 일시 오류에는 백오프 재시도를 붙입니다. 실패가 얼마나 줄어드는지 셉니다.
1단계. 검증과 다시 묻기
import json, urllib.request
# 이 PC 의 Ollama 는 오래 쓰다 보면 가끔 깨진 글자(@@@@)만 내는 일이 있었다. 실습을 시작할 때 모델을 내려 깨끗하게 다시 올린다
urllib.request.urlopen(urllib.request.Request("http://localhost:11434/api/generate", headers={"Content-Type": "application/json"},
data=json.dumps({"model": "qwen2.5:3b", "keep_alive": 0}).encode())).read()
import json, time
import pandas as pd
from openai import OpenAI, APIError
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
MODEL = "qwen2.5:3b"
test = pd.read_csv("inquiries.csv").iloc[20:]
LABELS = ("배송", "교환반품", "결제", "회원", "기타")
ASK = ('고객 문의를 분류해 {"분류": 분류 이름, "이유": 짧은 이유} 꼴의 JSON 으로만 답하세요. '
'분류 이름은 배송, 교환반품, 결제, 회원, 기타 가운데 하나입니다.')
def check(text):
"""본문 '퓨샷과 출력 꼴 고정'의 검증: (결과 또는 None, 까닭)"""
try:
obj = json.loads(text)
except (json.JSONDecodeError, TypeError):
return None, "JSON 이 아닙니다"
if obj.get("분류") not in LABELS:
return None, f"분류 '{obj.get('분류')}' 는 목록에 없습니다. {', '.join(LABELS)} 가운데 하나만 쓰세요"
return obj, "통과"
def call_llm(messages, json_mode, tries=3):
"""Ollama 일시 오류에는 0.5초, 1초로 늘리며 다시 보낸다(백오프)"""
extra = {"response_format": {"type": "json_object"}} if json_mode else {}
for k in range(tries):
try:
return client.chat.completions.create(model=MODEL, messages=messages, temperature=0, seed=42,
max_tokens=80, **extra).choices[0].message.content
except APIError:
if k == tries - 1:
raise
time.sleep(0.5 * 2 ** k)
def classify(q, json_mode=False, max_fix=0):
"""max_fix: 검증에 실패하면 까닭을 붙여 다시 묻는 횟수"""
messages = [{"role": "system", "content": ASK}, {"role": "user", "content": q}]
for attempt in range(max_fix + 1):
text = call_llm(messages, json_mode)
obj, why = check(text)
if obj:
return obj, attempt
messages += [{"role": "assistant", "content": text},
{"role": "user", "content": f"형식이 틀렸습니다: {why}. JSON 하나만 다시 쓰세요."}]
return None, max_fix
print(classify("카드 결제가 두 번 됐어요."))({'분류': '결제', '이유': '同一カード로 결제가 두 번 발생하여 문제 발생'}, 0)2단계. 세 가지 설정 견주기
rows = []
for name, json_mode, max_fix in [("지시만", False, 0), ("지시 + 다시 묻기 2번", False, 2), ("JSON 모드 + 다시 묻기 2번", True, 2)]:
start, valid, right, fixed = time.time(), 0, 0, 0
for q, y in zip(test["문의"], test["분류"]):
obj, attempt = classify(q, json_mode, max_fix)
valid += obj is not None
right += bool(obj) and obj["분류"] == y
fixed += bool(obj) and attempt > 0 # 다시 물어서 통과한 수
rows.append({"설정": name, "형식 통과(/20)": valid, "정답(/20)": right, "다시 물어 고친 수": fixed,
"초": round(time.time() - start)})
print(pd.DataFrame(rows).to_string(index=False)) 설정 형식 통과(/20) 정답(/20) 다시 물어 고친 수 초
지시만 20 13 0 25
지시 + 다시 묻기 2번 20 13 0 14
JSON 모드 + 다시 묻기 2번 20 13 0 14이 실습이 뜻하는 것
이 실습은 서류를 받는 창구 직원이 양식 검사를 하는 일과 같습니다. 칸이 맞게 채워졌는지 보는 검사표를 만들고, 틀리면 "어느 칸이 틀렸다"고 알려 주며 다시 받고, 접수 기계가 잠깐 멈추면 조금씩 더 기다렸다 다시 시도하게 한 뒤 서류 하나를 받아 봅니다(1단계). 그리고 검사만 하는 판, 다시 받기를 더한 판, 처음부터 정해진 양식지를 주는 판으로 20건씩 받아 견줍니다(2단계). 여기서 검사표가 check(), 다시 받기가 다시 묻기(max_fix), 조금씩 더 기다리기가 백오프 재시도, 정해진 양식지가 JSON(JavaScript Object Notation) 모드입니다. 비유와 다른 점은, 이 양식 검사는 칸이 맞는지만 볼 뿐 적힌 분류가 옳은지는 보지 않는다는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 한 건 | ({'분류': '결제', '이유': '同一カード로 …'}, 0) | 형식 검사를 한 번에 통과했고(다시 물은 횟수 0) 결제로 분류했습니다. 이유 칸에 일본어가 섞였지만 검사는 분류 칸만 보므로 통과했습니다 |
| 2. 지시만 | 형식 통과 20, 정답 13, 고친 수 0, 25초 | 지시만으로도 20건 모두 형식을 지켰습니다 |
| 2. 지시 + 다시 묻기 2번 | 형식 통과 20, 정답 13, 고친 수 0, 14초 | 형식이 깨진 일이 없어 다시 물을 일도 없었습니다 |
| 2. JSON 모드 + 다시 묻기 2번 | 형식 통과 20, 정답 13, 고친 수 0, 14초 | 앞의 두 판과 결과가 같습니다. 정답은 세 판 모두 13개입니다 |
한 줄로 줄이면, 검증과 다시 묻기, JSON 모드는 답의 "꼴"을 지켜 주는 보험이고 정답 수를 올려 주지는 않는다는 것을 보인 실습입니다. 이번 실행에서는 형식이 한 번도 깨지지 않아 보험을 쓸 일이 없었으므로, 이 표만으로는 보험이 얼마나 살려 주는지 알 수 없습니다(코드 설명에 적은 앞선 실행에서는 1개를 살렸습니다). 정답 수를 올리는 것은 실습 4처럼 프롬프트의 몫이고, 도구가 잠깐 실패할 때 재시도가 얼마나 도움이 되는지는 프로젝트 1에서 잽니다.
코드 설명
- 한 문의를 물으면
{'분류': '결제', '이유': '…'}처럼 검증을 통과한 사전과 다시 물은 횟수 0 이 나옵니다. 이유에 "同一カード" 같은 일본어가 섞였는데, 검증은 "분류" 키만 보므로 통과했습니다. 무엇을 검증할지 정하는 것도 설계입니다. - 이번 실행에서는 지시만으로도 20개 모두 JSON 형식을 지켰고, 다시 물을 일이 없었습니다. 교안을 만들며 먼저 돌렸을 때는 지시만 준 판이 20개 가운데 19개만 통과했고, 다시 묻기로 1개를 고쳐 20개가 되었습니다.
- 다시 묻기와 JSON 모드는 형식이 깨질 때를 위한 보험입니다. 형식이 늘 맞을 때는 시간만 조금 들고, 깨질 때는 그 한두 개를 살립니다.
- 세 판 모두 정답은 13개로 같습니다. JSON 모드와 다시 묻기는 "꼴"을 지켜 줄 뿐 분류를 더 잘하게 하지는 않습니다. 정확도는 실습 4처럼 프롬프트(설명과 예시)로 올립니다.