Skip to main content
Summary

로봇에게 "할 수 있는 말"을 정해 주기

LLM에게 로봇을 맡긴다고 해서, LLM이 로봇의 모든 모터를 직접 움직이게 해서는 안 된다. "컵을 집어 줘"라는 말은 자연스럽지만, 실제 로봇 입장에서는 카메라를 켜고, 컵을 찾고, 좌표를 계산하고, 로봇팔 경로를 만들고, 그리퍼를 닫고, 들어 올리고, 성공 여부를 확인해야 하는 긴 과정이다. 이 모든 과정을 LLM이 매번 자유롭게 코드로 만들어 실행한다면 위험하고 불안정하다.

그래서 필요한 것이 로봇 스킬이다.

로봇 스킬은 로봇이 수행할 수 있는 행동을 안전한 함수처럼 포장한 것이다. LLM은 "무엇을 해야 하는지" 계획하고, 스킬은 "어떻게 안전하게 실행할지" 책임진다. 사람에게 비유하면 LLM은 작업 지시서를 쓰는 관리자이고, 스킬은 현장에서 실제 장비를 다루는 숙련된 작업자다. 관리자가 "저 컵을 집어서 오른쪽에 놓자"라고 말하면, 작업자는 자신의 규칙과 안전 절차에 따라 움직인다.

이 장의 핵심은 단순하다. 좋은 AI 로봇 에이전트는 좋은 LLM만으로 만들어지지 않는다. LLM에게 어떤 스킬을 제공하고, 각 스킬의 입력과 출력을 얼마나 명확하게 설계하느냐가 전체 시스템의 품질을 크게 좌우한다.

로봇 스킬 설계

스킬은 로봇의 API다

개발자에게 API는 익숙한 개념이다. 어떤 함수가 어떤 입력을 받고, 어떤 결과를 돌려주는지 정해 두면, 내부 구현을 몰라도 그 기능을 사용할 수 있다. 로봇 스킬도 마찬가지다.

예를 들어 LLM은 다음과 같은 스킬을 호출할 수 있다.

{
  "skill": "detect_object",
  "args": {
    "object_name": "red cup",
    "region": "table"
  }
}

이때 LLM은 카메라 이미지가 어떤 토픽으로 들어오는지, 세그멘테이션 모델이 어떤 네트워크 구조인지, 카메라 좌표를 로봇 좌표로 어떻게 변환하는지 몰라도 된다. 그것은 detect_object 스킬 내부의 책임이다.

반대로 스킬 내부도 LLM의 마음을 추측하지 않아야 한다. object_name이 "red cup"이면 빨간 컵을 찾고, 찾지 못하면 실패를 반환해야 한다. "아마 사용자가 컵 비슷한 것을 말했겠지"라고 마음대로 판단해서 빨간 그릇을 선택하면 안 된다.

좋은 스킬 인터페이스는 다음 네 가지를 분명히 한다.

  • 무엇을 입력으로 받는가

  • 어떤 조건에서 실행을 거부하는가

  • 성공하면 무엇을 반환하는가

  • 실패하면 왜 실패했는지 어떻게 알려 주는가

이 네 가지가 분명할수록 LLM은 더 안정적으로 계획을 세울 수 있다. 반대로 스킬이 모호하면 LLM은 추측으로 빈칸을 채우게 되고, 로봇은 예상치 못한 행동을 하게 된다.

너무 작은 스킬과 너무 큰 스킬

스킬을 설계할 때 처음 부딪히는 질문은 "어느 정도 크기로 나눌 것인가?"이다.

가장 작은 수준으로 내려가면 set_joint_velocity, open_gripper, move_z_down 같은 스킬을 만들 수 있다. 이런 스킬은 세밀하지만 LLM이 호출하기에는 위험하다. LLM이 실수로 move_z_down을 반복 호출하면 로봇팔이 책상을 누를 수도 있다. 또 자연어 명령 하나를 수행하기 위해 너무 많은 단계가 필요해 계획이 길고 불안정해진다.

반대로 너무 큰 스킬도 문제가 된다. clean_room 같은 스킬은 입력은 간단하지만 내부가 지나치게 복잡하다. 실패했을 때 어디서 문제가 생겼는지 알기 어렵고, LLM이 중간 과정을 조정하기도 어렵다.

이 책에서 다루는 LLM 로봇 에이전트에는 중간 크기의 스킬이 잘 맞는다. 예를 들어 detect_object, pick, place, navigate_to, check_status처럼 사람이 보아도 하나의 의미 있는 행동으로 이해되는 단위가 좋다. 내부에는 경로 계획, 충돌 검사, 센서 확인, 재시도 같은 세부 절차가 들어가지만, 외부에는 안전하게 정리된 인터페이스만 드러낸다.

스킬의 크기를 정할 때는 이런 질문을 해 볼 수 있다. "이 스킬을 한 문장으로 설명할 수 있는가?" "실패했을 때 이유를 명확히 말할 수 있는가?" "LLM이 이 스킬을 호출한 뒤 다음 결정을 내릴 수 있는 충분한 정보를 받을 수 있는가?" 이 질문에 답하기 어렵다면 스킬을 더 나누거나, 반대로 묶어야 한다.

공통 반환 형식부터 정하자

스킬을 하나씩 만들기 전에 먼저 공통 반환 형식을 정하는 것이 좋다. 반환 형식이 제각각이면 LLM이나 상위 계획기가 결과를 해석하기 어렵다. 어떤 스킬은 true만 반환하고, 어떤 스킬은 문자열을 반환하고, 어떤 스킬은 예외를 던진다면 재계획 로직은 금방 복잡해진다.

다음은 로봇 스킬에서 사용할 수 있는 기본 반환 형식이다.

{
  "ok": true,
  "skill": "pick",
  "status": "SUCCESS",
  "message": "red cup을 안정적으로 집었습니다.",
  "data": {
    "object_id": "obj_12",
    "grasp_confidence": 0.86
  },
  "reason": null
}

실패했을 때는 같은 구조로 실패 이유를 담는다.

