기억 상실과 데자뷰: AI 에이전트가 재시작할 때마다 실패하는 방식 (그리고 우리가 이를 수정한 방법)
요약
본 글은 AI 에이전트가 재시작할 때 발생하는 '기억 상실(Amnesia)'과 '데자뷰(Déjà vu)'라는 두 가지 심각한 문제를 다룹니다. 기본적으로 메모리 내 세션 서비스에 의존하는 기존 프레임워크의 한계를 지적하며, 이를 Postgres와 같은 영구 저장소로 옮겨 에이전트의 신뢰성을 확보하는 방법을 제시합니다.
핵심 포인트
- 기본 에이전트 프레임워크는 재시작 시 대화 기록을 잃어버리는 '기억 상실' 문제가 발생함.
- 에이전트 메모리를 RAM 대신 Postgres 같은 영구 저장소로 옮겨야 신뢰성이 확보됨.
- 브라우저 기반 워크플로우의 경우, 탭을 닫으면 진행 상황이 손실되는 문제도 있음.
- 성공적인 에이전트 구축은 데모를 넘어 실제 프로덕션 환경에서의 안정성을 보장해야 함.
에이전트가 충돌하는 것은 짜증 납니다.
하지만 에이전트가 충돌했다가 다시 돌아오는 경우야말로 위험한 상황입니다.
저희가 프로덕션 환경에서 에이전트를 실행하기 시작했을 때, 재시작이 완전히 상반되는 두 가지 방식으로 잘못될 수 있다는 것을 발견했습니다. 때로는 에이전트가 기억 상실(amnesia) 상태로 돌아왔습니다. 대화 내용, 컨텍스트, 무엇을 하던 중이었는지 모든 것을 잊어버린 것입니다. 그리고 또 다른 때는, 단순히 작업을 재개하여 이를 고치려고 할 때, 데자뷰(déjà vu) 상태로 돌아왔습니다. 이미 현실 세계에서 수행했던 것들을 포함하여 처음부터 모든 것을 기쁘게 반복했습니다.
이메일을 보내는 것처럼요. 두 번이나 말입니다.
이 게시물은 이 두 가지 실패 사례, 왜 명백한 해결책들이 작동하지 않는지, 그리고 우리가 결국 무엇을 구축했는지에 관한 내용입니다. 이는 아무도 데모에서 보여주지 않는 에이전트의 부분이며, 신뢰할 수 있는지 여부를 결정하는 부분이기도 합니다.
아래 내용은 thaink²에서 구축한 오픈 소스 에이전트 런타임인 ApowerB에서 가져온 것이며, 모든 수정 사항은 실제 코드로 연결됩니다. 프로젝트가 처음이라면, 아키텍처 개요가 시작하기 좋은 곳입니다.
요약 (TL;DR)
- 기억 상실(Amnesia): 기본적으로 저희 에이전트 프레임워크는 모든 대화를 RAM에 보관했습니다. 재시작할 때마다 모든 것이 지워졌습니다. 우리는 에이전트의 메모리를 Postgres로 옮겼습니다.
- 데자뷰(Déjà vu):
파트 1: 기억 상실
하루에 여덟 번 재시작, 메모리 제로
어느 시점에 저희는 배포된 서비스 중 하나에 대한 내부 감사를 진행했고, 한 숫자가 눈에 띄었습니다. 바로 하루 평균 약 8번의 재시작이었습니다.
이는 이상한 일이 아닙니다. 모든 배포가 앱을 재시작하기 때문입니다. Kubernetes는 파드(pod)를 이동시키고, 노드는 드레인(drain)됩니다. 건강한 클러스터에서는 프로세스가 끊임없이 생겼다 사라집니다.
문제였던 부분은 이 재시작이 발생할 때마다 모든 대화 기록이 사라진다는 것이었습니다. 히스토리도, 컨텍스트도 없었죠. 사용자가 에이전트와 함께 작업을 절반쯤 진행하고 있을 때, 다음 메시지는 그들을 한 번도 들어본 적 없는 에이전트에게 전달되었습니다.
원인은 기본 설정에 있었습니다. 저희는 Google의 Agent Development Kit (ADK)을 기반으로 구축했습니다. 각 에이전트 대화는 세션(session) 안에 존재합니다. 세션이란 발생한 이벤트 목록(사용자가 말한 것, 모델이 응답한 것, 호출된 도구, 반환된 내용 등)입니다. 이 세션 자체가 에이전트의 단기 기억인 셈이죠. 만약 세션이 어디에 저장될지 구성하지 않으면, ADK는 **메모리 내 세션 서비스(in-memory session service)**로 폴백합니다. 기본적으로 Python dict와 같습니다. 이것은 여러분의 노트북에서는 완벽하게 작동하지만, 프로세스가 재시작할 때마다 증발해 버립니다.
워크플로우를 망친 탭
두 번째 종류의 기억 상실은 훨씬 더 어이없었습니다.
저희의 시각적 워크플로우 빌더(캔버스에 에이전트를 연결하고, 각 에이전트가 다음 에이전트에 출력을 전달하는 방식)는 원래 브라우저 내에서 실행되었습니다. 브라우저가 오케스트레이터 역할을 한 것입니다. 이는 긴 워크플로우 도중에 탭을 닫으면, 워크플로우가 그저... 멈춰버린다는 것을 의미했습니다. 중간에 말이죠. 자신이 어디까지 진행했는지 기록조차 없이요.
아무것도 남기지 않은 실행들
그리고 세 번째는 가장 근본적인 문제였습니다.
제품 전체에서 실패한 실행을 재시도할 수 있는 경로는 오직 하나, 즉 수신 이메일뿐이었습니다. 웹훅(webhook) 큐가 페이로드와 시도 횟수 카운터를 유지했기 때문입니다. 그 외의 모든 것(캔버스에서 시작된 워크플로우, 예약된 에이전트, API 호출)은 실패했을 때 아무것도 남기지 않았습니다. 워크플로우 라우터는 문자 그대로 파이썬 딕셔너리(Python dict)에 상태를 유지했지만, 스트림의 끝에서 비워졌습니다.
따라서 무언가 실패했을 때, 우리는 그것이 무엇을 하도록 요청받았는지 알지 못했기 때문에 다시 시작할 수 없었습니다. 우리는 추적(traces), 로그, 토큰 카운트 등을 가지고 있었습니다. 실패를 자세히 볼 수는 있었지만, 작업을 다시 수행할 수는 없었습니다.
그 순간 저희는 깨달았습니다:
부족했던 것은 관측 가능성(observability)이 아니었습니다. 입력값(input)이었습니다.
수정 1: 에이전트에게 재시작에도 살아남는 기억을 부여하기
첫 번째 수정은 설정 하나로 이루어지며, 이 게시물에서 가장 중요한 내용입니다. 우리는 ADK의 세션 서비스가 RAM 대신 Postgres를 가리키도록 했습니다(main.py 참고):
app = get_fast_api_app(
agents_dir=agents_dir,
session_service_uri="postgresql+asyncpg://...", # 세션은 Postgres에 저장
...
이제 모든 세션과 모든 이벤트가 데이터베이스의 한 행이 됩니다. 파드(pod)가 하루에 열 번 재시작하더라도, 대화 내용은 그대로 남아 있습니다. 그리고 보너스로, 이제 모든 대화는 **감사 추적 가능(auditable)**해졌는데, 이는 실제 비즈니스가 구동되는 환경에서는 매우 중요합니다.
저희는 장기 기억(long-term memory)(에이전트가 대화 간에 기억하는 것)에도 같은 작업을 수행했습니다. 기본 설정은 여전히 워커별 RAM에 있었고, 전달되지 않았습니다. 이제 그것은 애플리케이션 데이터베이스에 거주하며, 에이전트 및 사용자별로 범위가 지정되어 있어 한 사용자의 기억이 다른 사용자에게 유출되는 일이 없습니다.
프로덕션 환경에서만 나타나는 두 가지 작은 세부 사항:
- 커넥션 풀(connection pool) 관리. 기본적으로 세션 엔진은 워커당 최대 15개의 커넥션을 열었습니다 (SQLAlchemy의 기본값 5개에 오버플로우 10개 추가). 다른 서비스와 데이터베이스를 공유하는 경우, 이는 빠르게 누적됩니다. 저희는 이를 5개로 제한했습니다.
- 메모리 무한 증가 방지. ADK의 데이터베이스 세션 서비스에는 보존 정책(retention policy)이 없습니다:
events테이블은 에이전트의 턴마다 한 행씩 기록되며, 각 행은 전체 이벤트를 JSON 형태로 담고 있습니다. 그대로 두면 시간이 지남에 따라 데이터베이스를 서서히 소진시킵니다. 이제 작은 백그라운드 작업이 매일 한 번, 90일보다 오래된 이벤트를 삭제합니다. 세션 자체는 유지되지만, 오래된 턴의 상세 정보만 사라집니다.
Fix 2: 오케스트레이터(orchestrator)를 브라우저에서 분리하기
워크플로우 엔진 (workflow engine)을 ADK 자체의 Workflow 원시 요소(체이닝, 팬아웃, 조인 등)를 기반으로 서버로 옮겼으며, 임시 제작 스케줄러 대신 사용합니다. 모든 단계는 step_start, step_complete, 또는 step_error 이벤트를 발생시키고, 실행 결과는 finally 블록에 데이터베이스에 기록됩니다.
무엇이 정확히 변경되었는지 설명드리자면: 탭을 닫아도 여전히 실행은 중단됩니다. 이것은 의도된 동작입니다. 연결이 끊긴 클라이언트는 자체적으로 실행을 취소하며, 이 취소는 워크플로우가 시작한 모든 것(하위 워크플로우, 재시도 대기 등)으로 전파되므로, 아무것도 감독 없이 백그라운드에서 계속 실행되는 일이 없습니다. 변경된 점은 실행이 더 이상 사라지지 않는다는 것입니다. 이는 cancelled 상태로 입력 값과 함께 기록되며, 취소된 실행이라도 재생(replay)할 수 있습니다. 물론 안전한 경우에 한해서입니다. 이 생각은 파트 2까지 유지해 주세요.
Fix 3: 시작하기 전에 모든 실행을 기록하기
이것이 다른 모든 것을 가능하게 만든 핵심 기능입니다. 이제 모든 실행(워크플로우, 예약된 에이전트, 채팅 턴)은 한 행으로, 시작하는 순간 즉시 기록됩니다. 수신되는 웹훅(webhook)은 자체적인 영속적 큐(durable queue)와 고유성 보장(idempotency)을 유지하며, 이 두 가지는 실행 엔진이 통합되는 날에 병합될 것입니다.
해당 행이 무엇을 담고 있는지 알려드립니다 (run_store.py):
Table("agent_runs", metadata,
Column("run_id", String, primary_key=True),
Column("trigger", String), # workflow | schedule | chat | webhook
...
만약 실행에 파일이 포함되어 있다면, 우리는 즉시 해당 파일을 디스크에 복사합니다. 이것은 경험에서 비롯된 것입니다: 이메일 경로는 이미 첨부파일을 보관하고 있었는데, 이는 나중에 받은 편지함에서 삭제된 이메일을 재현할 수 있도록 하기 위함이었습니다. 사용자 파일에 포인터만 보관한다면, 그 파일이 사라지는 날에는 재현 자체가 불가능합니다.
수정 4: 메모리 식단 조절하기 (memory on a diet)
기억을 영속화하는 것은 기억상실증(amnesia) 문제를 해결했습니다. 하지만 새로운 문제도 만들었습니다: 모든 것을 기억하는 에이전트는 그 정보에 질식할 수 있다는 것입니다.
ADK는 매 턴마다 전체 대화 기록을 모델로 전송합니다. 이제 PDF를 이미지로 변환하여 모델이 읽을 수 있게 하는 도구를 상상해 보세요. 각 페이지는 약 10만~15만 토큰에 달하는 base64 블롭입니다. 5턴이 지나면, 모델에게 동일한 이미지가 다섯 번 전송됩니다. 이미 읽은 것에 대해 다섯 번이나 비용을 지불하고 있는 것입니다.
그래서 매 모델 호출 전에, 콜백 함수가 최신 메시지를 제외한 모든 메시지에서 큰 base64 페이로드를 제거합니다:
def before_model_callback(*, callback_context, llm_request):
contents = llm_request.contents or []
for content in contents[:-1]: # 최신 턴은 건드리지 않음
...
모델은 이미 이미지를 봤습니다. 다음 턴부터는 **이미지에 대한 모델 자체의 텍스트가 진실의 원천(source of truth)**입니다. 여전히 도구가 실행되었고 무엇을 생성했는지 알지만, 다시 원시 바이트 데이터까지는 아닙니다.
기억상실증: 해결됨. 이제 우리의 에이전트는 기억합니다. 이는 두 번째로 더 교묘한 문제로 이어집니다.
파트 2: 데자뷰
유혹적인 해결책
한 번의 실행(run)과 그 입력값(input)을 기록해 두면, 당연히 다음 아이디어는 이것입니다. 실행이 실패하면 그냥 재시작하는 것입니다. 동일한 입력값으로, 동일한 에이전트를 사용해서 다시 시도합니다. 완전히 자동화됩니다. 문제 해결, 맞죠?
들어보세요. 들어오는 고객 요청을 처리하는 임무를 가진 에이전트가 있다고 가정해 봅시다. 이 에이전트는 메시지를 읽고, 답장을 초안 작성한 다음, 이메일을 전송하고, CRM(고객 관계 관리) 시스템을 업데이트합니다. CRM 업데이트 도중에 파드(pod)가 강제 종료됩니다.
처음부터 재시작하면, 이 에이전트는 메시지를 읽고, 답장을 초안 작성한 다음, 다시 이메일을 전송합니다. 이제 고객은 두 개의 답변을 받게 됩니다. 어쩌면 모델이 같은 내용을 두 번 쓰지 않기 때문에 약간 다른 내용의 답변일 수도 있습니다.
이것이 문제의 핵심입니다. 에이전트는 순수 함수(pure function)가 아닙니다. 에이전트는 도구를 통해 세상에 행동합니다: 이메일을 보내고, 데이터베이스에 쓰고, API를 호출합니다. 이것들이 부작용(side effects)입니다. 그리고 맹목적인 재시작은 그 모든 것을 반복합니다.
왜 단순히 '3단계부터 재개'할 수 없는가
자연스러운 반응은 이렇습니다. 괜찮아, 그럼 처음부터 다시 시작하지 말고, 중단된 지점부터 재개하자. 이미 완료된 단계는 건너뛰어라.
전통적인 워크플로우 엔진(workflow engine)을 사용한다면 이것이 작동합니다. 왜냐하면 단계들이 고정적이고 결정론적(deterministic)이기 때문입니다. 3단계는 항상 3단계입니다.
하지만 LLM 에이전트에서는 '3단계'라는 것이 없습니다. '계획(plan)'은 오직 그 특정 실행 내에만 존재하며, 그 순간 모델의 추론 과정 안에 있습니다. 다시 실행하면 모델은 다른 경로를 따르거나, 도구를 다른 순서로 호출하거나, 첫 번째 시도 때 사용하지 않은 도구가 필요하다고 판단할 수도 있습니다. 이미 수행한 호출들을 건너뛰면서 에이전트를 재실행하는 깔끔한 방법이 없는 이유는, 그 호출들로 이어졌던 추론 과정 자체를 다시 재생(replay)할 수 없기 때문입니다.
그래서 우리는 '어떻게 재개할까?'라는 질문을 멈추고, 다른 질문을 던지기로 했습니다:
다시 시작하는 것이 안전한지 어떻게 알 수 있을까?
이것에 답하려면 정확히 한 가지 정보가 필요합니다. 이번 실행이 이미 세상과 상호작용했는지(touch the world)?
Fix 5: 모든 도구 호출을 기록하라 (답변이 아닌, 호출 자체를)
이제 모든 에이전트 실행은 호출한 도구 목록을 기록합니다. 저희는 세션 이벤트(run_main.py)에서 이를 직접 읽어옵니다:
def executed_tools(events) -> list[str]:
"""Tools called in a list of ADK events, in order.
...
이 문서 문자열에는 이 게시물 전체에서 가장 중요한 세부 사항이 숨겨져 있습니다. 저희는 **응답(response)**이 아니라 호출(call) 횟수를 계산합니다. 만약 이메일 도구가 이메일을 보내고 답변하기 전에 시간 초과된다면, 기록에 응답은 없습니다. 하지만 이메일은 전송되었습니다. 만약 답변한 도구만 세어본다면,
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기