LLM 엔지니어링10장 · MCP 와 Prompt·Context Engineering19 / 22← 이전목차다음 →MetaContents

10장. MCP 와 Prompt·Context Engineering

실습 6 — 실패에 강한 호출

LLM 에게 문의 분류를 JSON 으로 받아 검증하고, 틀리면 까닭을 붙여 다시 묻습니다. Ollama 의 일시 오류에는 백오프 재시도를 붙입니다. 실패가 얼마나 줄어드는지 셉니다.

1단계. 검증과 다시 묻기

Python
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단계. 세 가지 설정 견주기

Python
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처럼 프롬프트(설명과 예시)로 올립니다.
← 이전 절10장 · 실습 5 — 컨텍스트 고르기다음 절 →10장 · 프로젝트 1 — 사내 재고 시스템 연결