에이전트·RAG

AGENT / 6번째 글

생각을 여러 갈래로 펼치기: Tree-of-Thought와 Self-Consistency

추론 경로 하나로 모자랄 때 쓰는 두 처방을 한자리에서 비교한다. 트리로 갈래를 넓히는 Tree-of-Thought와 같은 길을 여러 번 걸어 다수결로 고르는 Self-Consistency를, 원리부터 호출 비용까지 함께 따라간다.

PALDYN Team42 MIN READ

지난 글에서 LLM에게 단계를 밟아 생각하게 만드는 Chain-of-Thought를 봤다. 중간 과정을 말로 풀어 쓰게 하는 것만으로 정답률이 오른다는 것이 그 글의 결론이었다. 이 글은 그다음 질문에 답한다. 경로 하나로 모자랄 때는 무엇을 더 하는가.

답은 둘인데 방향이 정반대다. Tree-of-Thought는 갈래를 옆으로 넓혀 여러 경로를 동시에 들고 가며 나쁜 것을 버린다. Self-Consistency는 갈래를 넓히지 않고 같은 길을 여러 번 걸은 뒤 가장 자주 도착한 곳을 답으로 삼는다. 둘을 한 편에 묶는 이유는 이것이 사실 같은 뼈대의 두 설정이기 때문이다. 여러 경로를 만들고, 그중 무엇을 남길지 고른다 — 앞의 것은 고르는 자리에 LLM 평가자를 놓고, 뒤의 것은 그 자리에 다수결을 놓는다.

단일 경로의 한계

선형 추론의 오류 누적

CoT는 추론을 선형(linear)으로 펼친다. 한 문장을 쓰고 그 문장을 전제로 다음 문장을 쓰는 식이라, 이미 쓴 단계는 되돌릴 수 없다. 생성 중인 모델에게는 자기가 세 단계 전에 잘못 갈라섰다는 것을 알아챌 방법도, 알아챈들 그 자리로 돌아갈 방법도 없다. 초반 한 걸음이 어긋나면 뒤의 논리가 아무리 매끄러워도 결론은 틀린다.

이 취약함이 얼마나 빨리 쌓이는지는 곱으로 세어 보면 보인다. 단계마다 90%의 확률로 옳게 간다고 하자. 다섯 단계짜리 문제라면 끝까지 옳을 확률은 0.95≈0.590.9^5 \approx 0.59 다. 단계별로는 훌륭한 정확도인데 전체로는 열 번 중 네 번을 틀린다. 열 단계면 0.350.35 로 떨어진다. 단계를 늘려 정확해지려는 기법이 단계를 늘릴수록 불안해지는 셈이다.

여기서 중요한 것은 어디가 틀렸는지 모른 채 결과만 틀린다는 점이다. 잘못된 전제 위에 세운 뒤쪽 계산이 그 자체로는 흠잡을 데 없으므로, 출력만 봐서는 그럴듯하다. 사람이 검산하지 않으면 걸러지지 않고, 그래서 이 문제는 「모델을 더 잘 설득하기」로는 풀리지 않는다. 구조를 바꿔야 한다.

온도와 다양성

두 기법이 공통으로 쓰는 재료가 하나 있다. 같은 프롬프트에 대해 서로 다른 답을 얻어 낼 수 있다는 성질이다.

LLM은 매 토큰마다 어휘 전체에 대한 확률 분포를 내놓고, 그중 하나를 골라 이어 쓴다. 언제나 확률이 가장 높은 토큰을 고르는 방식을 그리디 디코딩(greedy decoding)이라 하고, 흔히 temperature=0으로 부르는 설정이 그것이다. 이렇게 하면 같은 입력에는 언제나 같은 출력이 나온다. 온도(temperature)를 올리면 분포가 평평해져 확률이 낮은 토큰도 뽑힐 여지가 생기고, 같은 질문을 열 번 던지면 열 개의 서로 다른 추론이 나온다.

보통 온도는 「창의성 손잡이」로 소개되지만 여기서의 쓰임은 다르다. 온도가 만드는 것은 서로 독립에 가까운 여러 개의 시도이고, 두 기법은 그 시도들을 자산으로 쓴다. 한 번의 시도가 60%로 맞는다면, 서로 다른 방식으로 세 번 도착한 같은 답은 그보다 훨씬 믿을 만하다. 사람이 어려운 계산을 두 가지 방법으로 풀어 보고 답이 같으면 안심하는 것과 정확히 같은 논리다.

분기와 반복

같은 재료를 쓰지만 쓰는 방식이 갈린다.

