자율형 딜 탐색 에이전트 시스템을 구축하며 배운 점
요약
자율적으로 저렴한 상품을 탐색하고 가치를 추정하여 알림을 보내는 멀티 에이전트 AI 시스템 구축 과정을 다룹니다. 에이전트형 AI 아키텍처, RAG, 파인튜닝, 도구 호출 및 Modal을 활용한 서버리스 배포 등 실무적인 기술 경험을 공유합니다.
핵심 포인트
- 단일 모델 대신 전문화된 멀티 에이전트 팀 구성의 중요성
- Planner 에이전트를 통한 에이전트 간 오케스트레이션 구현
- Modal을 활용한 서버리스 GPU 인프라 및 모델 배포 전략
- RAG와 파인튜닝을 결합한 하이브리드 가격 추정 방식
지난 한 주 동안 저는 인터넷을 자율적으로 스캔하여 저렴한 상품을 찾고, 세 가지 다른 가격 책정 기술을 사용하여 제품의 실제 가치를 추정하며, 행동할 가치가 있는 딜을 찾는 즉시 제 휴대폰으로 알림을 보내는 멀티 에이전트 AI 시스템을 구축했습니다. 그 과정에서 저는 에이전트형 AI (Agentic AI) 아키텍처, RAG, 파인튜닝 (Fine-tuning) 대 프롬프팅 (Prompting), 도구 호출 (Tool calling), 그리고 Gradio 프런트엔드를 사용하여 실제 (비록 투박할지라도) 제품을 출시하는 것에 대한 수많은 실용적이고 전이 가능한 교훈을 얻었습니다.
이 포스트는 이를 작동하게 만든 코드와 함께 전체 여정을 기록한 글입니다.
전체 그림: 전문 에이전트 팀
**"The Price Is Right"**라는 별명이 붙은 이 시스템은 단일 모델이 모든 면에서 최고일 수는 없다는 아이디어를 바탕으로 구축되었습니다. 하나의 거대한 프롬프트 대신, 아키텍처는 문제를 각각 하나의 작업을 잘 수행하는 집중된 에이전트들로 나누고, 이를 플래닝 에이전트 (Planning agent)가 조정하도록 설계되었습니다:
- Scanner Agent (스캐너 에이전트) – 딜 RSS 피드를 훑으며 저렴한 LLM을 사용하여 가장 유망하고 설명이 잘 된 5개의 딜을 선정합니다.
- Specialist Agent (전문가 에이전트) – 제품 가격을 추정하도록 특별히 파인튜닝(Fine-tuned)한 소규모 오픈 소스 모델 (Llama 3.2)로, Modal에서 서버리스로 배포되었습니다.
- Frontier Agent (프런티어 에이전트) – RAG를 사용하여 가격 추정을 수행하는 프런티어 모델 (GPT-5.1)로, 컨텍스트를 위해 벡터 데이터베이스에서 유사한 제품을 가져옵니다.
- Ensemble Agent (앙상블 에이전트) – Specialist, Frontier, 그리고 클래식 신경망 (Neural network)을 결합하여 하나의 가중치 추정치로 만듭니다.
- Messaging Agent (메시징 에이전트) – 훌륭한 딜이 발견되면 Pushover를 통해 제 휴대폰으로 푸시 알림을 보냅니다.
- Autonomous Planning Agent (자율 플래닝 에이전트) – 함수/도구 호출 (Function/tool calling)을 사용하여 모든 것을 연결하는 "두뇌" 역할을 하며, 무엇을 스캔할지, 무엇을 추정할지, 언제 알림을 보낼지를 결정합니다.
- Deal Agent Framework (딜 에이전트 프레임워크) – 전체 루프를 실행하고 실행 간에 메모리를 유지하는 오케스트레이션 (Orchestration) 레이어입니다.
- Gradio UI – 발견되는 딜을 보여주는 라이브 대시보드와 하단의 제품 벡터 저장소(Vector store)를 보여주는 3D 시각화 기능을 제공합니다.
이 프로젝트를 구축하는 5일간의 과정은 다음과 같이 나누어집니다.
1일 차: Modal을 사용하여 클라우드에 모델 배포하기
첫 번째 교훈은 **인프라 (Infrastructure)**에 관한 것이었습니다. 어떻게 하면 자체 GPU 서버를 관리하지 않고 모델(특히 미세 조정된 (Fine-tuned) 오픈 소스 LLM)을 실행할 수 있을까요? 그 해답은 Modal이었습니다. 이는 GPU를 포함하여 클라우드에서 Python 함수를 실행할 수 있는 서버리스 (Serverless) 플랫폼입니다.
Modal의 "Hello World"는 매우 간단합니다. App과 Image (본질적으로 pip 의존성이 포함된 컨테이너 사양)를 정의하고, 일반 Python 함수에 @app.function 데코레이터를 붙이기만 하면 됩니다:
# hello.py
import modal
from modal import Image
...
여기서 제가 정말 좋았던 점은 region="eu" 파라미터입니다. 단 하나의 키워드 인자만으로 함수가 실제로 실행될 전 세계 위치를 지정할 수 있는데, 이는 지연 시간 (Latency), 데이터 거주성 (Data residency), 그리고 때로는 비용 측면에서 매우 중요합니다.
노트북에서 로컬 (Locally)로 호출하는 것과 원격 (Remotely)으로 호출하는 것 모두 매우 간단합니다:
from hello import app, hello, hello_europe
with app.run():
...
GPU에서 실제 LLM 실행하기
다음 단계는 실제 언어 모델을 실행하는 것입니다. 여기서 Modal의 GPU 지원과 Secrets가 빛을 발합니다. Hugging Face 토큰을 코드에 직접 입력(Hardcode)하고 싶지 않을 것이므로, Modal 대시보드에 특정 이름(예: huggingface-secret)으로 한 번 등록한 뒤 코드에서 참조하면 됩니다:
# llama.py
import modal
from modal import Image
...
이 과정에서 몇 가지 깨달은 점이 있습니다:
gpu="T4"설정만으로 GPU 기반 컨테이너를 요청할 수 있습니다. CUDA 드라이버를 씨름하거나 Dockerfile을 작성할 필요가 없습니다.timeout=1800설정이 중요합니다. 첫 호출 시 모델 가중치 (Model weights)를 다운로드하고 로드해야 하기 때문입니다. 이러한 콜드 스타트 (Cold start)는 몇 분이 걸릴 수 있습니다.- GPU가 필요한 함수 본문 내부의 모든 것(
transformers임포트, 토크나이저 (Tokenizer), 모델 등)은 함수 내부에서 임포트됩니다. 따라서 이는 제 노트북이 아닌 클라우드 컨테이너 내에서만 실행됩니다.
일시적인 앱에서 배포된 가격 책정 서비스로
첫째 날의 정말 중요한 개념적 도약은 일시적인 앱 (ephemeral app) (with app.run(): ..., 단일 호출을 위해 실행되고 해제되는 방식)에서 **배포된 앱 (deployed app)**으로 넘어가는 것이었습니다:
uv run modal deploy -m pricer_service
배포가 완료되면, 서비스는 제 노트북과 독립적으로 실행되며, 이름만 참조함으로써 어디에서나 호출할 수 있습니다:
import modal
Pricer = modal.Cls.from_name("pricer-service", "Pricer")
pricer = Pricer()
...
이것은 본질적으로 프로덕션 시스템을 위해 미세 조정된 모델 (fine-tuned model)을 "API 뒤에" 배치하는 방법이며, 바로 이 배포된 가격 책정 서비스 (pricer)를 래핑(wrap)하는 **전문가 에이전트 (Specialist Agent)**의 기반이 됩니다.
여기에는 멋진 최적화 방법도 있습니다. 기본적으로 Modal 컨테이너는 유휴 상태일 때 0으로 스케일 다운 (scale down)되므로, 비활성 상태 이후의 첫 번째 호출은 깨어나는 데 약 30초가 걸릴 수 있습니다. 약간의 크레딧을 더 사용할 의향이 있다면, 컨테이너를 웜 상태 (warm)로 유지할 수 있습니다:
import modal
Pricer = modal.Cls.from_name("pricer-service", "Pricer")
pricer = Pricer()
...
핵심 요약 (Takeaway): Modal은 "미세 조정된 모델을 마이크로서비스 (microservice)로 배포하기"를 한 줄의 데코레이터 (decorator)와 한 줄의 CLI 명령어로 만들어 줍니다. _일반적인 Python 함수를 작성하고, 데코레이터를 붙이고, 배포한 뒤, 원격 객체처럼 호출한다_는 사고 모델 (mental model)은 향후 어떤 "서비스로서의 전문가 모델 (specialist model as a service)" 프로젝트에서도 재사용할 것입니다.
2일 차: RAG, 프런티어 에이전트, 그리고 가격 책정기 앙상블
2일 차는 프런티어 모델 (frontier model, GPT-5.1)이 좁은 범위의 작업을 더 잘 수행하게 만드는 다른 방법인 검색 증강 생성 (Retrieval Augmented Generation, RAG), 그리고 _여러 개_의 가격 책정 전략을 하나로 결합하는 방법에 관한 것이었습니다.
벡터 스토어 (Vector Store) 구축
첫 번째 요소는 텍스트를 의미를 담은 384차원 벡터로 변환하는 로컬 오픈 소스 **문장 임베딩 모델 (sentence embedding model)**입니다:
from sentence_transformers import SentenceTransformer
encoder = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')
...
이 벡터들은 제품 설명 및 메타데이터(카테고리, 가격)와 함께 Chroma 벡터 데이터베이스(vector database)에 저장되며, 수십만 개의 제품에 대해 한 번에 1,000개씩 배치(batch) 단위로 처리됩니다:
collection_name = "products"
existing_collection_names = [collection.name for collection in client.list_collections()]
...
벡터 공간 시각화 (Visualizing the Vector Space)
가장 만족스러웠던 순간 중 하나는 t-SNE를 사용하여 이 384차원 벡터를 3D로 축소하고, 제품들이 카테고리별로 클러스터링(clustering)되는 것을 확인했을 때였습니다. 예를 들어, 전자제품은 한쪽 구석에, 악기는 다른 쪽 구석에 모여 있었습니다:
from sklearn.manifold import TSNE
import plotly.graph_objects as go
...
"임베딩(embeddings)이 의미적 유사성(semantic similarity)을 포착한다"라는 말을 듣는 것과, 동일한 제품 카테고리가 3D 공간에서 조밀한 클러스터를 형성하는 것을 실제로 보는 것은 전혀 다른 차원의 경험입니다.
프런티어 모델(Frontier Model)을 위한 그라운딩(Grounding)에 검색(Retrieval) 활용하기
벡터 저장소(vector store)가 구축되고 나면 실제 RAG 기술은 간단합니다. 새로운 제품에 대해 가장 가까운 5개의 이웃(nearest neighbours)을 찾고, 그들의 설명과 가격을 GPT-5.1에게 가격 추정을 요청하기 전 컨텍스트(context)로서 프롬프트(prompt)에 집어넣는 방식입니다:
def find_similars(item):
vec = vector(item)
results = collection.query(query_embeddings=vec.astype(float).tolist(), n_results=5)
...
이것이 Frontier Agent의 핵심이 되었습니다.
앙상블(Ensemble): 매우 다른 세 가지 모델의 결합
둘째 날의 가장 큰 깨달음(aha moment)은 제품 가격 추정이라는 동일한 문제에 대해 완전히 다른 세 가지 접근 방식을 하나로 **혼합(blended)**했을 때, 개별 모델 중 그 어떤 것보다 더 나은 결과를 낼 수 있다는 사실을 깨달은 것이었습니다:
- RAG + GPT-5.1 (
gpt_5__1_rag) — 검색된 컨텍스트를 사용하는 프런티어 모델(frontier model) - Modal에서 실행되는 미세 조정된 전문가 모델 (
specialist) — 이 작업에 특화되어 미세 조정(fine-tuned)된 소형 모델 - 6주 차의 임베딩으로 학습된 클래식 심층 신경망 (
deep_neural_network)
def get_price(reply):
reply = reply.replace("$", "").replace(",", "")
match = re.search(r"[-+]?\d*\.\d+|\d+", reply)
...
가중치(0.8 / 0.1 / 0.1)는 홀드아웃(held-out) 테스트 데이터로 평가했을 때, RAG 기반의 프런티어 모델(frontier model)이 가장 강력한 개별 예측 변수였기 때문에 선택되었습니다. 하지만 나머지 두 모델 역시 최종 추정치를 유용한 방향으로 미세하게 조정해 주었습니다. 이는 본질적으로 아주 작은, 수동으로 조정된 전문가 혼합 (Mixture-of-Experts, MoE) 방식이며, 대부분의 "텍스트로부터 숫자 추정하기" 문제에 일반화될 수 있습니다. 즉, 몇 개의 독립적인 추정기(estimator)를 확보한 다음 이를 혼합하는 방식입니다.
2일 차가 끝날 무렵, 이 세 가지 모델은 모두 적절한 에이전트 클래스인 FrontierAgent, NeuralNetworkAgent, EnsembleAgent로 래핑(wrapped)되었습니다. 각 클래스는 상위 수준의 오케스트레이션(orchestration)에 의해 호출될 준비가 된 단순한 .price(description) 메서드를 노출합니다.
3일 차: 웹 스캐닝 및 알림 전송
2일 차가 _"딜(deal)이 주어졌을 때, 그것의 실제 가치는 얼마인가?"_라는 질문에 답했다면, 3일 차는 가장 먼저 해결되어야 할 질문에 답합니다: "애초에 딜은 어디에서 오는가, 그리고 화면을 계속 응시하지 않고도 좋은 딜을 어떻게 알아낼 수 있는가?"
스캐너 에이전트 (The Scanner Agent)
여기서의 프롬프트 디자인(prompt design)은 그 자체로 작은 교훈을 주었습니다. 엣지 케이스(edge cases)를 명시적으로 다루는 것이 신뢰성을 대폭 향상시킨다는 점입니다:
SYSTEM_PROMPT = """당신은 목록에서 가장 상세하고 품질이 높은 설명과 가장 명확한 가격을 가진 딜을 선택하여, 가장 상세한 5개의 딜을 식별하고 요약합니다.
설명 없이 엄격하게 JSON 형식으로만 응답하며, 이 형식을 사용하십시오. 가격은 설명에서 도출된 숫자로 제공해야 합니다. 만약 딜의 가격이 명확하지 않다면, 해당 딜을 응답에 포함하지 마십시오.
가장 중요한 것은 가격과 함께 제품 설명이 가장 상세한 5개의 딜로 응답하는 것입니다. 딜의 조건을 언급하는 것은 중요하지 않습니다. 가장 중요한 것은 제품에 대한 철저한 설명입니다.
...
"""
구조화된 출력 (structured output, .chat.completions.parse(... response_format=DealSelection ...)를 통한 Pydantic 모델 사용)과 결합하면, 에이전트가 파이프라인의 나머지 부분이 기대하는 데이터 형식을 정확히 반환하도록 보장합니다. 즉, 자유 형식의 텍스트(free text)를 대상으로 하는 취약한 JSON 파싱 (JSON-parsing) 과정이 필요하지 않습니다.
메시징 에이전트 (Messaging Agent)와 Pushover
3일 차의 마지막 단계는 외부 세계와 연결 고리를 완성하는 것, 즉 푸시 알림 (push notifications) 이었습니다. Pushover를 사용하면 이 과정이 당황스러울 정도로 쉬워집니다. 앱을 등록하고, 사용자 키(user key)와 API 토큰을 받은 뒤, 단 한 번의 HTTP POST 요청으로 알림을 보낼 수 있습니다:
pushover_user = os.getenv('PUSHOVER_USER')
pushover_token = os.getenv('PUSHOVER_TOKEN')
pushover_url = "https://api.pushover.net/1/messages.json"
...
이 기능은 .notify(description, deal_price, estimated_value, url) 메서드를 가진 MessagingAgent로 래핑(wrapped)되어, "좋은 딜을 찾았습니다"라는 메시지를 "당신의 휴대폰이 울립니다"로 바꾸어 놓았습니다.
핵심 요약 (Takeaway): 에이전트 시스템 (Agentic systems)은 마법처럼 느껴지지만, 그 마법의 상당 부분은 단순한 배관 작업 (plumbing) 입니다. 즉, RSS 피드 입력, 구조화된 LLM 출력, 푸시 알림 출력과 같은 흐름입니다. 이 배관 작업을 매우 견고하게 만들고(그리고 엣지 케이스 (edge cases)에 대해 프롬프트를 매우 명시적으로 작성하는 것)이 바로 "지능적인" 부분을 신뢰할 수 있게 만드는 핵심입니다.
4일 차: 자율 계획 에이전트 (The Autonomous Planning Agent) — LLM에게 도구 사용법 가르치기
이 단계는 저에게 개념적으로 가장 중요한 날이었습니다. 지금까지의 모든 에이전트는 제 코드에 의해 명시적으로 (explicitly) 호출되었습니다: "이제 스캐너를 실행해", "이제 앙상블을 실행해", "이제 알림을 보내"와 같이 말이죠. 4일 차는 이를 뒤집습니다. LLM 스스로가 도구 (tools) 를 호출함으로써 _무엇을 할지, 그리고 어떤 순서로 할지_를 결정합니다.
1단계: 가짜 도구, 실제 개념
실제 에이전트를 연결하기 전에, 노트북에서는 도구 호출 루프 (tool-calling loop)를 이해하기 위해 세 가지 가짜 (fake) 함수를 구축합니다:
def scan_the_internet_for_bargains() -> str:
""" 이 도구는 인터넷을 스캔하여 좋은 딜을 찾고 유망한 딜의 큐레이션된 목록을 가져옵니다 """
print("인터넷을 스캔하는 가짜 함수 - 하드코딩된 딜 세트를 반환합니다")
...
각 도구(tool)에는 이름, 설명, 매개변수(parameters)를 기술하는 JSON 스키마 (JSON Schema)가 필요합니다. 이것이 실제로 LLM에 전달되어, 모델이 어떤 도구를 사용할 수 있고 어떻게 호출해야 하는지 알 수 있게 합니다:
scan_function = {
"name": "scan_the_internet_for_bargains",
"description": "인터넷에서 스크래핑한 최고의 딜 목록과 각 아이템의 판매 가격을 반환합니다",
...
2단계: 에이전트 루프 (The Agent Loop)
진정한 마법은 바로 이 루프(loop)에 있습니다. LLM에게 도구와 목표가 주어지면, 모델이 도구를 호출하기로 결정할 경우 코드가 실제 Python 함수를 실행하고 그 결과를 다시 모델에게 전달합니다. 그리고 모델이 만족할 때까지 이 과정이 반복됩니다:
def handle_to
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기