Published on

CoT 없이 성능 올리는 Self-Consistency 구현법

Authors

서론에서부터 결론까지 한 문장으로 요약하면, Self-Consistency는 “한 번의 그럴듯한 답” 대신 “여러 번의 독립 시도에서 가장 일관된 답”을 고르는 방식입니다. 문제는 많은 자료가 CoT(Chain-of-Thought) 기반으로 설명된다는 점입니다. 하지만 실제 서비스에서는 CoT를 사용자에게 노출하지 않거나(보안·정책·UX), 아예 생성 자체를 최소화하고 싶을 때가 많습니다.

이 글에서는 CoT 없이도 Self-Consistency를 적용해 성능을 올리는 실전 구현법을 다룹니다. 핵심은 (1) 샘플링을 어떻게 다양화할지, (2) 후보 답을 어떻게 정규화하고 집계할지, (3) 검증/재질의를 어떻게 붙일지를 분리해 설계하는 것입니다.

관련해서 “리소스가 빡빡한 로컬 LLM 환경에서 비용을 어떻게 줄일까?”가 고민이라면 Transformers 로컬 LLM OOM 해결 - 4bit+KV캐시도 같이 보면, n회 샘플링을 운영 비용 안에서 맞추는 데 도움이 됩니다.

Self-Consistency를 CoT 없이 쓰는 관점

전통적인 Self-Consistency는 대개 다음 흐름입니다.

  • 동일 질문을 temperature를 올려 여러 번 풉니다.
  • 각 샘플의 “추론 경로”가 다르더라도 결론이 같으면 그 결론을 채택합니다.

여기서 CoT가 하는 역할은 “다양한 풀이 경로”를 자연스럽게 만들어주는 촉매입니다. 하지만 CoT를 노출하지 않으려면, **풀이 경로 대신 ‘답의 다양성’과 ‘답의 일관성’**만을 목표로 삼으면 됩니다.

즉 CoT 없이 Self-Consistency를 쓴다는 건:

  1. 모델에게 결론만 출력하게 한다(혹은 구조화된 JSON만 출력).
  2. 같은 질문을 여러 번 던져 서로 다른 후보 답을 얻는다.
  3. 후보 답을 정규화(normalize) 한 뒤, 다수결/가중 투표/스코어링으로 최종 답을 선택한다.
  4. 필요하면 선택된 답을 검증 모델/규칙/툴로 재검증한다.

이때 중요한 포인트는 **“다수결” 자체가 아니라 “정규화와 집계의 품질”**입니다. 문자열이 조금만 달라도 다른 답으로 취급되면 Self-Consistency가 무력화됩니다.

아키텍처: 샘플링, 집계, 검증을 분리하라

운영에서 가장 깔끔한 형태는 아래 3단 분리입니다.

  • Sampler: 같은 프롬프트로 n개의 후보 답 생성
  • Aggregator: 후보 답을 정규화하고 최종 답 선택
  • Verifier(선택): 최종 답을 규칙/모델/툴로 검증하고 실패 시 재시도

이 분리는 “왜 이런 답이 나왔는지”를 사용자에게 설명하지 않고도, 시스템적으로 신뢰도를 올리는 데 유리합니다.

샘플링 전략: 다양성을 강제로 만든다

CoT를 빼면 샘플 간 다양성이 줄어들 수 있습니다. 그래서 아래 레버를 조합합니다.

  • temperature 증가 (예: 0.7~1.0)
  • top_p 조정 (예: 0.9~0.95)
  • seed 변경 (지원하는 API라면)
  • system 프롬프트에 “다른 가능성을 고려하라” 같은 메타 지시 추가
  • 출력 포맷을 강제해 후처리를 쉽게 만들기(예: JSON)

중요: CoT를 금지할 때는 프롬프트에서 부등호 문자를 그대로 쓰면 MDX에서 문제가 될 수 있으니, 문서/프롬프트 예시에서는 반드시 인라인 코드로 감싸거나 <, >로 치환하세요.

