
소형 Open LLM을 로봇에 탑재하여 걷고 물건을 집게 만든 방법
요약
Google의 Gemma-3-270M 소형 언어 모델을 활용하여 자연어 명령으로 로봇을 제어하는 방법을 다룹니다. MuJoCo 시뮬레이션 환경에서 모델을 미세 조정하여 로봇의 이동과 물건 집기 동작을 수행하도록 구현하는 과정을 설명합니다.
핵심 포인트
- Gemma-3-270M 모델을 활용한 로봇 제어 구현
- 자연어 명령을 로봇 동작 JSON 데이터로 변환
- MuJoCo 시뮬레이터를 통한 물리 환경 테스트
- 소형 모델의 지시 이행 능력을 위한 미세 조정 필요성
바로 본론으로 들어가겠습니다.
평소와 마찬가지로, 모든 결과물은 기사 끝에 링크되어 있습니다: Hugging Face의 모델 가중치(model weights)와 Codeberg의 소스 코드(source code)입니다.
이 기사는 무엇에 관한 것인가요?
저는 Google의 270M 파라미터 Gemma-3 언어 모델을 훈련시켜, MuJoCo 환경에서 인간의 자연어 명령(natural-language commands)을 사용하여 로봇 팔이 달린 궤도형 로봇(tracked robot)을 제어하는 방법을 설명할 것입니다.
이 로봇은 지도 위를 자유롭게 이동하고, 앞으로 가거나 뒤로 가고, 왼쪽과 오른쪽으로 회전하며, 물건을 집거나 내려놓을 수 있습니다.
아이디어가 어떻게 떠올랐나
집에 집게(claw)가 달린 DIY 궤도형 로봇 키트가 있습니다. 크기에 비해 놀라울 정도로 유능한 Gemma-3-270M을 접하게 되었을 때, 이를 로봇에 탑재하여 로봇을 제어하게 만들겠다는 아이디어가 떠올랐습니다.
여기 집게가 달린 DIY 궤도형 로봇 키트가 있습니다.
제 예상으로는 이 모델이 Raspberry Pi Zero 2 W에 문제없이 들어갈 것입니다. 아이디어는 다음과 같이 자유 형식으로 작성된 인간의 명령에 따라 움직일 수 있는 지능형 로봇을 만드는 것이었습니다:
- "왼쪽으로 돌아"
- "앞으로 10미터 가"
- "상자를 집어"
하지만 하드웨어를 파고들기 전에 모델 자체의 능력을 확인해 볼 가치가 있었습니다. 다행히 이 모든 것은 시뮬레이션(simulation)에서 먼저 테스트할 수 있습니다. 그것이 바로 이 기사의 주제입니다.
실험 시작하기
먼저, 미세 조정(fine-tuning)을 거치지 않은 기본 Gemma-3-270M이 지시 이행(instruction following)을 어떻게 처리하는지 확인할 필요가 있었습니다.
User:
당신은 로봇 컨트롤러입니다. 사용자 명령을 JSON으로 변환하세요.
사용 가능한 동작:
...
이 타란티노(Tarantino) 스타일의 대화에서 알 수 있듯이, 모델은 이 작업을 아주 잘 수행하지는 못합니다. 미세 조정(Fine-tuning)이 필요합니다.
모델 자체 외에도, 모델이 활동할 환경을 선택해야 했습니다.
저는 로보틱스에 탁월한 시뮬레이터인 MuJoCo를 선택했습니다. 물리 엔진이 NVIDIA Isaac Sim만큼 현실적이지는 않지만, 일반 노트북에서도 부드럽게 실행되며, 이전의 개인 프로젝트(pet projects)를 통해 이미 익숙해져 있었습니다.
때때로 MuJoCo 문서 사이트에는 정말 이상한 것들이 나타나곤 합니다...
명령 언어(command language) 또한 결정하기 쉬웠습니다. Gemma-3-270M은 매우 작은 모델이며, 영어 텍스트로 작동할 때 가장 성능이 좋습니다. 러시아어를 더 잘 이해하도록 가르쳐서 모델의 가중치(weights)에
합성 데이터 (synthetic data)입니다. 수동으로 작성된 약 70개의 예시가 OpenRouter를 통해 대규모 120B 모델에 의해 확장된 후, JSON 스키마 (JSON Schema)를 통해 엄격하게 검증됩니다.
1. 실제 생성 프롬프트 (The Actual Generator Prompt)
다음 명령어로 재현할 수 있습니다:
python -m dataset_gen.generate --dry-run
SYSTEM: 스키마 및 규칙 (Schema and Rules)
프롬프트는 전체 JSON 스키마로 시작합니다. 예를 들어, 여기 이동 스키마의 일부가 있습니다:
{
"type": "object",
"required": ["commands"],
...
그 다음 텍스트 규칙이 이어집니다:
ROLE: 당신은 궤도형 로봇 (tracked robot)을 위한 하나의 영어 자연어 지시문을 위 스키마에 엄격하게 부합하는 JSON 객체로 번역합니다.
...
USER: 예시 및 작업 (Example and Task)
EXAMPLES (형식 참조, 복사 금지):
{"instruction": "turn left then pick up the cube", "output":
{"commands": [{"action":"turn","direction":"left","angle_deg":90},
...
예시는 --fewshot N을 통해 무작위로 샘플링되므로, 각 배치 (batch)는 서로 다른 예시를 보게 되며 데이터셋은 더욱 다양해집니다.
2. 파이프라인 메커니즘 (dataset_gen/generate.py)
build_messages()는 위에서 보여준 것과 같이 SYSTEM + USER를 조립하며, 퓨샷 (few-shot) 예시는 시드 세트 (seed set)에서 무작위로 샘플링됩니다.- 별도의 의존성 없이 Python 표준 라이브러리인
urllib만을 사용하여 OpenRouter로 POST 요청을 보냅니다. 두 개의 120B 모델이 교대로 사용됩니다:openai/gpt-oss-120b:free및nvidia/nemotron-3-super-120b-a12b:free. - 응답은 JSON 배열로 파싱됩니다. 그 후 각 쌍은 동일한 스키마에 대해
jsonschema로 검증되고, 정규화된 지시문(normalized instruction)을 기준으로 중복이 제거된 뒤 JSONL에 추가됩니다.
3. 최종 데이터셋 (data/dataset.jsonl)
총 예시 수, 지시문-JSON 쌍: 2505개.
모든 쌍의 모든 명령어를 합산한 유형별 명령어 수:
| Action | Command count |
|---|---|
| move | 1938 |
| ... | ... |
결과적으로 네 가지 액션 (action)이 있습니다. 그중 일부와 그 수식어 (modifier)들을 더 자세히 살펴보겠습니다.
wait: 초 단위 대기
wait 명령에는 duration_s 파라미터가 있으며, 모델은 여기서 초 단위의 대기 시간을 지정합니다. 복잡한 것은 없습니다. 예를 들어, 데이터셋의 다음 시퀀스를 보겠습니다:
"뒤로 1미터 이동한 후, 3초 동안 멈췄다가, 다시 앞으로 1미터 이동"
-> [{"action":"move","direction":"backward","distance_m":1.0},
{"action":"wait","duration_s":3},
...
turn: 제자리에서 몸체 회전
로봇은 차동 구동 (differential-drive) 방식으로 제자리에서 회전합니다. 즉, 양측이 서로 반대 방향으로 회전하여 지정된 각도만큼 헤딩 (heading)을 변경합니다. 평행 이동하는 전진/후진 운동인 move와 달리, turn은 회전만을 수행합니다.
스키마 (Schema):
{
"action": "turn",
"direction": "left|right",
...
}
left는 반시계 방향을 의미하며, right는 시계 방향을 의미합니다. angle_deg는 항상 양수이며, 부호는 direction에 인코딩됩니다. speed는 선택 사항입니다.
데이터셋의 예시: "turn left 90 degrees"는 {"action":"turn","direction":"left","angle_deg":90}가 되며, "Rotate 360 degrees to the left."는 angle_deg: 360이 됩니다.
속도 제어: 선택적 speed 열거형 (Enum)
속도는 숫자로 지정되지 않고, move와 turn에 대해 {slow, normal, fast} 열거형 (enum)으로 지정됩니다. stop과 wait에는 속도가 없습니다.
이는 의도적인 선택이었습니다. 모델이 초당 미터 (meters per second)를 추측할 필요가 없어야 하기 때문입니다.
"as fast as you can" (가능한 한 빨리) -> ???
모델은 텍스트에 템포가 암시되어 있는 경우에만 speed를 출력합니다. 예를 들어, "slowly" (천천히) -> slow, "quickly" (빨리), "rush" (서둘러), "swiftly" (신속하게) -> fast와 같습니다. 그렇지 않으면 해당 필드는 생략되며 컨트롤러는 normal을 사용합니다.
| 속도 | move 선속도 (linear speed) | turn 각속도 (angular speed) |
|---|---|---|
| slow | 0.2 m/s | 0.5 rad/s |
| ... |
데이터셋의 일부 명령은 명시적인 speed를 포함하고 있으며, 나머지는 필드를 생략하여 normal을 암시합니다.
데이터의 예시: "turn left ninety degrees then creep back 0.5 meters"는 [{turn left 90}, {move backward 0.5, "speed":"slow"}]가 됩니다. "creep"이라는 단어는 템포(tempo)가 실제로 지정된 경우에만 slow로 매핑됩니다.
이미 눈치채셨겠지만, 여기에는 지도 정보가 전혀 없습니다. 로봇은 단순함을 유지하기 위해 설계상 "눈이 먼(blind)" 상태입니다. 실험의 향후 단계에서는 이를 추가할 예정입니다. 예를 들어, 로봇은 결국 "빨간색 큐브를 집어라"라는 말을 듣고, 스스로 그 큐브를 찾아내어 집어 올릴 수 있어야 합니다.
1단계: MuJoCo에서 탱크 만들기
대포 대신 로봇 집게(claw)가 달려 있고, 애니메이션 미소녀는 없는 형태입니다.
로봇은 빈 MJCF 파일로부터 점진적으로 성장했습니다. 먼저 월드(world)를 만들고, 그다음 몸체, 바퀴, 지지대를 만들었으며, 그 후에야 궤도로 날아가거나 다른 천상계로 이동하지 않고 움직일 수 있게 되었습니다.
여기서 명백한 단순화를 적용하겠습니다. 수십 개의 세그먼트로 구성된 폐쇄형 벨트 형태의 실제 궤도(track)는 이 실험에 필요하지 않으므로, 궤도는 물리적으로 정확하지 않을 것입니다.
대신 차동 구동(differential drive) 방식을 사용합니다. 양쪽에 각각 하나씩, 자체 속도 액추에이터(velocity actuator)를 가진 두 개의 구동 바퀴를 배치합니다. 회전은 실제 궤도형 섀시(tracked chassis)와 정확히 마찬가지로, 양쪽의 속도가 다르기 때문에 발생합니다.
월드(World)와 바닥(Floor)
모든 것은 XML 형식의 장면(scene) 물리 설정에서 시작됩니다. 전역 설정 외에도 바닥 전용 설정이 있습니다. 여섯 가지 파라미터를 하나씩 살펴보겠습니다.
timestep="0.002"
0.002초의 스텝은 500 Hz를 의미하며, 이는 접촉(contact)이 발생하는 시뮬레이션에서 익숙한 주파수입니다. 스텝이 더 크면 로봇이 바퀴 접촉 지점에서 "경련(twitching)"을 일으키기 시작합니다.
gravity="0 0 -9.81"
이것은 중력 벡터(gravity vector)입니다. 모든 것이 어디로, 얼마나 강하게 끌어당겨지는지를 지정하는 세 개의 숫자입니다.
- 이 세 숫자는 X, Y, Z 축, 즉 앞(forward), 옆(sideways), 위(upward)를 나타냅니다.
0 0 -9.81은 X나 Y 축을 따라 당겨지는 힘은 없고, Z 축을 따라 당겨지는 힘이-9.81임을 의미합니다.9.81은 지구의 중력 가속도인 9.81 m/s²이며, 학교 물리 시간에 배웠던 그g와 같습니다.- 마이너스(-) 부호가 붙는 이유는 Z축이 위쪽을 향하고 중력은 아래쪽으로 당기기 때문입니다.
간단히 말해, 이 줄은 "바닥을 향해 당겨지는 일반적인 지구 중력을 활성화한다"는 뜻입니다. 만약 0 0 0이라고 적는다면 로봇은 무중력 상태로 떠다닐 것이고, 0 0 -1.62는 달의 중력이 될 것이며, 0 0 -9.81은 평범한 지구의 중력입니다.
integrator="implicitfast"
시뮬레이터는 움직임을 연속적으로 계산하지 않습니다. 시뮬레이터는 작은 시간 단계(time steps)로 진행하며, 우리의 경우 각 단계는 0.002초입니다. 각 단계마다 시뮬레이터는 다음과 같은 질문에 답해야 합니다: "로봇이 지금 여기에 있고 이렇게 움직이고 있다면, 한 단계 뒤에는 어디에 있을 것인가?" 엔진이 이 질문에 답하기 위해 사용하는 방법(method)을 적분기 (integrator)라고 부릅니다.
Explicit Euler (명시적 오일러) 방식은 가장 단순한 방법입니다. 이 방식은 오직 현재 일어나고 있는 일만 보고, 다음 단계 전체가 정확히 동일하게 유지될 것이라고 가정합니다.
문제는 힘이 급격하게 가해질 때, 예를 들어 바퀴가 바닥을 강하게 칠 때 발생합니다. 한 단계(step) 동안 상황이 크게 변할 수 있지만, Explicit Euler는 이를 인지하지 못하고 업데이트되지 않은 과거의 상태를 바탕으로 동작합니다. 그 결과, 충격(impulse)을 감쇄(damp)시키지 못하고 오히려 증폭시킵니다. 로봇은 어디선가 에너지를 얻어 떨리기 시작하고, 최악의 경우 멀리 날아가 버립니다.
Implicit integrator (암시적 적분기), 그리고 그보다 더 빠르고 가벼운 버전인 implicitfast는 더 영리하게 작동합니다. 이 방식은 "로봇이 지금 어디에 있는가"뿐만 아니라 "단계가 끝날 때쯤 로봇이 어디에 도달해 있을 것인가"까지 고려하여, 스스로 모순되지 않는 답을 선택합니다.
이것이 바로 implicitfast를 사용하면 강한 접촉(hard contacts)이 발생하거나 상대적으로 큰 타임스텝(timestep)을 사용하더라도 시뮬레이션이 안정적으로 유지되는 이유입니다.
"Fast"라는 이름이 붙은 이유는 전체 암시적 스킴 (implicit scheme)은 비용이 많이 들기 때문이며, MuJoCo는 모든 것을 다시 계산하는 대신 점성 (viscosity)과 같이 정말로 중요한 힘의 부분에만 이를 적용하기 때문입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기

