
LLM 비용을 최적화할까? AI Router를 구현해 보았다
요약
LLM의 비용과 추론 시간을 최적화하기 위해 프롬프트와 컨텍스트를 분석하여 최적의 모델을 선택하는 AI Router 'cobaiter' 개발 사례를 소개합니다. 질문의 난이도와 모델의 성능, 비용을 고려하여 모델을 동적으로 선출하는 메커니즘을 다룹니다.
핵심 포인트
- LLM 비용(금전적 비용 및 추론 시간) 최적화를 위한 AI Router 구현
- 질문의 관련성(relevance)과 난이도(difficulty)를 기반으로 최적 모델 선출
- LiteLLM의 리버스 프록시로서 OpenAI API 호환 방식으로 동작
- 대화 일관성을 위해 임계값 기반의 모델 재선출 로직 적용
이 기사는 「Medley Summer Tech Blog Relay」의 7일째 기사입니다.
주식회사 Medley의 인재 플랫폼 본부 내, SRE 그룹에서 엔지니어로 근무하고 있는 사이토(@bbrfkr)입니다.
이번에는 최근 LLM을 사용하면서 느끼고 있던 답답함을 개선하기 위해, 개인적인 프로젝트이지만 실험적으로 개발하고 있는 AI Router 「cobaiter」에 대해 이야기해 보겠습니다.
Medley에서는 생성 AI 이용 가이드라인이 사내에 전개되어 있으며, 각 부문의 업무에서는 그 가이드라인에 따라 이용하고 있습니다.
LLM에는 실제로 지불하는 금전적인 비용과, 응답이 돌아오기까지의 추론 시간(Inference Time)이라는 비용 두 가지가 있습니다. 하지만 하나의 LLM만을 계속 사용하고 있으면, 이 두 가지를 모두 잘 최적화하고 있다고 말하기 어려운 상황이었습니다. 간단한 질문에도 고성능의 비싼 모델을 사용해 버리거나, 반대로 어려운 질문에 성능이 낮은 모델을 할당하는 등, 모델 선택이 고정되어 있기 때문에 발생하는 낭비가 생깁니다. 실제로 모델 전환이 번거로워서 어느샌가 Claude의 Opus에 계속 요청을 보내고 있었다는 식의 상황은 흔히 발생하곤 하죠...
그래서 조금 조사해 보았습니다만, 프롬프트 내용에 따라 최적의 모델을 골라내는 「라우팅 (Routing)」을 수행하는 프로젝트는 제가 찾은 범위 내에서는 그리 많이 보이지 않았습니다. 게다가 자신의 사용 방식에 맞춰 라우팅 동작을 세밀하게 튜닝하고 싶다는 목적도 있었기 때문에, 기존 도구를 찾는 것보다 직접 개발해 버리기로 생각했습니다. 그리고 탄생한 것이 cobaiter (Context Based AI Router)입니다. 로컬 LLM도 사용할 수 있고 Commit & Push & PR 생성 등은 로컬에서 처리할 수 있으므로, 개인적으로는 현재 이 라우터를 활용 중입니다!
cobaiter는 프롬프트나 지금까지의 대화 컨텍스트 (Context)를 바탕으로, 주어진 모델 리스트 중에서 답변에 최적인 모델을 선출합니다. 최적인지 여부를 판단하기 위해서는 프롬프트 및 컨텍스트를 다음 두 가지 관점에서 평가한 연속값을 사용합니다.
- 질문이 해당 모델의 특기 영역에 얼마나 부합하는가 (relevance)
- 답변의 난이도 (difficulty)
나아가 각 모델에는 능력 수준을 나타내는 「tier」를 설정하여, tier가 높은 모델일수록 난이도가 높은 문제에 대응할 수 있는 반면 응답 속도는 느려진다는 전제로 페널티를 부여합니다. 마찬가지로 모델의 이용 요금 (cost)에 따라서도 페널티를 부여하여, 성능과 속도·비용의 균형을 맞춘 선출을 수행합니다.
또한, 한 번의 대화 속에서 답변의 일관성을 유지하기 위해, 일단 어떤 모델을 선택하면 당분간은 같은 모델을 계속 사용하도록 하고 있습니다. 다만, 대화가 진행됨에 따라 컨텍스트가 변화하여 답변 난이도나 질문 분류가 바뀌는 경우에는 임계값 (Threshold)을 기준으로 모델을 재선출할 수 있도록 설계했습니다.
cobaiter 자체는 OpenAI API 호환 프록시 (Proxy)로 동작하며, 어디까지나 모델 선출만을 담당합니다. 실제로 추론을 수행하는 유효한 모델 군은 LiteLLM 측에서 관리하고, cobaiter는 그 LiteLLM 앞에 서는 리버스 프록시 (Reverse Proxy)로서 동작한다는 전제로 설계했습니다.
cobaiter는 OpenAI 호환 프록시로 동작합니다. 에이전트 측은 모델명을 의식하지 않고, 가상 모델 cobaiter-auto를 호출하는 것만으로 동작합니다.
cobaiter는 OSS로 제공되는 LLM 게이트웨이 「LiteLLM」의 리버스 프록시로 동작합니다. 따라서 이용 전에 LiteLLM을 구축해 두어야 합니다. 또한 실제로 호출할 모델 군은 LiteLLM 측에 설정해 두어야 합니다. cobaiter는 모델 선출만을 담당하며, 모델 관리는 수행하지 않습니다.
나아가 대화 상태나 모델 레지스트리 (Registry)의 영속화를 위해 Valkey를 사용합니다.
기동은 Docker Compose로 할 수 있습니다.
# .env 와 litellm_config.yaml 을 편집
cp .env.example .env
# valkey + litellm + cobaiter 세트를 기동
...
모델 리스트는 코드에 하드코딩하지 않고, models.yaml이라는 외부 파일로 관리합니다.
models:
- model: bbrfkr-llm-general # LiteLLM 이 공개하는 실제 모델명을 지정
description: 범용 대화·추론·문장 작성용
...
여기서 지정하는 description이나 task_examples
는 어디까지나 해당 모델의 '용도'만을 나타내는 자유 문구입니다. difficulty나 cost, 답변 속도에 대해서는 여기에 포함하지 않습니다. 이것들은 별도의 축으로서 평가하고 있으며, 이를 섞어버리면 embedding에 의한 분리가 무뎌지기 때문입니다.
호출 방식은 OpenAI 호환이므로, 기존의 에이전트 구현을 거의 그대로 유용할 수 있습니다.
$ curl -s localhost:8080/v1/chat/completions -d '{"model":"cobaiter-auto","messages":[{"role":"user","content":"こんにちは。"}]}' | jq '.choices[0].message.content'
"こんにちは!何かお手伝いできることはありますか?"
세세한 부분이지만, 응답 헤더(response header)의 x-cobaiter-route를 보면, 해당 답변이 새롭게 선정된 것인지, 기존 모델을 고정하여 계속 사용 중인 것인지, 문맥의 변화에 따른 재선정인지, 혹은 페일오버(failover)에 의한 것인지를 알 수 있도록 되어 있습니다.
# 첫 번째 호출은 새로운 대화이므로, route는 「classifier-select」로 신규 선정됨
$ curl -s localhost:8080/v1/chat/completions -d '{"model":"cobaiter-auto","messages":[{"role":"user","content":"こんにちはー。"}]}' -v > /dev/null
...
...
라우터의 내부를 다시 살펴보면, 생성형 LLM에 의한 고도의 분류를 수행하는 것이 아니라, relevance와 difficulty라는 두 가지 축만으로 판단하고 있다는 점이 포인트입니다.
relevance는 프롬프트로부터 만들어진 다이제스트(digest)와 각 모델의 task_examples (설정되지 않았다면 description) 간의 embedding 유사도(top-2-mean cosine), 즉 유사한 용어·문장인지 여부로 산출합니다.
difficulty는 요약이나 번역, 제목 생성과 같은 메타 태스크(meta-task) 어구가 포함된 경우에는 결정적으로 저난도로 판단하며, 그 외에는 '쉬운 태스크 집합', '어려운 태스크 집합'과의 유사도 비(relevance와 동일한 알고리즘)로부터 산출합니다.
이 두 축(relevance와 difficulty)은,
capability_fit = 1 − max(0, difficulty − tier/maxTier)
를 구함으로써 해당 모델이 컨텍스트에 대해 어느 정도 답변 가능한지를 0~1 사이의 연속량으로 산출하고,
suitability = relevance × capability_fit
를 구함으로써 추가로 cost/tier의 페널티로 재순위화(re-ranking)한다는 결정적인 코드에 의해 평가됩니다.
이를 통해 embedding 호출은 1 요청당 기본적으로 1회로 충분하기 때문에, 1회의 LLM 호출에서 라우팅에 필요한 레이턴시(latency)는 40-50ms 정도로 억제되었습니다.
내부 구현 확인을 위해, 다음과 같은 질문들을 cobaiter를 경유하여 AI에게 질문해 보았습니다.
-
간단한 인사
-
「こんにちは。」
-
IT 분야에 관한 질문
-
「jq 명령에서 .model과 .choices[].message.content를 모두 표시하는 쿼리를 알려주세요」
-
수학적인 질문
-
「괴델의 불완전성 정리를 증명해 주세요」
-
물리학적인 질문
-
「양자 계산에서 큐비트를 복제할 수 없음을 증명해 주세요」
cobaiter의 배후에 있는 LiteLLM에는 다음과 같은 모델들이 대기하고 있는 상태에서 검증을 진행합니다.
-
bbrfkr-llm-general
-
일반 질문 답변용, thinking 있음
-
백엔드는 로컬 모델인 「gemma4 26B A4B」
-
bbrfkr-llm-general-no-think
-
일반 질문 답변용, thinking 없음
-
백엔드는 로컬 모델인 「gemma4 26B A4B」
-
bbrfkr-llm-coding
-
코딩 질문 답변용, thinking 있음
-
백엔드는 로컬 모델인 「Qwen 3.6 35B A3B"
-
bbrfkr-llm-coding-no-think
-
코딩 질문 답변용, thinking 없음
-
백엔드는 로컬 모델인 「Qwen 3.6 35B A3B"
-
gpt-5.5
-
OpenAI에서 친숙한 모델
-
claude-sonnet-5
-
Claude에서 친숙한 모델
결과는 다음과 같았습니다.
$ curl -s localhost:8080/v1/chat/completions -d '{"model":"cobaiter-auto","messages":[{"role":"user","content":"こんにちは。"}]}' | jq '{ model: .model }'
{
"model": "bbrfkr-llm-general-no-think"
...
간단한 인사는 로컬 모델 중에서도 thinking (사고 과정)이 필요 없는 「bbrfkr-llm-general-no-think」로 흘러가며, 빠른 답변을 기대할 수 있습니다. jq 명령어 사용법에 관한 질문은 일반적인 질문이라기보다 IT 분야의 질문에 가깝기 때문에, 로컬 모델이면서 코딩 답변용인 「bbrfkr-llm-coding」으로 흘러갔습니다. 「괴델의 불완전성 정리」와 같은 기지의 수학적 질문에는 로컬 모델 중 thinking이 있는 「bbrfkr-llm-general」이 선정되었고, 양자역학 질문과 같은 전문적인 물리학 질문에는 강력한 클라우드 모델인 「gpt-5.5」가 선정되는 것을 확인할 수 있습니다.
대체로 기대했던 대로라고 할 수 있겠지요.
또한 구현 확인을 위해 몇 가지 자동 테스트를 준비했습니다.
- 사전에 정의된 embedding (임베딩) 공간을 사용하는, 외부 통신이 없는 유닛 테스트 (Unit Test)
- 실제 모델을 사용하는 테스트 케이스
tests/fixtures/routing_cases.yaml
자세한 내용은 테스트 케이스를 확인해 주시기 바랍니다만, 실제 모델을 사용하는 테스트 케이스를 통해 확인한 점은 다음과 같습니다. 「안녕하세요」라는 한마디는 로컬의 경량 범용 모델(bbrfkr-llm-general-no-think)에 도달하며, 코딩 계열 모델로는 흐르지 않습니다. 반면, 「이 Python 코드의 버그를 수정해 주세요」, 「코드 리뷰를 부탁합니다」, 「마이크로서비스 간의 통신 방식으로 고민 중입니다」와 같은 태스크 (Task)는 코딩 계열 모델(또는 고티어 범용 모델)에 도달하며, 경량 범용 모델은 피하게 됩니다. 「영문을 일본어로 번역해 줘」, 「교토 여행 계획을 세우고 싶어」라면 범용 모델에 도달하며, 코딩 계열 모델은 피하게 됩니다.
특히 확인하고 싶었던 부분은 경계 사례 (Edge Case)입니다. 「이 API의 요금 체계에 대해 알려주세요」라는 질문에는 "API"라는 코딩에 가까운 단어가 포함되어 있지만, 실제 의도(요금 상담)에 따라 범용 모델 쪽으로 올바르게 도달합니다. 또한 「거시 경제 정책이 환율에 미치는 영향에 대해 다각적으로 논하라」는 태스크는 difficulty (난이도)는 높게 측정되지만, 도메인 (Domain) 측면에서는 범용 상태를 유지하여 코딩 계열 모델로 흐르지 않습니다.
difficulty와 relevance (관련성)에 의한 도메인 판정이 독립적으로 기능하고 있다는 것을 이러한 사례들을 통해 확인할 수 있었습니다. 이러한 경계 사례들을 일부러 테스트에 포함시킨 이유는, 다루는 도메인을 향후 늘려갈 것을 전제로, 도메인이 증가하더라도 relevance의 분리가 깨지지 않는지 지속적으로 감시하기 위해서입니다.
이번에는 「하나의 LLM에 고정해서 계속 사용하면, 간단한 태스크에도 고성능·고비용 모델을 할당하게 된다」는 답답함에서 시작하여, 대화 내용에 따라 모델을 자동 선정하는 자체 루터(Router)인 cobaiter를 만들었습니다.
판정 로직에는 생성형 LLM을 사용하지 않고, embedding과 결정론적인 heuristic (휴리스틱)만으로 relevance와 difficulty를 산출하기 때문에, 레이턴시 (Latency)와 비용을 억제하면서도 자신의 취향에 맞춰 세밀하게 튜닝할 수 있습니다. 또한 하나의 대화 내에서는 모델을 고정하고, 문맥이 실질적으로 변했을 때만 hysteresis (히스테리시스)를 적용하여 재선정함으로써 답변의 일관성과 유연성을 양립시켰습니다. 모델의 용도나 비용, tier (티어)와 같은 정보는 모두 외부 설정 파일로 관리하고 있어, 코드를 변경하지 않고도 튜닝할 수 있다는 점도 마음에 듭니다.
참고로 아쉬운 점은, cobaiter를 사용하면 아무래도 LLM API를 호출해야 하므로 구독 계약만으로 완결되지 않는다는 것입니다. 다만, LiteLLM에는 결제 금액 제한을 걸 수 있으므로, 한 달에 총 30달러까지만 사용할 수 있도록 제어하여 어떻게든 해결하고 있습니다. 
향후 과제로는 대응 도메인(description/task_examples)의 확충, 페일오버 (Failover) 및 크레딧 고갈 관련 실운용 검증 등이 남아 있습니다. 코드는 공개되어 있으니, 관심이 있다면 직접 만져보시기 바랍니다.
Medley에서는 의료 헬스케어의 과제를 해결하기 위해 다양한 영역의 전문가를 찾고 있습니다!
관심이 있으시다면 꼭 연락해 주세요.
8일째는 @huku_developer 님입니다. 기대해 주세요!
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기