{
  "ok": false,
  "skill": "pick",
  "status": "FAILED",
  "message": "물체를 집었지만 들어 올린 뒤 그리퍼 안에서 감지되지 않았습니다.",
  "data": {
    "object_id": "obj_12",
    "stage": "lift_check"
  },
  "reason": "GRIPPER_EMPTY"
}

여기서 ok는 성공 여부를 가장 빠르게 판단하기 위한 값이다. status는 실행 상태를 조금 더 넓게 표현한다. message는 사람과 LLM이 읽기 쉬운 설명이다. data에는 다음 스킬이 사용할 수 있는 구조화된 정보가 들어간다. reason은 실패 원인을 표준 코드로 표현한다.

실패 원인을 문자열 메시지만으로 전달하면 재계획이 어렵다. "안 됐어요", "실패했습니다", "물체를 못 잡았습니다"는 사람이 보기에는 비슷하지만, 로봇 입장에서는 전혀 다른 상황일 수 있다. 물체를 못 찾은 것인지, 경로가 막힌 것인지, 그리퍼가 닫혔지만 물체가 빠진 것인지에 따라 다음 행동이 달라진다.

따라서 실패 원인은 가능한 한 표준화해야 한다.

OBJECT_NOT_FOUND       물체를 찾지 못함
AMBIGUOUS_TARGET       후보가 여러 개라 대상을 확정할 수 없음
OUT_OF_WORKSPACE       목표가 로봇 작업 공간 밖에 있음
PLAN_FAILED            경로 계획 생성 실패
COLLISION_RISK         충돌 위험 때문에 실행 거부
EXECUTION_FAILED       계획은 생성됐지만 실행 중 실패
GRIPPER_EMPTY          집었다고 판단했지만 그리퍼 안에 물체가 없음
TIMEOUT                제한 시간 안에 완료하지 못함
SAFETY_STOP            안전 정지 발생
INVALID_ARGUMENT       입력 인자가 잘못됨
PERMISSION_DENIED      허용되지 않은 명령 또는 권한 없음
PRECONDITION_FAILED    실행 전 조건이 만족되지 않음

이런 코드가 있으면 LLM은 실패를 보고 다음 결정을 내릴 수 있다. OBJECT_NOT_FOUND라면 다시 관찰하거나 사용자에게 위치를 물을 수 있다. AMBIGUOUS_TARGET이라면 "컵이 두 개 있습니다. 왼쪽 컵인가요, 오른쪽 컵인가요?"라고 질문할 수 있다. COLLISION_RISK라면 우회 경로를 찾거나 사람의 개입을 요청해야 한다.

detect_object: 세상을 이름으로 찾는 스킬

자연어 명령 로봇에서 가장 자주 호출되는 스킬 중 하나가 detect_object다. 사용자는 "빨간 컵", "노트북 오른쪽", "가까운 병"처럼 말한다. 로봇은 이 표현을 카메라 이미지와 3D 좌표로 바꿔야 한다.

detect_object는 다음과 같은 입력을 받을 수 있다.

{
  "object_name": "red cup",
  "region": "table",
  "max_results": 3,
  "min_confidence": 0.5,
  "need_3d_pose": true
}

object_name은 찾을 대상이다. region은 탐색 영역을 제한한다. 테이블 위에서 찾을 것인지, 선반에서 찾을 것인지, 로봇 앞쪽 전체에서 찾을 것인지 정해 주면 오검출을 줄일 수 있다. max_results는 몇 개까지 후보를 반환할지 정한다. min_confidence는 모델의 신뢰도 하한이다. need_3d_pose가 참이면 픽셀 위치뿐 아니라 로봇 좌표계에서의 3D 위치까지 계산해야 한다.

성공 반환은 다음처럼 만들 수 있다.

{
  "ok": true,
  "skill": "detect_object",
  "status": "SUCCESS",
  "message": "테이블 위에서 red cup 후보 1개를 찾았습니다.",
  "data": {
    "detections": [
      {
        "object_id": "obj_12",
        "label": "red cup",
        "confidence": 0.91,
        "bbox": [420, 180, 530, 340],
        "pose_base": {
          "x": 0.42,
          "y": -0.18,
          "z": 0.11
        },
        "graspable": true
      }
    ]
  },
  "reason": null
}

여기서 중요한 값은 object_id다. LLM이 이후 pick을 호출할 때 "아까 찾은 그 컵"을 다시 자연어로 설명하게 하면 혼동이 생길 수 있다. 대신 detect_object가 반환한 object_id를 사용하면 대상이 명확해진다.

{
  "skill": "pick",
  "args": {
    "object_id": "obj_12"
  }
}

로봇 시스템에서는 이름보다 참조가 중요하다. 사람은 "저 컵"이라고 손가락질하면 되지만, 로봇은 그 "저"를 좌표와 식별자로 저장해야 한다.

detect_object가 실패하는 경우도 잘 설계해야 한다. 물체가 보이지 않는 경우와 후보가 너무 많은 경우는 다르다.

{
  "ok": false,
  "skill": "detect_object",
  "status": "FAILED",
  "message": "테이블 위에서 red cup을 찾지 못했습니다.",
  "data": {
    "searched_region": "table"
  },
  "reason": "OBJECT_NOT_FOUND"
}
{
  "ok": false,
  "skill": "detect_object",
  "status": "FAILED",
  "message": "red cup 후보가 2개 있습니다. 대상을 하나로 확정할 수 없습니다.",
  "data": {
    "candidates": ["obj_12", "obj_18"]
  },
  "reason": "AMBIGUOUS_TARGET"
}

첫 번째 경우 LLM은 카메라 위치를 바꾸거나 사용자에게 "컵이 카메라에 보이지 않습니다"라고 말할 수 있다. 두 번째 경우에는 "왼쪽 컵과 오른쪽 컵 중 어느 컵인가요?"라고 되물을 수 있다. 같은 실패라도 다음 행동이 완전히 달라진다.

move_to: 로봇팔을 안전한 목표 자세로 보내기

