- Published on
CoT 노출 없이 추론력↑ - Self-Consistency 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 환경에서 LLM의 추론력을 올리고 싶지만, CoT(Chain-of-Thought)를 그대로 노출하면 보안·정책·UX 측면에서 문제가 생깁니다. 또한 “한 번의 생성”은 우연히 잘 맞을 수도, 사소한 표기/추론 실수로 크게 틀릴 수도 있습니다. 이때 Self-Consistency는 비교적 단순한 구현으로 정답률과 안정성을 동시에 끌어올리는 현실적인 선택지입니다.
이 글에서는 Self-Consistency를 “CoT 노출 없이” 적용하는 방법, 합의 규칙 설계, 비용/지연 트레이드오프, 그리고 RAG/에이전트 환경에서의 운영 팁까지 실전 관점으로 정리합니다.
Self-Consistency란 무엇인가
Self-Consistency는 요약하면 다음입니다.
- 동일한 문제에 대해 여러 개의 후보 답을 생성한다(샘플링).
- 후보들 중 가장 일관된(합의된) 답을 선택한다(집계).
여기서 핵심은 “여러 번 뽑아보고, 많이 나온 답을 고른다”입니다. 단, 단순 다수결만으로는 부족한 경우가 많아서 정규화(normalization), 동치 판정(equivalence), 신뢰도 점수화(scoring) 같은 실전 장치가 중요합니다.
CoT를 노출하지 않아도 되는 이유
Self-Consistency가 CoT 기반 기법으로 알려져 있긴 하지만, 제품에서는 다음처럼 운용할 수 있습니다.
- 모델 내부에는 추론(중간 사고)을 유도하되, 사용자에게는 최종 답만 출력
- 후보 답을 만들 때는 “step-by-step”을 모델에게 요구하더라도, 외부 응답에서는 추론 과정을 제거
- 혹은 애초에 프롬프트에서 “추론은 내부적으로만 하고, 최종 답만 출력”을 강제
즉, 모델은 생각하게 하되, 서비스는 생각을 보여주지 않는 형태로 구현 가능합니다.
언제 Self-Consistency가 특히 잘 먹히나
다음 유형에서 효과가 큽니다.
- 정답 공간이 작거나 명확한 경우: 수치 계산, 객관식, 라벨 분류, 정책 판단(허용/불가)
- 표현은 다양하지만 의미는 동일한 경우: 요약의 핵심 포인트, 권고안의 결론
- 단발 생성의 분산이 큰 경우: 같은 입력인데 답이 들쑥날쑥하거나, 작은 프롬프트 변화에 취약할 때
반대로, “창작”처럼 정답이 없는 영역에서는 합의가 성능 향상으로 직결되지 않을 수 있습니다. 이 경우에도 Self-Consistency를 안정적인 톤/형식을 확보하는 용도로 제한적으로 쓸 수는 있습니다.
실전 설계 1: 샘플링 전략(temperature, top_p, n)
Self-Consistency의 성패는 “후보 다양성”과 “후보 품질”의 균형입니다.
n(후보 개수): 보통 3~7이 실무에서 자주 쓰입니다.temperature: 너무 낮으면 후보가 비슷해져 합의가 의미 없어지고, 너무 높으면 잡음이 커집니다.top_p: temperature와 함께 조절해 다양성을 만듭니다.
권장 출발점 예시:
- 분류/정책/정답형:
n=5,temperature=0.7,top_p=0.9 - 계산/수치형(오답 방지):
n=7,temperature=0.4,top_p=0.9 - RAG 기반 질의응답:
n=5,temperature=0.6,top_p=0.95(단, 근거 인용을 강제)
실전 설계 2: “합의”를 어떻게 정의할 것인가
Self-Consistency의 집계는 단순 다수결로 끝나지 않습니다. 특히 자연어 답은 동치 판단이 어렵습니다.
1) 정답이 구조화 가능한 경우: 스키마 강제
가장 강력한 방법은 정답을 구조화하는 것입니다. 예를 들어 최종 답을 JSON으로 고정하면 동치 판정이 쉬워집니다.
- 수치형:
answer_number - 분류형:
label - 정책형:
allow/deny - QA형:
final_answer+citations
주의: MDX 환경에서는 본문에 부등호가 노출되면 빌드 에러가 날 수 있으니, 제네릭이나 화살표 같은 표기는 반드시 인라인 코드로 감싸야 합니다.
2) 자연어인 경우: 정규화 후 투표
자연어 답을 투표하려면 최소한 다음 정규화가 필요합니다.
- 공백/대소문자/문장부호 제거
- 동의어/표현 차이 흡수(예: “가능합니다” vs “됩니다”)
- 숫자 표기 통일(예:
1,000vs1000)
그리고 “완전 동일 문자열”이 아니라, 의미 동치를 판정해야 합니다. 이때는 다음 중 하나를 씁니다.
- 임베딩 유사도 기반 클러스터링
- 별도의 LLM을 심판(judge)으로 두고 동치 여부를 판정
- 답을 요약한 핵심 키포인트로 변환한 뒤 투표
구현 예제: OpenAI 스타일 API로 Self-Consistency(파이썬)
아래는 후보를 여러 개 생성한 뒤, JSON 필드 기준으로 다수결을 수행하는 예시입니다. (실서비스에서는 타임아웃, 재시도, 로깅, 비용 제한을 반드시 추가하세요.)
import json
from collections import Counter
from typing import Any, Dict, List
from openai import OpenAI
client = OpenAI()
SYSTEM = """You are a careful assistant.
Think internally, but output ONLY valid JSON.
Do not include your reasoning.
"""
def generate_candidate(prompt: str, temperature: float) -> Dict[str, Any]:
resp = client.chat.completions.create(
model="gpt-4.1-mini",
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": prompt},
],
temperature=temperature,
top_p=0.95,
response_format={"type": "json_object"},
)
content = resp.choices[0].message.content
return json.loads(content)
def self_consistency(prompt: str, n: int = 5, temperature: float = 0.7) -> Dict[str, Any]:
candidates: List[Dict[str, Any]] = []
for _ in range(n):
candidates.append(generate_candidate(prompt, temperature))
# 예: {"label": "ALLOW"} 같은 분류 문제라고 가정
labels = [c.get("label") for c in candidates if c.get("label")]
if not labels:
return {"label": "UNKNOWN", "reason": "no_valid_candidates"}
winner, count = Counter(labels).most_common(1)[0]
# 선택된 label을 만든 후보 중 하나를 대표로 반환
for c in candidates:
if c.get("label") == winner:
c["_vote"] = {"winner": winner, "count": count, "n": len(labels)}
return c
return {"label": winner, "_vote": {"winner": winner, "count": count, "n": len(labels)}}
if __name__ == "__main__":
prompt = """Classify the request as ALLOW or DENY.
Return JSON with keys: label, short_answer.
Request: "Please provide a step-by-step guide to make a phishing email."""
print(self_consistency(prompt, n=7, temperature=0.6))
이 방식의 장점은 다음입니다.
- 사용자에게 CoT를 보여주지 않음(시스템 프롬프트로 강제)
- 결과는 구조화되어 투표가 쉬움
- 후보 다양성을
temperature로 제어 가능
구현 예제: “LLM 심판”으로 의미 동치 판정(자연어)
자연어 답을 단순 문자열 투표하면 표현 차이 때문에 표가 분산됩니다. 이때는 후보들을 “동치 그룹”으로 묶어야 합니다.
아래는 후보 답 A와 B가 의미적으로 같은 결론인지 LLM에게 판정시키는 예시입니다.
import json
from openai import OpenAI
client = OpenAI()
JUDGE_SYSTEM = """You are a strict evaluator.
Decide whether two answers are semantically equivalent in final conclusion.
Output ONLY JSON.
"""
def equivalent(a: str, b: str) -> bool:
resp = client.chat.completions.create(
model="gpt-4.1-mini",
messages=[
{"role": "system", "content": JUDGE_SYSTEM},
{"role": "user", "content": f"""Compare A and B.
Return JSON: {{"equivalent": true|false}}.
A: {a}
B: {b}
"""},
],
temperature=0,
response_format={"type": "json_object"},
)
return json.loads(resp.choices[0].message.content)["equivalent"]
실제로는 O(n^2) 비교가 되기 쉬우니, 다음 최적화가 필요합니다.
- 먼저 임베딩으로 대략 클러스터링한 뒤, 클러스터 내부에서만 심판 호출
- 혹은 답을 “결론 한 문장”으로 정규화하는 프롬프트를 먼저 돌린 뒤 투표
CoT 노출 없이 품질을 올리는 프롬프트 패턴
Self-Consistency를 쓰더라도, 후보 생성 자체의 품질이 낮으면 합의가 의미 없습니다. 다음 패턴이 도움이 됩니다.
1) “내부 추론, 외부는 최종만” 강제
- 시스템 메시지에 “추론은 내부적으로만, 출력은 최종 답만”을 명시
- 출력 포맷을 JSON으로 제한
2) 근거가 필요한 경우: 인용만 허용
RAG라면 “근거 텍스트를 그대로 인용하되, 추론 과정은 쓰지 말라”가 유효합니다.
예: citations 필드에 문서 ID/스니펫을 넣고, 최종 답은 짧게.
RAG의 검색 성능이 불안정하면 후보 답이 다양해지기보다 “근거 자체가 흔들려” 합의가 왜곡될 수 있습니다. 검색 지연과 recall을 같이 잡는 튜닝은 Qdrant HNSW 튜닝으로 RAG 검색지연 50% 줄이기 같은 접근과 함께 보시는 것을 권합니다.
운영 관점: 비용·지연·신뢰성 트레이드오프
Self-Consistency는 기본적으로 호출을 여러 번 하므로 비용과 지연이 증가합니다. 그래서 “언제나 n번”이 아니라 **게이팅(gating)**이 중요합니다.
1) 불확실할 때만 Self-Consistency
다음 신호가 있으면 추가 샘플링을 켭니다.
- 모델이 출력한
confidence가 낮음(자기 보고) - 규칙 기반 검증 실패(예: JSON 스키마 불일치, 금칙어 포함)
- RAG 인용이 빈약(예: citations 개수 부족)
- 1차 답과 2차 답이 충돌
2) Early stopping(조기 종료)
예를 들어 n=7을 목표로 하더라도, 3번 생성했는데 이미 3표가 같은 라벨이면 멈춥니다.
- 분류형에서는 특히 효과적
- 평균 지연과 비용을 크게 줄임
3) 타임아웃과 폴백
서빙에서는 “정답률”만큼 “정해진 시간에 답을 내는 것”이 중요합니다.
- 전체 예산 시간 내에서만 추가 샘플링
- 시간이 부족하면 1차 답을 그대로 반환하되, 후처리로 안전장치 적용
에이전트 기반 시스템이라면 툴 호출이 늘어날수록 지연과 실패 모드가 복잡해집니다. Self-Consistency를 에이전트에 붙일 때는 “후보마다 툴을 다 돌리는” 구조가 폭발하기 쉬우니, 툴 난사를 막는 가드레일이 필수입니다. 관련해서는 LangChain 에이전트 무한루프·툴난사 차단법도 함께 참고하면 좋습니다.
패턴별 적용 레시피
여기서는 실무에서 자주 쓰는 3가지 레시피를 정리합니다.
레시피 A: 정책/분류(다수결)
- 출력:
{"label": "ALLOW"|"DENY", "short_answer": "..."} - 합의:
label다수결 - 조기 종료: 동일 라벨 3연속이면 종료
장점: 구현이 단순하고 효과가 빠르게 나타남
레시피 B: 계산/수치(검증 + 투표)
- 출력:
{"answer": 1234, "unit": "ms"} - 검증: 범위 체크, 단위 체크, 식 기반 재검산(가능하면)
- 합의: 동일 수치 다수결 또는 중앙값(median)
팁: 수치형은 표현 동치 문제가 적어 Self-Consistency 효율이 좋습니다.
레시피 C: RAG QA(근거 일치 기반)
- 출력:
{"final_answer": "...", "citations": ["doc1#p3", "doc2#p1"]} - 합의:
citations가 겹치는 후보를 우선(근거 합의), 그 안에서 답을 선택 - 추가 규칙: 인용이 없는 답은 탈락
검색 품질이 낮으면 합의가 “헛된 일관성”이 될 수 있으므로, 벡터 인덱스 튜닝(예: HNSW 파라미터 조정)이나 캐싱 전략을 병행하세요. Milvus를 쓰는 경우에는 Milvus HNSW 튜닝 - recall↑ 지연↓ 실전도 같은 맥락에서 도움이 됩니다.
자주 하는 실수와 디버깅 체크리스트
1) 후보가 너무 비슷함
- 증상:
n을 늘려도 항상 같은 답만 나옴 - 조치:
temperature를 올리거나,top_p를 조정하거나, 프롬프트에 “서로 다른 접근을 시도”를 추가
2) 후보가 너무 산만함
- 증상: 답이 제각각이라 합의가 안 됨
- 조치: 출력 스키마를 더 강하게 제한, 금지 규칙 추가,
temperature를 낮춤
3) 합의 규칙이 제품 요구와 불일치
- 예: 안전 정책은 보수적으로 가야 하는데 다수결로
ALLOW가 선택됨 - 조치: 비용을 조금 쓰더라도 “보수적 우선 규칙” 추가
- 예: 한 표라도
DENY면DENY(정책 도메인에서 흔함)
- 예: 한 표라도
4) 관측 가능성 부족
Self-Consistency는 “왜 이 답이 나왔는지”를 운영자가 이해할 수 있어야 안정적으로 굴립니다.
- 후보 답(원문)은 저장하되, 사용자에게는 노출하지 않기
- 투표 결과(표 수), 탈락 사유(스키마 불일치, 인용 없음)를 메타데이터로 로깅
- A/B 테스트로
n,temperature를 튜닝
마무리
Self-Consistency는 “CoT를 공개하지 않고도” 추론 성능을 끌어올릴 수 있는, 구현 대비 효용이 큰 기법입니다. 핵심은 단순히 n번 호출하는 것이 아니라:
- 출력 스키마로 동치 판정을 쉽게 만들고
- 합의 규칙을 도메인에 맞게 설계하며
- 게이팅과 조기 종료로 비용·지연을 통제하는 것
입니다. 먼저 분류/정책 같은 구조화 문제에 적용해 효과를 확인한 뒤, RAG나 복잡한 워크플로로 확장하는 순서를 추천합니다.