N=5의 Self-Consistency를 적용한 Sonnet이 3가지 작업 유형에서 Opus의 단일 호출 성능을 능가하다
요약
Claude Sonnet에 Self-consistency 기법을 적용하여 Opus 모델의 단일 호출 성능을 능가하는 최적화 방법을 소개합니다. N=5의 병렬 샘플링과 다수결 투표를 통해 비용과 지연 시간을 효율적으로 관리하며 정확도를 높이는 트레이드오프를 분석합니다.
핵심 포인트
- Self-consistency 적용 시 Sonnet의 정확도가 84%로 상승
- Opus 단일 호출 대비 낮은 비용과 유사한 지연 시간 유지
- 수학, 코드 완성, JSON 추출 등 비교 가능한 작업에 효과적
- 비교 가능한 이산적 정답이 존재하는 작업에서 투표 방식 유효
도서: Prompt Engineering Pocket Guide
나의 다른 저서: Thinking in Go (2권 시리즈) — Complete Guide to Go Programming + Hexagonal Architecture in Go
내 프로젝트: Hermes IDE | GitHub — Claude Code 및 기타 AI 코딩 도구를 사용하여 작업하는 개발자들을 위한 IDE
나: xgabriel.com | GitHub
지난달 내가 대화했던 한 팀은 Claude Sonnet을 사용하여 수치 추론 (numerical-reasoning) 작업에서 71%의 정확도를 얻고 있었고, 해결책이 Opus로 업그레이드하는 것이라고 결정했습니다. 비용은 3배로 뛰었습니다. 정확도는 78%로 올라갔습니다. 등급을 올린 것치고는 그리 좋지 않은 결과였습니다. 그러다 누군가 2022년의 Self-consistency 논문을 다시 꺼내어, Sonnet 모델을 유지한 채 Temperature 0.7에서 5개의 샘플을 병렬로 실행하고 답변에 대해 다수결 투표 (majority-voted)를 실시했습니다. 결과는 84%에 도달했습니다. 5개의 호출이 병렬로 분산되었기 때문에 지연 시간 (Latency)은 2.1초에서 2.4초로 늘어났습니다. 비용은 Opus 대비 낮아졌습니다. 이 포스트는 바로 그 트레이드오프 (trade)에 관한 것입니다. 세 가지 작업, 세 가지 설정, 실제 수치로 보여드립니다.
여전히 작동하는 2022년의 기술
Self-consistency는 Wang et al., 2022에서 유래되었습니다. 한 문장으로 요약하자면, 모델의 단일 탐욕적 답변 (greedy answer)을 취하는 대신, 0이 아닌 Temperature에서 N개의 샘플을 추출하고 최종 답변 중 가장 자주 등장하는 것을 반환하는 것입니다. 추론 과정 (reasoning chains)은 서로 다르지만, 결론은 수렴합니다. 해당 논문은 GSM8K, AQuA, SVAMP와 같이 최종 답변이 '=='로 비교 가능한 숫자인 수학 관련 데이터셋에서 성능 향상을 보여주었습니다. 이 기술은 Instruction tuning 이전부터 존재했을 정도로 오래되었습니다. 그럼에도 여전히 작동하는 이유는 이 기술이 겨냥하는 실패 모드 (failure mode)가 사라지지 않았기 때문입니다. 최첨단 모델 (Frontier models)은 샘플링 (sampling) 하에서 다양한 추론 경로를 생성하며, 올바른 경로가 대개 최빈값 (modal)인 경우가 많습니다. 2026년에 바뀐 점은 호출 비용이 저렴해졌고 비동기 (async) 방식이 기본값이 되었다는 것입니다. Sonnet에 대한 5번의 병렬 호출은 대략 한 번의 호출과 비슷한 실제 시간 (wall-time) 안에 완료됩니다. 지연 시간을 5배 지불하는 것이 아닙니다. 작업에 따라 다르겠지만, 다음 단계 모델로 한 번 호출하는 것보다 여전히 저렴한 5배의 토큰 (tokens) 비용을 지불하는 것입니다.
왜 특정 작업에서만 작동하는가
Self-consistency는 이산적이고 (discrete) 비교 가능한 최종 답변이 필요합니다. 그것이 이 기술의 전체적인 형태입니다. 당신은 투표를 하고 있는 것입니다.
투표(Voting)를 하려면 "두 답변이 동일하다"라는 조건이 작성 가능한 함수여야 합니다. 수학 문제의 경우 정답은 42입니다. 코드 완성(Code-completion) 작업의 경우 정답은 함수 본문이며, 이를 정규화(공백 제거, AST 파싱, 해싱 등)하여 비교할 수 있습니다. JSON 추출(JSON extraction) 작업의 경우 정답은 키를 정렬하여 문자열로 변환(stringify)할 수 있는 구조화된 객체입니다. 하지만 "X에 대해 500단어 분량의 포스트를 작성해줘"와 같은 요청에는 동치 관계(equivalence relation)가 존재하지 않습니다. 모든 샘플이 서로 다른 문자열이기 때문입니다. 이 경우 투표는 단순히 "첫 번째 것을 선택하라"로 축소되며, 이는 높은 온도(temperature)에서의 N=1과 다를 바 없고, 이는 온도 0에서의 N=1보다 성능이 더 나쁩니다. 아래의 벤치마크를 살펴보는 동안 이 필터를 염두에 두십시오. 이것이 유용한 기술과 값비싼 의식(ritual)을 가르는 차이점입니다.
벤치마크 설정
세 가지 작업. 세 가지 설정. 하나의 평가 환경(eval rig).
작업:
수학(Math): 200개의 GSM8K 스타일 문장제 문제. 최종 답변은 숫자입니다. 단위를 제거한 후 ==로 비교합니다.
코드(Code): 150개의 HumanEval 형태의 함수 완성 작업. 생성된 코드를 테스트 스위트(test suite)에 실행합니다. 통과/실패(Pass/fail)로 판정합니다.
JSON 추출(JSON extraction): 텍스트로 변환된 300개의 송장(invoice) PDF, 12개 필드를 가진 타겟 스키마(target schema). 필드 수준의 정확한 일치(exact match)를 비교하며, 필드 전체의 평균을 냅니다.
설정:
Sonnet×1: claude-sonnet-4-6, 온도 0 (탐욕적(greedy) 방식).
Sonnet×5: claude-sonnet-4-6, 온도 0.7, 5개의 샘플, 다수결 투표(majority vote).
Opus×1: claude-opus-4-6, 온도 0 (탐욕적(greedy) 방식).
온도 선택은 중요합니다. 단일 호출(singleton)의 경우, 모두가 프로덕션에서 사용하는 방식인 0을 선택했습니다. N=5 팬아웃(fan-out)의 경우 0.7을 선택했는데, 0.5 미만에서는 샘플들이 붕괴되어 투표가 무의미해지기 때문입니다. 0.9를 초과하면 샘플들이 너무 멀리 벗어나 최빈값(modal answer)이 정답이 아니게 됩니다. 원본 논문에서는 0.5–0.7을 사용했습니다. 본 벤치마크를 수행하기 전 소규모 그리드 탐색(grid sweep)을 거친 결과, 현재의 Anthropic 모델들의 이러한 작업 형태에는 0.7이 적절했습니다. 모든 실행은 동일한 작업 내에서 동일한 시스템 프롬프트(system prompt)를 사용했습니다. 각 셀(cell)당 3회의 독립적인 실행을 수행하여 결과를 평균 냈습니다. 지연 시간(Latency)은 병렬 팬아웃 오버헤드를 포함한 엔드 투 엔드(end-to-end) 기준입니다.
결과, 작업별
수학 (Math)
설정 | 정확도 (Accuracy) | p50 지연 시간 (latency) | 1k 작업당 비용 (Cost / 1k tasks)
Sonnet×1 | 71.3% | 1.9s | $2.40
Sonnet×5 | 84.1% | 2.4s | $11.80
Opus×1 | 78.6% | 3.1s | $14.20
Sonnet×5가 정확도 면에서 승리하며 Opus×1보다 저렴합니다. 그 이유는 정답 공간(answer space)이 매우 작고 이산적(discrete)이기 때문입니다. 서로 다른 산술 경로를 가진 5개의 사고 사슬 (chains-of-thought)이 올바른 숫자에 합의하는 빈도가, 탐욕적 (greedy)인 하나의 Opus 사고 사슬이 잘못된 숫자를 선택하는 빈도보다 더 높습니다. 지연 시간 (Latency)은 5개의 병렬 호출 중 가장 느린 호출이 p50을 지배하기 때문에 약간 상승합니다.
코드 (Code)
설정 | 정확도 (Accuracy) | p50 지연 시간 (latency) | 1k 작업당 비용 (Cost / 1k tasks)
Sonnet×1 | 64.0% | 3.4s | $5.10
Sonnet×5 | 76.7% | 4.1s | $24.80
Opus×1 | 73.3% | 5.8s | $28.60
동일한 양상입니다. Sonnet×5가 Opus×1을 3.4포인트 차이로 앞서며, 비용은 13% 더 저렴합니다. 핵심 비결은 투표 전의 정규화 (normalization) 단계에 있습니다: 주석을 제거하고, 공백을 정규화하며, 함수 본문을 파싱하고, 추상 구문 트리 (AST)를 해싱합니다. 변수 이름만 다른 두 솔루션은 함께 투표하게 됩니다. 이 과정이 없다면, 모든 샘플이 기술적으로는 서로 다른 문자열이기 때문에 투표가 무너집니다.
JSON 추출 (JSON extraction)
설정 | 정확도 (Accuracy) | p50 지연 시간 (latency) | 1k 작업당 비용 (Cost / 1k tasks)
Sonnet×1 | 88.2% | 1.4s | $1.90
Sonnet×5 | 93.4% | 1.7s | $9.20
Opus×1 | 90.1% | 2.2s | $11.30
Sonnet×5가 정확도에서 승리하며 여전히 Opus×1보다 저렴합니다. 투표 키 (voting key)는 키를 정렬하여 문자열화한 JSON 객체입니다. 필드별 투표 (12개 필드 각각을 독립적으로 투표한 후 조립)를 수행하면 동일한 데이터에서 정확도가 다시 90% 중반대까지 올라가는 경향이 있지만, 이는 대부분의 파이프라인에서 첫 단계로 굳이 필요하지 않은 복잡성을 추가합니다.
세 가지 작업 전체에 대한 요약: Sonnet×5는 모든 경우에서 정확도 면에서 Opus×1을 앞섰으며, 세 가지 중 두 가지 사례에서 비용 면에서도 앞섰습니다.
언제 이를 시도해야 하는가
모델 등급을 올리기 전에 자기 일관성 (self-consistency)을 시도해 볼 가치가 있다는 세 가지 신호는 다음과 같습니다:
- 검증 가능한 정답 (Verifiable answer): 정규화 여부와 상관없이 동일성을 비교할 수 있는 최종 추출된 결과물 (artifact)이 있는 경우.
- Sonnet은 정체되어 있고, Opus는 미미한 차이를 보일 때 (Sonnet is plateaued, Opus is marginal): Sonnet을 온도 (temperature) 0에서 측정하고 Opus를 온도 0에서 측정했을 때, Opus가 아주 많은 비용을 들여 겨우 몇 포인트의 이득만을 가져다주는 경우.
지연 시간 예산(Latency budget)에 병렬 팬아웃(Parallel fan-out)을 위한 여유가 있는 경우: 가장 느린 샘플이 전체를 지배함에 따라 발생하는 약 20-30%의 p50 증가를 엔드포인트가 수용할 수 있거나, 배치(Batch) 작업을 수행 중인 경우.
다음 세 가지 반대 신호가 있다면 건너뛰십시오:
- 개방형 생성 (Open-ended generation): 요약, 에세이, 자유 형식의 카피. 동치 관계(Equivalence relation)가 없으며, 투표(Vote)도 불가능합니다.
- 1초 미만의 엄격한 지연 시간 SLO (Strict latency SLO): 병렬 팬아웃조차도 가장 긴 꼬리 호출(Longest tail call)로 인한 오버헤드가 발생합니다.
- 데이터의 정확도 천장에 이미 도달한 경우: 일치도가 95%일 때 레이블(Labels)에 노이즈가 있다면, 현재 94%인 상태에서는 어떤 모델 개선도 의미가 없습니다.
실제 코드베이스에 연결하기
비동기 팬아웃(Async fan-out), 정규화된 답변(Normalized answer) 기준 모드, 가장 낮은 샘플 인덱스에서 타이 브레이크(Tie-break). 전체 코드는 60줄 미만입니다.
self_consistency.py
import asyncio
import hashlib
from collections import Counter
from anthropic import AsyncAnthropic
client = AsyncAnthropic()
MODEL = "claude-sonnet-4-6"
async def one_sample ( prompt : str , system : str ) -> str :
resp = await client . messages . create (
model = MODEL ,
max_tokens = 1024 ,
temperature = 0.7 ,
system = system ,
messages = [{ " role " : " user " , " content " : prompt }],
)
return resp . content [ 0 ]. text
def normalize ( answer : str ) -> str :
# 작업별로 적용 (task-specific).
# 수학의 경우: 숫자 이외의 문자 제거.
# JSON의 경우: 파싱 후 sort_keys=True로 덤프.
# 코드의 경우: AST로 파싱 후 body를 덤프.
return answer . strip (). lower ()
def vote ( samples : list [ str ]) -> str :
# 정규화된 형태를 해싱하여, 원래 문자열의 공백이나 표현이 다르더라도
# 동등한 답변들이 동일한 키로 합쳐지도록 합니다.
keyed = [ ( hashlib . sha1 ( normalize ( s ). encode ()). hexdigest (), s ) for s in samples ]
counts = Counter ( k for k , _ in keyed )
top_key , top_count = counts . most_common ( 1 )[ 0 ]
# 타이 브레이크(Tie-break): 승리한 키와 일치하는 첫 번째 샘플을 반환합니다.
# 투표 결과가 갈릴 때 결정론적(Deterministic)인 출력을 보장합니다.
for k, original in keyed:
if k == top_key:
return original
return samples[0] # 도달할 수 없는 코드이지만, 린트(lint) 오류 방지를 위해 작성함
async def self_consistency(prompt: str, system: str, n: int = 5) -> str:
tasks = [one_sample(prompt, system) for _ in range(n)]
samples = await asyncio.gather(*tasks)
return vote(samples)
이 60줄짜리 설정이 단순한 연습용 버전(toy versions)과 달리 제대로 구현한 몇 가지 사항이 있습니다.
normalize 함수가 핵심 규약(contract)입니다. 이 함수가 잘못되면 투표는 의미가 없어집니다. 수학 문제의 경우, 비교하기 전에 0-9, '.', ',', '-'를 제외한 모든 것을 제거해야 합니다. JSON의 경우, json.loads를 실행한 후 json.dumps(obj, sort_keys=True)를 수행합니다. 코드의 경우, 함수 본문을 파싱하고 주석과 변수명을 제거하며 AST(Abstract Syntax Tree)를 순회해야 합니다. Python의 ast나 tree-sitter 같은 라이브러리가 유용하게 쓰입니다. 투표는 원본 문자열(raw string)이 아니라 정규화된 형태의 해시(hash)를 기준으로 이루어집니다. 두 답변이 내용상 동일하더라도 끝에 줄바꿈(newline) 하나가 다를 수 있습니다. 정규화 후 해싱을 하면 이러한 차이를 없앨 수 있습니다.
타이브레이크(tie-break, 동점자 처리)는 승리한 해시와 일치하는 첫 번째 샘플을 반환하며, 이를 통해 예를 들어 두 답변이 각각 2표씩 얻어 동점이 되었을 때도 함수가 결정론적(Deterministic)인 출력을 유지하도록 합니다. 이는 다운스트림(downstream)의 캐시 키(cache key)로 활용할 때 유용합니다.
팬아웃(fan-out)은 순차적인 루프가 아닌 asyncio.gather를 사용합니다. 2026년 기준 '프로덕션 패턴으로서의 자기 일관성(self-consistency-as-a-prod-pattern)'의 핵심은 5개의 병렬 호출이 대략 한 번의 호출과 비슷한 실제 시간(wall time) 내에 완료된다는 점입니다. 만약 루프 안에서 await를 사용한다면, 1배의 지연 시간(latency)을 5배로 늘리는 셈이며, 이는 어떤 대화형 엔드포인트(interactive endpoint)에서도 허용될 수 없는 치명적인 문제입니다.
당신이 맞닥뜨릴 함정(The gotcha you'll hit)
이 코드를 처음 실행하면 투표가 제대로 작동하지 않는 것처럼 보일 것입니다. 5개의 샘플이 돌아왔는데 카운터가 {hash_a: 1, hash_b: 1, hash_c: 1, hash_d: 1, hash_e: 1}로 표시될 수 있습니다. 모든 샘플이 각자 고유한 그룹이 되어 과반수가 없고, 타이브레이크가 첫 번째 샘플을 선택하게 되므로, 결과적으로 지연 시간 페널티만 안고 N=1로 실행하는 것과 다를 바 없게 됩니다.
해결책은 언제나 normalize에 있습니다. 샘플들이 서로 다른 표현 방식, 다른 숫자 형식, JSON의 다른 필드 순서, 코드의 다른 공백을 가지고 있기 때문입니다.
당신의 작업이 어떤 형태이든, 해당 함수는 의미론적으로 동일한 (semantically-equivalent) 출력들을 동일한 문자열로 수렴시켜야 합니다. 요청 샘플들에 대한 버킷(buckets)을 출력하는 로깅 라인을 추가해 보면 5분 안에 문제를 발견할 수 있을 것입니다. 만약 정규화 (normalization) 이후에도 투표 결과가 여전히 1/1/1/1/1로 나뉜다면, 그것은 Self-consistency의 문제가 아닙니다. 그것은 모델이 진정으로 불확실한 상태임을 의미합니다. Self-consistency는 모델이 가지고 있지 않은 지식을 만들어낼 수 없습니다. 그럴 때는 RAG, 미세 조정 (fine-tuning), 또는 다른 모델로 넘어가십시오.
실패하는 지점: 이 기술에는 한 가지 명확한 실패 모드 (failure mode)가 있습니다. 바로 개방형 생성 (open-ended generation)입니다. 만약
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기