move_to는 로봇팔이나 엔드 이펙터를 특정 위치와 자세로 이동시키는 스킬이다. 이름은 간단하지만 내부는 무겁다. 목표 자세가 작업 공간 안에 있는지 확인해야 하고, 역기구학이 가능한지 검사해야 하며, 충돌 없는 경로를 계획하고, 실행 중 이상이 없는지 확인해야 한다.

LLM에게 move_to를 제공할 때는 특히 조심해야 한다. 좌표를 아무 값이나 넣을 수 있게 하면 위험하다. 예를 들어 z 값이 너무 낮으면 그리퍼가 테이블을 누를 수 있고, x 값이 너무 멀면 로봇팔이 도달 불가능한 자세를 시도할 수 있다.

따라서 move_to의 입력은 명확한 범위 제한을 가져야 한다.

{
  "target_pose": {
    "frame": "base_link",
    "x": 0.40,
    "y": -0.15,
    "z": 0.25,
    "roll": 3.14,
    "pitch": 0.0,
    "yaw": 1.57
  },
  "speed": "slow",
  "motion_type": "cartesian",
  "collision_check": true
}

frame은 좌표계다. 로봇 시스템에서 좌표계가 빠지면 거의 반드시 문제가 생긴다. 같은 x=0.4라도 카메라 기준인지, 로봇 베이스 기준인지, 지도 기준인지에 따라 전혀 다른 위치가 된다.

speed는 보통 숫자보다 제한된 문자열로 두는 편이 안전하다. LLM이 speed: 999 같은 값을 넣지 못하도록 slow, normal, fast 정도로 제한한다. 그리고 실제 속도 값은 로봇 제어기가 내부에서 정한다. motion_type도 마찬가지다. 직선에 가깝게 움직이는 카테시안 경로가 필요한지, 관절 공간에서 자연스럽게 움직이면 되는지 선택하게 할 수 있다.

성공 반환은 다음처럼 구성한다.

{
  "ok": true,
  "skill": "move_to",
  "status": "SUCCESS",
  "message": "목표 자세에 도달했습니다.",
  "data": {
    "final_pose": {
      "frame": "base_link",
      "x": 0.401,
      "y": -0.151,
      "z": 0.249
    },
    "position_error_m": 0.003
  },
  "reason": null
}

실패 반환에는 어느 단계에서 실패했는지가 들어가면 좋다.

{
  "ok": false,
  "skill": "move_to",
  "status": "FAILED",
  "message": "목표 자세로 가는 경로에서 충돌 위험이 감지되었습니다.",
  "data": {
    "stage": "planning",
    "target_pose": {
      "frame": "base_link",
      "x": 0.40,
      "y": -0.15,
      "z": 0.05
    }
  },
  "reason": "COLLISION_RISK"
}

LLM은 이 결과를 보고 z를 조금 올려 다시 시도할 수 있다. 하지만 그 재시도도 무한히 허용해서는 안 된다. 상위 계획기에는 "최대 재시도 횟수"와 "허용되는 보정 범위"가 있어야 한다. 로봇팔이 테이블 근처에서 실패했는데 LLM이 계속 아래로 내려가게 만드는 일은 반드시 막아야 한다.

pick: 집기는 하나의 동작이 아니라 작은 작전이다

사람에게 물체를 집는 일은 너무 자연스럽다. 하지만 로봇에게 pick은 작은 작전이다. 물체 위치를 확인하고, 접근 자세를 만들고, 그리퍼를 열고, 가까이 다가가고, 그리퍼를 닫고, 들어 올리고, 실제로 잡혔는지 확인해야 한다.

그러므로 pick 스킬은 단순히 그리퍼를 닫는 함수가 아니다. 로봇팔 Pick and Place 파이프라인을 안전하게 묶은 상위 스킬이다.

기본 입력은 다음처럼 설계할 수 있다.

{
  "object_id": "obj_12",
  "grasp_strategy": "auto",
  "approach": "top",
  "force_level": "normal",
  "verify_after_pick": true
}

object_id는 detect_object에서 받은 대상이다. grasp_strategy는 집기 후보를 선택하는 방식이다. 보통은 auto로 두고, 스킬 내부에서 물체 크기, 마스크, 깊이 정보, 그리퍼 폭을 고려해 후보를 고른다. approach는 접근 방향이다. 컵처럼 위에서 잡기 쉬운 물체는 top, 책처럼 옆에서 잡아야 하는 물체는 side가 필요할 수 있다.

force_level은 그리퍼 힘의 세기를 나타낸다. 여기서도 숫자를 직접 받기보다 light, normal, firm 같은 제한된 값을 쓰는 편이 안전하다. 유리컵을 잡는데 너무 큰 힘을 주면 물체가 손상될 수 있고, 무거운 상자를 너무 약하게 잡으면 들어 올리다 떨어뜨릴 수 있다.

성공 반환에는 집은 대상과 현재 상태가 들어간다.

{
  "ok": true,
  "skill": "pick",
  "status": "SUCCESS",
  "message": "red cup을 집어 올렸습니다.",
  "data": {
    "object_id": "obj_12",
    "held_object": "obj_12",
    "grasp_confidence": 0.88,
    "current_gripper_state": "holding"
  },
  "reason": null
}

실패는 단계별로 나누어야 한다. 접근 경로 생성이 실패했는지, 그리퍼가 물체에 닿기 전에 충돌 위험이 있었는지, 닫았지만 물체가 잡히지 않았는지에 따라 다음 행동이 달라진다.

{
  "ok": false,
  "skill": "pick",
  "status": "FAILED",
  "message": "접근 경로를 만들 수 없습니다. 물체 주변에 장애물이 있습니다.",
  "data": {
    "object_id": "obj_12",
    "stage": "approach_planning"
  },
  "reason": "PLAN_FAILED"
}
{
  "ok": false,
  "skill": "pick",
  "status": "FAILED",
  "message": "그리퍼를 닫았지만 물체가 감지되지 않았습니다.",
  "data": {
    "object_id": "obj_12",
    "stage": "grasp_verification"
  },
  "reason": "GRIPPER_EMPTY"
}

