- Published on
CoT 없이도 정확도↑ - Self-Consistency 구현법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
LLM을 제품에 붙이다 보면 “정확도를 더 올리고 싶은데, Chain-of-Thought(CoT)를 프롬프트에 넣자니 정책/보안/비용/지연이 부담”인 순간이 옵니다. 특히 사용자에게 중간 추론을 그대로 노출하면 민감정보가 섞이거나(프롬프트 인젝션에 의해), 모델이 스스로 그럴듯한 추론을 꾸며내는 부작용도 커집니다.
이때 꽤 강력한 대안이 Self-Consistency입니다. 핵심은 간단합니다.
- 같은 질문을 여러 번(샘플링) 물어본다
- 서로 다른 답 후보들을 모은다
- 가장 일관되게 많이 나온 답(또는 점수가 높은 답)을 최종 답으로 선택한다
즉, “추론을 공개적으로 길게 쓰게 만들지 않아도”, 확률적으로 더 안정적인 결론을 끌어내는 앙상블 기법에 가깝습니다. 이 글에서는 CoT를 노출하지 않는 형태로 Self-Consistency를 구현하는 방법, 집계 전략, 운영 시 비용/지연 트레이드오프까지 코드 중심으로 정리합니다.
Self-Consistency란 무엇인가
전통적으로 Self-Consistency는 “CoT를 여러 번 샘플링하고 최종 답을 다수결로 고른다”로 소개되는 경우가 많습니다. 하지만 제품 관점에서는 다음처럼 재정의하는 편이 더 유용합니다.
- 샘플링 기반 앙상블:
temperature를 올려 다양한 후보를 만들고 - 집계(aggregation): 후보들을 정규화/클러스터링한 뒤
- 선택(selection): 다수결, 가중 다수결, 신뢰도 기반 선택을 수행
중요한 포인트는 “CoT를 출력하도록 강제하지 않아도 된다”는 점입니다. 모델은 내부적으로 추론을 하겠지만, 우리는 최종 답만 받도록 스키마를 제한할 수 있습니다.
왜 CoT 없이도 효과가 나는가
모델이 한 번의 디코딩에서 실수하는 이유는 다양합니다.
- 검색/계산/규칙 적용에서의 단발성 오류
- 특정 토큰 선택이 이후 경로를 고정시키는
decoding path dependency - 애매한 문제에서의 편향된 첫 선택
Self-Consistency는 이 단발성 오류를 “여러 번 뽑아 평균내는” 방식으로 줄입니다. 특히 정답이 명확한 문제일수록, 정답 쪽으로 표가 몰리기 쉬워 정확도 향상이 큽니다.
구현 전략 1: 최종 답만 받는 JSON 스키마 고정
CoT를 노출하지 않으려면 출력 포맷을 강하게 제한하는 게 좋습니다. 예를 들어 다음처럼 “정답 필드만” 받습니다.
import json
from dataclasses import dataclass
from typing import Any, Dict, List, Tuple
# 예시: OpenAI 호환 API를 가정한 간단 래퍼(의사 코드)
# 실제론 openai / azure / vLLM 등 환경에 맞게 교체하세요.
def call_llm(prompt: str, temperature: float, seed: int | None = None) -> Dict[str, Any]:
"""모델 호출 결과를 dict로 반환한다고 가정."""
raise NotImplementedError
ANSWER_SCHEMA = {
"type": "object",
"properties": {
"answer": {"type": "string"},
"confidence": {"type": "number"}
},
"required": ["answer"],
"additionalProperties": False
}
def build_prompt(question: str) -> str:
# 부등호는 MDX 이슈가 있어 코드로 감쌌습니다.
return (
"You are a careful assistant. "
"Return ONLY valid JSON matching this schema: "
f"{json.dumps(ANSWER_SCHEMA)}\n"
"Do not include any explanation.\n"
f"Question: {question}\n"
)
여기서 confidence는 선택 사항입니다. 모델이 자기 확신을 과대평가하는 경향이 있으므로, 단독 사용보다는 집계 시 보조 신호로만 쓰는 걸 권합니다.
구현 전략 2: N회 샘플링 + 정규화 + 다수결
가장 기본은 N번 호출하고 answer를 정규화(normalize)한 뒤 다수결로 고르는 방식입니다.
import re
from collections import Counter
def normalize_answer(text: str) -> str:
# 케이스/공백/구두점/통화기호 등 간단 정규화
t = text.strip().lower()
t = re.sub(r"\s+", " ", t)
t = re.sub(r"[\.,!\?\"']", "", t)
return t
def self_consistency_vote(question: str, n: int = 7, temperature: float = 0.8) -> Tuple[str, List[str]]:
prompt = build_prompt(question)
candidates: List[str] = []
raw: List[str] = []
for i in range(n):
resp = call_llm(prompt, temperature=temperature, seed=i)
# resp["content"]가 JSON 문자열이라고 가정
obj = json.loads(resp["content"])
ans = obj["answer"]
raw.append(ans)
candidates.append(normalize_answer(ans))
counts = Counter(candidates)
best_norm, _ = counts.most_common(1)[0]
# 정규화된 best_norm에 대응하는 원문 답 하나를 대표로 선택
for a in raw:
if normalize_answer(a) == best_norm:
return a, raw
return raw[0], raw
N은 몇 번이 적당한가
- 보통
5~9사이가 실무에서 타협점이 됩니다. N을 늘리면 정확도는 오르지만, 비용과 지연이 선형으로 증가합니다.- 트래픽이 크면 “항상 N회”는 부담이므로, 아래의 **조기 종료(early stop)**를 함께 쓰는 게 좋습니다.
구현 전략 3: 조기 종료로 비용 줄이기
다수결이 충분히 굳어졌다면 더 뽑지 않고 멈춥니다. 예를 들어 k표 차이가 더 이상 뒤집히기 어려운 시점에 종료합니다.
from collections import defaultdict
def self_consistency_early_stop(
question: str,
max_n: int = 9,
min_n: int = 3,
temperature: float = 0.8,
margin: int = 2,
) -> Tuple[str, List[str]]:
prompt = build_prompt(question)
counts = defaultdict(int)
raw: List[str] = []
for i in range(max_n):
resp = call_llm(prompt, temperature=temperature, seed=i)
obj = json.loads(resp["content"])
ans = obj["answer"]
raw.append(ans)
norm = normalize_answer(ans)
counts[norm] += 1
# 최소 샘플 수 충족 후 조기 종료 판단
if i + 1 >= min_n:
top2 = sorted(counts.items(), key=lambda x: x[1], reverse=True)[:2]
top1_key, top1_cnt = top2[0]
top2_cnt = top2[1][1] if len(top2) > 1 else 0
# margin 이상 벌어지면 종료
if top1_cnt - top2_cnt >= margin:
# 대표 원문 반환
for a in raw:
if normalize_answer(a) == top1_key:
return a, raw
# 끝까지 가면 최다 득표
best_norm = max(counts.items(), key=lambda x: x[1])[0]
for a in raw:
if normalize_answer(a) == best_norm:
return a, raw
return raw[0], raw
실무 팁:
min_n은 너무 낮으면 우연에 흔들립니다.3정도부터 시작하는 편이 안전합니다.margin은 도메인 난이도에 따라 조정합니다. 쉬운 분류 문제는2, 어려운 생성 문제는3이상도 고려합니다.
구현 전략 4: “동의도”가 낮을 때만 Self-Consistency 켜기
항상 N회 샘플링하면 비용이 큽니다. 대신 1회 호출 결과가 애매할 때만 추가 샘플링을 수행하는 게 효율적입니다.
대표적인 트리거는 다음과 같습니다.
- 모델이 반환한
confidence가 낮다 - 답이 규격을 벗어났다(파싱 실패, 스키마 위반)
- 검증기(validator)에서 실패했다(예: 숫자 범위, 체크섬, 단위 일관성)
예: 1회 호출 후 검증 실패 시에만 Self-Consistency 실행
def validate_answer(question: str, answer: str) -> bool:
# 도메인별 룰을 넣으세요. 예: 숫자만 허용, 특정 포맷 강제 등
return len(answer.strip()) > 0
def answer_with_fallback(question: str) -> str:
prompt = build_prompt(question)
first = json.loads(call_llm(prompt, temperature=0.2, seed=0)["content"])["answer"]
if validate_answer(question, first):
return first
# 애매할 때만 더 비싼 전략 수행
best, _raws = self_consistency_early_stop(
question,
max_n=9,
min_n=3,
temperature=0.9,
margin=2,
)
return best
이 패턴은 운영 비용을 크게 줄입니다. “대부분의 쉬운 요청은 1회로 끝내고”, “어려운 요청만 앙상블로 보강”하는 형태입니다.
집계(aggregation) 고급화: 문자열 다수결의 한계
생성형 답변은 같은 의미라도 표현이 달라 다수결이 깨지기 쉽습니다.
1,000원vs1000원vs천 원Truevsyes- 요약 문장이 미세하게 다른 경우
이를 보완하는 방법은 3가지가 자주 쓰입니다.
1) 정규화 강화 + 도메인 토크나이징
- 숫자/단위는 파싱해서 표준화
- 날짜는
YYYY-MM-DD로 통일 - 불리언/선택지는 사전 매핑
from datetime import datetime
def normalize_choice(text: str, mapping: dict[str, str]) -> str:
t = normalize_answer(text)
return mapping.get(t, t)
2) 임베딩 기반 클러스터링 후 대표 선택
후보 문장들을 임베딩으로 묶고(코사인 유사도), 가장 큰 클러스터의 대표를 고릅니다. 이 방식은 “표현은 달라도 의미가 같은 답”을 합칠 수 있습니다.
구현은 환경마다 다르지만, 흐름은 아래와 같습니다.
- 후보
N개 생성 - 임베딩 벡터 계산
- 유사도 임계치(예:
0.9)로 연결 요소를 만들어 클러스터링 - 가장 큰 클러스터의 중심(또는 첫 문장)을 최종 답으로 선택
3) 메타-평가자(LLM Judge)로 최종 선택
후보가 5~9개일 때는 “후보들 중 정답을 고르는 작업”이 상대적으로 쉽습니다. 즉, 생성 모델을 한 번 더 호출해 pick_best(candidates)를 시키는 방식입니다.
주의점:
- Judge가 편향되면 오히려 악화될 수 있음
- 비용이 추가됨
그래도 “정답 검증 규칙을 코드로 쓰기 어려운 도메인”에서는 강력합니다.
CoT 비노출을 위한 프롬프트/출력 설계 체크리스트
- 출력은 반드시 JSON 등 구조화 포맷으로 제한
- “설명 금지”를 명시하고, 위반 시 재시도 로직을 둠
- 로그에는 원문 프롬프트/응답을 그대로 남기지 말고 마스킹/샘플링
- 모델이 중간 추론을 섞어도 파서가 견고하게 실패 처리하도록 설계
이런 “출력 제약 + 재시도 + 집계”는 LLM 파이프라인을 운영할 때 디버깅 경험을 크게 개선합니다. Next.js 기반 앱에서 캐시/상태가 꼬여 이상한 결과가 반복될 때의 대응 방식과도 결이 비슷합니다. 관련해서는 Next.js App Router RSC 캐시 꼬임 해결법도 함께 참고하면 좋습니다.
운영 관점: 지연, 비용, 관측가능성
Self-Consistency는 “정확도” 대신 “비용과 지연”을 지불합니다. 따라서 운영에서는 다음 지표를 꼭 봐야 합니다.
N평균/분포(조기 종료가 잘 먹히는지)- 합의율(Top1 득표율, Top1-Top2 격차)
- 실패율(파싱 실패, 검증 실패)
- 재시도율과 원인(스키마 위반, 빈 답, 금칙어 등)
또한 배포 파이프라인에서 모델 버전이 바뀌면 합의율이 달라질 수 있어, CI 단계에서 간단한 회귀 테스트를 돌리는 게 좋습니다. 모노레포 환경에서 워크플로우가 과도하게 늘어나는 문제를 피하려면 GitHub Actions 모노레포 CI/CD 워크플로우 폭증 막기 같은 패턴으로 테스트를 효율화하는 것도 실전 팁입니다.
실전 예시: 숫자/단위 답변에서 Self-Consistency
예를 들어 “요금 계산”처럼 단위와 숫자가 중요한 문제는 문자열 다수결이 취약합니다. 아래처럼 파싱해서 표준 형태로 만든 뒤 투표하면 안정성이 올라갑니다.
import decimal
def parse_price_krw(text: str) -> int | None:
t = text.strip().lower().replace(",", "")
t = t.replace("원", "").replace("krw", "").strip()
if not t:
return None
try:
# 정수 원 단위로 가정
return int(decimal.Decimal(t))
except decimal.InvalidOperation:
return None
def self_consistency_price(question: str, n: int = 7) -> int | None:
prompt = build_prompt(question)
prices: list[int] = []
for i in range(n):
obj = json.loads(call_llm(prompt, temperature=0.9, seed=i)["content"])
p = parse_price_krw(obj["answer"])
if p is not None:
prices.append(p)
if not prices:
return None
# 최빈값
return Counter(prices).most_common(1)[0][0]
이 방식은 “표현 다양성”을 제거하고 “의미 공간”에서 합의를 보게 해줍니다.
언제 Self-Consistency를 쓰면 좋은가
추천 케이스:
- 정답이 비교적 명확한 문제(분류, 단답, 계산, 규칙 적용)
- 출력 스키마가 단단한 경우(JSON, 선택지, 숫자)
- 단발성 오류가 제품 품질을 크게 훼손하는 경우
비추천/주의 케이스:
- 창작/브레인스토밍처럼 다양성이 가치인 경우
- 후보 간 의미가 모두 다르고 “정답” 정의가 어려운 경우
- 지연이 치명적인 실시간 UX
이때는 “항상 Self-Consistency” 대신 “검증 실패 시에만” 같은 조건부 적용이 현실적입니다.
마무리
Self-Consistency는 CoT를 사용자에게 노출하지 않으면서도 정확도를 끌어올릴 수 있는, 구현 난이도 대비 효과가 큰 기법입니다. 핵심은 샘플링과 집계이며, 실무에서는 다음 3가지를 같이 챙기면 성공 확률이 올라갑니다.
- 출력 스키마를 강하게 고정하고 파싱 실패를 안전하게 처리
- 조기 종료와 조건부 실행으로 비용/지연을 제어
- 문자열 다수결의 한계를 정규화/파싱/클러스터링으로 보완
다음 단계로는 “검증기(validator) 강화”와 “임베딩 클러스터링 기반 집계”를 붙여, 도메인에 맞는 안정적인 합의 메커니즘을 만드는 것을 추천합니다.