모든 판매 이벤트를 메모리 쓰기로 다루면 어떻게 될까?
출처: Dev.to
대부분의 LLM 애플리케이션을 만드는 엔지니어들은 메모리를 검색 문제로 생각합니다. 사용자가 질문을 할 때 올바른 컨텍스트를 어떻게 되돌려 줄까? 이것은 필요하지만 설계의 절반에 불과합니다. 나머지 절반—실제로 검색 품질을 좌우하는 부분—은 쓰기 경로입니다. 무엇을 저장하고, 어떻게 구조화하며, 언제 쓰느냐가 이후 모든 것을 결정합니다.
Deal Intelligence Agent를 구축하면서 나는 쓰기 아키텍처가 대부분의 팀이 조용히 실패하는 지점이라는 것을 깨달았습니다. 너무 늦게, 너무 적게 저장하거나, 잘못된 형식으로 너무 많이 저장한 뒤에야 검색 결과가 시끄럽다고 불평하게 됩니다. 여기서는 모든 영업 이벤트를 Hindsight에 명시적인 메모리 쓰기로 취급하면서 배운 점을 공유합니다.
에이전트 메모리를 설계할 때 본능적으로 선별적으로 저장하려 합니다. “중요한” 것만 저장하라는 것이죠. 문제는 “중요함”이 쿼리 시점에 정의된다는 점이며, 쓰기 시점에 무엇이 나중에 중요해질지 예측하기 어렵다는 점입니다.
Deal Intelligence Agent에서는 규칙을 다음과 같이 만들었습니다: 모든 도메인 이벤트를 메모리에 기록한다.
- 이의 제기? 기록한다.
- 단계 변경? 기록한다.
- 경쟁사 언급? 기록한다.
- 이메일 초안 작성? 기록한다.
- SMS 전송? 기록한다.
- 사전 콜 브리핑 생성? 기록한다.
- 롤플레이 세션 완료? 기록한다.
- 자동 파일럿 플레이북 생성? 기록한다.
# 사전 브리핑이 생성된 후
await memory_svc.store_memory(
deal_id=deal_id,
entry_type="briefing",
content=f"Pre-call briefing generated. Key risks: {risk_summary}",
metadata={"generated_at": datetime.utcnow().isoformat()}
)
# Twilio를 통해 SMS를 보낸 후
await memory_svc.store_memory(
deal_id=deal_id,
entry_type="outreach",
content=f"SMS sent to {contact_name}: {sms_preview}",
metadata={"channel": "sms", "sent_at": datetime.utcnow().isoformat()}
)
이 방식은 장황하게 보일 수 있습니다. 실제로도 장황합니다. 하지만 Hindsight의 영구 메모리 레이어가 이제 거래 전체 수명 주기에 대한 완전한 그림을 갖게 되면서 검색 품질이 크게 향상됩니다. 단순히 누군가 기록하기로 선택한 부분만이 아니라 말이죠.
메모리 아키텍처에서 가장 큰 영향을 미친 결정은 embedding_text 접두사 형식이었습니다.
예를 들어, 원시 내용인 "Price is 40% above current vendor"는 가격에 관한 일반적인 진술로 임베딩됩니다.
하지만 [PRICING] Deal abc123: Price is 40% above current vendor와 같이 저장하면 유형 컨텍스트와 함께 임베딩됩니다.
검색에 미치는 효과
"pricing objections"와 같은 쿼리는 원시 텍스트보다 [PRICING] 접두사가 붙은 항목을 훨씬 더 신뢰성 있게 끌어옵니다. 접두사는 벡터 공간 위에 얹힌 소프트 카테고리 인덱스 역할을 합니다.
entry = {
"id": self._generate_id(deal_id, content),
"deal_id": deal_id,
"type": entry_type,
"content": content,
"embedding_text": f"[{entry_type.upper()}] Deal {deal_id}: {content}"
}
result = await asyncio.to_thread(
self.client.memory.store,
user_id=deal_id,
text=entry["embedding_text"], # 임베딩되는 텍스트
metadata={"deal_id": deal_id, "type": entry_type, "content": content}
)
메타데이터는 구조화된 필드를 담고 있으며, embedding_text는 의미 검색을 위해 설계되었습니다. 두 요소는 서로 다른 목적을 가지고 있으므로 독립적으로 설계되어야 합니다.
사실은 메모리의 한 층에 해당합니다. 반면 상호작용—채팅 세션의 앞뒤 흐름—은 또 다른 층입니다. store_interaction 메서드는 모든 채팅 턴을 Hindsight의 add_message 파이프라인에 기록하여 시스템이 대화 패턴을 학습하도록 합니다(단순 사실 상태가 아니라).
async def store_interaction(
self,
deal_id: str,
role: str,
content: str,
metadata: Optional[Dict] = None
) -> None:
if self.use_hindsight:
await asyncio.to_thread(
self.client.memory.add_message,
user_id=deal_id,
role=role,
content=content,
metadata=metadata or {}
)
이는 store_memory와는 별개입니다. 사실은 안정적입니다—예: "CFO가 11월 3일에 가격 이의를 제기했다"는 변하지 않죠. 반면 상호작용은 시간에 따라 변하는 것으로, 에이전트와 영업 담당자 사이의 관계가 어떻게 진화하는지를 나타냅니다. Hindsight는 두 층을 모두 추적합니다.
조용히 이루어진 또 다른 설계 결정
Hindsight와의 통합이 완성되기 전에 완전한 인‑프로세스 폴백을 구축했습니다.
def __init__(self):
self.use_hindsight = HINDSIGHT_AVAILABLE and bool(self.api_key)
if self.use_hindsight:
self.client = hindsight.Client(
api_key=self.api_key,
pipeline_id=self.pipeline_id
)
# else: 모든 연산은 _fallback_store dict 로 흐름
폴백은 defaultdict(list)를 사용해 deal_id를 키로 저장합니다. 의미 검색이 아니라 키워드 매칭이지만, API 키 없이도 모든 다운스트림 기능을 개발·테스트할 수 있을 정도로 충분합니다. 프로덕션에서 Hindsight가 사용 불가능해도 gracefully하게 동작합니다.
교훈
모든 외부 의존성은 로컬 폴백을 가져야 시스템이 깨지지 않고 완전하게 느껴집니다. 폴백은 실제 구현이 아니라, 인프라에 얽매이지 않고 본 구현을 만들 수 있게 해 주는 스케폴딩입니다.
모든 것을 기록하고 embedding_text를 신중히 설계하면, 에이전트 메모리 레이어는 전체 거래의 쿼리 가능한 감사 로그가 됩니다. “무엇이 일어났는가”뿐 아니라 “어떤 순서·채널·결과로 일어났는가”까지 말이죠.
이것이 Autopilot(교차 거래 패턴 매칭), Roleplay 시뮬레이터(이해관계자 정확도 훈련), 사전 콜 브리핑(하나의 쿼리로 얻는 포괄적 컨텍스트), 리스크 히트맵(타입별 이벤트 빈도 기반 트렌드 분석)의 기반이 됩니다.
이러한 기능들을 별도의 데이터베이스나 파이프라인을 구축하지 않아도 모두 같은 Hindsight 메모리 스토어에 대한 쿼리만으로 구현할 수 있었던 이유는 쓰기 아키텍처가 처음부터 이를 지원하도록 설계되었기 때문입니다.
핵심 인사이트
메모리 품질은 쓰기 시점에 결정됩니다. 누군가 질문을 던질 때쯤에는 이미 답변을 개선할 여지가 없습니다—시그널이 스토어에 있든 없든 말이죠.
GitHub: https://github.com/chaitanya07-ai/deal-intelligence-agent
Live: https://deal-intelligence-agent-1.onrender.com