- Published on
CoT 없이 성능 올리는 Self-Consistency 구현법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론에서부터 결론까지 한 문장으로 요약하면, 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를 쓴다는 건:
- 모델에게 결론만 출력하게 한다(혹은 구조화된 JSON만 출력).
- 같은 질문을 여러 번 던져 서로 다른 후보 답을 얻는다.
- 후보 답을 정규화(normalize) 한 뒤, 다수결/가중 투표/스코어링으로 최종 답을 선택한다.
- 필요하면 선택된 답을 검증 모델/규칙/툴로 재검증한다.
이때 중요한 포인트는 **“다수결” 자체가 아니라 “정규화와 집계의 품질”**입니다. 문자열이 조금만 달라도 다른 답으로 취급되면 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종류입니다.
- 규칙 기반: 포맷, 금칙어, 길이, 숫자 범위
- 툴 기반: 계산기, DB 조회, API 호출로 팩트 체크
- 모델 기반: 별도 프롬프트로 “이 답이 조건을 만족하는지 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를 붙였는데 성능이 안 오르거나 오히려 나빠지는 경우는 보통 아래 중 하나입니다.
- 다양성이 부족:
temperature가 너무 낮거나, 프롬프트가 답을 강하게 유도 - 정규화가 부실: 같은 의미가 다른 문자열로 분산
- 문제 자체가 애매: 정답이 단일하지 않거나, 평가 기준이 불명확
- 일관되게 틀리는 편향: 검증기/툴/근거(RAG)가 필요
- 집계 기준이 목표와 불일치: “가장 흔한 답”이 “가장 좋은 답”이 아닐 때
운영 이슈를 추적할 때는 “추론 로그” 대신 다음을 저장하는 쪽이 안전합니다.
- 후보 답 목록(필요 시 마스킹)
- 정규화 키
- 집계 통계(표 수, 가중치 합)
- 검증기 결과
이런 식의 “관측 가능성”은 트랜잭션/분산 시스템에서 원인 추적하듯 접근해야 합니다. 분산 흐름에서 중복/순서 꼬임을 다루는 관점은 Saga 보상트랜잭션 설계 - 중복·순서꼬임 해결와도 닮아 있습니다. 여러 시도(샘플)가 생기면 결국 “집계와 멱등성”이 핵심 문제가 되기 때문입니다.
결론: CoT 없이도 Self-Consistency는 충분히 강력하다
CoT는 Self-Consistency의 필수 조건이 아닙니다. 핵심은 여러 후보를 만들고, 정규화/집계로 일관된 결론을 고르고, 검증기로 바닥 신뢰도를 보장하는 것입니다.
실전 적용 순서는 다음이 가장 무난합니다.
- 출력 포맷을 JSON으로 고정(설명 금지)
n=5~7에서 다수결 + 적응형 조기 종료- 라벨 축약으로 집계 품질 강화
- 실패 시 검증기 기반 재시도
- 비용이 크면 작은 모델 1차 + 큰 모델 2차
이렇게 하면 “설명은 숨기되 성능은 올리는” Self-Consistency를 운영 환경에 안전하게 이식할 수 있습니다.