- Published on
LLM Self-Consistency로 CoT 정답률 올리기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 추론 경로를 여러 번 뽑아 다수결로 고르는 Self-Consistency는, CoT(Chain-of-Thought) 기반 문제에서 “한 번의 그럴듯한 추론”이 아니라 “여러 번의 다양한 추론 중 가장 일관된 결론”을 채택하게 해 정답률을 끌어올리는 고전적이면서도 실전적인 기법입니다.
특히 수학/논리/계산/규칙 기반 문제처럼 단일 정답이 존재하는 영역에서 효과가 크고, 모델이 가끔 엉뚱한 경로로 빠지는 경우에도 최빈값이 정답으로 수렴하는 경향이 있습니다. 반면 비용과 지연이 증가하므로, “어떤 요청에만 적용할지”를 설계하는 것이 핵심입니다.
아래에서는 Self-Consistency의 원리, 프롬프트/샘플링 전략, 구현 코드, 운영에서 자주 부딪히는 함정과 최적화 포인트를 정리합니다.
Self-Consistency란 무엇인가
Self-Consistency는 간단히 말해 다음 절차입니다.
- 동일한 문제에 대해 CoT를
N번 샘플링한다 - 각 샘플에서 최종 답(
final answer)을 추출한다 - 최종 답에 대해
majority vote또는weighted vote를 수행한다 - 최빈 답을 최종 출력으로 채택한다
CoT는 “중간 추론을 생성한다”는 점에서 강력하지만, 샘플 1회에서는 우연히 잘못된 분기(계산 실수, 규칙 오독, 조기 결론)로 들어가면 그대로 틀립니다. Self-Consistency는 샘플링 다양성을 통해 이런 오차를 평균화합니다.
핵심은 “다양한 추론 경로를 유도하는 샘플링 설정”입니다. 즉, temperature=0처럼 결정론적으로 한 번만 뽑으면 Self-Consistency가 성립하지 않습니다.
언제 효과가 크고, 언제 주의해야 하나
효과가 큰 케이스
- 단일 정답이 존재하는 문제: 산술, 논리 퍼즐, 조건 기반 추론, 코드 결과 예측
- 모델이 정답을 알고 있지만 가끔 실수하는 문제: 계산 실수/단계 누락이 잦은 경우
- 정답 후보가 제한된 문제: 객관식, 범주형 분류(단, 클래스 불균형 주의)
주의할 케이스
- 정답이 주관적이거나 다중 해석 가능한 문제: 최빈값이 “정답”을 의미하지 않음
- 프롬프트가 답을 강하게 유도하는 경우: 다양한 경로가 나오지 않아 투표가 무의미
- 모델이 체계적으로 같은 편향을 가진 경우: 여러 번 뽑아도 같은 오답으로 수렴 가능
CoT를 그대로 노출하지 않는 운영 패턴
실서비스에서는 CoT를 그대로 사용자에게 노출하지 않는 요구가 많습니다. 예를 들어 내부 정책, 보안, UX(장황함) 이유가 있습니다.
이때는 “모델 내부에서만 추론하고 최종 답만 JSON으로 받는” 방식이 자주 쓰입니다. CoT 노출을 막는 프롬프트 가드가 필요하다면 아래 글도 함께 참고할 만합니다.
Self-Consistency는 “여러 번 샘플링”이므로, 각 샘플에서 CoT를 노출하지 않도록 출력 스키마를 강제하고, 최종 답만 투표에 사용하면 됩니다.
구현 전략 1: 최종 답만 투표하기
가장 실용적인 방식은 “각 샘플의 final만 뽑아서 투표”입니다. 구현이 단순하고, CoT 파싱이 필요 없습니다.
프롬프트 예시(JSON 강제)
아래 프롬프트는 추론은 내부적으로 하되 출력은 JSON만 내보내도록 유도합니다. MDX 환경에서는 부등호가 빌드 에러를 유발할 수 있으니, 제네릭이나 화살표 표기 같은 것은 모두 코드 블록 안에 넣습니다.
너는 문제를 풀되, 중간 추론을 절대 출력하지 마라.
반드시 다음 JSON 형식으로만 답해라.
{
"answer": "최종 답",
"confidence": 0.0
}
문제:
{QUESTION}
confidence는 모델이 자의적으로 쓰는 값이라 절대적 신뢰는 어렵지만, 동일 모델/동일 태스크에서 상대 비교나 가중 투표에 참고할 수 있습니다.
Python 예제: OpenAI API로 Self-Consistency
아래 코드는 N번 호출 후 answer 최빈값을 선택합니다. 네트워크 오류나 429 대응까지 고려하면 재시도/백오프가 필요합니다.
import json
import random
import time
from collections import Counter
from typing import Any, Dict, List, Tuple
from openai import OpenAI
client = OpenAI()
def call_once(question: str, temperature: float = 0.7, seed: int | None = None) -> Dict[str, Any]:
# seed는 일부 SDK/모델에서만 지원됩니다. 지원되지 않으면 제거하세요.
messages = [
{
"role": "system",
"content": (
"You must not reveal chain-of-thought. "
"Return only valid JSON with keys answer and confidence."
),
},
{
"role": "user",
"content": (
"Solve the problem but do not output reasoning. "
"Return JSON only.\n\n"
"{\n \"answer\": \"...\",\n \"confidence\": 0.0\n}\n\n"
f"Problem: {question}"
),
},
]
resp = client.chat.completions.create(
model="gpt-4.1-mini",
messages=messages,
temperature=temperature,
# seed=seed,
)
text = resp.choices[0].message.content
return json.loads(text)
def self_consistency(question: str, n: int = 7, temperature: float = 0.7) -> Tuple[str, Dict[str, int]]:
answers: List[str] = []
for i in range(n):
# 간단한 지터로 동시 폭주를 줄입니다.
time.sleep(random.uniform(0.05, 0.15))
out = call_once(question=question, temperature=temperature)
answers.append(str(out.get("answer", "")).strip())
counts = Counter(answers)
best, _ = counts.most_common(1)[0]
return best, dict(counts)
if __name__ == "__main__":
q = "If a store offers 20% off a $50 item, what is the final price?"
best, counts = self_consistency(q, n=9, temperature=0.8)
print("best:", best)
print("counts:", counts)
실전 팁
n은 보통5~15사이에서 비용 대비 효율이 좋습니다.temperature는0.6~1.0범위를 먼저 탐색합니다. 너무 낮으면 다양성이 안 나오고, 너무 높으면 무의미한 답이 늘 수 있습니다.- 응답 파싱 안정성을 위해 JSON 스키마를 더 엄격히 하거나, 파싱 실패 시 재호출하는 루프를 넣는 것이 안전합니다.
구현 전략 2: 정규화 후 투표(동치 처리)
최종 답만 투표하면 생기는 대표적 문제가 “표현만 다른 동일 답”입니다.
예:
"30"vs"$30"vs"30 dollars""0.3"vs"30%""(x, y) = (2, 3)"vs"x=2,y=3"
따라서 투표 전에 답을 정규화(normalization)하는 계층을 두면 성능이 좋아집니다.
import re
def normalize_answer(a: str) -> str:
a = a.strip().lower()
a = a.replace("$", "")
a = re.sub(r"\s+", " ", a)
# 퍼센트 표기 단순화: 30% -> 0.3 같은 변환은 태스크에 따라 위험할 수 있어
# 여기서는 예시로만 제시합니다.
if re.fullmatch(r"\d+(\.\d+)?%", a):
num = float(a[:-1])
a = str(num / 100.0)
return a
투표는 정규화된 키로 하되, 최종 출력은 원문 중 대표값을 선택하는 방식도 가능합니다.
구현 전략 3: 가중 투표(확률, 자기검증, 스코어)
단순 다수결은 강력하지만, 다음 상황에서는 가중치가 도움이 됩니다.
- 특정 샘플이 명백히 형식 오류/무응답
- 모델이 함께 제공한
confidence가 상대적으로 유의미한 태스크 - 별도 검증기(verifier)가 있는 경우(예: 계산 검증, 코드 실행 결과 검증)
가중 투표는 예를 들어 다음처럼 구현할 수 있습니다.
from collections import defaultdict
def weighted_vote(samples):
score = defaultdict(float)
for s in samples:
ans = normalize_answer(str(s.get("answer", "")))
w = float(s.get("confidence", 0.0))
# confidence가 0~1이 아닐 수 있으니 클램프
if w < 0.0:
w = 0.0
if w > 1.0:
w = 1.0
score[ans] += w
best = max(score.items(), key=lambda kv: kv[1])[0]
return best, dict(score)
다만 LLM의 자기 확신은 과신(overconfidence) 문제가 흔합니다. “가중치가 성능을 올리는지”는 반드시 오프라인 평가로 확인해야 합니다.
비용과 지연을 줄이는 운영 설계
Self-Consistency의 비용은 거의 선형으로 증가합니다. 따라서 아래 최적화가 중요합니다.
1) 게이팅(gating): 어려운 문제에만 적용
- 1회 호출 결과가 특정 기준을 만족하면 그대로 반환
- 기준 미달일 때만 Self-Consistency를 실행
기준 예:
- 모델이 반환한
confidence가 임계값 미만 - 답이 규칙/형식 검증에 실패(예: JSON 스키마, 정규식)
- 간단한 검증기에서 불합격(예: 산술 재계산)
2) 병렬 호출 + 레이트리밋 대응
N번 호출을 직렬로 하면 지연이 커집니다. 병렬화하되, 공급자 레이트리밋에 맞춰 동시성을 제한해야 합니다.
대량 트래픽에서 메모리나 저장소가 불어나는 문제도 생길 수 있는데, 예를 들어 대화형 에이전트에서 샘플 결과를 세션에 누적하면 메모리 폭증이 납니다. 캐시/TTL 전략이 필요할 수 있습니다.
3) n을 고정하지 말고 조기 종료
투표가 일찍 수렴하면 남은 샘플을 생략할 수 있습니다.
예:
n_max=11로 시작하되, 어떤 답이 이미k표를 얻으면 종료- 또는 남은 샘플이 모두 반대편에 몰려도 뒤집을 수 없는 시점에 종료
간단한 조기 종료 조건 예시는 다음과 같습니다.
from collections import Counter
def early_stop_majority(answers, n_max, win_margin=2):
c = Counter(answers)
best, best_cnt = c.most_common(1)[0]
remaining = n_max - len(answers)
# 2등이 남은 표를 다 가져가도 best를 못 이기면 종료
second_cnt = c.most_common(2)[1][1] if len(c) > 1 else 0
if best_cnt > second_cnt + remaining:
return True
# 또는 best가 일정 마진 이상 앞서면 종료(휴리스틱)
if best_cnt >= second_cnt + win_margin and len(answers) >= 5:
return True
return False
하이퍼파라미터 튜닝 가이드
temperature
- 너무 낮음: 경로 다양성이 부족해 투표 의미가 약함
- 너무 높음: 엉뚱한 답이 늘어 분산만 커짐
권장 시작점은 0.7 전후이며, 태스크별로 정답률 곡선을 측정해 최적점을 찾습니다.
n
n=3은 개선폭이 작을 수 있음n=5~9는 실전에서 가장 흔한 타협점n=15이상은 비용 대비 개선폭이 급격히 줄 수 있음
프롬프트 다양화 vs 샘플링 다양화
Self-Consistency는 보통 “같은 프롬프트 + 샘플링 다양화”로 충분합니다. 하지만 모델이 특정 프롬프트에 과도하게 끌리면, 프롬프트 자체를 약간씩 바꾸는 prompt ensemble이 도움이 될 때도 있습니다.
예:
- “다른 방법으로 다시 풀어라” 같은 지시를 시스템/유저 메시지에서 변주
- 출력 포맷은 동일하게 유지해 파싱 안정성 확보
평가 방법: 정답률만 보면 놓치는 것들
Self-Consistency 적용 전후를 비교할 때는 다음 지표를 함께 봐야 합니다.
- 정확도(accuracy)
- 평균 지연(latency)과 p95/p99
- 토큰 사용량(비용)
- 파싱 실패율(JSON 불일치)
- “다수결이 오답으로 수렴”하는 실패 케이스 비율
특히 오답이 다수결로 굳어지는 케이스는 프롬프트/정규화/검증기 설계로 줄여야 합니다.
자주 발생하는 실패 패턴과 대응
1) 답이 자주 동률로 나오는 경우
- 정규화가 부족하거나, 문제 자체가 애매한 경우
- 대응: 정규화 강화, 문제 정의 개선, 또는 verifier 도입
2) 모델이 JSON을 자주 깨는 경우
- 대응: 출력 스키마를 더 단순화하고, 파싱 실패 시 재시도
- “JSON만 출력” 가드를 강화하거나, 응답에서 JSON 부분만 추출하는 방어 로직 추가
3) 비용 폭증
- 대응: 게이팅, 조기 종료, 저가 모델로 다중 샘플링 후 고가 모델로 최종 검증(2단계 파이프라인)
정리
Self-Consistency는 CoT 기반 추론의 약점인 “단발성 실수”를, 다중 샘플링과 투표로 상쇄해 정답률을 올리는 검증된 방법입니다. 다만 호출 횟수만큼 비용과 지연이 늘기 때문에, 다음 3가지를 함께 설계해야 실전에서 이득을 봅니다.
- 어떤 요청에만 적용할지(게이팅)
- 답 정규화와 파싱 안정성
- 조기 종료와 레이트리밋 대응으로 비용/지연 최적화
위 패턴을 적용하면, CoT를 노출하지 않으면서도(또는 최소화하면서도) “정답률을 체감할 만큼” 끌어올리는 경우가 많습니다.