Published on

Self-Consistency로 CoT 신뢰도 높이는 실전

Authors

LLM을 제품에 붙여보면 금방 마주치는 문제가 있습니다. 같은 프롬프트인데도 한 번은 정답, 한 번은 그럴듯한 오답을 내는 현상입니다. 특히 Chain-of-Thought(이하 CoT) 기반 추론은 “설명”이 붙기 때문에 더 믿음직해 보이지만, 설명이 논리적으로 그럴싸해도 결론이 틀릴 수 있습니다.

이때 현업에서 가장 빨리 효과를 보는 방법 중 하나가 Self-Consistency입니다. 아이디어는 단순합니다. 같은 문제를 여러 번 풀게 한 뒤, 가장 일관되게 등장하는 답을 채택합니다. CoT를 “잘 쓰는 법”보다 “잘 검증하는 법”에 가깝고, 구현 난이도 대비 체감 성능이 좋습니다.

아래에서는 Self-Consistency의 원리, 언제 효과적인지, 서비스 적용 시 비용과 지연을 어떻게 제어하는지, 그리고 바로 가져다 쓸 수 있는 코드 패턴까지 실전 관점으로 정리합니다.

Self-Consistency란 무엇인가

Self-Consistency는 2022년경부터 널리 알려진 추론 강화 기법으로, 요약하면 다음과 같습니다.

  1. 동일한 질문에 대해 서로 다른 추론 경로를 여러 개 샘플링한다
  2. 각 샘플이 제시한 최종 답을 수집한다
  3. 최종 답들에 대해 다수결(majority vote) 혹은 가중 투표를 수행한다
  4. 선택된 답을 최종 출력으로 반환한다

핵심은 “추론 과정의 다양성”입니다. 따라서 보통 temperature를 0으로 고정하는 대신, 적당히 올려서 다양한 경로를 생성합니다.

CoT와의 관계

CoT가 “한 번의 길게 생각하기”라면, Self-Consistency는 “여러 번 다르게 생각해 보고 가장 많이 나온 결론을 고르기”입니다.

중요한 점은, Self-Consistency는 CoT를 반드시 요구하지 않습니다. 하지만 실제로는 CoT를 함께 쓰는 편이 유리합니다.

  • CoT는 중간 추론을 노출(또는 내부적으로 생성)하여 다양한 경로 샘플링을 쉽게 만든다
  • Self-Consistency는 그 다양한 경로 중 우연히 맞은 한 번이 아니라 반복적으로 맞는 패턴을 선택한다

언제 Self-Consistency가 특히 잘 먹히나

Self-Consistency는 모든 문제에서 만능은 아닙니다. “정답이 수렴하는 문제”에서 강합니다.

효과가 좋은 유형

  • 수학/논리 퍼즐/정형 추론: 답 공간이 작고 정답이 명확
  • 규칙 기반 변환: 예를 들어 포맷팅 규칙, 간단한 파싱 규칙
  • 도메인 지식이 충분히 들어간 폐쇄형 질의: 문서 기반 QA에서 근거가 명확한 경우

효과가 제한적인 유형

  • 창의적 글쓰기: 정답이 없고 다양성이 목적
  • 의견/취향: 다수결이 “정답”을 의미하지 않음
  • 답 공간이 매우 큰 생성형 작업: 예를 들어 코드 생성에서 완전 동일한 정답 문자열을 다수결로 뽑기 어려움

이런 경우엔 Self-Consistency를 “최종 문자열”이 아니라 “검증 가능한 구조”로 투표하도록 바꾸는 것이 중요합니다. 예를 들어 JSON의 특정 필드, 혹은 계산 결과 숫자만 투표 대상으로 삼는 식입니다.

실전 설계: 무엇을 투표할 것인가

현업 적용에서 가장 흔한 실패는 “그냥 출력 전체를 투표”하려는 시도입니다. 샘플마다 표현이 달라서 표가 갈리고, 다수결이 깨집니다.