GRIPPER_EMPTY가 발생하면 LLM은 같은 물체를 다시 검출하고 다른 집기 후보를 선택하게 할 수 있다. 하지만 COLLISION_RISK가 발생하면 단순 재시도보다 안전한 위치로 물러나거나 사람에게 확인을 요청하는 것이 낫다.

이처럼 pick은 성공보다 실패 설계가 더 중요하다. 로봇은 한 번에 완벽히 집지 못할 때가 많다. 좋은 스킬은 실패하지 않는 스킬이 아니라, 실패했을 때 상위 에이전트가 이해할 수 있는 언어로 상황을 알려 주는 스킬이다.

place: 어디에 놓을 것인가를 좌표로 바꾸기

place는 들고 있는 물체를 목표 위치에 내려놓는 스킬이다. 이 스킬은 pick보다 쉬워 보이지만 실제로는 만만하지 않다. "노트북 오른쪽", "상자 안", "테이블 가장자리 말고 가운데" 같은 표현은 모두 공간 관계를 포함한다.

place의 입력은 절대 좌표를 받을 수도 있고, 다른 물체와의 관계를 받을 수도 있다.

{
  "held_object": "obj_12",
  "target": {
    "type": "relative",
    "reference_object_id": "obj_07",
    "relation": "right_of",
    "offset_m": 0.15
  },
  "place_style": "upright",
  "verify_after_place": true
}

여기서 reference_object_id는 기준 물체다. 예를 들어 obj_07이 노트북이라면, right_of는 노트북의 오른쪽에 해당하는 영역을 뜻한다. 단, "오른쪽"은 기준 좌표계에 따라 달라질 수 있다. 사용자 기준 오른쪽인지, 로봇 기준 오른쪽인지, 카메라 이미지 기준 오른쪽인지 정해야 한다. 실전 시스템에서는 가능한 한 명시적으로 정의하는 것이 좋다. 예를 들어 데스크톱 로봇팔에서는 "카메라 이미지에서 오른쪽"이 사용자가 기대하는 오른쪽과 잘 맞을 수 있다. 이동 로봇에서는 지도 좌표계 기준이 더 적절할 수 있다.

절대 좌표 기반 입력은 다음처럼 구성할 수 있다.

{
  "held_object": "obj_12",
  "target": {
    "type": "pose",
    "frame": "base_link",
    "x": 0.45,
    "y": 0.20,
    "z": 0.10,
    "yaw": 1.57
  },
  "place_style": "upright",
  "verify_after_place": true
}

place_style도 중요하다. 컵은 세워서 놓아야 하고, 책은 눕혀서 놓는 것이 자연스럽다. 물체 종류별 기본 놓기 방식을 스킬 내부 지식으로 둘 수 있다. LLM이 "컵을 오른쪽에 놓아 줘"라고 했을 때 굳이 place_style을 말하지 않아도, 스킬이 컵은 upright를 기본값으로 선택할 수 있다.

성공 반환은 다음처럼 만든다.

{
  "ok": true,
  "skill": "place",
  "status": "SUCCESS",
  "message": "red cup을 노트북 오른쪽에 놓았습니다.",
  "data": {
    "object_id": "obj_12",
    "placed_pose": {
      "frame": "base_link",
      "x": 0.46,
      "y": 0.21,
      "z": 0.10
    },
    "gripper_state": "open"
  },
  "reason": null
}

실패할 때는 목표 위치가 문제인지, 내려놓는 동작이 문제인지 구분한다.

{
  "ok": false,
  "skill": "place",
  "status": "FAILED",
  "message": "목표 위치가 작업 공간 밖에 있어 물체를 놓을 수 없습니다.",
  "data": {
    "stage": "target_validation"
  },
  "reason": "OUT_OF_WORKSPACE"
}
{
  "ok": false,
  "skill": "place",
  "status": "FAILED",
  "message": "목표 위치 근처에 장애물이 있어 안전하게 내려놓을 수 없습니다.",
  "data": {
    "stage": "place_planning"
  },
  "reason": "COLLISION_RISK"
}

이 정보가 있으면 LLM은 "노트북 오른쪽 공간이 부족합니다. 조금 더 오른쪽에 놓아도 될까요?"처럼 사용자와 대화할 수 있다. 자연어 로봇의 장점은 여기서 드러난다. 실패를 조용히 끝내지 않고, 이해 가능한 말로 되돌려줄 수 있다.

로봇팔이 책상 위에서 작업한다면 move_to, pick, place가 핵심이다. 이동 로봇이라면 navigate_to가 중심이 된다. 사용자는 "회의실 앞으로 가 줘", "충전기로 돌아가", "창고 앞에서 기다려"라고 말한다. 이동 로봇은 장소 이름을 지도 좌표로 바꾸고, 경로를 계획하고, 장애물을 피하며 이동해야 한다.

navigate_to의 입력은 장소 이름 또는 지도 좌표가 될 수 있다.

{
  "destination": {
    "type": "place_name",
    "name": "meeting_room_front"
  },
  "navigation_mode": "normal",
  "avoid_people": true,
  "timeout_sec": 120
}

장소 이름은 미리 등록된 목록 안에 있어야 한다. LLM이 "달나라 기지" 같은 존재하지 않는 장소를 만들어 내면 안 된다. 따라서 meeting_room_front, charging_station, lab_door, storage_area처럼 허용된 장소 목록을 시스템이 관리해야 한다.

지도 좌표를 직접 받는 방식도 가능하다.

{
  "destination": {
    "type": "map_pose",
    "frame": "map",
    "x": 3.2,
    "y": -1.4,
    "yaw": 1.57
  },
  "navigation_mode": "slow",
  "avoid_people": true,
  "timeout_sec": 120
}

하지만 일반적인 자연어 에이전트에서는 LLM이 좌표를 직접 만드는 것보다 장소 이름을 선택하게 하는 편이 안전하다. 좌표 생성은 지도 서버나 위치 관리 모듈이 맡는 것이 좋다.

