합성 데이터로 LLM을 “내 도메인에 맞게” 재교육하는 법: SDG→필터링→SFT 파이프라인 심층 가이드
LLM 파인튜닝에서 가장 흔한 병목은 “좋은 학습 데이터가 없다”입니다. 특히 사내 도메인(고객지원, 결제/정산, 보안관제, 제조/물류 등)은 (1) 개인정보/저작권/계약 이슈로 원문 로그를 그대로 쓰기 어렵고, (2) 실제로는 케이스가 충분히 다양하지 않으며, (3) 라벨링 비용이 큽…
들어가며
LLM 파인튜닝에서 가장 흔한 병목은 “좋은 학습 데이터가 없다”입니다. 특히 사내 도메인(고객지원, 결제/정산, 보안관제, 제조/물류 등)은 (1) 개인정보/저작권/계약 이슈로 원문 로그를 그대로 쓰기 어렵고, (2) 실제로는 케이스가 충분히 다양하지 않으며, (3) 라벨링 비용이 큽니다. 이때 synthetic data generation(SDG)—즉 “교사(teacher) LLM이 데이터(질문/답/추론/툴콜)를 만들어 주고, 이를 선별해 학생(student) 모델을 SFT/PEFT로 학습”하는 방식이 현실적인 해법이 됩니다. NVIDIA Nemotron 계열은 “생성 모델 + Reward 모델로 스코어링/필터링”을 한 파이프라인으로 묶어 공개적으로 문서화해 둔 대표 사례입니다.1
다만 SDG는 만능이 아닙니다.
- 쓰면 좋은 때
- 도메인 Q/A가 반복적이고, 규칙/정책/프로시저가 존재(예: 운영 Runbook, 보안 대응 Playbook)
- “정답 형태”가 비교적 명확(예: SQL 생성, 티켓 분류/요약, 표준 응답 템플릿)
- 개인정보/내부정보 때문에 원문 로그 사용이 제한적이지만, 비식별화된 스키마/패턴은 활용 가능
- 쓰면 안 되는 때
- 정답이 본질적으로 주관적(브랜딩 카피, 창작)인데도 “정답”처럼 학습시키려는 경우
- teacher가 사실을 모르는 영역(최신 지식, 사내 시스템의 실제 동작)을 RAG/시뮬레이터 없이 상상으로 때우는 경우
- 합성 데이터가 실제 트래픽 분포를 대체해 버려 모델이 “현실 감각”을 잃는 경우(템플릿화/모드 붕괴)
핵심은 합성 데이터는 ‘현실을 대체’가 아니라 ‘현실을 증폭/보완’하는 도구라는 점입니다.
🔧 핵심 개념
1) LLM 합성 데이터 생성(SDG)의 정의
SDG는 보통 아래 산출물을 만듭니다.
- SFT용 (instruction → response): OpenAI chat format 같은
messages[]구조의 대화 쌍2 - 선호 데이터(Preference / Ranking): 같은 질문에 대한 여러 답을 만들어 pairwise/score 라벨을 생성(RLAIF/Reward modeling)1
- 에이전트/툴 실행 트레이스: tool-call, terminal run 등 “행동” 학습(실행/검증 루프 포함)3
여기서 2026년 흐름의 포인트는 “생성(teacher)”과 “평가/필터링(critic/reward)”을 분리해 품질을 끌어올리는 패턴입니다. Nemotron-4-340B는 Instruct로 생성하고 Reward 모델로 HelpSteer2 속성(도움됨/정확성/일관성/복잡도/장황함 등)을 점수화해 필터링하는 구성을 대표적으로 제시합니다.1
2) 내부 작동 방식(구조/흐름)
실무에서 잘 먹히는 SDG→SFT 흐름을 “아티팩트 라인리지” 관점으로 쪼개면 아래 6단계가 안정적입니다(문서/리포트에서 공통적으로 반복되는 형태).
- Seed(현실 기반 시드) 준비
- 운영 로그/티켓/FAQ/스키마에서 개인정보 제거한 “프롬프트 골격”만 추출
- 또는 taxonomy(업무 분류 체계) 정의 후 coverage 기준을 만든다
- Prompt synthesis(질문 생성)
- teacher에게 “도메인 상황/제약/엣지케이스”를 섞어 질문을 만들게 하되,
- 실제 분포를 망치지 않도록 quota(카테고리별 생성량)를 둔다
- Response synthesis(답변 생성)
- policy/규칙/시스템 제약을 prompt에 포함(예: 환불 정책, 에러코드 매핑)
- Critique/Scoring(평가)
- Reward/critic 모델로 점수화(HelpSteer류 다차원 점수, 또는 rule-based validator 혼합)1
- Filtering & De-dup(정제)
- 점수 threshold + 중복 제거 + “너무 쉬운 데이터” 컷
- SFT/PEFT(학습)
- LoRA 같은 PEFT로 빠르게 실험, 필요 시 full fine-tune
- 학습 후 offline eval + 실제 트래픽 shadow 테스트
NVIDIA 쪽 문서들은 이 파이프라인을 “재현 가능하게 코드로 정의”하고(NeMo Data Designer 등) “distillation-ready / license-compliant”를 강조합니다. 즉, 단순히 많이 생성하는 게 아니라 추적 가능성(reproducibility) + 라이선스/컴플라이언스를 파이프라인 요구사항으로 올린 게 2026년의 실무적 변화입니다.4
3) 다른 접근과의 차이점
- RAG만으로 해결: 지식 주입은 되지만 “말투/형식/절차”를 모델 파라미터에 새기긴 어렵고, latency/cost가 늘 수 있음.
- 사람 라벨링만: 품질은 좋지만 커버리지/비용이 병목.
- SDG: 커버리지/비용에서 강력하지만 “teacher 편향”과 “환각을 데이터로 굳힘” 리스크가 큼. 그래서 reward/validator 기반 필터링이 사실상 필수.
💻 실전 코드
아래 예제는 “고객지원 티켓 답변” 도메인을 가정합니다. 목표는:
- 사내 정책/제약을 반영한 SFT 데이터(OpenAI chat format)를 합성 생성
- 간단한 rule-based validator + LLM-as-a-judge(선택)로 품질 필터링
- 최종적으로
train.jsonl을 만들어 LoRA SFT로 넘길 수 있게 준비
0) 의존성/환경
1
2
3
4
python -m venv .venv
source .venv/bin/activate
pip install openai pydantic tqdm rapidfuzz jsonlines
export OPENAI_API_KEY="..."
1) 현실적인 시드(비식별 템플릿) + 생성
- 입력: 정책 요약 + 티켓 카테고리 + 고객 상황 변수
- 출력:
messages[]형태의 SFT 샘플
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# generate_sft_dataset.py
import os, json, random
from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field
from tqdm import tqdm
import jsonlines
from rapidfuzz import fuzz
from openai import OpenAI
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
POLICY = """
[Refund Policy]
- Digital goods: refundable within 7 days if not downloaded.
- Subscription: prorated refund within 14 days of first purchase only.
- Chargeback: ask for transaction id; do not request full card number.
- Always redact PII. Never ask for password or OTP.
"""
CATEGORIES = [
("refund_subscription", "구독 환불 요청"),
("billing_failed", "결제 실패/재시도 안내"),
("account_compromised", "계정 탈취 의심"),
("invoice_request", "세금계산서/인보이스 요청"),
]
class SFTSample(BaseModel):
messages: List[Dict[str, str]]
meta: Dict[str, Any] = Field(default_factory=dict)
def llm_generate(prompt: str, model: str = "gpt-4.1-mini") -> str:
resp = client.chat.completions.create(
model=model,
temperature=0.7,
messages=[
{"role": "system", "content": "You are a senior customer support specialist. Follow policy strictly."},
{"role": "user", "content": prompt},
],
)
return resp.choices[0].message.content
def build_prompt(cat_id: str, cat_desc: str) -> str:
# 현실적인 변수를 섞되, 분포가 한쪽으로 쏠리지 않도록 제한
locale = random.choice(["ko-KR", "en-US"])
channel = random.choice(["email", "in-app", "chat"])
days = random.choice([1, 3, 5, 8, 13, 20])
downloaded = random.choice([True, False])
tone = random.choice(["calm", "angry", "confused"])
return f"""
You will generate ONE training example for SFT in JSON.
Domain: SaaS customer support
Category: {cat_id} ({cat_desc})
Locale: {locale}
Channel: {channel}
Customer tone: {tone}
Constraints:
- Must comply with the policy below.
- The assistant response must be actionable, include next steps, and avoid collecting sensitive data.
- Include realistic details (timestamps, transaction id placeholders like TXN-XXXX) but DO NOT include real PII.
Policy:
{POLICY}
Output JSON schema:
{{
"messages": [
{{"role":"system","content":"(support agent behavior constraints)"}},
{{"role":"user","content":"(customer ticket message)"}},
{{"role":"assistant","content":"(agent reply)"}}
],
"meta": {{
"category":"{cat_id}",
"days_since_purchase": {days},
"downloaded": {str(downloaded).lower()},
"locale":"{locale}",
"channel":"{channel}"
}}
}}
Generate only JSON.
"""
def parse_json_str(s: str) -> Optional[dict]:
try:
return json.loads(s)
except Exception:
# 종종 모델이 ```json```을 붙이므로 최소한의 복구
s2 = s.strip().strip("```").replace("json\n", "")
try:
return json.loads(s2)
except Exception:
return None
def rule_validate(sample: dict) -> List[str]:
errs = []
msgs = sample.get("messages", [])
if len(msgs) != 3:
errs.append("messages_len_not_3")
return errs
user = msgs[1].get("content", "")
assistant = msgs[2].get("content", "")
# 금지: 비밀번호/OTP 요청
banned = ["password", "OTP", "one-time", "비밀번호", "인증번호", "otp"]
if any(b.lower() in assistant.lower() for b in banned):
errs.append("asks_sensitive_auth")
# 환불 정책과 정합성(대충이지만 실무에서 유용한 1차 방어막)
meta = sample.get("meta", {})
days = meta.get("days_since_purchase", 999)
downloaded = meta.get("downloaded", False)
cat = meta.get("category", "")
if cat == "refund_subscription" and days > 14:
# 14일 초과면 "예외 검토" 정도로 유도해야지 '무조건 환불'은 위험
if "refund" in assistant.lower() and "cannot" not in assistant.lower() and "review" not in assistant.lower():
errs.append("refund_policy_violation_subscription")
# 과도한 템플릿 중복 감지용 힌트(완벽하진 않음)
if fuzz.partial_ratio(assistant[:120], "Thank you for contacting us") > 95:
errs.append("too_templatey")
# 길이 체크(너무 짧으면 실무 대응력 낮음)
if len(assistant) < 500:
errs.append("too_short")
return errs
def main(n: int = 200, out_path: str = "train.jsonl"):
kept = 0
with jsonlines.open(out_path, mode="w") as writer:
for _ in tqdm(range(n)):
cat_id, cat_desc = random.choice(CATEGORIES)
prompt = build_prompt(cat_id, cat_desc)
raw = llm_generate(prompt)
obj = parse_json_str(raw)
if not obj:
continue
errs = rule_validate(obj)
if errs:
continue
writer.write(obj)
kept += 1
print(f"done. kept={kept}/{n}. output={out_path}")
if __name__ == "__main__":
main()
예상 출력
1
2
python generate_sft_dataset.py
done. kept=121/200. output=train.jsonl
이 결과물은 Nemotron 문서/툴들이 흔히 채택하는 OpenAI chat format과 동일한 계열이라, 이후 파인튜닝 파이프라인으로 연결하기 쉽습니다.2
2) (확장) Reward/LLM Judge로 2차 필터링
rule-based는 “명백한 금지”는 잡지만, 유용성/정확성/일관성은 놓칩니다. Nemotron-4-340B Reward가 HelpSteer2 축으로 점수화해 필터링하는 접근이 여기에 해당합니다.1
아래는 벤더 중립적으로 “judge 모델”에 JSON 평가를 요청해 threshold로 거르는 예시(비용이 들므로 샘플링해서 쓰는 게 보통입니다).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# judge_filter.py
import os, json
import jsonlines
from openai import OpenAI
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
JUDGE_PROMPT = """
You are grading a customer-support assistant reply.
Score each dimension 0..4 (integers).
Return JSON only.
Dimensions:
- helpfulness
- correctness (policy compliance + logical consistency)
- coherence
- completeness (clear next steps)
- safety (no sensitive data request)
Output:
{"helpfulness":0,"correctness":0,"coherence":0,"completeness":0,"safety":0,"notes":"..."}
"""
def judge(messages, model="gpt-4.1-mini"):
resp = client.chat.completions.create(
model=model,
temperature=0,
messages=[
{"role":"system","content":JUDGE_PROMPT},
{"role":"user","content":json.dumps(messages, ensure_ascii=False)},
],
)
return json.loads(resp.choices[0].message.content)
def main(inp="train.jsonl", out="train.filtered.jsonl", min_avg=3.2):
with jsonlines.open(inp) as r, jsonlines.open(out, "w") as w:
for obj in r:
scores = judge(obj["messages"])
avg = sum(scores[k] for k in ["helpfulness","correctness","coherence","completeness","safety"]) / 5
if avg >= min_avg and scores["safety"] >= 4 and scores["correctness"] >= 3:
obj["judge"] = scores
w.write(obj)
if __name__ == "__main__":
main()
⚡ 실전 팁 & 함정
Best Practice (실무에서 체감 큰 것 3가지)
1) Seed를 “현실 분포”로 고정하고, synthetic은 “커버리지 확장”에만 쓰기
가장 흔한 실패는 synthetic이 학습셋의 과반을 먹으면서 모델이 “현실 티켓 말투/잡음”을 잃는 겁니다. 운영 로그를 그대로 못 쓰면, 최소한 비식별 템플릿(문장 길이/오타/불만 패턴) 정도는 seed로 잡아두세요.
2) 생성 모델과 평가 모델을 분리(teacher ≠ critic)
Nemotron의 “Instruct로 생성 + Reward로 필터링” 구조가 실전적으로 강한 이유는, 같은 모델로 self-critique를 하면 편향을 강화하기 쉽기 때문입니다.1
3) 라이선스/컴플라이언스를 파이프라인 요구사항으로 넣기
2026년 들어 distillation/합성 데이터가 IP/정책 논쟁의 한가운데로 들어왔고, “합성 데이터 파이프라인을 license-safe로 만들자”는 가이드가 명시적으로 등장했습니다. 데이터 출처, 생성 프롬프트, 사용한 모델/버전, 필터링 규칙을 아티팩트로 남기세요.4
흔한 함정/안티패턴
- “10만 건 생성”이 목표가 되는 것: 양을 늘릴수록 중복/템플릿화가 급증합니다. reward/validator 없이 증식하면 성능이 오히려 떨어질 수 있습니다.
- 정답 검증 없는 사실 생성: policy/스키마처럼 검증 가능한 영역은 rule 기반 검증을 섞고, 외부 사실이 필요한 건 RAG로 ground 시키세요.
- train/test leakage: FAQ 문장을 그대로 paraphrase해서 학습시키면 평가가 부풀려집니다(특히 내부 벤치마크).
비용/성능/안정성 트레이드오프
- 비용: (생성 LLM 호출 + judge 호출 + 중복 제거/검증)로 생각보다 큽니다. 실무에선 “전량 judge”보다 샘플링 judge + rule 기반 전량 필터 조합이 흔합니다.
- 성능: SFT는 빠르게 스타일/절차를 바꾸지만, hallucination 억제는 SFT만으로 한계가 있어 reward/선호학습 쪽이 필요할 수 있습니다.
- 안정성: agentic/terminal-use 트레이스처럼 실행 루프를 넣으면 품질은 올라가지만, 인프라(샌드박스/리소스) 복잡도가 크게 증가합니다. Nemotron 쪽에서도 Dockerized execution loop 같은 접근을 명시합니다.3
🚀 마무리
정리하면, 2026년 9월 기준 “LLM 합성 데이터로 파인튜닝 데이터 구축”의 실전 정답은 SDG 자체가 아니라 ‘파이프라인’입니다.
- 도입 가치가 큰 팀: 도메인 정책/절차가 있고, 데이터 반출 제약이 있으며, SLM(작은 모델)로 latency/cost를 줄이고 싶은 팀
- 보류가 나은 팀: 사실 검증/품질 게이트 없이 “그냥 생성해서 학습”하려는 팀(장기적으로 품질 부채가 큼)
다음 학습/실험 추천: