LLM 활용과 튜닝 — 프롬프트·RAG·파인튜닝 중 무엇을 먼저

LLM 애플리케이션이 기대만큼 안 나올 때 손댈 수 있는 곳은 세 군데입니다. 프롬프트, RAG, 파인튜닝. 그런데 이 셋은 서로 다른 문제를 고칩니다. 잘못 고르면 시간과 돈을 쓰고도 아무것도 안 좋아집니다.


1. 세 가지는 무엇을 고치는가

수단 고치는 것 못 고치는 것
프롬프트 형식, 어투, 작업 이해, 단계별 추론 모델이 모르는 사실
RAG 모델이 모르는 사실 (사내 데이터, 최신 정보) 형식·어투의 일관성, 지연·비용
파인튜닝 형식·어투·스타일의 일관성, 지연·비용, 암묵적 규칙 모델이 모르는 사실

가장 중요한 구분은 이것입니다.

모르는 사실은 RAG로, 못 하는 방식은 파인튜닝으로.

파인튜닝으로 지식을 주입하려는 시도가 실무에서 가장 흔한 실수입니다. 파인튜닝은 “어떻게 말할지”를 가르치지, “무엇이 사실인지”를 안정적으로 심지 못합니다. 사내 규정 1,000건을 파인튜닝으로 외우게 하면, 모델은 규정 비슷한 말투로 그럴듯하게 지어냅니다. 규정이 바뀌면 다시 학습해야 하고, 어느 답이 어느 문서에서 왔는지도 못 밝힙니다.

진단 순서

답이 틀렸다
  │
  ├─ 사실이 틀렸나?               → RAG (검색 품질부터)
  ├─ 형식이 안 맞나?              → 프롬프트 (구조화 출력)
  ├─ 지시를 무시하나?             → 프롬프트 (명령 위치·명확성)
  ├─ 형식은 맞는데 어투가 들쭉날쭉 → 파인튜닝 검토
  └─ 느리거나 비싼가?             → 짧은 모델 + 파인튜닝 검토

항상 프롬프트 → RAG → 파인튜닝 순서입니다. 앞 단계로 해결되면 뒤는 필요 없고, 앞이 망가진 채로 뒤를 하면 효과가 안 납니다.


2. 프롬프트 — 실무에서 효과가 확실한 것들

프롬프트 기법은 많지만, 실제로 재현성 있게 효과가 나는 것은 몇 개뿐입니다.

역할과 작업을 분리해서 씁니다

나쁨:
  "학사 규정을 잘 아는 도우미로서 친절하게 답하되 정확해야 하고
   출처를 밝히고 모르면 모른다고 하고 존댓말을 쓰고..."

좋음:
  # 역할
  당신은 학사 안내 도우미입니다.

  # 규칙
  1. [문서]에 근거한 내용만 답합니다.
  2. 근거가 없으면 "자료에서 확인되지 않습니다"라고 답합니다.
  3. 각 문장 끝에 [1] 형태로 출처 번호를 표기합니다.

  # 출력 형식
  - 2~3문장의 요약
  - 필요 시 단계별 목록

번호 붙은 규칙 목록이 문단보다 훨씬 잘 지켜집니다. 나중에 규칙을 추가·수정하기도 쉽습니다.

지시를 앞과 뒤에 둡니다

긴 컨텍스트에서 모델은 가운데를 흘립니다(Lost in the Middle). 중요한 지시는 컨텍스트 앞에 두고, 길면 끝에서 한 번 더 반복합니다.

[시스템 지시]
[문서 1] [문서 2] ... [문서 10]
[질문]
[다시 한 번: 문서에 없는 내용은 답하지 마시오]   ← 이 한 줄이 효과가 있습니다

예시가 설명보다 강합니다

“간결하게 답하라”고 백 번 쓰는 것보다 간결한 예시 두 개가 낫습니다. 특히 출력 형식을 맞출 때는 예시가 압도적입니다.

질문: 휴학 신청 기간은?
답변: 매 학기 개시일로부터 30일 이내입니다. [1]

질문: 복수전공 신청 자격은?
답변: 2학기 이상 이수하고 평점 2.5 이상이어야 합니다. [2]

예시는 잘된 것만 넣지 말고, 경계 사례를 넣는 게 좋습니다. “자료에 없을 때 어떻게 답하는지”를 예시로 보여 주면 기권 동작이 훨씬 안정됩니다.