Tree-of-Thought Self-Consistency
늘리는 것 한 시점의 후보 갈래 처음부터 끝까지의 완주 횟수
중간 개입 매 단계마다 평가·가지치기 없음 (끝까지 두고 본다)
고르는 주체 LLM 평가자 답의 빈도
필요한 것 단계를 쪼갤 수 있는 문제 답을 비교할 수 있는 문제

「단계를 쪼갤 수 있는가」와 「답을 비교할 수 있는가」가 실제 선택 기준이다. Tree-of-Thought는 중간 상태를 꺼내 놓고 그것만 보고 유망함을 판단할 수 있어야 성립한다. Self-Consistency는 두 응답이 같은 답인지 기계적으로 판정할 수 있어야 성립한다. 이 조건은 뒤에서 각각의 약점으로 다시 나온다.

Tree-of-Thought의 모듈

생각과 트리

Tree-of-Thought(ToT)는 2023년 Yao 등이 제안한 프레임워크로, 추론을 문장의 줄이 아니라 트리로 모델링한다. 각 노드는 하나의 생각(thought)이고, 여기서 생각이란 문제를 푸는 도중의 중간 상태 — 지금까지 밟은 단계를 모아 놓은 부분 풀이 — 를 뜻한다. 뿌리는 아직 아무것도 안 한 상태이고, 아래로 내려갈수록 풀이가 한 단계씩 채워진다.

Tree-of-Thought 탐색 구조

트리로 놓으면 CoT가 못 하던 두 가지가 생긴다. 하나는 분기다. 한 상태에서 갈 수 있는 다음 단계가 여럿이라면 그 여럿을 모두 노드로 만들어 둘 수 있다. 다른 하나는 역추적(backtracking)이다. 어떤 가지가 막히면 그 가지를 버리고 위쪽 형제 노드로 돌아가 다른 길을 시도한다. 되돌릴 수 없다는 CoT의 제약이 여기서 사라진다. 고전적인 탐색 알고리즘이 수십 년 써 온 아이디어를 LLM 추론에 그대로 옮긴 것이고, 새로운 것은 노드를 만드는 일과 노드를 평가하는 일을 둘 다 LLM이 한다는 점뿐이다.

그래서 ToT는 모듈 셋으로 나뉜다. 생각을 만드는 생성기, 상태가 얼마나 유망한지 매기는 평가기, 그리고 어느 노드를 다음에 펼칠지 정하는 탐색기다. 셋은 서로 독립이라 하나씩 갈아 끼울 수 있다.

ToT 구현 아키텍처

후보 생성

첫 모듈은 현재 상태에서 다음 단계 후보 kk 개를 만든다. 방식이 둘이다.

독립 샘플링(independent sampling)은 같은 프롬프트를 온도를 올린 채 kk 번 호출한다. 구현이 단순하고 kk 개 호출을 병렬로 던질 수 있어 지연 시간이 한 번 호출과 비슷해진다. 대신 후보가 겹칠 수 있다. 모델이 보기에 가장 자연스러운 다음 수가 하나뿐이면 세 번을 물어도 셋 다 같은 답이 나온다. 갈래를 넓히려고 세 번 호출했는데 실제로는 한 갈래인 상태로 다음 단계에 들어가고, 비용만 세 배가 된다.

순차 제안(sequential proposal)은 이 문제를 프롬프트로 막는다. 「앞서 제안한 것과 다른 접근을 하나 더 내라」고 이전 후보들을 프롬프트에 넣어 다음 후보를 받는다. 다양성이 강제되는 대신 호출이 직렬이라 지연이 kk 배로 쌓이고, 앞서 나온 후보가 프롬프트에 붙으므로 입력 토큰도 늘어난다.

고르는 기준은 답의 개수다. 가능한 다음 수가 원래 많은 문제 — 창작, 계획 수립 — 는 독립 샘플링으로 충분하다. 반대로 다음 수가 몇 개 안 되고 그중 하나가 압도적으로 그럴듯한 문제에서는 순차 제안이 아니면 갈래가 안 벌어진다. 어느 쪽을 쓰든 이 모듈의 계약은 같다 — 현재 상태와 문제를 받아 다음 단계 후보 문자열 kk 개를 돌려준다. 뒤의 탐색 코드가 generate_thoughts(state, problem, k)로 부르는 것이 이 자리다.

상태 평가

두 번째 모듈이 ToT의 핵심이다. 만든 후보 중 무엇을 남길지 정해야 하는데, 정답을 모르는 상태에서 「이 길로 가면 답이 나올 것 같은가」를 판단해야 한다. 이것도 LLM에게 시킨다.

