Joyuno's Dev Blog

컨텍스트 윈도우를 넘어서: 2026년형 AI Agent 장·단기 메모리와 Long-term 상태 관리 구현 패턴

AI Agent

들어가며

프로덕션 AI Agent를 붙이다 보면 “모델 성능”보다 더 빨리 한계가 오는 지점이 state(상태) 입니다. 세션이 바뀌면 사용자의 선호/업무 맥락을 잊고, 도구 실행 중간에 죽으면 재시도 전략이 없고, 멀티스텝 워크플로우가 길어질수록 “무엇을 알고/했고/해야 하는지”가 붕괴합니다. 결국 Memory = UX이자 State = 안정성/비용 문제로 수렴합니다. (최근 커뮤니티에서도 “메모리 지속성과 비용 제어는 같은 문제”라는 관찰이 반복됩니다. (reddit.com))

언제 쓰면 좋은가

  • 사용자별 개인화(선호/규칙/금지사항)가 누적될수록 가치가 커지는 제품
  • 멀티스텝 툴 실행(ETL, 티켓 처리, 코드 변경, 리서치→작성 파이프라인)처럼 “중간 상태”가 중요한 에이전트
  • 장애/재시작/스케일아웃이 일상인 환경(컨테이너 재배치, 서버리스, 워커 풀)

언제 쓰면 안 되는가

  • 단발성 Q&A, “한 번 답하고 끝”인 기능(메모리 유지가 오히려 개인정보/규정 리스크)
  • 요구사항이 불명확한 초기 단계에서 “일단 다 저장”하는 접근(중복/오염/삭제 요구 대응 불가)
  • 강한 정합성이 필요한 트랜잭션(결제/송금 등)을 “에이전트 내부 state”에 의존하려는 설계(외부 시스템이 source of truth여야 함)

🔧 핵심 개념

1) 용어를 먼저 분리: STM / LTM / State

2026년 실전에서는 메모리를 3계층으로 나누는 게 가장 사고가 적습니다.

  • STM (Short-term / working memory): “지금 실행 중인 스텝”에 필요한 최근 대화/툴 결과. 보통 context window에 직접 로드.
  • LTM (Long-term memory): 세션을 넘어 유지되는 사용자 선호, 사실, 에피소드, 지식. RAG/Vector/Graph/문서 저장 등으로 보관.
  • State (durable execution state): “이 워크플로우가 어디까지 실행됐는지”, “어떤 tool call이 완료/실패했는지”, “다음 재시도는 무엇인지” 같은 재개(resume) 가능한 실행 상태.

여기서 중요한 포인트: LTM은 ‘기억(knowledge)’이고, State는 ‘진행 상황(checkpoint)’입니다. 이 둘을 섞으면 “잘못된 기억을 영구 저장”하거나 “실행 상태를 검색으로 복구”하는 지옥이 열립니다.

2) 2026년 트렌드: “Checkpoint + Rehydration”이 기본값

OpenAI Agents SDK는 메모리 주입(요약 파일)과 함께, snapshotting/rehydration(스냅샷/재수화)로 실패 후 같은 상태에서 이어가기 패턴을 전면에 둡니다. 즉, 에이전트를 “프로세스”가 아니라 “복구 가능한 실행체”로 다룹니다. (openai.com)

LangGraph 쪽도 동일한 방향인데, 그래프 각 스텝마다 state를 checkpointer로 저장해 thread 단위로 지속화할 수 있게 합니다. (docs.langchain.com)

정리하면 2026년형 표준 흐름은 아래입니다.

1) 요청 들어옴 → thread_id/run_id 부여
2) 그래프/에이전트가 스텝 실행
3) 각 스텝 종료 시점에 state checkpoint 저장 (재실행/재개를 위해)
4) 필요 시 LTM에 “추출된 사실/선호/요약”을 별도로 기록(비동기 가능)
5) 다음 요청에서 STM은 최근 대화 + “필요한 메모리만” 로드(전체 덤프 금지)

3) 접근 방식 차이: RAG만으로는 “상태”를 못 푼다

Make(자동화 플랫폼)에서도 지적하듯 RAG를 “메모리”로 쓰는 건 정적 지식에는 유효하지만, “빠르게 변하는 고객 상태”에는 freshness/인덱싱 문제가 생깁니다. (make.com)
즉,

  • “회사 휴가 정책” → RAG ok
  • “이 고객은 오늘 오전에 환불 요청했고, 내가 2단계까지 처리했음” → State/ledger가 필요

