- Published on
CoT 없이 성능 올리는 Self-Consistency 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 추론 경로(샘플)를 여러 번 생성한 뒤, 다수결·가중 투표로 최종 답을 고르는 Self-Consistency는 "추론을 더 잘하게" 만들기보다 "우연히 맞는 경로를 더 자주 채택"하게 만들어 정확도를 올리는 기법입니다. 중요한 점은, 이 과정에서 Chain-of-Thought(CoT)를 사용자에게 노출할 필요가 없다는 것입니다. 즉, 모델 내부적으로는 다양한 경로를 탐색하되, 외부 출력은 최종 답과 짧은 근거만 제공하는 형태로 안전하고 깔끔하게 운영할 수 있습니다.
이 글에서는 (1) CoT 없이 Self-Consistency를 설계하는 프롬프트/출력 스키마, (2) 투표 전략, (3) 신뢰도 추정과 조기 종료, (4) 비용 폭증을 막는 운영 팁을 실전 관점에서 다룹니다.
Self-Consistency가 먹히는 문제와 안 먹히는 문제
Self-Consistency는 "정답 후보가 여러 개로 갈릴 수 있고", "샘플링에 따라 답이 흔들리는" 문제에서 효과가 큽니다.
효과가 큰 케이스
- 수학/논리 퍼즐, 규칙 기반 추론(단, 계산 실수 가능성이 있는 유형)
- 코드 이해/버그 원인 후보가 여러 개인 디버깅 질의
- 데이터 정합성/정책 판단처럼 경계 조건이 많은 분류 문제
- 에이전트 플래닝에서 다음 액션 후보가 여럿이고, 일부는 치명적 실수를 내는 경우
효과가 제한적인 케이스
- 사실 조회형(knowledge lookup): 샘플링을 늘려도 "모르는 건 모른다"가 자주 반복됨
- 장문 생성 품질(문체/창의성): 다수결이 품질을 보장하지 않음
- 정답이 단일하지만 모델이 구조적으로 취약한 영역(예: 특정 API 스펙 환각): 투표해도 다 같이 틀릴 수 있음
CoT 없이 Self-Consistency를 하는 핵심: "답만" 구조화하기
MDX/프로덕션 UI에서 CoT를 그대로 노출하면 보안·정책·UX 측면에서 부담이 큽니다. 그래서 다음 원칙을 추천합니다.
- 모델에게는 "내부적으로는 자유롭게 추론"하되, 출력은 "최종 답"만 구조화해 달라고 요구
- 각 샘플은 동일한 스키마(JSON 등)로 반환
- 투표는 텍스트 유사도가 아니라 "정규화된 답" 기준으로 수행
프롬프트 템플릿(CoT 비노출)
아래는 "추론 과정은 출력하지 말고" 결과만 내게 하는 템플릿입니다.
SYSTEM:
You are a careful assistant. Think privately. Do not reveal chain-of-thought.
Return ONLY a JSON object that matches the schema.
USER:
문제: {question}
요구사항:
- reasoning(추론 과정) 텍스트를 절대 출력하지 말 것
- answer는 최종 답만
- confidence는 0~1 사이 숫자(주관적 확신)
JSON Schema:
{
"answer": string,
"confidence": number,
"short_rationale": string
}
여기서 short_rationale은 "근거 한두 문장" 정도로 제한합니다. 이건 CoT가 아니라 "요약된 근거"이므로 제품 정책에 맞게 통제하기 좋습니다.
투표 설계: "다수결"만으로 부족한 이유
Self-Consistency의 기본은 다수결이지만, 실무에서는 아래 이슈가 자주 나옵니다.
- 표기가 조금씩 달라 같은 답인데 다른 문자열로 취급됨
- 모델이 자신감
confidence를 과대평가하거나 들쭉날쭉함 - 답이 숫자/날짜/코드 스니펫처럼 정규화가 필요한 타입일 수 있음
따라서 "정규화 + 가중 투표 + 타이브레이커"가 필요합니다.
정규화 규칙 예시
- 숫자: 공백 제거,
,제거, 소수점 처리, 단위 통일 - 날짜: ISO-8601로 변환
- 선택지:
A/B/C/D로 매핑 - 코드: 해시(예: 공백 제거 후 SHA) 또는 AST 기반 비교(가능하면)
Python 구현 예제: Self-Consistency 엔진
아래 코드는 OpenAI 호환 API 스타일을 가정한 예시입니다(다른 LLM SDK여도 구조는 동일). 포인트는 n번 샘플링 후 답을 정규화해 투표하고, 필요하면 조기 종료하는 것입니다.
import json
import math
from collections import defaultdict
def normalize_answer(ans: str) -> str:
# 매우 단순한 예시: 공백/대소문자 정리
return " ".join(ans.strip().lower().split())
def weighted_vote(samples):
# samples: list of dicts {answer, confidence, short_rationale}
bucket = defaultdict(float)
raw_map = defaultdict(list)
for s in samples:
a = normalize_answer(s.get("answer", ""))
c = s.get("confidence", 0.0)
# confidence를 그대로 믿기보다 완만하게 반영(루트)
w = math.sqrt(max(0.0, min(1.0, float(c))))
bucket[a] += w
raw_map[a].append(s)
winner = max(bucket.items(), key=lambda x: x[1])[0]
return winner, bucket[winner], raw_map[winner]
def should_stop_early(bucket_scores, threshold=0.75):
# 간단한 조기 종료: 1등 점유율이 충분히 크면 멈춤
total = sum(bucket_scores.values())
if total <= 0:
return False
top = max(bucket_scores.values())
return (top / total) >= threshold
async def self_consistency_answer(client, question: str, k: int = 7):
samples = []
bucket_scores = defaultdict(float)
for i in range(k):
resp = await client.chat.completions.create(
model="gpt-4.1-mini",
temperature=0.8,
messages=[
{"role": "system", "content": "Think privately. Do not reveal chain-of-thought. Return ONLY JSON."},
{"role": "user", "content": f"문제: {question}\nJSON으로만 답해."},
],
)
text = resp.choices[0].message.content
data = json.loads(text)
samples.append(data)
a = normalize_answer(data.get("answer", ""))
c = float(data.get("confidence", 0.0))
bucket_scores[a] += math.sqrt(max(0.0, min(1.0, c)))
if should_stop_early(bucket_scores, threshold=0.78) and len(samples) >= 3:
break
winner, score, winners = weighted_vote(samples)
# 최종 출력은 "답"만, 근거는 winners 중 하나를 선택
best = max(winners, key=lambda s: s.get("confidence", 0.0))
return {
"answer": best.get("answer", ""),
"short_rationale": best.get("short_rationale", ""),
"meta": {
"num_samples": len(samples),
"normalized_winner": winner,
"winner_score": score,
},
}
왜 confidence를 루트로 완화하나
모델이 confidence=0.99를 남발하면 가중 투표가 쉽게 왜곡됩니다. 루트, 로그, 또는 구간화(예: 0.0/0.5/1.0)로 완화하면 한 샘플의 과대 자신감이 결과를 독점하기 어렵습니다.
투표의 함정: "다수결이 곧 정답"이 아닌 상황
Self-Consistency는 "정답이 더 자주 나오도록" 만드는 것이지, "검증"이 아닙니다. 특히 운영에서 다음 상황이 위험합니다.
- 모두가 같은 환각을 공유(데이터셋에 흔한 오답 패턴)
- 질문이 애매해서 모델이 특정 해석으로 쏠림
- 제한된 컨텍스트 때문에 근거가 부족한데도 자신감이 올라감
그래서 실무에서는 Self-Consistency를 "1차 후보 선택"으로 쓰고, 2차로 "검증기"를 붙이면 안정성이 크게 올라갑니다.
검증기(Verifier) 붙이기: CoT 없이도 가능
Verifier는 별도 호출로 "이 답이 조건을 만족하는지"만 체크합니다. 이때도 CoT를 노출할 필요가 없습니다.
Verifier 프롬프트 예시
SYSTEM:
You are a strict validator. Do not reveal chain-of-thought.
Return ONLY JSON.
USER:
Question: {question}
Proposed answer: {answer}
Validate:
- If the answer is correct and complete, return {"verdict":"pass"}
- Otherwise return {"verdict":"fail", "reason":"..."}
운영 팁
- Verifier는 더 낮은
temperature(예: 0~0.2)로 결정론적으로 - fail이면 Self-Consistency를 한 번 더 돌리거나, 다른 모델로 폴백
비용 제어: 샘플 수 k를 고정하지 말고 "적응형"으로
Self-Consistency의 가장 큰 단점은 비용입니다. k=10으로 고정하면 단순히 10배 비싸집니다. 실전에서는 다음의 적응형 전략이 효과적입니다.
1) 조기 종료(Early Stopping)
- 3개 샘플에서 2개가 동일 답이고, 점유율이 충분히 크면 종료
- 숫자 문제처럼 답이 빠르게 수렴하는 유형에서 비용을 크게 줄임
2) 난이도 기반 k 선택
- 짧고 단순한 질의는
k=3 - 애매하거나 실패 비용이 큰 질의는
k=7또는k=9
난이도 분류는 규칙 기반으로도 충분합니다. 예를 들어 "제약 조건 개수"(불릿 수), "입력 길이", "코드/로그 포함 여부"로 점수를 매길 수 있습니다.
3) 캐시 전략
동일 질문/동일 컨텍스트에서 Self-Consistency 결과를 캐시하면 비용이 급감합니다. 다만 캐시 키가 조금만 달라도 미스가 나기 쉬우니 정규화가 중요합니다.
캐시가 기대대로 동작하지 않거나 갱신이 꼬일 때는 프론트/서버 캐시 레이어를 함께 점검해야 합니다. Next.js 환경이라면 RSC 캐시 이슈를 다룬 글도 참고할 만합니다: Next.js 14 RSC 캐시 꼬임으로 갱신이 안될 때
실전 패턴 3가지
패턴 A: "선택지 문제"에 최적화된 Self-Consistency
선다형은 정규화가 쉬워 Self-Consistency 효율이 좋습니다.
- 샘플 출력은
answer: "A" | "B" | "C" | "D" - 투표는 문자열 그대로
- Verifier는 "선택 이유"가 아니라 "정답 조건 충족"만 판단
패턴 B: "디버깅/원인 분석"은 후보 리스트를 투표한다
원인 분석은 단일 답보다 "후보 top-3"가 더 유용할 때가 많습니다. 이 경우 각 샘플이 root_cause_candidates를 내고, 후보별 득표로 랭킹을 만들면 재현/조치가 빨라집니다.
운영 환경에서 타임아웃 폭증 같은 문제는 후보가 다양하게 갈리므로, 이런 랭킹 접근이 특히 유용합니다. 분산 시스템에서 데드라인 전파 누락이 장애로 이어진 사례는 gRPC MSA 데드라인 전파 누락으로 타임아웃 폭증 해결도 함께 보면 좋습니다.
패턴 C: "코드 생성"은 테스트를 투표의 기준으로 삼는다
코드 생성은 다수결로 텍스트를 고르는 순간 품질이 떨어질 수 있습니다. 대신:
- 여러 코드 후보 생성
- 각 후보를 실행/테스트
- 통과한 후보 중 가장 단순한 것 선택
즉, 투표 대신 "실행 결과"를 기준으로 선택합니다. Self-Consistency의 철학(여러 경로를 뽑아 좋은 걸 고른다)을 가장 강하게 살리는 방식입니다.
실패 모드와 디버깅 체크리스트
1) 출력 JSON이 깨진다
- 모델 출력에 설명이 섞임
- 코드 블록이 붙어서 파싱 실패
대응:
- 시스템 메시지에 "Return ONLY JSON"을 강하게
- 응답을
json모드 또는 구조화 출력 기능이 있는 SDK로 강제(가능한 경우) - 파싱 실패 시 1회 재시도(temperature를 낮춰)
2) 정규화가 부실해 투표가 분산된다
- 같은 답이지만 표기가 달라 표가 갈림
대응:
- 숫자/단위/날짜/선택지 등 타입별 정규화 함수를 분리
- 답 스키마를 문자열 하나로 두지 말고,
answer_type과value로 분리
3) 비용이 폭증한다
k가 크고, 조기 종료가 안 걸림
대응:
- 조기 종료 기준을 "점유율"로 두되 최소 샘플 수를 3~4로
- Verifier를 붙여서 재시도 횟수를 줄임
- 캐시 적중률을 높이기 위한 질문 정규화(공백/시간/세션 토큰 제거)
CI에서 캐시가 기대대로 안 먹어 비용/시간이 새는 경우도 많습니다. 캐시 디버깅 관점은 GitHub Actions 캐시가 안 먹을 때 디버깅 체크리스트도 유사한 사고방식을 제공합니다.
정리: CoT 없이도 "샘플링 + 선택"으로 이긴다
- Self-Consistency는 CoT 노출 없이도 적용 가능하며, 핵심은 "출력 스키마를 답 중심으로 고정"하는 것
- 다수결만 쓰지 말고 정규화, 가중치 완화, 타이브레이커를 넣어야 운영에서 흔들리지 않음
- Verifier를 붙이면 "다 같이 틀리는" 위험을 크게 낮출 수 있음
- 비용은 적응형
k, 조기 종료, 캐시로 제어
결국 Self-Consistency는 LLM을 더 똑똑하게 만드는 마법이 아니라, 불확실성을 관리하는 엔지니어링 기법입니다. CoT 없이도 충분히 성능을 끌어올릴 수 있고, 특히 실패 비용이 큰 업무 흐름에서 "안전한 정확도 상승"을 제공한다는 점이 실전에서 가장 큰 가치입니다.