따라서 투표 대상은 가능한 한 정규화된 값이어야 합니다.

추천 패턴 1: 최종 답만 강제 추출

프롬프트에서 최종 답을 특정 태그나 키로만 출력하게 하고, 그 값만 투표합니다.

  • 출력 포맷 예: final_answer 필드
  • CoT는 숨기거나(내부 추론), 혹은 별도 필드로 분리

추천 패턴 2: 구조화된 중간 산출물 투표

예를 들어 “정답과 근거 문장 id”를 같이 내게 하고, 정답은 다수결로, 근거는 정답을 낸 샘플들 중 점수가 높은 것을 선택합니다.

이 패턴은 RAG에서 특히 유용합니다. 검색이 흔들리면 답도 흔들립니다. 검색·리랭킹을 먼저 안정화하는 것도 중요하니, 필요하면 RAG 회수율 급락? 하이브리드+리랭커 튜닝도 함께 참고하면 좋습니다.

파라미터 튜닝: n, temperature, top_p의 현실적인 기준

Self-Consistency의 품질은 샘플 수 n에 크게 좌우되지만, 비용과 지연도 선형으로 증가합니다.

권장 출발점

  • n: 5 또는 7
  • temperature: 0.7 전후
  • top_p: 0.9 전후

정답이 매우 불안정하면 n을 늘리기 전에 먼저 아래를 점검합니다.

  • 프롬프트가 과도하게 장황해서 모델이 핵심 제약을 놓치지 않는가
  • 출력 포맷이 애매해서 파싱/정규화가 깨지지 않는가
  • RAG라면 컨텍스트가 과다/부족하지 않은가

비용/지연을 줄이는 2단계 전략

항상 n=7로 돌리면 비용이 큽니다. 실전에서는 적응형(Adaptive) Self-Consistency가 잘 맞습니다.

  1. 먼저 n=3으로 샘플링
  2. 다수결이 강하게 나왔으면(예: 3개 중 3개 동일) 즉시 종료
  3. 표가 갈리면 n=7까지 추가 샘플링

이 방식은 평균 비용을 크게 낮춥니다.

구현 예시: Node.js로 Self-Consistency 투표기 만들기

아래 예시는 OpenAI 호환 Chat Completions 스타일을 가정한 “개념 코드”입니다. 핵심은

  • 출력에서 final_answer만 뽑는다
  • 정규화해서 키로 만든다
  • 다수결로 선택한다
import crypto from "node:crypto";

function normalizeAnswer(s) {
  return String(s)
    .trim()
    .replaceAll(/\s+/g, " ")
    .toLowerCase();
}

function majorityVote(items) {
  const count = new Map();
  for (const x of items) {
    count.set(x, (count.get(x) ?? 0) + 1);
  }
  let best = null;
  let bestN = -1;
  for (const [k, v] of count.entries()) {
    if (v > bestN) {
      best = k;
      bestN = v;
    }
  }
  return { winner: best, votes: bestN, dist: count };
}

async function callLLM({ client, prompt, temperature }) {
  // client는 OpenAI SDK 또는 호환 클라이언트라고 가정
  const res = await client.chat.completions.create({
    model: "gpt-4.1-mini",
    temperature,
    top_p: 0.9,
    messages: [
      {
        role: "system",
        content:
          "You are a careful reasoner. Output JSON only with keys: final_answer, confidence." +
          " Do not include extra keys.",
      },
      { role: "user", content: prompt },
    ],
    response_format: { type: "json_object" },
  });

  const text = res.choices[0].message.content;
  const obj = JSON.parse(text);
  return {
    final_answer: obj.final_answer,
    confidence: obj.confidence,
    raw: obj,
  };
}