값 매기기(value)는 후보를 하나씩 보여 주고 점수나 레이블을 받는다. 1~10점을 매기게 할 수도 있지만, 실전에서는 「확실 / 유망 / 불가」 같은 세 단계 레이블이 다루기 쉽다. 숫자 척도는 모델이 대부분의 후보에 7점을 주는 식으로 뭉치는 일이 잦아서, 정작 순위를 가려야 할 때 변별력이 없다.

import anthropic

client = anthropic.Anthropic()

def evaluate_state(state: str, problem: str) -> float:
    prompt = f"""문제: {problem}

현재 풀이 상태:
{state}

이 경로가 최종 정답에 도달할 가능성을 한 단어로 평가하세요.
- 확실: 이미 답이 명확하다
- 유망: 계속 진행할 만하다
- 불가: 이 경로로는 답이 나오지 않는다

평가:"""
    resp = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=16,
        temperature=0,          # 평가는 흔들리면 안 된다
        messages=[{"role": "user", "content": prompt}],
    )
    text = resp.content[0].text
    if "확실" in text:
        return 1.0
    return 0.6 if "유망" in text else 0.0

평가 호출의 온도를 0으로 두는 것이 중요하다. 생성 쪽은 다양성이 목적이지만 평가 쪽은 재현성이 목적이다. 같은 상태에 매번 다른 점수가 붙으면 가지치기가 무작위 추첨이 된다. 그리고 max_tokens를 작게 잡는다 — 평가기는 한 단어만 필요한데 문단을 쓰게 두면 호출 수가 많은 이 자리에서 비용이 그대로 곱해진다.

투표(vote)는 후보를 나란히 놓고 「이 중 가장 유망한 것은 몇 번인가」를 묻는다. 호출이 후보 수만큼이 아니라 한 번으로 끝나서 싸고, 절대 점수가 아니라 상대 비교라 앞서 말한 뭉침 현상도 덜하다. 대신 「전부 가망 없음」을 표현할 자리가 없다. 어느 것이 최선인지는 알려 주지만 그 최선이 쓸 만한지는 안 알려 주므로, 막다른 가지를 통째로 버리는 판단이 어렵다. 값 매기기와 투표를 섞어 쓰는 구현이 많은 이유다.

BFS와 DFS

세 번째 모듈은 평가 결과를 받아 다음에 펼칠 노드를 고른다.

BFS(너비 우선 탐색)는 한 레벨을 통째로 펼치고 그중 상위 bb 개만 남긴 뒤 다음 레벨로 내려간다. 여러 갈래를 동시에 들고 가므로 초반 실수에 강하지만, 레벨마다 후보를 모두 만들어야 해서 메모리와 호출이 많다. 이렇게 폭을 bb 로 고정한 BFS를 빔 서치(beam search)라 부르고 bb 를 빔 폭이라 한다. 실전에서 ToT라고 하면 대개 이 형태다.

DFS(깊이 우선 탐색)는 한 경로를 끝까지 밀고 가다가 막히면 되돌아온다. 메모리를 거의 안 쓰고 운이 좋으면 답에 빨리 닿지만, 초반 갈래를 잘못 고르면 그 아래를 통째로 헤매고 나서야 돌아온다.

def tot_bfs(problem: str, n_steps: int = 4, k: int = 3, b: int = 3) -> list[str]:
    frontier = [""]                       # 뿌리: 아직 아무 단계도 없다

    for step in range(n_steps):
        candidates = []
        for state in frontier:
            for thought in generate_thoughts(state, problem, k):
                candidates.append(f"{state}\n단계 {step + 1}: {thought}")

        scored = [(s, evaluate_state(s, problem)) for s in candidates]
        scored.sort(key=lambda pair: pair[1], reverse=True)
        frontier = [s for s, score in scored[:b] if score > 0]

        if not frontier:                  # 살아남은 가지가 없다
            break

    return frontier

이 열몇 줄에 ToT가 다 들어 있다. 눈여겨볼 곳은 if score > 0 이다. 상위 bb 개를 자르는 것과 별개로, 점수가 0인 — 즉 평가기가 「불가」라고 한 — 상태는 순위와 무관하게 버린다. 이 조건이 없으면 모든 후보가 막다른 길인 단계에서도 억지로 세 개를 들고 다음 레벨로 내려가고, 그 아래로 만드는 호출이 전부 낭비가 된다. 살아남은 가지가 없으면 반복을 끊는 break 도 같은 목적이다. 탐색이 실패했다는 것을 일찍 알리는 편이 끝까지 헛돈 뒤 알리는 것보다 낫다.

Game of 24

4%와 74%

