한 번의 스냅샷, 수천 번의 롤아웃: 코딩 에이전트를 위한 실용적인 RL 설정
요약
코딩 에이전트의 RL(강화학습) 훈련 시 발생하는 환경 재구축 병목 현상을 분석하고, 이를 해결하기 위한 실용적인 롤아웃 하네스 설정 방법을 제시합니다. 컨테이너 기반 방식의 한계를 지적하며 효율적인 상태 복제 전략의 중요성을 강조합니다.
핵심 포인트
- RL 훈련의 주요 병목은 GPU 연산이 아닌 환경 재구축 과정임
- 코딩 에이전트의 초기 상태(s0)는 복잡한 저장소와 의존성을 포함함
- 그룹 기반 방식(GRPO 등)에서는 수만 번의 환경 복제가 필요함
- 컨테이너 방식은 격리는 좋으나 콜드 스타트 비용이 매우 높음
한 번의 스냅샷, 수천 번의 롤아웃: 코딩 에이전트를 위한 실용적인 RL 설정
RL 루프에서 병목 현상이 발생하는 지점은 GPU가 아닙니다. 환경을 재구축하는 과정이 병목입니다. 실제 수치를 바탕으로 해결책을 제시합니다.
RL 실행을 시작한 지 6시간이 지났습니다. GPU는 예열되었고, 보상 곡선(reward curve)은 서서히 상승하고 있으며, 정책(policy)은 학습 중입니다. 좋습니다.
이제 그 6시간 동안 실제로 무엇을 얻었는지 살펴보십시오. 그 시간의 상당 부분 동안, 여러분의 값비싼 가속기(accelerators)들은 옵티마이저(optimizer)를 기다리는 것도, 모델을 기다리는 것도 아닌, 환경이 부팅되기를 기다리며 유휴 상태로 머물러 있었습니다. 저장소를 클론하고, 의존성(dependencies)을 설치하고, 데이터셋을 복구합니다. 그러고 나서야 마침내 정책이 첫 번째 행동을 취할 수 있습니다.
이것은 아무도 RL 논문에 쓰지 않는 부분입니다. 보상 함수(reward function)에는 섹션이 할당됩니다. KL 페널티(KL penalty)에도 섹션이 할당됩니다. 하지만 모든 롤아웃(rollout)마다 환경을 복제하느라 실제 벽시계 시간(wall-clock time)을 잡아먹은 요소는, 마치 단순한 배관 작업(plumbing)처럼 느껴진다는 이유로 아무런 비중을 차지하지 못합니다.
이것은 단순한 배관 작업이 아닙니다. 에이전트 RL(agent RL)에서는 병목 현상입니다. 그리고 이것은 아직 거의 아무도 사용하지 않는 명확한 해결책이 있는 병목 현상입니다.
저는 지난 며칠 동안 실제 인프라에서 그 해결책을 테스트했습니다. 이 글은 제가 발견한 것들, 즉 직접 실행해 볼 수 있는 작동 가능한 롤아웃 하네스(rollout harness)라는 원시 도구(primitive), 실제 타이밍, 그리고 한계점을 알 수 있도록 도움이 되지 않는 부분들에 대해 다룹니다.
롤아웃은 연산의 탈을 쓴 상태 복제 문제이다
RL 단계를 골격만 남기고 분해해 보겠습니다:
s0 -> 정책(policy)이 행동을 샘플링함 -> 환경(env) 전이 -> ... -> 종료(terminal) -> 보상(reward)
수학 문제의 경우 s0는 문자열입니다. 코딩 에이전트의 경우 s0는 하나의 세계입니다. 즉, 이 커밋의 저장소, 이 Python 버전, 설치된 패키지들, 디스크의 데이터셋, 설정 스크립트로 인해 절반쯤 구축된 상태 등이 포함됩니다. 그 세계를 재구성하는 것은 여러분이 실행하는 모든 롤아웃에 지불해야 하는 입장료와 같습니다.
그리고 GRPO, GSPO 또는 모든 그룹 기반 방식(group-based method)에서는 스텝당 하나의 롤아웃(rollout)을 실행하지 않습니다. $G$개의 롤아웃, 즉 8개, 16개, 64개의 완료본(completions)을 실행하며, 각 완료본은 자신만의 깨끗한 s0 복사본을 필요로 합니다. 이를 수천 개의 스텝에 곱해 보면, 단 한 번의 실행 과정 동안 환경이 수만 번 재구축됩니다.
오늘날 팀들이 이를 처리하는 세 가지 방법이 있으며, 이 세 가지 모두 동일한 근본적인 문제를 해결하기 위한 임시방편(workarounds)입니다.
롤아웃당 컨테이너(Containers per rollout). 새로운 컨테이너를 띄우고, 이미지를 가져오고, 설치하고, 실행한 뒤, 해제합니다. 깨끗한 격리(isolation)를 제공하지만, 매번 롤아웃을 할 때마다 콜드 스타트(cold-start) 및 설정 비용을 지불해야 합니다. 정책(policy)이 움직이기 전, 매번 수십 초가 소요됩니다.
웜 풀(Warm pools). 미리 구축된 컨테이너 군단을 준비해 둡니다. 더 빠르지만, 이제 여러분은 풀 드리프트(pool drift), 축출 로직(eviction logic), 상태 확인(health checks), 그리고 소규모 분산 시스템의 운영 영역(ops surface)을 직접 관리해야 합니다. 지연 시간(latency) 문제를 인프라 문제로 맞바꾼 셈입니다.
단일 VM, 순차적 롤아웃(One VM, sequential rollouts). 저렴하고 간단하지만, 세 번째 롤아웃이 네 번째 롤아웃에 필요한 파일 시스템을 오염시켰다는 사실을 깨닫게 되는 순간, 가장 병렬화하고 싶었던 단 한 가지 요소를 직렬화(serialized)해 버렸음을 알게 됩니다.
이 모든 방법은 단 하나의 사실을 피해 가고 있습니다. 환경 상태를 복제하는 것은 비용이 많이 들기 때문에, 우리는 계속해서 그 비용을 지불하거나 그 비용을 피하기 위한 가설 구조(scaffolding)를 구축하고 있다는 점입니다. 만약 상태 복제가 아주 저렴하다면 어떨까요?
프리미티브(The primitive): 한 번의 스냅샷, N번의 복구
여기 해결책이 있습니다. 모든 롤아웃마다 s0를 재구축하는 대신, 한 번 구축하여 동결(freeze)한 뒤 동일한 복사본들을 나누어 주는 것입니다.
그 기반(substrate)은 마이크로VM(microVM) 샌드박스입니다. 이는 1초도 채 걸리지 않아 부팅되며, 전체 상태를 스냅샷(snapshot)한 뒤 그 스냅샷을 새롭고 독립적인 VM들로 복구(restore)할 수 있습니다. 저는 이를 Tensorlake 샌드박스에서 테스트했으며(아래 코드가 사용하는 방식입니다), 중요한 것은 이 패턴 자체입니다. 설정은 평범합니다:
from tensorlake.sandbox import Sandbox, CheckpointType
# 세상을 단 한 번 구축합니다.
...
그 snap이 바로 s0입니다. 이를 복구하면 롤아웃을 수행할 수 있는, 바이트 단위로 동일한(byte-identical) 새로운 세상이 주어집니다:
fork = Sandbox.create(snapshot_id=snap.snapshot_id) # s0의 깨끗한 복사본
더 나아가기 전에, 당연히 제기될 수 있는, 그리고 반드시 물어야 할 올바른 의문이 있습니다.
"왜 그냥 Docker 이미지를 만들지 않나요?"
이미지 (image)와 스냅샷 (snapshot)은 서로 다른 종류의 것이며, 그 차이점이 바로 이 방식이 작동하게 만드는 핵심이기 때문입니다.
이미지는 환경을 구축하기 위한 레시피입니다. 저장소 (repo), 언어, 의존성 (dependencies) 등을 포함하죠. 배포에는 매우 훌륭합니다. 하지만 빌드 타임 (build time)에 동결됩니다. 컨테이너가 시작된 이후에 발생한 일들에 대해서는 전혀 알지 못합니다. 즉, 설정 스크립트가 생성한 파일, 다운로드한 데이터셋, 마지막 실행이 기록한 체크포인트 (checkpoint), 이전 단계에서 변경한 설정 (config) 등을 알 수 없습니다.
반면 스냅샷은 런타임 (runtime)에 찍힙니다. 이미지 구조상으로는 불가능한 모든 것을 포함하여, 실제로 진행 중인 상태 그대로의 세상을 포착합니다.
저는 이를 직접 테스트했습니다. 실제 세션과 동일한 방식으로 샌드박스 (sandbox)를 구축했습니다: 의존성을 설치하고, 파일을 다운로드하고, 코드를 실행하여 embeddings.npy를 생성하고, 체크포인트 디렉토리를 작성하고, 설정을 변경했습니다. 그런 다음 이를 스냅샷으로 찍고 새로운 샌드박스로 복구했습니다. 살아남은 것들은 다음과 같습니다:
deps: requests + numpy 임포트 가능
download: downloaded_readme.md 존재함
generated: embeddings.npy (1000, 128)
...
Dockerfile은 첫 번째 줄을 제공합니다. 나머지 네 줄, 즉 s0의 핵심인 런타임 상태 (runtime state)는 스냅샷이 그것들을 포착했기 때문에 존재할 수 있는 것입니다. 이를 한 문장으로 요약하면 다음과 같습니다: 이미지는 환경을 배포하고, 스냅샷은 런타임 상태를 포착합니다.
실제로 실행 가능한 롤아웃 하네스 (rollout harness)
구체적인 예를 들어보겠습니다. 연습용 작업 (toy task)은 다음과 같습니다: 에이전트가 버그가 있는 Python 모듈을 수정하여 숨겨진 pytest 테스트 세트를 통과시켜야 합니다. 이 작업은 글에 담기에 충분히 작으면서도, 실제로 학습시키는 모든 작업과 구조적으로 동일합니다. 보상 (reward)은 통과한 테스트의 비율입니다. 이는 깔끔하고, 학습하기에 충분히 밀도가 높으며(dense), 편법을 쓰기 불가능합니다.
시작 상태 s0는 버그가 있는 모듈과 설치된 환경이 결합된 상태이며, 한 번 스냅샷(snapshot)을 찍어둡니다 (위의 코드 참고). 이제 롤아웃 (rollout) 단계를 진행합니다. G개의 정책 (policy) 샘플 각각에 대해, s0를 복구하고, 후보 (candidate)를 적용한 뒤, 점수를 매깁니다.
import concurrent.futures
from tensorlake.sandbox import Sandbox
...
실제 루프 (loop)에서는 candidates가 사용자의 정책 (policy)으로부터 나옵니다. 여기서는 전체 과정이 결정론적 (deterministic)이며 모델이나 API 키 없이도 재현할 수 있도록, 다양한 품질을 가진 8개의 후보 패치 (candidate patches)를 직접 스크립트로 작성했습니다. 핵심은 정책 (policy)이 아니라, 그 밑단에서 샌드박스 (sandbox)가 무엇을 수행하는가입니다.
제 실행 결과의 실제 출력은 다음과 같습니다:
rollout[0] reward=0.00 (3 failed)
rollout[1] reward=0.33 (2 failed, 1 passed)
rollout[2] reward=0.33 (2 failed, 1 passed)
...
이 보상 (reward) 벡터가 게임의 전부입니다. 여기서 반드시 내재화해야 할 점이 있습니다. 하나의 동일한 시작 상태로부터 G개의 독립적인 롤아웃 (rollouts)을 얻는 것이 가장 비용이 많이 드는 부분이며, 이는 모든 방법론에서 동일합니다. 그 이후에 보상 (rewards)을 가지고 무엇을 하느냐에 따라 기술적 접근 방식이 갈립니다. 거부 샘플링 미세 조정 (Rejection-sampling fine-tuning)은 가장 좋은 완성본을 유지하고 그것으로 학습합니다. 반면 GRPO와 GSPO는 그룹 전체를 사용하며, 각 롤아웃 (rollout)의 어드밴티지 (advantage)를 '해당 롤아웃의 보상에서 그룹 평균을 뺀 값'으로 계산하여, 정책 (policy)이 평균 이상의 결과물 쪽으로 나아가도록 유도합니다. 밑단에 깔린 값비싼 기본 연산 (primitive)은 동일합니다. 스냅샷 (snapshot)은 바로 그 기본 연산을 저렴하게 만들어주는 핵심입니다.
경제성, 이것이 실제 논거입니다
이제 중요한 부분입니다. 동일한 작업량에 대해 두 가지 접근 방식의 가격을 책정해 봅시다. 공정하게 말하자면, 두 방식 모두 롤아웃 (rollout)당 새로운 샌드박스 (sandbox)를 실행하므로 동일한 비용을 지불합니다. 유일한 차이점은 빌드 (build) 방식입니다.
제 실행 결과에서 얻은 두 가지 수치입니다. 환경을 구축하는 것 (numpy, pandas, requests, pytest 설치 및 데이터셋 생성)에는 7.2초가 걸렸습니다. 스냅샷 (snapshot)으로부터 샌드박스 (sandbox)를 복구하는 데는 약 2초가 걸렸습니다 (성능이 좋은 인프라에서는 1초 미만이 소요되지만, 프리 티어 (free-tier)의 경합으로 인해 제 경우에는 더 오래 걸렸습니다). 이제 전체 실행 비용을 계산해 봅시다. 스텝당 G = 8 롤아웃 (rollouts), 총 1000 스텝, 즉 8,000 롤아웃 (rollouts) 기준입니다:
롤아웃당: 빌드(build) 7.2s + 복구(restore) ~2s
G = 8, 1000 스텝 -> 8,000 롤아웃 (rollouts)
단순 루프(naive loop): 8,000 x (7.2s 빌드 + 2s 복구) ~= 20시간
...
약 4시간에 달하는 샌드박스(sandbox) 준비 시간은 실제로 존재하며, 스냅샷(snapshot) 루프를 사용하더라도 이 비용은 지불해야 합니다. 단순 루프(naive loop) 역시 마찬가지입니다. 이 부분은 서로 상쇄됩니다. 스냅샷이 제거하는 것은 8,000번 동안 동일한 환경을 재빌드(rebuilding)하는 데 소비되는 16시간입니다. 복구(restore) 비용을 무료로 만드는 것이 아니라, 중복되는 빌드(build)를 제거하는 것입니다.
심지어 이는 단 7초짜리 사소한 환경을 기준으로 한 결과입니다. 실제 코딩 에이전트(coding-agent) 설정(무거운 requirements.txt, 모델 다운로드, 데이터셋 등)으로 교체하면, 복구(restore) 시간은 일정하게 유지되는 반면 빌드(build) 비용은 초 단위에서 분 단위로 급증합니다.
에이전트 평가 (Agent evaluation). 동일한 기본 요소(primitive)를 사용하지만 목표가 다릅니다. 훈련(training) 대신 점수를 매기는 것입니다. 벤치마크의 시작 상태를 스냅샷(snapshot)하고, 이를 스위트(suite) 내의 모든 작업에 대해 포크(fork)한 뒤, 각각의 격리된 복사본에서 에이전트를 실행하여 통과/실패 여부를 수집합니다. Tensorlake는 실행 런타임(execution runtime)으로서 Harbor(최종 작업(terminal tasks)을 정의하고 검증하기 위한 프레임워크)에 연결되어, 에이전트의 자기 보고(self-report)를 신뢰하는 대신 실제 파일 시스템 검증을 통해 terminal-bench 스타일의 평가를 위한 샌드박스(sandbox) 군단을 실행합니다.
추론 시 병렬 탐색 (Parallel search at inference). 훈련을 완전히 생략하십시오. Best-of-N 샘플링, 도구 호출(tool calls)에 대한 트리 탐색(tree search), 여러 계획의 투기적 실행(speculative execution) 등 이 모든 것들은 "현재 상태를 포크하고, N개의 브랜치를 탐색한 뒤, 좋은 것을 유지한다"는 원리로 작동합니다. 훈련 시점이 아닌 추론(inference) 시점에 수행될 뿐, 동일한 스냅샷 및 팬아웃(snapshot-and-fan-out) 방식입니다.
관통하는 핵심 원리는 다음과 같습니다: 상태(state)는 병렬 처리를 비싸게 만드는 원인이며, 저렴한 스냅샷은 그 비용을 제거합니다. 이것이 실현되면, 구축하기에 너무 고통스러웠던 수많은 아키텍처들이 합리적인 선택지가 됩니다.
도움이 되지 않는 부분 (솔직한 부분)
좋은 소식만 전하는 결과물은 신뢰하지 않으므로, 한계점(edges)에 대한 지도를 제시하겠습니다.
외부 상태는 스냅샷에 포함되지 않습니다. 샌드박스가 네트워크를 통해 접근하는 모든 것(운영 데이터베이스, 제3자 API, 공유 큐 등)은 VM 외부이며 동결(freeze) 범위 밖에 있습니다. API 호출 중간에 샌드박스를 포크하면, N개의 모든 포크가 독립적으로 해당 호출을 완료하려고 시도할 것입니다. 만약 보상 함수(reward function)가 외부 서비스에 접근한다면, 멱등성(idempotency)과 결정론(determinism)은 더 이상 있으면 좋은 기능이 아니라 필수 사항이 됩니다.
올바른 체크포인트 유형을 선택하세요. 샌드박스(Sandbox)는 파일 시스템 스냅샷(filesystem snapshot)과 메모리 스냅샷(memory snapshot)을 제공합니다. 롤아웃(rollouts)을 위해서는 거의 항상 파일 시스템 스냅샷을 원하게 될 것입니다. 즉, 얼어붙은 프로세스 트리(process tree)가 아니라 s0로부터 깨끗한 디스크 리셋을 원하는 것입니다. 라이브 RAM과 실행 중인 프로세스를 추가로 캡처하는 메모리 스냅샷은 실제 세션을 일시 중지하고 재개하기 위한 용도이며, 비용이 더 많이 듭니다. 제 테스트 결과에 따르면, 메모리 변형 방식은 디스크 이미지 외에 캡처된 RAM이 수백 메가바이트 추가되었습니다. 기본값으로 사용하지 말고 의도적으로 사용하세요. 정말 직관에 반하는 결과 중 하나는, 제 실행 환경에서 더 무거운 메모리 스냅샷이 파일 시스템 스냅샷보다 더 빠르게 복구되었다는 점입니다(약 0.9초 대 1.9초). 이는 페이지 캐시(page cache)가 함께 따뜻하게(warm) 복구되기 때문입니다.
복구당 최소 소요 시간(floor)이 존재합니다. 스냅샷을 복구하는 것은 공짜가 아닙니다. 성능이 좋은 인프라에서는 1초 미만이 걸리지만, 제한된 티어(throttled tier)에서는 몇 초가 걸립니다. 만약 정책 단계(policy step) 자체가 1초 미만이라면, 이 최소 소요 시간은 전체 시간의 상당한 비중을 차지하게 됩니다. 만약 롤아웃이 초 단위로 측정되는 LLM 호출이라면, 이 시간은 노이즈 속으로 사라질 것입니다. 자신이 어떤 체제(regime)에 있는지 파악하세요.
스냅샷 저장 공간은 실질적인 문제입니다. 스냅샷은 아주 작은 차이(diff)를 추적하는 것이 아니라 할당된 디스크 전체를 추적합니다. 수천 개의 스냅샷을 유지한다면 수천 개의 디스크 이미지에 대한 비용을 지불하게 됩니다. 의도적으로 스냅샷을 찍고 정리(prune)하세요.
이 중 어느 것도 이 접근 방식 자체를 무너뜨리지는 않습니다. 다만 이 방식이 어디에 적합한지를 알려줄 뿐입니다: 무거운 환경, 많은 롤아웃, 그리고 중요한 격리(isolation). 이것이 바로 에이전트 RL(agent RL)의 형태와 정확히 일치합니다.
요약 (The takeaway)
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기