성공 반환은 도착 여부와 최종 위치를 담는다.

{
  "ok": true,
  "skill": "navigate_to",
  "status": "SUCCESS",
  "message": "회의실 앞에 도착했습니다.",
  "data": {
    "destination": "meeting_room_front",
    "final_pose": {
      "frame": "map",
      "x": 3.18,
      "y": -1.37,
      "yaw": 1.60
    }
  },
  "reason": null
}

실패 원인은 이동 로봇에서 특히 중요하다. 경로가 막힌 것인지, 자기 위치를 잃은 것인지, 사람이 가까이 있어 정지한 것인지에 따라 대응이 다르다.

{
  "ok": false,
  "skill": "navigate_to",
  "status": "FAILED",
  "message": "회의실 앞까지 가는 경로가 막혀 있습니다.",
  "data": {
    "destination": "meeting_room_front",
    "blocked_near": {
      "frame": "map",
      "x": 2.4,
      "y": -0.9
    }
  },
  "reason": "PATH_BLOCKED"
}

PATH_BLOCKED가 나오면 LLM은 잠시 기다렸다가 다시 시도하거나, 다른 경로를 요청하거나, 사용자에게 "복도에 장애물이 있어 이동할 수 없습니다"라고 말할 수 있다. 그러나 SAFETY_STOP이라면 이야기가 다르다. 이 경우 LLM은 재시도를 독단적으로 결정하면 안 되고, 안전 상태 확인이나 사람의 승인을 요구해야 한다.

stop: 가장 단순하지만 가장 중요한 스킬

로봇 스킬 목록에서 가장 단순해 보이는 것은 stop이다. 하지만 실제로는 가장 중요한 스킬 중 하나다. 자연어 명령 로봇은 언제든 멈출 수 있어야 한다. 사용자가 "멈춰", "그만", "잠깐!"이라고 말하면 로봇은 계획을 계속 해석하기보다 즉시 안전 정지 절차를 실행해야 한다.

stop은 보통 입력이 거의 필요 없다.

{
  "mode": "safe_stop",
  "reason": "user_request"
}

mode는 정지 방식이다. safe_stop은 현재 동작을 부드럽게 멈추는 방식이고, emergency_stop은 더 강한 긴급 정지다. 실제 시스템에서 긴급 정지는 하드웨어 안전 회로와 연결되는 경우가 많으므로, LLM이 마음대로 emergency_stop을 남발하지 않게 정책을 정해야 한다. 다만 사람의 명시적인 정지 명령은 항상 높은 우선순위를 가져야 한다.

반환은 간단하다.

{
  "ok": true,
  "skill": "stop",
  "status": "SUCCESS",
  "message": "로봇을 안전하게 정지했습니다.",
  "data": {
    "previous_task": "pick",
    "robot_state": "stopped"
  },
  "reason": null
}

stop은 다른 스킬보다 우선순위가 높아야 한다. pick이 실행 중이어도, navigate_to가 실행 중이어도 정지 명령은 끼어들 수 있어야 한다. 이를 위해 실행 시스템은 현재 스킬을 취소할 수 있는 구조를 가져야 한다. 다음 장에서 다룰 ROS2 액션 구조가 바로 이런 취소와 피드백 처리에 잘 맞는다.

check_status: 로봇에게 지금 괜찮은지 묻기

LLM은 로봇의 상태를 직접 느끼지 못한다. 배터리가 얼마 남았는지, 현재 물체를 들고 있는지, 마지막 실패 이유가 무엇인지, 안전 정지 상태인지 모른다. 그래서 상위 계획기가 자주 호출해야 하는 스킬이 check_status다.

check_status는 로봇의 현재 상태를 구조화해서 반환한다.

{
  "include": ["battery", "arm", "gripper", "navigation", "safety", "held_object"]
}

반환 예시는 다음과 같다.

{
  "ok": true,
  "skill": "check_status",
  "status": "SUCCESS",
  "message": "로봇 상태가 정상입니다.",
  "data": {
    "battery": {
      "level": 0.72,
      "charging": false
    },
    "arm": {
      "state": "idle",
      "within_workspace": true
    },
    "gripper": {
      "state": "open"
    },
    "navigation": {
      "state": "idle",
      "current_place": "lab_table"
    },
    "safety": {
      "state": "normal",
      "protective_stop": false
    },
    "held_object": null
  },
  "reason": null
}

check_status는 계획 전에도 유용하고, 실패 후에도 유용하다. 예를 들어 사용자가 "컵을 집어 줘"라고 했는데 현재 로봇이 이미 다른 물체를 들고 있다면, 바로 pick을 호출하면 안 된다. 먼저 check_status로 held_object를 확인하고, 필요하다면 기존 물체를 내려놓거나 사용자에게 물어야 한다.

실패 후에도 마찬가지다. pick이 실패한 뒤 그리퍼가 닫힌 채인지, 로봇팔이 낮은 위치에 멈춰 있는지, 안전 정지가 걸렸는지 확인해야 다음 행동을 정할 수 있다. 로봇 에이전트에서 check_status는 사람의 "잠깐, 지금 상황부터 보자"에 해당한다.

스킬 목록은 허용 목록이어야 한다

LLM은 자연어를 잘 이해하고 유연하게 계획을 세울 수 있지만, 바로 그 유연함 때문에 위험할 수 있다. LLM에게 "필요한 함수를 만들어서 실행해"라고 하면 안 된다. 로봇에서는 반드시 허용된 스킬 목록 안에서만 행동하게 해야 한다.

예를 들어 시스템 프롬프트나 계획기 설정에는 다음처럼 명시된 스킬 목록이 들어간다.

{
  "allowed_skills": [
    "detect_object",
    "move_to",
    "pick",
    "place",
    "navigate_to",
    "stop",
    "check_status"
  ]
}

LLM이 이 목록에 없는 throw_object, push_harder, disable_safety, run_shell_command 같은 명령을 생성하면 실행기는 즉시 거부해야 한다. 중요한 점은 거부를 LLM에게만 맡기면 안 된다는 것이다. LLM이 스스로 "이건 위험하니 하지 말아야지"라고 판단하는 것도 필요하지만, 최종 방어선은 실행 시스템에 있어야 한다.

