프롬프트 엔지니어링 고급 기법: Chain-of-Thought, Few-Shot 실전 가이드

지난해 11월, 나는 꽤 짜증스러운 상황에 처해 있었다. 고객 지원 티켓을 버그 리포트, 기능 요청, 일반 문의로 자동 분류하는 시스템을 만들고 있었는데 — GPT-4o 기반으로 짠 첫 번째 버전이 정확도 65% 수준에서 꼼짝을 안 했다. 프롬프트를 이리저리 손봐도 마찬가지였다. “주어진 텍스트를 분류하시오” 식의 단순 지시 방식으로는 아무리 다듬어도 한계가 명확했다.

그때부터 Few-Shot과 Chain-of-Thought를 본격적으로 파기 시작했다. 결론부터 말하면, 약 2주간 집중적으로 테스트한 끝에 정확도를 87%까지 끌어올렸다. 과정이 생각보다 단순하지 않았고, 잘못된 방향으로 시간을 꽤 날리기도 했다. 그 경험을 정리한다.


Few-Shot이 생각보다 까다로운 이유

Few-Shot 프롬프팅의 기본 개념은 단순하다. 모델에게 “이런 입력에는 이런 출력”이라는 예시를 몇 개 보여주고, 그 패턴을 따르게 하는 것이다. 근데 직접 써보면 금방 깨닫는다 — 예시를 아무거나 몇 개 집어넣는다고 잘 되는 게 아니다.

내가 처음에 한 실수가 바로 이거였다. 예시를 고를 때 “분류가 명확한 것들”만 선택했다. 그러니까 모델이 경계선 케이스 — 버그인지 기능 요청인지 모호한 티켓 — 를 만나면 그냥 틀렸다.

예시 선택 기준이 전부다. 내가 실제로 효과를 본 방식은 세 가지다. 첫째, 다양성 우선 — 예시들이 서로 비슷하면 안 된다. 각 예시가 다른 패턴을 커버해야 한다. 둘째, 경계선 케이스 포함 — 모호한 케이스를 예시에 넣고 그 판단 근거를 명시한다. 셋째, 클래스 균형 — 특정 분류 쪽으로 예시가 치우치면 모델도 그쪽으로 편향된다.

코드로 보면 이런 차이다:

# 별로 안 좋은 Few-Shot 예시 구성 — 예시가 너무 비슷하고 경계선 케이스 없음
bad_examples = """
예시 1:
입력: "앱이 자꾸 튕겨요"
출력: 버그 리포트

예시 2:
입력: "버튼을 누르면 오류가 납니다"
출력: 버그 리포트

예시 3:
입력: "검색 기능을 추가해주세요"
출력: 기능 요청
"""

# 더 나은 구성 — 다양성과 경계선 케이스, 판단 근거 포함
good_examples = """
예시 1:
입력: "다크 모드 추가해주세요. 밤에 눈이 아파요"
출력: 기능 요청
(눈 아픔은 불편함이지만, 핵심 요청은 현재 없는 기능)

예시 2:
입력: "결제하려고 하면 앱이 멈춥니다. 3번 시도했어요"
출력: 버그 리포트

예시 3:
입력: "비밀번호 변경 방법을 모르겠어요"
출력: 일반 문의

예시 4:
입력: "알림이 너무 많이 와서 끄고 싶은데, 설정에서 안 보여요"
출력: 버그 리포트
(알림 끄기 기능이 존재하는데 작동 안 함 = 버그. 기능 자체가 없는 게 아님)
"""

예시 4가 핵심이다. “알림을 끄고 싶다”는 언뜻 기능 요청처럼 보이지만, 기능이 있는데 안 된다면 버그다. 이런 판단 근거를 예시 안에 주석처럼 달아주면 모델이 비슷한 케이스에서 훨씬 잘 추론한다.

