
【LLM 구조화 출력】 라즈베리 파이 자작 로봇을 자연어 대화로 제어해 보았다
요약
Gemini API의 Structured Outputs 기능을 활용하여 라즈베리 파이 기반 자작 로봇을 자연어로 제어하는 방법을 소개합니다. LLM 출력의 불확실성으로 인한 파싱 에러 문제를 해결하고, 스키마 보증을 통해 안정적인 로봇 제어 아키텍처를 구현하는 과정을 다룹니다.
핵심 포인트
- LLM의 불확실한 출력이 하드웨어 제어 시 파싱 에러를 유발함을 설명
- Gemini API의 Structured Outputs를 통한 타입 보증 및 스키마 준수 방법
- 자연어 지시를 안정적인 JSON 명령어로 변환하여 로봇에 전달하는 구조
- Rust를 활용한 안전한 자율 정지 조건 실행 아키텍처
이 기사를 작성하게 된 계기는 취미로 하는 라즈베리 파이(Raspberry Pi) 제작입니다.
Raspberry Pi와 PiCar-X 키트를 사용하여 자작 로봇을 만들고 한동안 움직이다 보니, '이왕이면 키보드 조작이 아니라 자연어로 지시를 내려 자율적으로 움직이게 하면 재미있지 않을까?'라는 생각이 들었습니다.
막상 구현하려고 조사하기 시작하니, LLM의 출력 포맷을 프로그램에서 올바르게 읽어 들여 안정적으로 로봇을 움직이는 것의 어려움에 부딪혔습니다. 그래서 더 조사를 진행한 결과, Gemini API의 **「Structured Outputs (구조화 출력)」**를 사용하면 의도한 스키마(Schema)대로 출력을 타입 보증(Type Guarantee)하여 프로그램에 매끄럽게 통합할 수 있다는 것을 알게 되어 시도해 보았습니다.
결과적으로 매우 안정적인 로봇 제어를 실현할 수 있었기에, 그 지식을 정리했습니다.
- LLM의 응답을 프로그램으로 읽어 들일 때 발생하는 에러로 고민하고 계신 분
- 「Structured Outputs (구조화 출력)」의 구체적인 활용 사례를 알고 싶은 분
- 라즈베리 파이나 로봇 키트를 AI로 움직이는 것에 관심이 있는 분
"2초 전진하고, 장애물이 있으면 멈춰"
이러한 자연어 지시로부터 라즈베리 파이 카(PiCar-X) 등의 로봇을 원하는 대로 제어할 수 있다면 재미있겠지요.
하지만 물리적인 하드웨어를 LLM으로 제어하려고 할 때, 피할 수 없는 큰 장벽이 있습니다. 그것은 바로 **「LLM 출력의 불확실성 (파싱 에러)」**입니다.
일반적인 LLM 용도(채팅, 요약, 이메일 초안 작성 등)라면, 출력되는 텍스트에 다소 표기 흔들림이나 불필요한 인사말, 포맷의 붕괴가 있더라도 그것을 읽는 사람이 유연하게 해석할 수 있기 때문에 문제가 되지 않습니다.
하지만 이번 「로봇 제어」와 같이, LLM의 출력을 프로그램이 직접 읽어 들여 기계를 움직이는 용도에서는 이야기가 달라집니다. 단 한 글자의 파싱 에러나, 키(Key) 이름의 미세한 차이(예를 들어 steer와 steering의 차이 등)로 인해 에러가 발생하여, 로봇이 원하는 대로 움직이지 않는 문제가 생깁니다.
불확실한 LLM의 출력과, 원하는 대로 올바르게 제어하고 싶은 물리 로봇. 이 두 가지를 잘 연결하기 위해, 본 프로젝트에서는 **Gemini API의 구조화 출력 (Structured Outputs)**을 활용했습니다.
본 기사에서는 이 안전한 로봇 제어를 실현하기 위해 고안한 다음의 포인트들에 대해 순서대로 해설합니다.
- 구조화 출력이 없으면 LLM의 응답이 어떻게 파탄되어 로봇에 위험을 초래하는가
- Gemini API의 Structured Outputs를 사용하여 API 레벨에서 출력 스키마를 100% 보증하는 방법
- 복잡한 「자율 정지 조건 (until 조건)」을 LLM이 구성하게 하고, 이를 Rust 측에서 안전하게 실행하는 아키텍처
일반적인 LLM(JSON 모드나 특별한 스키마 제어를 적용하지 않은 상태)에 대해, "로봇 제어 지시를 JSON 형식으로만 출력해 주세요"라고 프롬프트로 지시한다고 가정해 봅시다.
그때 AI로부터 돌아오는 응답에는 다음과 같은 「익숙한 문제」가 다발합니다.
기대하는 출력 (프로그램이 그대로 읽을 수 있는 순수한 JSON):
{"commands": [{"action": "forward", "speed": 50, "duration_ms": 2000}]}
실제로 돌아오는 출력 (LLM이 친절을 베푼 결과):
알겠습니다! 다음 명령을 실행합니다:
```json
{"commands": [{"action": "forward", "speed": 50, "duration_ms": 2000}]}
위 내용을 실행해 주세요. 궁금한 점이 있으신가요?
프로그램이 받는 것은 문자열 전체이기 때문에, JSON 전후에 있는 "알겠습니다!", "위 내용을 실행해 주세요."와 같은 자연어 문장이나, 코드 블록을 나타내는 ` ```json `과 같은 기호가 그대로 혼입되어 버립니다. 이것들을 정규 표현식(Regular Expression) 등으로 제거하려고 해도, AI의 응답 방식은 매번 미묘하게 달라지기 때문에 예외 처리가 끝도 없이 늘어나 코드가 지저분해지는 원인이 됩니다.
---
## 복잡한 조건 분기(Nest)에서의 "키 이름(Key Name)" 흔들림
본 프로젝트의 가장 큰 공을 들인 부분은, 단순히 "전진해", "멈춰"뿐만 아니라, "초음파 센서의 거리가 15cm 미만이 될 때까지 전진해"와 같은 **"센서 연동 자율 정지(until 조건)"**를 LLM이 생각하도록 하는 점입니다.
...
```json
{
"commands": [
{
"action": "forward",
"speed": 40,
"duration_ms": 15000,
"until": {
"sensor": "distance",
"op": "lt",
"value": 15
}
}
]
}
: 로봇이 액션(Action)을 중단하는 조건(조건식)을 기술합니다. until
...
사용자의 자연어 입력
↓
┌──────────────────┐
│ GeminiAgent │ ← Gemini API와 통신. 프롬프트(Prompt)를 보내고, 구조화된 JSON을 받음
└──────────────────┘
↓(Command 리스트)
┌──────────────────┐
│ CommandQueue │ ← 받은 커맨드를 순서대로 로봇에게 실행시키는 큐(Queue)
└──────────────────┘
↓(모터·서보로의 지시)
┌──────────────────┐
│ PiCar-X │ ← 실제 로봇 본체 (Raspberry Pi + PiCar-X)
└──────────────────┘
↑(센서 값을 피드백)
GeminiAgent: 사용자의 자연어 입력을 Gemini API로 전송하고, 구조화된 커맨드 리스트(JSON)를 받습니다. API 통신은 전용 백그라운드 스레드(Background Thread)에서 수행되므로, 로봇의 제어 루프를 블록(Block)하지 않습니다. -
...
let response_schema = serde_json::json!({"type": "OBJECT",
"properties": {
"commands": {
"type": "ARRAY",
"description": "List of robot commands to execute in sequence.",
"items": {
"type": "OBJECT",
"properties": {
"action": {
"type": "STRING",
"enum": [
"forward",
"backward",
"steer",
"stop",
"set_cam_pan",
"set_cam_tilt",
"get_distance",
"get_grayscale",
"get_battery_status"
]
},
"speed": {
"type": "INTEGER",
"minimum": 0,
"maximum": 60
},
"duration_ms": {
"type": "INTEGER",
"minimum": 100,
"maximum": 30000
},
"angle": {
"type": "INTEGER",
"description": "Streering angle in degrees.
이동 중 방향을 전환하는 데 사용될 수 있습니다.
"최소값": -90, "최대값": 90
}
},
"required": ["action", "duration_ms"]
}
}
},
"required": ["commands"]
});
let request_body = serde_json::json!({
"contents": [{
"parts": [{
"text": prompt
}]
}],
"systemInstruction": {
"parts": [{
"text": SYSTEM_PROMPT
}]
},
"generationConfig": {
"temperature": 0,
"maxOutputTokens": 1024,
"responseMimeType": "application/json",
"responseSchema": response_schema
}
});
JSON Schema 정의: response_schema
...
pub struct GeminiAgent {
prompt_tx: Sender<String>,
response_rx: Receiver<LlmResponse>,
}
impl GeminiAgent {
pub fn new() -> Self {
let api_key = std::env::var("GEMINI_API_KEY").unwrap_or_default();
let (prompt_tx, prompt_rx) = mpsc::channel::<String>();
let (response_tx, response_rx) = mpsc::channel::<LlmResponse>();
// HTTP 통신을 수행하는 백그라운드(전용) 스레드를 시작
thread::spawn(move || {
for prompt in prompt_rx {
let result = Self::call_gemini(&api_key, &prompt);
if response_tx.send(result).is_err() {
break;
}
}
});
Self { prompt_tx, response_rx }
}
/// 메인 스레드에서 프롬프트를 전송 (비차단)
pub fn submit_prompt(&self, prompt: String) {
let _ = self.prompt_tx.send(prompt);
}
/// 메인 스레드에서 응답을 가져오기 시도 (비차단)
pub fn try_recv_response(&self) -> Option<LlmResponse> {
self.response_rx.try_recv().ok()
}
}
thread::spawn
...
{
"action": "forward",
"speed": 40,
"duration_ms": 15000,
"until": {
"sensor": "distance",
"op": "lt",
"value": 15
}
}
: 조건이 충족되지 않을 경우를 대비하여, 15초(15000ms)라는 긴 '안전용 타임아웃 시간'을 확보하고 있습니다. duration_ms
: 초음파 센서 (until
distance
)의 값이 15 (cm) 미만 (lt
)이 되는 순간, duration_ms
의 경과를 기다리지 않고 동작을 종료합니다.
상세 코드는 생략하지만 LLM이 생성한 until
조건은 AND / OR / NOT / XOR 등의 논리 연산을 조합한 재귀적 조건식으로 정의함으로써, 아무리 복잡한 중첩 구조라도 Rust 측에서 그대로 해석할 수 있습니다.
명령어의 실행은 thread::sleep
에 의한 블로킹 대기가 아니라, 메인 루프에서 매 프레임 호출되는 스테이트 머신 방식으로 처리됩니다. Instant::now()
의 차이(difference)로 경과 시간을 측정하며, '지정 시간이 지났는지', '센서 조건이 성립했는지' 두 가지 조건으로 동작을 전환합니다. 이를 통해 슬립(sleep) 중에 센서가 멈추는 문제를 피하면서도, 실시간으로 until
조건을 모니터링할 수 있습니다.
또한, 전진·후퇴 명령이 종료되는 타이밍에 모터를 자동으로 정지시키고, 꺾인 스티어링(steering) 각도를 똑바로 되돌리는 안전 처리도 포함되어 있습니다.
본 프로젝트에서는 불확실한 LLM의 출력과 엄격한 제어가 요구되는 물리 로봇(PiCar-X)을 연결하기 위해, Gemini API의 Structured Outputs (구조화 출력) 를 채택했습니다.
이를 통해 다음과 같은 이점을 얻을 수 있었습니다.
-
오류 없는 읽기 - API 측에서 출력 JSON의 스키마를 100% 보장해주기 때문에, Rust의
serde_json을 통한 역직렬화(deserialization) 실패로 인한 동작 오류나 프리징(freezing)을 거의 제로로 만들 수 있었습니다. -
복잡한 자율 로직의 구현 - LLM이 "장애물까지의 거리가 15cm 미만"과 같은 동적인 센서 판정 조건(
until)을 JSON Schema 제약 하에 올바르게 구성하게 하고, 이를 Rust 측의 비차단(non-blocking) 방식인CommandQueue::tick에서 실시간으로 모니터링 및 실행하는 고도의 메커니즘을 안전하게 구축할 수 있었습니다. -
불필요하고 번거로운 코드의 박멸 - 마크다운(Markdown) 표기법 제거이나 키(key) 이름의 미세한 흔들림(표기 불일치) 등을 보정하기 위한 복잡한 정규 표현식이나 문자열 조작 코드가 일절 필요 없게 되었습니다.
물리 로봇뿐만 아니라 스마트 홈 제어나 사내 시스템의 API 연동 등, "AI의 출력을 바탕으로 프로그램을 동작시키는" 모든 용도에서 구조화 출력은 이제 필수적인 기술이라고 할 수 있습니다.
현재는 로봇의 "이동(전진, 후퇴, 선회 등)"에 관한 지시와 제어가 중심이 되고 있습니다.
향후에는 로봇이 할 수 있는 일을 더욱 확장하기 위해, 카메라 방향을 바꾸는 지시(헤드 스윙)나 초음파·그레이스케일(grayscale) 센서로부터의 정보 취득(거리나 백선 검지), 배터리 상태 확인 등 이동 이외의 기능에 대해서도 확장성을 갖추어, 더욱 복잡하고 자율적인 대화 제어가 가능하도록 기능을 넓혀가고 싶습니다.
라즈베리 파이나 로봇 키트에 관심이 있다면, 꼭 구매해서 즐겨보시기 바랍니다!
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기