Published on

CoT 노출 없이 추론력 올리기 - Self-Consistency

Authors

서버/프로덕션 환경에서 LLM 추론 품질을 올리려다 보면 곧바로 부딪히는 제약이 있습니다.

  • 사용자가 Chain-of-Thought(CoT)를 보면 프롬프트 인젝션 표면이 커질 수 있음
  • 정책/컴플라이언스 상 내부 추론을 노출하면 안 되는 경우가 많음
  • CoT를 길게 출력하면 토큰 비용과 지연 시간이 급증함

그런데도 “추론력”은 올려야 합니다. 이때 자주 쓰이는 접근이 Self-Consistency입니다. 핵심은 간단합니다.

  • 모델이 한 번에 낸 답을 믿지 말고
  • 다양한 추론 경로를 여러 번 샘플링한 뒤
  • 답을 집계(majority vote / weighted vote / verifier) 해서 최종 답을 고릅니다.

중요한 점은, 이 과정에서 CoT는 내부적으로만 사용하고 최종 사용자에게는 최종 답만 반환할 수 있다는 것입니다.

Self-Consistency란 무엇인가

Self-Consistency는 “하나의 정답으로 수렴하는 경로가 여러 개 존재한다”는 직관을 활용합니다. 온도를 올려 샘플링하면 모델은 서로 다른 중간 경로(사고 과정)를 만들 수 있고, 그 결과로 나오는 최종 답이 일관되게 반복되는 값이라면 그 답이 맞을 확률이 올라갑니다.

정리하면 다음 3단계입니다.

  1. Generate: 동일 문제를 N번 샘플링(temperature, top_p 등으로 다양성 확보)
  2. Extract: 각 샘플에서 최종 답만 추출(중간 추론은 폐기)
  3. Aggregate: 다수결/가중치/검증기로 최종 선택

이 방식은 특히 수학/논리/다단계 추론에서 효과가 좋고, RAG나 툴 호출이 섞인 워크플로우에서도 “한 번의 실수”를 평균화하는 데 도움 됩니다.

왜 CoT를 노출하지 않아도 성능이 오르나

Self-Consistency는 CoT를 사용자에게 보여주기 위해 존재하는 기법이 아닙니다. 오히려 CoT는 모델이 내부적으로 더 좋은 답을 만들도록 돕는 잠재 변수에 가깝습니다.

  • 샘플링을 통해 다양한 잠재 경로를 탐색
  • 정답은 여러 경로에서 반복 출현
  • 오답은 경로마다 들쭉날쭉하게 분산되는 경향

따라서 CoT는 “생성 과정”에서만 쓰고, “출력”에서는 숨겨도 성능 이득이 유지됩니다.

프로덕션에서의 설계 포인트

Self-Consistency는 단순히 N번 호출하면 끝이 아니라, 비용/지연/안정성을 함께 설계해야 합니다.

1) 샘플 다양성: temperature만 올리면 끝이 아니다

  • temperature를 너무 낮추면 샘플들이 거의 동일해져서 투표가 의미 없어짐
  • 너무 높이면 근거 없는 답이 늘어 집계가 흔들릴 수 있음

보통은 다음 조합을 많이 씁니다.

  • temperature는 중간값(예: 0.6~0.9)
  • top_p0.9 내외
  • 시스템 프롬프트에 “최종 답만 출력” 같은 형식 제약을 강하게 걸고
  • 내부적으로는 reasoning(또는 비공개 사고) 사용이 가능한 모델이면 그 채널을 활용

2) 답 추출: 형식이 무너지면 집계가 실패한다

Self-Consistency의 실패 원인 1위는 “투표할 답을 제대로 못 뽑는 것”입니다.

  • 숫자 문제인데 42.42The answer is 42가 섞임
  • 선택지 문제인데 BOption B가 섞임
  • JSON을 요구했는데 가끔 텍스트로 튀어나옴

해결책은 두 가지입니다.

  • 출력 형식을 매우 강하게 제한(예: JSON schema, function/tool call)
  • 추출기를 둬서 normalize(정규화) 후 집계

이 패턴은 툴 스키마가 조금만 어긋나도 전체 파이프라인이 깨질 수 있어서, 툴 호출을 쓰는 경우에는 스키마 오류 대응도 함께 준비해야 합니다. 관련해서는 Claude Tool Use 400 invalid_tool_schema 해결 가이드 같은 “실패 모드” 정리가 큰 도움이 됩니다.

3) 집계 전략: 단순 다수결만이 답이 아니다

가장 쉬운 집계는 majority vote이지만, 실무에서는 다음이 자주 필요합니다.

  • 가중치 투표: 로그확률 또는 모델 confidence(가능하면)로 가중
  • Verifier: 별도의 검증 프롬프트/모델로 후보 답을 채점
  • Self-critique: 후보 답을 다시 설명하게 한 뒤 일관성 체크(단, CoT 노출 금지라면 내부 채널에서만)

