포스트

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

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

들어가며

프로덕션 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)

1
2
npm init -y
npm i @openai/agents better-sqlite3 zod
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 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) 기본 동작: “체크포인트 기반 재개 + 메모리 요약 주입”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
// 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)
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.