AI 에이전트의 장기 기억에 지식그래프 적용하기 - 바뀌는 사실을 다루는 법
앞선 두 글에서 지식그래프의 개념을 정리하고 GraphRAG 파이프라인을 만들어 봤다. 그런데 그 그래프를 AI 에이전트의 장기 기억으로 쓰려는 순간, 지금까지 무시해 온 문제가 튀어나온다. 사실이 시간에 따라 바뀐다는 것이다.
"저는 Gemma를 씁니다"라고 1월에 말한 사용자가 5월에는 다른 모델을 쓴다. 담당자가 바뀌고, 서비스가 폐기되고, 의존 관계가 재편된다. 기억을 단순히 쌓기만 하면 에이전트는 모순된 사실 두 개를 동시에 들고 있게 되고, 덮어쓰기만 하면 "예전에 무슨 얘기 했었죠?"에 답하지 못한다. 이 글은 그 사이를 어떻게 통과하는지에 대한 것이다.
이번 글의 코드는 SQLite와 표준 라이브러리만 쓰기 때문에 전부 실제로 실행해 출력을 확인했다. 붙여 넣은 결과는 모두 실행 결과다.
1. 덮어쓰기도 쌓기도 답이 아닌 이유
기억을 다루는 순진한 방법 두 가지는 이렇게 실패한다.
- 덮어쓰기 — 최신 값만 남긴다. 빠르고 간단하지만 "3월에는 어떤 모델을 쓰고 있었나"에 답할 수 없다. 더 나쁜 건 뒤늦게 도착한 오래된 로그가 최신 값을 덮어 버리는 사고다.
- 무한 누적 — 모든 발언을 그대로 쌓는다. 이력은 남지만 검색하면 서로 모순되는 사실 여러 개가 동시에 나온다. 이걸 그대로 프롬프트에 넣으면 LLM은 둘 중 하나를 임의로 고르거나 둘 다 언급하며 얼버무린다.
필요한 건 사실을 삭제하지 않으면서도 "지금 참인 것"을 명확히 골라낼 수 있는 구조다. 데이터베이스 분야에서 오래전에 정리된 개념이 여기에 그대로 들어맞는다. 시간축을 두 개 두는 것이다.
2. 시간축이 두 개 필요한 이유
사실 하나에 붙는 시간은 사실 두 종류다. 헷갈리기 쉬운데 구분해 두면 이후 설계가 훨씬 단순해진다.
- 유효 시간(valid time) — 그 사실이 현실에서 참이었던 구간. "1월 10일부터 5월 20일까지 Gemma를 썼다"의 그 구간이다.
- 기록 시간(transaction time) — 시스템이 그 사실을 알게 된 시점. 대화에서 그 말을 들은 시각이다.
두 시간축을 함께 기록하는 방식을 이중 시간(bi-temporal) 모델이라고 부른다.
둘을 분리해야 하는 이유는 그림 오른쪽 끝에 있다. 뒤늦게 도착한 2월치 로그다. 기록 시간으로는 가장 최신이지만 유효 시간으로는 과거다. 시간축이 하나뿐이면 이 데이터가 현재 값을 덮어 버린다. 두 축을 나눠 두면 "최근에 알게 된, 과거에 관한 사실"로 정확히 자리를 잡는다.
이 방식은 요즘 에이전트 메모리 계열 도구들이 공통적으로 채택하고 있다. 대표적인 오픈소스인 Graphiti도 엣지마다 유효 시간(valid_at, invalid_at)과 기록 시간(created_at, expired_at)을 함께 들고 있으며, 새 정보가 기존 사실과 충돌하면 삭제하지 않고 무효화 표시만 한다. 여기서는 그 아이디어를 최소 형태로 직접 구현해 본다.
3. 스키마 - 필드 네 개면 시작할 수 있다
거창한 그래프 DB부터 꺼낼 필요는 없다. 주어·술어·목적어 트리플에 시간 필드 네 개를 붙인 테이블 하나로 충분하다.
CREATE TABLE IF NOT EXISTS facts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject TEXT NOT NULL,
predicate TEXT NOT NULL,
object TEXT NOT NULL,
valid_from TEXT NOT NULL, -- 현실에서 참이 된 시점
valid_to TEXT, -- NULL이면 지금도 유효
ingested_at TEXT NOT NULL, -- 시스템이 알게 된 시점
expired_at TEXT, -- NULL이면 아직 무효화되지 않음
source TEXT, -- 어느 대화에서 나왔나
confidence REAL DEFAULT 1.0
);
CREATE INDEX IF NOT EXISTS idx_sp ON facts(subject, predicate);
여기서 가장 중요한 규칙은 하나다. UPDATE로 object를 바꾸지 않는다. DELETE도 하지 않는다. 값이 바뀌면 기존 행의 valid_to와 expired_at만 채우고 새 행을 추가한다. 이 원칙만 지키면 이력은 저절로 보존된다.
confidence를 둔 이유는 뒤에 나온다. 사용자가 직접 말한 사실과 문서에서 추출한 사실, 오래된 로그에서 복구한 사실의 무게가 같을 수 없기 때문이다.
4. 쓰기 - 충돌을 세 갈래로 나눈다
새 사실이 들어왔을 때 할 일은 "같은 주어·술어의 기존 사실이 있는가"를 먼저 확인하는 것이다. 그 다음은 세 가지 경우뿐이다.
세 갈래 중 어느 쪽으로 가든 기존 행은 남는다.
여기에 한 가지 구분이 더 필요하다. 단일값 술어와 다중값 술어다. "현재 사용 중인 모델"은 하나뿐이지만 "참여 중인 프로젝트"는 여러 개일 수 있다. 새 값이 들어왔을 때 기존 값을 무효화해야 하는지는 술어의 성격에 달렸으므로, 단일값 술어 목록을 따로 관리한다.
import sqlite3, datetime as dt
SINGLE_VALUED = {"USES_MODEL", "ROLE", "PREFERS_LANG"} # 값이 하나뿐인 술어
def now():
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
class Memory:
def __init__(self, path=":memory:"):
self.db = sqlite3.connect(path)
self.db.executescript(SCHEMA)
def write(self, s, p, o, valid_from=None, source=None, confidence=1.0):
vf = valid_from or now()
t = now()
rows = self.db.execute(
"SELECT id, object, valid_from, confidence FROM facts "
"WHERE subject=? AND predicate=? AND expired_at IS NULL AND valid_to IS NULL",
(s, p)).fetchall()
for fid, old_o, old_vf, old_conf in rows:
if old_o == o:
return ("중복", fid) # ① 같은 값 - 쓰지 않는다
if p in SINGLE_VALUED:
if old_vf > vf and old_conf >= confidence:
return ("무시(더 최신 사실 존재)", fid) # ③ 뒤늦게 온 과거 정보
self.db.execute( # ② 무효화하고 이어 붙인다
"UPDATE facts SET valid_to=?, expired_at=? WHERE id=?", (vf, t, fid))
cur = self.db.execute(
"INSERT INTO facts(subject,predicate,object,valid_from,ingested_at,source,confidence)"
" VALUES(?,?,?,?,?,?,?)", (s, p, o, vf, t, source, confidence))
self.db.commit()
return ("기록", cur.lastrowid)
무효화할 때 valid_to에 새 사실의 valid_from을 넣는 부분이 핵심이다. 이렇게 하면 두 사실의 유효 구간이 빈틈 없이 이어진다. 반면 expired_at에는 지금 시각을 넣는다. 유효 시간과 기록 시간이 서로 다른 값을 갖는 지점이 바로 여기다.
네 번의 쓰기를 순서대로 시도한 결과다.
write("Alice","USES_MODEL","Gemma3-4B", valid_from="2026-01-10", source="세션#12")
→ ('기록', 1)
write("Alice","OWNS","OrderApi", valid_from="2026-01-10", source="세션#12")
→ ('기록', 2)
write("Alice","USES_MODEL","Gemma3-4B", valid_from="2026-03-02", source="세션#40")
→ ('중복', 1)
write("Alice","USES_MODEL","Qwen3-8B", valid_from="2026-05-20", source="세션#77")
→ ('기록', 3)
write("Alice","USES_MODEL","Gemma3-4B", valid_from="2026-02-01", source="오래된 로그", confidence=0.6)
→ ('무시(더 최신 사실 존재)', 3)
마지막 줄이 이 구조의 값어치를 보여 준다. 뒤늦게 도착한 2월치 로그는 기록 시간상 가장 최근에 들어온 데이터지만, 유효 시간이 과거이고 신뢰도도 낮아서 현재 값을 건드리지 못했다. 시간축이 하나뿐인 설계였다면 에이전트는 이 시점부터 사용자가 Gemma를 쓴다고 믿게 된다.
5. 읽기 - 세 가지 질문에 답한다
기억을 이렇게 쌓아 두면 성격이 다른 세 가지 질문에 각각 답할 수 있다. "지금 참인 것", "그때 참이었던 것", "어떻게 변해 왔는지"다.
def current(self, s, p=None):
"""지금 유효한 사실만"""
q = ("SELECT predicate,object,valid_from,source FROM facts WHERE subject=? "
"AND valid_to IS NULL AND expired_at IS NULL")
args = [s]
if p:
q += " AND predicate=?"
args.append(p)
return self.db.execute(q, args).fetchall()
def as_of(self, s, when):
"""특정 시점에 참이었던 사실"""
return self.db.execute(
"SELECT predicate,object,valid_from,valid_to FROM facts WHERE subject=? "
"AND valid_from<=? AND (valid_to IS NULL OR valid_to>?)", (s, when, when)).fetchall()
def history(self, s, p):
"""하나의 술어가 변해 온 이력"""
return self.db.execute(
"SELECT object,valid_from,valid_to,source FROM facts "
"WHERE subject=? AND predicate=? ORDER BY valid_from", (s, p)).fetchall()
실행 결과다.
[현재 상태]
('OWNS', 'OrderApi', '2026-01-10T00:00:00+00:00', '세션#12')
('USES_MODEL', 'Qwen3-8B', '2026-05-20T00:00:00+00:00', '세션#77')
[2026-03-15 시점]
('OWNS', 'OrderApi', '2026-01-10T00:00:00+00:00', None)
('USES_MODEL', 'Gemma3-4B', '2026-01-10T00:00:00+00:00', '2026-05-20T00:00:00+00:00')
[USES_MODEL 이력]
('Gemma3-4B', '2026-01-10T00:00:00+00:00', '2026-05-20T00:00:00+00:00', '세션#12')
('Qwen3-8B', '2026-05-20T00:00:00+00:00', None, '세션#77')
같은 주어에 대해 현재는 Qwen3-8B, 3월 시점에는 Gemma3-4B가 나온다. 둘 다 맞는 답이고, 둘 다 근거가 되는 세션 번호를 달고 있다. "예전엔 X였지만 지금은 Y" 유형을 모순 없이 다룬다는 건 이런 뜻이다.
6. 에이전트 루프에 붙이기
이제 이 저장소를 에이전트 대화 루프에 끼워 넣는다. 읽기는 응답 전에, 쓰기는 응답 후에 놓는 것이 기본형이다.
읽기는 응답 전에, 쓰기는 응답 후에. 쓰기를 응답 경로에서 빼면 체감 속도가 달라진다.
읽기 쪽은 회수한 사실을 프롬프트에 넣을 텍스트로 펴는 함수 하나면 된다. 여기서 유효 시작 시점을 함께 적어 주는 것이 중요하다. LLM에게 "이건 언제부터 참인 정보"라는 단서를 주면 답변의 시제가 눈에 띄게 안정된다.
def recall_block(mem, subject, when=None):
rows = mem.as_of(subject, when) if when else mem.current(subject)
if not rows:
return ""
head = f"[장기 기억 · {when[:10] if when else '현재'} 기준]"
lines = [f"- {subject} {r[0]} {r[1]} (유효 시작 {r[2][:10]})" for r in rows]
return head + "\n" + "\n".join(lines)
[장기 기억 · 현재 기준]
- Alice OWNS OrderApi (유효 시작 2026-01-10)
- Alice USES_MODEL Qwen3-8B (유효 시작 2026-05-20)
[장기 기억 · 2026-03-15 기준]
- Alice OWNS OrderApi (유효 시작 2026-01-10)
- Alice USES_MODEL Gemma3-4B (유효 시작 2026-01-10)
이 블록을 시스템 메시지 끝에 붙이면 된다. 사용자가 "3월쯤엔 뭘 쓰고 있었죠?"처럼 시점을 언급하면 그 시점으로 as_of를 호출하고, 아니면 현재 기준으로 부른다. 시점 파싱까지 완벽하게 할 필요는 없고, 명시적인 날짜 표현만 잡아도 대부분의 경우를 커버한다.
쓰기 쪽은 대화에서 사실을 뽑아내는 단계가 앞에 붙는다. 앞 글의 추출 프롬프트와 거의 같은데, 결정적인 차이가 하나 있다. 유효 시점을 함께 뽑아야 한다는 것이다.
대화에서 사실을 추출한다. 각 사실에 valid_from을 반드시 채운다.
- "지금은 Qwen을 씁니다" → valid_from = 대화 시각
- "3월부터 Qwen으로 바꿨어요" → valid_from = 그해 3월 1일
- "예전에 Gemma를 썼었죠" → 과거형이다. 새 사실로 쓰지 말고
기존 사실의 확인으로만 취급한다
- 시점을 알 수 없으면 valid_from = 대화 시각으로 둔다
오늘 날짜는 {today}다. 상대적 표현은 이 날짜를 기준으로 환산한다.
과거형 문장을 걸러내는 규칙이 특히 중요하다. 이걸 넣지 않으면 사용자가 "예전엔 Gemma 썼었는데"라고 회상하는 순간 에이전트가 그것을 현재 사실로 기록해 버린다. 실제로 겪기 전에는 잘 상상이 안 되는 종류의 버그다.
7. 남는 문제들
위 구현은 최소 형태다. 실제로 굴리다 보면 다음 항목들이 차례로 걸린다.
- 재확인 기록 — 앞의 실행 결과에서 3월의 "중복" 케이스는 아무것도 남기지 않았다. 하지만 "3월에도 여전히 Gemma를 쓰고 있었다"는 것 자체가 정보다. 별도 컬럼이나 확인 로그 테이블에 남겨 두면 신뢰도 판단에 쓸 수 있다.
- 술어별 반감기 — 어떤 사실은 오래가고("생일") 어떤 사실은 금방 낡는다("현재 작업 중인 브랜치"). 술어마다 기대 수명을 두고, 그 기간이 지나면 신뢰도를 낮추거나 재확인을 요청하는 방식이 유효하다.
- 충돌 판정의 애매함 — 문자열 비교로 잡히는 충돌은 쉬운 쪽이다. "Gemma3-4B"와 "젬마 3 4B"는 같은 값인데 다르게 보인다. 앞 글의 엔티티 정규화가 여기서도 그대로 필요하다.
- 확장 — 트리플이 수십만 건을 넘고 다중 홉 질의가 필요해지면 SQLite로는 버겁다. 이때 그래프 DB로 옮기면 되는데, 위 스키마의 시간 필드들이 그대로 엣지 속성으로 이사 간다. 처음부터 이 형태로 잡아 두면 이전이 어렵지 않다.
반대로 처음부터 하지 않아도 되는 것도 있다. 온톨로지 추론 엔진을 붙이는 일이다. 시간이 개입하는 순간 "이행성"같은 공리가 시점마다 다르게 성립하기 때문에 복잡도가 크게 올라간다. 술어 목록과 단일값 여부만 정하고 시작해도 실용적인 값어치는 충분히 나온다.
정리
- 에이전트 기억에서 덮어쓰기는 이력을 잃고, 무한 누적은 모순을 만든다.
- 해법은 유효 시간과 기록 시간을 분리하는 것. 필드 네 개면 시작할 수 있다.
- 사실은 삭제하지 않는다. 무효화 표시만 하고 새 행을 이어 붙인다.
- 쓰기 충돌은 세 갈래다. 같은 값(중복) · 더 최신(무효화 후 삽입) · 더 과거(무시).
- 읽기는
current,as_of,history셋. 프롬프트에는 유효 시작 시점을 함께 넣는다. - 추출 프롬프트에서 과거형 문장을 걸러내지 않으면 회상이 현재 사실로 기록된다.
세 편에 걸쳐 온톨로지 개념에서 시작해 GraphRAG 파이프라인을 거쳐 시간을 다루는 에이전트 기억까지 왔다. 관통하는 원칙은 하나였다. 관계를 일급 시민으로 다루고, 근거를 함께 저장한다. 이 두 가지만 지키면 나머지는 구현 세부일 뿐이다.
이 시리즈의 다른 글: 온톨로지와 지식그래프 기초 - DB 스키마와 무엇이 다른가 / GraphRAG 파이프라인 구축 - 문서 뭉치에서 지식그래프까지
※ 이 글의 라이브러리 사용법·도구 목록·구현 방식은 2026년 기준이며 변경될 수 있습니다.
댓글
댓글 쓰기