또한 Zep은 “최근 몇 메시지는 raw short-term, 장기 컨텍스트는 별도 파이프라인으로 생성되며 ingestion 지연이 있을 수 있다”고 명시합니다. 이 말은 곧, LTM은 즉시 일관성을 보장하기 어렵고, 그래서 실행 안전성은 checkpoint/state로 해결해야 한다는 뜻입니다. (help.getzep.com)


💻 실전 코드

아래 예제는 “지원 티켓 처리 에이전트” 시나리오입니다.

  • 사용자는 여러 날에 걸쳐 티켓을 업데이트
  • 에이전트는 (1) 티켓 요약 (2) 정책 조회 (3) 답변 초안 생성 (4) CRM 업데이트 를 수행
  • 중간에 장애가 나도 마지막 스텝부터 재개
  • 사용자 선호(말투, 민감정보 처리 규칙 등)는 LTM(Notes)로 축적하고, 매 런에 “요약” 형태로 주입

구현 선택: OpenAI Agents SDK의 메모리 세션 개념(메모리 요약/주입) (openai.github.io) + 별도 DB에 “durable state(ledger)”를 저장하는 패턴
(왜 별도 DB냐? LTM은 비동기/압축/중복제거를 하기 때문에 “정확한 진행 상태” 저장소로 부적합)

1) 초기 셋업 (의존성/DB)

npm init -y
npm i @openai/agents better-sqlite3 zod
// db.ts
import Database from "better-sqlite3";

export const db = new Database("agent_state.sqlite");

db.exec(`
CREATE TABLE IF NOT EXISTS runs (
  run_id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  thread_id TEXT NOT NULL,
  status TEXT NOT NULL,           -- running|done|failed
  step TEXT NOT NULL,             -- classify|policy|draft|crm
  state_json TEXT NOT NULL,       -- durable state snapshot
  updated_at INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS memory_facts (
  user_id TEXT NOT NULL,
  key TEXT NOT NULL,
  value TEXT NOT NULL,
  updated_at INTEGER NOT NULL,
  PRIMARY KEY (user_id, key)
);
`);

2) 기본 동작: “체크포인트 기반 재개 + 메모리 요약 주입”

// agent.ts
import { z } from "zod";
import { db } from "./db";
import { MemorySession, run } from "@openai/agents";

const TicketInput = z.object({
  userId: z.string(),
  threadId: z.string(),
  runId: z.string(),
  ticketId: z.string(),
  message: z.string(),
});

type DurableState = {
  ticketId: string;
  lastUserMessage: string;
  classification?: { priority: "low" | "med" | "high"; topic: string };
  policySnippets?: string[];
  draftReply?: string;
  crmUpdated?: boolean;
};

function loadRun(runId: string): { step: string; state: DurableState } | null {
  const row = db.prepare("SELECT step, state_json FROM runs WHERE run_id=?").get(runId) as any;
  if (!row) return null;
  return { step: row.step, state: JSON.parse(row.state_json) };
}

function saveRun(params: {
  runId: string; userId: string; threadId: string;
  status: string; step: string; state: DurableState;
}) {
  db.prepare(`
    INSERT INTO runs(run_id,user_id,thread_id,status,step,state_json,updated_at)
    VALUES (@runId,@userId,@threadId,@status,@step,@stateJson,@updatedAt)
    ON CONFLICT(run_id) DO UPDATE SET
      status=excluded.status,
      step=excluded.step,
      state_json=excluded.state_json,
      updated_at=excluded.updated_at
  `).run({
    ...params,
    stateJson: JSON.stringify(params.state),
    updatedAt: Date.now(),
  });
}

// LTM(선호/규칙)을 "사실" 형태로 최소 스키마로 저장
function upsertMemoryFact(userId: string, key: string, value: string) {
  db.prepare(`
    INSERT INTO memory_facts(user_id,key,value,updated_at)
    VALUES (?,?,?,?)
    ON CONFLICT(user_id,key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
  `).run(userId, key, value, Date.now());
}

function loadMemorySummary(userId: string): string {
  const rows = db.prepare("SELECT key,value FROM memory_facts WHERE user_id=?").all(userId) as any[];
  if (!rows.length) return "No persisted user preferences yet.";
  // “전부 로드”가 아니라 요약 형태로만 주입 (토큰 비용/오염 방지)
  return rows.map(r => `- ${r.key}: ${r.value}`).join("\n");
}

