포스트

2026-05-19 강의 정리

[프로젝트4] 안내 + 언어모델 파인튜닝 1

2026-05-19 강의 정리

목차

  1. 문제와 접근
  2. FTState와 작업 폴더
  3. 파인튜닝 태스크 정의
  4. seed_pairs와 JSONL 포맷
  5. 데이터 추가와 증강 프롬프트
  6. parse_pairs
  7. 샘플 검증 함수
  8. validate와 중복 검사
  9. clean.jsonl 생성
  10. 핵심 개념 총정리
  11. 자주 나오는 실수/주의사항
  12. 학습 데이터의 품질 기준부터 고정하기

1. 문제와 접근

문체 변환용 파인튜닝에서는 학습 코드보다 입력과 정답 문장의 기준을 먼저 맞추는 일이 중요하다. 정중하게 바꾸는 과정에서 원래 의도나 조건이 달라지지 않도록 데이터 규칙을 세운다. 증강, 파싱, 검증을 분리해 학습에 넣을 샘플의 출처와 제외 이유를 추적한다.

1
2
3
4
5
6
7
8
9
seed pair 작성
  ↓
JSONL 변환
  ↓
LLM으로 데이터 증강
  ↓
검증
  ↓
clean.jsonl 생성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import os
import json
import re
from pathlib import Path
from dataclasses import dataclass, field
from dotenv import load_dotenv

load_dotenv()

from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage
from openai import OpenAI

oai = OpenAI()
llm = ChatOpenAI(model="gpt-4o-mini")

2. FTState와 작업 폴더

파인튜닝 진행 상태를 dataclass로 관리한다.

1
2
WORK_DIR = Path("./260509_ft")
WORK_DIR.mkdir(exist_ok=True)
1
2
3
4
5
6
7
8
9
10
11
12
@dataclass
class FTState:
    train_path: Path | None = None
    val_path: Path | None = None
    train_file_id: str | None = None
    val_file_id: str | None = None
    job_id: str | None = None
    ft_model: str | None = None
    notes: list = field(default_factory=list)


state = FTState()

3. 파인튜닝 태스크 정의

입력과 출력 스타일을 명확히 적어 둔다.

1
2
3
4
5
6
7
8
9
SYSTEM_MSG = "당신은 무례한 문장을 정중한 이메일 문장으로 바꿔주는 어시스턴트입니다."

TASK_SPEC = {
    "name": "polite_email_rewriter",
    "input": "무례하거나 짧은 한국어 한 줄",
    "output": "정중한 한국어 이메일 문장 1~2 문장",
    "min_examples": 30,
    "system": SYSTEM_MSG,
}

4. seed_pairs와 JSONL 포맷

OpenAI fine-tuning 데이터는 chat messages 형식의 JSONL로 만든다.

1
2
3
4
5
6
7
8
seed_pairs = [
    ("회의 내일로 미뤄.", "회의를 내일로 변경 가능할지 여쭙고 싶습니다."),
    ("문서 빨리 줘.", "문서 전달 가능 시점을 알려주실 수 있을까요?"),
    ("이거 다시 해.", "이 부분은 한 번 더 수정 부탁드려도 될까요?"),
    ("일정 잡아.", "편하신 일정을 알려주시면 회의를 잡아 두겠습니다."),
    ("왜 답장 안 해.", "혹시 이전 메일을 확인하셨을지 여쭙고 싶습니다."),
    ("이건 틀렸어.", "이 부분은 다시 한 번 검토가 필요해 보입니다."),
]
1
2
3
4
5
6
7
8
def to_jsonl_line(system, user, assistant) -> str:
    return json.dumps({
        "messages": [
            {"role": "system", "content": system},
            {"role": "user", "content": user},
            {"role": "assistant", "content": assistant},
        ]
    }, ensure_ascii=False)
1
2
3
4
seed_path = WORK_DIR / "seed.jsonl"
with open(seed_path, "w", encoding="utf-8") as f:
    for rude, polite in seed_pairs:
        f.write(to_jsonl_line(SYSTEM_MSG, rude, polite) + "\n")

5. 데이터 추가와 증강 프롬프트

생성한 데이터가 많아졌다는 사실과 표현의 범위가 넓어졌다는 판단은 구분한다. 같은 원문에서 파생한 문장이 과도하게 반복되지 않는지 보고, 요청·거절·사과처럼 변환 규칙이 달라지는 상황을 나누어 점검한다.

기본 seed에 수동 예시를 더하고, LLM으로 같은 스타일의 예시를 생성한다.

1
2
3
4
5
6
extras = [
    ("자료 빠뜨렸어.", "자료 한 가지가 누락된 것 같습니다. 확인 부탁드립니다."),
    ("일정 다시 봐.", "일정을 다시 한 번 확인해 주실 수 있을까요?"),
    ("이름 잘못 적었어.", "이름 표기를 한 번 더 확인해 주시면 감사하겠습니다."),
    ("결재 빨리 해.", "결재 처리 가능 시점을 알려주실 수 있을까요?"),
]
1
2
3
4
5
6
7
8
9
10
11
AUG_SYS = "당신은 학습용 데이터 페어를 생성하는 전문가입니다. 출력은 반드시 JSON 배열로"


def make_augment_prompt(pairs, n=8):
    sample = "\n".join(f'- "{r}" -> "{p}"' for r, p in pairs[:4])
    return (
        f"다음은 무례한 문장 -> 정중한 이메일 문장 페어입니다.\n"
        f"같은 스타일, 길이 분포로 새 페어 {n} 쌍을 한국어로 만드세요.\n"
        f'출력 형식: [{{"rude":"...","polite":"..."}}, ...]\n\n'
        f"예시:\n{sample}"
    )

