손으로 짜는 프롬프트의 한계

프롬프트를 문자열로 하드코딩하면 몇 가지 문제가 누적된다. 첫째, 성능 개선이 "예시 몇 개 더 넣기" 같은 감(感)에 의존한다. 둘째, 모델을 교체하면(예: 상위 모델 → 저비용 모델) 기존 프롬프트가 그대로 통하지 않아 다시 손으로 튜닝해야 한다. 셋째, 개선 여부를 정량적으로 검증할 회귀 테스트가 없다. 결과적으로 프롬프트는 재현 불가능한 아티팩트가 되고, 팀이 커질수록 유지보수 비용이 폭증한다.

DSPy는 프롬프트를 "직접 쓰는 문자열"이 아니라 입출력 시그니처 + 최적화 대상 파라미터로 다룬다. 즉 프롬프트 문구와 few-shot 예시를 데이터와 지표로부터 자동 탐색한다.

핵심 개념: Signature, Module, Optimizer

세 가지만 이해하면 된다. Signature는 태스크의 입출력 계약이고, Module은 이를 실행하는 실행 단위(예: Chain-of-Thought), Optimizer(구 Teleprompter)는 라벨링된 데이터와 metric으로 프롬프트/예시를 개선한다.

import dspy

lm = dspy.LM("openai/gpt-4o-mini", max_tokens=512)
dspy.configure(lm=lm)

class Classify(dspy.Signature):
    """고객 문의를 카테고리로 분류한다."""
    ticket: str = dspy.InputField()
    category: str = dspy.OutputField(desc="billing, bug, feature 중 하나")

program = dspy.ChainOfThought(Classify)
pred = program(ticket="결제가 두 번 됐어요")
print(pred.category)

최적화 파이프라인 구성

실무에서는 학습셋(trainset)과 검증셋(devset)을 나누고, metric을 코드로 정의한 뒤 Optimizer를 돌린다. 아래는 정확도 기반 metric과 부트스트랩 방식 최적화 예시다.

from dspy.teleprompt import BootstrapFewShotWithRandomSearch

def acc_metric(example, pred, trace=None):
    return example.category.lower() == pred.category.lower()

optimizer = BootstrapFewShotWithRandomSearch(
    metric=acc_metric,
    max_bootstrapped_demos=4,   # 자동 생성 예시 수
    max_labeled_demos=4,
    num_candidate_programs=8,   # 탐색할 후보 프로그램 수
)

compiled = optimizer.compile(program, trainset=trainset, valset=devset)
compiled.save("classify_v1.json")   # 최적화 결과를 아티팩트로 고정

핵심은 save()로 나온 JSON을 Git에 커밋하는 것이다. 최적화 결과(선택된 예시, 지시문)가 버전 관리되므로 재현성과 롤백이 확보된다.

Optimizer 선택 기준

데이터 양과 목표에 따라 Optimizer가 갈린다.

Optimizer주 대상필요 데이터특징
BootstrapFewShotfew-shot 예시수십 건빠르고 저렴, 기본값
...WithRandomSearch예시 조합 탐색수백 건검증셋으로 최적 조합 선택
MIPROv2지시문+예시 동시수백 건 이상탐색 비용 높고 성능 상단

초기에는 BootstrapFewShot으로 baseline을 잡고, 여력이 있을 때 MIPROv2로 지시문까지 최적화하는 순서를 권한다.

CI에 회귀 테스트로 붙이기

최적화된 프로그램을 로드해 devset 점수를 임계값과 비교하면, 모델·라이브러리 업데이트 시 성능 저하를 자동 감지할 수 있다.

import dspy
from dspy.evaluate import Evaluate

prog = dspy.ChainOfThought(Classify)
prog.load("classify_v1.json")

evaluator = Evaluate(devset=devset, metric=acc_metric, num_threads=8)
score = evaluator(prog)
assert score >= 0.85, f"regression: {score:.3f} < 0.85"

주의점

몇 가지 함정이 있다. metric이 태스크의 실제 품질과 어긋나면 최적화는 엉뚱한 방향으로 수렴한다 — metric 설계가 절반이다. 또한 Optimizer는 API를 반복 호출하므로 후보 수와 예시 수에 비례해 비용이 든다. 캐싱을 켜고 탐색 범위를 작게 시작하라. 최적화된 프롬프트가 특정 모델에 과적합될 수 있으니, 모델을 바꾸면 반드시 재컴파일하고 devset으로 재검증해야 한다. 마지막으로 자동 생성된 few-shot 예시에 PII가 섞일 수 있으므로, 아티팩트를 커밋하기 전 검수 단계를 두는 편이 안전하다.