LLM으로 LED 제어하기
2026.06.15
그거 아십니까. LLM으로 LED 제어가 정말 잘 됩니다. 단순히 껐다 켰다 하는 수준이 아니라, 굉장히 엄청나게 다양한 걸 시킬 수 있어요. 이 글은 제가 그 과정에서 거친 시행착오와, 최종적으로 도달한 꽤 마음에 드는 구조에 대한 이야기입니다.
다음과 같은 구성으로 작성되었습니다.
- 아주 간단하게 LED 제어하기
- 좋지 않은 방식으로 LED 제어하기
- 멋지게 LED 제어하기
아주 간단하게 LED 제어하기
처음 만들고 싶었던 건 별게 아니었어요. LLM이 화가 났을 때 빨간색 LED를 켜서, 〈2001 스페이스 오디세이〉의 HAL 9000이나 그런 작품들에 나오는 omni eye 같은 분위기를 연출하고 싶었습니다.
그래서 저는 ESP32 하나에 동그란 LED 링(WS2812 12개) 하나를 붙였습니다. 굳이 원형 LED를 고른 이유는 앞서 말했듯 "눈"을 연출하고 싶었기 때문이에요. 이건 제 첫번째 버전인데, 대충 상자를 케이스로 삼았습니다. 모티브는 놀랍게도, HAL 9000입니다.
이제 이걸 LLM에 연결합니다.
LLM과 LED를 연결하기.
LLM과 LED를 연결하는 것은, 두 가지 과정으로 나눠서 생각해야합니다. 제 생각에 어렵게 느껴지는데 사실 별거 아닌 부분이 있고, 별거 아니라고 생각했는데 어려운 부분이 있습니다.
첫번째 부분: LLM에 LED를 연결하는 부분입니다.
이 부분도... 다시 두 부분으로 나누어지는데요
우선 어떻게 컴퓨터랑 led를 연결하느냐... 에 대한 부분은 esp32는 아주 저렴하면서 wifi를 지원합니다.덕분에 이걸 활용했습니다. 컴퓨터에서 어떻게 다시 llm으로 연결되느냐.. 에 대한 부분은 mcp를 사용했습니다.
혹시 MCP를 모르시는 분을 위해 아주 간단히 설명하자면(많은게 생략되었습니다), 이름/설명/인자 이렇게 세개로 이루어져있습니다. 이름은 말그대로 이름이고요, light_control이라고 지어두면 llm이 아 얘는 불을 컨트롤 하는 도구구나 하고 눈치 채겠죠? 설명은 역시 말그대로 설명입니다. 자 인자에 on을 쓰면 불이 켜지고 off를 넣으면 불이 꺼져 이렇게 설명해주는 거죠 인자는 이제 llm이 작성하는 부분입니다. 사용자가 불꺼 라는 명령을 하면, 설명읽고 인자에 off를 넣어서 실행합니다.
정확히는 이런느낌입니다.
네 불을 끌게요. [light_control('off')] 이제 [ ] 안에 든거는 특별하게 작동해서 이 포맷으로 통신이 되는거죠
그러면 이제 esp32에서는 off가 저 통신라인으로 들어오면 꺼두게 하면 됩니다.
이제 여기서 어려운 두번째 부분이 나옵니다.
두번째 부분: 그래서 뭘 어떻게 제어하게 함? 이 글은 대부분 이 부분에 대해 고민한 부분들입니다.
on이면 led가 켜지고... off면 led가 꺼지게 한다. 이 정도면 아주 쉽습니다. 빨간불, 파란불, 초록불을 나오게? 이것도 쉬워요. 그냥 on off 대신 설명에 red/blue/green을 넣으면 됩니다.
결과적으로, llm을 일종의 분류기로 사용하는거죠.
저의 첫번쨰 버전도 이것과 완전히 동일했습니다만, 이제 llm입장을 속이기 위해 약간의 트릭을 사용했습니다.. 아래 인터렉션을 통해 제 첫번째 버전을 체험해보실 수 있습니다. 아주 간단해요.
실제 LED 링의 빛 움직임.
트릭이란, 인자에 입력 가능한 텍스트로 angry, annoy, neutral 이렇게 셋을 작성하도록 한 겁니다. 이름도 set_emotion으로 짓고요. 이 중 하나를 적으면 이렇게 됩니다.
- Angry: LED가 빨간색으로
- Annoy: LED가 노란색으로
- Neutral: LED가 초록색으로
그래서 이제 어떠한 llm이 화날 만한 상황이 오면 set_emotion('angry')라고 실행하게 하는거죠. 이 부분은 뭐 프롬프트 레벨에서는 간단한 트릭입니다. 사실 이 분이 꽤나 재미있는 부분이긴 합니다. tool description이 실제로 그 행동을 하도록 강제하는 것은 아니기 때문에, 이 특성으로 다양한 가능성과 위험성이 발생합니다. 이 부분에 대한 이야기는 다음에 llm spanker에 대한 글을 작성할 때 진행하겠습니다.
어쨋거나, 결과적으로 이건 3색 분류기입니다. 하지만, 나이브하게 생각하면, 훨씬 더 많은 색, 심지어는 움직이는 패턴도 추가할 수 있습니다. 패턴 하나를 만들고, 그 레이블을 LLM에 소개만 시켜주면 되니까요.
다만 이러한 접근에는 내제된 한계가 존재합니다. 패턴이 몇개 안되는 경우에는, 사실 큰 문제는 없습니다. 그 LED 자체가 재미없고 심심하다는거 말고요. 하지만 패턴이 많아지면? 이제 LLM 프롬프트에 매핑 테이블을 잔뜩 넣어줘야 합니다. 색이 수십 개로 늘어나면 그만큼 길어져요. MCU의 작은 메모리 안에서도, 그만큼의 패턴을 기억해야하고요.
좋지 않은 방식으로 LED 제어하기
곰곰이 생각해보니, ChatGPT든 Claude든 LED 코딩을 정말 잘합니다. 그러니까, 위에 있는 눈 깜빡이는 패턴도 사실 CHATGPT가 만들어준거거든요. 그럼 아예 실시간으로. LLM이 LED를 제어하는 코드를 통째로 실시간으로 짜게 시키면 되는 거 아닌가? 하는 생각이 들었어요.
예를 들면 "무지개 느낌으로 한 바퀴 돌게 해줘" 혹은 "comet처럼 꼬리를 남기며 돌게 해줘" 정도만 말해도, 대충 이런 코드는 금방 나옵니다.
무지개 회전이라면 이런 식이고요.
#define NUM_LEDS 12
CRGB leds[NUM_LEDS];
void rainbowSpin() {
uint8_t baseHue = millis() / 12;
for (uint8_t i = 0; i < NUM_LEDS; i++) {
leds[i] = CHSV(baseHue + i * 255 / NUM_LEDS, 255, 180);
}
FastLED.show();
}comet 꼬리라면 이런 식입니다.
#define NUM_LEDS 12
CRGB leds[NUM_LEDS];
void cometTrail() {
fadeToBlackBy(leds, NUM_LEDS, 46);
uint8_t head = (millis() / 45) % NUM_LEDS;
leds[head] += CHSV(150, 255, 255);
FastLED.show();
}그러나 여기에는 아주 치명적인 문제점 두가지가 있습니다.
첫번째 문제는 속도와 환경입니다. 우선, LLM이 ESP32같은 MCU에 코드를 올리고 컴파일 하는 것, 자체에는 어떠한 어려움도 없습니다. 실제로 저도 코덱스가 PLATFORMIO로 접근하게 하여 컴파일까지 진행합니다. 문제는 이 컴파일 과정이 느리고, 선도 필요합니다. 컴파일러도 미리 준비가 되어있어야하고요. 아무리 짱구를 돌려봐도 상당히 느리고 귀찮은 작업이 선행되어야했습니다. 고작 LED의 빨간색을 파란색으로 바꾸는데 3분동안 기다릴 수는 없어요. 이 관점에서 그나마 가장 괜찮다고 생각한 방법은 micropython과 같은 스크립트 언어 기반으로 어떻게든 추상화를 잘 짜서 정의해두는 거였습니다. 어떻게 추상화를 박아넣을지는 솔직히 생각은 안해봤는데 아마 어떠한 방식으로던 가능을 하지 않을까 싶습니다. 다만 그렇다라도, 여전히 한가지 치명적인 문제가 남습니다.
바로 안정성입니다. 아주 치명적인 문제가 될 수 있는데, 잘못된 코드가 들어가면 디바이스가 멀쩡하다는 보장이 없습니다. 사실 더 나아가서, LED를 폭탄으로 만들지 않을거란 보장이 없습니다...! 어쨋든 기본적으로 지금까지의 바이브코딩이 그나마 용서될 수 있던것은, 아무리 망해봤자 컴퓨터 하나 박살나는거 이상으로는 망하기 어렵기 때문 아니겠습니까? 하지만 이런 물리 하드웨어 상에서는, 솔직히 led 조종하는 것 정도는 괜찮지 않나 싶기도 하지만, 어쨋거나 원칙적으로는 led가 벽돌도 폭탄도 되지 않는다는 보장이 없는 이상 쓰기 불안한 것도 사실입니다.
멋지게 LED 제어하기
새삼스럽지만 저의 LED가 동그랗다라는 것을 깨달았습니다. 그리고 HSV 공간도 동그랗다는걸 깨달았습니다. 마침 운좋게도 당시 회사에서 무선통신 바탕으로 작업하고 있던 시기여서, 뭔가 sin cos을 끼얹으면 뭔가 야무지게 연결되지 않을까? 싶더라고요
결과적으로 아주 잘 됐습니다.
수식을 바탕으로 표현 가능한 여러 패턴들.
두 가지 변수를 정의했습니다. 각 LED의 위치가 theta가 되고[1], t는 시간입니다. 원형으로 배치된 12개 LED는 각자 자기 각도(theta)를 가지고 있고, 거기에 시간(t)과 관계되어 다양한 패턴을 만듭니다.
theta + t의 경우, 시간이 흐르면 모든 LED의 색상이 같은 속도로 회전하니까, 결과적으로 무지개가 빙글빙글 도는 패턴이 됩니다. 복잡하지만, 다른 패턴들도 이러한 방식으로 표현 가능합니다. 위의 인터렉션을 보시면 대충 감이 오실꺼라고 생각됩니다.
하지만 문제가 있습니다. 사람이 쓰기에는 상당히 괴랄합니다. 애초에 이런식으로 표현하고 보니, 우선 구성 자체는 매우 직관적이고 단순하기 때문에, 왜 애초에 이렇게 사용하지 않았지 라고 생각했습니다만, 사용하다 보니 알았습니다. 사람이 쓰기에는 대단히 비직관적이에요. HSV 색 공간과, 수식을 통한 시간적 움직임을 동시에 머릿속에 띄워놓고, 그걸 12개 LED 각각에 매핑해야 하니까요. 삼항연산자라도 하나 끼면 머리가 아파오기 시작합니다. 정신이 혼미해집니다.
하지만 큰 상관은 없습니다. 왜냐? 이건 어차피 사람이 쓸 게 아니거든요.
LLM은 이런 수식을 정말 잘 만듭니다. 재미있는 건, LLM한테 시키면 처음엔 "저는 이런 거 못 해요, 훈련 안 됐어요 어려워요" 이런 소리를 하는데 잘합니다. 아마 정렬과 능력 사이에서 오는 일종의 환각으로 생각하고 있습니다. 아라한 장풍 대작전이라고 영화가 있는데 거기서 엥 그러면 내가
뭐 벽도타고 장풍도 쏘겠네 이러면서 벽도 타고 장풍도 쏘는 장면이 있습니다. 약간 그 느낌입니다. 생각해보면 LLM이 잘하는 일의 특성과 잘 맞긴 합니다. 명확한 규칙(HSV 매핑, 시간 변수, 제한된 연산자)이 주어진 상태에서 수식을 조합하는 일이니까요. 어찌됬건 프롬프트만 잘 만들어주면 됩니다. 특히, 이제와서든 꽤나 구닥다리처럼 느껴지기도 합니다만, few-shot learning이 아주 효과적입니다.
그래서 결과적으로는 h,s,v 세 인자값에 대해서 수식을 입력받는 mcp로 구성하면 됩니다. (이 수식을 실제로 어떻게 파싱해서 계산하는지[2], 어디까지의 문법을 지원하는지[4], 그리고 mcp를 실제로 어떻게 나눴는지[6]는 부록에 따로 적어뒀습니다.)
프롬프트를 복사하고 결과를 붙여넣는 패턴 생성기.
위 인터렉션을 통해서 한 번 체험해 볼 수 있습니다. 실제로 어떤 prompt, 즉 description이 들어가서 어떤 출력을 보내는지 느낌을 보실 수 있습니다. 프롬프트 구성 자체는 mcp가 아니다 보니까 약간의 변형이 있었습니다. 우선 입력하는 부분에, 뭐 예를들면 "3번 깜빡이기" 혹은 "죽음을 led로 표현하기" 이런것들을 적으시고, copy prompt를 누르고, 복사된 프롬프트를 사용하시는 llm, 챗지피턴 클로드간에 붙여넣어 결과를 보시면 됩니다. 아마 프롬프트가 제대로 작동했다면 정규화된 hsv 수식이 나올텐데, 그걸 위 붙여놓는곳에 넣으시면 실제 작동 모습도 확인 가능합니다.
이러한 방식은 다양한 이점을 제공합니다.
- 빠르고 가볍습니다. 평가할 때는
theta에 각 LED의 각도를,t에 경과 시간을,i에 LED 인덱스를 넣고 같은 수식을 LED 개수만큼 반복해서 돌립니다. 게다가 색상(Hue)·채도(Sat)·명도(Val)가 각각 독립된 수식이라, LED 하나당 세 번, 12개면 한 프레임에 36번 평가하는 셈이에요. 이게 ~60fps 백그라운드 태스크에서 계속 돌아가는데도 ESP32는 끄떡없습니다. 매 프레임 문자열을 다시 파싱하는데도요. 그리고 수식만 입력으로 넣으면 되기 때문에, 컴파일 등의 과정도 필요 없습니다. 그리고, 아마 어지간하면 동일 기능 대비 코드 길이도 짧을 겁니다. 이건 llm 비용 입장에서 이득입니다.
- 안전합니다. 이 평가기에는 애초에 위험한 동작 자체가 문법에 없어요. 반복문도, 함수 정의도, 메모리 접근도, 외부 호출도 없습니다. 표현식 평가만 하니 무한 루프로 기기가 멈출 일도 없고요[3][5].
우선, 여기까지만 해도 이 작은 원형 led만으로 다양한 것들이 가능해집니다.
- 분위기에 맞게 조명을 틀어달라고 할 수 있고요, 뭐 어떤 음악에 어울리는 조명 이렇게요.
- 아니면 타이머같은걸 만들어 달라고 할 수도 있고, 메트로놈으로 써도 괜찮겠네요
- llm의 감정 표현 : 처음의 시작이었죠. 이제는 단순히 색만 바뀌는게 아니라, 문맥에 따라 다양한 표현이 가능합니다.
- 바이브 코딩하시면 일종의 진행 표시줄.. tqdm으로 써도 됩니다. 이제 중간중간 완성도에 따라서 채우라고 명령해두는거죠. 에러나면 빨간색으로 바꾸고. (정말 뜬금없는 이야기인데, tqdm이 바이브코딩으로 인해 가장 사용률 떨어진 라이브러리 중 하나 아닐까요? )
그런데, 이것 이외에 정말 멋진 장점 하나가 딸려옵니다. 바로, 다른 값을 끼워 넣기가 굉장히 쉽습니다. 무슨 말이냐면, 저 t라는 변수 있잖아요? 이건 사실 그냥 하나의 숫자값일 뿐입니다. 말 그대로 변수입니다. 그렇다면 여기에 다른 센서값을 넣으면 되는 거 아닌가? 하는 생각이 들었어요.
그래서 텅 빈 변수 몇개를 시스템에 추가했습니다[7]. 이런 표현이 가능하게요.
hue = theta + var_a
이제 var_a에 무엇을 넣느냐에 따라 디바이스의 정체성이 바뀝니다. 온도계 값을 넣을 수도, 음성의 피치 값을 넣을 수도 있어요. llm 입장에서도, 그냥 var_a가 온도계에서 흘러오는 값이다. 라고만 알면 되요.
예를 들어볼게요. "나는 -4도에서 40도까지의 온도를 전체적으로 보고 싶다. 낮을 때는 차갑게 보이고, 올라갈수록 빨갛게 변하고, 32도를 넘으면 정말 큰일 난 거니까 시뻘건 알람처럼 깜빡거렸으면 좋겠다." 이런 요구사항을, 바이브 코딩하듯 LLM에게 툭 던지면 됩니다. 아래 인터렉션을 참고해주세요.
-4~40도 온도 입력 예시.
이제 실제로 변수의 움직임에 따라서 반응하는 것을 확인 할 수 있습니다. 온도계, 음성에 따른 반응, 심지어 온도계와 습도계 두개의 변수를 받아 불쾌지수를 계산하는 것도 가능합니다. 외부 api 데이터나 더 많은 센서들과 결합한다면 그만큼 더 많은 활용이 가능하겠죠
정말 멋있다고 생각합니다.
마치며
만드는 데 든 비용은 대략 5달러 정도였습니다. 원리만 이해하시면 (그렇게 어렵진 않다고 생각해요) 여기저기 응용할 곳이 많아 보입니다. 실제 작동 영상이에요.
[1] theta의 정의
theta는 단순한 LED 인덱스가 아니라 정규화된 각도입니다. i번째 LED라면 theta = 2π · i / 12로, 0부터 한 바퀴(2π)까지 고르게 분포시킵니다. 그래야 12개가 원 위에 색을 균등하게 펼칠 수 있어요. LED가 24개면 마찬가지로 2π · i / 24로 나눠주면 됩니다. 원형이라 각도 매핑이 깔끔하게 떨어졌는데, 원이 아닌 다른 형태라면 인덱싱을 어떻게 가져갈지는 따로 고민해야 할 문제입니다.
[2] 파서
이 파서는 재귀 하강(recursive descent) 방식의 경량 수식 평가기입니다. AST는.... 여기 쓰기 넘 복잡하고 무겁고 할 필요도 없지 싶어서요. 문자열을 왼쪽부터 한 번 훑으면서, 연산자 우선순위에 맞춰 함수가 함수를 재귀적으로 호출하는 방식으로 파싱과 계산을 진행했습니다. 우선순위는 삼항연산자(?:) → 논리(||, &&) → 비교(<, >, == 등) → 덧셈·뺄셈 → 곱셈·나눗셈·나머지 → 단항(-, !, 괄호) 순으로 함수가 층층이 쌓여 있습니다.
[3] 파서 엣지케이스 처리
재귀 하강 파서다 보니 몇 가지 엣지케이스를 처리해줬습니다.
하나는 재귀 깊이입니다. 괄호를 깊게 중첩하면 함수 호출이 계속 쌓이는데, 백그라운드 태스크 스택이 4KB뿐이라 너무 깊으면 스택이 터집니다. 그래서 깊이 카운터로 상한을 뒀습니다.
다른 하나는 NaN입니다. mod(1, 0)이나 sqrt(-1) 같은 걸로 NaN을 만들 수 있고요. 그래서 이런 부분은 한 번 걸러줬습니다.
float eval(const char* expr, float theta, float t, int i) {
_expr = expr; _pos = 0;
_theta = theta; _t = t; _i = i;
_depth = 0; // 깊이 초기화
float r = _parseConditional();
if (isnan(r) || isinf(r)) return 0.0f; // NaN/Inf 차단
return r;
}
static const int MAX_DEPTH = 32;
float _parseConditional() {
if (++_depth > MAX_DEPTH) { _depth--; return 0.0f; } // 너무 깊으면 탈출
// ... 기존 로직 ...
_depth--;
return cond;
}그런데 사실 이정도로 복잡한걸 짤 일이 없기는 합니다.
[4] 지원하는 수식의 범위
"수식을 어디부터 어디까지 지원할 것인가"는 본문에 넣을까 말까 고민한 지점입니다. 중요한 부분같아용.
결국 LLM이 뱉은 수식을 파싱해서 직접 계산해야 하는데, 세상의 모든 문법을 다 지원하는 건 구현이 까다롭고 사실 다 필요하지도 않습니다. 그래서 패턴을 표현하는 데 필요한 만큼만 지원했습니다. 사칙연산과 나머지(%), 삼각함수(sin/cos/tan)와 abs/sqrt/floor/ceil, 두 인자 함수(min/max/pow/mod), 비교 연산자(<, >, <=, >=, ==, !=)와 논리 연산자(&&, ||, !), 그리고 삼항연산자(?:)까지입니다.
처음엔 "사칙연산이랑 sin/cos 정도면 되겠지" 했는데, 막상 만들다 보니 비교·논리·삼항이 들어가는 순간 표현력이 확 올라가더라고요. if문이 생기는거니까요
[5] HSV → RGB와 wrap-around (출력 범위 갇힘)
칩은 결국 RGB만 받기 때문에, 매 프레임 계산한 HSV 값을 RGB로 변환해서 흘려보내야 합니다. 이때 출력은 최종적으로 전부 범위가 강제로 갇힙니다. 색상(Hue)은 0~2π로 두는데, 한 바퀴를 넘으면 fmod로 다시 0으로 감기게 처리합니다. 음수로 떨어지면 2π를 더해 양수 구간으로 끌어올리고요. 무지개가 끊김 없이 회전하는 게 바로 이 wrap-around 덕분입니다. 반면 채도와 명도는 회전하는 값이 아니라 0~1로 잘라내고(constrain), 명도는 한 번 abs를 씌워 음수가 들어와도 밝기로 해석되게 했습니다. 이렇게 처리한 값을 0~255 바이트로 변환해 LED에 넣어요.
그래서 LLM이 v = 99999 같은 말도 안 되는 값을 뱉어도 그냥 1로 잘려 최대 밝기에서 멈출 뿐, 아무것도 망가지지 않습니다. 적어도 제가 분석한 결과로는 그러긴 한데, 혹시 이걸 부술 아이디어가 있으신 분은 저에게 알려주시면 감사하겠습니다
[6] MCP 구성
LED 표현 방식에 집중하느라 본문에서는 생략했지만, 사실 실제 디바이스 만들때는 mcp 세 개를 만들었습니다. 몇개의 mcp를 만들까 꽤 고민한 지점이에요. 그러니까, led가 사람을 위한 ui라면 mcp는 llm을 위한 ui잖아요 llm찡을 위한 그런 고민이 필요합니다.
create_pattern : hue·saturation·brightness 수식과 이름을 받아 영구 슬롯(1~5)에 패턴을 생성하고 저장합니다. change_slot : 활성 슬롯을 전환합니다. 0은 기본 눈 깜빡임(IDLE), 1~5는 저장된 패턴, 6은 소등(Blackout)이고, 실행 시간(duration)을 함께 줄 수 있습니다. slot_status : 저장된 모든 슬롯의 이름과 수식을 조회합니다.
이렇게 나눈 건 패턴을 저장하고 재사용할 수 있게 하고 싶어서였습니다. 그러면서 그냥 사용자가 저거 켜줘 했을때 키면서, 동시에 모든 수식은 보여주지 않기를 원했습니다. 그래서 의도적으로 저장할때 수식 뿐 아니라 name과 간단한 설명과 저장하게 했어요. 이건 사람을 위한게 아닙니다. 다른 세션의 llm을 위한 요소에요
다만 생성·저장(create_pattern)과 실행(change_slot)을 분리한 게 최선이었는지는 아직 확신이 없어요. 테스트 편의상 나눠뒀는데, 실제 사용 패턴에 따라서는 하나로 합치는 편이 나을 수도, 아니면 별도의 임시 메모리를 두는 편이 나을 수도 있습니다. 이건 유즈케이스를 더 모아봐야 답이 나올 문제 같습니다.
[7] 센서 포트와 정규화
var_a 같은 변수에 외부 센서값을 꽂을 때 핵심은, 센서값의 범위를 수식의 영역에 맞춰 정규화하는 일입니다. 이 시스템에서 Hue는 0~2π 한 바퀴로 표현되는데, 온도 25~35도를 그대로 Hue에 넣으면 한 바퀴(약 6.28)를 한참 넘어가서 색이 마구 돌아버려요. 그래서 "관심 있는 입력 구간"을 "보고 싶은 색 구간"으로 매핑하는 한 줄이 필요합니다. 센서값이 들쭉날쭉 튀면 색도 같이 떨리니, 간단한 이동평균 같은 스무딩을 한 번 걸어주면 훨씬 보기 좋고요. 문제는 정규화를 언제하냐입니다.
- 사실 아예 안할 수 도 있어요. 가능한 값 범위만 알려준다면 llm이 수식 생성 과정에서 알아서 할 수도 있습니다. 다만 여러 엣지케이스가 발생할 것같은 느낌입니다.
- 정규화를 포트 연결 그러니까 var_a와 외부 값을 연결하는 과정에 미리 하는게 최선인 듯 싶습니다. 다만 이렇게 미리 고정해두면 세션이 길어질수록 LLM이 현재 매핑 상태를 헷갈려 하는 문제가 있어요. 그래서 연결을 관리하는 별도의 런타임이 필요합니다. 런타임에서는 어떠한 포트가 어떤 세션에서 어떠한 정규화로 연결되었는지를 설명해줘야하고요. 이 연결 자체를 mcp로 관리 할 수 있겠죠.
그거 아십니까. LLM으로 LED 제어가 정말 잘 됩니다. 단순히 껐다 켰다 하는 수준이 아니라, 굉장히 엄청나게 다양한 걸 시킬 수 있어요. 이 글은 제가 그 과정에서 거친 시행착오와, 최종적으로 도달한 꽤 마음에 드는 구조에 대한 이야기입니다.
다음과 같은 구성으로 작성되었습니다.
- 아주 간단하게 LED 제어하기
- 좋지 않은 방식으로 LED 제어하기
- 멋지게 LED 제어하기
아주 간단하게 LED 제어하기
처음 만들고 싶었던 건 별게 아니었어요. LLM이 화가 났을 때 빨간색 LED를 켜서, 〈2001 스페이스 오디세이〉의 HAL 9000이나 그런 작품들에 나오는 omni eye 같은 분위기를 연출하고 싶었습니다.
그래서 저는 ESP32 하나에 동그란 LED 링(WS2812 12개) 하나를 붙였습니다. 굳이 원형 LED를 고른 이유는 앞서 말했듯 "눈"을 연출하고 싶었기 때문이에요. 이건 제 첫번째 버전인데, 대충 상자를 케이스로 삼았습니다. 모티브는 놀랍게도, HAL 9000입니다.
이제 이걸 LLM에 연결합니다.
LLM과 LED를 연결하기.
LLM과 LED를 연결하는 것은, 두 가지 과정으로 나눠서 생각해야합니다. 제 생각에 어렵게 느껴지는데 사실 별거 아닌 부분이 있고, 별거 아니라고 생각했는데 어려운 부분이 있습니다.
첫번째 부분: LLM에 LED를 연결하는 부분입니다.
이 부분도... 다시 두 부분으로 나누어지는데요
우선 어떻게 컴퓨터랑 led를 연결하느냐... 에 대한 부분은 esp32는 아주 저렴하면서 wifi를 지원합니다.덕분에 이걸 활용했습니다. 컴퓨터에서 어떻게 다시 llm으로 연결되느냐.. 에 대한 부분은 mcp를 사용했습니다.
혹시 MCP를 모르시는 분을 위해 아주 간단히 설명하자면(많은게 생략되었습니다), 이름/설명/인자 이렇게 세개로 이루어져있습니다. 이름은 말그대로 이름이고요, light_control이라고 지어두면 llm이 아 얘는 불을 컨트롤 하는 도구구나 하고 눈치 채겠죠? 설명은 역시 말그대로 설명입니다. 자 인자에 on을 쓰면 불이 켜지고 off를 넣으면 불이 꺼져 이렇게 설명해주는 거죠 인자는 이제 llm이 작성하는 부분입니다. 사용자가 불꺼 라는 명령을 하면, 설명읽고 인자에 off를 넣어서 실행합니다.
정확히는 이런느낌입니다.
네 불을 끌게요. [light_control('off')] 이제 [ ] 안에 든거는 특별하게 작동해서 이 포맷으로 통신이 되는거죠
그러면 이제 esp32에서는 off가 저 통신라인으로 들어오면 꺼두게 하면 됩니다.
이제 여기서 어려운 두번째 부분이 나옵니다.
두번째 부분: 그래서 뭘 어떻게 제어하게 함? 이 글은 대부분 이 부분에 대해 고민한 부분들입니다.
on이면 led가 켜지고... off면 led가 꺼지게 한다. 이 정도면 아주 쉽습니다. 빨간불, 파란불, 초록불을 나오게? 이것도 쉬워요. 그냥 on off 대신 설명에 red/blue/green을 넣으면 됩니다.
결과적으로, llm을 일종의 분류기로 사용하는거죠.
저의 첫번쨰 버전도 이것과 완전히 동일했습니다만, 이제 llm입장을 속이기 위해 약간의 트릭을 사용했습니다.. 아래 인터렉션을 통해 제 첫번째 버전을 체험해보실 수 있습니다. 아주 간단해요.
실제 LED 링의 빛 움직임.
트릭이란, 인자에 입력 가능한 텍스트로 angry, annoy, neutral 이렇게 셋을 작성하도록 한 겁니다. 이름도 set_emotion으로 짓고요. 이 중 하나를 적으면 이렇게 됩니다.
- Angry: LED가 빨간색으로
- Annoy: LED가 노란색으로
- Neutral: LED가 초록색으로
그래서 이제 어떠한 llm이 화날 만한 상황이 오면 set_emotion('angry')라고 실행하게 하는거죠. 이 부분은 뭐 프롬프트 레벨에서는 간단한 트릭입니다. 사실 이 분이 꽤나 재미있는 부분이긴 합니다. tool description이 실제로 그 행동을 하도록 강제하는 것은 아니기 때문에, 이 특성으로 다양한 가능성과 위험성이 발생합니다. 이 부분에 대한 이야기는 다음에 llm spanker에 대한 글을 작성할 때 진행하겠습니다.
어쨋거나, 결과적으로 이건 3색 분류기입니다. 하지만, 나이브하게 생각하면, 훨씬 더 많은 색, 심지어는 움직이는 패턴도 추가할 수 있습니다. 패턴 하나를 만들고, 그 레이블을 LLM에 소개만 시켜주면 되니까요.
다만 이러한 접근에는 내제된 한계가 존재합니다. 패턴이 몇개 안되는 경우에는, 사실 큰 문제는 없습니다. 그 LED 자체가 재미없고 심심하다는거 말고요. 하지만 패턴이 많아지면? 이제 LLM 프롬프트에 매핑 테이블을 잔뜩 넣어줘야 합니다. 색이 수십 개로 늘어나면 그만큼 길어져요. MCU의 작은 메모리 안에서도, 그만큼의 패턴을 기억해야하고요.
좋지 않은 방식으로 LED 제어하기
곰곰이 생각해보니, ChatGPT든 Claude든 LED 코딩을 정말 잘합니다. 그러니까, 위에 있는 눈 깜빡이는 패턴도 사실 CHATGPT가 만들어준거거든요. 그럼 아예 실시간으로. LLM이 LED를 제어하는 코드를 통째로 실시간으로 짜게 시키면 되는 거 아닌가? 하는 생각이 들었어요.
예를 들면 "무지개 느낌으로 한 바퀴 돌게 해줘" 혹은 "comet처럼 꼬리를 남기며 돌게 해줘" 정도만 말해도, 대충 이런 코드는 금방 나옵니다.
무지개 회전이라면 이런 식이고요.
#define NUM_LEDS 12
CRGB leds[NUM_LEDS];
void rainbowSpin() {
uint8_t baseHue = millis() / 12;
for (uint8_t i = 0; i < NUM_LEDS; i++) {
leds[i] = CHSV(baseHue + i * 255 / NUM_LEDS, 255, 180);
}
FastLED.show();
}comet 꼬리라면 이런 식입니다.
#define NUM_LEDS 12
CRGB leds[NUM_LEDS];
void cometTrail() {
fadeToBlackBy(leds, NUM_LEDS, 46);
uint8_t head = (millis() / 45) % NUM_LEDS;
leds[head] += CHSV(150, 255, 255);
FastLED.show();
}그러나 여기에는 아주 치명적인 문제점 두가지가 있습니다.
첫번째 문제는 속도와 환경입니다. 우선, LLM이 ESP32같은 MCU에 코드를 올리고 컴파일 하는 것, 자체에는 어떠한 어려움도 없습니다. 실제로 저도 코덱스가 PLATFORMIO로 접근하게 하여 컴파일까지 진행합니다. 문제는 이 컴파일 과정이 느리고, 선도 필요합니다. 컴파일러도 미리 준비가 되어있어야하고요. 아무리 짱구를 돌려봐도 상당히 느리고 귀찮은 작업이 선행되어야했습니다. 고작 LED의 빨간색을 파란색으로 바꾸는데 3분동안 기다릴 수는 없어요. 이 관점에서 그나마 가장 괜찮다고 생각한 방법은 micropython과 같은 스크립트 언어 기반으로 어떻게든 추상화를 잘 짜서 정의해두는 거였습니다. 어떻게 추상화를 박아넣을지는 솔직히 생각은 안해봤는데 아마 어떠한 방식으로던 가능을 하지 않을까 싶습니다. 다만 그렇다라도, 여전히 한가지 치명적인 문제가 남습니다.
바로 안정성입니다. 아주 치명적인 문제가 될 수 있는데, 잘못된 코드가 들어가면 디바이스가 멀쩡하다는 보장이 없습니다. 사실 더 나아가서, LED를 폭탄으로 만들지 않을거란 보장이 없습니다...! 어쨋든 기본적으로 지금까지의 바이브코딩이 그나마 용서될 수 있던것은, 아무리 망해봤자 컴퓨터 하나 박살나는거 이상으로는 망하기 어렵기 때문 아니겠습니까? 하지만 이런 물리 하드웨어 상에서는, 솔직히 led 조종하는 것 정도는 괜찮지 않나 싶기도 하지만, 어쨋거나 원칙적으로는 led가 벽돌도 폭탄도 되지 않는다는 보장이 없는 이상 쓰기 불안한 것도 사실입니다.
멋지게 LED 제어하기
새삼스럽지만 저의 LED가 동그랗다라는 것을 깨달았습니다. 그리고 HSV 공간도 동그랗다는걸 깨달았습니다. 마침 운좋게도 당시 회사에서 무선통신 바탕으로 작업하고 있던 시기여서, 뭔가 sin cos을 끼얹으면 뭔가 야무지게 연결되지 않을까? 싶더라고요
결과적으로 아주 잘 됐습니다.
수식을 바탕으로 표현 가능한 여러 패턴들.
두 가지 변수를 정의했습니다. 각 LED의 위치가 theta가 되고[1], t는 시간입니다. 원형으로 배치된 12개 LED는 각자 자기 각도(theta)를 가지고 있고, 거기에 시간(t)과 관계되어 다양한 패턴을 만듭니다.
theta + t의 경우, 시간이 흐르면 모든 LED의 색상이 같은 속도로 회전하니까, 결과적으로 무지개가 빙글빙글 도는 패턴이 됩니다. 복잡하지만, 다른 패턴들도 이러한 방식으로 표현 가능합니다. 위의 인터렉션을 보시면 대충 감이 오실꺼라고 생각됩니다.
하지만 문제가 있습니다. 사람이 쓰기에는 상당히 괴랄합니다. 애초에 이런식으로 표현하고 보니, 우선 구성 자체는 매우 직관적이고 단순하기 때문에, 왜 애초에 이렇게 사용하지 않았지 라고 생각했습니다만, 사용하다 보니 알았습니다. 사람이 쓰기에는 대단히 비직관적이에요. HSV 색 공간과, 수식을 통한 시간적 움직임을 동시에 머릿속에 띄워놓고, 그걸 12개 LED 각각에 매핑해야 하니까요. 삼항연산자라도 하나 끼면 머리가 아파오기 시작합니다. 정신이 혼미해집니다.
하지만 큰 상관은 없습니다. 왜냐? 이건 어차피 사람이 쓸 게 아니거든요.
LLM은 이런 수식을 정말 잘 만듭니다. 재미있는 건, LLM한테 시키면 처음엔 "저는 이런 거 못 해요, 훈련 안 됐어요 어려워요" 이런 소리를 하는데 잘합니다. 아마 정렬과 능력 사이에서 오는 일종의 환각으로 생각하고 있습니다. 아라한 장풍 대작전이라고 영화가 있는데 거기서 엥 그러면 내가
뭐 벽도타고 장풍도 쏘겠네 이러면서 벽도 타고 장풍도 쏘는 장면이 있습니다. 약간 그 느낌입니다. 생각해보면 LLM이 잘하는 일의 특성과 잘 맞긴 합니다. 명확한 규칙(HSV 매핑, 시간 변수, 제한된 연산자)이 주어진 상태에서 수식을 조합하는 일이니까요. 어찌됬건 프롬프트만 잘 만들어주면 됩니다. 특히, 이제와서든 꽤나 구닥다리처럼 느껴지기도 합니다만, few-shot learning이 아주 효과적입니다.
그래서 결과적으로는 h,s,v 세 인자값에 대해서 수식을 입력받는 mcp로 구성하면 됩니다. (이 수식을 실제로 어떻게 파싱해서 계산하는지[2], 어디까지의 문법을 지원하는지[4], 그리고 mcp를 실제로 어떻게 나눴는지[6]는 부록에 따로 적어뒀습니다.)
프롬프트를 복사하고 결과를 붙여넣는 패턴 생성기.
위 인터렉션을 통해서 한 번 체험해 볼 수 있습니다. 실제로 어떤 prompt, 즉 description이 들어가서 어떤 출력을 보내는지 느낌을 보실 수 있습니다. 프롬프트 구성 자체는 mcp가 아니다 보니까 약간의 변형이 있었습니다. 우선 입력하는 부분에, 뭐 예를들면 "3번 깜빡이기" 혹은 "죽음을 led로 표현하기" 이런것들을 적으시고, copy prompt를 누르고, 복사된 프롬프트를 사용하시는 llm, 챗지피턴 클로드간에 붙여넣어 결과를 보시면 됩니다. 아마 프롬프트가 제대로 작동했다면 정규화된 hsv 수식이 나올텐데, 그걸 위 붙여놓는곳에 넣으시면 실제 작동 모습도 확인 가능합니다.
이러한 방식은 다양한 이점을 제공합니다.
- 빠르고 가볍습니다. 평가할 때는
theta에 각 LED의 각도를,t에 경과 시간을,i에 LED 인덱스를 넣고 같은 수식을 LED 개수만큼 반복해서 돌립니다. 게다가 색상(Hue)·채도(Sat)·명도(Val)가 각각 독립된 수식이라, LED 하나당 세 번, 12개면 한 프레임에 36번 평가하는 셈이에요. 이게 ~60fps 백그라운드 태스크에서 계속 돌아가는데도 ESP32는 끄떡없습니다. 매 프레임 문자열을 다시 파싱하는데도요. 그리고 수식만 입력으로 넣으면 되기 때문에, 컴파일 등의 과정도 필요 없습니다. 그리고, 아마 어지간하면 동일 기능 대비 코드 길이도 짧을 겁니다. 이건 llm 비용 입장에서 이득입니다.
- 안전합니다. 이 평가기에는 애초에 위험한 동작 자체가 문법에 없어요. 반복문도, 함수 정의도, 메모리 접근도, 외부 호출도 없습니다. 표현식 평가만 하니 무한 루프로 기기가 멈출 일도 없고요[3][5].
우선, 여기까지만 해도 이 작은 원형 led만으로 다양한 것들이 가능해집니다.
- 분위기에 맞게 조명을 틀어달라고 할 수 있고요, 뭐 어떤 음악에 어울리는 조명 이렇게요.
- 아니면 타이머같은걸 만들어 달라고 할 수도 있고, 메트로놈으로 써도 괜찮겠네요
- llm의 감정 표현 : 처음의 시작이었죠. 이제는 단순히 색만 바뀌는게 아니라, 문맥에 따라 다양한 표현이 가능합니다.
- 바이브 코딩하시면 일종의 진행 표시줄.. tqdm으로 써도 됩니다. 이제 중간중간 완성도에 따라서 채우라고 명령해두는거죠. 에러나면 빨간색으로 바꾸고. (정말 뜬금없는 이야기인데, tqdm이 바이브코딩으로 인해 가장 사용률 떨어진 라이브러리 중 하나 아닐까요? )
그런데, 이것 이외에 정말 멋진 장점 하나가 딸려옵니다. 바로, 다른 값을 끼워 넣기가 굉장히 쉽습니다. 무슨 말이냐면, 저 t라는 변수 있잖아요? 이건 사실 그냥 하나의 숫자값일 뿐입니다. 말 그대로 변수입니다. 그렇다면 여기에 다른 센서값을 넣으면 되는 거 아닌가? 하는 생각이 들었어요.
그래서 텅 빈 변수 몇개를 시스템에 추가했습니다[7]. 이런 표현이 가능하게요.
hue = theta + var_a
이제 var_a에 무엇을 넣느냐에 따라 디바이스의 정체성이 바뀝니다. 온도계 값을 넣을 수도, 음성의 피치 값을 넣을 수도 있어요. llm 입장에서도, 그냥 var_a가 온도계에서 흘러오는 값이다. 라고만 알면 되요.
예를 들어볼게요. "나는 -4도에서 40도까지의 온도를 전체적으로 보고 싶다. 낮을 때는 차갑게 보이고, 올라갈수록 빨갛게 변하고, 32도를 넘으면 정말 큰일 난 거니까 시뻘건 알람처럼 깜빡거렸으면 좋겠다." 이런 요구사항을, 바이브 코딩하듯 LLM에게 툭 던지면 됩니다. 아래 인터렉션을 참고해주세요.
-4~40도 온도 입력 예시.
이제 실제로 변수의 움직임에 따라서 반응하는 것을 확인 할 수 있습니다. 온도계, 음성에 따른 반응, 심지어 온도계와 습도계 두개의 변수를 받아 불쾌지수를 계산하는 것도 가능합니다. 외부 api 데이터나 더 많은 센서들과 결합한다면 그만큼 더 많은 활용이 가능하겠죠
정말 멋있다고 생각합니다.
마치며
만드는 데 든 비용은 대략 5달러 정도였습니다. 원리만 이해하시면 (그렇게 어렵진 않다고 생각해요) 여기저기 응용할 곳이 많아 보입니다. 실제 작동 영상이에요.
[1] theta의 정의
theta는 단순한 LED 인덱스가 아니라 정규화된 각도입니다. i번째 LED라면 theta = 2π · i / 12로, 0부터 한 바퀴(2π)까지 고르게 분포시킵니다. 그래야 12개가 원 위에 색을 균등하게 펼칠 수 있어요. LED가 24개면 마찬가지로 2π · i / 24로 나눠주면 됩니다. 원형이라 각도 매핑이 깔끔하게 떨어졌는데, 원이 아닌 다른 형태라면 인덱싱을 어떻게 가져갈지는 따로 고민해야 할 문제입니다.
[2] 파서
이 파서는 재귀 하강(recursive descent) 방식의 경량 수식 평가기입니다. AST는.... 여기 쓰기 넘 복잡하고 무겁고 할 필요도 없지 싶어서요. 문자열을 왼쪽부터 한 번 훑으면서, 연산자 우선순위에 맞춰 함수가 함수를 재귀적으로 호출하는 방식으로 파싱과 계산을 진행했습니다. 우선순위는 삼항연산자(?:) → 논리(||, &&) → 비교(<, >, == 등) → 덧셈·뺄셈 → 곱셈·나눗셈·나머지 → 단항(-, !, 괄호) 순으로 함수가 층층이 쌓여 있습니다.
[3] 파서 엣지케이스 처리
재귀 하강 파서다 보니 몇 가지 엣지케이스를 처리해줬습니다.
하나는 재귀 깊이입니다. 괄호를 깊게 중첩하면 함수 호출이 계속 쌓이는데, 백그라운드 태스크 스택이 4KB뿐이라 너무 깊으면 스택이 터집니다. 그래서 깊이 카운터로 상한을 뒀습니다.
다른 하나는 NaN입니다. mod(1, 0)이나 sqrt(-1) 같은 걸로 NaN을 만들 수 있고요. 그래서 이런 부분은 한 번 걸러줬습니다.
float eval(const char* expr, float theta, float t, int i) {
_expr = expr; _pos = 0;
_theta = theta; _t = t; _i = i;
_depth = 0; // 깊이 초기화
float r = _parseConditional();
if (isnan(r) || isinf(r)) return 0.0f; // NaN/Inf 차단
return r;
}
static const int MAX_DEPTH = 32;
float _parseConditional() {
if (++_depth > MAX_DEPTH) { _depth--; return 0.0f; } // 너무 깊으면 탈출
// ... 기존 로직 ...
_depth--;
return cond;
}그런데 사실 이정도로 복잡한걸 짤 일이 없기는 합니다.
[4] 지원하는 수식의 범위
"수식을 어디부터 어디까지 지원할 것인가"는 본문에 넣을까 말까 고민한 지점입니다. 중요한 부분같아용.
결국 LLM이 뱉은 수식을 파싱해서 직접 계산해야 하는데, 세상의 모든 문법을 다 지원하는 건 구현이 까다롭고 사실 다 필요하지도 않습니다. 그래서 패턴을 표현하는 데 필요한 만큼만 지원했습니다. 사칙연산과 나머지(%), 삼각함수(sin/cos/tan)와 abs/sqrt/floor/ceil, 두 인자 함수(min/max/pow/mod), 비교 연산자(<, >, <=, >=, ==, !=)와 논리 연산자(&&, ||, !), 그리고 삼항연산자(?:)까지입니다.
처음엔 "사칙연산이랑 sin/cos 정도면 되겠지" 했는데, 막상 만들다 보니 비교·논리·삼항이 들어가는 순간 표현력이 확 올라가더라고요. if문이 생기는거니까요
[5] HSV → RGB와 wrap-around (출력 범위 갇힘)
칩은 결국 RGB만 받기 때문에, 매 프레임 계산한 HSV 값을 RGB로 변환해서 흘려보내야 합니다. 이때 출력은 최종적으로 전부 범위가 강제로 갇힙니다. 색상(Hue)은 0~2π로 두는데, 한 바퀴를 넘으면 fmod로 다시 0으로 감기게 처리합니다. 음수로 떨어지면 2π를 더해 양수 구간으로 끌어올리고요. 무지개가 끊김 없이 회전하는 게 바로 이 wrap-around 덕분입니다. 반면 채도와 명도는 회전하는 값이 아니라 0~1로 잘라내고(constrain), 명도는 한 번 abs를 씌워 음수가 들어와도 밝기로 해석되게 했습니다. 이렇게 처리한 값을 0~255 바이트로 변환해 LED에 넣어요.
그래서 LLM이 v = 99999 같은 말도 안 되는 값을 뱉어도 그냥 1로 잘려 최대 밝기에서 멈출 뿐, 아무것도 망가지지 않습니다. 적어도 제가 분석한 결과로는 그러긴 한데, 혹시 이걸 부술 아이디어가 있으신 분은 저에게 알려주시면 감사하겠습니다
[6] MCP 구성
LED 표현 방식에 집중하느라 본문에서는 생략했지만, 사실 실제 디바이스 만들때는 mcp 세 개를 만들었습니다. 몇개의 mcp를 만들까 꽤 고민한 지점이에요. 그러니까, led가 사람을 위한 ui라면 mcp는 llm을 위한 ui잖아요 llm찡을 위한 그런 고민이 필요합니다.
create_pattern : hue·saturation·brightness 수식과 이름을 받아 영구 슬롯(1~5)에 패턴을 생성하고 저장합니다. change_slot : 활성 슬롯을 전환합니다. 0은 기본 눈 깜빡임(IDLE), 1~5는 저장된 패턴, 6은 소등(Blackout)이고, 실행 시간(duration)을 함께 줄 수 있습니다. slot_status : 저장된 모든 슬롯의 이름과 수식을 조회합니다.
이렇게 나눈 건 패턴을 저장하고 재사용할 수 있게 하고 싶어서였습니다. 그러면서 그냥 사용자가 저거 켜줘 했을때 키면서, 동시에 모든 수식은 보여주지 않기를 원했습니다. 그래서 의도적으로 저장할때 수식 뿐 아니라 name과 간단한 설명과 저장하게 했어요. 이건 사람을 위한게 아닙니다. 다른 세션의 llm을 위한 요소에요
다만 생성·저장(create_pattern)과 실행(change_slot)을 분리한 게 최선이었는지는 아직 확신이 없어요. 테스트 편의상 나눠뒀는데, 실제 사용 패턴에 따라서는 하나로 합치는 편이 나을 수도, 아니면 별도의 임시 메모리를 두는 편이 나을 수도 있습니다. 이건 유즈케이스를 더 모아봐야 답이 나올 문제 같습니다.
[7] 센서 포트와 정규화
var_a 같은 변수에 외부 센서값을 꽂을 때 핵심은, 센서값의 범위를 수식의 영역에 맞춰 정규화하는 일입니다. 이 시스템에서 Hue는 0~2π 한 바퀴로 표현되는데, 온도 25~35도를 그대로 Hue에 넣으면 한 바퀴(약 6.28)를 한참 넘어가서 색이 마구 돌아버려요. 그래서 "관심 있는 입력 구간"을 "보고 싶은 색 구간"으로 매핑하는 한 줄이 필요합니다. 센서값이 들쭉날쭉 튀면 색도 같이 떨리니, 간단한 이동평균 같은 스무딩을 한 번 걸어주면 훨씬 보기 좋고요. 문제는 정규화를 언제하냐입니다.
- 사실 아예 안할 수 도 있어요. 가능한 값 범위만 알려준다면 llm이 수식 생성 과정에서 알아서 할 수도 있습니다. 다만 여러 엣지케이스가 발생할 것같은 느낌입니다.
- 정규화를 포트 연결 그러니까 var_a와 외부 값을 연결하는 과정에 미리 하는게 최선인 듯 싶습니다. 다만 이렇게 미리 고정해두면 세션이 길어질수록 LLM이 현재 매핑 상태를 헷갈려 하는 문제가 있어요. 그래서 연결을 관리하는 별도의 런타임이 필요합니다. 런타임에서는 어떠한 포트가 어떤 세션에서 어떠한 정규화로 연결되었는지를 설명해줘야하고요. 이 연결 자체를 mcp로 관리 할 수 있겠죠.
Here's a thing you might not know: you can control LEDs with an LLM, and it works really well. Not just flicking them on and off — you can get them to do a genuinely absurd range of things.
This post is about the trial and error I went through to get there, and the structure I eventually landed on, which I'm pretty happy with.
It's laid out in three parts:
- Controlling LEDs the very simple way
- Controlling LEDs the bad way
- Controlling LEDs the elegant way
Controlling LEDs the very simple way
The first thing I wanted to build was nothing fancy. I wanted a red LED to switch on whenever the LLM got angry — something with the vibe of HAL 9000 from 2001: A Space Odyssey, or the omni-eye you see in films like that.
So I stuck a circular LED ring (12× WS2812) onto a single ESP32. The reason I specifically went with a ring is, as I said, that I wanted to evoke an "eye." This was my first version — I just grabbed a box and used it as the case. The motif, surprisingly, is HAL 9000.
Now to wire this up to an LLM.
Connecting the LLM and the LED
Connecting an LLM to an LED is best thought of as two separate steps. In my experience, one of them feels hard but is actually trivial, and the other one seems trivial but is actually hard.
Step one: physically connecting the LED to the LLM.
This part splits in two again.
First, how do you connect a computer to the LED? The ESP32 is dirt cheap and supports Wi-Fi, so I leaned on that. Then, how does the computer connect back to the LLM? For that I used MCP.
In case you don't know MCP, here's the bare-bones version (a lot is being skipped): it's made of three things — a name, a description, and arguments. The name is literally just a name; if you call it light_control, the LLM goes "ah, this is the tool that controls lights." The description is, again, literally a description: "put on in the argument and the light turns on, put off and it turns off." The argument is the part the LLM fills in. When the user says "turn off the light," the LLM reads the description and runs it with off.
It looks roughly like this:
Sure, I'll turn it off. [light_control('off')]
The bit inside the [ ] gets handled specially — that's the format the communication happens through. So on the ESP32 side, you just make it switch off whenever off comes down that line.
And here's where the hard second step shows up.
Step two: okay, but control what, and how? Most of this post is me wrestling with this part.
on lights the LED, off kills it. That much is easy. Red, blue, green? Also easy — instead of on/off, you just put red/blue/green in the description. In effect, you're using the LLM as a kind of classifier.
My first version was exactly this, except I used a small trick to mess with the LLM's framing.
The actual light motion of the LED ring.
The trick was to make the allowed argument values angry, annoy, and neutral — three of them — and name the tool set_emotion. Write one of those and you get:
- Angry: LED turns red
- Annoy: LED turns yellow
- Neutral: LED turns green
So now, whenever a situation comes up that might make an LLM angry, you get it to run set_emotion('angry'). At the prompt level this is a simple trick. But it's a pretty interesting one, actually — because a tool description doesn't actually force the behavior it describes, this property opens up a bunch of possibilities and risks. I'll get into that part in a future post about the LLM spanker.
Anyway, the end result is a three-color classifier. But if you think about it naively, you could add far more colors — even moving patterns. You just build a pattern, then introduce its label to the LLM.
There's a built-in limit to this approach, though. When you only have a handful of patterns, it's basically fine — aside from the LED itself being boring and lifeless. But once the patterns pile up? Now you have to stuff a giant mapping table into the LLM prompt. Scale to dozens of colors and the prompt grows right along with it. And the MCU, with its tiny memory, has to remember every one of those patterns too.
Controlling LEDs the bad way
Thinking it over, I realized ChatGPT and Claude are both genuinely great at coding LEDs. I mean — that eye-blink pattern up above? ChatGPT actually wrote that. So I figured: why not just have the LLM write the LED-control code wholesale, in real time?
For example, if you ask for "make a rainbow circle the ring" or "make a comet trail circle the ring," it can spit out code like this almost immediately.
For a rainbow spin, it might look like this.
#define NUM_LEDS 12
CRGB leds[NUM_LEDS];
void rainbowSpin() {
uint8_t baseHue = millis() / 12;
for (uint8_t i = 0; i < NUM_LEDS; i++) {
leds[i] = CHSV(baseHue + i * 255 / NUM_LEDS, 255, 180);
}
FastLED.show();
}For a comet trail, it might look like this.
#define NUM_LEDS 12
CRGB leds[NUM_LEDS];
void cometTrail() {
fadeToBlackBy(leds, NUM_LEDS, 46);
uint8_t head = (millis() / 45) % NUM_LEDS;
leds[head] += CHSV(150, 255, 255);
FastLED.show();
}There are two fatal problems with this.
The first problem is speed and environment. Getting an LLM to push code onto an MCU like the ESP32 and compile it isn't hard in itself — I actually let Codex go through PlatformIO and handle compilation. The problem is that the compile step is slow, it needs a cable, and you need the compiler set up ahead of time. No matter how I turned it over in my head, a slow and annoying chunk of work always had to happen first. You can't wait three minutes just to change an LED from red to blue.
From that angle, the least-bad idea I came up with was to define some clever abstraction on top of a scripting language like MicroPython. Honestly I never worked out exactly how I'd bake that abstraction in, but I imagine it's doable somehow. Even so, one fatal problem remains.
That problem is safety. This can get genuinely dangerous: if bad code goes in, there's no guarantee the device stays intact. Going further — there's no guarantee it won't turn the LED into a bomb. The reason vibe-coding has been forgivable so far is that, however badly it blows up, it's hard to do worse than wrecking one computer, right? But on physical hardware? Sure, controlling an LED is probably harmless enough — but in principle, as long as there's no guarantee the LED won't become a brick or a bomb, it's genuinely nerve-wracking to use.
Controlling LEDs the elegant way
It occurred to me, somewhat belatedly, that my LED is round. And that HSV space is also round. As luck would have it, I happened to be working on something wireless-comms-related at the company around then, so I thought — maybe if I drizzle some sin and cos over this, it'll click together nicely?
It worked out great.
Various patterns expressible from a formula.
I defined two variables. Each LED's position becomes theta[1], and t is time. The 12 LEDs arranged in a circle each carry their own angle (theta), and combined with time (t) they produce all kinds of patterns.
Take theta + t: as time passes, every LED's color rotates at the same rate, so the result is a rainbow spinning round and round. It's more involved, but other patterns are expressible the same way. The interaction above should give you the rough feel.
But there's a problem. It's pretty gnarly for a human to write. When I first expressed things this way, the construction itself was so intuitive and simple that I wondered why I hadn't been doing it like this all along — and then I started actually using it and found out. It is deeply unintuitive for a person. You have to hold the HSV color space and the time-driven motion in your head at once, and map that onto each of 12 LEDs individually. Slip in a single ternary operator and your head starts to hurt. Your mind goes hazy.
But that barely matters. Why? Because a human was never going to be the one writing this.
LLMs are really good at making these formulas. The funny part is, when you ask one to do it, at first it goes "oh, I can't do this, I wasn't trained for it, this is hard" — and then it does it fine. I think of it as a kind of hallucination born from the gap between alignment and capability. When you think about it, it lines up well with what LLMs are good at: composing formulas under a clear set of rules (HSV mapping, a time variable, a limited operator set). You just have to write the prompt well. And — even if it feels a touch old-fashioned to say so now — few-shot learning is extremely effective here.
So in the end, you build an MCP that takes formulas for the three arguments h, s, and v. (How the formula actually gets parsed and evaluated[2], how far the supported syntax goes[4], and how I actually split up the MCPs[6] are written up separately in the appendix.)
A pattern generator: copy the prompt, paste back the result.
You can try it through the interaction above. You'll get a feel for which prompt — i.e. description — goes in and what output comes back. Since the prompt construction itself isn't an MCP, there's a slight variation. Type something into the input — say "blink three times" or "express death as an LED" — hit copy prompt, and paste the copied prompt into whatever LLM you use, ChatGPT or Claude. If the prompt did its job, you'll get a normalized HSV formula back; drop that into the paste box above and you can watch it actually run.
This approach buys you a bunch of advantages.
- It's fast and light. To evaluate, you plug each LED's angle into
theta, the elapsed time intot, the LED index intoi, and run the same formula once per LED. On top of that, Hue, Sat, and Val are each their own independent formula, so that's three evaluations per LED — 36 per frame for 12 LEDs. This runs continuously as a background task at ~60fps and the ESP32 doesn't even flinch, despite re-parsing the strings every single frame. And since the only input is the formula, there's no compile step. Plus, for equivalent functionality, the code is probably shorter — which is a win on LLM cost.
- It's safe. This evaluator has no dangerous operations in the grammar to begin with. No loops, no function definitions, no memory access, no external calls. It only evaluates expressions, so there's no way to hang the device with an infinite loop. (The edge cases that do come from the parser itself — overly deep parenthesis nesting, NaN, and the like — I filtered out separately[3]. How the output gets clamped into a safe range is in the appendix too[5].)
Even just this far, this little ring of LEDs can do a surprising amount:
- You can ask it to set the lighting to match a mood — like, lighting that suits a particular song.
- You could have it build a timer, or use it as a metronome.
- LLM emotional expression — the original starting point. Except now it's not just a color change; depending on context, it can express all sorts of things.
- When you're vibe-coding, it can act as a kind of progress bar — use it like tqdm. You tell it to fill up as completion progresses, and flip to red on an error. (Total tangent, but isn't tqdm one of the libraries vibe-coding has tanked the usage of the most?)
But there's one more genuinely great perk that comes along for the ride. Namely: swapping in other values is incredibly easy. What I mean is — you know that t variable? It's really just a single number. Literally a variable. So I thought: couldn't I just feed some other sensor value in there?
So I added a few empty variables to the system[7], to make expressions like this possible:
hue = theta + var_a
Now the device's identity changes depending on what you put in var_a. You could feed it a thermometer reading, or the pitch of a voice. And from the LLM's side, all it needs to know is "var_a is a value flowing in from a thermometer."
Let me give an example. "I want to watch the -4°C to 40°C range. Keep it cool-colored at the low end, push it toward red as temperature rises, and once it passes 32°C, make it blink like a blood-red alarm." You just lob a requirement like that at the LLM, vibe-coding style. See the interaction below.
A -4-to-40°C temperature input example.
Now you can actually watch it react to the variable's movement. A thermometer, voice-reactive behavior — you could even take two variables, a thermometer and a hygrometer, and compute a discomfort index. Combine it with external API data or more sensors and the range of uses opens up that much further.
I think it's genuinely cool.
Wrapping up
The whole thing cost me about $5 to build. Once you understand the principle (which isn't that hard, I think), there seem to be plenty of places to apply it.
Here's the actual working video.
[1] What theta is
theta isn't just the LED index — it's a normalized angle. For the i-th LED, theta = 2π · i / 12, spreading evenly from 0 to one full turn (2π). That's what lets the 12 LEDs lay color out uniformly around the circle. With 24 LEDs you'd divide by 24 the same way: 2π · i / 24. Because it's circular, the angle mapping fell out cleanly; for a non-circular shape, how to handle indexing is a separate thing you'd have to think about.
[2] The parser
This parser is a lightweight, recursive-descent expression evaluator. An AST? Too complicated and heavy to put here, and not really necessary anyway. It scans the string left to right once, with functions calling functions recursively in operator-precedence order to do parsing and evaluation in one pass. The precedence is stacked function-by-function: ternary (?:) → logical (||, &&) → comparison (<, >, ==, etc.) → addition/subtraction → multiplication/division/modulo → unary (-, !, parentheses).
[3] Parser edge cases
Being a recursive-descent parser, there were a couple of edge cases to handle.
One is recursion depth. Nest parentheses deeply and the function calls keep stacking up — and since the background task stack is only 4KB, going too deep blows it out. So I capped it with a depth counter.
The other is NaN. You can produce a NaN with something like mod(1, 0) or sqrt(-1), so I filtered that out once.
float eval(const char* expr, float theta, float t, int i) {
_expr = expr; _pos = 0;
_theta = theta; _t = t; _i = i;
_depth = 0; // reset depth
float r = _parseConditional();
if (isnan(r) || isinf(r)) return 0.0f; // block NaN/Inf
return r;
}
static const int MAX_DEPTH = 32;
float _parseConditional() {
if (++_depth > MAX_DEPTH) { _depth--; return 0.0f; } // bail if too deep
// ... existing logic ...
_depth--;
return cond;
}That said, you basically never end up writing anything complex enough for this to matter.
[4] How far the supported syntax goes
"Where to start and stop supporting syntax" was something I went back and forth on whether to put in the main text. It's an important bit, I think.
Ultimately you have to parse and evaluate the formula the LLM spits out yourself, and supporting every grammar in the world is both fiddly to implement and not actually necessary. So I supported only as much as patterns need: the four arithmetic operators plus modulo (%), trig functions (sin/cos/tan) and abs/sqrt/floor/ceil, two-argument functions (min/max/pow/mod), comparison operators (<, >, <=, >=, ==, !=) and logical operators (&&, ||, !), and the ternary operator (?:).
At first I figured "arithmetic and sin/cos should be enough," but building it out, the moment comparison, logic, and ternary went in, the expressiveness shot way up. You get an if statement, after all.
[5] HSV → RGB and wrap-around (output range clamping)
The chip only takes RGB in the end, so every frame the computed HSV value has to be converted to RGB and flushed out. At this point the output gets forcibly clamped into range. Hue lives in 0–2π, and when it crosses a full turn, fmod wraps it back to 0. If it falls negative, you add 2π to lift it back into the positive range. That wrap-around is exactly what makes the rainbow rotate seamlessly. Saturation and value, by contrast, aren't rotating values — they get clipped to 0–1 (constrain), and value gets an abs pass so a negative still reads as brightness. The processed values then convert to 0–255 bytes and go to the LED.
So even if the LLM spits out something nonsensical like v = 99999, it just clips to 1 and stalls at max brightness — nothing breaks. At least that's how it shakes out by my analysis; if anyone has an idea for how to break it, I'd be grateful if you let me know.
[6] MCP structure
I skipped this in the main text to stay focused on the LED-expression side, but for the actual device I made three MCPs. How many MCPs to make was something I deliberated on quite a bit. Think of it this way: if the LED is a UI for humans, the MCP is a UI for the LLM — you need that kind of consideration for the little llm-chan.
create_pattern: takes hue/saturation/brightness formulas plus a name, and creates and saves a pattern into a permanent slot (1–5).change_slot: switches the active slot. 0 is the default eye-blink (IDLE), 1–5 are saved patterns, 6 is Blackout; you can also hand it aduration.slot_status: looks up the name and formula of every saved slot.
I split it this way because I wanted patterns to be saveable and reusable. At the same time, I wanted it to switch on when the user just says "turn that on," without exposing all the formulas in the process. So I deliberately had it save not just the formula but a name and a short description. This isn't for humans — it's an element for the LLM in another session.
I'm still not sure splitting creation/saving (create_pattern) from execution (change_slot) was the best call, though. I split them for testing convenience, but depending on actual usage patterns, merging them into one might be better, or keeping a separate scratch memory might be. This feels like a question I'll only answer after collecting more use cases.
[7] Sensor ports and normalization
When you plug an external sensor value into a variable like var_a, the key is normalizing the sensor's range to fit the formula's domain. In this system Hue is expressed across one full turn, 0–2π — so if you feed a 25–35°C temperature straight into Hue, it sails way past a full turn (~6.28) and the color just spins wildly. So you need one line that maps "the input range you care about" onto "the color range you want to see." And if the sensor value jitters around, the color trembles with it, so running a little smoothing — a simple moving average, say — makes it look much nicer.
The question is when to normalize.
- You could actually skip it entirely. If you just tell the LLM the possible value range, it might handle it during formula generation on its own. Though I have a feeling that'd spawn various edge cases.
- Doing the normalization up front, at the port-connection step — i.e. where you wire
var_ato the external value — seems like the best option. The catch is that if you fix it ahead of time like this, then the longer a session runs, the more the LLM gets confused about the current mapping state. So you need a separate runtime to manage the connections. That runtime has to explain which port was connected in which session under which normalization. And that connection itself could be managed via MCP.