구현 1: OpenAI 스타일 API로 Self-Consistency (TypeScript)

아래 코드는 “CoT 없이 결론만”을 JSON으로 강제하고, n회 샘플링 후 다수결로 고르는 가장 기본적인 구현입니다.

type Candidate = {
  answer: string;
  confidence?: number; // 모델이 숫자로 내주면 가중치에 활용 가능
};

function normalizeAnswer(raw: string): string {
  return raw
    .trim()
    .toLowerCase()
    .replace(/\s+/g, " ")
    .replace(/["'`]/g, "")
    .replace(/[.,!?]/g, "");
}

function majorityVote(candidates: Candidate[]): { final: Candidate; stats: Record<string, number> } {
  const counts: Record<string, number> = {};
  const rep: Record<string, Candidate> = {};

  for (const c of candidates) {
    const key = normalizeAnswer(c.answer);
    counts[key] = (counts[key] ?? 0) + 1;
    rep[key] = rep[key] ?? c;
  }

  const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
  const winnerKey = sorted[0]?.[0];
  if (!winnerKey) throw new Error("No candidates");

  return { final: rep[winnerKey], stats: counts };
}

async function sampleOnce(prompt: string): Promise<Candidate> {
  // 예시: OpenAI 호환 Chat Completions API
  // 실제 SDK/엔드포인트는 사용 환경에 맞게 바꾸세요.
  const res = await fetch("/api/llm", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      model: "gpt-4.1-mini",
      temperature: 0.9,
      top_p: 0.95,
      messages: [
        {
          role: "system",
          content:
            "You are a precise assistant. Do not reveal chain-of-thought. Output only valid JSON.",
        },
        {
          role: "user",
          content:
            `${prompt}\n\nReturn JSON with keys: answer (string), confidence (number 0..1).`,
        },
      ],
      response_format: { type: "json_object" },
    }),
  });

  const data = await res.json();
  // data parsing은 실제 응답 구조에 맞게 조정
  const content = data.choices[0].message.content;
  const parsed = JSON.parse(content);

  return {
    answer: String(parsed.answer ?? "").trim(),
    confidence: typeof parsed.confidence === "number" ? parsed.confidence : undefined,
  };
}

export async function selfConsistentAnswer(prompt: string, n = 7) {
  const candidates: Candidate[] = [];
  for (let i = 0; i < n; i++) {
    candidates.push(await sampleOnce(prompt));
  }

  const { final, stats } = majorityVote(candidates);
  return { final, candidates, stats };
}

이 구현의 함정

  • 정규화가 약하면 사실상 “문자열 비교”가 되어 다수결이 깨집니다.
  • 짧은 답(예: A, B)은 잘 되지만, 서술형 답은 변형이 많아 집계가 어렵습니다.
  • 모델이 자신감(confidence)을 대충 내면 가중치로 쓰기 어렵습니다.

그래서 실전에서는 “서술형”을 그대로 투표하지 말고, 답을 구조화하거나 라벨로 축약하는 단계를 넣습니다.

구현 2: 서술형을 라벨로 축약해 집계 품질 올리기

예를 들어 분류 문제라면, 후보 답을 “긴 설명”이 아니라 고정 라벨로 받는 게 훨씬 안정적입니다.

  • 라벨 예: "YES", "NO", "UNKNOWN"
  • 또는 선택지: "A", "B", "C"
type Label = "YES" | "NO" | "UNKNOWN";

function normalizeLabel(x: string): Label {
  const v = x.trim().toUpperCase();
  if (v === "YES" || v === "Y") return "YES";
  if (v === "NO" || v === "N") return "NO";
  return "UNKNOWN";
}

function voteLabel(labels: string[]): { label: Label; counts: Record<Label, number> } {
  const counts: Record<Label, number> = { YES: 0, NO: 0, UNKNOWN: 0 };
  for (const l of labels) counts[normalizeLabel(l)]++;

  const label = (Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0] as Label) ?? "UNKNOWN";
  return { label, counts };
}