예시 개수는 3~8개 사이가 실용적이다. 이론적으로는 많을수록 좋다고 할 수 있지만, 토큰 비용이 올라가고 컨텍스트 창을 잡아먹는다. 나는 최종적으로 6개가 내 케이스에서 가성비가 가장 좋았다.


Chain-of-Thought: 모델에게 “생각할 공간”을 주면 생기는 일

Chain-of-Thought(CoT)는 모델이 최종 답변 전에 추론 과정을 먼저 쓰게 하는 방식이다. “답을 내기 전에 단계별로 생각해봐”라고 시키는 거다.

처음 들었을 때 솔직히 반신반의했다. 결국 같은 모델 아닌가? 근데 논리적 추론이 필요한 태스크에서는 효과가 명확하게 달랐다. 모호한 티켓 분류에서 CoT 없이 그냥 분류를 요청하면 모델이 “그냥 찍는” 경향이 있다. CoT를 붙이면 잘못된 방향으로 추론을 시작해도 중간에 스스로 교정하는 경우가 생긴다 — 사람이 “잠깐, 그게 아니고…” 하며 생각을 되짚는 것처럼.

# CoT 없는 버전 — 모델이 그냥 찍는다
prompt_without_cot = """
다음 고객 지원 티켓을 분류하세요: 버그 리포트, 기능 요청, 일반 문의 중 하나

티켓: "{ticket_text}"

분류:"""

# CoT 적용 버전 — 추론 과정을 강제
prompt_with_cot = """
다음 고객 지원 티켓을 분류하세요: 버그 리포트, 기능 요청, 일반 문의 중 하나

분류 전에 다음을 순서대로 생각하세요:
1. 사용자가 보고하는 핵심 문제가 무엇인가?
2. 현재 기능이 의도대로 동작하지 않는가, 아니면 새 기능을 원하는가?
3. 단순히 정보를 요청하는 것인가?

반드시 다음 형식으로 답하세요:
[추론]: (추론 과정을 2~3문장으로)
[분류]: 버그 리포트 | 기능 요청 | 일반 문의 중 하나만

티켓: "{ticket_text}"
"""

그런데 CoT가 항상 좋은 건 아니다. 몇 가지 경우에서는 오히려 역효과가 났다. 감정 분석처럼 직관적인 태스크는 추론 단계를 늘리면 과잉 분석이 된다. 응답 속도가 중요한 실시간 API라면 토큰이 늘어나는 만큼 레이턴시도 늘어난다. 나는 처음에 모든 프롬프트에 CoT를 붙였다가 API 비용이 예상보다 30% 더 나왔다. 태스크를 보고 선택적으로 써야 한다.

한 가지 팁 — “단계별로 생각하세요(Let’s think step by step)”라는 영어 트리거 문구가 한국어보다 효과적인 경우가 있었다. 모델 훈련 데이터 분포 때문인지 정확히는 모르겠는데, 복잡한 추론 태스크에서 체감 차이가 있었다. 100% 확신은 못 하겠고, 본인이 직접 A/B 테스트해보길 권한다.


내가 실제로 틀렸던 지점들

솔직하게 말하자면, 중간에 꽤 큰 실수를 세 번 했다.

실수 1: 예시에 내 편견을 넣은 것. Few-Shot 예시를 만들 때 내가 “당연히 이렇게 분류해야지”라고 생각하는 케이스들을 혼자 골랐다. 나중에 팀원들한테 같은 티켓을 보여줬더니 분류가 달랐다. 내 예시가 내 판단 기준을 학습시키고 있었던 거다. 예시를 만들기 전에 팀 내에서 레이블링 가이드라인을 먼저 합의했어야 했다.

실수 2: CoT 출력을 그대로 파싱하려 한 것. CoT를 쓰면 모델이 추론 과정을 길게 출력한다. 처음에 이 출력에서 최종 분류를 정규식으로 파싱하려 했는데, 모델이 출력 형식을 조금씩 달리 하면서 파싱이 자꾸 깨졌다. 해결책은 위 코드처럼 최종 답변을 [분류]: 형태의 명확한 형식으로 출력하도록 프롬프트에 못 박는 것이었다. 나중에 파싱 코드에서 예외 처리하는 것보다 프롬프트 레벨에서 형식을 강제하는 게 훨씬 낫다.