Yao 등이 ToT를 평가한 대표 과제는 Game of 24다. 네 개의 숫자 — 이를테면 4, 8, 8, 2 — 를 사칙연산으로 한 번씩만 써서 정확히 24를 만드는 퍼즐이다. 논문의 보고에서 단순 Few-shot CoT의 성공률은 4%였고, ToT를 BFS로 돌렸을 때는 74%였다. 같은 모델, 같은 문제, 프롬프팅 구조만 바꿔서 얻은 차이다.

이렇게 벌어지는 이유는 이 퍼즐이 CoT에 가장 불리한 종류이기 때문이다. Game of 24에는 부분 점수가 없다. 24가 나오거나 안 나오거나 둘 중 하나이고, 중간까지 그럴듯하게 간 풀이도 마지막에 23이 나오면 0점이다. 한 번의 직선 추론이 정확히 맞는 조합을 처음에 집어야 하는데, 모델에게는 그 조합을 미리 알아볼 방법이 없다.

탐색 공간의 크기

얼마나 넓은 공간에서 하나를 집는 일인지 세어 보면 감이 온다. 첫 수순에서 네 수 중 둘을 고르는 방법이 (42)=6\binom{4}{2} = 6 가지다. 두 수에 적용할 연산은 덧셈·곱셈에 뺄셈과 나눗셈이 각각 두 방향이므로 6가지다. 곱하면 36가지. 이제 수가 셋 남았으니 다음 수순은 3×6=183 \times 6 = 18 가지, 마지막은 1×6=61 \times 6 = 6 가지다.

36×18×6=3,88836 \times 18 \times 6 = 3{,}888

중복을 빼지 않은 거친 수이지만 크기는 이 정도다. CoT는 이 3,888갈래 중 하나를 골라 끝까지 걸어간 뒤 결과를 내놓는다. 4%라는 숫자가 이상하지 않은 이유다. 반면 ToT는 첫 수순에서 여러 갈래를 동시에 열어 두고, 「4를 8로 나눠 0.5를 만들었다」 같은 가망 없는 중간 상태를 그 자리에서 잘라 낸다. 정답을 처음부터 맞힐 필요가 없어지고, 대신 부분 상태를 보고 가망을 알아보기만 하면 된다. 훨씬 쉬운 요구다.

바꿔 말하면 ToT가 이기는 조건은 「생성이 어렵고 평가가 쉬운 문제」다. Game of 24가 그 극단이고, 코드 디버깅이나 계획 수립도 대체로 그 성질을 갖는다 — 고칠 방법을 떠올리기는 어렵지만 어떤 가설이 말이 안 되는지는 금방 안다. 반대로 평가가 생성만큼 어려운 문제에서는 평가기가 틀린 가지를 남기고 맞는 가지를 자르므로, 호출만 늘고 정확도는 안 오른다.

가지치기와 빔 폭

가지치기는 정확도만이 아니라 비용을 위한 장치이기도 하다. 트리를 자르지 않고 k=3k = 3 으로 네 단계를 전개하면 노드가 3+9+27+81=1203 + 9 + 27 + 81 = 120 개다. 빔 폭 b=3b = 3 을 걸면 단계마다 아홉 개를 만들어 셋만 남기므로, 만드는 노드가 3+9+9+9=303 + 9 + 9 + 9 = 30 개로 줄어든다. 네 배 차이이고, 단계가 늘수록 이 격차는 지수로 벌어진다. 다섯 단계면 자르지 않은 쪽이 363개, 자른 쪽은 39개다.

가지치기가 줄이는 노드 수

여기서 빔 폭은 단순한 성능 손잡이가 아니라 탐색을 유한하게 만드는 장치라는 점이 드러난다. bb 를 두지 않으면 트리는 지수로 자라 어느 문제에서든 예산을 넘긴다. 반대로 b=1b = 1 이면 매 단계 하나만 남기므로 사실상 CoT로 돌아간다 — 다만 매 단계에 평가 호출이 붙은 비싼 CoT다. 실전에서 2에서 5 사이를 쓰는 것은 이 두 극단 사이에서 타협한 결과다.

Self-Consistency

다수결 앙상블

Self-Consistency는 2022년 Wang 등이 제안한 기법이고, 아이디어를 한 문장으로 줄이면 「한 번 묻지 말고 여러 번 물어 다수결로 정하라」다. 같은 질문에 온도를 올린 CoT를 nn 번 돌려 nn 개의 추론 경로를 얻고, 각 경로에서 최종 답만 뽑아, 가장 많이 나온 답을 채택한다.

Self-Consistency 다수결 앙상블

ToT와 비교하면 없는 것이 눈에 띈다. 중간에 아무것도 안 본다. 경로가 좋은지 나쁜지 도중에 판단하지 않고, 막힌 것 같아도 자르지 않고 끝까지 둔다. 평가는 오직 마지막에, 그것도 LLM이 아니라 빈도 세기가 한다.