추론이 필요하면 먼저 생각하게 합니다

계산·비교·다단계 판단이 들어가면 결론부터 내지 못하게 합니다.

다음 순서로 답하시오.
1. 질문이 요구하는 조건을 나열한다
2. 각 조건에 해당하는 근거를 문서에서 찾는다
3. 종합해 결론을 낸다

다만 추론 특화 모델을 쓴다면 이런 지시가 오히려 방해가 됩니다. 모델이 이미 내부에서 하는 일을 밖에서 시키면 중복이고 토큰만 늘어납니다. 쓰는 모델의 성격에 맞춰야 합니다.


3. 구조화 출력 — 파싱 실패를 없애는 법

애플리케이션에 붙이려면 모델 출력이 반드시 파싱돼야 합니다. “JSON으로 답해 줘”라고 부탁하는 방식은 언젠가 깨집니다.

나쁨:  "JSON 으로 답해 줘"
       → 앞에 "물론입니다! 아래와 같습니다:" 를 붙이거나
       → ```json 코드펜스로 감싸거나
       → 마지막 쉼표를 남기거나

스키마를 강제합니다

OpenAI API 기준으로 구조화 출력(Structured Outputs) 은 JSON 스키마를 주면 그 스키마를 따르도록 디코딩 자체를 제약합니다. 모델이 잘 지켜 주길 바라는 게 아니라, 형식에서 벗어난 토큰을 생성할 수 없게 만듭니다.

from pydantic import BaseModel
from typing import Literal

class 답변(BaseModel):
    요약: str
    근거문서번호: list[int]
    확신도: Literal["high", "medium", "low"]
    답변불가: bool

response = client.responses.parse(
    model="...",
    input=[{"role": "user", "content": question}],
    text_format=답변,
)
result = response.output_parsed   # 파싱된 객체

이렇게 하면 파싱 실패 처리 코드가 통째로 사라집니다.

스키마 설계 요령

① 자유 텍스트 필드를 남발하지 않습니다. 열거형으로 가둘 수 있으면 가둡니다.

# 나쁨
category: str              # "학사", "학사관련", "학사 문의" ... 제각각

# 좋음
category: Literal["학사", "장학", "취업", "기타"]

② 모델이 생각할 자리를 만들어 줍니다. 결론 필드만 있으면 바로 결론부터 내야 합니다.

class 판정(BaseModel):
    검토_과정: str        # ← 먼저 생각하게
    결론: Literal["승인", "반려"]
    사유: str

필드 순서가 곧 생성 순서이므로, 추론 필드를 결론보다 앞에 두는 게 중요합니다.

③ “모름”을 표현할 수단을 줍니다. Optional 이나 답변불가 플래그가 없으면 모델은 억지로 채웁니다.


4. 파라미터 — 실제로 만지는 것들

파라미터 하는 일 실무
temperature 다음 토큰 확률 분포를 평탄화/뾰족하게 사실 기반 작업은 0~0.2. 창작은 0.7~1.0
top_p 누적 확률 상위 p까지만 후보로 temperature와 동시에 만지지 않습니다. 하나만
max_tokens 출력 상한 반드시 설정. 무한 반복 사고 방지 + 비용 상한
seed 재현성 평가·디버깅에 유용. 완전 보장은 아님
stop 중단 문자열 형식이 정해진 출력에서 뒤에 덧붙는 말 차단

temperature = 0 이라고 결과가 항상 같지는 않습니다. 부동소수점 연산 순서, 배치 구성, 백엔드 하드웨어에 따라 미세하게 달라질 수 있습니다. 결정성이 필요하면 캐싱으로 보장해야지 파라미터에 기대면 안 됩니다.


5. 비용 — 무엇이 지배하는가

토큰 구조부터

비용 = (입력 토큰 × 입력 단가) + (출력 토큰 × 출력 단가)

출력 단가가 입력 단가보다 훨씬 비쌉니다 (보통 3~5배). 그런데 실제 애플리케이션에서는 입력 토큰이 압도적으로 많습니다.

RAG 챗봇 한 번의 요청

  시스템 프롬프트     500 토큰
  도구 정의          1,500 토큰
  대화 이력          1,000 토큰
  검색된 문서 5개    3,000 토큰
  ───────────────────────────
  입력 합계          6,000 토큰
  출력                 200 토큰   ← 답변은 짧다

즉 비용 최적화의 핵심은 입력을 줄이는 것입니다. 답변을 짧게 시키는 것은 효과가 작습니다.

프롬프트 캐싱

같은 접두사(prefix)가 반복되면 캐시가 걸립니다. 시스템 프롬프트와 도구 정의는 매 요청 동일하므로 캐시 대상입니다.

[시스템 프롬프트] [도구 정의] | [대화 이력] [검색 문서] [질문]
└────── 고정, 캐시됨 ────────┘ └───── 매번 달라짐 ─────┘

캐시가 걸리려면 접두사가 바이트 단위로 같아야 합니다. 그래서 이런 걸 조심해야 합니다.

# 나쁨 — 매 요청 타임스탬프가 달라져 캐시가 깨짐
system = f"당신은 도우미입니다. 현재 시각: {now}"

# 좋음 — 변하는 것은 뒤로
system = "당신은 도우미입니다."
user   = f"[현재 시각: {now}]\n{question}"

변하는 값을 앞에 두면 그 뒤 전부가 캐시 미스입니다. 순서 하나로 비용이 몇 배 차이 납니다.

그 외 절감 수단

  • 배치 API — 즉시 응답이 필요 없는 작업(대량 분류, 임베딩 생성)은 배치로 보내면 크게 쌉니다
  • 모델 계층화 — 질문 분류·라우팅 같은 단순 작업은 작은 모델, 최종 생성만 큰 모델
  • 컨텍스트 예산 — 검색 문서를 top-10에서 top-5로 줄이면 입력이 절반입니다. 재순위화를 넣으면 적게 넣고도 품질을 유지할 수 있습니다

6. 파인튜닝 — 언제 값어치를 하는가

이럴 때만 검토합니다

조건 설명
프롬프트로 안 되는 게 확인됐다 예시를 20개 넣어도 형식이 흔들린다
말로 설명하기 어려운 규칙이 있다 사내 문서 톤, 특정 판단 기준 — 예시로만 전달 가능
프롬프트가 너무 길어졌다 긴 지시를 가중치로 내려 입력 토큰과 지연을 줄인다
작은 모델로 내려야 한다 큰 모델 수준의 특정 작업 성능을 작은 모델에서
학습 데이터가 있다 최소 수백 건. 품질이 양보다 중요

마지막 조건이 실질적인 관문입니다. “좋은 예시 500건”을 만드는 일이 파인튜닝 작업의 대부분입니다.

데이터 준비가 전부입니다

{"messages": [{"role": "system", "content": "..."}, {"role": "user", "content": "휴학 신청 기간은?"}, {"role": "assistant", "content": "매 학기 개시일로부터 30일 이내입니다. [학사운영규정 제21조]"}]}
{"messages": [{"role": "system", "content": "..."}, {"role": "user", "content": "총장님 취미가 뭐예요?"}, {"role": "assistant", "content": "학사 관련 문의만 도와드릴 수 있습니다."}]}

지켜야 할 것들입니다.

  • 추론 시점과 똑같은 형식으로 만듭니다. 학습 때 시스템 프롬프트를 넣었으면 추론 때도 넣어야 합니다
  • 거절·기권 사례를 반드시 포함합니다. 긍정 예시만 학습하면 모든 질문에 답하려 듭니다. 이게 환각의 직접 원인이 됩니다
  • 분포를 실제 트래픽에 맞춥니다. 어려운 질문만 모아 학습하면 쉬운 질문에서 이상해집니다
  • 평가셋을 미리 떼어 둡니다. 학습에 쓴 데이터로 평가하면 아무 의미가 없습니다

LoRA — 왜 전체를 안 건드리나

전체 파라미터를 학습하면 메모리도, 저장 공간도, 배포도 감당이 안 됩니다.

LoRA(Low-Rank Adaptation) 는 원래 가중치를 얼려 두고, 옆에 작은 행렬 두 개만 학습합니다.

원래 가중치 W (d × d, 고정)
        +
학습하는 것: A (d × r) × B (r × d)    ← r 은 8, 16 처럼 아주 작음

학습 파라미터 수: d×d  →  2×d×r     (r ≪ d 이므로 수백 배 감소)

어댑터 파일만 수 MB~수십 MB이므로, 작업별로 여러 개를 만들어 필요할 때 갈아 끼울 수 있습니다. QLoRA 는 여기에 기반 모델을 4비트로 양자화해 올려 단일 GPU에서도 돌게 만든 것입니다.

파인튜닝 후에도 RAG는 남습니다

이 둘은 대체 관계가 아닙니다.

파인튜닝 → 답변 형식, 어투, 출처 표기 방식, 기권 판단을 몸에 익힘
RAG     → 오늘 시점의 사실을 공급

둘 다 있어야 "일관된 형식으로 정확한 사실을" 답한다

7. 평가 — 없으면 개선도 없습니다

프롬프트를 바꿨는데 좋아졌는지 나빠졌는지 모르면, 그건 개선이 아니라 도박입니다.

최소한의 체계

1. 평가셋을 만든다 (실제 질문 100~300건 + 기대 답변)
2. 자동 지표를 정한다
3. 변경할 때마다 전체를 돌려 비교한다
4. 회귀가 나면 되돌린다

무엇을 재나

종류 방법 쓰는 곳
규칙 기반 정규식, JSON 파싱 성공률, 필수 필드 존재 형식 검증. 싸고 확실
정답 대조 분류 정확도, 추출 F1 정답이 명확한 작업
LLM-as-a-judge 다른 모델에게 채점시킴 자유 서술 답변
사람 평가 샘플링해서 직접 최종 확인. 비싸니 적게

LLM-as-a-judge 를 쓸 때 주의할 점이 있습니다.

  • 채점 기준을 구체적으로 줍니다. “좋은 답변인가” 대신 “문서에 없는 주장이 있는가(예/아니오)”
  • 점수보다 이진 판정이 안정적입니다. 1~5점 척도는 모델마다 편차가 큽니다
  • 위치 편향이 있습니다. A/B 비교 시 순서를 바꿔 두 번 돌려 평균 냅니다
  • 자기 선호가 있습니다. 같은 계열 모델이 자기 출력을 높게 칩니다. 가능하면 다른 계열로 채점합니다

회귀 테스트로 굳힙니다

한 번 고친 버그는 평가셋에 박아 둡니다.

사용자가 "작년 규정으로 답한다"고 신고
  → 그 질문을 평가셋에 추가, 기대 답변 명시
  → 이후 모든 변경에서 이 케이스가 깨지는지 자동 확인

프롬프트는 눈에 안 보이는 결합이 많아서, A를 고치면 B가 깨지는 일이 아주 흔합니다. 회귀 테스트가 없으면 이걸 사용자가 발견하게 됩니다.


8. 운영에서 챙길 것

스트리밍은 지연이 아니라 체감을 바꿉니다. 전체 응답 시간은 같지만 첫 토큰이 빨리 나오면 사용자는 훨씬 빠르다고 느낍니다. 챗봇이라면 기본값으로 켜 둡니다.

실패를 설계합니다. API는 타임아웃 나고, 속도 제한에 걸리고, 가끔 503을 뱉습니다. 지수 백오프 재시도와 대체 모델 경로, 그리고 전부 실패했을 때의 정적 응답까지 준비해 둬야 합니다.

요청과 응답을 로깅합니다. 프롬프트 버전, 모델 이름, 토큰 수, 지연, 사용자 피드백을 남겨야 나중에 분석할 수 있습니다. 이 로그가 다음 평가셋과 파인튜닝 데이터의 원천입니다.

모델 버전을 고정합니다. 별칭(latest 류)을 쓰면 어느 날 조용히 동작이 바뀝니다. 버전을 박아 두고, 올릴 때는 평가셋으로 비교한 뒤 올립니다.


정리

질문
무엇부터 손대나 프롬프트 → RAG → 파인튜닝. 앞 단계로 되면 뒤는 필요 없다
파인튜닝으로 지식을 넣어도 되나 안 된다. 모르는 사실은 RAG, 못 하는 방식은 파인튜닝
JSON 파싱이 자꾸 깨진다 부탁하지 말고 스키마로 강제. 구조화 출력
비용은 어디서 새나 출력이 아니라 입력. 시스템 프롬프트·도구 정의·검색 문서
캐싱을 어떻게 살리나 변하는 값을 프롬프트 앞에 두지 않는다. 접두사가 같아야 걸린다
파인튜닝 데이터에서 놓치기 쉬운 것 거절·기권 사례. 긍정 예시만 주면 모든 질문에 답하려 든다
평가는 어떻게 평가셋 100~300건 + 자동 지표 + 고친 버그는 회귀 테스트로 박아 둔다

참고 문헌