async function stepClassify(state: DurableState): Promise<DurableState> {
  // 실제로는 모델 호출 + 스키마 검증을 권장
  const priority = state.lastUserMessage.includes("긴급") ? "high" : "med";
  return { ...state, classification: { priority, topic: "refund" } };
}

async function stepPolicy(state: DurableState): Promise<DurableState> {
  // 정책은 RAG로 가져오되, state에는 "근거 스니펫"만 저장 (원문 전체 저장 금지)
  const snippets = [
    "Refunds allowed within 14 days if unused.",
    "Escalate to human if chargeback mentioned."
  ];
  return { ...state, policySnippets: snippets };
}

async function stepDraft(state: DurableState, memorySummary: string): Promise<DurableState> {
  // 여기서 OpenAI Agents SDK를 사용해 초안을 생성한다고 가정
  const session = new MemorySession({
    // SDK가 런 시작 시 memory_summary.md 형태로 주입하는 패턴을 제공 ([openai.github.io](https://openai.github.io/openai-agents-js/guides/sandbox-agents/memory))
    // 실제 옵션/필드는 SDK 버전에 맞춰 조정
  } as any);

  const prompt = `
You are a support agent.
User memory summary:
${memorySummary}

Ticket: ${state.ticketId}
User message: ${state.lastUserMessage}
Policy snippets:
${(state.policySnippets ?? []).map(s => `- ${s}`).join("\n")}

Write a concise reply draft in Korean, polite but direct.
`;
  const result = await run({
    agent: {
      name: "support-drafter",
      instructions: prompt,
    } as any,
    session,
  });

  const draft = (result as any).output_text ?? "초안 생성 실패";
  return { ...state, draftReply: draft };
}

async function stepCrm(state: DurableState): Promise<DurableState> {
  // 외부 CRM 업데이트는 idempotency key가 필수(여기서는 생략)
  return { ...state, crmUpdated: true };
}

export async function handleTicket(raw: unknown) {
  const input = TicketInput.parse(raw);

  // 0) 재개 지점 로드
  const loaded = loadRun(input.runId);
  let step = loaded?.step ?? "classify";
  let state: DurableState = loaded?.state ?? {
    ticketId: input.ticketId,
    lastUserMessage: input.message,
  };

  // 1) LTM 요약 로드(“필요한 만큼만”)
  const memorySummary = loadMemorySummary(input.userId);

  // 2) 실행 + 각 스텝마다 durable checkpoint 저장
  saveRun({ runId: input.runId, userId: input.userId, threadId: input.threadId, status: "running", step, state });

  try {
    if (step === "classify") {
      state = await stepClassify(state);
      step = "policy";
      saveRun({ runId: input.runId, userId: input.userId, threadId: input.threadId, status: "running", step, state });
    }

    if (step === "policy") {
      state = await stepPolicy(state);
      step = "draft";
      saveRun({ runId: input.runId, userId: input.userId, threadId: input.threadId, status: "running", step, state });
    }

    if (step === "draft") {
      state = await stepDraft(state, memorySummary);
      step = "crm";
      saveRun({ runId: input.runId, userId: input.userId, threadId: input.threadId, status: "running", step, state });
    }

    if (step === "crm") {
      state = await stepCrm(state);
      step = "done";
      saveRun({ runId: input.runId, userId: input.userId, threadId: input.threadId, status: "done", step, state });
    }

    // 3) LTM 업데이트(동기/비동기 선택)
    // 예: 말투 선호를 감지했다면 key-value로 덮어쓰기(“facts overwrite on change” 패턴)
    if (input.message.includes("존댓말로")) {
      upsertMemoryFact(input.userId, "tone", "use polite Korean (존댓말)");
    }

    return { ok: true, step, state };
  } catch (e: any) {
    saveRun({ runId: input.runId, userId: input.userId, threadId: input.threadId, status: "failed", step, state });
    throw e;
  }
}

예상 출력(예시)

  • 첫 실행: classify → policy → draft → crm → done으로 진행, 중간마다 DB에 checkpoint 저장
  • 프로세스가 draft 직후 죽어도 다음 실행에서 step=crm부터 재개(“이미 만든 draft를 다시 만들지 않음”)

3) 확장: “장기 메모리”를 저장할 때의 최소 규칙

여기서 LTM을 무작정 conversation log로 저장하지 않고, 추출된 사실/선호/규칙만 구조화해서 넣었습니다. 이게 2026년 실전에서 중요한 이유는:

  • 중복/충돌이 줄고(“같은 사실을 30번 다른 문장으로 저장” 문제) (reddit.com)
  • 삭제/정정이 가능하고(키 단위 overwrite)
  • 토큰 비용이 예측 가능해집니다