이 단순함이 값이다. 문제를 단계로 쪼갤 필요도, 중간 상태를 평가하는 프롬프트를 따로 설계할 필요도 없다. 이미 쓰고 있는 CoT 프롬프트를 그대로 두고 호출을 반복하기만 하면 된다.

GSM8K의 18%p

효과는 벤치마크로 확인된다. 초등 수준 서술형 수학 문제 모음인 GSM8K에서 그리디 CoT가 56.5%였을 때, 경로 40개를 샘플링한 Self-Consistency는 74.4%였다. 파인튜닝도 모델 교체도 없이 프롬프팅 방식만 바꿔 약 18%p를 얻었다.

Self-Consistency 성능 비교

왜 오르는지는 확률로 따라갈 수 있다. 경로 하나가 60% 확률로 맞고 틀릴 때는 전부 같은 오답으로 몰린다고 — 다수결에 가장 불리한 가정으로 — 두자. 경로 9개 중 5개 이상이 맞을 확률은 이렇게 나온다.

∑i=59(9i)0.6i 0.49−i≈0.733\sum_{i=5}^{9} \binom{9}{i} 0.6^{i} \, 0.4^{9-i} \approx 0.733

60%가 73%가 된다. 실제로는 오답이 한 곳에 몰리지 않고 여러 값으로 흩어지므로 — 계산 실수는 저마다 다른 숫자를 낸다 — 이득은 이 계산보다 크다. 정답은 하나로 모이고 오답은 흩어진다는 비대칭이 이 기법의 진짜 동력이다.

같은 식이 경고도 준다. 경로 하나의 정확도가 0.5를 밑돌면 다수결은 성능을 깎는다. 위 계산에서 0.6을 0.4로 바꾸면 결과는 약 0.27로, 한 번 물었을 때의 0.4보다 낮다. 틀린 쪽으로 쏠린 다수를 증폭할 뿐이기 때문이다. Self-Consistency는 이미 반쯤 맞는 모델을 더 맞게 만드는 도구이지, 못 푸는 문제를 풀어 주는 도구가 아니다. 정확도가 바닥인 과제에서 샘플만 늘리는 것은 돈을 태우는 일이다.

성능은 샘플을 늘릴수록 오르다가 수렴한다. 대체로 10~20개에서 이득의 대부분이 나오고, 40개까지 늘려도 그 뒤의 증가분은 완만하다. 비용이 샘플 수에 정비례하므로 이 수렴 지점을 아는 것이 실무에서는 성능 자체보다 중요하다.

답 추출과 정규화

구현에서 실제로 애먹는 곳은 다수결이 아니라 그 앞이다. Counter로 세려면 먼저 각 응답에서 답을 뽑아내야 하는데, LLM은 매번 다른 문장으로 답을 쓴다.

from collections import Counter
import re

ANSWER_PATTERNS = [
    r"(?:최종\s*)?(?:답|정답)[:\s]+([^\n]+)",
    r"따라서[^\n]*?(\d[\d,\.]*)",
    r"=\s*(\d[\d,\.]*)\s*(?:이다|입니다|원|개|명)?",
]

def extract_answer(text: str) -> str:
    for pattern in ANSWER_PATTERNS:
        m = re.search(pattern, text)
        if m:
            return m.group(1).strip().rstrip(".")
    numbers = re.findall(r"\d+", text.split("\n")[-1])
    return numbers[-1] if numbers else text.strip()[:50]

def self_consistency(question: str, n: int = 10, temperature: float = 0.8) -> dict:
    prompt = f"Q: {question}\nA: 단계별로 생각해 봅시다."
    answers = []
    for _ in range(n):
        resp = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=512,
            temperature=temperature,
            messages=[{"role": "user", "content": prompt}],
        )
        answers.append(extract_answer(resp.content[0].text))

    votes = Counter(answers)
    best, count = votes.most_common(1)[0]
    return {"answer": best, "confidence": count / n, "votes": dict(votes)}

이 정규식들이 조용히 무너지는 자리가 많다. 「8,400원」과 「8400」이 다른 표로 세어지고, 「24개」와 「24」가 갈라지며, 소수점 자리가 다른 두 답이 같은 계산 결과인데도 따로 잡힌다. 표가 흩어지면 다수결의 전제가 깨진다 — 맞는 답 여섯 개가 세 가지 표기로 쪼개지면 오답 넷에 진다.

