← Start here · Module 5 · Project
작고 싸고 로컬에서 도는 모델을 내 태스크에 특화시키는 것 — 이게 fine-tuning이에요. 예제는 고객지원 티켓 라우팅: 티켓 하나를 밀리초 만에, 추론 시 API 호출 0번으로 담당 팀에 배정합니다.
고객지원 티켓 하나를 담당 6개 팀 중 하나로 라우팅 — 같은 48개짜리 gold 테스트셋으로 채점해 방법끼리 숫자를 직접 비교합니다.
모델(또는 분류기)은 모든 티켓에 대해 팀 하나를 반드시 골라야 합니다 — 하지만 맞는다는 보장은 없어요. 그래서 같은 척도로 방법을 비교하는 게 핵심입니다.
난이도는 팀 설명(taxonomy)에 일부러 안 적어 둔 조직 고유 규칙에서 나옵니다. 정의만 보고 추론하면 틀려요 — 이건 정의가 아니라 예시로 가르치는 지식입니다.
| 티켓은 이렇게 생겼는데… | 뻔한 추측 | 실제 담당 |
|---|---|---|
| "플랜 업그레이드 / 좌석 추가" | Sales | Billing |
| "계정 닫기 / 삭제" | Account Access | Data Privacy |
| "내가 안 한 결제 / 해킹당함" | Billing | Trust & Safety |
| "로그인 안 됨 — 500 에러" | Account Access | Technical Support |
고전 예제 — 손글씨 숫자 인식 — 은 우리 티켓 라우터와 똑같은 모양이에요: 원시 입력 → 학습된 특징 → 클래스별 점수 하나씩.
아주 단순한 신경망은 회색조 픽셀 배열을 숫자 0–9로 "분류"합니다. 각 노드는 입력마다
가중치(W)와 오프셋(b)을 갖고: output = W·input + b.
학습(training)은 예측을 정답과 비교해 오차를 줄이는 쪽으로 모든 W·b를 조금씩 밀어요 — 그 유명한 backpropagation 알고리즘입니다.
티켓 라우터도 같은 3층 구조예요: embedder가 입력+은닉 특징층 역할을, logistic-regression head가 출력층 역할을 합니다.
스타터 finetune.zip은 딱 두 덩어리예요: 데이터(taxonomy·gold·few-shot·train)와 코드(공용 common.py + 스테이지 셋 + 채점기). 세 스테이지가 같은 데이터·같은 채점기를 공유하는 게 핵심입니다.
| 파일 | 역할 | 핵심 |
|---|---|---|
taxonomy.json | 6개 팀 이름 + 설명 | 프롬프트 텍스트로 그대로 들어감. 하우스룰은 일부러 빠져 있음 |
test.jsonl | 48개 gold 티켓 | 세 방법을 재는 고정 척도 — 예시·학습에 절대 안 씀(누수 방지) |
seed_examples.jsonl | 21개 라벨 예시 | stage 1이 프롬프트에 붙여 하우스룰을 시연 |
synthetic_train.jsonl | ~240개 학습 티켓 | stage 2의 학습셋. Claude가 생성한 합성 데이터(데모용) |
common.py | 공용 헬퍼 | ① 데이터 IO, ② Claude 라우팅(강제 tool 사용), ③ 로컬 임베더 — 세 스테이지가 공유 |
stage0/1/2 | 세 가지 방법 | zero-shot · few-shot · custom-trained. 각자 예측을 data/preds/에 기록 |
evaluate.py | 채점 + 비교 | accuracy · per-class F1 · confusion matrix + 세 방법 progression 표 |
common.py가 프로젝트의 심장이에요. prompt 스테이지(0·1)는 여기 classify()로 Claude를 강제 tool과 함께 부르고, 학습 스테이지(2)는 여기 embed()로 티켓을 벡터로 만듭니다. 두 세계가 한 파일에 같이 살아요 — 아래 5B.8에서 실제 코드로 봅니다.
같은 태스크를 푸는 세 가지 방법. 방법(method)만 바뀌고, 테스트셋과 지표는 고정입니다.
1 Claude call / ticket1 Claude call / ticket0 Claude calls / ticketcustom-trained가 프롬프트 방법을 항상 이기는 건 아니에요(학습 데이터만큼만 잘함) — 볼 건 "누가 이기냐"가 아니라 어디서 이기고 지는가입니다. 축이 다르니까요:
| 축 | Zero-shot | Few-shot | Custom-trained |
|---|---|---|---|
| 셋업 비용 | 0 (프롬프트만) | 낮음 (예시 몇 개) | 학습셋 + 학습 1회 |
| 티켓당 추론 | Claude 1회 | Claude 1회 | API 0회 · 밀리초 |
| 하우스룰 학습 | 못 함 (정의만 봄) | 예시로 시연 | 라벨로 학습 |
| 정확도 상한 | 모델의 일반 상식 | + 예시 몇 개 | = 학습 데이터 품질 |
| 새 규칙 반영 | 프롬프트 수정 | 예시 추가 | 재라우팅이 자동 축적 |
evaluate.py로 그 숫자를 직접 보여줍니다.
custom-trained 방법은 코드로 보면 다섯 함수의 직선이에요: 읽고 → 임베딩하고 → 학습하고 → 예측하고 → 채점. 그리고 점선 하나 — 사람이 다시 넘긴 티켓이 학습셋으로 되먹임되는 피드백 루프가 이 방법을 시간이 갈수록 좋아지게 만듭니다.
가운데 직선(load → embed → fit → predict → eval)은 한 번 도는 학습 파이프라인이에요. 진짜 가치는 점선 피드백에 있어요: 잘못 라우팅된 티켓을 사람이 올바른 팀으로 다시 넘기면, 그게 곧 새 라벨이 돼서 학습셋으로 흘러 들어갑니다 — 라벨을 사실상 공짜로 축적하는 되먹임 루프예요.
두 단계뿐이에요: 라벨된 데이터를 얻고, 작은 모델을 학습. 학습이 건드리는 건 위에 얹은 작은 head 하나입니다.
# ① 라벨된 데이터 — (ticket → team) 쌍
# production: 해결된 티켓 로그에서 캡처. this project: 완제 세트가 동봉.
rows = load("data/synthetic_train.jsonl") # ~240 rows
# ② 학습 — embed → logistic regression
X = embed(texts) # MiniLM, 384-dim, local
clf = LogisticRegression().fit(X, y)
# inference: embed + multiply, API 호출 없음
다이어그램에서 EMBED는 채도가 빠진 채 가만히 있고 HEAD만 숨쉬듯 미세하게 움직여요 — 이게 frozen vs trained의 시각적 대비예요. 학습이 건드리는 건 작은 head뿐이라 gradient가 위의 분류기만 갱신합니다 — 빠르고, 싸고, 과적합도 잘 안 나요.
embed()는 티켓 문장을 384개의 숫자(벡터)로 바꿔요. 비슷한 뜻의 티켓은 비슷한 벡터가 되고,
head는 그 숫자들 위에서 팀 경계를 긋습니다. 아래는 진짜 학습 티켓 하나가 벡터로 접히는 걸 축약한 모습이에요(8칸으로 줄임):
두 조각이면 프로젝트의 두 세계가 다 보여요: ① prompt 스테이지가 Claude를 강제 tool로 부르는 common.py, ② custom 스테이지가 embed→fit→predict하는 stage2_finetune.py. 각 코드 조각 위의 미니 다이어그램이 흐름 지도예요.
def route_tool(names: list[str]) -> dict:
"""A forced tool whose only field is the team, constrained to a known enum."""
return {
"name": "route_ticket",
"description": "Assign the support ticket to exactly one team.",
"input_schema": {
"type": "object",
"properties": {"team": {"type": "string", "enum": names}},
"required": ["team"],
},
}
def classify(text: str, system: str, names: list[str]) -> str:
resp = get_client().messages.create(
model=MODEL,
max_tokens=256,
system=system,
messages=[{"role": "user", "content": text}],
tools=[route_tool(names)],
tool_choice={"type": "tool", "name": "route_ticket"},
)
block = next(b for b in resp.content if b.type == "tool_use")
return block.input["team"]
강제 tool은 형식 문제가 아니라 보장이에요. tool_choice가 없으면 모델은 "이건 Billing 같네요…"처럼 서술할 수도, 오탈자를 낼 수도 있어요. enum + 강제 호출 덕분에 stage 0·1의 차이는 오직 정확도일 뿐, 절대 깨진 출력이 아닙니다 — 그래서 세 방법을 같은 척도로 공정하게 비교할 수 있어요.
def train():
train_path = common.DATA / "synthetic_train.jsonl"
# … (raise if the training file is missing)
rows = common.load_jsonl(train_path)
X = common.embed([r["text"] for r in rows])
y = [r["team"] for r in rows]
clf = LogisticRegression(max_iter=2000, C=10.0)
clf.fit(X, y)
joblib.dump(clf, MODEL_PATH)
return clf
def predict(tickets):
clf = load_classifier()
X = common.embed([t["text"] for t in tickets])
preds = clf.predict(X)
return [
{"id": t["id"], "gold": t["team"], "pred": str(p), "text": t["text"]}
for t, p in zip(tickets, preds)
]
train()과 predict()가 똑같은 common.embed()를 쓰는 데 주목하세요 — 학습 때 문장을 벡터로 만든 그 함수로 추론 때도 똑같이 벡터를 만들어요. 그래서 stage 2의 추론엔 Claude 호출이 한 번도 없습니다: 임베딩 한 번 + 행렬곱 한 번이면 팀이 나와요.
스테이지를 순서대로 돌리면 세 방법의 F1과 confusion matrix가 나란히 나옵니다.
# 스타터: finetune.zip (taxonomy + gold set + few-shot + 학습셋 + 스테이지)
# https://ksetp.netlify.app/assets/special-topics/finetune.zip
unzip finetune.zip && cd finetune
python3.11 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python stage0_zeroshot.py # 베이스라인
python stage1_fewshot.py # + 예시
python stage2_finetune.py # 학습 + 채점
python evaluate.py # 세 방법 나란히 (F1 + confusion matrix)
taxonomy.json · test.jsonl (48 gold)seed_examples.jsonl (21 few-shot)synthetic_train.jsonl (~240 train)common.py — 라우팅·임베딩 공통stage0 · stage1 · stage2evaluate.py — F1 + confusion matrixANTHROPIC_API_KEY는 공유 .env를 그대로 씁니다(stage2 추론엔 필요 없지만 stage0/1엔 필요).
evaluate.py가 찍는 것각 스테이지는 자기 리포트를 찍고, evaluate.py는 캐시된 예측을 읽어(추가 Claude 호출 없음) 세 방법을 progression 표 하나로 모아요:
================ PROGRESSION ================
stage accuracy macro-F1
Stage 0 — zero-shot prompting ... ...
Stage 1 — few-shot prompting ... ...
Stage 2 — distilled classifier ... ...
"라벨만 필요하고 실패가 싼" 태스크라면, 프롬프팅을 넘어 작은 로컬 분류기가 더 싸고 빠를 수 있어요 — 값은 얼어붙은 embedder 위 작은 head 하나에 담깁니다.
custom-trained 라우터에서 학습 중 실제로 업데이트되는 가중치는 어디일까요?
embedder(MiniLM)는 고정된 특징 추출기로 그대로 두고, gradient는 위의 작은 분류기 head만 갱신해요. 그래서 라벨 몇백 개로도 빠르게 학습되고 과적합이 덜합니다 — "무거운 표현은 재사용, 가벼운 결정층만 내 데이터로 학습"이 이 패턴의 본질이에요.
tool_choice로 route_ticket 툴을 강제하는 건 주로 무엇을 위한 걸까요?
강제 tool + enum은 포맷 정리가 아니라 라우팅 가능성 보장이에요. 없으면 모델이 자유 서술하거나 엉뚱한 팀 이름을 낼 수 있어요. 항상 유효한 팀 하나가 나오니까 stage 0·1·2를 비교할 때 차이는 오직 정확도일 뿐, 깨진 출력이 섞이지 않습니다.
티켓 하나를 추론할 때, few-shot(stage 1)과 custom-trained(stage 2)의 비용/호출은 어떻게 다를까요?
few-shot도 결국 매 티켓마다 Claude를 부르는 프롬프트 방법이에요(예시는 프롬프트를 더 길게 만들 뿐). custom-trained는 추론 시 API 호출이 0번 — 로컬 임베딩 한 번과 행렬곱 한 번이면 끝이라 밀리초·1센트의 몇 분의 1로 돌아갑니다. 이게 "작은 모델로 배포"의 실질적 이득이에요.
이 프로젝트는 synthetic_train.jsonl(Claude가 생성한 합성 데이터)로 학습해요. 실무에서 프로덕션 로그로 학습하는 게 더 나은 이유는?
stage 2 분류기는 학습셋에 있는 경계만 배울 수 있어요 — 데이터 품질이 성능의 천장입니다. 합성 데이터는 자체 완결성을 위해 편하지만, 생성 모델의 편향과 진짜 트래픽과 다른 분포를 물려받을 수 있어요. 그래서 실무에선 실제로 해결된 티켓(사람이 라우팅한 라벨)을 학습셋으로 쓰는 게 낫습니다. (양보다 분포·편향 문제예요.)