← Start here · Module 5 · Project

Module 5 · Project — Custom Training (fine-tuning)

작고 싸고 로컬에서 도는 모델내 태스크에 특화시키는 것 — 이게 fine-tuning이에요. 예제는 고객지원 티켓 라우팅: 티켓 하나를 밀리초 만에, 추론 시 API 호출 0번으로 담당 팀에 배정합니다.

🧭 이 토픽은 원본 Special Topics 덱Topic 01 · Custom Training을 옮긴 거예요. 개념은 Module 5A · Agents와 독립적인 보너스 트랙입니다. Topic 02(VLA)는 코드까지 파는 별도 프로젝트로 커져서 Project: VLA로 분리했어요.
🚧 실행 예제는 finetune.zip에서 시작합니다. 저장소에 완성본이 없어요 — ANTHROPIC_API_KEY만 넣고 스테이지를 순서대로 돌리면 됩니다(아래 5B.9).

5B.1 · What it builds

고객지원 티켓 하나를 담당 6개 팀 중 하나로 라우팅 — 같은 48개짜리 gold 테스트셋으로 채점해 방법끼리 숫자를 직접 비교합니다.

6팀 taxonomy.json
  • Billing — 인보이스·청구·환불·결제수단·구독 플랜
  • Account Access — 로그인·비밀번호·재설정·SSO·2FA·잠긴 계정
  • Technical Support — 버그·에러·크래시·동작 안 하는 기능
  • Sales — 플랜·가격·견적·데모·트라이얼·구매
  • Trust & Safety — 어뷰즈·사기·스팸·괴롭힘·계정 보안
  • Data Privacy — 개인정보 요청/처리: 내보내기·보존·저장/공유 범위

모델(또는 분류기)은 모든 티켓에 대해 팀 하나를 반드시 골라야 합니다 — 하지만 맞는다는 보장은 없어요. 그래서 같은 척도로 방법을 비교하는 게 핵심입니다.

5B.2 · 왜 어려운가 — 숨은 하우스룰

난이도는 팀 설명(taxonomy)에 일부러 안 적어 둔 조직 고유 규칙에서 나옵니다. 정의만 보고 추론하면 틀려요 — 이건 정의가 아니라 예시로 가르치는 지식입니다.

티켓은 이렇게 생겼는데…뻔한 추측실제 담당
"플랜 업그레이드 / 좌석 추가"SalesBilling
"계정 닫기 / 삭제"Account AccessData Privacy
"내가 안 한 결제 / 해킹당함"BillingTrust & Safety
"로그인 안 됨 — 500 에러"Account AccessTechnical Support
💡 이 하우스룰은 정의로는 못 배우고 예시로만 배우는 지식이에요. 그래서 뒤의 세 방법이 갈립니다: 정의만 주는 zero-shot은 여기서 무너지고, few-shot·custom-trained는 예시로 이 경계를 배웁니다.

5B.3 · 신경망이 분류하는 법 (primer)

고전 예제 — 손글씨 숫자 인식 — 은 우리 티켓 라우터와 똑같은 모양이에요: 원시 입력 → 학습된 특징 → 클래스별 점수 하나씩.

