LLM과 ROS2 연결하기
LLM에게 운전대를 직접 맡기지 않는다
자연어 명령 로봇을 만들 때 가장 위험한 착각은 "LLM이 똑똑하니 로봇을 바로 움직이게 해도 되겠지"라고 생각하는 것이다. 채팅창에서 LLM은 그럴듯한 문장을 잘 만든다. 하지만 로봇은 문장이 아니라 전압, 속도, 관절각, 경로, 충돌 가능성 속에서 움직인다. 텍스트 한 줄이 실제 모터의 회전이 되는 순간, 작은 실수도 책상 위 컵을 밀어 떨어뜨리거나 로봇팔을 작업대에 부딪히게 만들 수 있다.
그래서 이 장의 핵심 원칙은 간단하다. LLM은 계획을 만들고, ROS2는 실행한다.
LLM은 사용자의 말을 이해하고, 목표를 뽑고, 작업 순서를 만든다. "빨간 컵을 집어서 노트북 오른쪽에 놓아줘"라는 말을 들으면 먼저 컵을 찾아야 하고, 그다음 집어야 하며, 노트북 위치를 확인한 뒤, 오른쪽에 놓아야 한다고 판단한다. 하지만 그 판단이 곧바로 모터 명령이 되어서는 안 된다. 실제 센서 조회, 좌표 변환, MoveIt2 경로 계획, Nav2 이동, 그리퍼 제어, 충돌 회피는 ROS2 노드와 스킬 서버가 맡아야 한다.
이러한 분리는 최신 LLM-ROS 연구와 구현 사례에서도 반복해서 등장한다. 예를 들어 ROS-LLM 프레임워크는 LLM 출력에서 행동 표현을 추출하고, 이를 ROS 액션과 서비스 실행으로 연결하며, 시퀀스·행동 트리·상태 기계 같은 실행 표현을 지원하는 구조를 제안한다1. NASA JPL의 ROSA 역시 LLM을 로봇의 저수준 제어기로 쓰기보다, ROS 시스템을 자연어로 진단하고 조작하는 도구 기반 에이전트로 설계한다. 특히 도구 호출, 파라미터 처리, 제약 검증을 중요한 구성 요소로 둔다2.
이 장에서는 바로 그 중간 계층을 만든다. 사용자의 자연어와 ROS2 실행 시스템 사이에 "통역사이자 안전 게이트" 역할을 하는 인터페이스를 세우는 것이다.
사용자 명령
↓
LLM 계획 노드
↓
구조화 명령 생성
↓
검증기와 스킬 디스패처
↓
ROS2 서비스·액션·토픽
↓
비전, MoveIt2, Nav2, 컨트롤러, 센서
↓
실행 결과와 피드백
↓
LLM 재판단이 구조를 잡아 두면 LLM이 조금 엉뚱한 말을 하더라도 로봇은 허용된 스킬 안에서만 움직인다. LLM이 "하늘을 날아가서 컵을 가져와"라고 계획해도, 스킬 목록에 fly가 없으면 실행되지 않는다. LLM이 좌표를 잘못 만들더라도 작업 공간 밖이면 검증 단계에서 거절된다. 로봇이 물체를 놓쳤다면 실패 이유가 다시 LLM에게 전달되고, LLM은 같은 명령을 무작정 반복하는 대신 다른 방법을 선택할 수 있다.

