
딜(Deal)이 막힌 이유를 기억하는 영업 에이전트
요약
기존 영업 AI의 한계인 메모리 부재 문제를 해결하기 위해, 지속성 메모리(Persistent Memory)를 아키텍처 핵심 요소로 도입한 영업 에이전트 시스템을 소개합니다. CrewAI와 Hindsight를 활용하여 이전 대화의 맥락을 유지하며 차단 요소 분석 및 개인화된 후속 이메일을 생성하는 파이프라인을 구축했습니다.
핵심 포인트
- Stateless LLM의 한계를 극복하는 구조적 메모리 아키텍처 설계
- CrewAI 기반의 분석가 및 작성가 에이전트 협업 모델
- Hindsight를 활용한 세션 간 지속성 메모리 구현
- Recall-Analyze-Write-Save로 이어지는 선형적 파이프라인
딜(Deal)이 막힌 이유를 기억하는 영업 에이전트
내가 이전에 보았던 모든 영업 AI는 동일한 문제, 즉 메모리(Memory)가 없다는 문제를 겪었습니다. 트랜스크립트(Transcript)를 입력하면 후속 이메일을 생성해내지만, 다음 통화는 처음부터 다시 시작됩니다. 다섯 번의 대화 후에 실제 의사 결정권자(Decision-maker)가 누구인지 물어보면, 마치 해당 계정(Account)에 대해 들어본 적이 없는 것처럼 대답합니다. 영업 사원을 유능하게 만드는 컨텍스트(Context)—즉, 고객이 실제로 무엇을 중요하게 생각하는지, 누가 중요한 인물인지, 무엇이 이미 해결되었는지에 대한 축적된 정보—는 상태가 없는(Stateless) LLM 호출 과정에서 살아남지 못합니다.
그래서 저는 기억할 수 있는 시스템을 구축했습니다. 사후에 덧붙여진 벡터 데이터베이스(Vector Database) 방식이 아니라, 핵심 아키텍처(Architectural) 고려 사항으로서의 메모리입니다. 이것이 어떻게 작동하는지에 대한 이야기입니다.
시스템이 하는 일
이 시스템은 영업 통화 트랜스크립트(Transcript)를 처리하여 두 가지를 생성합니다: 현재의 실제 차단 요소(Blocker)에 대한 분석과 개인화된 후속 이메일입니다. 핵심적인 차이점은 모든 통화가 이전의 모든 통화 내용을 바탕으로 구축된다는 점입니다.
아키텍처는 CrewAI를 통해 구동되는 두 개의 협력 에이전트(Agent)로 구성되며, Hindsight를 통한 지속성 메모리(Persistent Memory)와 cascadeflow를 통한 비용 효율적인 모델 라우팅(Model Routing)을 지원합니다. 각 통화에 대한 파이프라인(Pipeline)은 정확히 네 단계로 이루어집니다:
- 회상(Recall): 이전 통화로부터 이 고객에 대해 알려진 모든 것을 불러옵니다.
- 분석가 에이전트(Analyst agent): 새로운 트랜스크립트와 회상된 메모리를 읽고, 현재의 실제 차단 요소와 의사 결정권자를 식별합니다.
- 작성가 에이전트(Writer agent): 해당 분석 내용을 개인화된 후속 이메일로 변환합니다.
- 저장(Save): 다음 번을 위해 이번 통화에서 추출된 사실들을 메모리에 다시 저장합니다.
def process_call(customer: str, transcript: str) -> dict:
"""한 번의 통화에 대해 전체 recall -> analyze -> write -> save 파이프라인을 실행합니다."""
memory = recall_memory(customer)
...
이 파이프라인은 의도적으로 선형적입니다. 분석가(Analyst)는 원문 전사(transcript)와 이전 통화에서 회상된 모든 정보를 확인합니다. 작성자(Writer)는 분석가의 출력물과 동일한 회상된 컨텍스트를 확인합니다. 쓰기(writing) 작업 이후에 메모리가 저장되므로, 다음 통화에서는 현재의 분석 결과가 반영된 사실들을 추출할 수 있습니다.
복리로 쌓이는 메모리
여기서 핵심적인 기술적 이야기는 에이전트 메모리 (agent memory)입니다. 단순히 텍스트를 저장하는 것이 아니라, 세션 전반에 걸쳐 구조화된 이해를 축적하는 것입니다.
저는 메모리 백엔드로 Hindsight를 사용했습니다. 모델은 간단합니다. 공유된 "뱅크 (bank)"가 모든 고객의 메모리를 저장합니다. 각 고객의 메모리는 모든 쓰기 작업에 customer:<slug> 태그를 달고, 회상 시 tags_match="all_strict"로 필터링함으로써 다른 고객의 메모리와 격리됩니다. 고객 데이터가 서로 섞이는 일은 절대 발생하지 않습니다.
def save_memory(customer: str, notes: str) -> None:
_ensure_bank()
_call(
...
중요한 것은 API가 아니라, 복리로 쌓이는(compounding) 동작입니다. 각 통화가 끝날 때마다 추출된 사실들이 뱅크에 축적됩니다. 다섯 번째 통화에 이르렀을 때, 시스템에는 첫 번째 통화 후의 5개와 비교하여 33개의 저장된 사실이 쌓여 있었습니다. 더 중요한 것은 저장된 내용의 "품질"이 진화했다는 점입니다. 초기 사실들은 표면적인 가격 문제였으나, 나중에는 특정 인물, 그들의 정확한 권한 수준, 아직 대기 중인 보안 문서, 그리고 이미 해결된 사항들을 포착했습니다.
형태가 변한 딜 (Deal)
데이터셋에 포함된 다섯 번의 통화는 B2B 영업에서 흔히 나타나며, 상태 비저장(stateless) 에이전트가 제대로 처리하기 어려운 패턴을 따릅니다.
통화 1: 운영 부문 부사장(VP Operations)인 Mike Reynolds는 월 4,800달러의 가격이 문제라고 말합니다. Jordan은 투자 대비 수익률(ROI)에 집중합니다. 시스템은 Mike에게 가격에 초점을 맞춘 이메일을 생성합니다.
통화 2: IT 보안 팀장(IT Security Lead)인 Sarah Chen이 합류하여 데이터 거주성(data residency) 및 SOC2 관련 질문을 제기합니다. Mike는 그녀의 말을 끊으며 말합니다: "너무 세부적인 사항까지 깊게 들어가지 맙시다." 시스템은 Sarah의 우려 사항을 기록하지만, 명목상 책임자는 여전히 Mike입니다.
Call 3: Jordan이 15% 할인을 제안하며 돌아옵니다. Mike는 가격 문제가 거의 해결되었다고 말합니다. 하지만 Sarah가 진행을 가로막습니다. 그녀는 SOC2 Type 2 (Type 1이 아님), 서면 데이터 레지던시 (data residency) 보장, 그리고 데이터 삭제 정책을 요구합니다. 차단 요소가 예산에서 컴플라이언스 (compliance, 준수 사항)로 옮겨갔습니다.
Call 4: 재무 부서에서 예산을 승인했습니다. Mike는 나타나지 않고, Jordan과 Sarah만 남았습니다. Sarah는 이를 명확히 합니다: "여기서 승인하는 사람은 바로 저입니다. Mike가 예산을 관리하지만, 보안 검토를 통과하지 못하면 계약은 없습니다." 실제 의사 결정권자는 결코 Mike가 아니었습니다.
Call 5: Sarah가 SOC2 Type 2를 검토했고 (통과), 레지던시 제어 기능이 포함된 엔터프라이즈 (Enterprise) 티어에 대한 예산이 확정되었습니다. 단 한 가지 항목이 남았습니다: EU 데이터 레지던시 보장에 대한 서면 문서입니다. 그게 전부입니다. 서류 한 장이면 됩니다.
Call 5를 단독으로 처리하는 상태 비저장 (stateless) 에이전트라면, 이미 예산 승인을 받은 부사장에게 여전히 ROI (투자 대비 효율)를 피칭하고 있을 것입니다. 메모리 기반 (memory-backed) 시스템은 가격 문제가 두 번 전의 통화에서 해결되었다는 점, Sarah가 승인권자라는 점, 그리고 특정 문서 하나가 계약을 성사시킨다는 점을 알고 있습니다.
Call 1의 이메일은 Mike를 수신인으로 하여 ROI와 비용 정당화에 대부분의 내용을 할애했습니다. 반면 Call 5의 이메일은 Sarah에게 직접 전달되었으며, EU 데이터 레지던시 보장을 명시적으로 언급했습니다. 이 차이는 정교함의 차이가 아니라, 바로 메모리의 차이입니다.
비용 효율적인 라우팅 (Cost-Aware Routing)
모든 모델 호출이 동일한 모델을 사용할 필요는 없습니다. 녹취록에서 세 개의 핵심 사실을 추출하는 작업은, 다섯 번의 통화 맥락을 통해 실제 의사 결정권자가 누구인지 추론하는 작업과는 다른 과업입니다.
저는 이를 자동으로 처리하기 위해 cascadeflow를 사용했습니다. 설정은 두 개의 모델로 구성됩니다: 초안 작성자 (drafter) 역할을 하는 Groq 상의 저렴한 qwen3-32b, 그리고 초안 작성자의 출력이 품질 임계값을 통과하지 못할 때만 실행되는 검증자 (verifier) 역할을 하는 gpt-oss-120b입니다.
drafter = ModelConfig(
name="qwen/qwen3-32b",
provider="groq",
...
모든 호출은 기록됩니다: 어떤 모델이 사용되었는지, 에스컬레이션(escalation)이 발생했는지, 그 이유는 무엇인지, 그리고 얼마나 걸렸는지 말입니다. 5번의 호출을 통한 전체 실행의 결정 로그(decision log)를 살펴보면 패턴이 명확합니다. 분석가(analyst)의 추론은 더 큰 모델로 에스컬레이션되는 반면, 더 단순한 추출(extraction) 작업은 더 저렴한 모델에 머뭅니다. 각 호출에 대한 Cascadeflow의 라우팅 결정은 명시적으로 나타납니다. 단순 추출의 경우 "moderate query suitable for cascade optimization"(캐스케이드 최적화에 적합한 중간 난이도 쿼리), 분석가 및 작가(writer) 호출의 경우 "hard query requires best model for quality"(품질을 위해 최상의 모델이 필요한 어려운 쿼리)라고 표시됩니다.
어려운 부분들
아키텍처 자체보다 더 큰 고통을 준 세 가지 버그가 있었습니다.
조용한 빈 출력 (The silent empty output). qwen3-32b 모델은 심층 추론(deep reasoning)을 수행할 때 실제 답변을 내놓기 전에 확장된 <think>...</think> 블록을 작성합니다. 만약 max_tokens가 너무 낮게 설정되어 있다면—제 초기값은 512였습니다—모델은 내부 추론에 토큰 예산을 모두 소진하고 눈에 보이는 결과물을 아무것도 반환하지 않게 됩니다. 해결책은 모든 호출에서 max_tokens를 2048로 높이는 것이었습니다. 증상은 미묘했습니다. 모델 호출은 200 상태 코드로 성공했지만, <think> 블록을 제거한 후 반환된 콘텐츠가 비어 있었습니다. 저는 _strip_think를 적용하기 전의 원시 출력(raw output)을 출력해 봄으로써 이를 잡아냈습니다.
말이 나온 김에—이 헬퍼(helper) 함수는 작지만 필수적입니다:
def _strip_think(text: str) -> str:
"""모델 출력에서 qwen 스타일의 <think>...</think> 추론 블록을 제거합니다."""
return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
이 함수가 없다면, 추론 모델의 내부 숙고 과정이 렌더링된 출력에 그대로 나타납니다. 이는 장황하며 사용자에게는 무관한 정보입니다.
이벤트 루프 충돌 (The event loop collision). Hindsight의 동기(sync) 클라이언트는 내부적으로 자체 이벤트 루프에서 aiohttp를 구동합니다. 자체 asyncio 루프를 실행하는 Streamlit의 스크립트 스레드에서 이를 호출하면 RuntimeError: Timeout context manager should be used inside a task가 발생합니다. 이 에러는 명확한 동시성(concurrency) 에러가 아닌 타임아웃(timeout)으로 나타나기 때문에 혼란을 줍니다.
해결 방법: 모든 Hindsight 호출을 단일 워커(worker)를 가진 전용 ThreadPoolExecutor를 통해 라우팅합니다. 해당 워커 스레드는 실행 중인 이벤트 루프(event loop)가 없으므로, 클라이언트가 충돌 없이 자체적인 루프를 생성할 수 있습니다.
_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="hindsight")
def _call(fn, *args, **kwargs):
...
하나의 워커가 호출을 직렬화(serialized)하여 유지합니다. Hindsight 클라이언트는 하나의 aiohttp 세션을 안전하게 재사용합니다. Streamlit의 이벤트 루프는 절대 간섭하지 않습니다. 이 패턴은 이미 이벤트 루프를 점유하고 있는 프레임워크에서 비동기 기반(async-backed) 동기 코드를 호출해야 할 때 광범위하게 적용 가능합니다.
cascadeflow의 자체 이벤트 루프. 유사한 충돌이 cascadeflow에서도 발생했습니다. 각 호출에 asyncio.run()을 사용하는 방식은 첫 번째 호출은 작동했지만 루프를 닫아버렸기 때문에, 이후 호출에서 Event loop is closed 에러와 함께 실패했습니다. 해결 방법은 모듈 임포트(import) 시점에 하나의 지속적인 이벤트 루프를 생성하고, 프로세스의 수명 동안 모든 호출을 loop.run_until_complete()를 통해 라우팅하는 것이었습니다.
의존성(dependencies) 고정하기. 지루한 내용일 수 있지만 그래도 말씀드리겠습니다. hindsight-client>=0.8과 같은 요구 사항은 새로운 환경에서 설치할 경우 아직 존재하지 않는 버전으로 조용히 해결(resolve)될 수 있습니다. 저는 실제로 깔끔하게 설치되는 정확한 버전들로 모두 고정했습니다: hindsight-client==0.8.3, cascadeflow==0.7.1, crewai==0.86.0. 릴리스 주기가 빠른 최신 라이브러리들을 통합하는 경우, 버전을 조기에 고정하는 것이 "내 컴퓨터에서는 되는데"라는 식의 대화를 방지해 줍니다.
이것이 유용한 경우
복합 컨텍스트 (compounding-context) 패턴은 지식 상태가 진화하며 다중 세션 상호작용 (multi-session interactions)이 발생하는 모든 곳에 적용됩니다. 고객 지원 (Customer support)이 가장 명확한 비유입니다. 고객이 이미 말한 내용, 이미 시도해 본 해결책, 그리고 고객의 환경이 무엇인지 기억하는 지원 에이전트는 매번 똑같은 진단 질문을 던지는 에이전트보다 훨씬 더 유용할 것입니다. 동일한 논리가 연구 보조원 (research assistants), 온보딩 흐름 (onboarding flows), 그리고 인간이 신뢰할 수 있는 속도로 추적하기보다 컨텍스트가 더 빠르게 쌓이는 모든 상황에 적용됩니다.
모델 라우팅 레이어 (model routing layer)는 메모리 레이어 (memory layer)와 분리 가능하며 그 자체로도 유용합니다. 단순한 프롬프트와 복잡한 프롬프트가 섞인 상태로 많은 LLM 호출을 수행하고 있다면, 매 호출마다 거대 모델 (large model)에 비용을 지불하는 것은 불필요합니다. Cascadeflow의 자동 에스컬레이션 (automatic escalation)은 사용자가 무엇이 무엇인지 수동으로 분류할 필요 없이, 쉬운 호출은 저렴하게 유지해 줍니다.
핵심 요약 (Takeaways)
메모리는 부가 기능이 아닌, 일급 아키텍처 (first-class architecture)여야 합니다. 후속 조치를 유용하게 만드는 세션 컨텍스트 (session context)는 명시적으로 지속 (persist)되고 회상 (recall)되어야 합니다. 고객별 태깅, 분석 전 회상, 작성 후 저장과 같은 제약 사항을 중심으로 구축하는 것이 전체 설계를 형성합니다.
장애물은 변합니다. 시스템이 이를 감지해야 합니다. 첫 번째 통화(Call 1)에서 언급된 장애물은 가격이었습니다. 다섯 번째 통화(Call 5)에 이르러 가격은 더 이상 중요하지 않았습니다. 메모리가 없는 시스템은 더 이상 존재하지 않는 장애물을 계속 다룹니다. 메모리가 있는 시스템은 무언가가 해결된 시점을 추적하고, 그것을 대체한 요소로 초점을 전환할 수 있습니다.
동기식 프레임워크 (synchronous frameworks) 내의 비동기 라이브러리 (Async libraries)는 주의가 필요합니다. Hindsight와 cascadeflow 모두 Streamlit에서 이벤트 루프 (event loop) 충돌을 일으켰습니다. 자체 루프를 소유하는 단일 전용 스레드 (single dedicated thread)를 사용하는 패턴은 이러한 종류의 문제에 대한 재사용 가능한 해결책입니다.
추론 예산 (reasoning budget)이 중요합니다. 사고 사슬 (Chain-of-thought) 모델은 답변하기 전에 생각하는 데 토큰을 소비합니다. 만약 max_tokens 상한선이 너무 낮으면, 오류 없이 빈 응답만 받게 됩니다. 추론과 출력을 모두 수용할 수 있도록 토큰 제한 크기를 설정하세요.
저비용 우선 라우팅 (Cheap-first routing)은 설정할 가치가 있습니다. 코드가 많이 필요하지는 않지만, 사용자 상호작용당 발생하는 수많은 LLM 호출의 경제성을 변화시킵니다. 단순한 작업은 빠르고 저렴하게 실행되며, 복잡한 추론은 필요할 때만 상위 모델로 격상됩니다.
코드는 CrewAI, Hindsight, cascadeflow, 그리고 Streamlit을 사용한 Python으로 작성되었습니다. 모델은 Groq에서 실행됩니다. Hindsight 문서는 hindsight.vectorize.io에서, cascadeflow 문서는 docs.cascadeflow.ai에서 확인할 수 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기