
에이전트 상태 비동기화(Agent State Desync): 에이전트가 기억을 잃는 이유와 해결 방법
요약
에이전트의 모델 컨텍스트(단기 기억)와 실제 작업 상태(외부 데이터)가 일치하지 않는 '상태 비동기화' 현상을 설명합니다. 프로세스 중단 시 발생하는 중복 작업 문제를 해결하기 위해 내구성(durability) 있는 상태 기록의 중요성을 강조합니다.
핵심 포인트
- 상태 비동기화: 모델 컨텍스트와 실제 작업 상태 간의 불일치 현상
- 모델 컨텍스트는 RAM에 존재하는 휘발성 단기 기억임
- 실제 상태는 외부 시스템이나 DB에 존재하는 권위 있는 복사본임
- 해결책은 메모리 증설이 아닌 프로세스 재시작에도 유지되는 내구성 확보
- 도서: Agents in Production — Building, Tracing, and Shipping Multi-Step AI You Can Trust
- 저자의 다른 저서: Observability for LLM Applications — The AI Engineer's Library (2권 시리즈)의 동반 도서
- 내 프로젝트: Hermes IDE | GitHub — Claude Code 및 기타 AI 코딩 도구를 사용하여 작업하는 개발자를 위한 IDE
- 자기소개: xgabriel.com | GitHub
당신은 에이전트에게 다음과 같은 작업을 부여합니다: "리스본 여행을 예약해줘, 900유로 이내로 하고, 창가 좌석을 선호해." 에이전트는 항공권을 검색합니다. 호텔 API를 호출합니다. 좌석을 예약합니다. 그런데 일곱 번째 단계에서 포드(pod)가 재시작됩니다. 배포(deploy), OOM(Out of Memory) 킬, 또는 스팟 인스턴스(spot instance) 회수 중 무엇 때문인지는 중요하지 않습니다.
사용자가 재시도합니다. 에이전트는 비어 있는 messages 리스트부터 다시 시작합니다. 항공권을 다시 검색합니다. 호텔 API를 다시 호출합니다. 그리고 두 번째 좌석을 예약합니다. 첫 번째 예약은 더 이상 존재하지 않는 프로세스에서 발생했고 아무것도 기록되지 않았기 때문입니다. 에이전트는 기억상실증에 걸렸고, 이 기억상실증 때문에 사용자에게 중복 예약이라는 비용을 발생시켰습니다.
이것이 바로 상태 비동기화(state desync)입니다. 모델의 컨텍스트(context)와 작업의 실제 상태(real state)가 서로 어긋났고, 에이전트는 잘못된 상태를 믿게 된 것입니다.
두 가지 종류의 상태, 그리고 그중 하나만이 지속 가능(durable)하다
사람들이 "에이전트 상태(agent state)"라고 부르는 것에는 두 가지가 있으며, 이 둘을 혼동하는 지점에서 버그가 발생합니다.
첫 번째는 **모델 컨텍스트(model context)**입니다: 매 턴마다 당신이 보내는 messages 리스트입니다. 시스템 프롬프트(System prompt), 사용자 턴(user turns), 도구 호출(tool calls), 도구 결과(tool results)가 포함됩니다. 이것은 단기 기억(short-term memory)입니다. 프롬프트에 실려 RAM에 존재합니다.
두 번째는 **작업의 실제 상태(real state of the task)**입니다: 어떤 항공권을 이미 검색했는지, 어떤 예약을 이미 완료했는지, 이미 지출한 340유로가 얼마인지 등을 의미합니다. 이 중 일부는 messages 리스트에도 존재하지만, 권위 있는 복사본(authoritative copy)은 외부 세계, 즉 Stripe, 호텔의 예약 시스템, 또는 당신의 데이터베이스 행(row)에 존재합니다.
이 두 가지가 일치할 때 에이전트는 정상적으로 작동합니다. 하지만 이들이 어긋나기 시작하면(drift), 에이전트는 이미 수행한 작업을 반복하거나 수행하지 않은 작업을 건너뛰게 됩니다. 크래시(crash)는 이들을 어긋나게 만드는 가장 빠른 방법인데, 크래시가 발생하면 RAM에 있는 복사본은 삭제되지만 세상(world)의 복사본은 그대로 남기 때문입니다.
해결책은 더 많은 메모리를 사용하는 것이 아닙니다. 해결책은 내구성(durability)입니다. 프로세스가 종료되어도 살아남을 수 있는 어딘가에 궤적(trajectory)을 기록하고, 프로세스가 다시 시작될 때 그것으로부터 재구축해야 합니다.
실제로 영속화(persist)해야 하는 것은 스크래치패드(scratchpad)입니다
루프(loop) 내부에서 에이전트는 스크래치패드(scratchpad), 즉 현재 작업의 '생각-행동-관찰(Thought-Action-Observation)' 이력을 유지합니다. 1단계: 항공권을 검색했고, 이 세 가지 결과를 얻었다. 2단계: 09:40 항공편을 선택했고, 14A 좌석을 예약했다. 사용자는 이를 절대 볼 수 없습니다. 이것은 7단계에서 에이전트가 3단계에서 무엇을 했는지 기억할 수 있도록 존재합니다.
그 스크래치패드가 바로 중요한 상태(state)입니다. 만약 그것이 프로세스와 함께 사라진다면, 에이전트는 망각합니다. 따라서 이를 온전하게 유지되기를 바라는 messages 배열의 일부가 아니라, 명시적인 객체(explicit object)로 모델링하십시오.
# scratchpad.py
from dataclasses import dataclass, field, asdict
...
to_dict와 from_dict가 핵심입니다. 직렬화(serialize)할 수 있는 스크래치패드는 체크포인트(checkpoint)를 만들 수 있는 스크래치패드입니다. 메모리에만 담아둘 수 있는 스크래치패드는 다음 OOM(Out Of Memory) 킬(kill) 발생 시 잃게 될 뿐입니다.
마지막이 아니라 매 단계마다 체크포인트를 생성하세요
단순한 본능은 작업이 끝날 때 상태를 저장하는 것입니다. 그것은 완전히 거꾸로 된 생각입니다. 완료된 작업은 체크포인트가 필요하지 않습니다. 7단계에서 죽어버리는 작업은 체크포인트가 필요하며, 이 작업은 완료되기 전에 죽기 때문에 매 단계마다 기록해야 합니다.
다음은 SQLite를 기반으로 한 내구성 있는 저장소(durable store)입니다. 운영 환경에서는 이를 Postgres나 Redis로 교체하십시오. 구조는 동일합니다.
# checkpoint.py -- stdlib only
import json
import sqlite3
...
두 가지 세부 사항이 중요합니다. task_id 기본 키(primary key)에 대한 REPLACE INTO는 각 저장이 해당 작업의 마지막 체크포인트(checkpoint)를 덮어쓴다는 것을 의미합니다. 따라서 저장소는 계속 늘어나는 로그가 아니라, 진행 중인 작업당 하나의 행(row)을 보유하게 됩니다. 그리고 save()는 호출될 때마다 커밋(commit)을 수행합니다. 이 커밋이 내구성 경계(durability boundary)입니다. 만약 함수가 반환된 직후 한 명령(instruction) 뒤에 프로세스가 종료되더라도, 체크포인트는 디스크에 저장되어 있습니다.
재구축(Rebuild), 그 다음 중단된 지점부터 계속하기
이제 루프(loop)입니다. 시작 시 체크포인트를 로드하려고 시도합니다. 만약 존재한다면, 이를 모델 컨텍스트(model context)로 다시 재생(replay)하고 마지막으로 기록된 단계의 다음 단계부터 계속 진행합니다. 만약 존재하지 않는다면, 이는 새로운 작업입니다.
# agent.py -- pip install "anthropic==0.69.0"
from anthropic import Anthropic
from scratchpad import Scratchpad
...
중요한 줄은 pad.record(...) 직후에 오는 store.save(pad)입니다. 메모리 상의 스크래치패드(scratchpad)와 디스크 상의 스크래치패드는 결코 한 단계 이상 차이가 나지 않습니다. 포드(pod)가 재시작되고 사용자가 동일한 task_id로 재시도하면, store.load는 이미 발생한 7개의 단계를 반환하고, context_from은 이를 모델의 컨텍스트로 다시 재생하며, 에이전트는 1단계가 아닌 8단계부터 작업을 이어갑니다.
모델은 당신이 '생존하기를 바랐던' 상태가 아니라, 당신이 '소유했던' 상태를 읽음으로써 기억을 되찾습니다.
함정: 이미 발생한 부작용(side effects)
스크래치패드를 재구축하는 것은 컨텍스트와 당신의 자체 저장소 사이의 간극을 메워줍니다. 하지만 당신의 저장소와 외부 세계 사이의 간극을 메워주지는 않습니다. 6단계에서의 좌석 예약은 실제 효과를 동반한 실제 API 호출이었습니다. 스크래치패드를 재생하는 것은 에이전트에게 좌석을 예약했다고 알려줄 뿐입니다. 만약 충돌(crash)이 API 호출과 store.save 사이에서 발생하여 6단계가 두 번 실행되었다면, 중복된 예약을 취소해주지는 않습니다.
정직한 해결책은 효과 경계(effect boundary)에서의 멱등성(idempotency) 확보입니다. 세상을 변화시키는 모든 도구 호출(tool call)은 작업(task)과 단계(step)에서 유도된 키를 포함해야 하며, 다운스트림 시스템(downstream system)은 이 키를 바탕으로 중복을 제거(dedupe)해야 합니다.
def reserve_seat(task_id, step, flight, seat):
key = f"{task_id}:{step}:reserve"
return booking_api.reserve(
...
이제 재실행(replayed)된 6단계가 처음 사용했던 것과 동일한 키를 가지고 예약 API(booking API)에 도달하면, API는 두 번째 예약을 만드는 대신 기존의 예약 정보를 반환합니다. Stripe, 대부분의 예약 시스템, 그리고 잘 설계된 모든 내부 API는 정확히 이러한 목적을 위해 멱등성 키(idempotency key)를 허용합니다. 체크포인팅(Checkpointing)은 에이전트에게 기억을 되찾아 줍니다. 멱등성 키는 그 기억이 중복 결제로 이어지는 것을 방지합니다.
규칙
모델 컨텍스트(Model context)는 윈도우(window)에 의해 제한되며 RAM에 존재합니다. 실제 상태(Real state)는 여러분의 저장소와 외부 세계에 의해 제한됩니다. 충돌(crash)이 발생하면 첫 번째는 삭제되고 두 번째는 남게 되며, 에이전트가 재개(resume) 시 잘못된 상태를 신뢰할 때 발생하는 것이 바로 비동기화(desync)입니다.
스크래치패드(scratchpad)를 직렬화(Serialize)하세요. 마지막이 아니라 매 단계마다 체크포인트를 생성하세요. 재시작 시에는 체크포인트로부터 재구축하세요. 외부 세계에 영향을 주는 모든 도구 호출(tool call)에 멱등성 키를 부여하세요. 이 네 가지를 수행하면 궤적 중간의 충돌(mid-trajectory crash)은 중복 예약이 아닌 재개(resume)가 됩니다.
실제 인프라(재시작되는 포드(pods), 작업 중 배포(deploys), 사라지는 스팟 인스턴스(spot instances))에서 생존해야 하는 에이전트를 구축하고 있다면, 위에서 언급한 루프, 체크포인팅, 멱등성 패턴을 다루는 _Agents in Production_이 도움이 될 것입니다. 그 동반서인 _Observability for LLM Applications_에서는 재개 과정을 추적(trace)하는 법을 배워, 사후에 에이전트가 정확히 어느 단계를 재실행했는지, 그리고 효과(effect)를 이중으로 발생시켰는지 확인할 수 있습니다. 이 두 권은 _The AI Engineer's Library_입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기