- Published on
CoT 없이도 잘 푸는 이유 - Self-Consistency 구현
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
LLM 추론 성능을 끌어올리는 대표 기법으로 Chain-of-Thought(이하 CoT)가 자주 언급됩니다. 하지만 제품 환경에서는 CoT를 그대로 노출하기 어렵습니다. 개인정보나 내부 정책이 섞일 수 있고, 프롬프트 인젝션 표면도 넓어지며, 무엇보다 사용자에게 불필요한 장황한 중간 과정을 보여줄 이유가 없기 때문입니다.
그런데도 “CoT 없이도 잘 푸는” 시스템들이 있습니다. 핵심은 모델이 스스로 여러 번 샘플링해 다양한 해답 후보를 만들고, 그중 가장 일관된 결론을 선택하는 Self-Consistency입니다. 이 글에서는 Self-Consistency가 왜 효과적인지, 그리고 실제로 구현할 때 어떤 설계 포인트가 있는지 코드 중심으로 정리합니다.
또한 운영 관점에서 실패율을 낮추는 재시도, 폴백, 서킷브레이커 같은 안정화 패턴도 함께 다룹니다. 관련해서는 OpenAI Responses API 500·503 대응 재시도 폴백 서킷브레이커 글을 같이 보면 좋습니다.
Self-Consistency란 무엇인가
Self-Consistency는 간단히 말해 다음 절차입니다.
- 같은 문제를 여러 번 푼다(샘플링).
- 여러 답 중에서 가장 자주 등장하거나 서로 가장 일관된 답을 고른다(집계).
- 필요하면 추가 샘플을 더 뽑거나, 불확실하면 보수적으로 처리한다(적응형 예산).
중요한 점은 “중간 추론 과정(CoT)을 사용자에게 보여주지 않아도” 이 절차가 동작한다는 것입니다. 모델 내부에서는 다양한 경로로 추론을 시도하고, 우리는 최종 답만 모아 다수결을 취하면 됩니다.
CoT 없이도 성능이 오르는 직관
LLM의 한 번의 생성은 확률적 샘플입니다. 온도(temperature)가 0이 아니면 특히 그렇습니다. 즉 한 번의 답은 “가장 그럴듯한 단일 샘플”일 뿐이고, 문제에 따라 틀린 경로로 빠질 수 있습니다.
Self-Consistency는 이 확률적 특성을 이용해 “여러 번 뽑아보고, 가장 안정적으로 반복되는 결론을 채택”합니다. 다음과 같은 상황에서 특히 효과적입니다.
- 정답이 비교적 명확하지만, 한 번의 샘플이 실수하기 쉬운 문제
- 모델이 여러 풀이 경로를 가질 수 있는 문제(수리, 논리, 규칙 기반)
- 출력 공간이 제한된 문제(객관식, 라벨 분류, 단답)
반대로 출력이 장문이며 정답 공간이 넓은 생성형 작업에서는 단순 다수결이 약해집니다. 이때는 “의미적 클러스터링”이나 “평가자 모델로 rerank”가 필요합니다.
구현 목표: CoT를 숨기고, 답만 합의한다
실전 구현에서 목표는 다음입니다.
- 사용자에게는 최종 답만 반환한다.
- 내부적으로는
N번 샘플링하고 합의한다. - 합의 신뢰도가 낮으면 추가 샘플링하거나 보수적으로 실패 처리한다.
- 비용과 지연시간을 관리한다(동시 실행, 적응형 중단).
이 글의 코드는 TypeScript 기준이며, OpenAI Responses API 스타일로 작성합니다. (다른 벤더/SDK에도 동일한 구조로 적용 가능합니다.)
1) 샘플링 설계: 다양성 확보가 핵심
Self-Consistency의 성패는 “답 후보의 다양성”에 달려 있습니다. 다양성이 없으면 다수결이 의미가 없습니다.
temperature: 보통0.7전후가 무난합니다.top_p:0.9정도로 함께 조정하는 경우가 많습니다.seed: 고정하면 재현성은 좋아지지만 다양성이 줄 수 있습니다.- 프롬프트 변주: 동일 프롬프트만 반복하면 편향이 반복될 수 있어, 경미한 변주를 주기도 합니다.
다만 “프롬프트 변주”는 제품 정책상 위험할 수 있으니, 먼저 temperature 기반의 샘플링부터 시작하는 것을 권장합니다.
2) 답 추출: 출력 포맷을 강제해 집계 가능하게
Self-Consistency에서 가장 흔한 실패는 “답을 집계할 수 없게 출력이 제각각”인 것입니다. 따라서 출력은 JSON 등 고정 포맷으로 제한하는 편이 안전합니다.
주의: MDX 빌드 에러를 피하기 위해 제네릭 표기는 항상 백틱으로 감쌉니다.
type Candidate = {
answer: string;
confidence?: number; // 모델이 자체적으로 추정한 값(선택)
rationale?: string; // 내부 기록용(사용자에게는 미노출)
};
type Parsed =
| { ok: true; value: Candidate }
| { ok: false; error: string; raw: string };
function safeJsonParse(raw: string): Parsed {
try {
const v = JSON.parse(raw);
if (!v || typeof v.answer !== "string") {
return { ok: false, error: "invalid schema", raw };
}
return {
ok: true,
value: {
answer: v.answer.trim(),
confidence: typeof v.confidence === "number" ? v.confidence : undefined,
rationale: typeof v.rationale === "string" ? v.rationale : undefined,
},
};
} catch (e) {
return { ok: false, error: "json parse failed", raw };
}
}
에러를 예외로 던지지 않고 값으로 다루면, 샘플 중 일부가 깨져도 전체 파이프라인이 계속 진행됩니다. 이런 스타일은 견고한 운영 코드에 유리합니다. C++ 쪽이지만 “예외 없이 안전하게 흐름을 유지하는” 관점에서는 C++23 std - -expected로 예외 없이 안전한 소유권 글의 사고방식과도 유사합니다.
3) 집계 전략: 단답은 다수결, 장문은 정규화
단답/라벨 문제: 다수결
가장 단순하고 강력합니다.
function majorityVote(candidates: Candidate[]) {
const counts = new Map<string, number>();
for (const c of candidates) {
const key = c.answer;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
let bestAnswer = "";
let bestCount = 0;
for (const [ans, cnt] of counts) {
if (cnt > bestCount) {
bestAnswer = ans;
bestCount = cnt;
}
}
const total = candidates.length;
const agreement = total === 0 ? 0 : bestCount / total;
return { answer: bestAnswer, agreement, counts };
}
여기서 agreement는 “합의율”로, 후술할 적응형 샘플링/중단의 핵심 신호가 됩니다.
장문/자유형 답: 정규화 또는 클러스터링
자유형 답은 문장 표현이 달라서 단순 문자열 다수결이 깨집니다. 최소한 다음을 적용하세요.
- 공백/대소문자/구두점 정규화
- 숫자/단위 표준화
- 핵심 키만 추출(예: 최종 결론 한 줄)
실무에서는 “최종 답만 한 줄로 출력”하게 프롬프트를 설계하거나, “정답 필드만 JSON으로 강제”하는 것이 가장 비용 대비 효과가 좋습니다.
4) 적응형 예산: 합의가 높으면 빨리 끝내기
N을 무조건 크게 잡으면 비용과 지연이 늘어납니다. 대신 다음 전략을 씁니다.
- 최소
minSamples만 먼저 뽑는다. - 합의율이 임계치 이상이면 중단한다.
- 낮으면
maxSamples까지 추가로 뽑는다.
type SelfConsistencyOptions = {
minSamples: number;
maxSamples: number;
agreementThreshold: number; // 예: 0.6
};
function shouldStop(agreement: number, sampled: number, opt: SelfConsistencyOptions) {
if (sampled < opt.minSamples) return false;
return agreement >= opt.agreementThreshold;
}
이렇게 하면 쉬운 문제는 빠르게 끝나고, 어려운 문제에만 예산을 더 씁니다.
5) OpenAI Responses API로 Self-Consistency 파이프라인
아래 예시는 “동시에 여러 샘플을 생성하고, 파싱 후, 다수결로 합의”하는 기본 구현입니다. 실제 SDK 메서드 이름은 환경에 따라 다를 수 있으니 구조를 참고하세요.
중요: 이 글에서는 CoT를 사용자에게 노출하지 않습니다. 필요하면 rationale은 내부 로그로만 남깁니다.
import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
type RunResult = {
finalAnswer: string;
agreement: number;
sampled: number;
debug: {
answers: string[];
parseFailures: number;
};
};
async function generateOne(prompt: string) {
const res = await client.responses.create({
model: "gpt-4.1-mini",
input: [
{
role: "system",
content:
"You are a solver. Return ONLY valid JSON: {\"answer\": string, \"confidence\": number, \"rationale\": string}.",
},
{ role: "user", content: prompt },
],
temperature: 0.7,
top_p: 0.9,
});
// SDK마다 텍스트 추출 방식이 다릅니다. 여기서는 개념적으로 rawText를 얻는다고 가정합니다.
const rawText = res.output_text;
return rawText;
}
export async function selfConsistentSolve(
prompt: string,
opt: { minSamples: number; maxSamples: number; agreementThreshold: number }
): Promise<RunResult> {
let sampled = 0;
const candidates: Candidate[] = [];
let parseFailures = 0;
while (sampled < opt.maxSamples) {
const batchSize = Math.min(3, opt.maxSamples - sampled);
const raws = await Promise.all(
Array.from({ length: batchSize }, () => generateOne(prompt))
);
for (const raw of raws) {
sampled += 1;
const parsed = safeJsonParse(raw);
if (!parsed.ok) {
parseFailures += 1;
continue;
}
candidates.push(parsed.value);
}
const { answer, agreement } = majorityVote(candidates);
if (shouldStop(agreement, sampled, opt)) {
return {
finalAnswer: answer,
agreement,
sampled,
debug: {
answers: candidates.map((c) => c.answer),
parseFailures,
},
};
}
}
const { answer, agreement } = majorityVote(candidates);
return {
finalAnswer: answer,
agreement,
sampled,
debug: {
answers: candidates.map((c) => c.answer),
parseFailures,
},
};
}
운영 팁: 합의율이 낮을 때의 처리
합의율이 낮다는 것은 대체로 다음 중 하나입니다.
- 문제 자체가 모호하다(입력 품질 문제)
- 모델이 지식/추론 한계에 부딪혔다
- 출력 포맷이 흔들려 파싱 실패가 많다
이때의 대응은 “무작정 샘플 수를 늘리는 것”이 아니라, 다음 순서가 비용 효율적입니다.
- 출력 스키마를 더 엄격히 한다(JSON 강제, 예시 제공)
- 질문을 재구성한다(누락된 조건을 되묻기)
- 그래도 안 되면 더 강한 모델로 폴백
6) CoT 미노출 설계: 내부 추론은 로그로만
Self-Consistency는 종종 “각 샘플의 풀이 과정”을 비교하는 방식으로 설명되지만, 제품에서는 굳이 사용자에게 보여줄 필요가 없습니다.
권장 패턴은 다음입니다.
- 모델에는
rationale필드를 쓰게 하되, 사용자 응답에는 포함하지 않는다. - 관측 가능성(Observability)을 위해 내부 로그에는 남긴다.
- 개인정보/민감정보가 섞일 수 있으므로 로그 마스킹 규칙을 둔다.
이렇게 하면 CoT를 공개하지 않으면서도 디버깅 가능성을 유지할 수 있습니다.
7) 실패 처리: 재시도·폴백·서킷브레이커
Self-Consistency는 호출 횟수가 늘어나므로, API 오류(예: 500, 503)의 누적 확률도 커집니다. 따라서 안정화 패턴이 필수입니다.
- 개별 샘플 생성은 지수 백오프 재시도
- 특정 오류가 연속되면 서킷브레이커로 빠르게 실패
- 폴백 모델 또는 단일 샘플 모드로 강등
구체적인 구현 패턴은 OpenAI Responses API 500·503 대응 재시도 폴백 서킷브레이커에서 더 자세히 다뤘습니다.
여기서는 Self-Consistency 관점의 핵심만 짚으면 다음과 같습니다.
- “전체 루프”를 재시도하지 말고 “샘플 단위”로 재시도하라
- 파싱 실패는 재시도보다 프롬프트/스키마 개선이 우선이다
- 합의율이 낮은 상태에서 무한 샘플링을 하지 말고
maxSamples와 타임아웃을 강제하라
8) 평가: 합의율만 믿지 말고 정답 검증을 붙이기
다수결은 “가장 흔한 답”을 고를 뿐 “정답”을 보장하지 않습니다. 특히 모델이 같은 편향을 공유하면 틀린 답으로도 쉽게 합의합니다.
가능하면 다음을 추가하세요.
- 규칙 기반 검증기(예: 숫자 범위, 포맷, 단위)
- 외부 도구 검증(계산기, DB 조회, 컴파일/테스트)
- 작은 평가자 모델로 최종 후보를 채점해 rerank
예를 들어 “정답이 숫자 하나”인 문제라면, 후보 답을 파싱해 단위/범위를 검증하고, 실패하면 추가 샘플링 또는 질문 재구성으로 넘어갈 수 있습니다.
9) 언제 Self-Consistency를 쓰면 좋은가
적용 추천 시나리오:
- 단답형 QA, 라벨 분류, 규칙 기반 추론
- 코드 생성 후 테스트로 검증 가능한 경우(테스트 통과율을 집계 신호로 사용)
- 비용 대비 정확도가 중요한 백오피스 자동화
비추천 또는 보완 필요:
- 장문 에세이처럼 정답 공간이 넓고, “합의” 정의가 애매한 작업
- 최신 정보가 중요한 작업(검색/도구 호출이 더 중요)
- 보안상 프롬프트 변주가 위험한 환경(변주 없이도 되지만 다양성 확보가 제한됨)
결론
Self-Consistency는 CoT를 공개하지 않아도 LLM의 추론 성능을 올릴 수 있는 실용적인 앙상블 기법입니다. 핵심은 “여러 번 샘플링해서 다양성을 확보”하고, “집계 가능한 출력 포맷을 강제”하며, “합의율 기반으로 적응형 예산을 운영”하는 것입니다.
구현 자체는 단순하지만, 운영에서는 호출 횟수 증가로 인한 오류 처리와 비용 관리가 성패를 가릅니다. 샘플 단위 재시도, 폴백, 서킷브레이커를 붙이고, 합의율이 낮을 때는 무작정 샘플을 늘리기보다 스키마/질문 품질을 먼저 개선하세요.
이 과정을 잘 설계하면, 사용자에게는 깔끔한 최종 답만 제공하면서도 “CoT 없이도 잘 푸는” 경험을 안정적으로 만들 수 있습니다.