본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 02. 18:06

AI 에이전트에게 더 나은 시간적 추론 (Temporal Reasoning)이 필요한 이유와 해결 방법

요약

기존 에이전트 메모리 시스템의 선형적 처리 한계를 극복하기 위한 시간적 추론(Temporal Reasoning) 구현 방법을 다룹니다. Python 기반의 학술적 접근 대신 Node.js와 SQLite를 활용하여 프로덕션 환경에 즉시 적용 가능한 효율적인 해결책을 제안합니다.

핵심 포인트

  • 기존 에이전트 메모리는 시간적 맥락을 고려하지 못하는 평면적 구조임
  • Node.js와 SQLite를 활용해 프로덕션 환경에 최적화된 구현 가능
  • TReMu 프레임워크를 통해 시간 관련 질문 정확도를 대폭 향상 가능
  • 상대적 시간 표현 해결 및 날짜 산술 적용이 핵심 메커니즘임

대부분의 에이전트 메모리 시스템은 저장된 사실을 선형적으로 처리합니다. 특정 사실이 언제 참이었는지, 그 사실이 대체되었는지, 혹은 시간에 대해 어떻게 추론해야 하는지에 대한 개념이 전혀 없습니다.

이 글은 우리가 어떻게 문제를 진단하고, 관련 연구를 찾아냈으며, Python이나 subprocess, 학술적 오버헤드 없이 Node.js와 SQLite만을 사용하여 프로덕션(production) 환경에서 작동하는 해결책을 구축했는지에 대한 이야기입니다.

한 독자로부터 다음과 같은 맥락의 아주 좋은 질문을 받았습니다:

“시간적 추론 (Temporal reasoning)은 제가 더 많은 연구가 이루어지길 바라는 분야입니다. 대부분의 검색 시스템은 메모리를 평면적인 사실의 주머니(flat bag of facts)로 취급하며, 에이전트는 어제의 사실이 지난달의 사실보다 우선한다는 것을 알 방법이 없습니다.”

이 흥미로운 질문은 저녁 식사 전 늦은 밤, 우리를 Arxiv의 심층적인 연구 속으로 빠져들게 만들었습니다.

우리는 시간적 지식 그래프 (temporal knowledge graphs), 이중 시간 저장 모델 (bi-temporal storage models), 신경 기호적 추론 파이프라인 (neuro-symbolic reasoning pipelines) 등 다양한 각도에서 이 문제에 접근하는 몇몇 논문들을 발견했습니다. 그들 대부분은 Python을 사용했습니다. 그리고 모두 학술적인 내용이었습니다. 그중 어떤 것도 코드를 새로 작성하지 않고는 프로덕션 Node.js 에이전트에 바로 적용할 수 있는 것이 없었습니다.

이는 결국 우리가 VEKTOR의 초기 단계에서 내렸고 결코 후회하지 않은 결정으로 우리를 다시 이끌었습니다. 바로 Python 대신 Node.js를 선택한 것입니다. Python이 훌륭하지 않아서가 아닙니다. Python이 현재 대세인 데에는 합당한 이유가 있으며, 그 위에 구축된 머신러닝 (ML) 생태계는 진정으로 세계적인 수준입니다. 우리가 Node.js를 선택한 이유는 동시 실행 (concurrent execution), 파일 I/O 속도, 그리고 에이전트 도구가 느릿하지 않고 빠릿하게 느껴지도록 만드는 이벤트 루프 (event loop) 모델 때문이었습니다. 그 선택은 특정 문을 닫기도 하지만, 동시에 다른 문을 열어주기도 합니다.

우리가 발견한 가장 흥미로운 논문은 UIUC와 AWS에서 발표한 'TReMu — Multi-Session Dialogues에서의 LLM 에이전트를 위한 시간적 추론 (Temporal Reasoning for LLM-Agents in Multi-Session Dialogues)'이었습니다. 이들의 프레임워크는 시간 관련 질문에 대한 GPT-4o의 정확도를 29%에서 77%로 끌어올렸습니다. 그 메커니즘은 다음과 같습니다: 데이터 입력 (ingest) 시점에 상대적 시간 표현을 해결한 다음, 쿼리 (query) 시점에 Python을 사용하여 날짜 산술 (date arithmetic)을 실행하는 방식입니다.

우리는 Python 부분을 버려야만 했습니다. 대신 우리가 구축한 방식이 아마도 더 깔끔할 것입니다. 적어도 우리는 그렇게 생각합니다.

아무도 이야기하지 않는 문제