그래서 추출은 정규식으로 시작하되 정규화를 반드시 붙인다. 쉼표와 단위를 떼고, 숫자면 수치로 바꿔 비교하고, 소수는 자릿수를 맞춘다. 그리고 이 문제가 왜 생기는지를 거슬러 올라가면 프롬프트다. 「마지막 줄에 답: <숫자> 형식으로만 쓰라」고 명시하면 추출기가 할 일이 크게 줄고, 구조화된 출력을 지원하는 API를 쓰면 추출 단계를 아예 없앨 수도 있다. 답 형식을 강제하는 한 줄이 정규식 열 줄보다 낫다.

USC 집계

빈도 세기에는 근본적인 한계가 있다. 답이 숫자나 짧은 레이블일 때만 성립한다는 것이다. 요약문 다섯 개는 문자열로 비교하면 전부 다르므로 최다 득표가 언제나 1표이고, 다수결이 무의미해진다.

2023년 Chen 등이 제안한 Universal Self-Consistency(USC)는 이 자리를 LLM에게 넘긴다. 응답 nn 개를 모두 한 프롬프트에 담고 「이 중 가장 일관성 있는 것을 고르라」고 시키는 방식이다.

def universal_self_consistency(question: str, responses: list[str]) -> str:
    candidates = "\n\n".join(f"[응답 {i}]\n{r}" for i, r in enumerate(responses, 1))
    prompt = f"""다음은 같은 질문에 대한 {len(responses)}개의 응답입니다.

질문: {question}

{candidates}

응답들을 검토하고, 여러 응답이 공통으로 말하는 내용을 기준으로
가장 일관성 있는 답을 고르거나 종합하세요.

최종 답:"""
    resp = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=512,
        temperature=0,
        messages=[{"role": "user", "content": prompt}],
    )
    return resp.content[0].text

집계 호출 하나가 더 붙지만, 그 대신 요약·번역·설명처럼 답이 문장인 과제에도 이 기법을 쓸 수 있게 된다. 대가는 집계가 다시 LLM의 판단이 된다는 점이다. Counter는 틀릴 수가 없지만 집계 LLM은 소수 의견에 설득당할 수 있다. 그리고 응답 nn 개를 전부 프롬프트에 넣으므로 입력 토큰이 nn 개 응답의 길이만큼 붙는다 — 긴 응답을 열 개 모으면 집계 호출 하나가 생성 호출 열 개보다 비싸질 수 있다.

ToT와 Self-Consistency의 관계

평가기와 다수결

여기까지 오면 두 기법의 관계가 보인다. ToT의 세 모듈 중 평가기 자리에 다수결을 놓은 것이 Self-Consistency다. 그리고 탐색기 자리에는 「가지치기 없음」을 놓았다. 트리로 그리면 뿌리에서 nn 개의 가지가 뻗어 나와 한 번도 갈라지지도 잘리지도 않은 채 잎까지 가는, 폭이 nn 이고 갈래가 없는 모양이다.

이 관점이 실무에서 쓸모 있는 이유는 모듈을 섞을 수 있다는 것을 알려 주기 때문이다. ToT의 평가기가 미덥지 않은 자리 — 상태를 보고 유망함을 판단하기 어려운 문제 — 에서는 값 매기기 대신 그 상태에서 몇 번 굴려 보고 답이 모이는지를 보는 방식으로 바꿀 수 있다. 반대로 Self-Consistency가 너무 비쌀 때는 중간에 한 번 잘라 내는 단계를 끼워 ToT 쪽으로 옮길 수 있다. 두 이름은 이 스펙트럼 위의 두 지점일 뿐이다.

선택 기준

고르는 기준을 정리하면 이렇다.

상황 고르는 것
중간 상태만 보고 가망을 판단할 수 있다 ToT
답이 하나의 값이고 비교가 쉽다 Self-Consistency
역추적이 필요하다 (퍼즐·게임·디버깅) ToT
이미 쓰는 CoT 프롬프트를 고치고 싶지 않다 Self-Consistency
답이 문장이라 빈도를 셀 수 없다 ToT 또는 USC
지연 시간이 중요하다 Self-Consistency (병렬 호출)

마지막 줄이 자주 간과된다. Self-Consistency의 nn 개 호출은 서로 완전히 독립이라 전부 동시에 던질 수 있다. 벽시계 시간은 한 번 호출과 거의 같고 늘어나는 것은 비용뿐이다. ToT는 다르다. 다음 단계의 후보를 만들려면 이전 단계의 평가가 끝나야 하므로 단계 수만큼은 반드시 직렬이다. 네 단계짜리 ToT는 아무리 병렬화해도 최소 여덟 번의 왕복(생성 넷, 평가 넷)이 걸린다. 사용자가 기다리는 화면 뒤에서 도는 코드라면 이 차이가 결정적이다.

표의 분포와 신뢰도