6. parse_pairs

LLM 출력에서 JSON 배열만 뽑아 페어 목록으로 변환한다.

1
2
3
4
5
6
7
8
def parse_pairs(text):
    m = re.search(r"\[.*\]", text, re.DOTALL)
    if not m:
        return []
    try:
        return [(it["rude"], it["polite"]) for it in json.loads(m.group(0))]
    except Exception:
        return []
1
2
3
4
5
6
resp = llm.invoke([
    SystemMessage(content=AUG_SYS),
    HumanMessage(content=make_augment_prompt(seed_pairs, n=10)),
]).content

augmented = parse_pairs(resp)

7. 샘플 검증 함수

구조, 역할, 응답 존재, 토큰 수를 검사한다.

1
2
3
4
import tiktoken

enc = tiktoken.get_encoding("cl100k_base")
MAX_TOKENS_PER_SAMPLE = 4096
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def check_sample(row) -> list[str]:
    errs = []
    msgs = row.get("messages", [])
    roles = {m.get("role") for m in msgs}

    if not {"system", "user", "assistant"} <= roles:
        errs.append("필수 role 누락")
    if not any(m.get("role") == "assistant" and m.get("content") for m in msgs):
        errs.append("assistant 응답 누락")

    total = sum(len(enc.encode(m.get("content", ""))) for m in msgs)
    if total > MAX_TOKENS_PER_SAMPLE:
        errs.append("토큰 개수 초과")

    return errs

8. validate와 중복 검사

문자열이 완전히 같은 중복을 제거해도 의미가 거의 같은 샘플은 남을 수 있다. 원본과 증강본을 같은 그룹으로 관리하면 이후 train/validation 분리 시 비슷한 예제가 양쪽에 섞이는 상황을 확인하기 쉽다.

JSON 파싱, 구조 검사, user 중복을 한 번에 확인한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def validate(path):
    seen, bad, rows = set(), [], []

    for i, line in enumerate(open(path, encoding="utf-8")):
        try:
            row = json.loads(line)
        except Exception:
            bad.append((i, "json 파싱 실패"))
            continue

        errs = check_sample(row)
        user_msg = next((m["content"] for m in row.get("messages", []) if m["role"] == "user"), "")

        if user_msg in seen:
            errs.append("user 중복")
        seen.add(user_msg)

        (bad if errs else rows).append((i, ", ".join(errs)) if errs else row)

    return rows, bad

9. clean.jsonl 생성

검증을 통과한 row만 새 파일로 저장한다.

1
2
3
4
5
6
7
def pairs_to_jsonl(pairs: list[tuple[str, str]], system: str, out_path: Path) -> int:
    n = 0
    with open(out_path, "w", encoding="utf-8") as f:
        for rude, polite in pairs:
            f.write(to_jsonl_line(system, rude, polite) + "\n")
            n += 1
    return n
1
2
3
4
5
6
7
8
9
def filter_clean(in_path: Path, out_path: Path) -> dict:
    ok_rows, bad_rows = validate(in_path)
    with open(out_path, "w", encoding="utf-8") as f:
        for row in ok_rows:
            f.write(json.dumps(row, ensure_ascii=False) + "\n")

    total = len(ok_rows) + len(bad_rows)
    pct = (len(ok_rows) / total * 100) if total else 0.0
    return {"ok": len(ok_rows), "bad": len(bad_rows), "pass_rate": pct}

10. 핵심 개념 총정리

단계역할
TASK_SPEC파인튜닝 목표와 출력 스타일 정의
seed_pairs사람이 만든 기본 학습 예시
to_jsonl_linechat fine-tuning JSONL 형식 생성
make_augment_promptLLM 데이터 증강 프롬프트
parse_pairsLLM 출력 JSON 배열 파싱
check_samplerow 단위 구조/토큰 검사
validate파일 단위 검증과 중복 검사
filter_clean통과 샘플만 clean.jsonl로 저장

11. 자주 나오는 실수/주의사항

실수 1: extras를 만들고 seed_pairs를 다시 append

1
2
3
4
# 올바른 코드
with open(seed_path, "a", encoding="utf-8") as f:
    for rude, polite in extras:
        f.write(to_jsonl_line(SYSTEM_MSG, rude, polite) + "\n")

실수 2: LLM 증강 출력이 순수 JSON이 아님

1
m = re.search(r"\[.*\]", text, re.DOTALL)

코드블록이나 설명이 섞일 수 있으므로 JSON 배열만 잘라낸다.

실수 3: user 중복 방치

1
같은 user 문장이 반복되면 데이터 다양성이 낮아지고 검증에서 제거해야 한다.

실수 4: assistant 응답 없는 샘플

1
2
if not any(m.get("role") == "assistant" and m.get("content") for m in msgs):
    errs.append("assistant 응답 누락")

12. 학습 데이터의 품질 기준부터 고정하기

정중한 문장인지와 원문을 보존했는지를 별도로 검토한다. 날짜·금액·요청 조건이 바뀐 샘플은 말투가 자연스러워도 같은 작업의 정답으로 보기 어렵다.

데이터를 수정할 때는 추가 수량뿐 아니라 제외 사유와 유형별 분포를 함께 남긴다. 학습 결과가 달라졌을 때 모델 설정과 데이터 변경의 영향을 구분하려면 정제 규칙과 데이터 버전도 결과의 일부로 관리해야 한다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

인기 태그