OpenAI Decisions API를 반복적으로 사용하여 SO-101 구동하기 - Playground부터 실기까지
요약
OpenAI의 Decisions API를 활용하여 로봇 SO-101을 구동하는 방법을 다룹니다. 이 API는 일반적인 텍스트 생성 대신 '예/아니오'나 정형화된 선택지 중 확률적 답변을 반환하여, 로봇 제어에 필요한 판단 과정을 빠르고 효율적으로 구현할 수 있습니다. Playground 테스트부터 실제 IK(역운동학) 적용까지의 전 과정을 정리했습니다.
핵심 포인트
- Decisions API는 텍스트 생성 없이 정형화된 선택지/조건 확률만 반환합니다.
- 로봇 제어에 필요한 반복적 판단(예: 왼쪽인가 오른쪽인가)을 빠르고 효율적으로 구현할 수 있습니다.
- Playground 테스트를 통해 'predicate'와 'choice' 기능을 활용하는 방법을 익혔습니다.
- 실제 장비 구동 시 IK(역운동학) 적용 과정을 거쳐야 합니다.
서론
지난 기사에서는 로봇용 모델을 학습시키지 않고, Claude × MCP로 SO-101을 직접 구동하여 이구아나 인형을 잡았습니다.
'로봇 모델 없이도 범용 멀티모달 모델로 잡을 수 있다'는 것은 확인했지만, 아무래도 느린 것이 과제였습니다. 한 번의 판단에 몇 초가 걸리며, 그 판단을 수십 번 반복해야 하기 때문입니다.
그래서 이번에는 OpenAI의 Decisions API를 사용해 보았습니다.
문장을 생성하지 않고 '예/아니오' 또는 '선택지 중 어느 것'을 확률과 함께 반환하는 데 특화된 API로, 일반 응답보다 빠르다고 알려져 있습니다.
아이디어는 간단합니다.
게임의 컨트롤러처럼 '오른쪽', '왼쪽', '뒤', '앞'을 선택하는 판단을 반복하면, 조금씩 인형에 가까이 다가갈 수 있지 않을까?
본 기사에서는 Playground에서 선택지를 테스트하는 것부터 시작하여, IK(역운동학)를 적용해 카메라 방향을 자동으로 측정하고 실제 장비를 구동하는 일련의 과정을 정리했습니다.
도중에 여러 번 실패했기 때문에, 어려움을 겪었던 부분까지 포함해서 작성하겠습니다.
구성
| 항목 | 내용 |
|---|---|
| 로봇 | SO-101 팔(LeRobot으로 캘리브레이션 완료) |
| ... |
Decisions API란?
Decisions API는 이미지나 텍스트를 '재료(input)'로 전달하고, 그것에 대한 '질문(questions)'에 정형화된 답변을 반환하는 API입니다. 질문의 종류는 세 가지가 있습니다.
| 종류 | 반환되는 것 | 이번 사용법 |
|---|---|
| predicate | 조건이 참일 확률 (0~1) | '이구아나가 보이는가', '잡고 있는가' |
| choice | 정해진 선택지 중 하나 + 각 선택지의 확률 | '이구아나는 왼쪽/중앙/오른쪽 중 어느 곳에 있는가' |
| score | 순서가 있는 단계에 대한 점수 | 이번에는 사용하지 않음 |
좌표나 문장은 반환되지 않습니다. 따라서 '이구아나가 어디에 있나요?'라고 물을 수 없고, '왼쪽인가 오른쪽인가'를 반복해서 물어가며 접근하는 형태가 됩니다.
작성 시점에서는 공개 베타 버전이며, 사용할 수 있는 모델은 gpt-6-luna만 있고, 비용은 입력 100만 토큰당 0.10달러(출력은 과금 없음)였습니다. 최신 정보는 공식 문서를 확인해 주세요.
Step 1: 먼저 Playground에서 시도하기
코드를 작성하기 전에, Playground에서 카메라 이미지를 넣고 테스트했습니다. 여기서 몇 가지 중요한 점을 알게 되었기 때문에 순서대로 작성하겠습니다.
1-1. 먼저 '보이는가'(predicate)