Self-Consistency는 답 말고 하나를 더 준다. 표의 분포다. 열 번 중 아홉 번이 42라고 한 것과, 42가 넷·37이 셋·51이 셋인 것은 같은 「42」여도 뜻이 다르다.

이 값을 그대로 쓰는 자리가 있다. 표가 갈린 질문만 골라 사람에게 넘기거나, 더 큰 모델로 다시 돌리거나, 「확실하지 않다」고 답하는 라우팅이다. 한 번만 호출했다면 모델이 자신 있게 틀린 답을 내놓아도 알아챌 방법이 없는데, 표가 갈린 것은 기계적으로 감지된다.

다만 이 비율을 확률로 읽으면 안 된다. 「신뢰도 0.7」은 「70% 확률로 맞다」는 뜻이 아니라 「같은 프롬프트를 열 번 돌렸더니 일곱 번이 이 답이었다」는 뜻일 뿐이다. 모델이 일관되게 틀리는 문제라면 표는 만장일치인데 답은 오답이다. 일관성은 정확성이 아니다. 이 값은 임계치를 정해 라우팅에 쓰는 상대적인 신호이고, 그 임계치도 자기 데이터에서 재 보고 정해야 한다.

호출 비용

호출 수 계산

두 기법 모두 정확도를 호출 수로 산다. 얼마나 사는지는 세어 보면 나온다.

Self-Consistency는 간단하다. 샘플 nn 개면 호출 nn 번, 비용도 nn 배다. USC를 붙이면 집계 호출 하나가 더 붙는다.

ToT는 곱셈이다. 앞의 tot_bfs를 k=3k = 3, b=3b = 3, 4단계로 돌리면 이렇게 센다. 첫 단계는 뿌리 하나에서 후보 셋을 만들고 셋을 평가하니 6번. 이후 세 단계는 각각 상태 셋에서 아홉을 만들고 아홉을 평가하니 18번씩, 합쳐 54번. 전부 60번이다. 같은 문제에 Self-Consistency를 10개로 돌리면 10번이니 여섯 배 차이다.

ToT 호출≈2k×(1+b×(단계 수−1))\text{ToT 호출} \approx 2k \times \bigl(1 + b \times (\text{단계 수} - 1)\bigr)

이 식이 알려 주는 것은 손잡이 셋 중 어느 것도 공짜가 아니라는 점이다. 단계를 하나 늘리면 18번이 붙고, 빔 폭을 하나 늘리면 단계마다 6번이 붙는다. 그리고 평가 호출이 정확히 절반을 차지한다 — 평가 프롬프트를 짧게 두고 max_tokens를 조이는 것이 여기서 곧바로 절반의 절약으로 돌아온다.

태스크별 샘플 수

Self-Consistency 쪽은 경험적인 기준선이 있다.

과제 성격 권장 샘플 수 비용 배수
단순 계산 5~8개 5~8×
수학 추론 10~20개 10~20×
복잡한 논리 20~40개 20~40×
창의적 글쓰기 3~5개 3~5×

표의 마지막 줄이 다른 셋과 성격이 다르다. 창작에는 애초에 맞는 답이 없으므로 다수결로 고를 것도 없다. 여기서 샘플을 여럿 뽑는 목적은 정확도가 아니라 선택지이고, 고르는 것은 사람이거나 USC 방식의 종합이다. 표에 적힌 3~5라는 수도 「사람이 훑어보기 좋은 개수」에서 나온 것이지 수렴 지점이 아니다.

나머지 셋은 앞의 수렴 이야기와 이어진다. 문제가 어려울수록 경로 하나의 정확도가 낮고, 정확도가 낮을수록 다수가 안정되기까지 더 많은 표가 필요하다. 다만 이 표는 시작점일 뿐이다. 실제로는 자기 데이터에서 nn 을 5, 10, 20으로 바꿔 가며 정확도를 재고 곡선이 평평해지는 지점을 찾는 편이 낫다. 그 지점이 20을 넘어간다면 샘플을 더 늘리기보다 프롬프트나 모델을 먼저 손보는 것이 옳다.

조기 종료와 캐싱

정해진 nn 을 끝까지 돌리는 것은 낭비인 경우가 많다. 쉬운 문제는 다섯 개만 뽑아도 만장일치가 나오는데 스무 개를 채우고 있을 이유가 없다. 배치로 나눠 뽑으면서 신뢰도가 목표에 닿으면 끊는다. 아래의 sample_answers는 앞 코드에서 표본을 뽑는 루프만 떼어 낸 것이다 — 호출한 응답을 extract_answer에 통과시켜 답 문자열의 리스트를 돌려준다.

