실제로 무언가를 기억하는 개인용 AI 어시스턴트 구축하기 (Claude + Telegram + SQLite)
요약
Claude와 Telegram, SQLite를 활용하여 단기 및 장기 메모리를 갖춘 개인용 AI 어시스턴트를 구축하는 방법을 설명합니다. 대화 문맥을 유지하는 로링 윈도우 방식과 영구적인 사실을 저장하는 메모리 아키텍처를 다룹니다.
핵심 포인트
- 단기 메모리와 장기 메모리의 개념적 분리 및 설계
- SQLite를 활용한 로컬 데이터 지속성 확보
- 비용 효율적인 Claude Haiku 모델 사용
- 컨텍스트 오염 방지를 위한 데이터베이스 스키마 구조
대부분의 AI 어시스턴트는 근본적인 결함이 있습니다. 모든 대화가 차갑게(cold) 시작된다는 점입니다. ChatGPT는 당신이 어제 불렛 포인트(bullet points)를 선호한다고 말한 것을 알지 못합니다. Claude는 당신이 Node 프로젝트를 작업 중이라는 사실을 잊어버립니다. 몇 주 동안 쌓아온 문맥(context)은 탭을 닫는 순간 증발해 버립니다.
저는 다른 것을 원했습니다. Telegram에서 작동하고, 제 자신의 머신에서 실행되며, 세션 간에 실제로 상태(state)를 유지하는 개인용 어시스턴트 말입니다. 제가 이것을 어떻게 구축했는지, 그리고 왜 이 아키텍처(architecture)가 효과적인지 설명하겠습니다.
문제의 형태
코드를 작성하기 전에, 채팅 어시스턴트에게 "무언가를 기억한다는 것"이 실제로 무엇을 의미하는지 정확히 정의할 필요가 있습니다. 여기에는 두 가지 뚜렷한 메모리(memory) 유형이 있습니다:
단기 메모리 (Short-term memory) — Claude가 일관된 답변을 제공하기 위해 필요한 대화 문맥(context)입니다. "사용자가 방금 무엇을 물었는가?" 만약 이전 메시지들을 API에 다시 전달하지 않는다면, 모델은 정의상 상태가 없는(stateless) 상태가 됩니다.
장기 메모리 (Long-term memory) — 세션 전반에 걸쳐 지속되어야 하는 사실들입니다. "사용자는 간결한 답변을 선호한다." "그들은 Node.js 프로젝트를 구축하고 있다." 이러한 정보들은 프로세스가 재시작될 때 사라져서는 안 됩니다.
대부분의 튜토리얼은 이 두 가지를 모두 다루지 않습니다. 그들은 단지 Claude의 응답을 한 번 받는 법을 보여주고 끝냅니다. 이 포스트는 두 가지 모두를 다룹니다.
스택 (Stack)
- Node.js — 런타임 (runtime)
node-telegram-bot-api— Telegram 인터페이스@anthropic-ai/sdk— Claude Haiku (빠름, 메시지당 약 $0.001)better-sqlite3— 지속성 메모리 (단일.db파일, 서버 불필요)dotenv— 설정 (config)
총 의존성(dependencies)은 4개입니다. 전체 시스템은 파일 하나로 구성됩니다.
데이터베이스 스키마 (Database schema) — 두 개의 테이블, 하나의 파일
메모리 아키텍처(architecture)는 의도적으로 단순하게 설계되었습니다:
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL, -- 'user' 또는 'assistant'
...
messages는 단기 메모리(short-term memory), 즉 순환하는 대화 창(rolling conversation window)을 처리합니다. notes는 장기 메모리(long-term memory), 즉 사용자가 /remember 명령어로 명시적으로 저장하는 사실들을 처리합니다.
이들이 분리되어 있는 데에는 이유가 있습니다. 대화 기록(Conversation history)은 자연스러운 만료 시점이 존재합니다. 즉, N번의 턴이 지나면 오래된 대화는 더 이상 관련성이 없어집니다. 반면 개인 메모(Personal notes)는 만료되지 않습니다. "나는 섭씨(Celsius)를 선호해"라는 정보는 영원히 유효합니다. 이 둘을 하나의 테이블에 섞어버리면, 오래된 메시지로 컨텍스트(Context)를 오염시키거나 영구적인 사실을 실수로 삭제하게 되는 상황 중 하나를 마주하게 될 것입니다.
로링 윈도우(Rolling window) — 단기 메모리가 제한되는 방식
모든 Claude API 호출에는 토큰(Tokens) 비용이 발생합니다. 500개의 대화 기록을 전달하는 것은 비용이 많이 들 뿐만 아니라 역효과를 낳기도 합니다(모델의 어텐션(Attention)이 매우 긴 컨텍스트에서 희석되기 때문입니다). 해결책은 로링 윈도우(Rolling window)를 사용하는 것입니다.
function saveMessage(role, content) {
db.prepare(`INSERT INTO messages (role, content) VALUES (?, ?)`).run(role, content);
...
저장할 때마다 DELETE 명령어를 통해 윈도우 범위를 벗어난 모든 데이터를 정리(Pruning)합니다. 기본값은 10회의 대화(사용자 + 어시스턴트 쌍을 포함한 20개의 행)입니다. 이는 .env 파일의 MEMORY_WINDOW 설정을 통해 구성할 수 있습니다.
왜 MAX_HISTORY * 2를 사용할까요? Anthropic의 메시지 API는 user/assistant 턴이 번갈아 가며 나타나야 합니다. 만약 메시지를 별도로 저장하고 메시지 개수로 계산한다면, 어차피 짝수 단위가 필요할 것입니다. 윈도우 크기를 두 배로 늘리고 행(Row) 수로 정리하면 이러한 불변성(Invariant)을 자연스럽게 유지할 수 있습니다.
장기 메모리 노트 — 시스템 프롬프트에 사실 주입하기
이 부분이 핵심입니다. 노트는 메시지로 전달되지 않고, _시스템 프롬프트(System prompt)_에 주입됩니다. 이것이 중요한 이유는 다음과 같습니다:
- 시스템 프롬프트 내용은 대화의 어떤 턴이 시작되기 전에도 먼저 읽힙니다.
- 대화 기록 제한(Conversational history limit)에 포함되지 않습니다.
- 모델은 이를 사용자가 한 번 말했던 내용이 아니라, 기초적인 컨텍스트(Foundational context)로 취급합니다.
async function askClaude(userMessage) {
const notes = getNotes();
let systemText = SYSTEM_PROMPT;
...
/remember 나는 중부 표준시(Central Time)에서 일해라고 실행하면, 해당 노트는 notes 테이블에 저장됩니다. 이후의 모든 API 호출은 모든 노트를 다시 읽어 시스템 프롬프트에 추가합니다. 이제 모델은 재시작하거나 대화 기록을 삭제하더라도 사용자의 시간대를 영구적으로 알 수 있습니다. 노트가 항상 시스템 프롬프트에 포함되어 있기 때문입니다.
전체 메시지 핸들러 (The full message handler)
데이터베이스 헬퍼(database helpers)가 준비되면 Telegram 측 구현은 간단합니다:
bot.on('message', async (msg) => {
if (!isAllowed(msg)) return; // 허용된 단일 채팅 ID — 기본적으로 비공개
const text = msg.text;
...
인증 가드(isAllowed)는 .env 파일의 TELEGRAM_ALLOWED_CHAT_ID와 msg.chat.id를 비교하여 확인합니다. 이는 누군가 실수로 당신의 봇 토큰(bot token)을 발견하더라도 봇이 응답하지 않도록 방지합니다. 토큰이 실수로 노출될 경우 실제로 매우 중요한 문제입니다.
내장 명령어 (Built-in commands)
세 가지 명령어가 기본적으로 제공됩니다:
/remember <text> — 영구적인 노트 저장
/forget — 대화 기록 삭제 (노트는 유지됨)
/status — 가동 시간(uptime), 메시지 수, 메모리 통계
/forget은 messages를 삭제하지만 notes는 건드리지 않습니다. 이는 의도된 설계입니다. 새로운 대화를 시작한다고 해서 이미 저장된 사실(facts)까지 지워져서는 안 되기 때문입니다. 만약 노트를 지우고 싶다면 별도의 /clearnotes 명령어가 필요할 것입니다.
/status는 디버깅 시 유용합니다:
Assistant Status
Uptime: 2h 14m
...
운영 비용 (Running cost)
Claude Haiku의 가격은 입력 토큰 100만 개당 $0.80, 출력 토큰 100만 개당 $4.00입니다. 일반적인 메시지 교환(입력 500 토큰, 출력 200 토큰)의 비용은 약 $0.0012입니다. 이를 하루에 30번 실행하면 하루에 $0.036이 소요됩니다. Anthropic 크레딧 $5는 일반적인 개인 용도로 사용할 경우 대략 4~5개월 동안 유지됩니다.
Telegram 봇은 무료입니다. SQLite도 무료입니다. 유일한 반복 비용은 Anthropic API이며, 이 정도 사용 수준에서는 사실상 오차 범위 내의 금액입니다.
벡터 DB가 아닌 SQLite를 사용하는 이유 (Why SQLite and not a vector DB)
모든 AI 메모리 튜토리얼은 Pinecone, Weaviate, 혹은 최소한 Redis를 사용하려고 합니다. 하지만 당신의 노트를 처리하는 개인용 어시스턴트에게 이는 과도한 스펙(overkill)입니다.
당신은 100,000개의 노트를 가지고 있지 않을 것입니다. 아마 50개 정도일 것입니다. 50개의 행을 반환하는 SELECT * FROM notes를 실행하여 이를 시스템 프롬프트(system prompt)에 주입하는 것은 즉각적이고, 비용이 들지 않으며, 절대 실패하지 않습니다. 50개의 항목에 대한 의미론적 검색(Semantic search)은 굳이 해결할 필요가 있는 문제가 아닙니다.
SQLite는 또한 무료로 내구성 (Durability)을 제공합니다. better-sqlite3 쓰기 작업은 동기적 (Synchronous)입니다. 즉, 비동기 (Async) 오류 발생 가능성이 없으며, 관리해야 할 커넥션 풀 (Connection pool)도 없습니다. 프로세스를 재시작해도 파일은 그 자리에 그대로 있습니다.
진정한 의미론적 검색 (Semantic retrieval) 문제(수천 개의 문서, 퍼지 검색 (Fuzzy lookup), 사용자 간 검색 등)가 발생할 때는 벡터 (Vector) 접근 방식을 확장하십시오. 한 개인의 메모를 위한 용도라면 SQLite 테이블이 적합한 도구입니다.
확장하기
이 아키텍처 (Architecture)는 각 새로운 명령어가 독립적이기 때문에 확장이 깔끔합니다. 자연스럽게 추가할 수 있는 몇 가지 예시는 다음과 같습니다.
cron을 통한 데일리 브리핑 (Daily briefings):
const cron = require('node-cron');
cron.schedule('0 8 * * *', async () => {
...
SQLite에 저장되는 리마인더 (Reminders):
bot.onText(/^\/remind (.+) in (\d+)(m|h)$/i, (msg, match) => {
if (!isAllowed(msg)) return;
const ms = match[3] === 'h' ? match[2] * 3600000 : match[2] * 60000;
...
비전 (Vision) — 전송한 사진 분석:
bot.on('photo', async (msg) => {
if (!isAllowed(msg)) return;
const fileId = msg.photo[msg.photo.length - 1].file_id;
...
이 각각은 독립적입니다. 하나를 추가한다고 해서 다른 기능에 영향을 주지 않습니다.
이 아키텍처가 할 수 없는 것
한계점에 대해 솔직하게 말씀드리자면 다음과 같습니다.
- 의미론적 검색 (Semantic search) 불가 — 메모가 있는 그대로 (Verbatim) 주입됩니다. 만약 메모가 200개라면, 주입하기 전에 관련성에 따라 필터링을 하고 싶을 것입니다. 메모가 20~30개 정도라면 이는 문제가 되지 않습니다.
- 단일 사용자 전용 —
ALLOWED_CHAT_ID가드 (Guard)는 의도된 것입니다. 다중 사용자를 지원하려면 사용자별 메모 테이블과 더 세심한 인증 (Auth)이 필요합니다. - 스트리밍 (Streaming) 불가 —
bot.sendMessage는 Claude가 응답할 때까지 차단 (Block)됩니다. 긴 응답의 경우, 스트리밍 방식을 사용하면 텍스트가 생성되는 대로 보여줄 수 있습니다. 구현은 가능하지만, 이 최소 버전에서는 포함하지 않았습니다. - 로컬 지속성 (Local persistence)만 지원 —
memory.db는 프로세스를 실행하는 곳에 존재합니다. 기기를 옮긴다면 파일을 함께 가져가야 합니다. 자동 클라우드 동기화는 지원하지 않습니다.
전체 .env 설정
ANTHROPIC_API_KEY=sk-ant-...
TELEGRAM_BOT_TOKEN=your_bot_token_from_botfather
TELEGRAM_ALLOWED_CHAT_ID=your_numeric_chat_id
...
Telegram에서 @userinfobot에게 메시지를 보내 귀하의 채팅 ID (chat ID)를 확인하세요.
실행하기
npm install
cp .env.example .env
# 필요한 세 가지 키를 입력하세요
...
지속적인 운영을 위해, PM2로 감싸서 실행하세요:
npm install -g pm2
pm2 start index.js --name assistant
pm2 startup # 재부팅 시 자동 재시작
...
마치며
여기서 핵심적인 통찰은 LLM 어시스턴트에게 "기억 (memory)"란 사실 세심한 데이터 관리일 뿐이라는 점입니다. 단기 문맥 (Short-term context)은 메시지 배열 (messages array)로 전달되는 순환하는 SQLite 윈도우 (rolling SQLite window)입니다. 장기적인 사실 (Long-term facts)은 시스템 프롬프트 (system prompt)에 주입되는 별도의 테이블입니다. 이 중 어느 것도 벡터 데이터베이스 (vector database), 클라우드 서비스, 또는 위에 나열된 4개의 npm 패키지 이상의 무언가를 필요로 하지 않습니다.
전체 구현 코드는 주석이 잘 달린 약 280줄의 JavaScript로 이루어져 있습니다. 이 규모는 의도된 것입니다. 한 번에 읽을 수 있을 만큼 작으면서도, 단순한 'Hello-world' 수준의 뼈대가 아닌 실제로 작동하는 시스템이 될 만큼 충분히 큽니다.
이는 88개의 MCP 도구와 완전한 자율 루프 (autonomy loops)를 실행하는 더 큰 AI 시스템에서 사용되는 것과 동일한 기억 패턴입니다. 차이점은 18개월 동안 추가된 기능들뿐입니다. 핵심적인 기억 아키텍처 (memory architecture)는 변하지 않았습니다.
전체 스타터 키트 — 완전한 index.js, 30분 설정 가이드, 그리고 10개의 사전 구축된 확장 기능 (날씨, 알림, 시각 지능 (vision), cron 브리핑, 웹 검색, FTS5 노트 검색, 뽀모도로 타이머 등) — 를 원하신다면, **https://milkyway801.gumroad.com/l/hkdaox**에서 19달러의 일회성 결제로 이용하실 수 있습니다. 구독이나 라이선스 키는 없습니다. 압축을 풀고, .env를 채운 뒤, 실행하면 됩니다.
이 패턴을 기반으로 구축한 확장 기능이나 기억 아키텍처에 대한 질문이 있다면 댓글로 남겨주세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기