AI 에이전트 3계층 메모리 구조 — 작업기억·에피소드·지식그래프
"에이전트에 기억을 붙이자"는 요구는 대개 이렇게 구현됩니다. 대화를 전부 DB에 넣고, 다음 턴에 최근 N개를 꺼내 프롬프트에 붙인다. 이 방식은 두 달쯤 잘 돌아가다가 무너집니다. 대화가 쌓이면 N개로는 지난달 결정을 못 찾고, N을 키우면 컨텍스트 윈도우가 터지며 비용이 폭증합니다.
문제는 모든 기억을 같은 형태로 다루려 한 것입니다. 방금 한 말과 3개월 전 결정과 "이 사람은 Go를 쓴다"는 사실은 성격이 다릅니다. 접근 빈도도, 필요한 정밀도도, 적정 크기도 다릅니다. 그래서 계층을 나눕니다.
세 개의 계층
| 계층 | 담는 것 | 수명 | 형태 |
|---|---|---|---|
| Working (작업기억) | 지금 진행 중인 대화 | 세션 | 원문 메시지 |
| Episodic (일화기억) | 지난 세션에서 있었던 일 | 수주~수개월 | 요약문 |
| Semantic (의미기억) | 사람·프로젝트·관계 같은 사실 | 영구 | 그래프 |
인지심리학 용어를 빌렸지만 구현 관점에서는 단순합니다. Working은 원문 그대로, Episodic은 압축해서, Semantic은 구조화해서 저장합니다. 뒤로 갈수록 정보 밀도가 높아지고 크기는 작아집니다.
핵심 아이디어: 점진적 로딩 (L0 → L1 → L2)
계층을 나눈 것만으로는 부족합니다. 진짜 이득은 "매 턴 얼마나 올릴 것인가"에서 나옵니다. 전부 올리면 계층을 나눈 의미가 없으니까요.
그래서 3단 로딩을 씁니다.
- L0 — 자동 주입 (항상): 관련된 과거 세션의 한 줄 요약 몇 개만 시스템 프롬프트에 자동으로 넣습니다. 에이전트가 "아, 이 얘기 전에 했었지"를 알아채기에 딱 필요한 만큼입니다. 항목당 50토큰 안팎.
- L1 — 요청 시 검색: 에이전트가 더 알아야겠다고 판단하면 툴로 검색합니다. 결과는 ID + 짧은 요약 목록이고, 특정 항목의 전문이 필요하면 ID로 한 번 더 펼칩니다.
- L2 — 관계 질의: "이 사람이 관여한 프로젝트는?" 같은 연결이 필요할 때 지식그래프를 조회합니다.
핵심은 L0만 무조건 비용을 쓰고, L1·L2는 에이전트가 필요하다고 판단할 때만 쓴다는 것입니다. 대부분의 턴에서 과거 기억은 필요 없습니다.
에이전트가 이 구조를 알아야 제대로 씁니다. 그래서 시스템 프롬프트에 한 문단을 넣습니다.
당신에게는 3단계 기억이 있습니다:
- 자동 회상(L0): 위 "Memory Context" 항목은 자동 주입된 과거 세션 힌트입니다.
- 일화기억(L1): 세션 요약 전문 — memory_search로 찾고, memory_expand(id)로 펼치세요.
- 의미기억(L2): 사람·프로젝트·관계 그래프 — knowledge_graph_search로 조회하세요.
과거 작업·결정·사람·선호를 묻는 질문에 답하기 전에 먼저 검색하세요.
결과가 없으면 도구 이름을 언급하지 말고 자연스럽게 모른다고 답하세요.
L0 자동 주입 구현
턴이 시작될 때 사용자 메시지로 에피소드 저장소를 검색해, 점수가 임계치를 넘는 상위 몇 개의 초압축 요약(abstract)만 뽑아 프롬프트 섹션으로 만듭니다.
func (a *autoInjector) Inject(ctx context.Context, p InjectParams) (*InjectResult, error) {
// 인사말·감탄사 같은 사소한 메시지에는 검색 자체를 건너뛴다
if isTrivialMessage(p.UserMessage) {
return &InjectResult{}, nil
}
// 대화 맥락을 섞어 검색어를 만든다 (아래 설명)
query := buildRecallQuery(p.UserMessage, p.RecentContext)
results, err := a.store.Search(ctx, query, p.AgentID, p.UserID, SearchOptions{
MaxResults: p.MaxEntries * 2, // 넉넉히 뽑아 임계치로 거른다
MinScore: 0.3,
TextWeight: 0.7, // 키워드 검색 비중
VectorWeight: 0.3, // 의미 검색 비중
})
if err != nil || len(results) == 0 {
return &InjectResult{}, err
}
var sb strings.Builder
sb.WriteString("## Memory Context\n\n과거 세션의 관련 기억 (자세한 내용은 검색 도구 사용):\n")
injected := 0
for _, r := range results {
if injected >= p.MaxEntries || r.Abstract == "" {
continue
}
sb.WriteString("- " + r.Abstract + "\n")
injected++
}
return &InjectResult{Section: sb.String(), Injected: injected}, nil
}
세 가지 설계 판단이 들어 있습니다.
- 사소한 메시지 필터 — "ㅇㅋ", "고마워"에 벡터 검색을 돌리는 건 낭비이고, 엉뚱한 기억이 딸려 올라와 오히려 방해가 됩니다.
- 키워드 7 : 의미 3 하이브리드 — 자동 주입은 매 턴 도는 경로라 지연이 곧 응답 속도입니다. 고유명사(사람 이름, 프로젝트명)를 잡는 데는 키워드 검색이 빠르고 정확해서 비중을 높입니다. 정밀도가 중요한 L1 검색에서는 반대로 벡터 비중을 올려도 됩니다.
- 임계치 미달은 아예 안 넣음 — 애매한 기억을 넣으면 모델이 무관한 과거 대화를 현재 맥락으로 착각합니다. 없는 게 틀린 것보다 낫습니다.
대명사 문제 — 최근 맥락을 검색어에 섞기
실제로 굴려 보면 바로 부딪히는 문제가 있습니다. 사용자가 "그거 어떻게 됐어?"라고 묻습니다. 이 문장만으로 검색하면 아무것도 안 나옵니다. 대명사에는 검색할 내용이 없기 때문입니다.
해법은 단순합니다. 최근 사용자 발화 몇 개를 검색어에 붙입니다.
// 최근 사용자 턴을 이어붙여 회상 질의를 풍부하게 만든다.
// 예산: 최대 2턴, 총 300자 — 너무 길면 원 질문이 희석된다.
func buildRecentContext(history []Message) string {
const maxTurns, maxRunes = 2, 300
turns := make([]string, 0, maxTurns)
// 뒤에서부터 훑는다 — 앞쪽이 잘려나갔어도 최신 턴은 확보된다
for i := len(history) - 1; i >= 0 && len(turns) < maxTurns; i-- {
if history[i].Role != "user" || history[i].Content == "" {
continue
}
turns = append([]string{history[i].Content}, turns...) // 순서 보존
}
joined := strings.Join(turns, " | ")
// 룬 단위로 자른다 — 바이트로 자르면 한글·베트남어가 깨져
// 임베딩 모델에 잘못된 UTF-8이 들어간다
if r := []rune(joined); len(r) > maxRunes {
joined = string(r[len(r)-maxRunes:]) // 최신 쪽을 남긴다
}
return joined
}
바이트가 아니라 룬으로 자르는 것은 다국어 서비스에서 필수입니다. 한글은 UTF-8에서 3바이트라 바이트로 자르면 글자가 반토막 나고, 깨진 UTF-8이 임베딩 API에 들어가면 조용히 이상한 벡터가 나옵니다. 그리고 뒤쪽(최신)을 남기는 것도 의도적입니다. 현재 질문에 가까운 맥락일수록 대명사 해소에 도움이 됩니다.
계층 간 이동은 이벤트로 — 요청 경로를 막지 마라
Working에서 Episodic으로, Episodic에서 Semantic으로 올라가는 승격은 전부 비동기여야 합니다. 세션 요약은 LLM 호출이라 몇 초가 걸리는데, 이걸 응답 경로에 넣으면 사용자가 그만큼 기다립니다.
// 이벤트 체인: 세션 종료 → 요약 → 개체 추출 → 중복 정리
bus.Subscribe(EventSessionCompleted, episodicWorker.Handle) // 대화 → 요약
bus.Subscribe(EventEpisodicCreated, semanticWorker.Handle) // 요약 → 개체·관계
bus.Subscribe(EventEntityUpserted, dedupWorker.Handle) // 유사 개체 병합
// 만료된 에피소드는 주기적으로 정리한다 — 메모리도 잊어야 한다
go func() {
ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
for range ticker.C {
if n, err := store.PruneExpired(context.Background()); err == nil && n > 0 {
slog.Info("episodic prune completed", "deleted", n)
}
}
}()
각 워커가 하는 일은 이렇습니다.
- 에피소드 워커 — 세션 메시지를 LLM에 넘겨 요약합니다. 요약 프롬프트를 구체적으로 쓰는 게 중요합니다: "핵심 결정, 사용자·프로젝트에 대해 알게 된 사실, 완료·진행 중 작업, 기술적 세부사항, 표현된 선호. 인사말·군더더기 제외. 개체 이름은 명시적으로 포함." 마지막 문장이 다음 단계를 살립니다.
- 의미 워커 — 요약에서 사람·프로젝트·기술 같은 개체와 그 관계를 뽑아 그래프에 올립니다.
- 중복 제거 워커 — "온비서"와 "온 비서"가 별개 노드로 쌓이는 걸 막습니다. 이걸 안 하면 그래프가 몇 달 만에 쓸모없어집니다.
L0 요약은 LLM 없이 만든다
자동 주입용 한 줄 요약까지 LLM으로 만들면 비용이 두 배가 됩니다. 이건 추출식으로 충분합니다 — 이미 만들어진 요약문에서 의미 있는 첫 문장을 뽑으면 됩니다.
// 요약문에서 ~50토큰짜리 초록을 뽑는다. LLM 호출 없음.
func generateAbstract(summary string) string {
for _, s := range splitSentences(summary) {
s = strings.TrimSpace(s)
r := []rune(s)
if len(r) < 20 {
continue // 너무 짧은 조각은 건너뛴다
}
if len(r) > 200 {
return string(r[:200]) + "..."
}
return s
}
if r := []rune(summary); len(r) > 200 {
return string(r[:200]) + "..."
}
return summary
}
정교하지 않지만 L0의 역할이 "정확한 정보 전달"이 아니라 "관련성 힌트"라서 충분합니다. 에이전트가 이 한 줄을 보고 "더 알아봐야겠다"고 판단하면 L1으로 내려가 정확한 내용을 가져옵니다.
이 구조가 해결하는 것
- 토큰 비용이 대화 길이에 비례하지 않습니다. 매 턴 고정으로 붙는 건 L0 몇 줄뿐입니다.
- 오래된 기억에도 접근 가능합니다. 최근 N개 방식과 달리 3개월 전 결정도 검색으로 찾습니다.
- "이 사람이 관여한 프로젝트" 같은 다중 홉 질문에 답할 수 있습니다. 요약문 검색만으로는 안 되고 그래프가 필요한 영역입니다.
- 잊기가 구현됩니다. 에피소드에 만료를 두면 오래된 잡담은 사라지고, 그래프로 승격된 사실은 남습니다. 사람의 기억과 닮은 동작입니다.
주의할 점
- 회상 품질을 측정하세요. 주입된 항목 수와 최고 점수를 기록해 두면, 임계치가 너무 높아 아무것도 안 걸리는지, 너무 낮아 잡음이 들어오는지 판단할 수 있습니다.
- 프라이버시 경계를 지키세요. 개인 대화에서 얻은 기억이 그룹 대화에 인용되면 사고입니다. 검색에 사용자·에이전트 범위를 반드시 걸어야 합니다.
- 요약의 요약을 조심하세요. 압축을 반복하면 정보가 왜곡됩니다. 원문 세션은 별도로 보존하고, 요약은 언제든 다시 만들 수 있게 두세요.
정리
- 기억은 Working(원문) → Episodic(요약) → Semantic(그래프) 세 계층으로
- 비용은 점진적 로딩으로 잡는다 — L0만 항상, L1·L2는 필요할 때
- 자동 주입은 키워드 비중을 높인 하이브리드 검색 + 임계치 컷, 애매하면 안 넣는다
- 대명사는 최근 사용자 턴을 검색어에 섞어 푼다 (룬 단위 절단 필수)
- 계층 간 승격은 이벤트 기반 비동기 — 응답 경로를 막지 않는다
- L0 초록은 추출식으로 충분 — 역할이 힌트지 정보 전달이 아니다
메모리 설계의 어려움은 저장이 아니라 망각입니다. 무엇을 안 올릴지 정하는 게 무엇을 올릴지 정하는 것보다 훨씬 중요합니다.
댓글
댓글 쓰기