컴퓨터 액세스 시스템

에이전트 메모리 시스템: AI에 지속적인 컨텍스트 부여하기


모든 AI 에이전트에는 메모리 문제가 있습니다. 모델에 대한 각 호출은 무상태입니다. 모델은 마지막 요청을 기억하지 못합니다. 사용자의 이름, 선호도, 이전 결정도 기억하지 못합니다. 외부 메모리 없이는 에이전트가 매 턴마다 제로에서 시작합니다.

이 가이드는 네 가지 메모리 전략(가장 단순한 것부터 가장 강력한 것까지)과 각각을 언제 사용할지 다룹니다. 모든 예제는 TypeScript와 Anthropic SDK를 사용합니다.

에이전트에 메모리가 필요한 이유

코드 리뷰 에이전트를 생각해 보세요. 첫 번째 실행 시, 사용자가 팀의 규약을 설명합니다: any 타입 없음, 항상 오류를 명시적으로 처리, 함수형 스타일 선호. 에이전트가 좋은 리뷰를 생성합니다.

두 번째 실행 시, 사용자가 다른 파일을 제출합니다. 에이전트는 모든 것을 잊었습니다. 어제 표시했던 것과 같은 규약 위반을 놓칩니다.

이것이 핵심 문제입니다: 에이전트는 무상태이지만, 유용한 에이전트에는 연속성이 필요합니다.

메모리는 에이전트에게 세 가지 능력을 부여합니다:

  • 회상 — 대화의 이전 부분이나 이전 세션에서 사실을 검색
  • 개인화 — 시간이 지남에 따라 사용자 선호도에 적응
  • 조정 — 멀티에이전트 시스템에서 에이전트 간 상태 공유

네 가지 메모리 유형

코드를 작성하기 전에 전략을 명확하게 명명하는 것이 도움이 됩니다. 에이전트 메모리는 네 가지 패턴으로 분류됩니다:

유형저장 위치범위최적 용도
버퍼컨텍스트 내 (메시지 배열)현재 세션짧은 대화
요약컨텍스트 내 (압축)현재 세션긴 대화
시맨틱외부 (벡터 DB)세션 간지식 회상
에피소드외부 (키-값 저장소)세션 간사용자 사실, 선호도

각 전략은 단순성, 비용, 능력 사이에서 서로 다른 트레이드오프를 가집니다. 문제를 해결하는 최소한의 것을 선택하세요.

전략 1: 대화 버퍼

가장 단순한 전략은 모든 모델 호출에 완전한 대화 기록을 전달하는 것입니다. 모델은 모든 이전 메시지를 보고 완전한 컨텍스트로 응답합니다.

import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
interface Message {
role: "user" | "assistant";
content: string;
}
// 버퍼는 완전한 대화 기록을 보관합니다
const buffer: Message[] = [];
async function chat(userMessage: string): Promise<string> {
// 새 사용자 메시지를 버퍼에 추가
buffer.push({ role: "user", content: userMessage });
const response = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 1024,
// 모든 호출에서 전체 버퍼 전달
messages: buffer,
});
const assistantMessage =
response.content[0].type === "text" ? response.content[0].text : "";
// 어시스턴트 응답을 버퍼에 추가
buffer.push({ role: "assistant", content: assistantMessage });
return assistantMessage;
}
// 사용 예시
await chat("My name is Alex and I prefer Python over TypeScript.");
await chat("What language should I use for my next project?");
// 모델은 첫 번째 메시지에서 명시된 선호도를 기억합니다

사용 시기: 20턴 미만의 대화. 단순한 챗봇. 프로토타입.

한계: 컨텍스트 윈도우는 유한합니다. 긴 세션은 결국 토큰 제한을 초과하며, 가장 오래된 메시지를 삭제해야 합니다. 모델은 초기 컨텍스트를 조용히 잃어버리며, 이는 일관성 없는 동작을 유발합니다.

전략 2: 롤링 요약

대화가 길어지면 오래된 턴을 삭제하는 대신 요약합니다. 최근 메시지의 슬라이딩 윈도우 이전에 발생한 일의 압축된 요약을 유지합니다.