4) 비용/지연: N을 고정하지 말고 적응형으로

항상 N=10을 돌리면 비용이 폭발합니다. 보통은 적응형이 더 낫습니다.

  • 최소 N_min(예: 3)부터 시작
  • 투표가 k표 이상으로 벌어지면 조기 종료
  • 애매하면 추가 샘플을 더 뽑아 확신도를 끌어올림

이때 서버 리소스(특히 메모리)도 같이 봐야 합니다. 에이전트/RAG에서 컨텍스트가 커지면 호출당 메모리/토큰이 증가하고, 다중 샘플링이 곧바로 비용 폭증으로 이어집니다. 장기적으로는 RAG 구조 최적화도 함께 고려하는 편이 좋습니다. 예를 들어 AutoGPT 메모리 팽창·환각 줄이는 RAG+벡터DB처럼 “컨텍스트를 줄이면서 정확도를 올리는” 방향과 Self-Consistency는 상호 보완 관계입니다.

구현 예제 1: 파이썬으로 Self-Consistency 투표기

아래 예제는 핵심 아이디어(샘플링, 답 추출, 다수결, 조기 종료)를 담은 간단한 형태입니다. 실제로는 호출부를 사용하는 SDK에 맞게 바꾸면 됩니다.

import re
from collections import Counter
from typing import Optional


def normalize_answer(text: str) -> str:
    # 예: 숫자 답만 뽑는 문제라면 숫자만 추출
    m = re.search(r"-?\d+(?:\.\d+)?", text)
    if m:
        return m.group(0)

    # 그 외에는 공백/대소문자 정규화
    return re.sub(r"\s+", " ", text.strip()).lower()


def majority_vote(answers: list[str]) -> tuple[str, float]:
    c = Counter(answers)
    best, cnt = c.most_common(1)[0]
    return best, cnt / len(answers)


class LLMClient:
    def complete(self, prompt: str, temperature: float, top_p: float) -> str:
        # TODO: 실제 LLM API 호출로 교체
        raise NotImplementedError


def self_consistency_solve(
    llm: LLMClient,
    prompt: str,
    n_min: int = 3,
    n_max: int = 9,
    early_stop_conf: float = 0.67,
    temperature: float = 0.8,
    top_p: float = 0.9,
) -> dict:
    samples: list[str] = []
    norm_answers: list[str] = []

    for i in range(n_max):
        raw = llm.complete(prompt, temperature=temperature, top_p=top_p)
        samples.append(raw)

        a = normalize_answer(raw)
        norm_answers.append(a)

        if i + 1 >= n_min:
            best, conf = majority_vote(norm_answers)
            if conf >= early_stop_conf:
                return {
                    "answer": best,
                    "confidence": conf,
                    "used_samples": i + 1,
                }

    best, conf = majority_vote(norm_answers)
    return {"answer": best, "confidence": conf, "used_samples": n_max}

포인트는 다음입니다.

  • normalize_answer가 사실상 품질을 좌우합니다. 문제 유형별로 별도 구현하세요.
  • early_stop_conf는 트래픽/비용/정확도 목표에 맞춰 튜닝합니다.
  • 최종 반환은 answer만 주고, samples(중간 텍스트)는 로깅 정책에 따라 저장/폐기합니다.

구현 예제 2: JSON 강제 + 투표 안정화

형식이 자주 깨지는 환경이라면 “답을 JSON 하나로만 내라”를 강하게 요구하는 편이 낫습니다. 아래는 모델 출력이 반드시 JSON이어야 한다고 가정하고, 파싱 실패 시 재시도하는 패턴입니다.

import json
from collections import Counter


def parse_json_answer(text: str) -> str | None:
    try:
        obj = json.loads(text)
        # 예: {"final": "B"}
        if isinstance(obj, dict) and "final" in obj:
            return str(obj["final"]).strip()
        return None
    except Exception:
        return None


def aggregate_votes(votes: list[str]) -> str:
    return Counter(votes).most_common(1)[0][0]


def run_sc_json(llm, question: str, n: int = 5) -> str:
    prompt = (
        "You must answer in JSON only. "
        "Return exactly: {\"final\": \"...\"}. "
        "Do not include any other keys or text.\n\n"
        f"Question: {question}"
    )

    votes: list[str] = []
    for _ in range(n):
        raw = llm.complete(prompt, temperature=0.8, top_p=0.9)
        ans = parse_json_answer(raw)
        if ans is None:
            # 파싱 실패는 버리거나, 별도 재시도 정책을 둘 수 있음
            continue
        votes.append(ans)

    if not votes:
        # 모든 샘플이 실패하면 fallback 정책 필요
        return "UNKNOWN"

    return aggregate_votes(votes)

