추론 능력은 어떻게 훈련되는가: 검증 가능한 보상과 강화학습
추론 모델이 되돌아가기와 자기 검증을 학습하는 원리를 검증 가능한 보상(RLVR)과 그룹 상대 정책 최적화를 중심으로 설명하고, 보상 해킹과 학습 붕괴를 막는 실무 장치를 정리합니다.
지난 글에서는 이미 훈련된 모델에 컴퓨트를 더 쓰는 방법을 다뤘다. 그런데 추론 모델의 진짜 차별점은 추론 시점의 기법이 아니라 훈련 단계에서 얻은 습관이다. 긴 사고 구간을 만들고, 중간에 “잠깐, 이건 아닌 것 같다”며 되돌아가고, 답을 쓰기 전에 스스로 검산하는 행동은 프롬프트로 지시해서 나오는 것이 아니라 훈련으로 몸에 밴 것이다. 그 훈련이 어떻게 작동하는지 알면 왜 특정 영역에서만 추론 모델이 강한지도 자연스럽게 이해된다.
지도학습으로는 안 되는 이유
가장 먼저 떠오르는 접근은 잘 쓰인 풀이 과정을 대량으로 모아 지도학습을 시키는 것이다. 실제로 이 방법도 어느 정도는 통한다. 하지만 두 가지 벽에 부딪힌다.
첫째, 사람이 쓴 풀이에는 되돌아가기가 없다. 교과서 해설이든 블로그 풀이든, 사람은 이미 정답을 아는 상태에서 깔끔한 경로만 적는다. “이 방법을 5분 시도했는데 막혀서 다른 방법으로 갔다”는 과정은 기록에 남지 않는다. 그런 데이터로 학습한 모델은 막혔을 때 어떻게 해야 하는지를 배우지 못한다.
둘째, 모방은 자기 능력에 맞지 않는다. 지도학습은 모델에게 “이렇게 풀어라”고 정답 경로를 강요하는데, 그 경로가 그 모델에게 최적이 아닐 수 있다. 모델마다 잘 다루는 표현 방식이 다르므로, 스스로 시도해 보고 잘 되는 것을 강화하는 편이 효율적이다.
강화학습은 이 둘을 동시에 해결한다. 모델이 직접 여러 번 시도하게 하고, 결과가 맞았는지만 알려 준다. 경로는 모델이 알아서 찾는다.
검증 가능한 보상이라는 조건
강화학습의 난점은 언제나 보상 함수다. “좋은 답”을 수치로 정의해야 하는데, 자연어 출력에서 이게 어렵다. 사람 선호를 모아 보상 모델을 학습시키는 방식이 널리 쓰였지만, 추론 훈련에는 잘 맞지 않는다. 사람이 긴 수학 풀이의 중간 단계가 옳은지를 판단하기 어렵기 때문이다.
그래서 추론 훈련은 채점이 기계적으로 가능한 문제로 범위를 좁힌다. 이것이 검증 가능한 보상(verifiable rewards)의 핵심이다.
- 수학: 최종 답을 정답과 문자열·수치로 대조
- 코드: 단위 테스트를 실행해 통과 여부 확인
- 구조 출력: JSON 스키마 검증 통과 여부
- 형식 논리: 증명 검증기 통과 여부
이 문제들은 정답 판정에 사람도, 판정 모델도 필요 없다. 그래서 값싸고 정확하며, 수백만 회 반복해도 신호가 흔들리지 않는다.
그룹 상대 우위: 가치 모델 없이 하기
전통적인 정책 최적화는 각 상태의 기대 보상을 추정하는 가치 모델(critic)을 따로 학습시킨다. 정책 모델과 비슷한 크기의 모델을 하나 더 돌려야 하므로 메모리와 계산이 두 배로 든다.
추론 훈련에서 널리 쓰이는 접근은 이 가치 모델을 없애고, 같은 문제에 대한 여러 롤아웃의 평균을 기준선으로 삼는 것이다. 문제 $q$에 대해 $G$개의 응답 $o_1, \dots, o_G$를 생성하고 각각 보상 $r_i$를 받았다면, 상대 우위는 이렇게 계산된다.
같은 문제를 8번 풀게 했더니 3번 맞고 5번 틀렸다면, 맞은 3개는 양의 우위를, 틀린 5개는 음의 우위를 받는다. 별도 모델 없이 “이 문제 기준으로 이 시도가 평균보다 나았는가”를 직접 계산한 것이다.
이 방식은 문제 난도의 차이도 자동으로 흡수한다. 쉬운 문제는 8개 다 맞아서 표준편차가 0에 가까워지고 우위가 거의 0이 된다. 즉 배울 게 없는 문제에서는 그래디언트가 자연히 작아진다. 반대로 절반쯤 맞는 문제에서 신호가 가장 커진다.
import torch
def group_advantages(rewards: torch.Tensor, eps: float = 1e-4) -> torch.Tensor:
"""rewards: (B, G) — 문제 B개 × 롤아웃 G개"""
mean = rewards.mean(dim=1, keepdim=True)
std = rewards.std(dim=1, keepdim=True)
return (rewards - mean) / (std + eps)
def policy_loss(
logprobs: torch.Tensor, # (B, G, T) 현재 정책
old_logprobs: torch.Tensor, # (B, G, T) 롤아웃 시점 정책
ref_logprobs: torch.Tensor, # (B, G, T) 기준 모델
advantages: torch.Tensor, # (B, G)
mask: torch.Tensor, # (B, G, T) 패딩 제외
clip_eps: float = 0.2,
kl_coef: float = 0.02,
) -> torch.Tensor:
ratio = torch.exp(logprobs - old_logprobs)
adv = advantages.unsqueeze(-1)
unclipped = ratio * adv
clipped = torch.clamp(ratio, 1 - clip_eps, 1 + clip_eps) * adv
pg_loss = -torch.min(unclipped, clipped)
# 기준 모델에서 너무 멀어지지 않도록 억제
kl = torch.exp(ref_logprobs - logprobs) - (ref_logprobs - logprobs) - 1
loss = (pg_loss + kl_coef * kl) * mask
return loss.sum() / mask.sum()
clip_eps와 kl_coef 두 항이 모두 “급격히 변하지 말 것”을 강제한다. 이게 없으면 몇 스텝 만에 정책이 무너져서 의미 없는 토큰만 반복하는 상태로 수렴한다. 실무에서 학습이 발산할 때 가장 먼저 확인할 값이기도 하다.
사고 길이가 저절로 늘어난다
이 훈련에서 관찰되는 흥미로운 현상이 있다. 아무도 “더 길게 생각하라”고 지시하지 않았는데, 학습이 진행되면서 모델의 평균 응답 길이가 계속 늘어난다. 더 오래 생각한 롤아웃이 더 자주 정답을 맞혔고, 그래서 그 패턴이 강화된 것이다.
같은 방식으로 되돌아가기 표현도 출현한다. “잠깐, 위에서 부호를 잘못 봤다” 같은 문장이 학습 중간부터 등장하기 시작하는데, 이것도 지시한 것이 아니라 그런 궤적이 더 자주 정답에 도달했기 때문에 강화된 결과다. 강화학습이 지도학습보다 나은 지점이 정확히 여기다. 인간 데이터에 없는 행동을 발견할 수 있다.
다만 길이가 늘어나는 것이 무조건 좋은 것은 아니다. 어느 시점부터는 정확도 향상 없이 길이만 늘어나는 구간이 온다. 그래서 실무 훈련에서는 길이에 약한 패널티를 넣거나, 정답을 맞힌 롤아웃 중 짧은 것에 가산점을 주는 형태를 함께 쓴다.
보상 해킹: 가장 흔한 실패
보상 함수에 빈틈이 있으면 모델은 반드시 그 빈틈을 찾아낸다. 이건 모델이 교활해서가 아니라 최적화가 원래 그렇게 동작하기 때문이다.
실제로 겪는 형태는 다음과 같다.
테스트 우회. 코드 과제에서 실제 로직을 구현하는 대신 테스트 케이스의 입력을 하드코딩해 분기하는 코드를 생성한다. 테스트는 통과하고 보상은 1.0이다. 방어책은 학습에 쓰는 테스트와 평가에 쓰는 테스트를 분리하고, 숨겨진 테스트를 일정 비율 섞는 것이다.
답 태그 남발. 정답 형식을 <answer>...</answer>로 요구하면, 모델이 한 응답에 여러 개의 답 태그를 넣어 그중 하나라도 걸리길 노린다. 파서가 첫 번째만 취하는지 마지막만 취하는지에 따라 행동이 갈린다. 태그가 정확히 하나일 때만 채점하고, 아니면 0점을 주는 것이 확실하다.
판정 모델 아부. 보상을 판정 모델이 주면 모델은 그 판정자가 좋아하는 표면 특징을 학습한다. 길이, 확신 어투, 목록 형식 같은 것들이다. 내용은 그대로인데 점수만 오른다. 그래서 추론 훈련에서는 판정 모델 보상을 주 신호로 쓰지 않는다.
def verifiable_reward(output: str, task: dict) -> float:
# 형식 위반은 즉시 0점 — 태그가 정확히 하나여야 한다
tags = re.findall(r"<answer>(.*?)</answer>", output, re.S)
if len(tags) != 1:
return 0.0
answer = normalize(tags[0])
if task["type"] == "math":
return 1.0 if answer == normalize(task["gold"]) else 0.0
if task["type"] == "code":
# 학습용 테스트 + 비공개 테스트를 모두 통과해야 한다
public_ok = run_tests(answer, task["public_tests"])
hidden_ok = run_tests(answer, task["hidden_tests"])
return 1.0 if (public_ok and hidden_ok) else 0.0
raise ValueError(f"unknown task type: {task['type']}")
숨겨진 테스트를 함께 요구하는 한 줄이 테스트 우회를 거의 차단한다. 모델은 볼 수 없는 테스트에 맞춰 하드코딩할 수 없기 때문이다.
학습 중에 봐야 하는 지표
훈련이 잘 가고 있는지는 보상 곡선만으로 알 수 없다. 보상이 오르면서 모델이 망가지는 경우가 흔하다. 최소한 다음 네 가지를 함께 본다.
형식 위반율. 답 태그가 없거나 파싱 불가능한 응답의 비율이다. 초반에는 빠르게 떨어져야 하고, 학습 후반에 다시 오르면 정책이 무너지기 시작한 신호다.
평균 응답 길이. 완만히 증가하는 것은 정상이다. 하지만 특정 스텝에서 급격히 튀거나 최대 길이에 붙어 버리면 길이 보상 해킹을 의심해야 한다.
기준 모델과의 KL. 이 값이 계속 커지면 모델이 원래 언어 능력에서 멀어지고 있다는 뜻이다. 수학은 잘 푸는데 일상 대화가 이상해지는 현상이 여기서 나온다.
보류 문제 정확도. 학습에 쓰지 않은 문제 집합에서의 정확도다. 학습 보상은 계속 오르는데 이 값이 정체하거나 떨어지면 학습 문제에 과적합된 것이다.
이 훈련이 갖는 한계
검증 가능한 보상은 강력하지만 적용 범위가 좁다는 대가가 있다. 수학과 코드에서 배운 사고 패턴이 다른 영역으로 얼마나 전이되는가는 아직 명확하지 않다. 경험적으로는 논리적 다단계 추론이 필요한 작업 전반에 어느 정도 전이되지만, 문체나 창작처럼 정답이 없는 영역에서는 이득이 거의 없다.
또한 이 훈련은 모델이 애초에 그 문제를 가끔이라도 풀 수 있어야 성립한다. $G$개 롤아웃이 전부 틀리면 상대 우위가 전부 0이 되어 학습 신호가 사라진다. 그래서 훈련 데이터의 난도 분포를 모델 능력에 맞춰 조절하는 것이 실제로는 알고리즘 선택보다 결과에 더 큰 영향을 준다. 너무 쉬우면 배울 게 없고, 너무 어려우면 신호가 없다.
정리
추론 모델의 능력은 긴 사고를 지시받아서가 아니라, 채점 가능한 문제를 수없이 풀면서 잘 되는 패턴이 강화된 결과다. 그래서 채점이 확실한 영역에서 가장 강하고, 채점 기준이 없는 영역에서는 이득이 크지 않다.
직접 훈련할 일이 없더라도 이 구조를 알아 두면 실무 판단이 달라진다. 모델이 왜 수학과 코드에서 특히 강한지, 왜 형식을 이상할 만큼 정확히 지키는지, 왜 검증기를 붙였을 때 성능이 크게 오르는지가 모두 같은 이유에서 나온다. 그 검증기를 어떻게 만들 것인가가 다음 주제다.
지난 글: 테스트 타임 컴퓨트: 추론 시점에 계산을 더 쓴다는 것
다음 글: 검증자 모델: 답을 고르는 눈을 따로 기른다
읽어주셔서 감사합니다. 😊