두 계층으로 나누기: 계획 계층과 실행 계층
LLM과 ROS2를 연결할 때 먼저 정해야 할 것은 "누가 무엇을 책임지는가"이다. 책임이 섞이면 디버깅이 어려워지고, 안전 장치도 흐려진다. 따라서 시스템을 크게 두 계층으로 나눈다.
계획 계층은 사용자의 의도를 다룬다. 자연어 명령을 읽고, 필요한 대상과 조건을 찾고, 여러 단계를 순서대로 배열한다. 이 계층에는 LLM, 프롬프트, 대화 메모리, 작업 분해 로직, 재계획 로직이 들어간다. 계획 계층의 출력은 사람이 읽는 문장이 아니라, 실행 계층이 이해할 수 있는 구조화된 명령이어야 한다.
실행 계층은 로봇의 몸을 다룬다. 카메라에서 물체를 찾고, 로봇팔 경로를 계산하고, 이동 로봇을 목적지까지 보내고, 그리퍼를 열고 닫는다. 이 계층에는 ROS2 노드, 서비스, 액션, 토픽, MoveIt2, Nav2, 컨트롤러, 센서 드라이버, 안전 모니터가 들어간다.
둘 사이에는 얇지만 매우 중요한 중간 계층이 있다. 이 계층을 여기서는 스킬 디스패처라고 부르자. 디스패처는 LLM이 만든 명령을 받아 다음을 확인한다.
요청한 스킬이 허용 목록에 있는가
필요한 인자가 모두 들어 있는가
인자 타입과 단위가 맞는가
좌표, 속도, 힘, 시간 값이 안전 범위 안에 있는가
현재 로봇 상태에서 실행 가능한가
실행 중 취소나 정지가 가능한 인터페이스인가
검증을 통과한 명령만 ROS2 서비스나 액션 호출로 바뀐다. 이 구조에서는 LLM이 ROS2 전체 그래프를 마음대로 건드리지 않는다. LLM은 공개된 스킬 명세만 보고 계획한다. 실제 ROS2 시스템은 디스패처 뒤에 숨겨져 있으며, 스킬 서버가 정해 둔 방식으로만 접근된다.
비유하자면 LLM은 레스토랑 손님이고, ROS2는 주방이다. 손님은 "파스타 하나 주세요"라고 말할 수 있지만, 주방의 가스 밸브를 직접 열거나 칼을 들고 재료를 자르지는 않는다. 메뉴판이 스킬 명세이고, 주문을 확인하는 직원이 디스패처이며, 요리사는 ROS2 스킬 서버다.
스킬 서버는 로봇의 메뉴판이다
지난 장에서 우리는 move_to, detect_object, pick, place, navigate_to, stop, check_status 같은 로봇 스킬을 설계했다. 이번 장에서는 이 스킬들을 LLM이 호출할 수 있는 서버로 노출한다.
스킬 서버는 "로봇이 할 수 있는 일"을 정해진 인터페이스로 제공하는 ROS2 노드다. 중요한 점은 스킬 서버가 단순한 함수 묶음이 아니라는 것이다. 각 스킬은 이름, 설명, 입력 파라미터, 반환값, 실행 조건, 안전 제한을 함께 가져야 한다. LLM은 이 명세를 읽고 어떤 스킬을 어떤 순서로 호출할지 결정한다.
예를 들어 스킬 명세는 다음처럼 표현할 수 있다.
skills:
- name: detect_object
description: 카메라에서 특정 물체를 찾고 로봇 좌표계 기준 위치를 반환한다.
input_schema:
label: string
color: optional_string
region: optional_string
output_schema:
success: boolean
object_id: optional_string
pose: optional_pose
confidence: float
reason: optional_string
interface:
type: service
name: /skills/detect_object
preconditions:
- camera_ready
- name: pick
description: object_id로 지정된 물체를 안전하게 집는다.
input_schema:
object_id: string
grasp_hint: optional_string
output_schema:
success: boolean
stage: string
reason: optional_string
interface:
type: action
name: /skills/pick
preconditions:
- arm_ready
- gripper_ready
- object_pose_known
safety_limits:
workspace: table_zone
max_retry_inside_skill: 1
- name: place
description: 들고 있는 물체를 목표 위치에 놓는다.
input_schema:
target_pose: pose
reference_object_id: optional_string
relation: optional_string
output_schema:
success: boolean
stage: string
reason: optional_string
interface:
type: action
name: /skills/place
preconditions:
- holding_object이 명세는 LLM에게 "가능한 행동의 경계"를 알려 준다. 동시에 디스패처에게는 검증 기준이 된다. pick에는 object_id가 필요하다. 따라서 LLM이 pick(label='cup')처럼 호출하면 디스패처는 거절할 수 있다. 먼저 detect_object로 컵을 찾아 object_id를 얻어야 하기 때문이다.
스킬 명세를 잘 만들면 프롬프트도 단순해진다. LLM에게 복잡한 ROS2 내부 구조를 설명할 필요가 없다. 대신 이렇게 말하면 된다.
너는 로봇 작업 계획자다.
아래 스킬 목록에 있는 스킬만 사용할 수 있다.
각 단계는 스킬 이름과 인자를 가진 구조화 명령으로 출력한다.
목록에 없는 행동은 만들지 않는다.
확실하지 않은 대상은 먼저 detect_object 또는 check_status로 확인한다.이 원칙은 생각보다 강력하다. LLM은 말이 많고 창의적이지만, 스킬 목록이 좁으면 그 안에서 계획을 세워야 한다. 창의성은 작업 분해에 쓰고, 실행은 검증된 스킬에 맡기는 것이다.
토픽, 서비스, 액션을 어떻게 나눌까
ROS2에는 여러 통신 방식이 있다. LLM과 연결할 때도 이 차이를 이해해야 한다. 모든 스킬을 토픽 하나로 밀어 넣으면 간단해 보이지만, 나중에 피드백과 실패 처리가 어려워진다. 반대로 모든 것을 액션으로 만들면 구조는 통일되지만, 짧은 조회 작업까지 지나치게 무거워질 수 있다.
기본 기준은 다음과 같다.
| ROS2 인터페이스 | 적합한 경우 | 로봇 스킬 예시 |
|---|---|---|
| 토픽 | 계속 흘러가는 데이터, 상태 방송, 센서 스트림 | 카메라 이미지, 현재 로봇 상태, 진행 로그, 비상 정지 신호 |
| 서비스 | 짧은 요청-응답 작업 | check_status, 현재 자세 조회, 한 번의 객체 검출 요청 |
| 액션 | 시간이 걸리고 중간 피드백과 취소가 필요한 작업 | navigate_to, move_to, pick, place |
예를 들어 check_status는 서비스로 충분하다. 요청하면 현재 배터리, 팔 상태, 그리퍼 상태, 에러 상태를 바로 돌려주면 된다. 반면 pick은 액션이 어울린다. 접근하고, 그리퍼를 열고, 내려가고, 닫고, 들어 올리는 동안 시간이 걸린다. 실행 중에는 "접근 중", "그리퍼 닫는 중", "들어 올리는 중" 같은 피드백을 보낼 수 있어야 한다. 사용자가 중간에 "멈춰!"라고 말하면 취소도 가능해야 한다.
detect_object는 상황에 따라 서비스나 액션이 될 수 있다. 단일 프레임에서 빠르게 찾는다면 서비스가 좋다. 하지만 카메라를 움직이며 여러 각도에서 찾거나, 몇 초 동안 후보를 누적해 신뢰도를 높인다면 액션이 더 적합하다.
여기서 중요한 것은 LLM이 토픽, 서비스, 액션의 세부 문법을 직접 다루지 않게 하는 것이다. LLM은 pick(object_id='obj_17')라는 고수준 호출만 만든다. 이 호출을 ROS2 액션 클라이언트로 바꾸는 일은 디스패처가 맡는다.
자연어 계획을 구조화 명령으로 바꾸기
LLM의 출력은 두 종류로 나눌 수 있다. 하나는 사용자에게 보여 줄 설명이고, 다른 하나는 로봇이 실행할 명령이다. 로봇 실행에는 반드시 두 번째 형태가 필요하다. "컵을 찾은 다음 집어 올립니다"라는 문장은 사람이 이해하기 쉽지만, 프로그램이 안전하게 검증하기 어렵다.
그래서 LLM 출력은 JSON이나 함수 호출 형태로 제한한다. ROS 기반 자연어 제어 예제에서도 사용자 명령을 미리 정의한 온톨로지, 즉 허용된 행동 형식에 맞는 JSON으로 변환하는 방식을 사용한다3. 핵심은 "자유로운 문장"을 "검증 가능한 데이터"로 바꾸는 것이다.
예를 들어 사용자가 이렇게 말했다고 하자.
빨간 컵을 집어서 노트북 오른쪽에 놓아줘.LLM의 출력은 다음처럼 되어야 한다.
{
"task_id": "task_001",
"steps": [
{
"skill": "detect_object",
"args": {
"label": "cup",
"color": "red"
}
},
{
"skill": "detect_object",
"args": {
"label": "laptop"
}
},
{
"skill": "pick",
"args": {
"object_ref": "step_1.object_id",
"grasp_hint": "top"
}
},
{
"skill": "place",
"args": {
"reference_object_ref": "step_2.object_id",
"relation": "right_of",
"offset": {
"x": 0.18,
"y": 0.0,
"z": 0.0
}
}
}
]
}이 JSON은 아직 실행 명령이 아니다. 실행 후보일 뿐이다. 디스패처는 먼저 각 단계를 검증한다. detect_object가 존재하는가? label은 문자열인가? pick에 들어간 object_ref는 이전 단계의 결과를 참조하는가? place의 offset은 허용된 범위 안인가? right_of라는 관계는 시스템이 정의한 관계인가?
검증을 통과하면 디스패처는 첫 번째 단계부터 실행한다. 첫 번째 detect_object 결과로 obj_cup_17이 나오면 세 번째 단계의 object_ref가 실제 object_id로 해석된다. 두 번째 detect_object에서 노트북의 위치가 나오면 right_of 관계와 offset을 이용해 놓기 목표 자세를 계산한다. 그리고 pick, place 액션을 순서대로 호출한다.
이 과정을 코드로 아주 단순화하면 다음과 같다.
ALLOWED_SKILLS = {
'detect_object': DetectObjectSpec,
'pick': PickSpec,
'place': PlaceSpec,
'check_status': CheckStatusSpec,
}
def dispatch_plan(plan):
context = {}
for index, step in enumerate(plan.steps):
skill_name = step.skill
args = resolve_refs(step.args, context)
if skill_name not in ALLOWED_SKILLS:
return fail(index, 'unknown_skill', skill_name)
spec = ALLOWED_SKILLS[skill_name]
validated_args = spec.validate(args)
if not spec.preconditions_met():
return fail(index, 'precondition_failed', skill_name)
result = call_ros_interface(spec, validated_args)
context[f'step_{index + 1}'] = result
if not result.success:
return result
return success(context)실제 구현에서는 여기에 타임아웃, 취소 처리, 로그 저장, 사용자 확인 요청, 안전 구역 검사, 좌표 변환 검사 등이 추가된다. 하지만 뼈대는 같다. LLM 출력은 검증하고, 검증된 명령만 ROS2로 보낸다.
스킬 디스패처 만들기
스킬 디스패처는 LLM과 ROS2 사이의 게이트웨이다. 이 노드는 LLM API를 직접 호출할 수도 있고, 외부 LLM 서버에서 받은 계획만 처리할 수도 있다. 구조는 프로젝트 규모에 따라 달라지지만, 기본 역할은 일정하다.
디스패처는 먼저 스킬 명세를 로드한다. 보통 YAML, JSON, Python 설정 파일로 관리한다. 다음으로 사용자 명령을 받아 LLM에 전달하고, LLM의 구조화 출력을 파싱한다. 파싱에 실패하면 실행하지 않고 다시 생성하도록 요청하거나 사용자에게 명확한 질문을 한다. 파싱에 성공하면 스키마 검증을 하고, 각 스킬을 ROS2 인터페이스로 매핑한다.
스킬 서버 쪽은 두 가지 방식으로 만들 수 있다.
하나는 스킬마다 개별 서비스와 액션을 만드는 방식이다. 예를 들어 /skills/detect_object는 서비스, /skills/pick은 액션, /skills/place도 액션으로 만든다. 이 방식은 ROS2 타입을 명확하게 정의할 수 있어 안정적이다. 로봇 프로젝트가 커질수록 이 방식이 좋다.
다른 하나는 범용 ExecuteSkill 액션을 만들고, 그 안에 스킬 이름과 JSON 인자를 넣는 방식이다. 이 방식은 빠르게 실험하기 쉽다. 새 스킬을 추가할 때 ROS2 인터페이스 파일을 매번 만들지 않아도 된다. 다만 타입 안정성이 약해지므로 내부 검증을 더 강하게 해야 한다.
방식 A: 개별 인터페이스
/skills/detect_object 서비스
/skills/pick 액션
/skills/place 액션
/skills/check_status 서비스
방식 B: 범용 인터페이스
/skills/execute 액션
goal: skill_name, args_json
feedback: stage, progress, message
result: success, reason, data_json처음 배우는 단계에서는 방식 B가 편하다. 하지만 실제 로봇팔, 이동 로봇, 안전 요구가 있는 프로젝트에서는 방식 A를 권장한다. ROS2 타입이 분명하면 잘못된 인자가 컴파일이나 런타임 초기에 드러난다. 또한 각 스킬별로 액션 피드백 메시지를 더 풍부하게 설계할 수 있다.
예를 들어 Pick 액션의 피드백은 다음처럼 설계할 수 있다.
feedback:
stage: string # planning, approaching, grasping, lifting
progress: float # 0.0 to 1.0
message: string
result:
success: boolean
stage: string
reason: string
holding_object: boolean
final_gripper_width: float이런 반환값은 LLM에게 매우 중요하다. 단순히 실패했다고만 알려 주면 LLM은 같은 행동을 다시 반복하기 쉽다. 반대로 "접근 단계에서 경로 계획 실패", "그리퍼를 닫았지만 물체가 감지되지 않음", "들어 올리는 중 물체가 떨어짐"처럼 알려 주면 다음 판단이 달라진다.
연결 방식 1: 로컬 ROS2 노드로 직접 연결하기
가장 단순하고 안정적인 방식은 LLM 브리지 자체를 ROS2 노드로 만드는 것이다. 예를 들어 llm_task_node라는 Python 노드를 만들고, 이 노드가 사용자 명령을 받아 LLM API를 호출한 뒤, ROS2 서비스와 액션 클라이언트를 통해 스킬 서버를 호출한다.
사용자 입력 토픽 또는 웹 UI
↓
llm_task_node
↓
/skills/detect_object 서비스 클라이언트
/skills/pick 액션 클라이언트
/skills/place 액션 클라이언트
↓
스킬 서버들이 방식의 장점은 ROS2와 자연스럽게 통합된다는 점이다. rclpy로 서비스 클라이언트와 액션 클라이언트를 만들 수 있고, ROS2 로그, 파라미터, 네임스페이스, 라이프사이클 관리도 그대로 쓸 수 있다. 로봇 내부 컴퓨터에서 실행하면 네트워크 구조도 단순하다.
단점은 LLM 연동 코드가 ROS2 워크스페이스 안으로 들어온다는 것이다. 클라우드 LLM API, 웹 UI, 대화 메모리, 사용자 인증 같은 기능을 붙이다 보면 로봇 코드와 서비스 코드가 섞일 수 있다. 그래서 규모가 커지면 LLM 서버와 ROS2 브리지를 분리하는 편이 좋다.
로컬 직접 연결은 다음 상황에 잘 맞는다.
실험실에서 하나의 로봇을 제어한다
LLM 호출과 ROS2 실행을 같은 컴퓨터에서 관리한다
빠르게 프로토타입을 만들고 싶다
ROS2 액션 피드백을 세밀하게 다루고 싶다
LangChain 같은 에이전트 프레임워크를 ROS2 노드 안에 넣고, move_forward, rotate, get_pose, move_to_pose, move_to_named_target 같은 로봇 전용 도구를 정의하는 구현 사례도 있다6. 이런 구조는 교육용 예제에서 특히 이해하기 쉽다. LLM의 도구 호출이 곧 로봇 스킬 호출로 이어지기 때문이다.
연결 방식 2: rosbridge와 CLI 래퍼 사용하기
두 번째 방식은 ROS2 시스템을 외부 에이전트가 접근할 수 있도록 중간 다리를 놓는 것이다. 대표적인 도구가 rosbridge다. rosbridge는 WebSocket을 통해 ROS 토픽, 서비스, 액션에 접근할 수 있게 해 준다. 그러면 로봇 내부 ROS2 노드가 아닌 외부 프로그램에서도 네트워크로 ROS 그래프를 읽고 명령을 보낼 수 있다.
ros-skill 같은 프로젝트는 에이전트가 스킬 문서를 읽고, CLI 도구를 실행해 rosbridge WebSocket을 통해 ROS 또는 ROS2 로봇과 상호작용하는 구조를 보여 준다. 이때 토픽 목록 조회, 서비스 호출, 액션 전송, 센서 구독 등의 결과를 JSON으로 돌려받도록 구성한다4.
LLM 에이전트
↓
CLI 래퍼 또는 스킬 실행기
↓
rosbridge WebSocket
↓
ROS2 로봇이 방식의 장점은 LLM 에이전트와 로봇 시스템을 느슨하게 연결할 수 있다는 점이다. 에이전트는 ROS2 환경을 직접 source하지 않아도 되고, 웹 서버나 데스크톱 앱에서 로봇에 접근할 수 있다. 여러 로봇이나 원격 시뮬레이터를 다룰 때도 편하다.
하지만 조심해야 할 점도 있다. rosbridge를 열어 두면 외부에서 ROS 그래프에 접근할 수 있는 길이 생긴다. 따라서 인증, 네트워크 제한, 명령 허용 목록, 속도 제한, 로그 기록이 필수다. 특히 /cmd_vel 같은 속도 명령 토픽을 외부에서 직접 publish할 수 있게 두는 것은 위험하다. 가능하면 raw 토픽을 열기보다 navigate_to, stop, dock처럼 의미 있는 스킬만 노출해야 한다.
CLI 래퍼를 쓸 때도 마찬가지다. LLM이 임의의 셸 명령을 만들게 해서는 안 된다. 에이전트가 실행할 수 있는 명령 템플릿을 정해 두고, 인자만 검증해서 넣어야 한다. 즉, "LLM이 명령줄을 작성한다"가 아니라 "LLM이 허용된 도구 호출을 선택한다"가 되어야 한다.
연결 방식 3: MCP 서버를 경계 계층으로 두기
최근에는 LLM 애플리케이션과 외부 도구를 연결하기 위해 MCP 같은 표준화된 도구 연결 방식도 쓰인다. ROS2 MCP 서버는 LLM 클라이언트가 구조화된 도구 호출을 보내면, 서버가 이를 ROS2 명령으로 변환하는 구조를 취할 수 있다. 예를 들어 특정 MCP 서버는 move_robot 도구를 제공하고, 이 도구 호출을 ROS2의 /cmd_vel 토픽에 Twist 메시지로 변환해 일정 시간 동안 이동한 뒤 정지하도록 구성한다5.
MCP 지원 LLM 클라이언트
↓
ROS2 MCP 서버
↓
ROS2 노드와 토픽
↓
로봇 실행MCP 서버의 장점은 "도구 경계"가 명확하다는 것이다. LLM 클라이언트는 서버가 공개한 도구만 볼 수 있다. 서버는 각 도구의 입력 스키마를 정의하고, 호출을 검증하고, 결과를 구조화해서 돌려준다. 따라서 앞에서 말한 스킬 명세와 잘 맞는다.
다만 MCP든 rosbridge든, 외부 연결 계층을 만들 때의 원칙은 같다. 이 계층은 단순 통로가 아니라 보안 게이트여야 한다. 인증되지 않은 사용자는 접근하지 못해야 하고, 위험한 명령은 애초에 도구로 공개하지 않아야 하며, 모든 호출은 로그로 남아야 한다.
실전에서는 세 방식을 섞어 쓸 수 있다. 로봇 내부에는 ROS2 스킬 서버를 두고, 같은 네트워크 안의 브리지 노드가 MCP나 HTTP API를 제공한다. LLM 에이전트는 이 브리지에만 접근한다. 브리지는 인증, 권한, 검증, 로그를 담당하고, ROS2 쪽에는 검증된 스킬 호출만 전달한다.
장시간 작업은 액션으로 다룬다
자연어 명령 로봇에서 가장 답답한 순간은 로봇이 오래 움직이는데, LLM도 사용자도 무슨 일이 일어나는지 모르는 경우다. "회의실로 가줘"라고 했는데 30초 동안 아무 반응이 없다면 사용자는 불안해진다. 로봇이 경로를 찾고 있는지, 장애물 때문에 멈췄는지, 이미 도착했는데 보고를 안 하는지 알 수 없다.
ROS2 액션은 이런 문제를 해결하기 좋다. 액션은 목표를 보내고, 실행 중 피드백을 받고, 필요하면 취소하고, 마지막에 결과를 받는 구조다. 로봇 작업 대부분은 액션과 잘 맞는다.
예를 들어 navigate_to 액션은 다음 흐름으로 동작할 수 있다.
1. 목표 전송: 회의실 앞 좌표로 이동
2. 피드백: 현재 위치, 남은 거리, 경로 상태
3. 이벤트: 장애물로 일시 정지 또는 우회 경로 계산
4. 취소 가능: 사용자 정지 명령 또는 안전 모니터 개입
5. 결과 반환: 성공, 실패, 실패 이유, 최종 위치LLM은 매 순간 모든 피드백을 길게 읽을 필요가 없다. 대신 디스패처가 피드백을 요약해서 LLM 컨텍스트에 넣어 주면 된다.
현재 실행 상태:
- skill: navigate_to
- target: meeting_room_front
- progress: 62%
- remaining_distance: 3.4m
- issue: temporary_obstacle_detected
- last_update: 14:32:08이런 상태 요약은 재계획의 재료가 된다. 사용자가 "그럼 왼쪽 복도로 돌아가"라고 말하면 LLM은 현재 실행 중인 navigate_to를 취소하고, 다른 경유지를 넣은 새 계획을 만들 수 있다. 장애물이 오래 지속되면 LLM은 사용자에게 "회의실 앞 경로가 막혀 있습니다. 우회해서 이동할까요?"라고 물을 수 있다.
로봇팔 작업도 마찬가지다. pick 액션이 실행 중일 때 피드백은 단순한 진행률보다 더 의미 있어야 한다.
pick 피드백 예시:
- planning: 물체까지의 접근 경로 계산 중
- approaching: pre-grasp pose로 이동 중
- aligning: 카메라 기반 위치 보정 중
- grasping: 그리퍼 닫는 중
- lifting: 물체를 들어 올리는 중이렇게 단계가 나뉘어 있으면 실패도 더 정확히 해석할 수 있다. planning에서 실패했다면 경로 문제일 가능성이 높다. grasping에서 실패했다면 집기 자세나 물체 위치 문제일 수 있다. lifting에서 실패했다면 물체가 미끄러졌거나 그리퍼 힘이 부족했을 수 있다.
실행 결과는 풍부해야 한다
스킬 서버의 결과는 true 또는 false만으로 끝나면 안 된다. LLM이 다음 판단을 하려면 실패의 원인과 현재 상태를 알아야 한다. 좋은 결과 메시지는 다음 정보를 포함한다.
성공 여부
실패가 발생한 단계
실패 이유 코드
사람이 읽을 수 있는 설명
현재 로봇 상태
관측된 환경 변화
재시도에 도움이 되는 힌트
예를 들어 pick 실패 결과는 다음처럼 만들 수 있다.
{
"success": false,
"skill": "pick",
"stage": "grasping",
"reason_code": "empty_grasp",
"message": "그리퍼를 닫았지만 물체 접촉이 감지되지 않았습니다.",
"robot_state": {
"arm_ready": true,
"gripper_ready": true,
"holding_object": false
},
"observation": {
"object_id": "obj_cup_17",
"object_still_visible": true,
"estimated_shift": {
"x": 0.025,
"y": -0.010,
"z": 0.000
}
},
"suggested_recovery": [
"redetect_object",
"try_side_grasp",
"ask_user_if_object_is_occluded"
]
}이 결과를 받은 LLM은 "다시 pick"만 반복하지 않아도 된다. 먼저 detect_object를 다시 호출해 위치를 갱신할 수 있다. 컵이 옆으로 밀렸다면 새로운 grasp 후보를 선택할 수 있다. 물체가 보이지 않는다면 사용자에게 "빨간 컵이 카메라에서 보이지 않습니다. 컵을 작업 영역 안에 놓아 주시겠어요?"라고 물을 수 있다.
실행 결과가 풍부할수록 LLM의 재계획은 똑똑해진다. 반대로 결과가 빈약하면 LLM은 추측으로 빈칸을 채운다. 로봇에서 추측은 위험하다. 따라서 실패를 잘 설명하는 것은 안전 기능의 일부다.
예제: 빨간 컵을 노트북 오른쪽에 놓기
이제 전체 흐름을 하나의 장면으로 묶어 보자. 작업대 위에 빨간 컵과 노트북이 있다. 사용자가 말한다.
빨간 컵을 집어서 노트북 오른쪽에 놓아줘.LLM은 먼저 고수준 계획을 만든다.
1. 빨간 컵을 찾는다.
2. 노트북을 찾는다.
3. 컵을 집는다.
4. 노트북 오른쪽의 안전한 놓기 위치를 계산한다.
5. 컵을 그 위치에 놓는다.
6. 결과를 확인한다.하지만 이 문장 계획은 바로 실행되지 않는다. LLM은 스킬 명세에 맞춰 구조화 명령을 만든다. 디스패처는 명령을 검증한다. detect_object는 허용된 스킬이다. label과 color 타입도 맞다. pick은 object_id가 필요한데, 이전 단계 결과를 참조하므로 허용된다. place는 reference_object_id와 relation을 사용한다. relation=right_of는 시스템이 지원하는 관계다.
첫 번째 실행에서 detect_object는 빨간 컵의 위치와 object_id를 반환한다.
result step_1:
- success: true
- object_id: obj_cup_17
- pose: base_link 기준 x=0.42, y=0.18, z=0.03
- confidence: 0.91두 번째 실행에서 노트북도 찾는다.
result step_2:
- success: true
- object_id: obj_laptop_03
- pose: base_link 기준 x=0.55, y=-0.05, z=0.02
- confidence: 0.88이제 pick 액션이 시작된다. 디스패처는 액션 서버에 목표를 보내고 피드백을 받는다.
pick feedback:
- planning 20%
- approaching 45%
- grasping 70%
- lifting 95%결과가 성공이면 place 목표를 계산한다. "노트북 오른쪽"은 자연어 표현이다. 로봇은 이를 좌표로 바꿔야 한다. 예를 들어 작업대 좌표계에서 오른쪽을 +y 방향으로 정의했다면, 노트북 중심에서 y 방향으로 18cm 떨어진 지점을 놓기 후보로 만든다. 그 지점이 작업 영역 안에 있고, 충돌 물체가 없고, 로봇팔이 도달 가능하면 place 액션을 호출한다.
마지막으로 check_status나 시각 검증 스킬을 호출해 컵이 실제로 놓였는지 확인한다. 성공하면 사용자에게 이렇게 답한다.
빨간 컵을 노트북 오른쪽에 놓았습니다.만약 place에서 실패했다면 답변은 달라져야 한다.
노트북 오른쪽 공간이 막혀 있어 컵을 놓지 못했습니다. 노트북의 오른쪽 앞쪽 빈 공간에 놓아도 될까요?이 차이가 AI 로봇 에이전트의 품질을 만든다. 성공했을 때만 말하는 로봇은 데모용 로봇이다. 실패 이유를 이해하고 다음 선택지를 제안하는 로봇은 에이전트에 가까워진다.
중간 계층은 안전 경계이기도 하다
LLM과 ROS2를 연결하는 계층은 편의 기능이 아니다. 안전 경계다. 여기서 어떤 명령을 허용하고 어떤 명령을 막을지 결정한다. 다음 장에서 안전 설계를 더 깊게 다루겠지만, 이 장의 구현에서도 최소한의 원칙은 지켜야 한다.
첫째, 허용 목록 방식으로 설계한다. LLM이 호출할 수 있는 스킬을 명시적으로 등록하고, 목록에 없는 스킬은 모두 거절한다. "기본 허용"이 아니라 "기본 거절"이어야 한다.
둘째, raw 제어 토픽을 직접 열지 않는다. /cmd_vel이나 관절 명령 토픽은 강력하다. 실험에서는 편리하지만, LLM이 직접 속도 값을 만들게 하면 위험하다. 이동이 필요하면 navigate_to나 move_relative_safe처럼 제한된 스킬을 통해 실행한다.
셋째, 범위를 제한한다. 좌표는 작업 공간 안에 있어야 하고, 속도와 가속도는 한계 안에 있어야 하며, 그리퍼 힘도 물체와 환경에 맞게 제한해야 한다. 제한은 프롬프트가 아니라 코드로 검사해야 한다.
넷째, 정지 명령은 LLM보다 빨라야 한다. 사용자가 "멈춰!"라고 하거나 안전 센서가 위험을 감지하면, LLM의 판단을 기다리지 않고 안전 노드가 즉시 정지해야 한다. 그다음 정지 결과를 LLM에게 알려 주면 된다.
다섯째, 모든 실행은 로그로 남긴다. 누가 어떤 명령을 내렸고, LLM이 어떤 계획을 만들었고, 어떤 스킬이 호출되었고, 결과가 어땠는지 기록해야 한다. 로그는 디버깅 도구이자 안전 감사 기록이다.
구현을 시작하는 최소 구조
처음부터 거대한 에이전트 시스템을 만들 필요는 없다. 다음과 같은 최소 구조로 시작하면 충분하다.
ai_robot_agent/
skill_specs/
manipulation.yaml
perception.yaml
agent_bridge/
llm_client.py
plan_parser.py
validator.py
dispatcher_node.py
skill_servers/
detect_object_server.py
pick_action_server.py
place_action_server.py
check_status_server.py
interfaces/
action/
Pick.action
Place.action
srv/
DetectObject.srv
CheckStatus.srv
logs/먼저 check_status부터 만든다. 이 스킬은 로봇을 움직이지 않으므로 안전하게 테스트할 수 있다. 그다음 detect_object를 붙인다. 카메라 결과가 구조화되어 나오는지 확인한다. 이후 pick과 place를 액션으로 만든다. 마지막에 LLM 계획 노드를 연결한다.
개발 순서는 다음이 좋다.
LLM 없이 스킬 서버를 직접 호출해 테스트한다.
정해진 JSON 계획 파일을 디스패처에 넣어 실행한다.
LLM이 JSON 계획을 생성하게 하되, 실행 전 사람이 확인한다.
검증과 로그가 안정되면 자동 실행을 허용한다.
실패 결과를 LLM에 돌려주고 재계획을 붙인다.
이 순서를 지키면 문제를 나눠서 잡을 수 있다. 스킬 서버가 문제인지, 디스패처가 문제인지, LLM 계획이 문제인지 구분할 수 있다. 많은 로봇 프로젝트가 어려워지는 이유는 모든 것을 한 번에 붙이기 때문이다. 자연어, 비전, 좌표 변환, 경로 계획, 로봇팔 실행, LLM 재계획을 한꺼번에 디버깅하면 작은 오류 하나도 찾기 어렵다.
LLM 컨텍스트에는 무엇을 넣어야 할까
LLM은 현재 로봇 상태를 모르면 좋은 계획을 만들 수 없다. 하지만 모든 센서 데이터를 그대로 넣을 수도 없다. 카메라 이미지, 라이다 스캔, TF 트리, 액션 로그를 전부 프롬프트에 넣으면 길고 비싸며 오히려 혼란스럽다.
따라서 디스패처는 ROS2 세계를 LLM이 이해할 수 있는 요약으로 바꿔야 한다.
현재 로봇 상태 요약:
- arm_ready: true
- gripper_state: open
- holding_object: false
- camera_ready: true
- workspace: table_zone
- detected_objects:
- obj_cup_17: red cup, confidence 0.91
- obj_laptop_03: laptop, confidence 0.88
- safety_state: normal
- last_failed_skill: none이 요약은 계획 전에 제공할 수도 있고, 각 스킬 실행 후 갱신할 수도 있다. 중요한 것은 LLM에게 "실행 가능한 현실"을 알려 주는 것이다. LLM은 상식적으로 컵을 집기 전에 컵을 찾아야 한다고 알 수 있지만, 지금 카메라가 꺼져 있는지, 로봇팔이 에러 상태인지, 이미 물체를 들고 있는지는 알 수 없다. 이런 정보는 ROS2에서 가져와야 한다.
컨텍스트는 짧고 구체적이어야 한다. "주변에 여러 물체가 있음"보다 "빨간 컵 obj_cup_17이 x=0.42, y=0.18에 있음"이 낫다. "로봇팔이 이상함"보다 "arm_ready=false, reason=protective_stop"이 낫다. LLM은 모호한 문장을 잘 포장할 수 있지만, 로봇 실행에는 명확한 상태가 필요하다.
좋은 연결 구조의 기준
LLM과 ROS2가 잘 연결된 시스템은 겉으로 보기에는 단순하다. 사용자가 말하면 로봇이 움직이고, 문제가 생기면 설명한다. 하지만 내부에는 몇 가지 기준이 숨어 있다.
첫째, 계획과 실행이 분리되어 있다. LLM은 고수준 단계와 도구 선택을 담당하고, ROS2는 센서와 액추에이터 실행을 담당한다.
둘째, 모든 명령은 구조화되어 있다. 자연어 문장을 그대로 실행하지 않고, JSON이나 함수 호출 형태로 변환한 뒤 검증한다.
셋째, 스킬은 명세를 가진다. 이름만 있는 함수가 아니라 입력, 출력, 선행 조건, 안전 제한, 실패 이유를 포함한다.
넷째, 장시간 작업은 액션으로 처리한다. 피드백, 취소, 결과 반환이 가능해야 한다.
다섯째, 실행 결과가 다시 LLM으로 돌아간다. 성공 여부뿐 아니라 실패 원인과 현재 상태가 돌아가야 재계획이 가능하다.
여섯째, 연결 계층이 보안과 로그의 경계가 된다. 로컬 ROS2 노드, rosbridge, CLI 래퍼, MCP 서버 중 어떤 방식을 쓰더라도 인증, 권한 제한, 명령 허용 목록, 감사 로그가 필요하다.
이 기준을 만족하면 자연어 명령 로봇은 훨씬 다루기 쉬워진다. LLM이 모든 것을 해결해 주는 마법 상자가 아니라, ROS2 위에서 동작하는 계획자로 자리 잡는다. ROS2는 검증된 로봇 실행 계층으로 남고, LLM은 사용자의 의도를 실행 가능한 순서로 바꾸는 두뇌 역할을 한다.
다음 장에서는 이 구조 위에서 가장 중요한 문제를 다룬다. 바로 실패 감지와 재계획이다. 물체가 보이지 않을 때, 경로가 막혔을 때, 로봇팔이 집기에 실패했을 때, 에이전트는 어떻게 같은 실수를 반복하지 않고 다른 선택을 할 수 있을까? 이제 로봇은 단순히 명령을 수행하는 단계를 넘어, 실패를 이해하고 다시 시도하는 단계로 들어간다.
참고
1ROS-LLM: A ROS framework for embodied AI with task feedback and structured reasoning
2Enabling Novel Mission Operations and Interactions with ROSA: The Robot Operating System Agent
3GitHub - MengyangGao/LLM-Robot: Use LLM to understand user input and control the robot under ROS
4GitHub - lpigeon/ros-skill: Agent Skill for ROS/ROS2 robot control via rosbridge WebSocket
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.