더 고급으로 가면 Zep처럼 “장기 컨텍스트 문자열 + 최근 raw 메시지”를 분리하는 구조를 쓰거나 (help.getzep.com), Mem0처럼 “압축 엔진으로 토큰을 줄이는” 서비스를 붙이는 선택지도 있습니다. (mem0docs.xyz)


⚡ 실전 팁 & 함정

Best Practice (2~3개)

1) State(재개)와 LTM(지식)을 물리적으로 분리

  • State는 트랜잭션/정합성/재시도에 최적화(SQLite/Postgres/Redis).
  • LTM은 압축/검색/정책(보존/삭제)에 최적화(Vector/Graph/문서/전용 메모리 레이어).
  • LangGraph의 checkpointer가 “그래프 실행 상태”를 저장하는 이유도 이 분리에 있습니다. (docs.langchain.com)

2) 메모리 로딩은 ‘전부 주입’이 아니라 ‘요약 + 선택적 retrieval’

  • 커뮤니티 사례처럼 “관련 없는 메모리를 매번 로드하면 토큰 비용 폭발”합니다. (reddit.com)
  • 기본은 memory_summary(짧고 안정적인 규칙/선호) + 필요 시 tool로 검색(semantic/graph).

3) LTM 쓰기는 비동기(또는 배치)로, 대신 State는 동기

  • Zep도 ingestion 지연이 있을 수 있음을 문서화합니다. (help.getzep.com)
  • 즉시 반영이 필요한 건 State에 기록하고, LTM은 “나중에 반영돼도 되는 지식”만.

흔한 함정/안티패턴

  • Anti-pattern: RAG를 “고객 상태 DB”처럼 사용
    • RAG 인덱스는 업데이트/삭제/정정이 까다롭고, freshness 관리가 어렵습니다. (make.com)
  • Anti-pattern: conversation 전체를 영구 저장하고 매번 다 넣기
    • 비용 증가 + 개인정보 리스크 + 모델이 과거의 잘못된 추론을 “사실”로 굳히는 오염.
  • Anti-pattern: 멀티 에이전트가 같은 LTM을 경쟁적으로 업데이트
    • “누가 최종 진실을 결정하나?”가 없으면 메모리 충돌이 누적됩니다(특히 팀/멀티-agent). (reddit.com)

비용/성능/안정성 트레이드오프

  • DB 1개로 끝내고 싶다: 작은 규모면 SQLite로도 충분(상태/핵심 facts) + 필요해질 때만 vector/graph 추가.
  • 낮은 지연: hot state는 Redis/SQLite, LTM retrieval은 캐시/배치/비동기.
  • 높은 안정성: checkpoint 주기를 촘촘히 할수록 안전하지만 쓰기 비용 증가 → “스텝 종료 시점” 또는 “외부 side-effect 직전/직후”만 저장하는 전략이 현실적.

🚀 마무리

2026년 5월 기준으로, “기억하는 에이전트”를 만드는 핵심은 멋진 벡터DB가 아니라:

  • Durable State(체크포인트)로 실행을 복구 가능하게 만들고
  • LTM은 압축/요약/검색 가능한 지식 레이어로 분리하며
  • 매 런에 주입되는 컨텍스트는 ‘요약 + 선택적 retrieval’로 비용을 통제하는 것입니다.

도입 판단 기준은 간단합니다.

  • “중간에 죽어도 이어가야 한다” → 먼저 checkpoint/state부터
  • “사용자별 선호가 반복해서 등장한다” → key-value facts + 요약 주입
  • “대화/지식이 길어져 검색이 필요하다” → 그때 vector/graph/Zep/Mem0류를 붙이기 (help.getzep.com)

다음 학습 추천:

  • LangGraph의 persistence/checkpointer 개념을 먼저 익히고(워크플로우 재개) (docs.langchain.com)
  • 그 다음 OpenAI Agents SDK의 memory 주입/rehydration 흐름을 보고(런타임 복구) (openai.com)
  • 마지막으로 Mem0/Zep/Letta 같은 “LTM 제품/런타임”을 비교하며, 내 제품에 필요한 메모리 타입(선호/사실/에피소드/스킬)을 어디에 둘지 결정하는 순서가 가장 실패 확률이 낮습니다. (help.getzep.com)