실제로 망각하는 메모리 에이전트 구축하기 (그리고 그 과정이 왜 어려운지 가르쳐준 세 가지 버그)
요약
사용자의 과거 대화를 기억하고 중요한 정보를 추출하는 메모리 레이어를 갖춘 AI 에이전트 구축 과정을 다룹니다. 벡터 데이터베이스와 캐싱을 활용한 아키텍처 설계 과정과 실제 구현 시 마주한 구조적 버그 및 인프라 관리의 어려움을 설명합니다.
핵심 포인트
- 상태가 없는(stateless) AI 채팅에 메모리 레이어를 추가하여 지속적인 문맥 유지
- Qwen Cloud, Neon Postgres(pgvector), Upstash Redis를 활용한 기술 스택 구성
- 정보 추출 단계에서 전체 대화 기록을 참조하지 못해 발생하는 구체성 상실 문제
- 실제 배포 환경(Alibaba Cloud ECS)에서의 인프라 및 환경 설정 관리의 중요성
Nidhi 작성: Qwen Cloud와 함께하는 Global AI Hackathon Series, Track 1: MemoryAgent를 위해 구축됨
Track 1에 등록했을 때, 나는 메모리가 기본적으로 벡터 데이터베이스 (vector database) 문제라고 생각했다. 임베딩 (embeddings)을 저장하고, 유사한 메시지를 검색하면 끝이라고 말이다. 첫 번째 프로토타입은 잘 작동했다. 그러다 실제로 사용해 보려고 시도하기 시작했을 때, 상황은 흥미로워졌다.
아이디어
대부분의 AI 채팅은 상태가 없다 (stateless). 탭을 닫으면 다음 대화는 제로 상태에서 시작된다. 우리가 계속해서 "지능적"이라고 부르는 존재에게 이는 이상한 제약이다. 당신을 잘 아는 사람이라면 3일 전에 당신의 이름, 직업, 또는 무엇을 하고 있는지 말했던 내용을 잊지 않을 것이다. 그래서 계획은 다음과 같았다: 채팅 에이전트 아래에 메모리 레이어 (memory layer)를 구축하는 것. 매 턴마다 관련 있는 과거 문맥 (context)을 회상하고, 이를 염두에 두고 응답하며, 기억할 가치가 있는 새로운 사실을 추출한다. 중요한 것은 남고, 사소한 것은 만료된다.
그 부분은 빠르게 완성되었다. 백엔드를 위한 FastAPI를 사용했으며, 두 개의 서비스로 나누었다: 저장 및 지능을 처리하는 메모리 코어 (memory core)와 이를 호출하는 상단의 얇은 채팅 레이어 (chat layer). 채팅 모델과 임베딩 모두를 위해 Qwen Cloud를 사용했다. 실제 벡터 저장소로는 pgvector가 활성화된 Neon Postgres를, 캐싱을 위해서는 Upstash Redis를 사용했으며, 이 모든 것은 Alibaba Cloud ECS에 배포되었다. 며칠 이내에 나는 메모리를 저장하고, 중요도를 점수화하며, 다음 메시지에서 이를 회상하는 작동 가능한 채팅 UI를 갖게 되었다.
완성된 것처럼 보였다. 하지만 완성된 것이 아니었다.
그 과정 또한 깔끔하지 않았다. 실제로 서로 통신해야 하는 두 개의 FastAPI 서비스, 데이터를 삽입하기도 전에 pgvector가 활성화되어 있어야 하는 Neon의 Postgres 인스턴스, 연결할 수 없을 때 실패를 알리는 대신 조용히 아무 작업도 하지 않는 (no-ops) Redis 캐시, 그리고 실제 배포 과정 — Alibaba Cloud ECS 박스에 SSH로 접속하여, 어떤 환경에 있는지 잊어버려 venv가 활성화되지 않았음을 깨닫고, 두 서비스 모두 ModuleNotFoundError와 함께 충돌하는 것을 지켜보다가, 다시 활성화하고, 재시작하고, 이를 반복하는 과정까지. 이 중 그 어떤 것도 아키텍처 다이어그램 (architecture diagram)에는 나타나지 않는다. 이 모든 것이 실제 시간을 잡아먹었다.
버그 1: 시스템이 구체성을 처벌했다
저는 테스트 데이터가 아니라 실제 대화를 통해 이 시스템을 돌려보았습니다. 사용자가 실제로 이야기하는 방식 그대로 말한 것이죠. 어느 시점에서 제가 제 이름을 알려줬습니다. 하루 뒤, 저는 그것이 무엇을 기억하고 있는지 물어봤는데, 제 이름에는 24시간 만료 타이머가 걸려 있었습니다. 별개로, 제가 체리토마토와 바질을 키운다고 언급했는데, 이 구체적인 사실이 같은 대화에서 했던
그 버그는 구조적인 문제였습니다. 정보를 추출하는 단계 — 대화 턴에서 지속적인 사실을 뽑아내어 저장하는 부분 — 가 오직 현재의 턴, 즉 사용자의 메시지와 어시스턴트의 응답만을 살펴보았습니다. 사용자의 메시지가 "이것을 저장해줘"였을 때, 해당 추출 호출(extraction call)이 바라보는 세상에는 실제로 저장할 만한 내용이 아무것도 없었습니다. 진짜 내용인 — 훈련 계획(training plan) — 은 몇 개의 메시지 전, 추출기가 전혀 보지 못한 다른 턴에서 이미 생성되었기 때문입니다. 확인을 생성한 채팅 응답은 전체 대화 기록(conversation history)을 사용할 수 있었기에, 확신에 차고 구체적으로 들렸습니다. 하지만 그 확인에 대해 실제로 동작해야 할 추출 호출은 그러한 문맥(context)을 전혀 가지고 있지 않았습니다. 동일한 파이프라인의 두 부분이 완전히 다른 양의 정보를 가지고 작동하고 있었으며, 아키텍처의 그 어떤 것도 제가 직접 확인하기 전까지는 그 불일치를 가시화해주지 않았습니다.
해결책은 최근의 대화 기록을 추출 프롬프트(extraction prompt)에도 함께 입력하고, 명시적인 지침을 주는 것이었습니다. 즉, 사용자가 새로운 사실을 직접 말하는 대신 이전에 말한 무언가를 다시 가리키고 있다면, 무엇을 추출할지 결정하기 전에 해당 기록을 사용하여 그 참조(reference)를 해결(resolve)하라는 지침이었습니다. 수정 후에는 동일한 "이것을 저장해줘"라는 요청이 참조 대상이 무엇이든 그 실제 내용을 정확하게 추출해냈습니다.
여기서 제 기억에 남은 것은 해결책이 아니라, 그 실패가 얼마나 설득력 있었는지 하는 점이었습니다. 확신이 없어 보이는 틀린 답은 재검토를 받습니다. 하지만 완전한 자신감을 가지고 전달되는 틀린 답은 대개 재검토를 받지 않습니다. 제가 이 문제를 발견한 것은 시스템에서 어떤 문제 신호를 보냈기 때문이 아니라, 단지 습관적으로 메모리 패널을 확인했기 때문이었습니다.
세 번째 버그: 단 한 번도 작동한 적 없던 기능
Smart Forget는 가장 흥미로운 부분 중 하나가 될 예정이었습니다. 타이머가 만료되는 즉시 기억을 맹목적으로 삭제하는 대신, 만료 기간이 지난 모든 것을 모아 Qwen에게 한 번 더 판단을 요청하는 방식이었습니다. 즉, "기술적으로는 시간이 다 되었지만, 이것이 여전히 중요한가?"라고 묻는 것이었습니다. 저는 이를 구축하고 테스트했지만, 모든 실행 결과는 reviewed: 0, deleted: 0으로 보고되었습니다. 저는 짧은 테스트 기간 동안 아직 만료된 것이 아무것도 없다는 뜻이라고 가정했고, 이는 타당해 보였습니다. TTL (Time To Live)은 일 단위로 측정되었고, 저는 한 번에 몇 시간씩만 테스트하고 있었기 때문입니다.
그것이 아니었습니다. 원인은 제 오류 처리 (error handling) 로직에 의해 조용히 삼켜진 크래시 (crash)였습니다. 데이터베이스는 타임스탬프를 시간대 인식 (timezone-aware) datetime 객체로 반환합니다. 하지만 제 코드는 이를 시간대 미인식 (timezone-naive) 상태인 datetime.utcnow()와 비교하고 있었습니다. Python은 이 두 객체 사이의 뺄셈 연산을 거부하며 TypeError를 발생시키고, 제 try/except 블록이 이 오류를 잡아내어 기본값인 "유지"로 설정해 버린 것입니다. 무서운 점은, 눈치챌 수 있을 정도로 크게 크래시가 발생하지 않았다는 것입니다. 저의 오류 처리 로직이 실제 실패를 마치 신중하고 보수적인 동작처럼 보이게 만들었습니다. 실제로는 해당 기능이 단 한 번의 실제 비교도 수행하지 못했는데 말입니다.
제가 이를 발견할 수 있었던 이유는 모의 객체 (mocks)를 사용한 유닛 테스트 (unit tests)가 아니라, 실제 실행 중인 배포 환경에 대해 라이브 HTTP 호출을 수행하고 실제 결과를 확인하는 스크립트로 구성된 진짜 테스트 스위트 (test suite)를 구축했기 때문입니다. 심지어 그 당시에도 테스트는 처음에는 통과했습니다. 0 deleted가 기술적으로 틀린 것은 아니었기 때문입니다. 다만 그것이 "의미 있게" 맞았던 것도 아니었습니다. 실제로 이를 잡아낸 방법은 한 단계 더 나아가는 것이었습니다. 데이터베이스에서 직접 특정 기억의 만료 시간을 과거로 밀어 넣어 Smart Forget을 트리거하고, 서버 로그를 실시간으로 관찰하는 것이었습니다. 그때서야 우아해 보이는 폴백 (fallback) 뒤에 숨겨져 있던 실제 TypeError가 나타났습니다.
해결책 자체는 간단했습니다. 모든 곳에서 시간대 인식 (timezone-aware) datetimes로 전환하고, 불일치가 조용히 재발하지 않도록 근본적인 스키마 (schema)를 수정하는 것이었습니다. 더 큰 해결책은 절차적인 것이었습니다. 테스트를 통과하는 것과 올바르게 동작하는 시스템은 같은 것이 아니며, 그 둘 사이의 간극이 바로 이런 문제들이 숨어 있는 지점입니다.
이것이 실제로 내게 가르쳐준 것
이 세 가지 버그 중 그 어떤 것도 내가 알아챌 만한 에러 메시지를 던지거나, 첫 번째 시도에서 테스트를 실패시키지 않았습니다. 그중 두 개는 내부적으로 아무것도 하지 않으면서도 확신에 찬 답변을 내놓았습니다. 세 번째는 실제로는 고장 난 상태임에도 불구하고 신중하고 조심스러운 것처럼 보였습니다. 매끄러운 답변, 200 상태 코드 (status code), 그리고
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기