
라우터로서의 LLM: 로컬 Telegram 이메일 에이전트를 위한 의도 분류 (Intent Classification)
요약
로컬 Telegram 이메일 에이전트의 명령 계층을 구현하기 위해 LLM을 라우터로 활용하는 방법을 설명합니다. 정규 표현식 대신 LLM이 자연어를 분석하여 구조화된 JSON 의도를 반환하도록 설계하여 유연성을 높였습니다.
핵심 포인트
- 자연어 입력을 구조화된 JSON 의도로 변환하는 LLM 라우터 설계
- 정규 표현식 기반 파싱의 취약성을 LLM 기반 의도 분류로 해결
- 사용자 명령을 실제 핸들러로 전달하는 3단계 계층 구조 활용
첫 번째 기사에서 저는 Llamail 시스템 전체를 보여드렸습니다: Gmail, Telegram, n8n, FastAPI, llama.cpp, SQLite, ChromaDB, 그리고 Sable이라는 이름의 로컬 합성 어시스턴트(synthetic assistant)를 포함합니다.
두 번째 기사에서는 /search와 /ask의 내부 작동 원리를 다루었습니다: ChromaDB의 시맨틱 검색 (semantic search)과 SQLite FTS5 키워드 검색 (keyword search)을 결합한 하이브리드 검색 (hybrid retrieval) 방식입니다.
이 기사는 그 앞에 위치한 명령 계층 (command layer)에 관한 것입니다.
파트 1을 놓치셨다면, 거기서부터 먼저 시작하세요:
From Inbox to Character: Building a Private, Local AI Email Agent
파트 2는 이 라우터 (router)가 위치한 검색 계층 (retrieval layer)을 다룹니다:
How /search and /ask Work: Local Hybrid RAG with ChromaDB + SQLite FTS5
자, 서론은 끝났으니 흥미로운 부분으로 들어가 봅시다.
처음에 제 Telegram 에이전트는 직접적인 명령만 이해할 수 있었습니다:
/search budget Q2
/recent 5
/draft reply 3 agree and ask for Friday
...
이 방식도 작동은 했지만, 사람들이 어시스턴트에게 자연스럽게 말하는 방식은 아니었습니다. 그래서 저는 그냥 다음과 같이 입력하고 싶었습니다:
내 수입 상황이 어때?
지난주 예산에 관한 이메일 찾아줘
두 번째 이메일에 금요일이 괜찮다는 답장을 써줘
이를 해결하는 잘못된 방법은 구식 방식인 수많은 정규 표현식 (regexes) 뭉치를 사용하는 것입니다. 명령어가 늘어날수록 파서 (parser)는 더욱 취약해집니다. 그것은 LLM 시대의 어시스턴트가 작동해야 하는 방식이 아닙니다.
유용한 패턴은 다음과 같습니다: 정확하고 빠른 결과를 위해 정밀한 명령어를 유지하되, 메시지가 자연어 (natural language)인 경우에는 로컬 LLM을 라우터 (router)로 사용하는 것입니다. 라우터는 사용자에게 직접 답변하지 않습니다. 대신 다음과 같이 구조화된 JSON을 반환합니다:
{
"intent": "import_status",
"params": {},
...
그러면 일반적인 Python 코드가 해당 의도 (intent)를 실제 핸들러 (handler)로 전달합니다.
라우터의 3단계 계층

핵심 아이디어는 라우터가 단순히 "모든 것을 LLM에 보내는 것"이 아니라는 점입니다. 그렇게 하면 속도가 느려지고 예측 가능성이 떨어지며, 로컬이 아닌 클라우드 호스팅을 사용할 경우 비용이 더 많이 발생할 것입니다. 대신, 다음과 같이 세 가지 계층 (tiers)을 사용합니다:
사용자 메시지 (User message)
|
|-- 계층 1 (Tier 1): /slash command
...
**계층 1 (Tier 1)**은 명시적인 슬래시 명령어 (slash commands)를 위한 것입니다. 이것이 가장 빠르고 예측 가능합니다. 만약 제가 /import status라고 입력한다면, 에이전트가 제가 무엇을 의도했는지 모델에게 묻느라 몇 초를 허비해서는 안 됩니다.
**계층 2 (Tier 2)**는 소수의 직접적인 복합 명령어 (compound commands) 세트를 위한 것입니다. 현재 코드에서 해당 세트는 상당히 좁습니다:
_DIRECT_COMMANDS = {"import", "draft", "campaign", "schedule"}
이 명령어들은 보통 하위 명령어 (subcommands)를 가집니다: import status, draft reply, campaign preview, schedule list. 첫 번째 단어가 명령어 네임스페이스 (command namespace) 역할을 하기 때문에, 슬래시 없이도 인식하는 것이 안전합니다.
**계층 3 (Tier 3)**은 자연어 폴백 (natural language fallback)입니다. 메시지가 슬래시 명령어가 아니고, 앞서 언급한 직접적인 복합 명령어 제품군 중 하나도 아니라면, 라우터는 LLM에게 이를 분류하도록 요청합니다.
이것이 중요한 이유는 단순한 명령어 파서 (bare-command parser)가 일반적인 언어를 가로챌 수 있기 때문입니다. 만약 모든 첫 번째 단어를 명령어로 취급한다면, 다음과 같은 메시지는 오류를 일으킬 수 있습니다:
show me the latest emails from John
첫 번째 단어는 show이지만, 사용자는 이메일 번호를 포함한 저수준 (low-level)의 /show 명령어를 내리는 것이 아닙니다. 그들은 자연스럽게 질문하고 있습니다. 이 설계에서, 단독으로 쓰인 show는 LLM을 우회하지 않습니다. 자연어는 분류기로 전달되며, /show 3은 정확한 명령어로 남습니다.
엔트리 포인트 (The Entry Point)
Telegram 라우터는 다음 위치에 존재합니다:
webservice/src/email_service/services/telegram_handler.py
진입점은 handle_command()입니다. 이는 간소화된 형태입니다:
(상당히 많은 조건부 분기(conditional branches)가 존재하지만, 목표는 단순함과 효율성입니다)
def handle_command(text: str, chat_id: str | int = "") -> str:
text = text.strip()
if not text:
...
여기에는 놓치기 쉬운 두 가지 세부 사항이 있습니다.
첫째, 라우터(router)는 사용자 메시지와 어시스턴트(assistant)의 답변을 모두 저장합니다. 이를 통해 명령 핸들러(command handlers) 자체가 채팅 기록을 책임지게 만들지 않고도, 시스템의 대화 부분에 짧은 기억력을 부여할 수 있습니다.
둘째, 정확한 명령 경로(command path)와 자연어 경로(natural language path)는 동일한 핸들러 함수에서 끝납니다. 자연어 분류(natural language classification)는 검색, 초안 작성, 가져오기 상태 또는 캠페인 작업에 대한 두 번째 구현을 생성하지 않습니다. 단지 어떤 기존 함수를 실행해야 하는지를 결정할 뿐입니다.
슬래시 명령(Slash Commands)의 중요성
저는 여전히 일반적인 명령 디스패치 테이블(command dispatch table)을 유지하고 있습니다:
COMMAND_DISPATCH = {
"help": (lambda *_: HELP_TEXT, False),
"accounts": (lambda *_: account_info(), False),
...
이것이 설계의 실용적인 부분입니다. LLM 라우터는 에이전트가 대화하는 것처럼 느껴지게 만들지만, 명령 디스패치(command dispatch)는 제가 원하는 것이 정확히 무엇인지 알고 있을 때 사용성을 유지해 줍니다.
예를 들어:
/recent 10
/search invoice 4521
/show 2
...
이러한 것들은 지루하고 결정론적(deterministic)이어야 합니다. 왜냐하면 좋은 어시스턴트는 모든 버튼 누름을 추론 문제(reasoning problem)로 만들거나 단순한 사례에 계산 자원을 낭비해서는 안 되기 때문입니다.
분류기 프롬프트(The Classifier Prompt)
자연어 경로는 하나의 Jinja2 템플릿을 사용합니다:
webservice/src/email_service/templates/classify_intent.j2

현재 프롬프트는 의도적으로 보수적입니다:
사용자의 메시지로부터 의도(intent)를 분류하세요.
당신은 개인 로컬 이메일 시스템을 위한 Sable의 의도 라우터(intent router)입니다.
문자 그대로, 정확하고, 보수적으로 행동하세요. 실제 명령을 합리적으로 추론할 수 있다면 그것을 선택하세요.
...
이것은 이미 긴 글이기 때문에 전체 템플릿을 다 담지는 않았지만, 그 형태를 보여줍니다. 실제 버전에는 가져오기 제어(import controls), 검색(search), 질문(ask), 이메일 작업(email actions), 초안 작성(drafting), 문법 교정(grammar correction), 캠페인 관리(campaign management), 계정 정보(account info), 도움말(help), 그리고 잡담(chitchat)을 위한 분류기 가시적 의도(classifier-visible intents)들이 나열되어 있습니다. 아래의 디스패치 테이블(dispatch table)에는 예약 전송 도우미(scheduled-send helpers)를 포함하여 29개의 호출 가능한 목적지(destinations)가 포함되어 있습니다.
이 프롬프트는 몇 가지 중요한 역할을 수행합니다:
- 라우터를 단순히 분류기(classifier)로 정의합니다.
- 분류기에게 보이는 모든 목적지를 나열합니다.
- 각 의도에 필요한 파라미터(parameters)를 명시합니다.
today를 포함하여, "지난주" 또는 "지난 3일"과 같은 문구가 ISO 날짜로 변환될 수 있게 합니다.- 모든 메시지를 이메일 작업으로 강제하는 대신,
chitchat을 실제 의도로 취급합니다.
grammar 의도는 동일한 Telegram 에이전트에 내장된 작은 글쓰기 유틸리티입니다. 이는 영어가 모국어가 아닌 사용자로서 제가 매일 사용하는 유용한 도우미일 뿐입니다. 만약 제가 fix grammar: I wants to meeting on tuesday와 같이 입력하면, 라우터는 해당 텍스트만 전용 교정 프롬프트(proofreading prompt)로 보내고 교정된 버전을 반환합니다. 이는 모델에게 내 메일함을 검색하거나 전체 이메일을 작성하라고 요청하지 않고도, 답장에 붙여넣기 전에 문장을 정리하고 싶을 때 유용합니다.
grammar 규칙이 존재하는 이유는 작은 모델이 일반적인 글쓰기 요청을 교정 작업으로 지나치게 의욕적으로 해석할 수 있기 때문입니다. 이러한 가드레일(guardrails)이 없다면, "오늘 밤에 볼 만한 영화 추천해줘"와 같은 메시지가 실수로 문법 작업이 될 수 있습니다. 프롬프트는 레시피, 추천, 설명, 의견, 그리고 일반적인 잡담은 chitchat에 속한다고 모델에게 알려줍니다.
LLM 경로 (The LLM Route)
폴백 경로(fallback route)는 작습니다:
def _llm_route(text: str, chat_id: str) -> str:
try:
telegram_notifier.notify("Analyzing your message...")
...
try 블록 내부의 첫 번째 줄은 UX 트릭(UI 세계의 간단한 로더(loader)와 유사한 기능)입니다:
telegram_notifier.notify("Analyzing your message...")
로컬 추론 (Local inference)은 몇 초 정도 걸릴 수 있습니다. 만약 사용자가 n8n을 통해 자연어 메시지를 보냈는데 모델이 완료될 때까지 아무런 반응이 없다면, 에이전트가 멈춘 것처럼 느껴집니다. 알림기(notifier)는 즉시 직접적인 Telegram 푸시를 보내고, 그 후 분류(classification)와 핸들러(handler) 실행이 완료되면 정상적인 n8n 응답이 도착합니다.
이것이 정확성을 위해 필수적인 것은 아니지만, 에이전트가 더 반응성이 좋고 견고하게 느껴지도록 만들기 때문에 좋은 UX 패턴입니다.
디스패치(Dispatch)는 단순한 레지스트리(Registry)일 뿐입니다
분류기(classifier)는 의도(intent) 이름과 몇 가지 파라미터(params)를 반환합니다. 나머지는 Python이 처리합니다:
INTENT_DISPATCH = {
"import_start": (import_start, ["account", "count"]),
"import_pause": (import_pause, ["account"]),
...
이것이 이 패턴이 이해하기 쉬운 상태로 유지되는 주요 이유입니다. LLM은 데이터베이스 세션(database sessions), Gmail 클라이언트(Gmail clients), 초안 ID(draft IDs), OAuth 객체(OAuth objects) 또는 도구 객체(tool objects)를 절대 받지 않습니다. LLM은 오직 레이블(label)을 선택하고 문자열을 추출할 뿐입니다.
시스템 경계가 깔끔합니다:
LLM responsibility:
"What does the user want?"
...
이러한 분리는 더 작은 로컬 모델(local models)을 더욱 유용하게 만듭니다. 모델이 전체 작업을 해결할 필요는 없습니다. 결정론적 코드(deterministic code)가 작업을 이어받을 수 있을 정도로 의도를 충분히 잘 분류하기만 하면 됩니다.
최종 폴백(Fallback)으로서의 잡담 (Chitchat)
가장 좋은 개선 사항 중 하나는 chitchat을 명시적으로 만든 것이었습니다.
chitchat 의도가 없다면, 모든 메시지는 명령(command)이 되어야 합니다. 이는 어처구니없는 실패 모드(failure modes)를 만들어냅니다:
"thanks"
은 다음과 같이 변할 수 있습니다:
{ "intent": "search", "params": { "query": "thanks" } }
또는:
{ "intent": "ask", "params": { "question": "thanks" } }
둘 다 유용하지 않기 때문에, 저는 다음과 같은 방식으로 구현했습니다: 인사(greetings), 감사(thanks), 잡담(small talk), 레시피(recipes), 의견(opinions), 설명(explanations), 그리고 일반적인 대화(general conversation)는 모두 chitchat으로 보냅니다. 해당 핸들러(handler)는 별도의 프롬프트(prompt)를 사용합니다:
webservice/src/email_service/templates/chitchat.j2
그리고 바로 그곳이 Sable의 목소리가 위치할 곳입니다. 라우터(router) 프롬프트는 문자 그대로(literal) 그리고 보수적으로 유지되며, 페르소나(persona) 프롬프트가 일반적인 대화를 처리합니다.
이러한 분리는 매우 중요한데, 라우터는 역할극(roleplay)을 해서는 안 되기 때문입니다. 라우터는 그저 경로를 지정(route)해야 합니다.
자연어 예시 (Natural Language Examples)
다음은 분류기(classifier)가 수행해야 하는 작업의 예시입니다:
how is my import going?
{ "intent": "import_status", "params": {}, "confidence": "high" }
find emails about the budget from last week
{
"intent": "search",
"params": {
...
what did John say about the proposal?
"intent": "ask",
"params": {
...
fix grammar: I wants to meeting on tuesday
{ "intent": "grammar", "params": {
...
검색(search) 예시는 왜 today가 프롬프트에 주입(inject)되는지를 보여줍니다. 분류기는 검색 핸들러(search handler)가 실행되기 전에 상대적인 구절(relative phrases)을 실제 날짜로 변환할 수 있습니다.
새로운 의도(Intent)를 추가하는 방법
새로운 자연어 명령(natural-language command)을 추가하려면 세 곳을 업데이트해야 합니다.
첫째, 적절한 모듈(module)에 핸들러(handler)를 작성합니다:
cmd_email.py
cmd_import.py
cmd_draft.py
...
둘째, 모델이 추출해야 할 파라미터(parameters)를 포함하여 classify_intent.j2에 의도(intent)를 추가합니다.
셋째, INTENT_DISPATCH에 핸들러(handler)를 추가합니다.
이것으로 끝입니다. 이 솔루션의 묘미는 균형을 맞추기 위해 유지해야 할 중앙 정규 표현식(regex) 트리도 없고, 해당 기능에 대한 별도의 자연어 구현도 필요 없다는 점입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기