이 패턴은 Q&A, 정책 판단, 라우팅(어떤 툴을 호출할지) 같은 곳에서 특히 강합니다.

가중 Self-Consistency: “다수”가 아니라 “신뢰 합”으로 고르기

다수결은 간단하지만, 후보마다 품질이 크게 다르면 손해를 봅니다. 이때는 confidence 또는 외부 스코어를 가중치로 사용합니다.

  • 모델 confidence(주의: 캘리브레이션 필요)
  • 검증기 점수(규칙/테스트/툴 결과)
  • retrieval 점수(근거 문서 유사도)
function weightedVote(candidates: Candidate[]) {
  const score: Record<string, number> = {};
  const rep: Record<string, Candidate> = {};

  for (const c of candidates) {
    const key = normalizeAnswer(c.answer);
    const w = typeof c.confidence === "number" ? Math.max(0, Math.min(1, c.confidence)) : 1;
    score[key] = (score[key] ?? 0) + w;
    rep[key] = rep[key] ?? c;
  }

  const winnerKey = Object.entries(score).sort((a, b) => b[1] - a[1])[0]?.[0];
  if (!winnerKey) throw new Error("No candidates");

  return { final: rep[winnerKey], score };
}

실무 팁: 처음부터 가중치를 믿기보다, 다수결로 1차, 가중치로 동률 깨기 같은 보수적 전략이 안정적입니다.

검증기(Verifier) 붙이기: CoT 없이도 신뢰도를 올리는 핵심

Self-Consistency는 “모델이 자주 하는 실수”를 줄이는 데 강하지만, 모델이 일관되게 틀리는 경우를 막지는 못합니다. 그래서 최종 답에 대해 간단한 검증을 붙이면 효과가 커집니다.

검증은 크게 3종류입니다.

  1. 규칙 기반: 포맷, 금칙어, 길이, 숫자 범위
  2. 툴 기반: 계산기, DB 조회, API 호출로 팩트 체크
  3. 모델 기반: 별도 프롬프트로 “이 답이 조건을 만족하는지 YES/NO”만 판단

모델 기반 검증 예시(판정만)

async function verifyAnswer(question: string, answer: string): Promise<boolean> {
  const res = await fetch("/api/llm", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      model: "gpt-4.1-mini",
      temperature: 0,
      messages: [
        {
          role: "system",
          content:
            "You are a strict verifier. Do not reveal chain-of-thought. Output only JSON.",
        },
        {
          role: "user",
          content:
            `Question: ${question}\nAnswer: ${answer}\n\nReturn JSON: {"valid": true|false}.`,
        },
      ],
      response_format: { type: "json_object" },
    }),
  });

  const data = await res.json();
  const parsed = JSON.parse(data.choices[0].message.content);
  return Boolean(parsed.valid);
}

검증기가 false를 반환하면 전략은 둘 중 하나입니다.

  • n을 늘려 재샘플링
  • 프롬프트를 약간 바꿔 재시도(예: 제약 조건을 더 명확히)

운영에서의 비용 관리: n을 무작정 키우지 마라

Self-Consistency는 기본적으로 비용이 n배 듭니다. 그래서 아래 최적화가 중요합니다.

  • 적응형 샘플링: 3번만 뽑아도 2표가 같으면 조기 종료
  • 캐시: 동일 입력에 대한 결과 캐시(단, 온도 샘플링이면 캐시 키 설계 주의)
  • 작은 모델로 1차 후, 애매할 때만 큰 모델

캐시 키 설계는 LLM에도 그대로 적용됩니다. 입력 문자열만이 아니라 model, temperature, top_p, system 프롬프트, 툴 버전까지 포함해야 “캐시 미적중/오염”을 줄일 수 있습니다. 이 주제는 GitHub Actions 캐시 미적중? 키 설계 7원칙의 사고방식이 그대로 통합니다.

적응형 샘플링 코드

