아들과 함께 만들어보는 인공지능(LLM) 로봇 만들기 프로젝트 — EP 6. AI 로봇과 로컬 LLM 서버 LAN 통신 연결
아들과 함께 만들어보는 인공지능(LLM) 로봇 만들기 프로젝트 — EP 6. AI 로봇과 로컬 LLM 서버 LAN 통신 연결
로봇과 LLM 서버를 연결하는 방법을 결정해야 했다.
지금까지는 로봇 본체에 코드를 올려서 단독으로 동작하는 것만 했다. HC-SR04로 거리 재고, 모터 제어하는 건 아두이노 혼자서 할 수 있다. 근데 이 프로젝트의 목적은 LLM이 판단하는 로봇이다. 카메라 프레임과 센서 데이터를 LLM 서버로 보내고, LLM이 명령을 내려서 로봇이 움직이는 구조가 필요하다.
로봇(엣지) ↔ LLM 서버(Mac)를 어떻게 연결할지가 이번 편의 주제다.
세 가지 선택지
WebSocket: 양방향 실시간 통신. 구현이 간단하고 HTTP 기반이라 방화벽 문제가 적다. 로봇에서 데이터를 보내고 서버에서 명령을 스트리밍으로 받는 구조에 적합하다.
gRPC: Google이 만든 RPC 프레임워크. Protocol Buffers로 데이터를 직렬화해서 WebSocket보다 페이로드가 작다. 타입 안전성이 있고 스트리밍 지원도 된다. 근데 설정이 복잡하다. 클라이언트와 서버 양쪽에 Protobuf 스키마를 관리해야 한다.
ROS2 over LAN: 로봇 전용 미들웨어. DDS(Data Distribution Service) 기반으로 토픽 pub/sub 구조를 쓴다. 로봇 프레임워크에 native하게 붙는다. 근데 로봇 본체가 아두이노인 동안은 직접 쓰기 어렵다. Pi로 전환 이후에 의미 있다.
지금 단계에서 아두이노가 엣지 디바이스다. 아두이노는 HTTP 클라이언트를 올리기 어렵다. WiFi 쉴드를 붙이거나 시리얼-WiFi 브릿지를 쓰는 방법이 있는데, 복잡도가 올라간다.
그래서 현재 구조를 다르게 잡았다.
실제 선택한 구조
아두이노는 USB 시리얼로 노트북과 연결한다. 노트북(얇은 Python 브릿지 스크립트)이 시리얼로 아두이노 센서 데이터를 읽고, WebSocket으로 Mac LLM 서버에 보낸다. LLM 서버가 명령을 내려주면 브릿지가 시리얼로 아두이노에 전달한다.
[Arduino] ←시리얼→ [Python 브릿지] ←WebSocket/LAN→ [Mac LLM 서버]
아두이노가 직접 WiFi를 쓰지 않아도 된다. 브릿지 역할을 하는 노트북이 네트워크를 담당한다. 나중에 Pi로 전환하면 Pi가 브릿지 역할까지 합쳐서 할 수 있다.
브릿지 코드
Python 브릿지는 70줄 정도다.
import asyncio
import json
import serial
import websockets
SERIAL_PORT = '/dev/cu.usbmodem14201'
BAUD_RATE = 9600
LLM_SERVER = 'ws://192.168.1.100:8765'
async def bridge():
ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=1)
async with websockets.connect(LLM_SERVER) as ws:
print(f"LLM 서버 연결됨: {LLM_SERVER}")
async def read_serial():
while True:
line = await asyncio.get_event_loop().run_in_executor(
None, ser.readline
)
if line:
data = line.decode('utf-8').strip()
# 형식: "dist:23,cam:1"
await ws.send(json.dumps({"sensor": data}))
async def read_commands():
async for message in ws:
cmd = json.loads(message)
# 형식: {"action": "forward", "speed": 150}
command_str = f"{cmd['action']},{cmd.get('speed', 0)}\n"
ser.write(command_str.encode())
await asyncio.gather(read_serial(), read_commands())
asyncio.run(bridge())
LLM 서버 쪽은 llama.cpp 서버가 WebSocket을 바로 지원하지 않아서 별도 래퍼 서버를 올렸다. FastAPI로 WebSocket 엔드포인트를 열고, 센서 데이터가 들어오면 llama.cpp /completion API를 호출해서 명령을 받아 로봇에 내려준다.
from fastapi import FastAPI, WebSocket
import httpx
import json
app = FastAPI()
LLAMA_URL = "http://localhost:8080/completion"
SYSTEM_PROMPT = """
당신은 로봇 제어 AI입니다. 센서 데이터를 받으면 다음 중 하나만 JSON으로 반환하세요:
{"action": "forward", "speed": 150}
{"action": "backward", "speed": 100}
{"action": "left", "speed": 120}
{"action": "right", "speed": 120}
{"action": "stop", "speed": 0}
장애물이 20cm 이하면 반드시 stop을 반환하세요.
"""
@app.websocket("/robot")
async def robot_ws(websocket: WebSocket):
await websocket.accept()
async for data in websocket.iter_text():
sensor = json.loads(data)
prompt = f"센서 데이터: {sensor['sensor']}"
async with httpx.AsyncClient() as client:
resp = await client.post(LLAMA_URL, json={
"prompt": f"{SYSTEM_PROMPT}\n\n{prompt}",
"max_tokens": 50,
"temperature": 0.1
})
result = resp.json()
command = result['content'].strip()
await websocket.send_text(command)
RTT 실측
LAN 안에서 얼마나 빠른지 측정해봤다.
측정 방법: 브릿지에서 센서 데이터를 LLM 서버에 보내고, 명령이 돌아오기까지의 시간.
| 경로 | 평균 RTT | 최대 RTT |
|---|---|---|
| 브릿지 → LLM 서버 (네트워크만) | 1.2ms | 4.8ms |
| LLM 추론 포함 전체 | 430ms | 680ms |
| 시리얼 왕복 포함 전체 | 445ms | 700ms |
LAN 네트워크 자체는 1~5ms다. 시간을 잡아먹는 건 LLM 추론이다. Qwen2.5-7B로 짧은 명령을 생성하는 데 400~650ms 걸린다. M4 Pro 기준이다.
클라우드 API를 썼다면 네트워크 RTT만 80~200ms가 추가된다. 로컬 LAN과 클라우드의 차이가 여기서 나온다. LLM 추론 자체가 병목이기 때문에 네트워크를 LAN으로 유지하면 그 부분은 거의 사라진다.
0.5초 지연이 많은 건지 적은 건지는 쓰임새에 따라 다르다. 지금 단계의 로봇은 느리게 움직이니까 0.5초는 충분히 수용 가능하다. 빠른 반응이 필요한 회피 동작은 아두이노가 독립적으로 처리하게 해뒀다(장애물 15cm 이하면 즉시 정지는 Arduino 레벨에서 처리). LLM은 고차원 판단만 담당한다.
실제로 연결됐을 때
처음으로 전체 파이프라인이 연결됐을 때를 기억한다.
브릿지를 켜고, LLM 서버를 켜고, 아두이노를 연결했다. 터미널에 로그가 찍히기 시작했다.
LLM 서버 연결됨: ws://192.168.1.100:8765
센서: dist:45,cam:0
LLM 응답: {"action": "forward", "speed": 150}
명령 전송: forward,150
센서: dist:38,cam:0
LLM 응답: {"action": "forward", "speed": 150}
센서: dist:22,cam:0
LLM 응답: {"action": "forward", "speed": 100}
센서: dist:17,cam:0
LLM 응답: {"action": "stop", "speed": 0}
명령 전송: stop,0
로봇이 앞으로 가다가 거리가 줄어들수록 속도를 낮추고, 17cm에서 멈췄다.
LLM이 판단했다. 아두이노 코드에 멈추는 로직을 하드코딩한 게 아니라, LLM이 센서 데이터를 보고 "stop"을 내린 것이다.
아들이 그 로그를 옆에서 봤다. "AI가 읽고 있는 거야?" 맞다.
아직 불안정한 것들
연결은 됐지만 안정적인 건 아니다.
LLM 응답이 간혹 JSON이 아닌 텍스트로 나온다. "장애물이 감지되었습니다. 정지하는 것이 좋겠습니다."처럼. 파싱이 실패해서 명령이 전달되지 않는다. 시스템 프롬프트를 강화해도 완전히 없어지지 않았다. 낮은 temperature(0.1)를 써도 가끔 나온다.
임시로 응답에서 JSON을 정규식으로 추출하는 fallback을 추가했다.
다른 문제는 브릿지-서버 연결이 끊어지면 로봇이 멈추지 않고 마지막 명령을 계속 실행한다. 안전 장치가 필요하다. 브릿지 heartbeat를 구현해서 일정 시간 명령이 없으면 아두이노가 자동으로 멈추게 해야 한다. 이건 다음 단계다.
Pi로 전환하면 이 브릿지가 Pi 안으로 들어간다. 별도 노트북이 필요 없어진다. 그때 구조가 좀 더 깔끔해질 것이다.
댓글
댓글 쓰기