flowchart LR P["input layer
one node per pixel (28x28 = 784)"] --> H["hidden layer(s)
learn features:
edges, loops, strokes"] H --> O["output layer
one score per class (0-9)"] O --> D(["pick the largest -> 7"]) class H core;

아주 단순한 신경망은 회색조 픽셀 배열을 숫자 0–9로 "분류"합니다. 각 노드는 입력마다 가중치(W)와 오프셋(b)을 갖고: output = W·input + b.

학습(training)은 예측을 정답과 비교해 오차를 줄이는 쪽으로 모든 W·b를 조금씩 밀어요 — 그 유명한 backpropagation 알고리즘입니다.

티켓 라우터도 같은 3층 구조예요: embedder가 입력+은닉 특징층 역할을, logistic-regression head가 출력층 역할을 합니다.

5B.4 · 파일 지도 & 역할 — 무엇이 어디에 사는가

스타터 finetune.zip은 딱 두 덩어리예요: 데이터(taxonomy·gold·few-shot·train)와 코드(공용 common.py + 스테이지 셋 + 채점기). 세 스테이지가 같은 데이터·같은 채점기를 공유하는 게 핵심입니다.

flowchart TB subgraph DATA["data/ — fixed inputs"] TAXO["taxonomy.json
6 teams + descriptions"] GOLD["test.jsonl
48 gold tickets (the yardstick)"] SEED["seed_examples.jsonl
21 few-shot examples"] TRAIN["synthetic_train.jsonl
~240 training tickets"] end subgraph CODE["code — one per method"] COMMON["common.py
shared helpers"] ST0["stage0_zeroshot.py"] ST1["stage1_fewshot.py"] ST2["stage2_finetune.py"] EVALF["evaluate.py
F1 + confusion"] end COMMON --> ST0 COMMON --> ST1 COMMON --> ST2 ST0 --> EVALF ST1 --> EVALF ST2 --> EVALF
파일역할핵심
taxonomy.json6개 팀 이름 + 설명프롬프트 텍스트로 그대로 들어감. 하우스룰은 일부러 빠져 있음
test.jsonl48개 gold 티켓세 방법을 재는 고정 척도 — 예시·학습에 절대 안 씀(누수 방지)
seed_examples.jsonl21개 라벨 예시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에서 실제 코드로 봅니다.

5B.5 · 세 가지 방법 — 프롬프트에서 학습된 모델까지

같은 태스크를 푸는 세 가지 방법. 방법(method)만 바뀌고, 테스트셋과 지표는 고정입니다.

flowchart TB subgraph L0["Stage 0 · Zero-shot"] ZERO["taxonomy + ticket → Claude
1 call / ticket"] end subgraph L1["Stage 1 · Few-shot"] FEW["+ 21 labeled examples → Claude
1 call / ticket"] end subgraph L2["Stage 2 · Custom-trained"] TUNE["embed + local classifier
0 calls / ticket"] end ZERO -->|"add examples"| FEW -->|"stop prompting, start training"| TUNE
progression
  • Stage 0 · Zero-shot — Claude가 팀 정의 + 티켓만 봄. 베이스라인. 안 적힌 하우스룰은 놓침. 1 Claude call / ticket
  • Stage 1 · Few-shot — 같은 프롬프트 + ~21개 라벨 예시로 하우스룰을 시연. 팀 경계가 실제로 어디 있는지 예시가 가르침. 1 Claude call / ticket
  • Stage 2 · Custom-trained — 라벨된 티켓으로 작은 로컬 분류기를 학습. 추론 시 Claude 호출 없음 — embed + 행렬곱. 0 Claude calls / ticket

여기서 custom training이 말이 되는 이유

when to fine-tune
  • ① 그냥 분류기다 — 우리는 라벨만 있으면 되지 언어 출력이 필요 없어요. 작은 모델이 자연스러운 선택.
  • ② 실패가 싸다 — 몇 %가 엉뚱한 팀으로 가도 사람이 다시 넘기면 끝. 치명적이지 않음.
  • ③ 데이터를 얻기 쉽다 — 며칠~몇 주 제대로 라우팅된 티켓이 곧 학습셋이고, 다시 넘겨진 티켓이 되먹임(feedback)돼 시간이 갈수록 저절로 좋아짐. 라벨을 사실상 공짜로 축적.

세 방법은 정확도 경쟁이 아니라 트레이드오프 공간이에요

custom-trained가 프롬프트 방법을 항상 이기는 건 아니에요(학습 데이터만큼만 잘함) — 볼 건 "누가 이기냐"가 아니라 어디서 이기고 지는가입니다. 축이 다르니까요:

Zero-shotFew-shotCustom-trained
셋업 비용0 (프롬프트만)낮음 (예시 몇 개)학습셋 + 학습 1회
티켓당 추론Claude 1회Claude 1회API 0회 · 밀리초
하우스룰 학습못 함 (정의만 봄)예시로 시연라벨로 학습
정확도 상한모델의 일반 상식+ 예시 몇 개= 학습 데이터 품질
새 규칙 반영프롬프트 수정예시 추가재라우팅이 자동 축적
💡 그래서 결정은 볼륨과 실패 비용이 가릅니다. 티켓이 가끔 오고 실수가 아프면 프롬프트가 안전하고, 티켓이 대량이고 실패가 싸면 custom-trained가 압도적으로 싸요 — 이 프로젝트가 evaluate.py로 그 숫자를 직접 보여줍니다.

5B.6 · 흐름 — stage 2 파이프라인을 함수 이름으로 ★

custom-trained 방법은 코드로 보면 다섯 함수의 직선이에요: 읽고 → 임베딩하고 → 학습하고 → 예측하고 → 채점. 그리고 점선 하나 — 사람이 다시 넘긴 티켓이 학습셋으로 되먹임되는 피드백 루프가 이 방법을 시간이 갈수록 좋아지게 만듭니다.

flowchart LR LOADJ["load_jsonl()"] --> EMB["embed() · common.py"] --> FIT["LogisticRegression().fit(X, y)"] --> PRED["predict()"] --> EVAL["evaluate.py · F1 + confusion"] EVAL -. "misrouted tickets" .-> REROUTE["human reroutes a ticket"] REROUTE -.-> TRAINSET["training set"] TRAINSET -.-> LOADJ

가운데 직선(load → embed → fit → predict → eval)은 한 번 도는 학습 파이프라인이에요. 진짜 가치는 점선 피드백에 있어요: 잘못 라우팅된 티켓을 사람이 올바른 팀으로 다시 넘기면, 그게 곧 새 라벨이 돼서 학습셋으로 흘러 들어갑니다 — 라벨을 사실상 공짜로 축적하는 되먹임 루프예요.

🔁 점선 위를 도는 점이 바로 그 피드백 엔진이에요. prompt 방법(0·1)엔 이 루프가 없어요 — 예시를 손으로 늘리지 않는 한 저절로 좋아지지 않습니다. custom-trained는 운영하는 것만으로 학습 데이터가 쌓여요.

5B.7 · 내부 동작 — frozen embedder + trained head

두 단계뿐이에요: 라벨된 데이터를 얻고, 작은 모델을 학습. 학습이 건드리는 건 위에 얹은 작은 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 호출 없음
flowchart LR TICKET["ticket text
'My card was charged twice'"] --> EMBED["Embedding model · MiniLM
FROZEN — weights never change"] EMBED -->|"384-dim vector"| HEAD["Logistic-regression head
TRAINED — the only weights that learn"] HEAD --> TEAM(["team: Billing"]) class HEAD core;

다이어그램에서 EMBED는 채도가 빠진 채 가만히 있고 HEAD만 숨쉬듯 미세하게 움직여요 — 이게 frozen vs trained의 시각적 대비예요. 학습이 건드리는 건 작은 head뿐이라 gradient가 위의 분류기만 갱신합니다 — 빠르고, 싸고, 과적합도 잘 안 나요.

"임베딩"이 실제로 하는 일 — 텍스트가 숫자 벡터로

embed()는 티켓 문장을 384개의 숫자(벡터)로 바꿔요. 비슷한 뜻의 티켓은 비슷한 벡터가 되고, head는 그 숫자들 위에서 팀 경계를 긋습니다. 아래는 진짜 학습 티켓 하나가 벡터로 접히는 걸 축약한 모습이에요(8칸으로 줄임):

🤔 왜 head를 학습까지 해? 그냥 코사인 최근접이 아니라, 384차원 중 어느 차원이 각 팀에 중요한지를 배우기 때문 — 클래스별 가중치를 학습해요. 모든 차원을 똑같이 취급하는 단순 최근접보다 하우스룰 경계를 더 잘 잡습니다.
⚠️ 동봉된 학습셋은 데모용이에요(프로덕션 로그 없이도 돌게 하려고 Claude가 생성). 실무에선 프로덕션의 진짜 라벨 예시로 학습합니다 — 진짜 분포를 담고 모델 편향이 없으니까요.

5B.8 · 코드로 보기 — 강제 tool + embed→fit

두 조각이면 프로젝트의 두 세계가 다 보여요: prompt 스테이지가 Claude를 강제 tool로 부르는 common.py, custom 스테이지가 embed→fit→predict하는 stage2_finetune.py. 각 코드 조각 위의 미니 다이어그램이 흐름 지도예요.

① 강제 tool 사용 — 왜 라벨이 항상 유효한가 · common.py

flowchart LR SCHEMA["route_tool()
enum = team names"] --> FORCE["tool_choice = route_ticket
(forced)"] --> VALID["block.input['team']
always a valid team"]
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의 차이는 오직 정확도일 뿐, 절대 깨진 출력이 아닙니다 — 그래서 세 방법을 같은 척도로 공정하게 비교할 수 있어요.

② embed → fit → predict — 학습과 추론 · stage2_finetune.py

flowchart LR LOAD2["load_jsonl()"] --> EMB2["embed()
384-dim, local"] --> FIT2["fit(X, y)"] --> DUMP["joblib.dump()"] DUMP --> PRED2["predict()
embed + matmul"]
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 호출이 한 번도 없습니다: 임베딩 한 번 + 행렬곱 한 번이면 팀이 나와요.

5B.9 · Build & run locally

스테이지를 순서대로 돌리면 세 방법의 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)
in the box
  • taxonomy.json · test.jsonl (48 gold)
  • seed_examples.jsonl (21 few-shot)
  • synthetic_train.jsonl (~240 train)
  • common.py — 라우팅·임베딩 공통
  • stage0 · stage1 · stage2
  • evaluate.py — F1 + confusion matrix

ANTHROPIC_API_KEY는 공유 .env를 그대로 씁니다(stage2 추론엔 필요 없지만 stage0/1엔 필요).

스코어보드 읽는 법 — evaluate.py가 찍는 것

각 스테이지는 자기 리포트를 찍고, evaluate.py는 캐시된 예측을 읽어(추가 Claude 호출 없음) 세 방법을 progression 표 하나로 모아요:

flowchart LR PREDS["cached predictions
(stage 0 · 1 · 2)"] --> CMP["compare vs 48 gold labels"] CMP --> CONF["confusion matrix
(which team confused with which)"] CMP --> MF1["accuracy + macro-F1
per stage"] MF1 --> TABLE(["one progression table"]) CONF --> TABLE
================  PROGRESSION  ================
stage                              accuracy  macro-F1
Stage 0 — zero-shot prompting          ...      ...
Stage 1 — few-shot prompting           ...      ...
Stage 2 — distilled classifier         ...      ...
⚠️ 같은 48개 gold로 세 방법 전부를 채점해요 — test 셋은 예시·학습에 절대 안 써요(누수 방지). 그래서 숫자 차이가 순수하게 방법 차이입니다.

5B.10 · The key idea — 큰 모델로 데이터를, 작은 모델로 배포를

"라벨만 필요하고 실패가 싼" 태스크라면, 프롬프팅을 넘어 작은 로컬 분류기가 더 싸고 빠를 수 있어요 — 값은 얼어붙은 embedder 위 작은 head 하나에 담깁니다.

custom-trained 라우터에서 학습 중 실제로 업데이트되는 가중치는 어디일까요?

embedder(MiniLM)는 고정된 특징 추출기로 그대로 두고, gradient는 위의 작은 분류기 head만 갱신해요. 그래서 라벨 몇백 개로도 빠르게 학습되고 과적합이 덜합니다 — "무거운 표현은 재사용, 가벼운 결정층만 내 데이터로 학습"이 이 패턴의 본질이에요.

tool_choiceroute_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 분류기는 학습셋에 있는 경계만 배울 수 있어요 — 데이터 품질이 성능의 천장입니다. 합성 데이터는 자체 완결성을 위해 편하지만, 생성 모델의 편향과 진짜 트래픽과 다른 분포를 물려받을 수 있어요. 그래서 실무에선 실제로 해결된 티켓(사람이 라우팅한 라벨)을 학습셋으로 쓰는 게 낫습니다. (양보다 분포·편향 문제예요.)