팔 위의 카메라 이미지를 업로드하고, predicate로 이렇게 질문했습니다.
-
프롬프트(질문):
Is the small orange plastic lizard toy visible anywhere in the image? -
입력(재료): 이미지 +
Top camera view of a small robot arm workspace. Target object: small orange plastic lizard toy.
결과는 **100%**였습니다.
1-2. 다음으로 '왼쪽인가 오른쪽인가'(choice)

같은 이미지로 질문을 choice로 변경했습니다.
-
프롬프트(질문):
Where is the small orange plastic lizard toy horizontally relative to the image center? -
입력(재료): 이미지 + 상황 설명 (1-1과 동일)
선택지(value/description)는 이 4가지입니다.
image_left / The toy is to the left of the image center.
aligned / The toy is horizontally near the image center.
image_right / The toy is to the right of the image center.
...
결과는 이런 형태로 반환됩니다.
image_left 67%
aligned 27%
image_right 3%
...
가장 높은 것은 image_left이지만, aligned
여전히 고민하고 있습니다. 이그아나가 '중앙보다 약간 왼쪽'에 있다면 적절한 답입니다.
핵심은 **판단할 수 없음(cannot_tell)**을 반드시 포함하는 것입니다. 보이지 않을 때 억지로 '왼쪽', '오른쪽'을 선택하지 못하게 하는 탈출구 역할을 하며, 이것이 있으면 로봇이 임의의 방향으로 움직이는 것을 막을 수 있습니다.
여기까지로 '보이는지', '왼쪽인지 오른쪽인지'를 이미지 한 장에서 판단할 수 있다는 것을 알게 되었습니다.
Step 2: 첫 번째 스크립트 (관절 직접 구동)
Playground에서 좋은 반응을 얻었기에, 스크립트로 만들었습니다. 매 단계마다 두 대의 카메라 이미지를 하나의 요청에 모아 보내고, 여러 질문에 동시에 답하도록 합니다.
def call_decisions(frames, intro_text, questions):
content = [{"type": "input_text", "text": intro_text}]
for label, frame in frames:
...
질문은 이런 형태로 정의합니다 (top 카메라 측 예).
{"type": "choice", "name": "top_x",
"instructions": "In Image 1, where is the small orange plastic lizard toy horizontally relative to the red cross?",
"choices": [
...
top 카메라 이미지에는 OpenCV를 사용해 빨간색 십자 (발톱 바로 아래에 위치하는 점)를 그린 후 전송하고 있습니다. '이미지 중앙'보다 '빨간색 십자'가 더 어디에 맞추어야 할지가 명확합니다.
로봇을 움직이지 않고 판단만 시도하는 decide 명령어
latency: 1.52s
top_visible: 0.00
top_x: cannot_tell (p=0.53, confidence=0.37)
...
top에는 이그아나가 찍히지 않았으므로 cannot_tell을 선택하고, 고정된 side 카메라는 '이그아나는 그리퍼의 왼쪽'이라고 높은 확신도로 답했습니다. 보이지 않는 것은 보이지 않는다고 답변할 수 있어 목표대로 작동합니다.
응답 시간은 첫 호출에 2.9초 정도였고, 그 이후는 0.6~0.8초, 실제 연속 호출에서는 0.3~0.5초 수준까지 단축되었습니다. 이전 Claude × MCP 방식보다 1회 판단이 상당히 빠릅니다.
실패: 바닥을 기듯이 움직임
첫 버전은 '왼쪽으로'라면 어깨를 +4°로, '뒤로'라면 어깨와 팔꿈치를 ±4°로 하는 식으로 관절 각도를 직접 더하고 빼는 방식이었습니다. 이것으로 실기(實機)를 구동하자, 암(arm)이 바닥을 기듯이 움직였습니다.
어깨와 팔꿈치를 돌리면 발톱은 앞뒤뿐만 아니라 높이도 함께 변합니다. 높이를 신경 쓰지 않고 가까이 붙였기 때문에 점점 낮아졌습니다.
Step 3: IK (역운동학) 도입
그래서 관절이 아닌 '손끝의 위치'로 명령하도록 변경했습니다.
pip install "lerobot[kinematics]"
LeRobot에는 키네마티스(kinematics) 메커니즘 (내부적으로 placo 사용)이 있어, SO-101의 URDF (로봇 형태 데이터)를 읽어 사용할 수 있습니다.
순운동학(FK): 관절 각도 → 손끝 위치 -
역운동학(IK): 놓고자 하는 위치 → 관절 각도
from lerobot.model.kinematics import RobotKinematics
kin = RobotKinematics(
urdf_path="SO101/so101_new_calib.urdf", # TheRobotStudio/SO-ARM100의 URDF
...
이로써 '높이는 그대로, 뒤로 2cm'와 같은 명령이 가능해져서, 높은 위치를 유지하며 가까이 붙이고, 정면에 오면 아래로 내리는 움직임을 자연스럽게 작성할 수 있게 되었습니다.
다만, 여기에서도 실패가 계속됩니다. 실제 로그에서 원인이 명확히 파악된 것들을 언급합니다.
| 증상 | 로그에서 파악한 원인 |
|---|---|
| 아직 바닥을 기는 상태 | 시작 시 손끝이 책상에서 3.8cm 떨어져 있었다. '초기 높이를 유지'하도록 설계되었기 때문에, 그 낮은 상태 그대로 붙여 놓았다. |
| ... | |
| 마지막의 '반대로 움직이기'가 가장 까다로웠습니다. 카메라 부착 방식(회전이나 방향)에 따라 '이미지의 왼쪽'이 책상 위의 어느 쪽에 해당하는지가 달라지므로, 처음에는 수동으로 확인하여 설정을 수정하는 방식으로 만들었습니다. 하지만 이것은 실수하기 쉽고, 확인 작업도 번거롭습니다. |
Step 4: 직접 자세를 잡고, 스스로 방향을 측정하기
여기서 근본적인 설계 자체를 재검토했습니다.
- '높이가 너무 낮으니 자세를 다시 잡아주세요'라고 멈추는 것이 아니라,
스스로 자세가 취하는 위치로 이동한다 - 방향에 대한 대응을 사람이 확인하는 것이 아니라,
스스로 조금 움직여서, 카메라 영상으로 측정한다
자세 (Pose)
시작하면 먼저 스스로 '밑동에서 20cm 전방, 손톱은 가능한 한 아래쪽'의 자세로 이동합니다. 책상에 바짝 붙어 시작한 경우라면, 먼저 살짝 띄운 다음 움직이므로 옆으로 치지 않습니다.
방향 자동 보정 (Automatic Calibration)
팔을 앞뒤좌우로 조금씩 움직여서, 카메라 영상이 어느 쪽으로 움직였는지 OpenCV를 이용해 측정합니다.
top 카메라 (팔과 함께 움직임): 전경 전체가 틀어지기 때문에, 그 틀어짐을 측정합니다.
처음에는 이미지 전체의 틀어짐(위상 상관관계, phase correlation)으로 측정했지만, 잘 되지 않았습니다. top 카메라에는 팔과 함께 움직이는 그리퍼가 크게 찍혀 있어서, '움직이지 않는 부분'에 끌려갔기 때문입니다.
다음으로 특징점 대응(feature point correspondence)으로 변경했지만, 이번에는 신문지를 깔아 놓은 탓에 실패했습니다. 신문의 글자는 비슷한 형태의 반복이므로, 다른 글자끼리 잘못 대응시켜 버립니다.
최종적으로 다음 형태로 정착했습니다.
def scene_shift(before, after):
g1, g2 = gray(before), gray(after)
h, w = g1.shape
...
side 카메라 (고정): 이쪽은 '화면 내에서 움직인 부분의 평균'으로 측정했지만, 갈 때와 올 때 방향이 불일치했습니다. 손톱 끝을 앞으로 내밀 때는 어깨나 팔꿈치가 다른 방향으로 움직이기 때문에, 평균하면 '팔 전체의 움직임'이 되어버립니다.
그래서, 손톱만 살짝 닫았다가 열어서, 이미지가 변화한 곳을 손톱의 위치로 찾도록 했습니다. 손톱의 개폐만으로 움직이는 것은 손톱뿐이므로, 위치를 명확히 알 수 있습니다. 나머지는 그 주변의 작은 이미지를, 움직인 후의 이미지 속에서 찾는 것(템플릿 매칭, template matching)을 통해, 손톱의 움직임만을 추적할 수 있게 했습니다.
측정한 결과로부터, '책상 위의 방향 → 이미지 속의 방향'에 대한 2×2 대응 관계를 만들고, calib.json에 저장합니다. 로봇을 움직일 때는 그 역을 풀어 '이미지의 왼쪽으로 가고 싶다 → 책상 위의 이 방향으로 움직인다'와 같이 변환합니다. 카메라가 비스듬하거나 회전하여 부착되어 있어도 작동합니다.
Step 5: 어깨・팔꿈치・손목을 연동시키기 (그리고 가동 범위의 함정)
IK(Inverse Kinematics)에 '손끝의 위치'만 지정했기 때문에, 손톱의 방향은 거의 자유였습니다. 그 결과, 손끝이 아래로 내려가 있어도, 어깨(F2)・팔꿈치(F3)・손목(F4)이 제대로 연동되지 않아, 손톱이 기울어진 채 움직이고 있었습니다.
팔꿈치만 굽혀도, 손톱 끝은 수직으로 내려오지 않습니다. 어깨・팔꿈치・손목을 연동시켜야 비로소, 손톱을 수직으로 유지한 채 내릴 수 있습니다. 그래서 IK에 '손톱은 가능한 한 수직 아래를 향하도록'이라는 조건을 추가했습니다.
그런데 실제 기기에서는 다음 경고가 대량으로 발생하며 멈췄습니다.
WARNING:root:Relative goal position magnitude had to be clamped to be safe.
{ 'wrist_flex': { 'original goal_pos': 83.64768880316238,
'safe goal_pos': 75.95604395604396}}
이것은 LeRobot의 안전 장치(max_relative_target (한 번의 지령은 현재 위치에서 8°까지))가 작동한 것입니다. '현재 위치 + 8° = 75.96°'이므로, 손목은 실제로는 약 68°에서 멈춰 있던 것입니다.
원인은, URDF와 실제 기기의 가동 범위 차이였습니다.
- URDF에서는, 손목이 **95°**까지 구부러지는 것으로 되어 있다 - 실제 기기에서는,
lerobot-calibrate로 기록된 범위(이 개체에서는 약 68°)까지만 구부러진다
robot.connect()
캐리브레이션 파일을 읽어 각 모터의 가동 범위 상한과 하한을 서보 본체에 기록합니다. 즉, 손목은 서보 자체가 '범위 밖으로는 가지 않는다'고 판단하여 멈춰 있던 것입니다. URDF가 정의하는 범위를 믿었던 IK는 도달할 수 없는 각도를 계속해서 명령했습니다.
대책으로, 기동 시 LeRobot의 캐리브레이션에서 실제 가동 범위를 읽어와 그 범위 내에서 해를 구하는 IK를 직접 작성했습니다.
# robot.bus.calibration에는 lerobot-calibrate로 기록한 범위(모터의 생 값 0~4095)가 들어있다
for j in ARM:
c = robot.bus.calibration[j]
...
IK는 손끝 위치를 최우선으로 하고, 그 안에서 집게발을 최대한 아래로 향하게 합니다. 가동 범위 끝에 붙어버린 관절은 분리하고 나머지 관절로 다시 계산합니다.
Step 6: 안전 장치 (Safety)
시행착오를 거치며 다음 메커니즘들을 추가했습니다.
속도 제한: 각 관절은 초당 30° 이내로 움직이고, 실제로 목표에 도달할 때까지 기다린 후에 다음 단계로 진행합니다 (어깨는 팔 전체의 무게를 지탱하므로 빠르게 움직이면 따라잡지 못합니다).
- 도달 불가능한 관절 감지: 목표 지점에서 6° 이상 벗어난 상태로 도달하지 못하면 계속 밀지 않고 멈춥니다.
- 급격한 움직임 방지: 한 번의 이동으로 관절이 30° 이상 움직이는 계산이 나오는 명령어는 실행하지 않습니다. 큰 이동은 2cm씩 분할합니다.
- 오류 발생 시 즉시 힘을 풀지 않기: 아임을 현재 위치에 유지한 채로 5초를 센 후에 토크를 해제합니다.
오류가 발생하여 멈춥니다: 방향을 측정하기 위한 이동이 불가능합니다 (joint_jump(39deg))
아임은 현재 위치에 유지하고 있습니다.
5초 후에 토크가 해제됩니다. 아임을 손으로 지탱해 주세요 (바로 풀려면 Ctrl+C)
...
전체 흐름
최종적인 메커니즘은 다음과 같았습니다.
카메라 2대 ──이미지──▶ Decisions API (gpt-6-luna)
'왼쪽/중앙/오른쪽', '잡을 수 있는 높이인가' 등을 선택
│
...
1회 동작의 흐름은 다음과 같습니다.
준비 자세: 스스로 준비 자세로 이동합니다.
- 방향 측정 (최초 1회): 조금씩 움직여 카메라 영상의 움직임으로부터 방향 대응을
calib.json에 저장합니다. - 탐색: 조금 높은 위치에서 탐색합니다. top에 비치지 않으면 side를 보고 가까이 가져가고, 그래도 안 되면 목을 흔듭니다.
- 정렬: top 이미지의 빨간 십자 모양에 오리가 올 때까지 가깝게 합니다 (놓치면 마지막으로 보였던 위치로 돌아갑니다).
- 잡기: 수직으로 조금씩 내린 후, side에서 '잡을 수 있는 높이'라고 판단하면 닫고, 수직으로 올립니다.
- 운반: 같은 방법으로 바구니 위로 (바구니 가장자리보다 낮으면 먼저 올립니다).
- 놓기: 열어보고, 바구니에 들어갔는지 확인한 후, 준비 자세로 돌아갑니다.
구동까지의 절차
1. LeRobot 환경 및 캐리브레이션
conda create -y -n lerobot python=3.12
conda activate lerobot
pip install
다시 측정해 보겠습니다.
## 파악한 점
**Decisions API에 대하여**
- '왼쪽인지 오른쪽인지', '보이는지 아닌지'와 같은 판단은 이미지 1장만으로도 상당히 안정적으로 답변을 해준다. 신뢰도가 함께 제공되므로, '자신이 없을 때는 움직이지 않는다'는 로직을 구현하기 쉽다.
- 1회당 약 0.3~0.7초 정도로, 이전 Claude × MCP 방식보다 훨씬 빠르다.
- 한편으로는,
**질문의 전제가 이미지와 맞지 않으면 답변 거부나 잘못된 답이 나온다**. 혼동하기 쉬운 물건을 착각하는 경우도 발생하기 쉽고 - 좌표는 반환되지 않으므로, '조금 움직여서 다시 묻기'를 반복해야 한다. 한 번의 작업을 위해 수십 단계가 걸린다.
**로봇 측에 대하여**
- 판단의 정밀도보다,
**로봇 자체의 구현(IK, 방향 대응, 가동 범위, 안전)이 훨씬 어려웠다** - '사람에게 확인시키는' 방식은 오류가 나기 쉽고 번거롭다. 카메라 영상에서 스스로 측정하는 것이 더 확실했다.
- URDF 값을 그대로 믿지 않고,
`lerobot-calibrate`
으로 기록한 실기 범위를 사용하는 것이 중요하다.
## 향후 계획
- 실기에서의 일련의 성공(잡아서 바구니에 넣기)을 확인한다.
- 각 단계의 이미지와 판단은
`runs/`
에 저장되어 있으므로, 성공한 시도를 ACT 등의 모방 학습 데이터로 사용한다. 위치 맞추기는 이미 학습된 정책($ ext{π}_{0.5}$ 등)에 맡기고, Decisions API는 '잡았는지', '무엇을 잡을지'와 같은 판단에만 사용하는 하이브리드 방식도 시도하고 싶다.
코드 일체는 (리포지토리 URL)에 두고 있습니다.
### 토론

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기