import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
interface ConversationState {
summary: string; // 오래된 턴의 압축된 기록
recentMessages: Array<{ role: "user" | "assistant"; content: string }>;
maxRecentTurns: number; // 마지막 N턴을 그대로 유지
}
const state: ConversationState = {
summary: "",
recentMessages: [],
maxRecentTurns: 6, // 사용자 3턴 + 어시스턴트 3턴
};
// 오래된 턴을 실행 중인 요약으로 압축
async function compressSummary(
currentSummary: string,
messagesToCompress: Array<{ role: string; content: string }>
): Promise<string> {
const formatted = messagesToCompress
.map((m) => `${m.role.toUpperCase()}: ${m.content}`)
.join("\n");
const response = await client.messages.create({
model: "claude-haiku-4-5-20251001",
max_tokens: 512,
messages: [
{
role: "user",
content: `Update the conversation summary by adding the new exchanges below.
Return only the updated summary. Keep it under 200 words.
Current summary:
${currentSummary || "(none)"}
New exchanges:
${formatted}`,
},
],
});
return response.content[0].type === "text" ? response.content[0].text : currentSummary;
}
async function chat(userMessage: string): Promise<string> {
// 버퍼가 제한을 초과하면 가장 오래된 절반을 압축
if (state.recentMessages.length >= state.maxRecentTurns * 2) {
const toCompress = state.recentMessages.splice(0, state.maxRecentTurns);
state.summary = await compressSummary(state.summary, toCompress);
}
state.recentMessages.push({ role: "user", content: userMessage });
// 메시지 배열 구성: 시스템 요약 + 최근 그대로의 턴
const messages = [
...(state.summary
? [
{
role: "user" as const,
content: `Context from earlier in this conversation:\n${state.summary}`,
},
{
role: "assistant" as const,
content: "Understood. I will use that context.",
},
]
: []),
...state.recentMessages,
];
const response = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 1024,
messages,
});
const assistantMessage =
response.content[0].type === "text" ? response.content[0].text : "";
state.recentMessages.push({ role: "assistant", content: assistantMessage });
return assistantMessage;
}

요약 호출은 빠르고 저렴한 모델(Haiku)을 사용합니다. 메인 대화는 유능한 모델(Sonnet)을 사용합니다. 이 분리는 품질을 유지하면서 비용을 낮게 유지합니다.

사용 시기: 20턴 이상의 세션. 지원 에이전트. 장기 실행 작업 에이전트.

한계: 요약은 세부 사항을 잃습니다. 대화 초기에 언급된 구체적인 사실이 모호한 진술로 압축될 수 있습니다. 구체적인 사실의 정확한 회상을 위해서는 에피소드 메모리를 사용하세요.

전략 3: 에피소드 메모리

에피소드 메모리는 사용자나 세션에 관한 구체적인 사실을 키-값 저장소에 저장합니다. 에이전트는 대화 중에 사실을 추출하고 미래 세션에서 이를 검색합니다.

