- Published on
CoT 없이도 성능↑ - Self-Consistency 디코딩
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 경로로 여러 번 답을 생성한 뒤, 가장 일관된 답을 고르는 것만으로도 모델 성능이 꽤 올라갑니다. 이것이 Self-Consistency(SC) 디코딩의 핵심입니다. 특히 “CoT(Chain-of-Thought)를 길게 쓰면 잘 맞는다”는 경험칙을 알고 있어도, 제품에서는 CoT를 그대로 노출하기 어렵습니다. 보안·정책·UX·비용 이슈가 얽히기 때문입니다.
이 글에서는 CoT를 사용자에게 보여주지 않으면서도 성능을 올리는 Self-Consistency 디코딩을 실무 관점에서 설명하고, 바로 적용 가능한 코드 패턴(파이썬)을 제공합니다. 또한 비용/지연, 투표 설계, 실패 모드와 디버깅 포인트까지 함께 다룹니다.
Self-Consistency란 무엇인가
Self-Consistency는 “하나의 디코딩 결과” 대신 “여러 개의 후보 답”을 생성하고, 그중 가장 많이 등장하거나(majority vote), 가장 일관된(consensus) 답을 최종 출력으로 선택하는 전략입니다.
핵심 아이디어는 간단합니다.
- LLM의 샘플링은 확률적이어서, 같은 질문에도 여러 답이 나올 수 있음
- 추론 문제에서는 올바른 답으로 수렴하는 경로가 여러 개 존재
- 한 번의 greedy/beam 결과가 틀릴 수 있지만, 여러 번 샘플링하면 정답이 더 자주 등장하는 경향
- 따라서 “여러 번 생성 + 집계”가 단일 생성보다 정확도를 높임
여기서 중요한 점은, SC는 CoT 자체가 필수는 아니라는 것입니다. 원래 논문/소개에서는 다양한 reasoning path를 유도하기 위해 CoT를 사용하곤 하지만, 제품에서는 다음처럼 설계할 수 있습니다.
- 모델 내부에는 추론을 하게 두되, 출력은 최종 답만 내보내기
- 혹은 아예 “최종 답만” 형식으로 강제하고도 샘플링 다양성만 확보하기
즉, “CoT를 노출하지 않는다”와 “모델이 추론을 하지 않는다”는 동치가 아닙니다.
CoT 없이도 성능이 오르는 이유
CoT를 출력하지 않으면 reasoning이 약해질 것 같지만, SC는 다른 축에서 성능을 끌어올립니다.
- 샘플링 분산을 이용한 오류 상쇄
- 단일 샘플은 우연히 함정(잘못된 가정, 계산 실수, 단위 착각)에 빠질 수 있습니다.
- 여러 샘플을 모으면 이런 우연 오류가 평균적으로 줄어듭니다.
- 답 공간(answer space)의 수렴 특성
- 많은 문제는 최종 답이 짧고(숫자, 선택지, 엔티티), 오답은 다양합니다.
- 이때 다수결은 강력합니다. 정답이 반복될 확률이 상대적으로 높기 때문입니다.
- 검증 가능한 포맷과 결합이 쉬움
- “정답은
A|B|C|D중 하나” 같은 제약을 걸면 집계가 더 안정적입니다. - 추론을 길게 써서 설득하는 대신, 형식 제약 + 다중 샘플 + 투표로 안정성을 확보합니다.
언제 Self-Consistency가 특히 유효한가
다음 유형에서 SC는 체감 성능이 좋습니다.
- 수학/논리/퍼즐: 최종 답이 숫자나 짧은 문자열
- 분류/선택형 QA: 보기 중 하나를 고르는 문제
- 규칙 기반 변환: 정규화, 파싱, 포맷 변환 등(단, 제약을 강하게)
- RAG 후 최종 결론 선택: 여러 근거를 읽고 결론만 내리게 할 때
반대로 다음 상황에서는 효과가 제한적일 수 있습니다.
- 창작/서술형: “정답”이 하나로 수렴하지 않음
- 장문 요약: 투표 기준이 모호(문장 수준 편집 거리 등 추가 설계 필요)
- 사실성: 다수결이 진실을 보장하지 않음(집단 환각 가능)
기본 구현: 다중 샘플 + 다수결 투표
아래는 OpenAI 호환 API 스타일을 가정한 예시입니다. 포인트는 temperature를 올려 다양성을 확보하고, n_samples만큼 반복 호출한 뒤 답을 정규화(normalize)하여 투표하는 것입니다.
import re
from collections import Counter
from typing import List, Dict, Any
def normalize_answer(text: str) -> str:
"""투표를 위해 답을 정규화한다.
- 공백/개행 제거
- 불필요한 접두어 제거
- 숫자만 남기기 같은 태스크별 규칙 적용 가능
"""
t = text.strip()
t = re.sub(r"^final\s*answer\s*:\s*", "", t, flags=re.IGNORECASE)
t = re.sub(r"\s+", " ", t)
return t
def majority_vote(candidates: List[str]) -> Dict[str, Any]:
norm = [normalize_answer(c) for c in candidates]
counts = Counter(norm)
best, best_cnt = counts.most_common(1)[0]
return {
"best": best,
"count": best_cnt,
"total": len(candidates),
"distribution": counts,
}
def call_llm(client, prompt: str, *, temperature: float) -> str:
# 예시: OpenAI 호환 Chat Completions 형태
resp = client.chat.completions.create(
model="gpt-4.1-mini",
messages=[
{"role": "system", "content": "You are a careful assistant. Output ONLY the final answer."},
{"role": "user", "content": prompt},
],
temperature=temperature,
)
return resp.choices[0].message.content
def self_consistency_decode(client, prompt: str, *, n_samples: int = 9, temperature: float = 0.8) -> Dict[str, Any]:
candidates = [call_llm(client, prompt, temperature=temperature) for _ in range(n_samples)]
voted = majority_vote(candidates)
voted["candidates"] = candidates
return voted
# 사용 예
# result = self_consistency_decode(client, "What is 17 * 19?", n_samples=11, temperature=0.9)
# print(result["best"], result["distribution"])
이 방식의 장점은 단순함입니다. 단점은 비용과 지연이 n_samples에 거의 비례한다는 점입니다.
제품 적용을 위한 프롬프트 패턴
CoT를 숨기고 “최종 답만” 받으려면, 출력 포맷을 강제하는 편이 투표 안정성을 크게 올립니다.
1) 선택형(다지선다) 포맷 강제
문제: ...
보기:
A) ...
B) ...
C) ...
D) ...
규칙:
- 정답은 반드시 A,B,C,D 중 하나만 출력
- 다른 텍스트를 절대 출력하지 말 것
이렇게 하면 후보 답의 정규화가 거의 필요 없어지고, 투표가 매우 견고해집니다.
2) 숫자 답 포맷 강제
규칙:
- 최종 답은 숫자만 출력
- 쉼표, 단위, 설명 문장 금지
그 다음 정규화에서 re.findall로 숫자만 뽑아 투표하면 됩니다.
투표 설계: 다수결만으로 부족할 때
실무에서는 다수결이 애매해지는 케이스가 자주 나옵니다. 예를 들어 분포가 4,3,2로 갈리거나, 최빈값이 2표로 동률인 경우입니다.
이럴 때는 다음 전략을 조합합니다.
1) 임계치(threshold) 기반 재시도
- 최빈값 비율이
p = best_cnt / n_samples일 때p가 낮으면 재샘플링 - 예:
p가0.45미만이면n_samples를 추가로 더 뽑기
2) 2단계 디코딩(리랭킹)
- 1단계: 다양한 후보 생성
- 2단계: 별도 호출로 후보만 놓고 “정답 하나를 선택”하게 함
2단계는 “판정자(judge)” 프롬프트로 구현합니다.
def judge_best(client, question: str, candidates: List[str]) -> str:
joined = "\n".join([f"- {c}" for c in candidates])
prompt = (
"Choose the single best final answer for the question. "
"Output ONLY the answer text exactly as one of the options.\n\n"
f"Question:\n{question}\n\nOptions:\n{joined}"
)
resp = client.chat.completions.create(
model="gpt-4.1-mini",
messages=[
{"role": "system", "content": "You are a strict evaluator. Output only one option."},
{"role": "user", "content": prompt},
],
temperature=0.0,
)
return resp.choices[0].message.content.strip()
이 방식은 비용이 더 들지만, 동률/근소 차이 상황에서 안정적입니다.
3) 제약 기반 검증(validator) 결합
정답을 프로그램으로 검증할 수 있다면(예: 수식 평가, JSON 파싱, 타입 체크), 투표보다 검증이 우선입니다.
- 후보를 생성
- 파서/검증기 통과한 후보만 남김
- 남은 후보에 대해 투표 또는 judge
이 접근은 “다수결이 틀릴 수 있음” 문제를 줄여줍니다.
비용과 지연: SC를 싸게 만드는 방법
SC의 가장 큰 단점은 호출 수 증가입니다. 이를 완화하는 실전 팁입니다.
1) 적응형 샘플링(adaptive sampling)
처음부터 n_samples=15로 고정하지 말고, 5로 시작해 분포를 보고 추가 샘플을 뽑습니다.
n=5에서 최빈값이 4표 이상이면 종료- 아니면
+4씩 추가
2) 모델 계층화
- 1차 후보 생성은 더 싼 모델
- 동률/불확실할 때만 상위 모델로 judge
3) 캐시 전략
같은 질문이 반복되는 서비스(FAQ, 에이전트 툴 응답)라면 후보/분포를 캐시해 비용을 줄일 수 있습니다. 캐시 설계는 인증/키 회전/JWKS 캐시와 유사하게 “언제 무효화할지”가 중요합니다. 캐시 불일치로 장애가 나는 유형은 JWT 검증 실패 - JWKS kid 불일치·캐시 7가지에서 다룬 패턴과도 닮아 있습니다.
실패 모드와 디버깅 포인트
SC를 붙였는데도 성능이 안 오르거나, 오히려 이상해지는 경우가 있습니다.
1) 답 정규화가 부정확
예:
"10"과"10."이 다른 답으로 집계"A"와"A)"가 다른 답으로 집계
해결:
- 태스크별 정규화 함수를 강하게
- 출력 포맷을 더 엄격히
2) 샘플 다양성이 부족
temperature가 너무 낮거나, 프롬프트가 너무 강하게 “한 가지 표현”만 강제하면 후보가 거의 동일해져 SC 효과가 줄어듭니다.
temperature를0.7이상으로top_p조정- 단, 포맷 제약은 유지(답 표현만 다양해지는 것은 피해야 함)
3) 다수결이 환각을 강화
특정 오답이 “그럴듯해서” 자주 등장하면 다수결이 오히려 그 오답을 선택할 수 있습니다.
해결:
- 검증기 결합(가능하면 최우선)
- RAG라면 근거 문서 인용을 내부적으로만 요구하고, judge 단계에서 근거 일치 여부를 평가
- 도메인 지식이 강한 태스크는 temperature를 낮추고,
n_samples를 늘리기보다 “판정 단계”를 강화
4) 운영 환경에서의 관측성 부족
SC는 “왜 이 답이 선택됐는지”를 분포로 설명할 수 있습니다. 따라서 다음을 로깅하면 디버깅이 쉬워집니다.
- 후보 답 원문 리스트
- 정규화 결과
- 분포(카운트)
- 종료 조건(임계치 충족 vs 최대 샘플 도달)
이런 관측성은 배포 이후 문제를 좁히는 데 결정적입니다. 운영 디버깅 관점의 접근은 Next.js 14 서버액션 500·CSRF·캐시 꼬임 해결에서의 “원인 분리” 방식과도 통합니다.
실전 예시: JSON 스키마 출력 + Self-Consistency
도구 호출이나 구조화 응답에서는 “형식 오류”가 가장 흔한 실패입니다. 이때는 SC를 “형식 통과율”을 올리는 데도 쓸 수 있습니다.
- 여러 번 생성
- JSON 파싱 성공한 후보만 남김
- 특정 필드만 투표(예:
action,id)
아래 예시는 answer 필드만 뽑아 투표하는 패턴입니다.
import json
from collections import Counter
def try_parse_json(s: str):
try:
return json.loads(s)
except Exception:
return None
def sc_json_answer(client, question: str, n_samples: int = 9):
prompt = (
"Return JSON only with keys: answer, confidence. "
"No extra text.\n\n"
f"Q: {question}"
)
parsed = []
for _ in range(n_samples):
out = call_llm(client, prompt, temperature=0.8)
obj = try_parse_json(out)
if obj and "answer" in obj:
parsed.append(obj)
if not parsed:
return {"error": "no_valid_json"}
answers = [normalize_answer(str(o["answer"])) for o in parsed]
best, cnt = Counter(answers).most_common(1)[0]
return {
"best": best,
"count": cnt,
"valid": len(parsed),
"total": n_samples,
}
구조화 출력의 엄격함은 도구 호출 안정성의 핵심입니다. JSON 스키마/형식 강제는 Claude Tool Use 400 오류, JSON Schema로 끝내기에서 다룬 내용과 함께 보면 더 빠르게 정리됩니다.
체크리스트: 바로 적용할 때 이것만은
- 출력 포맷을 짧고 엄격하게(선택지, 숫자, JSON 키 제한)
n_samples는 작게 시작하고 적응형으로 늘리기- 정규화 함수는 태스크별로 분리해 테스트하기
- 동률/불확실 시 judge 단계(temperature
0) 추가 - 가능하면 검증기(파서/타입/규칙/실행)로 후보를 먼저 걸러내기
- 후보/분포/종료 조건을 로깅해 재현 가능하게 만들기
마무리
Self-Consistency 디코딩은 “더 똑똑한 모델”이 아니라 “더 안전한 선택 과정”으로 성능을 끌어올리는 방법입니다. CoT를 사용자에게 노출하지 않아도, 다중 샘플링과 집계를 통해 추론형 태스크의 정답률을 실용적으로 개선할 수 있습니다.
다만 SC는 만능이 아닙니다. 비용·지연을 관리하고, 투표가 실패하는 케이스(환각의 다수결, 포맷 흔들림)를 검증/판정 단계로 보완해야 합니다. 이 조합까지 갖추면, “CoT 없이도 성능↑”을 제품에서 재현 가능한 형태로 만들 수 있습니다.