Published on

CoT 없이도 정확도↑ - Self-Consistency 구현법

Authors

서론

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원 vs 1000원 vs 천 원
  • True vs yes
  • 요약 문장이 미세하게 다른 경우

이를 보완하는 방법은 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 비노출을 위한 프롬프트/출력 설계 체크리스트

  1. 출력은 반드시 JSON 등 구조화 포맷으로 제한
  2. “설명 금지”를 명시하고, 위반 시 재시도 로직을 둠
  3. 로그에는 원문 프롬프트/응답을 그대로 남기지 말고 마스킹/샘플링
  4. 모델이 중간 추론을 섞어도 파서가 견고하게 실패 처리하도록 설계

이런 “출력 제약 + 재시도 + 집계”는 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) 강화”와 “임베딩 클러스터링 기반 집계”를 붙여, 도메인에 맞는 안정적인 합의 메커니즘을 만드는 것을 추천합니다.