실수 3: 예시가 너무 깔끔했다. 이게 가장 의외였다 — 예시를 “교과서 같은” 티켓들로만 구성했더니, 실제 사용자 티켓에서 성능이 떨어졌다. 실제 티켓은 오타가 있고, 맥락이 부족하고, 한 티켓에 여러 문제가 섞여 있다. 예시도 그런 현실적인 노이즈를 일부 반영해야 했다.


Few-Shot + CoT 조합, 언제 쓰는 게 맞나

두 기법을 같이 쓰면 강력하지만, 모든 상황에 최적은 아니다. 내가 쓰는 판단 기준은 이렇다.

Few-Shot만 쓸 때: 태스크 패턴이 명확하고 반복적일 때. 추론보다 형식이나 스타일을 맞추는 게 중요할 때. 응답 속도가 중요할 때.

CoT만 쓸 때: 새로운 유형의 문제라 예시를 구성하기 어려울 때. 다단계 추론이 필요한 복잡한 태스크. 추론 과정을 로그로 남겨야 할 때 (디버깅 목적으로 꽤 유용하다).

둘 다 쓸 때: 복잡한 분류 태스크처럼 모호성이 높고 판단 근거가 필요한 경우. 정확도가 매우 중요한 케이스.

조합할 때는 Few-Shot 예시 자체에 추론 과정을 포함시키는 방식이 효과적이다. 예시가 “입력 → 출력”이 아니라 “입력 → 추론 → 출력” 형태가 되는 거다 — 이걸 Few-Shot CoT라고 부른다. 위에 쓴 good_examples 코드가 실제로 그 형태다. 각 예시에 달린 괄호 안 설명이 바로 추론 단계 역할을 한다.


결국 내가 권하는 방법

2주간 테스트한 결과를 솔직하게 정리하면 이렇다.

시작은 Few-Shot부터. CoT를 먼저 붙이고 싶은 충동이 있지만, 좋은 예시 5~6개를 만드는 게 기반이 되어야 한다. 예시 품질이 나쁘면 CoT를 붙여도 나쁜 추론을 그럴듯하게 포장할 뿐이다.

예시는 팀이 같이 만든다. 혼자 만들면 본인 편견이 들어간다. 실제 데이터에서 케이스를 뽑고, 두 명 이상이 레이블을 붙인 뒤 불일치하는 케이스를 먼저 검토한다. 의견이 갈리는 케이스가 오히려 가장 좋은 예시 재료다.

CoT는 선택적으로. 복잡도와 비용을 따져서 쓴다. 내 경우 분류 태스크에는 확실히 도움됐지만, 요약 태스크에서는 별 차이가 없었다.

출력 형식은 항상 명시한다. CoT든 아니든, 파싱이 필요한 구조화된 출력이라면 형식을 프롬프트에 정확히 지정한다.

그리고 가장 중요한 것 — 평가 데이터셋을 미리 만든다. 프롬프트를 바꿀 때마다 “느낌상 좋아진 것 같은데”로 판단하면 안 된다. 레이블이 달린 테스트 케이스 50~100개를 만들어 두고, 변경할 때마다 정확도를 측정해야 한다. 이게 없으면 어떤 변경이 실제로 효과가 있는지 전혀 알 수 없다. 나는 이걸 뒤늦게 구축했는데, 처음부터 만들었다면 일주일은 아꼈을 것 같다.

이 접근법은 분류 태스크 외에도 대부분 통한다. 창작이나 개방형 생성 태스크는 정량 평가가 어려우니 별도의 기준을 세우는 게 먼저지만, 그건 또 다른 글로 정리할 예정이다.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top