오늘은 로컬 LLM을 직접 서비스에 붙여보는 실험을 해봤습니다.
요즘 로컬 LLM이나 여러 모델을 역할별로 나눠 사용하는 구조에 대해 이런저런 이야기를 많이 듣고 있기도 했고, 저도 실제로 한 번 돌려봐야 어느 정도인지 감이 올 것 같았습니다.
마침 제가 만들고 있는 서비스 중에 실험하기 적당한 게 하나 있었습니다.
‘오늘 얼마 먹어도 돼?’
사용자가 하루 동안 먹은 음식을 그냥 평소 말하듯 입력하면 기록하고, 오늘 얼마나 더 먹을 수 있는지 보여주는 작은 서비스입니다.
예를 들면 이런 식입니다.
“아침에 계란 두 개 먹었어.”
“아까 먹은 바나나 취소해줘.”
“오늘 얼마나 더 먹어도 돼?”
이 서비스에서는 사용자의 문장을 받으면 먼저 이게 음식 추가인지, 수정인지, 삭제인지, 상태를 묻는 건지 등을 판단합니다.
현재는 이 판정을 Jev라는 외부 모델에 맡기고 있습니다.
문득 이런 생각이 들었습니다.
이 정도의 짧은 문장 분류라면 굳이 외부 모델을 호출하지 않고 작은 로컬 LLM으로 처리해도 되지 않을까?
그래서 직접 테스트해보기로 했습니다.
일단 모델부터 설치하지는 않았다
처음에는 Ollama를 설치하고 모델부터 실행하면 될 거라고 생각했습니다.
그런데 실제 코드를 다시 살펴보니 먼저 해야 할 게 있었습니다.
현재 앱에서 AI가 정확히 무엇을 하고 있는지를 분리하는 일이었습니다.
확인해보니 생각보다 구조가 명확했습니다.
LLM은 사용자의 의도를 판단합니다.
반면 음식 이름과 양을 해석하고, 칼로리를 조회하고, 실제 기록을 추가하거나 수정하는 작업은 코드가 처리하고 있었습니다.
즉 대략 이런 구조였습니다.
사용자 입력
→ AI가 의도 판단
→ 코드가 명령 결정
→ 음식 데이터 조회
→ 실제 기록 처리
이 구조라면 실험하기 상당히 좋았습니다.
기존 로직을 거의 건드리지 않고 판정기만 로컬 모델과 비교해볼 수 있기 때문입니다.
그래서 바로 로컬 모델을 실제 기능으로 넣지 않고 세 가지 모드를 만들었습니다.
off
기존 시스템 그대로 사용합니다.
shadow
기존 Jev의 판단을 실제 결과로 사용하면서, 같은 문장을 로컬 LLM에게도 보내 결과만 비교합니다.
active
로컬 모델의 결과가 일정 조건을 만족하면 사용하고, 그렇지 않으면 기존 Jev로 다시 보냅니다.
그리고 production에서는 무조건 off가 되도록 막았습니다.
지금 목적은 새 기능을 만드는 것이 아니라 이게 정말 쓸 만한지 확인하는 것이었기 때문입니다.
테스트 데이터도 만들었다
그냥 몇 문장 던져보고
“오, 잘 되는데?”
라고 끝내면 의미가 없을 것 같았습니다.
그래서 로컬 라우터용으로 67개의 테스트 케이스를 만들었습니다.
예를 들면 이런 문장들입니다.
“아침에 계란 두 개 먹었어.”
“점심 김치찌개랑 밥.”
“아까 먹은 바나나 취소해줘.”
“나 오늘 얼마나 더 먹어도 돼?”
“배고프다.”
“오늘 운동했는데 더 먹어도 돼?”
여기에 오타, 줄임말, 조사가 빠진 문장, 음식 여러 개가 들어간 문장, 애매한 표현들도 섞었습니다.
그리고 정확도만 보는 게 아니라
- 평균 응답 시간
- p95 응답 시간
- JSON 파싱 실패
- timeout
- confidence
- 기존 Jev와의 일치율
- 실제로 외부 호출을 얼마나 줄일 수 있는지
까지 같이 측정하게 만들었습니다.
그런데 갑자기 Jev가 바보가 됐다
실험 중간에 조금 당황스러운 일이 있었습니다.
기존 Jev에
“아침에 계란 두 개 먹었어.”
를 넣었는데 confidence가 이상하게 낮게 나오기 시작했습니다.
“오늘 얼마나 남았어?”
같은 문장도 엉뚱하게 음식 추가로 판단했습니다.
기존 테스트에서 intent 정확도가 90% 중반대였기 때문에 갑자기 모델 성능이 크게 떨어진 것처럼 보였습니다.
혹시 서비스 쪽 모델이 바뀐 건가 싶어서 baseline을 다시 측정했습니다.
결과는 정상이었습니다.
기존 측정: 93.3%
오늘 측정: 95.0%
모델 버전도 같았습니다.
그럼 뭐가 문제였을까.
결국 범인은 AI가 아니라 Windows Git Bash의 한글 인코딩이었습니다.
curl 명령에서 한글을 직접 body에 넣었더니 UTF-8이 아니라 CP949 바이트로 서버에 전달되고 있었습니다.
즉 모델은 제가 쓴 한국어를 본 게 아니라 깨진 문자열을 보고 있었던 겁니다.
UTF-8 파일로 body를 저장한 뒤 --data-binary로 다시 보내니 정상적으로 confidence 1.00이 나왔습니다.
AI 성능 테스트를 하다가 인코딩 문제를 잡게 될 줄은 몰랐습니다.
그래도 덕분에 테스트 방법까지 문서에 남겨두었습니다.
드디어 로컬 모델을 돌려봤다
PC에는 RTX 2060 SUPER 8GB가 있습니다.
Ollama를 설치하고 우선 작은 모델 두 개부터 테스트했습니다.
- EXAONE 3.5 2.4B
- Qwen 2.5 3B
결과는 조금 아쉬웠습니다.
둘 다 intent 정확도가 **79.4%**였습니다.
기존 Jev는 같은 데이터에서 **98.4%**였습니다.
심지어 코드 기반 mock router도 84.1%였습니다.
속도도 예상과 달랐습니다.
EXAONE 2.4B 평균 약 358ms.
Qwen 3B 평균 약 431ms.
Jev는 약 213ms였습니다.
로컬에서 GPU로 직접 돌리는데 외부 모델보다 느렸습니다.
그리고 더 큰 문제가 하나 있었습니다.
두 작은 모델 모두 맞는 답이든 틀린 답이든 거의 무조건 confidence를 0.9 이상으로 줬습니다.
틀리면서도
“나 이거 95% 확신해.”
라고 하는 셈입니다.
이 상태에서는 confidence threshold를 안전장치로 사용할 수도 없습니다.
마지막으로 7B까지 해봤다
그래도 작은 모델만 보고 결론을 내리기는 아쉬웠습니다.
그래서 Qwen 2.5 7B까지 받아서 한 번 더 테스트했습니다.
모델 크기는 약 4.7GB.
RTX 2060 SUPER에서도 100% GPU로 실행됐습니다.
결과는 확실히 좋아졌습니다.
EXAONE 2.4B
79.4%
Qwen 3B
79.4%
Qwen 7B
88.9%
Jev
98.4%
7B 정도가 되니 작은 모델과는 차이가 꽤 났습니다.
confidence도 처음으로 조금 의미가 생겼습니다.
confidence 0.9 이상인 답만 보면 정확도가 92.7%였습니다.
그리고 현재 설정해둔 0.85 threshold와 수정·삭제 요청 fallback 규칙을 적용하면 실제 Local 결과를 사용하게 되는 43건에서는 43건 모두 정답이었습니다.
이 부분은 꽤 인상적이었습니다.
작은 로컬 모델도 조건을 잘 걸면 일부 작업 정도는 충분히 맡길 수 있겠다는 생각이 들었습니다.
하지만 문제가 하나 남았습니다.
속도입니다.
Qwen 7B 평균 응답 시간은 약 593ms.
p95는 1초 정도였습니다.
Jev는 평균 약 214ms였습니다.
그리고 모델이 GPU 메모리에서 내려간 상태에서 첫 요청을 보내면 로딩에 약 4.2초가 걸렸습니다.
그래서 비용도 계산해봤다
그러면 느리더라도 API 비용을 크게 줄일 수 있다면 사용할 이유가 있을 수 있습니다.
현재 구조대로라면 Qwen 7B가 Jev 호출의 약 68% 정도를 대신할 수 있었습니다.
그런데 Jev 비용을 계산해보니 60건 호출에 약 0.003달러 수준이었습니다.
요청 1,000건을 기준으로 계산해도 로컬 모델로 줄일 수 있는 비용이 몇 센트 수준입니다.
반면 production은 Vercel에 올라가 있습니다.
로컬 모델을 실제 서비스에 사용하려면 별도의 GPU 서버를 운영해야 합니다.
그러면 상황이 이상해집니다.
몇 센트를 아끼기 위해 GPU 서버와 새로운 인프라를 추가해야 합니다.
사용자는 응답을 더 늦게 받습니다.
정확도도 기존 시스템보다 낮습니다.
결론이 꽤 명확해졌습니다.
그래서 안 쓰기로 했다
처음 이 작업을 시작할 때 목표는 로컬 LLM을 도입해보는 것이었습니다.
그런데 하루 동안 테스트한 결과는 반대였습니다.
지금 이 서비스에는 로컬 LLM을 도입하지 않는 것이 더 낫다.
현재의 Jev가 더 정확합니다.
더 빠릅니다.
비용도 이미 충분히 쌉니다.
운영해야 할 인프라도 적습니다.
로컬 LLM이 나빠서라기보다는 현재 문제에 이미 더 적합한 도구가 있었던 것에 가깝습니다.
그래서 실험 코드는 기본값 off 상태로 남겨두고, production에서는 사용하지 않기로 했습니다.
그래도 오늘 실험은 꽤 마음에 들었다
예전 같았으면 새로운 기술이 보이면
“이걸 어디에 넣어볼까?”
부터 생각했을 것 같습니다.
오늘은 조금 달랐습니다.
직접 붙여보고,
테스트셋을 만들고,
속도를 재고,
정확도를 비교하고,
비용까지 계산한 다음,
안 쓰는 쪽을 선택했습니다.
새로운 기술을 도입하는 것 자체가 개발의 목적은 아니니까요.
그리고 또 하나 알게 된 것도 있습니다.
2~3B 정도의 작은 모델에서는 아직 제가 원하는 판정 품질이 나오지 않았지만, 7B쯤 되니 꽤 재미있는 수준까지 올라왔습니다.
조금 더 좋은 GPU나 다른 워크로드라면 결과가 또 달라질 수도 있을 것 같습니다.
그래서 오늘의 결론은 이렇게 남겨두려고 합니다.
로컬 LLM 도입은 실패했다.
라고 쓰기보다는,
로컬 LLM을 도입하지 않을 근거를 얻었다.
정도가 더 정확할 것 같습니다.
이런 실험은 꽤 재미있습니다.