이 접근은 “집계 가능한 형태”를 보장하기 좋지만, 모델이 규칙을 어기는 순간 전부 무효가 될 수 있습니다. 그래서 실무에서는 다음 중 하나를 함께 둡니다.

  • JSON 파싱 실패 시 1회 정도만 재시도
  • 파싱 실패율이 일정 임계치를 넘으면 temperature를 낮추거나 모델을 바꿈
  • 툴 호출(function calling)로 아예 구조화된 응답을 강제

Self-Consistency를 어디에 붙이면 효과가 큰가

1) 최종 답이 단일 값으로 정리되는 문제

  • 수학 문제의 최종 수치
  • 객관식 선택지
  • 규칙 기반 분류(label)

이런 경우는 normalize와 투표가 쉽고, Self-Consistency의 비용 대비 효과가 좋습니다.

2) RAG 파이프라인의 “최종 결정” 단계

RAG에서 검색 결과가 애매하면 한 번의 생성으로는 흔들립니다. 이때는 다음처럼 구성할 수 있습니다.

  • 검색은 1회만 수행(비용 절감)
  • 동일 컨텍스트를 두고 생성만 N회 샘플링
  • 답을 투표 또는 verifier로 확정

벡터 검색 자체가 느리거나 튜닝이 필요하다면, 먼저 검색 품질/속도를 안정화하는 게 선행입니다. 예를 들어 Milvus/HNSW 기반에서 정확도가 흔들리면 검색 단계부터 손봐야 합니다. 관련해서는 RAG 정확도 폭락? Milvus HNSW 튜닝 7가지 같은 체크리스트를 같이 보는 편이 좋습니다.

3) 에이전트의 “행동 선택” 단계(툴 호출 전)

에이전트가 다음 행동을 고르는 단계에서 Self-Consistency를 쓰면, 잘못된 툴 호출을 줄이는 데 도움이 됩니다.

  • 후보 행동을 여러 번 샘플링
  • 행동 타입별로 투표
  • 선택된 행동만 실행

단, 툴 호출은 부작용(side effect)이 있을 수 있으니 “샘플링 중에는 실행하지 말고 계획만 만들기”가 중요합니다.

실패 모드와 대응

1) 다수결이 오답으로 수렴

모델이 체계적으로 편향된 오답을 내면, 많이 뽑아도 오답이 다수일 수 있습니다.

대응:

  • verifier(채점기)를 추가해 후보 답을 검증
  • 외부 근거(RAG, 계산기 툴)로 정답성 확인
  • 프롬프트에 제약(단위, 형식, 조건)을 강화

2) 답이 여러 형태로 분산되어 표가 갈림

예: 1/2, 0.5, 50%가 서로 다른 답으로 집계됨

대응:

  • normalize를 강하게(유리수 변환, 단위 통일)
  • 문제 유형별 canonical form 정의

3) 비용이 감당 안 됨

대응:

  • 적응형 N(조기 종료)
  • 쉬운 문제는 1회, 어려운 문제만 Self-Consistency 적용(난이도 분기)
  • 컨텍스트 축소(RAG 압축, 불필요한 히스토리 제거)

CoT 비노출을 위한 프롬프트 패턴

Self-Consistency를 쓰면서도 사용자에게 CoT를 노출하지 않으려면 “출력 제한”을 명확히 해야 합니다.

  • “최종 답만 출력”
  • “설명/과정/근거를 쓰지 말 것”
  • “형식은 정확히 한 줄” 또는 “JSON만”

예:

You are a solver. Think internally but do not reveal reasoning.
Return only the final answer, with no explanation.
If the answer is a number, output only the number.

여기서 중요한 건, Self-Consistency는 샘플을 여러 번 뽑기 때문에 “한 번쯤 규칙을 어기는 출력”이 섞일 가능성이 올라간다는 점입니다. 따라서 형식 강제와 추출/정규화는 선택이 아니라 필수입니다.

정리

Self-Consistency는 CoT를 사용자에게 공개하지 않고도 추론 성능을 끌어올릴 수 있는, 비교적 단순하지만 강력한 방법입니다. 실무 적용의 핵심은 다음 4가지입니다.

  • 샘플 다양성을 확보하되 과도한 랜덤성은 피할 것
  • 답 추출/정규화 레이어를 반드시 둘 것
  • 다수결만 고집하지 말고 verifier나 가중치를 고려할 것
  • 비용/지연을 위해 적응형 N과 조기 종료를 설계할 것

이 4가지만 지키면, “CoT는 숨기고 결과는 더 정확하게”라는 목표에 꽤 가까이 갈 수 있습니다.