Published on

CoT 없이도 성능↑ - Self-Consistency 디코딩

Authors

서로 다른 경로로 여러 번 답을 생성한 뒤, 가장 일관된 답을 고르는 것만으로도 모델 성능이 꽤 올라갑니다. 이것이 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는 다른 축에서 성능을 끌어올립니다.

  1. 샘플링 분산을 이용한 오류 상쇄
  • 단일 샘플은 우연히 함정(잘못된 가정, 계산 실수, 단위 착각)에 빠질 수 있습니다.
  • 여러 샘플을 모으면 이런 우연 오류가 평균적으로 줄어듭니다.
  1. 답 공간(answer space)의 수렴 특성
  • 많은 문제는 최종 답이 짧고(숫자, 선택지, 엔티티), 오답은 다양합니다.
  • 이때 다수결은 강력합니다. 정답이 반복될 확률이 상대적으로 높기 때문입니다.
  1. 검증 가능한 포맷과 결합이 쉬움
  • “정답은 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가 낮으면 재샘플링
  • 예: p0.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 효과가 줄어듭니다.

  • temperature0.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 없이도 성능↑”을 제품에서 재현 가능한 형태로 만들 수 있습니다.