import Anthropic from "@anthropic-ai/sdk";
import fs from "fs/promises";
import path from "path";
const client = new Anthropic();
// 프로덕션에서는 이 파일 저장소를 Redis나 데이터베이스로 교체하세요
const MEMORY_PATH = "./memory.json";
interface EpisodicStore {
[userId: string]: Record<string, string>;
}
async function loadMemory(): Promise<EpisodicStore> {
try {
const data = await fs.readFile(MEMORY_PATH, "utf-8");
return JSON.parse(data);
} catch {
return {};
}
}
async function saveMemory(store: EpisodicStore): Promise<void> {
await fs.writeFile(MEMORY_PATH, JSON.stringify(store, null, 2));
}
// 마지막 교환에서 구조화된 사실 추출
async function extractFacts(
userMessage: string,
assistantReply: string
): Promise<Record<string, string>> {
const response = await client.messages.create({
model: "claude-haiku-4-5-20251001",
max_tokens: 256,
messages: [
{
role: "user",
content: `Extract any personal facts, preferences, or important decisions from this exchange.
Return a JSON object of key-value pairs. Return {} if there is nothing worth remembering.
User: ${userMessage}
Assistant: ${assistantReply}`,
},
],
});
try {
const text = response.content[0].type === "text" ? response.content[0].text : "{}";
const match = text.match(/\{[\s\S]*\}/);
return match ? JSON.parse(match[0]) : {};
} catch {
return {};
}
}
// 저장된 사실을 시스템 프롬프트 주입으로 포맷
function formatMemory(facts: Record<string, string>): string {
const entries = Object.entries(facts);
if (entries.length === 0) return "";
return (
"What you know about this user:\n" +
entries.map(([k, v]) => `- ${k}: ${v}`).join("\n")
);
}
async function chat(userId: string, userMessage: string): Promise<string> {
const store = await loadMemory();
const userFacts = store[userId] ?? {};
const systemPrompt = formatMemory(userFacts);
const response = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 1024,
system: systemPrompt || undefined,
messages: [{ role: "user", content: userMessage }],
});
const assistantMessage =
response.content[0].type === "text" ? response.content[0].text : "";
// 사실을 추출하고 미래 세션을 위해 지속화
const newFacts = await extractFacts(userMessage, assistantMessage);
if (Object.keys(newFacts).length > 0) {
store[userId] = { ...userFacts, ...newFacts };
await saveMemory(store);
}
return assistantMessage;
}
// 세션 1
await chat("user-123", "I prefer concise answers. No bullet points unless necessary.");
// 세션 2 (다른 프로세스, 며칠 후)
await chat("user-123", "Explain how TCP handshakes work.");
// 에이전트는 세션 1의 포맷 선호도를 기억합니다

사용 시기: 여러 세션에 걸쳐 실행되는 사용자 대면 에이전트. 개인화. 선호도 추적.

한계: 사실이 시간이 지남에 따라 축적됩니다. 오래된 사실은 잘못된 동작을 생성할 수 있습니다(사용자가 선호하는 언어를 변경했는데 이전 선호도가 여전히 저장소에 있음). 사실을 업데이트하거나 만료시키는 메커니즘을 추가하세요.

전략 4: 시맨틱 메모리 (벡터 검색)

에피소드 메모리는 개별 사실을 저장합니다. 시맨틱 메모리는 의미에 의해 인덱싱된 문서, 코드, 또는 대화 청크를 저장합니다. 에이전트가 정보가 필요할 때 쿼리를 사용하여 인덱스를 검색합니다.

이것이 Retrieval-Augmented Generation(RAG)의 기반입니다.

import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
interface Document {
id: string;
content: string;
embedding?: number[];
}
// 텍스트 문자열에 대한 임베딩 벡터 생성
async function embed(text: string): Promise<number[]> {
// Claude는 직접 임베딩 엔드포인트를 노출하지 않습니다.
// 전용 임베딩 모델을 사용하세요. 이 예제는 설명을 위한 스텁을 사용합니다.
// 프로덕션에서는: text-embedding-3-small (OpenAI), embed-english-v3 (Cohere),
// 또는 nomic-embed-text와 같은 자체 호스팅 모델을 사용하세요.
throw new Error("Replace this stub with a real embedding call");
}
// 두 벡터 사이의 코사인 유사도 계산
function cosineSimilarity(a: number[], b: number[]): number {
const dot = a.reduce((sum, val, i) => sum + val * b[i], 0);
const magA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0));
const magB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0));
return dot / (magA * magB);
}
class SemanticMemory {
private documents: Document[] = [];
// 나중에 검색하기 위해 문서 인덱싱
async add(id: string, content: string): Promise<void> {
const embedding = await embed(content);
this.documents.push({ id, content, embedding });
}
// 쿼리에 대해 가장 관련성 높은 top-k 문서 검색
async search(query: string, topK = 3): Promise<Document[]> {
const queryEmbedding = await embed(query);
return this.documents
.filter((doc) => doc.embedding)
.map((doc) => ({
doc,
score: cosineSimilarity(queryEmbedding, doc.embedding!),
}))
.sort((a, b) => b.score - a.score)
.slice(0, topK)
.map(({ doc }) => doc);
}
}
const memory = new SemanticMemory();
async function answerWithContext(question: string): Promise<string> {
// 관련 문서 검색
const relevant = await memory.search(question);
const context = relevant.map((d) => d.content).join("\n\n---\n\n");
const response = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 1024,
system: context
? `Answer the question using the following context:\n\n${context}`
: undefined,
messages: [{ role: "user", content: question }],
});
return response.content[0].type === "text" ? response.content[0].text : "";
}
// 예시: 지식 베이스 인덱싱 및 쿼리
await memory.add("doc-1", "The team uses React 18 with the app router pattern.");
await memory.add("doc-2", "All API routes must validate input with Zod schemas.");
await memory.add("doc-3", "Database queries use Drizzle ORM. Avoid raw SQL.");
const answer = await answerWithContext("How should I write a new API endpoint?");
// 에이전트는 doc-2와 doc-3을 검색하여 근거 있는 답변을 생성합니다