당신의 AI 에이전트에게 "우리는 어떤 데이터베이스를 사용하고 있나요?"라고 물어보십시오. 그러면 에이전트는 설령 그 사실이 6개월이나 지났고 세 번의 결정이 지나 업데이트되지 않았더라도, 마지막으로 저장된 내용을 바탕으로 자신 있게 대답할 것입니다.

이것은 환각 (hallucination) 문제가 아닙니다. 그 사실은 실재했습니다. 과거에는 사실이었습니다. 단지 더 이상 사실이 아닐 뿐입니다.

대부분의 검색 증강 메모리 (retrieval-augmented memory) 시스템은 저장된 기억을 의미적 유사성 (semantic similarity)과 최신성 (recency)에 따라 순위가 매겨진 평면적인 집합 (flat collection)으로 취급합니다. 어떤 사실이 세상에서 언제 사실이었는지, 그리고 에이전트가 그것을 언제 학습했는지에 대한 명시적인 모델이 없습니다. 새로운 사실에 의해 기존 사실이 대체되었음을 표시하는 메커니즘도 없습니다. 그리고 "Redis를 사용하기로 결정한 시점과 거기서 마이그레이션한 시점 사이의 간격은 얼마나 되나요?"와 같은 질문에 답할 방법도 분명히 없습니다.

에이전트는 시간에 대해 자신이 무엇을 모르는지조차 모릅니다. 에이전트는 모든 사실이 영원히 똑같이 유효한, 영원한 현재 속에 살고 있습니다.

연구: TReMu

2025년 9월 24일 수정, 일리노이 대학교 (University of Illinois)와 AWS의 팀이 'TReMu — Multi-Session Dialogues에서의 LLM 에이전트를 위한 시간적 추론 (Temporal Reasoning for LLM-Agents in Multi-Session Dialogues)'을 발표했습니다.

이 논문은 전문을 읽어볼 가치가 있지만, 가장 눈에 띄는 수치는 다음과 같습니다: 시간적 추론 질문에 대한 표준 GPT-4o 프롬프팅 (prompting) 점수는 29.83%입니다. 반면 그들의 프레임워크는 77.67%를 기록했습니다. 이는 인간이 아주 쉽다고 느끼는 질문들에서 48포인트나 도약한 수치입니다.

그 질문들은 무엇이었을까요? 세 가지 유형이 있습니다:

시간적 앵커링 (Temporal Anchoring) — "이 일이 정확히 언제 일어났나요?" 사용자가 "지난주 월요일에 세미나에 갔어요"라고 말합니다. 그때가 언제였을까요? 대부분의 시스템은 이벤트 날짜가 아닌 입력 (ingestion) 타임스탬프를 저장합니다. 이 둘은 서로 다릅니다.

시간적 선행성 (Temporal Precedence) — “이 두 사건 중 어느 것이 먼저 일어났을까요?” 단순히 가장 최근에 저장된 것을 아는 것을 넘어, 여러 세션에 걸친 실제 이벤트의 순서를 알아야 합니다.

시간 간격 (Temporal Interval) — “이 두 사건 사이에 얼마나 시간이 흘렀나요?”에는 실제 날짜 산술(date arithmetic)이 필요합니다. 느낌이나 감이 아닙니다. “꽤 전에” 같은 표현도 안 됩니다. 정확한 일수 단위가 필요합니다.

논문에서 제시하는 해결책은 두 부분으로 구성되어 있습니다:

시간 인식 메모리화 (Time-aware memorization) — 데이터를 수집(ingest)할 때, 상대적인 시간 표현(“지난 금요일,” “2주 전”)을 구체적인 달력 날짜로 변환합니다. 이벤트 날짜를 데이터 수집 날짜와 분리하여 저장합니다.

신경-기호적 시간 추론 (Neuro-symbolic temporal reasoning) — 질의(query)할 때, 날짜 산술을 수행하는 Python 코드를 생성하고, 이를 실행한 후, 그 출력을 사용하여 질문에 답합니다.

1부는 명확히 올바릅니다. 2부는 학문적 틀 안에 포장된 좋은 아이디어이지만, 저희의 프로덕션 아키텍처에서 작동하도록 더 다듬어야 했던 부분이 있었습니다.

Python 서브프로세스의 문제점

이 논문은 연구 노트북 환경에서 실행되기 때문에 Python을 사용합니다. dateutil.relativedelta는 날짜 계산에 정말 훌륭합니다. 하지만

TReMu 논문의 핵심 통찰은 이벤트 시간 (event time)과 언급 시간 (mention time)이 서로 다른 차원이라는 점입니다. 사용자가 “지난 화요일에 새로운 CTO를 만났어”라고 말할 때, 이벤트는 지난 화요일에 발생했습니다. 에이전트는 그것을 오늘 알게 된 것입니다. 표준 메모리 시스템은 이 둘을 하나의 타임스탬프 (timestamp)로 통합해 버립니다.

