- Published on
Chain-of-Thought 없이 추론 강화 - Self-Consistency
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론에서 먼저 짚고 넘어갈 점이 있습니다. 많은 팀이 LLM 추론 성능을 올리기 위해 Chain-of-Thought(CoT)를 유도하지만, CoT를 그대로 외부로 노출하면 보안·프롬프트 유출·규정 준수·사용자 경험(불필요하게 장황함) 측면에서 부담이 생깁니다. 반대로 CoT를 숨기면 모델이 덜 똑똑해지는 것처럼 느껴지기도 합니다.
이때 유용한 접근이 Self-Consistency입니다. 핵심은 간단합니다. 한 번의 “그럴듯한” 추론에 베팅하지 말고, 같은 질문을 여러 번 샘플링한 뒤 가장 일관되게 등장하는 결론을 선택합니다. CoT를 사용자에게 보여주지 않아도, 내부적으로는 다양한 추론 경로를 탐색한 효과를 얻어 정답률을 올릴 수 있습니다.
Self-Consistency란 무엇인가
Self-Consistency는 “샘플링 기반 앙상블”에 가깝습니다.
- 동일한 입력에 대해 모델을
n번 호출합니다. - 각 호출은
temperature를 높이거나top_p를 조절해 서로 다른 추론 경로(답 후보)를 유도합니다. - 최종 답은 다수결 또는 가중 투표로 결정합니다.
중요한 점은, 사용자에게는 최종 결론만 제공해도 된다는 것입니다. 즉, CoT를 출력 정책상 제한해야 하는 환경에서도 추론 품질을 개선할 수 있습니다.
CoT 없이도 효과가 나는 이유
모델은 확률적으로 다음 토큰을 생성합니다. 특히 어려운 문제일수록 “정답으로 가는 경로”가 하나로 수렴하지 않고 여러 갈래로 분기합니다. 단일 샘플은 그중 하나를 택해버리는데, Self-Consistency는 분기된 경로들을 여러 번 탐색해 “가장 자주 도달하는 결론”을 선택합니다.
이를 운영 관점으로 번역하면 다음과 같습니다.
- 단발 호출은 운이 나쁘면 오답 경로로 빠질 수 있음
- 다회 샘플링은 오답이 섞이더라도 정답이 반복적으로 나타날 가능성이 커짐
언제 Self-Consistency가 특히 잘 먹히나
Self-Consistency는 모든 문제에 만능은 아닙니다. 다음 조건에서 효과가 큽니다.
- 정답의 형태가 비교적 명확한 문제
- 수학, 논리 퍼즐, 규칙 기반 추론, 분류/선택형 문제
- 모델이 자주 흔들리는 구간이 있는 문제
- 경계 조건, 예외 처리, 다단계 계산
- 정답 검증 또는 정규화가 쉬운 출력
A/B/C/D선택,true/false, JSON 필드, 숫자 값
반대로, 창의적 글쓰기처럼 “정답”이 정의되기 어렵거나, 출력이 장문 서술이라 후보 간 동치 판정이 어려우면 효율이 떨어집니다.
Self-Consistency의 기본 알고리즘
가장 단순한 형태는 “정규화 후 다수결”입니다.
n번 샘플 생성- 각 답을 정규화(공백, 대소문자, 포맷 통일)
- 동일 답변 빈도 계산
- 최빈값 선택
여기서 실무적으로 중요한 건 2번입니다. 모델 출력은 사소하게 달라질 수 있으므로, “동일 답” 판정을 위한 정규화가 품질을 좌우합니다.
예시: 선택형 문제
모델에게 선택지만 출력하게 강제하면 정규화가 쉬워집니다.
// TypeScript 예시: Self-Consistency 다수결
type Choice = "A" | "B" | "C" | "D";
type Sample = {
choice: Choice;
raw: string;
};
function normalizeChoice(text: string): Choice | null {
const t = text.trim().toUpperCase();
if (t.startsWith("A")) return "A";
if (t.startsWith("B")) return "B";
if (t.startsWith("C")) return "C";
if (t.startsWith("D")) return "D";
return null;
}
function majorityVote(samples: Sample[]): { winner: Choice; counts: Record<Choice, number> } {
const counts: Record<Choice, number> = { A: 0, B: 0, C: 0, D: 0 };
for (const s of samples) counts[s.choice] += 1;
const winner = (Object.keys(counts) as Choice[])
.sort((x, y) => counts[y] - counts[x])[0];
return { winner, counts };
}
이 구조에서 핵심은 “모델이 선택지를 벗어나지 않게” 하는 프롬프트와, 벗어났을 때 재시도하는 방어 로직입니다.
CoT를 노출하지 않는 프롬프트 설계
Self-Consistency를 쓰더라도, 프롬프트가 장황한 추론을 출력하도록 두면 비용이 늘고(토큰 증가) 사용자 정책과 충돌합니다. 따라서 다음 패턴이 실무에서 안전합니다.
- 모델에게 “내부적으로는 생각하되, 출력은 결론만” 요구
- 출력 형식을 강제(JSON 또는 한 글자 선택)
예시 프롬프트(선택형):
- 시스템:
너는 정확한 문제 풀이 모델이다. 내부적으로는 충분히 검토하되, 출력은 선택지 한 글자만 반환한다. - 유저:
문제 ... 선택지 A,B,C,D 중 정답은? 출력은 A,B,C,D 중 하나만.
여기서도 < 와 > 같은 문자가 본문에 그대로 나오면 MDX에서 JSX로 오인될 수 있으니, 문서화 시에는 반드시 인라인 코드로 감싸거나 엔티티로 치환하는 습관이 필요합니다.
구현 디테일: 샘플링 파라미터와 호출 전략
temperature와 top_p
- Self-Consistency의 목적은 “다양한 경로 샘플링”이므로
temperature를0에 두면 효과가 약합니다. - 보통
temperature를0.7전후로 올리고,top_p는0.9정도로 시작해 튜닝합니다.
다만 너무 랜덤하면 오답 후보가 급증해 투표가 분산됩니다. 실무적으로는 다음 가이드가 무난합니다.
- 쉬운 문제:
n을 줄이고temperature도 낮게 - 어려운 문제:
n을 늘리되,temperature는 중간값을 유지
n(샘플 수) 선택
n이 커질수록 정답률은 오르지만, 비용과 지연이 선형으로 증가합니다. 일반적인 절충안은 5 또는 7입니다.
n=3: 최소한의 앙상블n=5: 체감 성능 개선이 자주 관찰됨n=7이상: 비용 대비 효용이 점점 줄어드는 구간
병렬 호출과 레이트 리밋
Self-Consistency는 호출 수가 늘기 때문에 레이트 리밋과 쿼터에 민감합니다. 병렬 호출로 지연을 줄이려다 429가 늘면 전체 성공률이 오히려 떨어집니다.
이런 운영 이슈는 별도로 정리한 글의 패턴이 그대로 적용됩니다.
핵심은 다음입니다.
- 동시성 제한(세마포어)
- 지수 백오프 + 지터
- 부분 실패 시 재시도하되, 전체 타임아웃을 관리
아래는 간단한 병렬 샘플링 예시입니다.
// 병렬 샘플링 + 동시성 제한 예시 (의사 코드에 가까움)
class Semaphore {
private queue: Array<() => void> = [];
private permits: number;
constructor(permits: number) { this.permits = permits; }
async acquire(): Promise<() => void> {
if (this.permits > 0) {
this.permits -= 1;
return () => { this.permits += 1; this.queue.shift()?.(); };
}
await new Promise<void>(resolve => this.queue.push(resolve));
return this.acquire();
}
}
async function withRetry<T>(fn: () => Promise<T>, maxAttempts: number): Promise<T> {
let attempt = 0;
let lastErr: unknown;
while (attempt < maxAttempts) {
attempt += 1;
try {
return await fn();
} catch (e) {
lastErr = e;
const backoffMs = Math.min(2000, 200 * Math.pow(2, attempt - 1));
const jitterMs = Math.floor(Math.random() * 100);
await new Promise(r => setTimeout(r, backoffMs + jitterMs));
}
}
throw lastErr;
}
async function sampleMany(
n: number,
concurrency: number,
callModel: (seed: number) => Promise<string>
): Promise<string[]> {
const sem = new Semaphore(concurrency);
const tasks = Array.from({ length: n }, (_, i) => i);
return Promise.all(
tasks.map(async (i) => {
const release = await sem.acquire();
try {
return await withRetry(() => callModel(i), 3);
} finally {
release();
}
})
);
}
이때 seed는 실제 API에서 직접 제어가 안 될 수도 있지만, 프롬프트에 “서로 다른 관점으로 검토하라” 같은 지시를 섞거나, temperature를 유지한 채 호출을 분리해도 충분히 다양성이 생깁니다.
투표 고도화: 단순 다수결을 넘어서
1) 가중 투표(자기 신뢰도 활용)
모델에게 “정답과 함께 확신도(0~1)”를 출력하게 하고, 확신도를 가중치로 사용합니다. 단, 모델의 자기평가가 항상 정직하거나 정확하진 않아서 맹신하면 안 됩니다.
예시 출력(JSON):
{ "answer": "B", "confidence": 0.72 }
이런 형태로 강제하면 정규화가 쉬워지고, 동점 처리도 자연스러워집니다.
2) 검증자(verifier) 추가
샘플링으로 후보 답을 모은 뒤, 별도의 “검증 프롬프트”로 후보를 평가해 최종 선택을 하는 방식입니다.
- 1단계: 생성기(generator)로 후보
k개 생성 - 2단계: 검증자(verifier)가 후보를 비교 평가
이 방식은 호출이 더 늘지만, 다수결이 깨지는 케이스(정답이 소수로만 등장하는 케이스)를 완화할 수 있습니다.
3) 동치 판정(semantic equivalence)
서술형 답변이라면 문자열 동일성으로는 투표가 불가능합니다. 이때는
- 답을 구조화(JSON 스키마)
- 핵심 필드만 비교
- 또는 임베딩 기반 클러스터링
같은 기법이 필요합니다. 실무에서는 “처음부터 구조화 출력으로 제한”하는 편이 비용과 안정성 면에서 유리합니다.
운영 관점 체크리스트: 비용, 지연, 실패 모드
Self-Consistency는 성능을 돈과 지연으로 사는 기법입니다. 따라서 운영 설계가 중요합니다.
비용 관리
n을 고정하지 말고 난이도에 따라 동적으로- 1차로
n=3실행 후, 표가 갈리면 추가 샘플을 더 뽑는 “점진적 샘플링”
예시 전략:
- 3회 샘플링
- 최빈값이 3표면 종료
- 2대1이면 2회 추가
- 그래도 동률이면 검증자 호출
지연(latency) 관리
- 병렬 호출 + 동시성 제한
- 전체 타임아웃을 두고, 일부 샘플 실패 시에도 “가용한 표로” 결론을 내는 폴백
실패 모드
- 특정 프롬프트에서 모델이 포맷을 자주 깨는 경우
- 투표가 계속 동률로 끝나는 경우
- 429 또는 네트워크 오류로 샘플이 부족해지는 경우
이런 문제는 CI에서 프롬프트 회귀 테스트로 잡는 편이 좋습니다. 프롬프트 변경이 잦은 팀이라면 GitHub Actions로 간단한 평가 파이프라인을 만들어두는 것도 도움이 됩니다.
실전 예제: 숫자 답변 Self-Consistency
숫자 문제는 정규화가 쉬워 Self-Consistency의 효용이 큽니다. 아래는 “숫자만 출력”을 강제하고, 파싱 가능한 값만 투표에 포함하는 예시입니다.
type NumericSample = { value: number; raw: string };
function parseNumberOnly(text: string): number | null {
const t = text.trim();
// 숫자, 소수점, 음수만 허용
if (!/^[-]?\d+(\.\d+)?$/.test(t)) return null;
const v = Number(t);
return Number.isFinite(v) ? v : null;
}
function voteNumber(samples: NumericSample[]): { winner: number; freq: Map<number, number> } {
const freq = new Map<number, number>();
for (const s of samples) freq.set(s.value, (freq.get(s.value) ?? 0) + 1);
let winner = samples[0].value;
let best = -1;
for (const [k, c] of freq.entries()) {
if (c > best) { best = c; winner = k; }
}
return { winner, freq };
}
async function selfConsistentNumber(
n: number,
callModel: () => Promise<string>
): Promise<{ answer: number; used: number; discarded: number }> {
const raws = await Promise.all(Array.from({ length: n }, () => callModel()));
const parsed: NumericSample[] = [];
let discarded = 0;
for (const r of raws) {
const v = parseNumberOnly(r);
if (v === null) discarded += 1;
else parsed.push({ value: v, raw: r });
}
if (parsed.length === 0) throw new Error("No valid numeric samples");
const { winner } = voteNumber(parsed);
return { answer: winner, used: parsed.length, discarded };
}
포인트는 “포맷을 지키지 않은 샘플을 과감히 버린다”는 것입니다. Self-Consistency는 애초에 다회 샘플링이므로, 일부 샘플을 폐기해도 전체 결과가 안정적으로 남는 구조를 만들 수 있습니다.
결론: CoT 없이도 추론을 ‘운영적으로’ 강화하기
Self-Consistency는 CoT를 사용자에게 노출하지 않으면서도 추론 성능을 올릴 수 있는 현실적인 방법입니다. 특히 선택형·정답 검증이 쉬운 문제에서 효과가 크고, 구현 난이도도 비교적 낮습니다.
정리하면 적용 순서는 다음이 좋습니다.
- 출력 포맷을 강제해 정규화 비용을 낮춘다
n=3에서 시작해 표가 갈릴 때만 샘플을 추가하는 점진 전략을 쓴다- 429와 지연을 고려해 동시성 제한과 백오프를 넣는다
- 필요하면 검증자 단계를 추가해 동률과 소수 정답 문제를 보완한다
CoT를 둘러싼 정책·보안·UX 제약이 있는 환경이라면, Self-Consistency는 “프롬프트 한 방” 대신 “시스템 설계”로 추론을 강화하는 좋은 출발점이 됩니다.