export async function selfConsistency({ client, prompt, n = 5 }) {
  const temperature = 0.7;

  const samples = [];
  for (let i = 0; i < n; i++) {
    const s = await callLLM({ client, prompt, temperature });
    samples.push(s);
  }

  const normalized = samples.map((s) => normalizeAnswer(s.final_answer));
  const { winner, votes, dist } = majorityVote(normalized);

  // winner에 해당하는 원본 샘플 하나를 대표로 선택(가장 높은 confidence)
  const candidates = samples
    .map((s, idx) => ({ ...s, idx, norm: normalized[idx] }))
    .filter((s) => s.norm === winner)
    .sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0));

  const picked = candidates[0];

  return {
    answer: picked.final_answer,
    votes,
    n,
    dist: Object.fromEntries([...dist.entries()]),
    sample_hash: crypto
      .createHash("sha256")
      .update(JSON.stringify(samples))
      .digest("hex"),
  };
}

포인트

  • response_format을 JSON으로 강제해 파싱 안정성을 높였습니다.
  • 다수결은 정규화된 문자열 기준으로 수행합니다.
  • 동률 처리, 숫자/단위 정규화(예: 1,000 vs 1000) 같은 디테일을 추가하면 안정성이 더 올라갑니다.

고급 패턴: 다수결만으로 부족할 때

Self-Consistency는 “가장 자주 나온 답”을 고르지만, 다수가 틀릴 수도 있습니다. 특히 모델이 특정 편향된 휴리스틱에 빠지면 오답이 다수파가 되기도 합니다.

1) 가중 투표: 모델의 자기평가를 그대로 믿지 말고 보정하기

샘플마다 confidence를 받더라도, 모델의 자기 확신은 종종 과대평가됩니다. 그래도 완전히 버리기보다는 다음처럼 제한적으로 씁니다.

  • 기본은 다수결
  • 다수결이 약할 때(예: 7개 중 3표, 2표, 2표)만 confidence 합으로 타이브레이크

2) 검증자(Verifier) 추가

“답을 고르는 모델”과 “답을 검증하는 모델”을 분리하면 성능이 더 오릅니다.

  • 1단계: Self-Consistency로 후보 답 k개 선정
  • 2단계: Verifier가 각 후보를 채점하고 1개 선택

이 구조는 지연이 늘지만, 고위험 도메인(금융/의료/보안)에서 유용합니다.

3) 도메인 규칙 기반 체크

정답이 숫자라면 간단한 산술 검증, 포맷이 정해져 있으면 스키마 검증을 붙이세요. “LLM을 LLM로만 검증”하면 비용이 커지고 실패 모드가 겹칩니다.

운영 관점: 재현성, 관측성, 장애 대응

Self-Consistency는 본질적으로 샘플링을 쓰므로, 운영에서 다음이 중요합니다.

재현성

  • 요청별로 seed를 고정할 수 있는 API라면 seed를 저장
  • 불가능하다면 최소한 입력 프롬프트, 모델 버전, 파라미터, 샘플 결과 해시를 저장

위 코드의 sample_hash는 “이 요청에서 어떤 샘플들이 나왔는지”를 추적하기 위한 최소 장치입니다.

관측성 지표

  • vote_strength: 최다 득표 수 나누기 n (예: 5/7)
  • entropy: 분포가 얼마나 퍼져 있는지
  • disagreement_rate: 1표라도 다른 답이 나온 비율

이 지표들은 “언제 n을 늘려야 하는지”, “언제 verifier로 넘겨야 하는지”를 결정하는 신호가 됩니다.

지연 최적화

샘플을 순차로 돌리면 느립니다. 가능하면 병렬 호출을 쓰세요.

export async function selfConsistencyParallel({ client, prompt, n = 7 }) {
  const temperature = 0.7;

  const tasks = Array.from({ length: n }, () =>
    callLLM({ client, prompt, temperature })
  );

  const samples = await Promise.all(tasks);

  const normalized = samples.map((s) => normalizeAnswer(s.final_answer));
  const { winner, votes, dist } = majorityVote(normalized);

  return {
    answer: samples.find((s, i) => normalized[i] === winner)?.final_answer,
    votes,
    n,
    dist: Object.fromEntries([...dist.entries()]),
  };
}