즉, 구조는 이렇게 되어야 한다.

사용자 명령
  → LLM 계획 생성
  → 스킬 이름과 인자 검증
  → 안전 정책 검사
  → 로봇 실행
  → 결과 반환

LLM이 아무리 그럴듯한 계획을 만들어도, 스킬 이름이 허용 목록에 없으면 실행되지 않는다. 인자 범위를 벗어나도 실행되지 않는다. 안전 정책에 걸려도 실행되지 않는다. 이 세 겹의 필터가 있어야 물리 세계에서 LLM을 사용할 수 있다.

인자 범위를 제한해야 안전해진다

스킬 이름만 제한해서는 부족하다. 허용된 스킬이라도 잘못된 인자를 넣으면 위험하다. move_to는 허용된 스킬이지만, 테이블 아래 좌표로 움직이라는 입력은 거부해야 한다. navigate_to는 허용된 스킬이지만, 계단 근처 금지 구역으로 이동하라는 입력은 거부해야 한다.

따라서 각 스킬에는 인자 검증 규칙이 필요하다.

{
  "skill": "move_to",
  "constraints": {
    "frame": ["base_link"],
    "x_range": [0.15, 0.65],
    "y_range": [-0.40, 0.40],
    "z_range": [0.08, 0.60],
    "speed": ["slow", "normal"],
    "collision_check_required": true
  }
}

이 규칙에 따르면 z가 0.03인 목표는 거부된다. speed가 very_fast인 입력도 거부된다. collision_check를 끄는 요청도 거부된다.

이동 로봇에서는 장소 목록과 금지 구역이 중요하다.

{
  "skill": "navigate_to",
  "constraints": {
    "allowed_place_names": [
      "lab_table",
      "meeting_room_front",
      "charging_station",
      "storage_area"
    ],
    "forbidden_zones": [
      "stairs",
      "elevator_door",
      "restricted_lab"
    ],
    "max_timeout_sec": 180,
    "navigation_mode": ["slow", "normal"]
  }
}

이렇게 하면 LLM이 "옥상으로 가자"라고 계획해도, 장소 이름이 허용 목록에 없으므로 실행되지 않는다. 사용자가 실제로 옥상 이동을 원한다면 먼저 시스템 관리자가 지도와 장소를 등록하고 안전 검증을 마쳐야 한다.

인자 제한은 LLM을 불신해서가 아니라, 로봇을 현실 세계에서 다루기 위한 기본 예절이다. 사람도 공장 장비를 조작할 때 버튼마다 가능한 동작 범위가 정해져 있다. AI 로봇에게도 같은 원칙이 필요하다.

사전 조건과 사후 조건을 명시하기

스킬에는 실행 전 만족해야 하는 조건과 실행 후 기대되는 조건이 있다. 이를 각각 사전 조건과 사후 조건이라고 부를 수 있다.

pick의 사전 조건은 "대상 물체가 검출되어 있어야 한다", "그리퍼가 비어 있어야 한다", "로봇팔이 안전 상태여야 한다" 등이다. 사후 조건은 "그리퍼가 물체를 들고 있어야 한다"이다.

place의 사전 조건은 "현재 들고 있는 물체가 있어야 한다", "목표 위치가 유효해야 한다", "내려놓을 공간이 비어 있어야 한다"이다. 사후 조건은 "물체가 목표 근처에 놓여 있고 그리퍼가 열려 있어야 한다"이다.

이를 문서화하면 다음처럼 표현할 수 있다.

pick:
  preconditions:
    - object_id exists
    - gripper is empty
    - robot arm is not in safety stop
  postconditions:
    - held_object == object_id
    - gripper_state == holding
  failure_reasons:
    - OBJECT_NOT_FOUND
    - PLAN_FAILED
    - COLLISION_RISK
    - GRIPPER_EMPTY

이런 정의는 단순한 문서가 아니다. 실제 실행 전에 검사할 수 있는 규칙이 된다. pick을 호출하기 전에 held_object가 이미 있다면 실행기는 PRECONDITION_FAILED를 반환한다.

{
  "ok": false,
  "skill": "pick",
  "status": "REJECTED",
  "message": "이미 다른 물체를 들고 있어 pick을 실행할 수 없습니다.",
  "data": {
    "held_object": "obj_03"
  },
  "reason": "PRECONDITION_FAILED"
}

이 반환을 받은 LLM은 "현재 다른 물체를 들고 있습니다. 먼저 내려놓을까요?"라고 사용자에게 물을 수 있다. 이처럼 사전 조건은 로봇이 이상한 상태로 들어가는 것을 막고, 대화형 에이전트가 자연스럽게 문제를 해결하도록 돕는다.

상태 메시지는 사람과 LLM이 함께 읽는다

스킬 반환값의 message는 단순한 장식이 아니다. 이 메시지는 로그에도 남고, 사용자에게 말로 전달될 수도 있으며, LLM의 다음 계획에도 사용된다. 그래서 너무 기계적이어도 안 되고, 너무 감성적이어도 안 된다.

좋은 메시지는 짧고 구체적이다.

좋음: 테이블 위에서 빨간 컵을 찾지 못했습니다.
나쁨: 실패했습니다.
좋음: 목표 위치가 로봇 작업 공간 밖에 있습니다.
나쁨: 뭔가 이상합니다.
좋음: 그리퍼를 닫았지만 물체가 감지되지 않았습니다.
나쁨: 집기 실패.

reason은 기계가 읽기 위한 코드이고, message는 사람과 LLM이 읽기 위한 설명이다. 둘 중 하나만 있어서는 부족하다. reason만 있으면 사용자가 이해하기 어렵고, message만 있으면 자동 재계획이 어렵다.

