← Start here · Module 5 · Project
진짜 ~450M 파라미터 VLA(SmolVLA)가 MuJoCo 시뮬 속 로봇 팔을 CPU만으로 구동합니다.
obs → action → step 지각-행동 루프를 한 스텝씩 눈으로 확인하는 게 이 프로젝트의 전부예요.
robot.zip(파일 4개)을 한 줄씩 뜯어보는 코드 워크스루예요.
VLA가 처음이면 Project: fine-tuning에서 개요를 먼저 훑어도 좋습니다.
SmolVLA가 gym-aloha(MuJoCo)의 양팔 로봇을 구동 → 결과로 rollout.mp4(팔이 움직이는 영상) + 스텝별 로그(지시와 VLA가 낸 action 벡터)를 남깁니다. 루프를 눈에 보이게 만드는 게 목적이에요.
저가형 팔이 말로 준 지시를 따라 움직이는 모습(실제 하드웨어).
범용 정책(generalist policy)이 여러 태스크·물체로 일반화하는 모습.
LLM이 텍스트 → 텍스트라면, Vision-Language-Action 모델은 (이미지 + 자연어 지시) → action이에요:
구조적으로 VLA는 멀티모달 LLM(비전 인코더 + 언어 모델)에서 텍스트 생성 head를 action head로 갈아끼운 것과 비슷해요. SmolVLA는 작은 vision-language backbone에 "action expert"를 붙여 짧은 action 청크를 냅니다. 학습은 텔레오퍼레이션 시연의 모방(imitation) — (카메라, state, 지시, action) 튜플로 이뤄져요. LLM에서 넘어올 때의 결정적 전환은 출력이 언어가 아니라 특정 로봇 몸체를 위한 모터 명령이라는 것 — 그래서 embodiment가 그렇게 중요해집니다.
robot.zip을 풀면 딱 4개예요. 무겁게 느껴져도 실제 프로젝트 코드는 작습니다 — 무거운 건 다운로드되는 모델·시뮬 에셋이지 우리 코드가 아니에요.
| 파일 | 역할 |
|---|---|
check_setup.py | 설치·버전을 검증하고 정확한 obs/action 스펙을 출력. 롤아웃 도중 크래시 대신 여기서 드리프트를 먼저 드러냄. |
run_demo.py | VLA 닫힌 루프 본체. observation 조립 → 예측 → 시뮬 step → 반복 → rollout.mp4. |
requirements.txt | lerobot[smolvla](torch·transformers·정책 코드) + gym-aloha(MuJoCo) + imageio(영상). |
README.md | VLA란 무엇인가 · 스택 선택 이유 · embodiment 한계 · 트러블슈팅 표. |
다섯 저장소를 붙이는 대신 LeRobot 하나가 정책·데이터 규약·체크포인트를 묶어줘요. 모델은 SmolVLA(~450M), 시뮬은 gym-aloha(MuJoCo), 런타임은 PyTorch CPU.
| 레이어 | 선택 | 이유 |
|---|---|---|
| 프레임워크 | LeRobot (Hugging Face) | VLA 정책 + obs/action 규약 + 사전학습 체크포인트를 한 곳에. 저장소 5개를 손으로 붙일 필요 없음. |
| VLA 모델 | SmolVLA (~450M) · lerobot/smolvla_base | 노트북 CPU에서 도는 가장 작은 진짜 VLA. 액션당 ~1–3초. (OpenVLA-7B는 CPU에서 스텝당 수 분) |
| 시뮬레이터 | gym-aloha (MuJoCo) | Apple Silicon CPU 네이티브, GPU 없이 렌더. 양팔 ALOHA 로봇 시뮬. |
| 런타임 | PyTorch · CPU | CUDA 불필요. 이 스택의 MPS(애플 GPU) 지원은 들쭉날쭉 → CPU가 신뢰 기본값. |
from_pretrained 하나로 정책과 그 정책이 기대하는 규약이 함께 딸려옵니다. "glue code를 안 쓰는 게 아니라
이미 붙어서 온다"가 핵심이에요.VLA의 본질을 한 루프로: 카메라 이미지 + 지시 → VLA → action 벡터 → 시뮬이 로봇을 움직임 → 다음 프레임 → 반복. run_demo.py가 정확히 이 루프예요(실질 ~40줄).
🔵 점(dot)이 이 네 노드를 영원히 도는 게 이 프로젝트의 전부예요 —
OBS → VLA → ACT → SIM → OBS. "루프가 엔진"이라는 걸 눈으로 붙잡아 두세요. 아래 5C.5는
이 루프를 실제 함수 이름으로 펼칩니다.
LLM 턴과 비교했을 때, 이 VLA 루프에서 가장 본질적으로 다른 점은?
멀티모달 LLM도 이미지를 받지만, 출력은 여전히 텍스트예요. VLA는 출력이 연속 action 벡터(모터 명령)라서 "몸(embodiment)"이 결정적으로 중요해집니다 — 그리고 다음 관찰이 방금 한 행동의 결과라, 열린 텍스트 생성이 아니라 닫힌 지각-행동 루프가 됩니다.
위 루프를 run_demo.py의 실제 함수 호출 순서로 펼친 지도예요. 앞쪽 4개는 한 번만 하는 셋업,
뒤쪽은 매 스텝 도는 루프 — 초록 점이 그 루프를 계속 돕니다.
LOADC→PRET→PROC→RESET)은 딱 한 번, 루프(BOBS→…→STEP)는 스텝마다예요.
점이 도는 여섯 노드가 곧 5C.4의 OBS→VLA→ACT→SIM을 코드로 확대한 것 — 같은 루프, 다른 배율입니다.
REND는 루프와 별개로 프레임을 쌓다가, 끝나면 SAVE로 영상을 저장해요.
셋업 네 노드(LOADC→PRET→PROC→RESET)의 실제 코드는 이렇게 짧아요. 두 군데를 눈여겨보세요 —
① policy.reset()이 비우는 action-chunk 큐, ② 전·후처리기를 만들 때의 device 오버라이드.
SmolVLAPolicy = load_smolvla_class()
policy = SmolVLAPolicy.from_pretrained(args.checkpoint)
policy.config.device = args.device
policy.to(args.device)
policy.eval()
policy.reset() # clears the internal action-chunk queue
# The pre-processor tokenizes the instruction + normalizes inputs; the
# post-processor un-normalizes the predicted action. These live alongside the
# checkpoint on the hub, so we load them from the same path.
#
# The saved processor config hard-codes device 'cuda' — its DeviceProcessorStep
# asserts CUDA at construction time, which fails on a Mac. Override it to our
# device via the factory's per-step overrides (keyed by the step name).
device_override = {"device_processor": {"device": args.device}}
preprocessor, postprocessor = make_pre_post_processors(
policy.config,
pretrained_path=args.checkpoint,
preprocessor_overrides=device_override,
postprocessor_overrides=device_override,
)
env = gym.make(env_id, obs_type="pixels_agent_pos", render_mode="rgb_array")
obs, _ = env.reset(seed=0)
select_action은 매번 새로 추론하는 게 아니라 큐에서 하나씩 꺼내 주고, 큐가 비면 다시 채웁니다.
policy.reset()은 이 큐를 비워 깨끗한 상태에서 롤아웃을 시작하게 해요 —
이전 에피소드의 잔여 action이 새 롤아웃에 새지 않도록요. device_override는 CUDA 머신에서 저장된 처리기 설정을
Mac의 CPU로 되돌리는 실무 트릭이고요(README 트러블슈팅과 이어져요).
로봇-러닝 생태계는 빨리 변해요(import 경로·env id·키가 드리프트). 그래서 롤아웃 전에 스펙을 출력해 어긋남을 먼저 드러냅니다.
# check_setup.py — 4단계로 "지금 내 환경의 진짜 스펙"을 찍어줌
# 1) torch / lerobot import 되나
# 2) gym-aloha 가 env 를 registry 에 등록했나 → 등록된 id 목록 출력
# 3) env 만들어 스펙 출력: action_space.shape (정책이 내야 할 action dim!)
# 카메라 키, agent_pos dim
# 4) SmolVLA 로드 → 체크포인트가 '기대하는' 스펙 출력:
# cfg.input_features (카메라 키 · state dim)
# cfg.output_features (action dim — 학습 당시 로봇 기준)
진짜 main()의 네 단계는 이렇게 생겼어요 — 각 단계가 print로 자기 결과를 찍어 어디서 어긋났는지 눈에 보이게 합니다.
def main() -> int:
print("\n1. Core imports")
# … import torch, lerobot
print("\n2. Simulator (gym-aloha / MuJoCo)")
import gym_aloha # noqa: F401 (registers the gym_aloha/* envs)
env_ids = [s for s in gym.registry.keys() if "aloha" in s.lower()]
print("\n3. Make an env and show its spec")
env = gym.make(env_id, obs_type="pixels_agent_pos", render_mode="rgb_array")
ok(f"action_space: {env.action_space.shape} (this is the # of action dims the policy must output)")
ok(f"agent_pos dim: {obs['agent_pos'].shape}")
print("\n4. Load the SmolVLA policy and show what IT expects")
policy = SmolVLAPolicy.from_pretrained("lerobot/smolvla_base")
cfg = policy.config
in_feats = getattr(cfg, "input_features", None)
out_feats = getattr(cfg, "output_features", None)
print(f" input_features: {in_feats}")
print(f" output_features: {out_feats}")
왜 중요하냐면 — 4단계가 찍는 체크포인트의 action dim(output_features)과 3단계의
시뮬 action dim(action_space.shape)이 다를 수 있어요. check_setup.py는
바로 이 두 숫자를 나란히 찍고 "다르면 다른 로봇 몸체용 체크포인트"라는 노트까지 출력합니다 — 그 불일치가 곧
embodiment 교훈의 씨앗입니다.
매 스텝, 시뮬이 준 raw 데이터를 체크포인트가 기대하는 모양으로 맞춰요. 핵심: 모양을 하드코딩하지 않고 policy.config.input_features에서 읽어와서 맞춥니다. 아래 다이어그램이 함수 안 흐름의 지도, 그 아래가 실제 코드예요.
def build_observation(policy, obs: dict, instruction: str):
# … read expected keys/shapes from config.input_features; fit the sim to them
import torch
feats = policy.config.input_features # {key: PolicyFeature(type, shape)}
visual_keys = [k for k in feats if k.startswith("observation.images")]
pixels = obs.get("pixels")
frames = list(pixels.values()) if isinstance(pixels, dict) else [pixels]
batch = {}
for i, key in enumerate(visual_keys):
frame = frames[i] if i < len(frames) else frames[-1]
_, h, w = feats[key].shape # (C, H, W)
img = torch.from_numpy(frame).float().permute(2, 0, 1) / 255.0 # (C,H,W), unbatched
img = torch.nn.functional.interpolate(
img.unsqueeze(0), size=(h, w), mode="bilinear", align_corners=False
).squeeze(0)
batch[key] = img
if "observation.state" in feats:
want = feats["observation.state"].shape[0]
s = np.asarray(obs["agent_pos"], dtype=np.float32)
s = np.pad(s, (0, want - len(s)))[:want] if len(s) < want else s[:want]
batch["observation.state"] = torch.from_numpy(s) # (D,), unbatched
batch["task"] = instruction # the language half of "vision-language-action"
return batch
모양(HxW·state 길이)을 코드에 하드코딩하지 않고 config.input_features에서 읽는 이유는?
체크포인트는 자기가 학습된 카메라 키·state dim·action dim을 config에 실어 옵니다. 거기서 읽어
맞추면, 모델이나 라이브러리 버전이 바뀌어도 스펙의 단일 출처(single source of truth)를 따라가
깨지지 않아요 — 드리프트에 강한 설계입니다.
조립한 raw obs를 전처리기(토크나이즈+정규화+배치+device)에 통과 → select_action → 후처리기(역정규화) → 시뮬 step. 이게 매 틱 반복돼요 — 초록 점이 도는 사이클이 곧 아래 코드의 한 바퀴입니다.
for t in range(args.steps):
raw = build_observation(policy, obs, instruction)
batch = preprocessor(raw) # tokenize + normalize + batch + device
with torch.inference_mode():
action = policy.select_action(batch) # (1, action_dim), normalized
action = postprocessor(action) # un-normalize, back to cpu
action = np.asarray(action.squeeze(0).detach().cpu(), dtype=np.float32)
# Embodiment adaptation: the base checkpoint outputs actions for a
# different robot than this sim, so the dims won't match. We force-fit
# (truncate / zero-pad) and clip to the sim's range so the LOOP runs. …
if action.shape[0] != env_dim and not warned:
print(…) # one-time mismatch warning (see 5C.9)
warned = True
if action.shape[0] > env_dim:
action = action[:env_dim]
elif action.shape[0] < env_dim:
action = np.pad(action, (0, env_dim - action.shape[0]))
action = np.clip(action, env.action_space.low, env.action_space.high).astype(np.float32)
obs, reward, terminated, truncated, _ = env.step(action)
frames.append(env.render())
make_pre_post_processors).
지시문을 토큰화하는 게 여기라, 이 단계를 빼먹으면 select_action이
KeyError: observation.language.tokens로 터집니다.
preprocessor(raw)가 하는 일이 아닌 것은?
전처리기는 입력을 모델이 먹을 수 있게 다듬는 단계예요(토크나이즈·정규화·배치·device). 팔을 움직이는 건
env.step(action) — 후처리기로 역정규화된 action을 시뮬에 넣을 때죠. 전처리 → 예측 → 후처리 → step
순서가 이 루프의 뼈대입니다.
전·후처리기(make_pre_post_processors)를 "있으면 편한 선택적 glue"라고 봐도 될까요?
전처리기는 이 체크포인트가 학습 때 쓴 그대로의 토큰화기·정규화 통계를 담고 있어요. 그래서 지시문이 여기서
observation.language.tokens로 바뀌고, 입력이 학습 분포로 정규화됩니다. 이걸 건너뛰면 모델이 기대하는 키가 없어
KeyError: observation.language.tokens로 죽고, 설령 통과해도 정규화가 어긋나 값이 무의미해져요 —
그래서 체크포인트와 한 몸이지 곁다리 glue가 아닙니다.
루프는 끝까지 돌지만 동작은 의미가 없어요. base 체크포인트는 SO-101(단일 저가 팔)용인데 시뮬은 양팔 ALOHA라, 카메라·state·action의 모양과 의미가 다릅니다. 그 간극이 바로 embodiment 수업이에요.
이 force-fit이 실제로 뭘 하는지 눈으로 보면 이래요 — 모델이 낸 벡터를 시뮬이 원하는 슬롯 수에 억지로 맞춥니다:
위 숫자(모델 6 · 시뮬 14)는 대략이에요 — SO-101 단일 팔(≈6) 정책을 양팔 ALOHA(14) 시뮬에 넣으면 모자란 슬롯을
zero-pad합니다(반대로 모델이 더 크면 truncate). 정확한 값은 각자
check_setup.py가 찍어주는 output_features ↔ action_space를 보세요;
라이브러리·체크포인트 버전마다 드리프트할 수 있어요. 핵심은 숫자가 아니라 억지로 맞춘다는 사실입니다.
run_demo.py는 이걸 알고 일부러 처리해요: action dim이 안 맞으면 한 번 경고를 찍고
잘라내기/제로패딩 + 시뮬 범위로 clip해서 루프가 끝까지 돌게 합니다. 메커니즘은 정확히 보여주되,
정책이 이 몸에 안 맞으니 동작은 무의미 — 이 정직함이 포인트입니다.
이 데모의 팔 동작이 어설픈 근본 이유는?
모델 크기나 CPU 속도의 문제가 아니에요. action head는 특정 로봇 몸체에 맞춰 학습됩니다. 학습 몸체(SO-101)와 실행 몸체(ALOHA)가 다르면 force-fit으로 루프는 돌아도 모션은 의미가 없어요. 해결책은 몸이 맞는 정책(매칭 체크포인트 또는 fine-tune)입니다.
데모를 돌렸더니 루프가 끝까지 잘 돌았어요. 그럼 이 정책이 이 로봇에 맞는다고 봐도 될까요?
"루프가 돈다 ≠ 정책이 이 몸에 맞는다." force-fit(truncate/zero-pad + clip)은 dim 불일치를 숨기고
루프만 굴러가게 만들 뿐이에요. 크래시가 없다고 embodiment가 맞는 게 아니라, 오히려 force-fit이 불일치를 눈에 안 띄게
덮어버린 거죠. 맞는지 확인하려면 check_setup.py의 두 dim이 실제로 같은지, 그리고 태스크가 완수되는지를 봐야 합니다.
base 체크포인트로 "루프를 구경"하는 게 이 폴더의 목적(=옵션 0)이에요. 여기서 제대로 동작까지 가고 싶다면, 자연스러운 다음 걸음들이 있습니다 — 대부분 5B fine-tuning과 곧장 이어져요.
teleoperate → record → train → deploy 전 과정을 안내합니다(시뮬의 embodiment 간극이 사라지는 지점이에요).ACT / Diffusion Policy / SmolVLA를 바꿔 끼워
"언어 없는" 행동복제(behavior cloning)와 언어 조건 VLA의 차이를 대조해 보세요. 단, 신뢰성 있게 구할 수 있는 sim
체크포인트는 종종 언어가 없는 ACT/Diffusion이라, "language" 부분은 fine-tune으로 채워야 할 수 있어요.Python 3.10 권장, CPU면 충분. 순서: 설치 → check_setup.py(스펙 확인) → run_demo.py(루프).
# 스타터: robot.zip (README + requirements + check_setup.py + run_demo.py)
# https://ksetp.netlify.app/assets/special-topics/robot.zip
unzip robot.zip && cd robot
python3.10 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt # lerobot[smolvla] · gym-aloha · imageio[ffmpeg]
python check_setup.py # 먼저! 스펙 출력 (첫 실행 ~1.8GB 다운로드)
python run_demo.py # transfer-cube, 200 steps → rollout.mp4
python run_demo.py --task insertion --steps 300
python run_demo.py --instruction "pick up the cube and pass it to the other hand"
--steps를 줄이세요. 라이브 창은 macOS에서 까다로워, 프레임을 rollout.mp4로 렌더합니다.
config.input_features에서 자동으로 읽어 맞춥니다 —
"루프는 오래 가는 아이디어, 주변 API 이름은 아니다"가 이 프로젝트의 태도예요.
VLA = 멀티모달 LLM + action head를 닫힌 지각-행동 루프로 돌리는 것. 이 루프가 durable하고, 라이브러리 이름·키는 흐릅니다. 그리고 몸(embodiment)이 안 맞으면 정책은 안 통해요.
세 모듈을 관통하는 공통 패턴을 가장 잘 요약한 것은?
5A는 모델을 그대로 두고 루프(제어 구조)로 도구를 부르고, 5B는 얼어붙은 embedder 위 작은 head만 학습하고, 5C는 멀티모달 backbone에 action head만 갈아끼웁니다. 셋 다 무거운 표현은 재사용하고 얇은 층만 목적에 맞춘다는 같은 리듬이에요 — 그게 이 커리큘럼의 관통 아이디어입니다.