병렬 호출은 레이트 리밋과 비용 폭증 리스크가 있으니, 큐잉/동시성 제한을 함께 두는 편이 안전합니다.

LLM 서빙을 KServe나 vLLM로 운영 중이라면, 다중 샘플링은 502나 타임아웃을 더 자주 유발할 수 있습니다. 인퍼런스 장애를 잡는 루틴은 KServe vLLM 배포 후 502? InferenceService 8단계를 참고해 “인프라 레벨 병목”부터 제거하세요.

RAG + Self-Consistency: 검색 흔들림까지 포함해 안정화하기

RAG에서는 “모델 추론의 불확실성” 외에 “검색 결과의 불확실성”이 추가됩니다. 이때 Self-Consistency를 적용하는 방법은 두 가지입니다.

  1. 고정 컨텍스트: 검색은 1회만 하고, 같은 컨텍스트로 n번 추론
  2. 가변 컨텍스트: 검색도 매번(또는 일부) 다시 하고, end-to-end로 n번 샘플링

일반적으로는 1번이 비용 대비 효율이 좋습니다. 2번은 검색 자체가 불안정하거나, 리랭커가 확률적 요소를 포함할 때 고려합니다.

또한 “근거가 있는 답만 채택”하도록 정책을 두면 환각을 줄일 수 있습니다.

  • 답과 함께 citations(문서 id, 문장 id)를 강제
  • citations가 비어 있으면 해당 샘플은 투표에서 제외

보안/정확도 요구가 높은 업무에서의 체크리스트

Self-Consistency를 붙였다고 해서 “정확도 문제가 해결”되지는 않습니다. 대신 실패 모드를 더 빨리 탐지하고 불확실한 케이스를 격리할 수 있어야 합니다.

  • vote_strength가 낮으면 “확신 낮음”으로 응답하거나 사람 검토로 라우팅
  • 중요한 작업은 Verifier 또는 규칙 검증을 추가
  • 프롬프트 인젝션 방어는 별개로 필요(특히 RAG)

분산 트랜잭션이나 보상 트랜잭션 같은 영역에서는 “한 번의 잘못된 판단”이 중복 실행으로 이어질 수 있습니다. LLM을 오케스트레이션에 쓰는 경우, 멱등성/중복 방지 설계는 반드시 별도로 챙겨야 합니다. 관련해서는 Saga 패턴에서 보상 트랜잭션 중복 실행 막는 법도 함께 보면 연결 지점이 많습니다.

실전 적용 순서: 가장 안전한 롤아웃

  1. 출력 포맷 고정: JSON 스키마로 final_answer 분리
  2. 정규화/투표 로직 구현: 문자열, 숫자, 단위 정규화 포함
  3. 적응형 n: 먼저 n=3, 불일치 시 확장
  4. 관측성 추가: 분포/합의 강도 지표 로깅
  5. 고위험 구간에만 Verifier: 전체가 아니라 예외 케이스에만 적용

이 순서대로 가면 “성능 향상”과 “비용/지연 통제”를 동시에 달성하기 쉽습니다.

마무리

Self-Consistency는 CoT의 장점을 살리면서도, 단발성 샘플링의 불안정성을 완화하는 실전형 기법입니다. 다만 성공의 관건은 n을 무작정 늘리는 것이 아니라, 무엇을 투표할지(정규화된 최종 답), 언제 더 샘플링할지(적응형 종료), **불확실성을 어떻게 운영 정책으로 흡수할지(관측성과 라우팅)**에 있습니다.

CoT를 “잘 말하게” 만드는 것만큼, “일관되게 맞게” 만드는 장치를 함께 두면 LLM 기능을 제품에 넣었을 때의 신뢰도가 확실히 달라집니다.