VEKTOR에는 이미 연결될 준비가 된 vektor_timeline 테이블과 event_date 컬럼이 있었습니다. 문제는 데이터 수집 (ingestion) 단계에 있었습니다. 추출 프롬프트 (extraction prompt)가 상대적 시간 표현 (relative time expressions)을 요청하지 않았고, 이를 달력 날짜 (calendar dates)로 변환해 주는 장치도 없었습니다.

해결책: JavaScript를 위한 프로덕션급 NLP 날짜 파서 (date parser)인 chrono-node를 사용합니다.

import * as chrono from 'chrono-node';  
function resolveRelativeDate(expression, sessionTimestamp) {  
// chrono가 기본적으로 처리하지 못하는 관용구를 전처리합니다.  
let expr = expression.trim();  
expr = expr.replace(/\ba\s+fortnight(\s+ago)?\b/i, '2 weeks ago');  
expr = expr.replace(/\bhalf\s+a\s+year(\s+ago)?\b/i, '6 months ago');  
const anchor = new Date(sessionTimestamp);  
const parsed = chrono.parseDate(expr, anchor, { forwardDate: false });  
return parsed ? parsed.toISOString().slice(0, 10) : null;  
}

단 두 줄의 코드입니다. “지난 추수감사절(last Thanksgiving)”, “2주 전(a fortnight ago)”, “작년 3분기(Q3 of last year)”, “그저께(the day before yesterday)” 등을 처리할 수 있습니다. anchor 파라미터는 세션 타임스탬프 (session timestamp)로, 상대적 표현이 계산되는 기준 시점이 됩니다.

이 함수는 사실 관계 (facts)가 저장된 후, 세션 수집 루프 (session ingest loop)의 마지막 단계에서 호출됩니다.

// 사실 관계를 저장한 후, 이벤트 날짜를 vektor_timeline에 반영합니다.  
await patchSessionIngest(storedFacts, sessionTimestamp, db);  

2단계: SQL 시간적 추론 (SQL Temporal Reasoning)
Python 코드를 생성하고 실행하는 대신, 이미 존재하는 도구들을 사용합니다.

**
시간적 앵커링 (Temporal Anchoring) — “X는 언제 발생했는가?”
**

시간적 앵커링 (Temporal Anchoring) — “X는 언제 발생했는가?”

SELECT m.id, m.rowid, m.content,
COALESCE(t.iso_date, m.event_date) AS resolved_date
FROM memories m
LEFT JOIN vektor_timeline t ON t.memory_id = m.rowid
WHERE m.rowid IN (/* recall result rowids */)
AND (m.superseded_by IS NULL)
AND NOT EXISTS (
SELECT 1 FROM memory_edges me
WHERE me.source_id = m.rowid AND me.edge_type = 'SUPERSEDES'
)
AND (m.event_date IS NOT NULL OR t.iso_date IS NOT NULL)
ORDER BY resolved_date DESC
시간 간격 (Temporal Interval) — “A와 B 사이에는 얼마나 시간이 흘렀는가?”

SELECT
CAST(ABS(
julianday(COALESCE(tb.iso_date, b.event_date)) -
julianday(COALESCE(ta.iso_date, a.event_date))
) AS INTEGER) AS days_between
FROM memories a
LEFT JOIN vektor_timeline ta ON ta.memory_id = a.rowid
JOIN memories b ON b.rowid = ?
LEFT JOIN vektor_timeline tb ON tb.memory_id = b.rowid
WHERE a.rowid = ?
julianday()는 네이티브 SQLite 함수입니다. 의존성이 없습니다. 트랜잭션 방식으로 작동합니다. 기존 데이터베이스 연결 내부에서 실행됩니다. 결과는

Zep/Graphiti 방식(사실당 4개의 타임스탬프를 사용하는 이중 시간 저장 (bi-temporal storage))은 아키텍처 측면에서는 올바르지만, 사후에 적용하기에는 비용이 많이 듭니다. 유효 타임스탬프 (valid timestamps), 무효 타임스탬프 (invalid timestamps), 이벤트 시간 (event times), 그리고 수집 시간 (ingestion times)을 관리하려면 모든 작성자 (writer) 측에서 쓰기 시점에 스키마 규율 (schema discipline)을 준수해야 합니다.

게으른 대안 (The lazy alternative): 그래프 내의 SUPERSEDES 엣지 (edge).

