본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 12. 3. · 13 Views
라우팅 에이전트 구현 완벽 가이드
사용자의 의도를 파악하여 적절한 처리 경로로 안내하는 라우팅 에이전트를 처음부터 끝까지 구현합니다. LLM 기반 의도 분류부터 스트림릿 UI까지 실무에서 바로 활용할 수 있는 내용을 다룹니다.
목차
1. 라우터 클래스 설계
김개발 씨는 회사에서 고객 문의를 자동으로 처리하는 챗봇을 만들라는 지시를 받았습니다. 문제는 고객들이 환불, 배송, 상품 문의 등 다양한 질문을 하는데, 이걸 어떻게 적절한 담당자에게 연결할 수 있을지 막막했습니다.
선배 박시니어 씨가 다가와 말했습니다. "라우터 패턴을 써봐요.
마치 전화 교환원처럼 말이죠."
라우터 클래스는 들어오는 요청을 분석하여 적절한 처리 경로로 안내하는 중앙 관제탑입니다. 마치 공항의 관제탑이 각 비행기를 적절한 활주로로 안내하듯이, 라우터는 사용자의 질문을 알맞은 핸들러로 연결합니다.
잘 설계된 라우터는 시스템의 확장성과 유지보수성을 크게 향상시킵니다.
다음 코드를 살펴봅시다.
from dataclasses import dataclass
from enum import Enum
from typing import Callable, Dict, Optional
# 라우팅 가능한 경로를 열거형으로 정의합니다
class RouteType(Enum):
REFUND = "refund"
SHIPPING = "shipping"
PRODUCT = "product"
GENERAL = "general"
# 라우터의 핵심 구조를 담는 데이터 클래스
@dataclass
class Router:
routes: Dict[RouteType, Callable]
default_route: RouteType = RouteType.GENERAL
def register(self, route_type: RouteType, handler: Callable):
# 새로운 경로와 핸들러를 등록합니다
self.routes[route_type] = handler
def get_handler(self, route_type: RouteType) -> Callable:
# 해당 경로의 핸들러를 반환하거나 기본값 사용
return self.routes.get(route_type, self.routes[self.default_route])
김개발 씨는 입사 6개월 차 주니어 개발자입니다. 이번에 맡은 프로젝트는 고객 문의 자동화 시스템인데, 처음에는 단순히 if-else 문으로 처리하면 되겠다고 생각했습니다.
하지만 요구사항을 정리하다 보니 문제가 보이기 시작했습니다. 환불 문의, 배송 추적, 상품 정보, 일반 문의...
종류가 벌써 네 가지나 되고, 앞으로 더 늘어날 예정이었습니다. if-else 문이 끝없이 이어질 것 같은 불길한 예감이 들었습니다.
박시니어 씨가 화이트보드 앞에 섰습니다. "라우터 패턴을 생각해봐요.
전화 교환원을 떠올려보세요." 옛날 전화 교환원은 어떻게 일했을까요? 전화가 오면 먼저 용건을 파악합니다.
"환불 문의요? 2번 부서로 연결해드릴게요." 교환원은 모든 업무를 직접 처리하지 않습니다.
대신 적절한 담당 부서로 연결해주는 역할을 합니다. 라우터 클래스도 마찬가지입니다.
사용자의 요청이 들어오면 그 의도를 파악하고, 적절한 핸들러로 연결합니다. 직접 처리하는 것이 아니라 연결만 담당하는 것이죠.
코드를 살펴보겠습니다. 먼저 RouteType 열거형을 정의했습니다.
이것은 우리 시스템이 처리할 수 있는 모든 경로의 목록입니다. 환불, 배송, 상품, 일반 문의 네 가지를 정의했습니다.
왜 문자열 대신 열거형을 사용할까요? "refund"라는 문자열은 오타가 나도 파이썬이 잡아주지 않습니다.
하지만 RouteType.REFUN처럼 잘못 쓰면 즉시 에러가 발생합니다. 이렇게 타입 안전성을 확보할 수 있습니다.
다음으로 Router 데이터 클래스를 보겠습니다. routes 딕셔너리는 각 경로와 그에 해당하는 핸들러 함수를 매핑합니다.
default_route는 어떤 경로에도 해당하지 않을 때 사용할 기본값입니다. register 메서드는 새로운 경로를 등록합니다.
나중에 "교환 문의"라는 새 경로가 필요하면 이 메서드만 호출하면 됩니다. 기존 코드를 수정할 필요가 없습니다.
get_handler 메서드는 경로에 해당하는 핸들러를 찾아 반환합니다. 만약 등록되지 않은 경로라면 기본 핸들러를 반환합니다.
이것이 바로 폴백 처리의 시작점입니다. 실무에서는 이 구조 위에 LLM을 얹어 의도를 자동으로 분류합니다.
김개발 씨는 드디어 전체 그림이 보이기 시작했습니다.
실전 팁
💡 - 열거형을 사용하면 오타로 인한 버그를 컴파일 타임에 잡을 수 있습니다
- 데이터 클래스는 보일러플레이트 코드를 줄여주고 불변성을 보장합니다
- 기본 경로를 반드시 설정하여 예외 상황에 대비하세요
2. 의도 분류 프롬프트 작성
라우터의 뼈대는 완성됐지만, 김개발 씨에게 새로운 고민이 생겼습니다. "고객이 '제품 언제 와요?'라고 물으면 이게 배송 문의인지 어떻게 알죠?" 박시니어 씨가 웃으며 답했습니다.
"그래서 LLM이 필요한 거예요. 프롬프트를 잘 작성하면 LLM이 의도를 분류해줍니다."
의도 분류 프롬프트는 LLM에게 사용자의 질문이 어떤 카테고리에 속하는지 판단하도록 요청하는 지시문입니다. 마치 신입 직원에게 업무 매뉴얼을 주는 것처럼, 프롬프트는 LLM에게 분류 기준과 출력 형식을 명확히 알려줍니다.
좋은 프롬프트는 모호한 질문도 정확하게 분류할 수 있게 해줍니다.
다음 코드를 살펴봅시다.
# 의도 분류를 위한 시스템 프롬프트
CLASSIFICATION_PROMPT = """
당신은 고객 문의를 분류하는 전문가입니다.
사용자의 질문을 분석하여 다음 카테고리 중 하나로 분류하세요.
## 카테고리 정의
- refund: 환불, 취소, 반품 관련 문의
- shipping: 배송, 도착 예정일, 배송 추적 관련 문의
- product: 상품 정보, 재고, 사양 관련 문의
- general: 위 카테고리에 해당하지 않는 일반 문의
## 출력 형식
반드시 카테고리명만 소문자로 출력하세요.
예시: refund
## 사용자 질문
{user_query}
"""
def create_classification_prompt(user_query: str) -> str:
# 사용자 질문을 프롬프트에 삽입합니다
return CLASSIFICATION_PROMPT.format(user_query=user_query)
김개발 씨는 고민에 빠졌습니다. 고객들은 정말 다양한 방식으로 질문합니다.
"환불해주세요", "돈 돌려받고 싶어요", "취소할래요"... 모두 같은 의도인데 표현이 다릅니다.
이걸 일일이 키워드로 매칭하려면 끝이 없을 것 같았습니다. 바로 이 지점에서 LLM의 힘이 빛을 발합니다.
LLM은 문맥을 이해합니다. "물건 언제 와요?"라는 질문에서 배송 문의라는 의도를 파악할 수 있습니다.
하지만 LLM도 명확한 지시가 필요합니다. 프롬프트 엔지니어링은 마치 신입 직원 교육과 같습니다.
아무리 똑똑한 신입이라도 회사의 분류 체계를 모르면 제대로 일할 수 없습니다. 프롬프트는 그 매뉴얼 역할을 합니다.
코드의 프롬프트 구조를 살펴보겠습니다. 첫 줄에서 LLM의 역할을 정의합니다.
"당신은 고객 문의를 분류하는 전문가입니다." 이렇게 역할을 부여하면 LLM이 그에 맞게 행동합니다. 다음으로 카테고리 정의 섹션이 중요합니다.
각 카테고리가 무엇을 의미하는지 구체적인 예시와 함께 설명합니다. "refund: 환불, 취소, 반품 관련 문의"처럼 키워드를 나열하면 LLM이 유사한 표현도 매칭할 수 있습니다.
출력 형식을 명시하는 것도 핵심입니다. "반드시 카테고리명만 소문자로 출력하세요"라고 못 박아두지 않으면, LLM이 "이 질문은 환불 관련으로 보입니다.
따라서 refund입니다."처럼 장황하게 답할 수 있습니다. 그러면 파싱이 어려워집니다.
예시를 제공하는 것도 좋은 전략입니다. "예시: refund"처럼 기대하는 출력의 구체적인 모습을 보여주면 LLM이 형식을 정확히 따릅니다.
마지막으로 {user_query} 플레이스홀더를 사용했습니다. 파이썬의 format 메서드로 실제 사용자 질문을 삽입합니다.
이렇게 하면 프롬프트 템플릿을 재사용할 수 있습니다. 흔히 하는 실수 중 하나는 카테고리 정의를 모호하게 작성하는 것입니다.
"product: 상품 관련"이라고만 쓰면, 상품 환불 문의가 product로 분류될 수 있습니다. 구체적인 예시를 들어 경계를 명확히 해야 합니다.
김개발 씨는 프롬프트를 작성하며 깨달았습니다. LLM에게 일을 시키는 것도 결국 커뮤니케이션이라는 것을요.
실전 팁
💡 - 카테고리 간 경계가 모호한 케이스를 미리 정의해두세요
- 출력 형식을 엄격하게 지정하여 파싱 오류를 방지하세요
- Few-shot 예시를 추가하면 분류 정확도가 높아집니다
3. 라우팅 로직 구현
프롬프트까지 완성한 김개발 씨는 이제 실제로 LLM을 호출하여 분류 결과를 받아야 합니다. "프롬프트를 LLM에 보내고, 응답을 받아서, 해당 경로로 연결하면 되겠네요!" 말은 쉽지만, 실제 구현에는 예외 처리와 검증 로직이 필요합니다.
라우팅 로직은 LLM의 분류 결과를 받아 실제 핸들러로 연결하는 핵심 흐름입니다. 마치 네비게이션이 목적지를 입력받아 최적 경로를 계산하듯이, 라우팅 로직은 사용자 질문에서 의도를 추출하고 적절한 처리 경로를 결정합니다.
안정적인 서비스를 위해 응답 검증과 예외 처리가 필수입니다.
다음 코드를 살펴봅시다.
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage
class RoutingAgent:
def __init__(self):
self.llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
self.router = Router(routes={})
def classify_intent(self, user_query: str) -> RouteType:
# LLM을 호출하여 의도를 분류합니다
prompt = create_classification_prompt(user_query)
response = self.llm.invoke([HumanMessage(content=prompt)])
# 응답을 정제하고 RouteType으로 변환합니다
intent = response.content.strip().lower()
try:
return RouteType(intent)
except ValueError:
# 알 수 없는 응답이면 기본 경로 반환
return RouteType.GENERAL
def route(self, user_query: str) -> str:
# 의도 분류 후 해당 핸들러 실행
intent = self.classify_intent(user_query)
handler = self.router.get_handler(intent)
return handler(user_query)
김개발 씨는 드디어 핵심 로직을 구현할 차례입니다. 지금까지 만든 Router 클래스와 프롬프트를 연결하여 실제로 동작하는 에이전트를 만들어야 합니다.
RoutingAgent 클래스의 생성자를 보겠습니다. LangChain의 ChatOpenAI를 사용하여 LLM 인스턴스를 만듭니다.
여기서 temperature=0으로 설정한 것이 중요합니다. 분류 작업은 창의성이 아닌 일관성이 필요하기 때문입니다.
temperature가 높으면 같은 질문에도 다른 분류 결과가 나올 수 있습니다. classify_intent 메서드가 의도 분류의 핵심입니다.
먼저 앞서 만든 프롬프트 함수로 완성된 프롬프트를 생성합니다. 그다음 LLM을 호출하여 응답을 받습니다.
응답을 받은 후에는 반드시 정제 과정이 필요합니다. strip()으로 앞뒤 공백을 제거하고, lower()로 소문자로 변환합니다.
LLM이 "Refund" 또는 "REFUND"로 답할 수도 있기 때문입니다. 그다음 try-except 블록이 중요합니다.
**RouteType(intent)**는 문자열을 열거형으로 변환하는데, 만약 LLM이 정의되지 않은 값을 반환하면 ValueError가 발생합니다. 이때 기본 경로인 GENERAL을 반환하여 시스템이 멈추지 않도록 합니다.
route 메서드는 전체 흐름을 조율합니다. 의도를 분류하고, 해당 핸들러를 가져와서 실행합니다.
단 세 줄이지만 모든 조각이 여기서 하나로 합쳐집니다. 실무에서 주의할 점이 있습니다.
LLM API 호출은 네트워크 통신이므로 실패할 수 있습니다. 타임아웃, 네트워크 오류, API 한도 초과 등 다양한 예외가 발생할 수 있습니다.
프로덕션 코드에서는 이런 예외 처리가 반드시 필요합니다. 또한 LLM의 응답 시간은 일정하지 않습니다.
빠르면 몇 백 밀리초, 느리면 몇 초가 걸릴 수 있습니다. 사용자 경험을 위해 로딩 표시나 스트리밍 응답을 고려해야 합니다.
김개발 씨는 코드를 실행해보며 뿌듯함을 느꼈습니다. "제품 언제 도착해요?"라고 입력하니 정말로 shipping이 반환되었습니다.
하지만 아직 핸들러가 없어서 에러가 났습니다. 다음 단계로 넘어가야 할 때입니다.
실전 팁
💡 - temperature=0으로 설정하여 분류 결과의 일관성을 확보하세요
- LLM 응답은 항상 정제하고 검증해야 합니다
- API 호출 실패에 대비한 재시도 로직을 추가하세요
4. 경로별 핸들러 작성
라우팅은 잘 되는데 막상 연결할 핸들러가 없습니다. 김개발 씨는 각 경로별로 실제 처리 로직을 구현해야 합니다.
"환불 문의가 들어오면 뭘 해야 하죠?" 박시니어 씨가 답했습니다. "각 핸들러는 그 도메인의 전문가예요.
환불 핸들러는 환불 정책을 알고, 배송 핸들러는 배송 정보를 알죠."
핸들러는 특정 경로에 대한 실제 처리를 담당하는 전문가입니다. 마치 병원에서 내과, 외과, 소아과 의사가 각자의 전문 분야를 담당하듯이, 각 핸들러는 자신의 도메인에 특화된 응답을 생성합니다.
핸들러는 독립적으로 개발하고 테스트할 수 있어 유지보수가 용이합니다.
다음 코드를 살펴봅시다.
# 각 경로별 전문 핸들러를 정의합니다
HANDLER_PROMPTS = {
RouteType.REFUND: """당신은 환불 전문 상담원입니다.
환불 정책: 구매 후 7일 이내 전액 환불, 14일 이내 50% 환불
사용자 문의: {query}
친절하게 답변하세요.""",
RouteType.SHIPPING: """당신은 배송 전문 상담원입니다.
배송 정보: 일반 배송 2-3일, 특급 배송 익일 도착
사용자 문의: {query}
구체적인 배송 정보를 안내하세요.""",
RouteType.PRODUCT: """당신은 상품 전문 상담원입니다.
재고 확인과 상품 상세 정보를 안내합니다.
사용자 문의: {query}
정확한 정보를 제공하세요.""",
}
def create_handler(route_type: RouteType, llm: ChatOpenAI):
# 클로저를 사용하여 핸들러 함수를 생성합니다
def handler(query: str) -> str:
prompt = HANDLER_PROMPTS.get(route_type, "질문: {query}")
response = llm.invoke([HumanMessage(content=prompt.format(query=query))])
return response.content
return handler
김개발 씨는 핸들러의 역할을 이해하기 시작했습니다. 라우터가 교환원이라면, 핸들러는 실제로 전화를 받는 각 부서의 담당자입니다.
환불팀은 환불 정책을 속속들이 알고 있고, 배송팀은 배송 현황을 실시간으로 파악합니다. 코드에서 HANDLER_PROMPTS 딕셔너리를 정의했습니다.
각 RouteType에 대해 전문화된 시스템 프롬프트를 미리 작성해둡니다. 환불 핸들러의 프롬프트에는 환불 정책이 명시되어 있고, 배송 핸들러에는 배송 소요 시간 정보가 포함되어 있습니다.
이렇게 하면 각 핸들러가 도메인 지식을 갖게 됩니다. 일반적인 LLM은 구체적인 환불 정책을 모릅니다.
하지만 프롬프트에 정책을 주입하면 마치 그 정책을 아는 것처럼 답변할 수 있습니다. create_handler 함수는 클로저를 사용합니다.
클로저는 함수가 정의될 때의 환경을 기억하는 기법입니다. route_type과 llm 변수가 내부 handler 함수에서 사용되지만, handler 함수가 반환된 후에도 이 변수들을 기억합니다.
왜 클로저를 사용할까요? 핸들러를 등록할 때 route_type별로 다른 프롬프트를 사용해야 하는데, 클로저를 쓰면 각 핸들러가 자신의 프롬프트를 기억합니다.
팩토리 패턴의 일종이라고 볼 수 있습니다. 실무에서는 핸들러가 더 복잡해집니다.
단순히 프롬프트만 다른 게 아니라, 데이터베이스를 조회하거나 외부 API를 호출해야 할 수 있습니다. 환불 핸들러는 주문 DB를 조회하고, 배송 핸들러는 물류사 API를 호출할 수 있습니다.
이런 경우 핸들러를 클래스로 만드는 것이 좋습니다. 각 핸들러 클래스가 필요한 의존성을 주입받고, 복잡한 비즈니스 로직을 캡슐화할 수 있습니다.
핸들러의 또 다른 장점은 독립적인 테스트가 가능하다는 것입니다. 환불 핸들러만 따로 테스트할 수 있습니다.
이것이 관심사 분리의 힘입니다. 김개발 씨는 각 핸들러를 라우터에 등록했습니다.
이제 시스템이 완전한 형태를 갖추기 시작했습니다.
실전 팁
💡 - 핸들러 프롬프트에 도메인 지식을 명시적으로 주입하세요
- 복잡한 핸들러는 클래스로 분리하여 의존성을 관리하세요
- 각 핸들러를 독립적으로 테스트할 수 있는 구조를 유지하세요
5. 폴백 처리 구현
테스트를 하던 김개발 씨가 이상한 질문을 입력해봤습니다. "오늘 날씨 어때?" 시스템이 멈춰버렸습니다.
박시니어 씨가 말했습니다. "예상치 못한 입력은 항상 들어와요.
폴백 처리가 필수입니다." 어떤 상황에서도 시스템이 멈추지 않도록 안전장치를 마련해야 합니다.
폴백 처리는 예상치 못한 상황에서 시스템이 우아하게 대응하는 안전장치입니다. 마치 비상구가 평소에는 쓰이지 않지만 위급할 때 생명을 구하듯이, 폴백은 시스템의 안정성을 보장합니다.
분류 실패, 핸들러 오류, API 타임아웃 등 다양한 예외 상황을 처리합니다.
다음 코드를 살펴봅시다.
class RoutingAgentWithFallback:
def __init__(self):
self.llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
self.router = Router(routes={})
self._setup_handlers()
def _setup_handlers(self):
# 모든 경로에 핸들러를 등록합니다
for route_type in RouteType:
self.router.register(route_type, create_handler(route_type, self.llm))
# 폴백 핸들러를 별도로 등록합니다
self.fallback_handler = self._create_fallback_handler()
def _create_fallback_handler(self):
def fallback(query: str) -> str:
# 어떤 카테고리에도 해당하지 않을 때의 응답
return f"죄송합니다. '{query}'에 대해 정확한 답변을 드리기 어렵습니다. " \
"환불, 배송, 상품 관련 문의를 도와드릴 수 있습니다."
return fallback
def route(self, user_query: str) -> str:
try:
intent = self.classify_intent(user_query)
handler = self.router.get_handler(intent)
return handler(user_query)
except Exception as e:
# 모든 예외 상황에서 폴백 실행
return self.fallback_handler(user_query)
김개발 씨는 당황했습니다. 분명히 잘 돌아가던 시스템이 "오늘 날씨 어때?"라는 질문에 오류를 뱉었습니다.
LLM이 "weather"라고 답했는데, 이건 정의된 RouteType에 없는 값이었습니다. 이것이 바로 폴백 처리가 필요한 이유입니다.
실제 서비스에서는 사용자가 어떤 질문을 할지 예측할 수 없습니다. 아무리 프롬프트를 잘 작성해도 LLM은 가끔 예상치 못한 응답을 합니다.
네트워크가 끊길 수도 있고, API 한도가 초과될 수도 있습니다. 코드의 _create_fallback_handler 메서드를 보겠습니다.
이 핸들러는 어떤 카테고리에도 속하지 않는 질문에 대한 기본 응답을 제공합니다. 중요한 것은 사용자에게 무엇을 할 수 있는지 안내한다는 점입니다.
"죄송합니다"로 끝나면 사용자는 막막해집니다. 대신 "환불, 배송, 상품 관련 문의를 도와드릴 수 있습니다"라고 안내하면 사용자가 다음 행동을 취할 수 있습니다.
route 메서드의 try-except 블록이 핵심입니다. 전체 라우팅 과정을 try 블록으로 감싸고, 어떤 예외가 발생하든 폴백 핸들러가 실행됩니다.
이렇게 하면 시스템이 절대 멈추지 않습니다. 실무에서는 예외의 종류에 따라 다른 처리를 할 수 있습니다.
네트워크 오류면 재시도하고, 인증 오류면 관리자에게 알리고, 알 수 없는 오류면 폴백을 실행하는 식입니다. 로깅도 중요합니다.
except 블록에서 예외를 조용히 삼키면 안 됩니다. 어떤 오류가 발생했는지 로그로 남겨야 나중에 분석하고 개선할 수 있습니다.
김개발 씨는 폴백을 추가한 후 다시 "오늘 날씨 어때?"를 입력해봤습니다. 이번에는 시스템이 멈추지 않고 "죄송합니다..."라는 안내 메시지가 나왔습니다.
사용자 경험이 훨씬 나아졌습니다. 박시니어 씨가 덧붙였습니다.
"폴백은 실패를 숨기는 게 아니야. 실패를 우아하게 처리하는 거지."
실전 팁
💡 - 폴백 메시지에 사용 가능한 기능을 안내하여 사용자를 유도하세요
- 예외 발생 시 반드시 로깅하여 분석에 활용하세요
- 예외 종류에 따라 재시도, 알림, 폴백 등 다른 전략을 적용하세요
6. 스트림릿 UI 구현
CLI로 테스트하던 김개발 씨에게 기획팀에서 연락이 왔습니다. "데모를 보여줘야 하는데, 화면이 있으면 좋겠어요." 박시니어 씨가 웃으며 말했습니다.
"스트림릿 써봐요. 파이썬 개발자가 30분 만에 웹 UI를 만들 수 있어요."
스트림릿은 파이썬 개발자를 위한 웹 UI 프레임워크입니다. HTML, CSS, JavaScript 없이 파이썬 코드만으로 인터랙티브한 웹 애플리케이션을 만들 수 있습니다.
마치 주피터 노트북이 데이터 분석을 위한 것이라면, 스트림릿은 AI 애플리케이션 데모를 위한 것입니다.
다음 코드를 살펴봅시다.
import streamlit as st
from routing_agent import RoutingAgentWithFallback
# 페이지 설정
st.set_page_config(page_title="고객 문의 도우미", page_icon="💬")
st.title("고객 문의 자동 응답 시스템")
# 세션 상태에 에이전트 초기화
if "agent" not in st.session_state:
st.session_state.agent = RoutingAgentWithFallback()
# 채팅 히스토리 관리
if "messages" not in st.session_state:
st.session_state.messages = []
# 이전 메시지 표시
for message in st.session_state.messages:
with st.chat_message(message["role"]):
st.markdown(message["content"])
# 사용자 입력 처리
if prompt := st.chat_input("문의사항을 입력하세요"):
# 사용자 메시지 추가
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
# 에이전트 응답 생성
with st.chat_message("assistant"):
response = st.session_state.agent.route(prompt)
st.markdown(response)
st.session_state.messages.append({"role": "assistant", "content": response})
김개발 씨는 스트림릿을 처음 접했습니다. 백엔드 개발자인 그에게 프론트엔드는 늘 부담이었습니다.
하지만 스트림릿 공식 문서를 보고 놀랐습니다. 진짜 파이썬 코드만으로 웹 페이지가 만들어졌습니다.
코드의 첫 부분에서 st.set_page_config로 페이지 설정을 합니다. 브라우저 탭에 표시될 제목과 아이콘을 지정합니다.
st.title은 페이지 상단에 큰 제목을 표시합니다. 스트림릿의 핵심 개념은 세션 상태입니다.
웹은 기본적으로 상태가 없습니다. 매 요청마다 서버는 이전 요청을 기억하지 못합니다.
하지만 st.session_state를 사용하면 사용자별로 상태를 유지할 수 있습니다. 에이전트 인스턴스를 세션 상태에 저장하는 이유가 있습니다.
스트림릿은 사용자가 뭔가를 입력할 때마다 전체 스크립트를 다시 실행합니다. 에이전트를 매번 새로 만들면 비효율적이므로, 세션에 저장해두고 재사용합니다.
messages 리스트는 대화 히스토리를 저장합니다. 각 메시지는 role(user 또는 assistant)과 content를 가집니다.
페이지가 다시 로드될 때 이 히스토리를 순회하며 이전 대화를 표시합니다. st.chat_input은 화면 하단에 입력창을 만듭니다.
왈러스 연산자(:=)를 사용하여 입력값이 있을 때만 처리합니다. 사용자가 엔터를 누르면 prompt 변수에 값이 할당되고 if 블록이 실행됩니다.
st.chat_message는 말풍선 형태의 메시지를 표시합니다. with 문과 함께 사용하면 해당 블록의 모든 출력이 말풍선 안에 들어갑니다.
role에 따라 사용자와 어시스턴트의 말풍선이 다른 스타일로 표시됩니다. 실무에서는 로딩 표시를 추가하면 좋습니다.
st.spinner를 사용하면 에이전트가 응답을 생성하는 동안 로딩 애니메이션을 보여줄 수 있습니다. 사용자가 시스템이 작동 중임을 알 수 있어 경험이 좋아집니다.
김개발 씨는 streamlit run app.py 명령을 실행했습니다. 브라우저가 열리고 깔끔한 채팅 인터페이스가 나타났습니다.
기획팀 데모 준비 완료입니다.
실전 팁
💡 - st.session_state로 에이전트를 재사용하여 성능을 최적화하세요
- st.spinner로 로딩 상태를 표시하여 사용자 경험을 개선하세요
- 사이드바에 설정 옵션을 추가하면 데모가 더 풍성해집니다
7. 에이전트 테스트 및 개선
데모는 성공적이었습니다. 하지만 박시니어 씨가 말했습니다.
"이제 시작이에요. 실제 서비스에 배포하려면 테스트와 모니터링이 필요해요." 김개발 씨는 다양한 케이스로 시스템을 검증하고 개선하는 방법을 배우기 시작했습니다.
테스트와 개선은 에이전트의 품질을 높이는 지속적인 과정입니다. 마치 자동차가 출시 전에 수천 번의 테스트를 거치듯이, 에이전트도 다양한 시나리오에서 검증되어야 합니다.
단위 테스트, 통합 테스트, 그리고 실제 사용자 피드백을 통해 점진적으로 개선합니다.
다음 코드를 살펴봅시다.
import pytest
from routing_agent import RoutingAgentWithFallback, RouteType
class TestRoutingAgent:
def setup_method(self):
self.agent = RoutingAgentWithFallback()
# 의도 분류 테스트
@pytest.mark.parametrize("query,expected", [
("환불해주세요", RouteType.REFUND),
("배송 언제 와요?", RouteType.SHIPPING),
("이 제품 재고 있나요?", RouteType.PRODUCT),
("안녕하세요", RouteType.GENERAL),
])
def test_intent_classification(self, query, expected):
result = self.agent.classify_intent(query)
assert result == expected
# 폴백 처리 테스트
def test_fallback_on_unknown_query(self):
result = self.agent.route("오늘 날씨 어때?")
assert "죄송합니다" in result
# 응답 품질 테스트
def test_response_contains_policy(self):
result = self.agent.route("환불하고 싶어요")
assert "7일" in result or "환불" in result
김개발 씨는 테스트의 중요성을 절감했습니다. 데모에서는 잘 작동했지만, 실제 사용자들은 예상치 못한 질문을 던집니다.
"환불ㅎㅐ주세요"처럼 오타가 있을 수도 있고, "돈 다시 받을 수 있어요?"처럼 간접적인 표현을 쓸 수도 있습니다. 코드에서 pytest를 사용한 테스트 클래스를 정의했습니다.
setup_method는 각 테스트 전에 실행되어 깨끗한 에이전트 인스턴스를 준비합니다. @pytest.mark.parametrize 데코레이터가 강력합니다.
하나의 테스트 함수로 여러 케이스를 검증할 수 있습니다. 쿼리와 기대 결과를 튜플로 정의하면 pytest가 각각에 대해 테스트를 실행합니다.
새로운 케이스를 추가하고 싶으면 리스트에 튜플을 하나 더 추가하면 됩니다. test_fallback_on_unknown_query는 폴백이 제대로 동작하는지 확인합니다.
서비스 범위를 벗어난 질문에 "죄송합니다"가 포함된 응답이 나오는지 검증합니다. 이런 테스트가 없으면 폴백 로직을 실수로 깨뜨려도 모를 수 있습니다.
test_response_contains_policy는 응답 품질을 검증합니다. 환불 문의에 대한 응답에 환불 정책이 포함되어 있는지 확인합니다.
프롬프트를 수정했을 때 중요한 정보가 빠지지 않았는지 확인하는 안전장치입니다. 실무에서는 이보다 더 다양한 테스트가 필요합니다.
유사한 표현 테스트(환불, 반품, 취소가 모두 같은 의도로 분류되는지), 경계 케이스 테스트(매우 긴 입력, 빈 입력, 특수문자만 있는 입력), 성능 테스트(응답 시간이 일정 기준 이내인지) 등이 있습니다. 또한 실제 사용자 대화 로그를 수집하여 분석하는 것이 중요합니다.
분류가 잘못된 케이스를 찾아 테스트에 추가하고, 프롬프트를 개선하는 피드백 루프를 구축해야 합니다. 김개발 씨는 테스트를 실행하며 몇 가지 실패 케이스를 발견했습니다.
"물건 안 왔어요"가 SHIPPING 대신 GENERAL로 분류되었습니다. 프롬프트에 "물건이 도착하지 않음"을 배송 관련 키워드로 추가했더니 테스트가 통과했습니다.
이것이 바로 테스트 주도 개선입니다. 테스트가 문제를 발견하고, 개선 후 테스트가 성공을 확인합니다.
실전 팁
💡 - 실제 사용자 로그에서 실패 케이스를 수집하여 테스트에 추가하세요
- 프롬프트 변경 시 반드시 회귀 테스트를 실행하세요
- 응답 품질 메트릭을 정의하고 지속적으로 모니터링하세요
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
Helm 마이크로서비스 패키징 완벽 가이드
Kubernetes 환경에서 마이크로서비스를 효율적으로 패키징하고 배포하는 Helm의 핵심 기능을 실무 중심으로 학습합니다. Chart 생성부터 릴리스 관리까지 체계적으로 다룹니다.
보안 아키텍처 구성 완벽 가이드
프로젝트의 보안을 처음부터 설계하는 방법을 배웁니다. AWS 환경에서 VPC부터 WAF, 암호화, 접근 제어까지 실무에서 바로 적용할 수 있는 보안 아키텍처를 단계별로 구성해봅니다.
AWS Organizations 완벽 가이드
여러 AWS 계정을 체계적으로 관리하고 통합 결제와 보안 정책을 적용하는 방법을 실무 스토리로 쉽게 배워봅니다. 초보 개발자도 바로 이해할 수 있는 친절한 설명과 실전 예제를 제공합니다.
AWS KMS 암호화 완벽 가이드
AWS KMS(Key Management Service)를 활용한 클라우드 데이터 암호화 방법을 초급 개발자를 위해 쉽게 설명합니다. CMK 생성부터 S3, EBS 암호화, 봉투 암호화까지 실무에 필요한 모든 내용을 담았습니다.
AWS Secrets Manager 완벽 가이드
AWS에서 데이터베이스 비밀번호, API 키 등 민감한 정보를 안전하게 관리하는 Secrets Manager의 핵심 개념과 실무 활용법을 배워봅니다. 초급 개발자도 쉽게 따라할 수 있도록 실전 예제와 함께 설명합니다.