def adaptive_self_consistency(question, target=0.7, max_n=20, batch=5) -> dict:
    answers, used = [], 0
    while used < max_n:
        answers.extend(sample_answers(question, batch))   # batch개 병렬 호출
        used += batch
        best, count = Counter(answers).most_common(1)[0]
        if count / used >= target:
            return {"answer": best, "confidence": count / used, "used": used}
    return {"answer": best, "confidence": count / used, "used": used}

배치 크기가 이 코드의 유일한 설계 결정이다. 1로 두면 낭비가 가장 적지만 호출이 전부 직렬이 되어 병렬성이라는 Self-Consistency의 장점이 사라진다. 배치를 5로 두면 다섯 개를 동시에 던지고 그 결과로 판단하므로, 최악의 경우 네 개를 더 쓰는 대신 왕복 횟수가 최대 넷으로 줄어든다.

ToT 쪽의 절약은 다른 데서 온다. 생성 호출과 평가 호출 모두 앞부분이 매번 같다 — 문제 설명과 지시문이 그대로 반복되고, 달라지는 것은 뒤에 붙는 현재 상태뿐이다. 프롬프트 캐싱은 반복되는 앞부분을 서버에 캐시해 두고 재사용하는 기능이라 이 구조에 정확히 들어맞는다. 캐시가 걸리는 조건은 접두사가 바이트 단위로 같은 것이므로, 고정된 부분을 앞에, 매번 바뀌는 상태를 뒤에 두는 순서가 중요하다. 조건이 하나 더 있다. 캐시는 그 앞부분이 모델마다 정해진 최소 길이를 넘을 때만 걸리고, 못 넘으면 오류 없이 조용히 안 걸린다 — 앞에서 본 짧은 평가 프롬프트가 그 경우다. 문제 설명과 지시문이 그 길이를 넘는 자리라면, 호출이 60번일 때 그중 59번이 같은 앞부분을 다시 보내는 셈이니 이 순서 하나로 입력 비용의 상당 부분이 사라진다.

적용 제외 과제

마지막으로, 두 기법을 안 쓰는 판단이 가장 자주 옳다는 것을 적어 둔다.

단계가 명확하고 선형인 계산, 단순 질의응답, 요약, 분류에는 그냥 CoT를 쓴다. 이런 과제는 경로 하나의 정확도가 이미 높아서 다수결로 얻을 여지가 작고, ToT의 평가기가 판단할 중간 상태도 없다시피 하다. 사용자가 응답을 기다리는 대화형 화면이라면 ToT는 애초에 후보가 아니다 — 단계마다 왕복이 쌓이는 구조라 지연을 숨길 방법이 없다.

정확도가 아니라 형식이 문제일 때도 이 기법들은 답이 아니다. 모델이 계산은 맞게 하는데 답을 엉뚱한 모양으로 내놓거나, 있어야 할 항목을 빠뜨리거나, 지시한 어조를 안 지키는 경우다. 여기에 샘플 스무 개를 던지면 스무 개가 똑같이 형식을 어긴다. 이런 자리는 경로를 늘려 고칠 것이 아니라 모델에게 주는 지시 자체를 다시 짜야 하는 자리다. 역할과 제약을 어디에 어떤 순서로 적을지, 매 요청에 바뀌는 부분과 고정된 부분을 어떻게 가를지 — 다음 글에서는 그 골격을 다룬다. 이 글에서 본 캐싱이 왜 접두사의 순서에 달려 있는지도 거기서 이어진다.


읽어주셔서 감사합니다. 😊

LATEST

에이전트·RAG의 최신 글

에이전트·RAG2026.08.23

에이전트는 어디서 어긋나기 시작하는가

에이전트가 이상한 답을 낼 때 증상은 마지막에 보이지만 어긋난 자리는 그보다 앞입니다. 한 걸음이 무너지는 여섯 자리를 나누고, 증상에서 원인을 거슬러 찾는 방법과 자리별 처방을 정리합니다.

11 MIN
에이전트·RAG2026.08.23

에이전트가 무엇을 했는지 나중에 알 수 있게 만들기

에이전트는 같은 입력에도 다른 경로로 갑니다. 그래서 로그 몇 줄로는 왜 그렇게 됐는지 복원이 안 됩니다. 트레이스를 어떻게 나누고 구간마다 무엇을 붙이며 어떤 지표를 볼지 정리합니다.

11 MIN
에이전트·RAG2026.08.23

에이전트 비용은 걸음 수보다 빨리 늘어난다

걸음이 두 배면 비용은 두 배가 아니라 서너 배입니다. 왜 그렇게 되는지, 어디서 새는지, 상한을 몇 겹으로 어떻게 거는지와 실제로 효과가 큰 순서대로의 대응을 정리합니다.

13 MIN