새로운 메모리가 오래된 메모리를 대체할 때, memory_edgesSUPERSEDES 엣지를 작성합니다. 회상 (recall) 시점에 NOT EXISTS 안티 조인 (anti-join)을 사용하면, 대체된 노드들을 수학적으로 보이지 않게 만들 수 있습니다:

AND NOT EXISTS (  
SELECT 1 FROM memory_edges me  
WHERE me.source_id = m.rowid AND me.edge_type = 'SUPERSEDES'  
)

(source_id, edge_type)에 인덱스를 하나 생성하면, 규모가 커지더라도 빠르게 실행됩니다.

핵심적인 추가 사항: 엣지를 작성하기 전의 충돌 해결 에이전트 (Conflict Resolution Agent) 게이트입니다. 맹목적인 자동 대체 (automated supersession)는 위험합니다. 예를 들어, "나도 Ethereum을 샀어"라는 문장이 단순히 의미론적 (semantically)으로 유사하다는 이유만으로 "나는 Bitcoin을 보유하고 있어"를 대체된 것으로 표시해서는 안 됩니다.

async function checkConflict(contentOld, contentNew, llmCall) {  
const prompt = `  
Fact A (older): "${contentOld}"  
Fact B (newer): "${contentNew}"  
Does Fact B logically SUPERSEDE (replace/update) Fact A?

-   TRUE: "We use AWS" → "We migrated to GCP"
-   FALSE: "I own Bitcoin" → "I also bought Ethereum" Reply ONLY with JSON: {"supersedes": true|false, "reasoning": "..."}`;
const result = JSON.parse(await llmCall(prompt));
return result.supersedes === true;
}

LLM이 논리적 대체임을 명시적으로 확인한 경우에만 엣지가 작성됩니다. 그 외의 모든 것은 가산적 (additive)으로 유지됩니다.

4단계: 온디맨드 시간적 추론 (On-Demand Temporal Reasoning)을 위한 MCP 도구
SQL 라우팅 (기준점 (anchor) vs 우선순위 (precedence) vs 구간 (interval))을 하드코딩하는 대신, 두 가지 MCP 도구를 노출하여 에이전트가 이를 주도하도록 합니다.

query_timeline — 특정 키워드에 대해 시간 순으로 정렬된 이벤트를 반환하며, 대체된 사실은 제외합니다. 에이전트는 이를 사용하여 무엇이 언제 일어났는지 파악합니다.

calculate_date_math — 두 개의 ISO 날짜를 입력받아 일/주/월 단위와 포맷팅된 문자열을 반환합니다. 서브프로세스 없이 순수 JavaScript 날짜 산술 (Date arithmetic)을 사용합니다.

// 에이전트가 query_timeline이 두 날짜를 반환한 후 이를 호출합니다.
const result = await calculate_date_math({
date_a: '2025-03-15', // Redis 결정
date_b: '2025-09-10' // Valkey 마이그레이션
});
// → { days: 179, formatted: "6 months", ... }
이것은 TReMu의 "뉴로-심볼릭 (neuro-symbolic)" 패턴이 올바르게 번역된 형태입니다. 즉, 에이전트는 추론 단계 (reasoning steps)를 작성하고, 실행 계층 (execution layer)이 정밀도를 처리합니다. 차이점은 이제 "실행"이 서브프로세스 오버헤드가 없는 순수 JavaScript 함수라는 점입니다.

우리가 세 번의 디버깅 세션을 소모하게 만든 스키마의 함정 (Schema Gotcha)

왜 버그에 대해 언급하나요?

그것들이 우리 실험실에서의 실제 테스트 단계이기 때문입니다. 모든 개발자가 겪는 일이며, 여러 번의 수정과 검토를 거쳐 5분 만에 해결되었습니다.

학술 논문에서는 언급하지 않는 한 가지가 있습니다: VEKTOR의 memories.id는 TEXT 컬럼 (포맷: vektor-slipstream-memory-8054)입니다. 반면 vektor_timeline.memory_id 컬럼은 INTEGER (SQLite rowid)입니다. 우리가 무심코 작성한 모든 JOIN 문은 t.memory_id = m.id를 사용했습니다. 이로 인해 SQLite가 정수 5726과 텍스트 vektor-slipstream-memory-8054를 비교하게 되면서, 아무런 오류 없이 0개의 행을 반환했습니다.

해결 방법은 m.id가 아닌 m.rowid를 기준으로 JOIN을 수행하고, (텍스트 ID를 반환하는) 프로덕션 리콜 (production recall)을 조회 함수 (lookup function)로 감싸는 것입니다:

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0