또한 메시지에는 불확실성을 솔직하게 담는 것이 좋다. "컵이 없습니다"보다 "카메라 시야 안에서 컵을 찾지 못했습니다"가 더 정확하다. 실제로 컵이 없는 것이 아니라 가려져 있을 수도 있기 때문이다. 로봇의 언어는 단정적이기보다 관찰 기반이어야 한다.

"가까운 컵을 조심히 집어서 노트북 오른쪽에 놓아줘" 실행하기

이제 앞 장에서 보았던 자연어 명령을 스킬 설계 관점에서 완성해 보자.

사용자가 말한다.

가까운 컵을 조심히 집어서 노트북 오른쪽에 놓아줘.

LLM은 이 문장을 바로 로봇팔 좌표로 바꾸지 않는다. 먼저 필요한 정보를 스킬 호출로 얻는다.

{
  "skill": "check_status",
  "args": {
    "include": ["arm", "gripper", "safety", "held_object"]
  }
}

로봇이 정상이고 그리퍼가 비어 있다면, 컵과 노트북을 찾는다.

{
  "skill": "detect_object",
  "args": {
    "object_name": "cup",
    "region": "table",
    "max_results": 5,
    "need_3d_pose": true
  }
}

컵 후보가 여러 개라면 detect_object는 여러 후보를 반환한다. "가까운 컵"이라는 조건은 로봇 기준 거리 또는 카메라 기준 거리로 계산할 수 있다. 이 기준은 시스템 정책으로 정해야 한다. 예를 들어 데스크 위 로봇팔에서는 로봇 베이스 기준 거리가 적절하다.

다음으로 노트북을 찾는다.

{
  "skill": "detect_object",
  "args": {
    "object_name": "laptop",
    "region": "table",
    "max_results": 1,
    "need_3d_pose": true
  }
}

컵과 노트북이 모두 확인되면 pick을 호출한다. "조심히"라는 표현은 force_level과 속도로 반영할 수 있다.

{
  "skill": "pick",
  "args": {
    "object_id": "obj_cup_02",
    "grasp_strategy": "auto",
    "approach": "top",
    "force_level": "light",
    "verify_after_pick": true
  }
}

성공하면 place를 호출한다.

{
  "skill": "place",
  "args": {
    "held_object": "obj_cup_02",
    "target": {
      "type": "relative",
      "reference_object_id": "obj_laptop_01",
      "relation": "right_of",
      "offset_m": 0.15
    },
    "place_style": "upright",
    "verify_after_place": true
  }
}

이 흐름에서 중요한 것은 LLM이 자유롭게 "오른쪽 좌표는 대충 y=0.3일 것"이라고 상상하지 않는다는 점이다. LLM은 의미를 해석하고, 실제 좌표 계산과 안전 검사는 스킬과 실행 시스템이 맡는다.

만약 컵이 두 개 있는데 가까운 컵을 결정할 수 없다면 어떻게 할까? detect_object는 AMBIGUOUS_TARGET을 반환하고, LLM은 사용자에게 질문한다.

컵이 두 개 보입니다. 로봇에 더 가까운 컵은 왼쪽의 흰 컵으로 보이는데, 이 컵을 집을까요?

만약 노트북 오른쪽 공간이 부족하다면 place는 COLLISION_RISK 또는 PLAN_FAILED를 반환하고, LLM은 이렇게 말할 수 있다.

노트북 오른쪽에 빈 공간이 부족합니다. 노트북 앞쪽에 놓아도 될까요?

이것이 스킬 기반 에이전트의 장점이다. LLM이 모든 것을 맞히는 것이 아니라, 스킬의 결과를 보며 대화하고 조정한다.

스킬 내부는 폐루프여야 한다

LLM 입장에서 스킬은 한 번 호출하면 결과가 나오는 함수처럼 보인다. 하지만 로봇 내부에서는 가능한 한 폐루프 구조로 동작해야 한다. 폐루프란 실행 중 센서 피드백을 계속 확인하면서 오차를 줄이는 방식이다.

예를 들어 pick 내부는 다음처럼 동작할 수 있다.

1. object_id의 최신 위치 확인
2. 집기 후보 자세 생성
3. 접근 경로 계획
4. 접근 중 충돌 위험 감시
5. 그리퍼 닫기
6. 힘 센서 또는 그리퍼 폭으로 잡힘 여부 확인
7. 들어 올린 뒤 카메라 또는 그리퍼 상태로 재확인
8. 성공 또는 실패 반환

외부에서 보면 pick(object_id) 하나지만, 내부는 계속 관찰하고 판단한다. 이 구조가 있어야 로봇이 작은 오차에 버틸 수 있다. 카메라 좌표가 몇 밀리미터 틀리거나, 물체가 살짝 움직이거나, 그리퍼가 미끄러지는 일은 흔하다. 스킬 내부에서 이런 변화를 감지하고 보정해야 한다.

LLM이 매 밀리초마다 "조금 왼쪽, 조금 아래, 이제 닫아"라고 지시하는 구조는 좋지 않다. LLM은 느리고, 물리 제어에 필요한 안정성을 보장하기 어렵다. 빠른 제어와 센서 피드백은 로봇 제어 계층에서 처리하고, LLM은 작업 수준의 의사결정에 집중해야 한다.

스킬 문서는 모델을 위한 사용 설명서다

사람 개발자만 스킬 문서를 읽는 것이 아니다. LLM도 스킬 설명을 바탕으로 계획을 만든다. 따라서 스킬 문서는 간결하면서도 충분히 구체적이어야 한다.

예를 들어 pick 스킬을 다음처럼 설명할 수 있다.

name: pick
description: 검출된 물체를 로봇 그리퍼로 집어 들어 올린다.
arguments:
  object_id:
    type: string
    required: true
    description: detect_object가 반환한 물체 ID
  grasp_strategy:
    type: enum
    values: [auto, top, side]
    default: auto
  force_level:
    type: enum
    values: [light, normal, firm]
    default: normal
  verify_after_pick:
    type: boolean
    default: true
returns:
  ok: boolean
  reason: standardized failure reason or null
  data.held_object: object_id when success
constraints:
  - gripper must be empty
  - object must be within workspace
  - collision check cannot be disabled