export async function selfConsistencyAdaptive(prompt: string, maxN = 9) {
  const candidates: Candidate[] = [];
  const counts: Record<string, number> = {};

  for (let i = 0; i < maxN; i++) {
    const c = await sampleOnce(prompt);
    candidates.push(c);

    const key = normalizeAnswer(c.answer);
    counts[key] = (counts[key] ?? 0) + 1;

    // 조기 종료: 2표 이상이면서 나머지로 뒤집기 어려운 경우 등
    const top = Object.values(counts).sort((a, b) => b - a);
    const best = top[0] ?? 0;
    const second = top[1] ?? 0;

    if (best >= 3 && best - second >= 2) break;
  }

  const { final, stats } = majorityVote(candidates);
  return { final, candidates, stats };
}

프롬프트 템플릿: “CoT 금지”를 안전하게 거는 법

CoT를 막는다고 해서 “생각하지 마” 같은 문장만 넣으면 효과가 들쭉날쭉합니다. 대신 아래처럼 출력 스키마를 강제하고, 설명은 금지하고, 필드만 채우게 만드는 편이 안정적입니다.

  • 시스템 메시지에: “추론을 노출하지 말 것, JSON만 출력”
  • 유저 메시지에: “키 이름과 타입, 허용 값”을 명시

예시 템플릿:

  • 시스템: Do not reveal chain-of-thought. Output only valid JSON. No extra text.
  • 유저: Return JSON: {"answer": string, "confidence": number}

또한 서술형 답이 필요해도, 집계를 위해 answer 외에 label을 같이 받는 패턴이 좋습니다.

{"label":"A","answer":"..."}

이렇게 하면 집계는 label로 하고, 최종 출력은 answer를 쓰는 식으로 설계를 분리할 수 있습니다.

실패 모드와 디버깅 체크리스트

Self-Consistency를 붙였는데 성능이 안 오르거나 오히려 나빠지는 경우는 보통 아래 중 하나입니다.

  1. 다양성이 부족: temperature가 너무 낮거나, 프롬프트가 답을 강하게 유도
  2. 정규화가 부실: 같은 의미가 다른 문자열로 분산
  3. 문제 자체가 애매: 정답이 단일하지 않거나, 평가 기준이 불명확
  4. 일관되게 틀리는 편향: 검증기/툴/근거(RAG)가 필요
  5. 집계 기준이 목표와 불일치: “가장 흔한 답”이 “가장 좋은 답”이 아닐 때

운영 이슈를 추적할 때는 “추론 로그” 대신 다음을 저장하는 쪽이 안전합니다.

  • 후보 답 목록(필요 시 마스킹)
  • 정규화 키
  • 집계 통계(표 수, 가중치 합)
  • 검증기 결과

이런 식의 “관측 가능성”은 트랜잭션/분산 시스템에서 원인 추적하듯 접근해야 합니다. 분산 흐름에서 중복/순서 꼬임을 다루는 관점은 Saga 보상트랜잭션 설계 - 중복·순서꼬임 해결와도 닮아 있습니다. 여러 시도(샘플)가 생기면 결국 “집계와 멱등성”이 핵심 문제가 되기 때문입니다.

결론: CoT 없이도 Self-Consistency는 충분히 강력하다

CoT는 Self-Consistency의 필수 조건이 아닙니다. 핵심은 여러 후보를 만들고, 정규화/집계로 일관된 결론을 고르고, 검증기로 바닥 신뢰도를 보장하는 것입니다.

실전 적용 순서는 다음이 가장 무난합니다.

  1. 출력 포맷을 JSON으로 고정(설명 금지)
  2. n=5~7에서 다수결 + 적응형 조기 종료
  3. 라벨 축약으로 집계 품질 강화
  4. 실패 시 검증기 기반 재시도
  5. 비용이 크면 작은 모델 1차 + 큰 모델 2차

이렇게 하면 “설명은 숨기되 성능은 올리는” Self-Consistency를 운영 환경에 안전하게 이식할 수 있습니다.