프로덕션 사용을 위해 인메모리 저장소를 전용 벡터 데이터베이스로 교체합니다. 이미 Postgres를 사용하고 있다면 Pgvector(PostgreSQL 확장)가 가장 단순합니다. ChromaQdrant도 좋은 독립 실행형 옵션입니다.

사용 시기: 문서 어시스턴트. 코드 검색 에이전트. 지식 베이스 Q&A. 대규모 코퍼스에서의 회상이 필요한 모든 것.

메모리를 MCP에 연결하기

MCP 서버 가이드를 따랐다면 메모리를 MCP 리소스로 노출할 수 있습니다. 이렇게 하면 Claude Code를 포함한 모든 MCP 호환 클라이언트가 메모리 레이어에 접근할 수 있습니다.

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new Server(
{ name: "memory-server", version: "1.0.0" },
{ capabilities: { tools: {}, resources: {} } }
);
// 도구: 사실 저장
server.setRequestHandler("tools/call", async (request) => {
if (request.params.name === "remember") {
const { key, value, userId } = request.params.arguments as {
key: string;
value: string;
userId: string;
};
await storeEpisodicFact(userId, key, value);
return { content: [{ type: "text", text: `Stored: ${key} = ${value}` }] };
}
if (request.params.name === "recall") {
const { userId } = request.params.arguments as { userId: string };
const facts = await loadEpisodicFacts(userId);
return {
content: [{ type: "text", text: JSON.stringify(facts, null, 2) }],
};
}
});
const transport = new StdioServerTransport();
await server.connect(transport);

MCP를 지원하는 모든 에이전트가 이제 rememberrecall을 표준 도구로 호출할 수 있습니다. 메모리는 에이전트별 구현이 아닌 공유 서비스가 됩니다.

올바른 전략 선택

이 결정 표를 출발점으로 사용하세요:

상황전략
단일 세션, 20턴 미만버퍼
단일 세션, 장기 실행요약
멀티 세션, 사용자 선호도에피소드
쿼리할 대규모 지식 베이스시맨틱
위의 모두결합: 세션에는 요약 + 세션 간에는 에피소드/시맨틱

버퍼로 시작하세요. 대화가 길어지면 요약 레이어를 추가합니다. 사용자가 세션 간에 돌아오면 에피소드 메모리를 추가합니다. 검색할 문서 코퍼스가 있으면 시맨틱 메모리를 추가합니다.

필요하기 전에 복잡성을 추가하지 마세요. 대화 버퍼는 대부분의 프로토타입에 맞는 답변입니다. 메모리를 조기에 과도하게 엔지니어링하면 비례적인 이점 없이 비용, 지연 시간, 유지보수 표면적이 증가합니다.

결론

메모리는 하나의 것이 아닙니다. 서로 다른 범위에서 서로 다른 문제를 해결하는 전략의 스택입니다. 버퍼는 현재 세션을 처리합니다. 요약은 세션을 연장합니다. 에피소드 메모리는 세션을 이어줍니다. 시맨틱 메모리는 답변을 지식 베이스에 기반합니다.

오늘 가진 문제를 해결하는 레이어를 선택하세요. 나중에 구현을 교체할 수 있도록 깔끔한 인터페이스 뒤에 래핑하세요. 에이전트가 MCP를 통해 통신한다면 메모리를 공유 도구로 노출하세요. 각 에이전트를 단순하게 유지하면서 시스템 전체에 영구적인 두뇌를 부여합니다.


관련 기사