이 문서에는 세 가지가 들어 있다. 첫째, LLM이 어떤 인자를 넣어야 하는지 알 수 있다. 둘째, 실행기가 인자를 검증할 수 있다. 셋째, 개발자가 스킬 내부 구현을 바꾸더라도 외부 계약을 유지할 수 있다.

스킬 문서를 만들 때는 "LLM이 오해할 만한 부분"을 미리 줄여야 한다. 예를 들어 move_to가 로봇팔 이동인지 이동 로봇 주행인지 모호하면 안 된다. 이 책에서는 로봇팔 엔드 이펙터 이동은 move_to, 이동 로봇의 장소 이동은 navigate_to로 나누었다. 이름만 잘 지어도 많은 혼란을 줄일 수 있다.

실행 거부도 정상적인 결과다

로봇 시스템에서는 "실행하지 않음"도 중요한 행동이다. 위험하거나 모호한 명령을 받았을 때, 로봇은 억지로 뭔가를 해서는 안 된다. 이때 스킬은 실패가 아니라 REJECTED 상태를 반환할 수 있다.

{
  "ok": false,
  "skill": "move_to",
  "status": "REJECTED",
  "message": "목표 z 좌표가 허용 범위보다 낮아 실행하지 않았습니다.",
  "data": {
    "allowed_z_range": [0.08, 0.60],
    "requested_z": 0.02
  },
  "reason": "INVALID_ARGUMENT"
}

FAILED와 REJECTED는 다르다. FAILED는 실행을 시도했지만 성공하지 못한 것이다. REJECTED는 실행 전에 검사에서 막은 것이다. 이 구분은 안전과 디버깅에 중요하다. REJECTED가 많다면 LLM의 계획 생성이나 인자 변환 규칙을 고쳐야 한다. FAILED가 많다면 로봇 제어, 인식, 환경 조건을 점검해야 한다.

또 하나의 상태로 CANCELED를 둘 수 있다. 사용자가 중간에 "멈춰"라고 말했거나, 상위 계획기가 작업을 취소한 경우다.

{
  "ok": false,
  "skill": "navigate_to",
  "status": "CANCELED",
  "message": "사용자 요청으로 이동 작업을 취소했습니다.",
  "data": {
    "stopped_at": {
      "frame": "map",
      "x": 1.8,
      "y": -0.4
    }
  },
  "reason": "USER_CANCELED"
}

이렇게 상태를 나누면 로그 분석도 쉬워진다. 로봇이 정말 못한 것인지, 안전하게 거부한 것인지, 사용자가 멈춘 것인지 구분할 수 있기 때문이다.

스킬 설계 체크리스트

실제 프로젝트에서 새 스킬을 만들 때는 다음 항목을 확인하면 좋다.

  • 스킬 이름이 한 가지 의미로 분명하게 이해되는가

  • 입력 인자에 타입, 기본값, 허용 범위가 정의되어 있는가

  • 성공 반환과 실패 반환이 같은 구조를 사용하는가

  • 실패 원인이 표준 코드로 정리되어 있는가

  • 실행 전 사전 조건과 실행 후 사후 조건이 명시되어 있는가

  • LLM이 임의 좌표나 위험한 값을 넣지 못하도록 제한되어 있는가

  • 안전 검사, 충돌 검사, 권한 검사가 스킬 내부 또는 실행 직전에 포함되어 있는가

  • 실행 로그에 남길 정보가 충분한가

  • 사용자가 이해할 수 있는 상태 메시지를 반환하는가

  • 취소, 시간 초과, 안전 정지 상황을 처리할 수 있는가

이 체크리스트는 번거로워 보이지만, 로봇이 현실 세계에서 움직이기 시작하면 곧 필요성을 느끼게 된다. 소프트웨어 함수는 잘못 호출되면 에러 로그가 남는다. 로봇 스킬은 잘못 호출되면 컵을 떨어뜨리거나, 책상을 치거나, 사람을 놀라게 할 수 있다. 그래서 로봇의 API는 일반 API보다 더 보수적으로 설계해야 한다.

좋은 스킬은 LLM을 똑똑하게 만든다

LLM 로봇 에이전트를 만들 때 많은 사람이 먼저 더 강한 모델을 찾는다. 물론 모델의 추론 능력은 중요하다. 하지만 실제 프로젝트에서는 스킬 설계가 더 큰 차이를 만들 때가 많다.

스킬이 모호하면 좋은 LLM도 흔들린다. "이동"이라는 스킬 하나에 로봇팔 이동과 주행 이동이 섞여 있고, 실패 이유가 모두 "실패"로만 돌아오고, 좌표 범위 제한도 없다면 LLM은 안정적으로 계획할 수 없다.

반대로 스킬이 명확하면 보통의 LLM도 꽤 좋은 계획자가 된다. detect_object로 대상을 찾고, pick으로 집고, place로 내려놓고, 실패하면 표준 이유를 보고 다시 묻거나 재시도한다. 이것은 마법이 아니라 잘 설계된 인터페이스의 힘이다.

이 장에서 우리는 LLM이 호출할 수 있는 로봇 스킬을 함수처럼 정의했다. move_to, detect_object, pick, place는 로봇팔 작업의 뼈대가 되고, navigate_to, stop, check_status는 이동 로봇과 안전 제어의 기반이 된다. 그리고 모든 스킬은 공통 반환 형식, 표준 실패 코드, 인자 제한, 사전 조건과 사후 조건을 가져야 한다.

다음 단계는 이 스킬들을 실제 로봇 시스템과 연결하는 것이다. LLM은 계획을 만들고, ROS2는 실행을 담당한다. 그 사이에서 스킬 서버는 LLM의 요청을 검증하고, 로봇 액션을 호출하고, 실행 결과를 다시 구조화해서 돌려준다. 이제 자연어 계획은 실제 로봇의 움직임으로 이어질 준비를 마쳤다.

책 목록

AI 로봇 에이전트 만들기: 비전·로봇팔·학습·LLM으로 자연어 명령을 실행하는 로봇

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기