화면에는 지난 대화가 다 보였다. 어제 물어본 것, 그제 시킨 것이 스크롤을 올리면 그대로 있었다. 그래서 나는 이 AI 비서가 대화를 기억한다고 믿었다.

아니었다. 비서는 바로 직전에 내가 한 말도 보지 못하고 있었다.

화면에 보이던 대화 기록은 브라우저가 따로 저장해 둔 그림이었다. AI 에게 요청을 보낼 때는 지금 친 한 줄만 실려 갔다. 매 메시지가 완전히 처음 만나는 대화였던 셈이다. "아까 그거" 라고 하면 그럴듯하게 둘러댔기 때문에 한동안 몰랐다.

지난 편에서 만든 텔레그램 봇도 똑같다. 오늘은 그 봇이 어제 한 말을 기억하게 만든다.

AI 는 원래 아무것도 기억하지 않는다

먼저 오해 하나를 치운다. 대화형 AI 는 대화를 기억하지 않는다. 요청 한 번에 넘겨준 것만 본다. 우리가 쓰는 채팅 앱이 기억하는 것처럼 보이는 건, 앱이 지난 대화를 매번 같이 넘겨주기 때문이다.

그러니 기억을 만든다는 건 결국 이 두 가지다.

  • 대화를 어딘가에 적어 둔다
  • 다음 요청 때 적어 둔 것 중 최근 것을 같이 넘긴다

여기서 중요한 결정이 하나 있다. 적어 두는 곳은 서버다. 화면(브라우저)이 아니다. 앞의 사고가 정확히 이것이었다 — 기록이 화면 쪽에만 있으면 사람은 보는데 AI 는 못 본다. 그리고 폰에서 한 얘기를 노트북에서 이어갈 수도 없다.

만들기

지난 편의 bot.js 옆에 파일 하나를 더 만든다. 대화마다 파일 하나, 한 줄에 한 메시지씩 쌓는 방식이다. 데이터베이스는 쓰지 않는다. 처음엔 이걸로 충분하고, 문제가 생기면 파일을 열어 눈으로 볼 수 있다.

// memory.js — 대화를 파일에 쌓고, 매번 최근 것만 꺼내 준다
const fs = require('fs');
const path = require('path');

const DIR = path.join(__dirname, 'memory');
fs.mkdirSync(DIR, { recursive: true });

const fileOf = (chatId) => path.join(DIR, `${chatId}.jsonl`);

// 한 턴(질문+답)을 한 번에 적는다.
function saveTurn(chatId, question, answer) {
  const at = new Date().toISOString();
  const lines = [
    JSON.stringify({ role: 'user', content: question, at }),
    JSON.stringify({ role: 'assistant', content: answer, at }),
  ];
  fs.appendFileSync(fileOf(chatId), lines.join('\n') + '\n');
}

// 최근 대화를 꺼낸다. 개수와 글자 수 둘 다로 자른다.
function recentTurns(chatId, maxMessages = 20, maxChars = 8000) {
  const f = fileOf(chatId);
  if (!fs.existsSync(f)) return [];
  const all = fs.readFileSync(f, 'utf8').trim().split('\n')
    .filter(Boolean).map((l) => JSON.parse(l));
  const out = [];
  let chars = 0;
  for (let i = all.length - 1; i >= 0 && out.length < maxMessages; i--) {
    chars += all[i].content.length;
    if (chars > maxChars) break;
    out.unshift({ role: all[i].role, content: all[i].content });
  }
  while (out.length && out[0].role !== 'user') out.shift();  // 첫 메시지는 사용자여야 한다
  return out;
}

module.exports = { saveTurn, recentTurns };

지난 편의 ask() 는 질문 하나만 받았다. 이제 대화 번호도 받아서, 최근 대화를 앞에 붙여 보낸다.

const { saveTurn, recentTurns } = require('./memory');

async function ask(chatId, question) {
  const messages = [...recentTurns(chatId), { role: 'user', content: question }];
  const res = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: {
      'x-api-key': process.env.ANTHROPIC_API_KEY,
      'anthropic-version': '2023-06-01',
      'content-type': 'application/json',
    },
    body: JSON.stringify({
      model: '<사용할 모델명>',
      max_tokens: 2000,
      // 🔑 현재 시각은 매 요청마다 새로 계산해 넣는다 (아래 함정 2)
      system: `지금은 ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })} (한국 시각)이다.`,
      messages,
    }),
  });
  const data = await res.json();
  const answer = data.content?.[0]?.text;
  if (answer) saveTurn(chatId, question, answer);   // 답을 받았을 때만 적는다 (아래 함정 1)
  return answer ?? '(답을 받지 못했습니다)';
}

그리고 메인 루프에서 부르는 곳 한 줄만 바꾼다.

const answer = await ask(msg.chat.id, msg.text);

함정

1. 질문만 적히고 답이 안 적히면, 다음 요청이 통째로 거부된다

처음엔 질문이 들어오자마자 적고, 답이 오면 답을 적었다. 자연스러워 보인다. 그런데 중간에 요청이 실패하면 파일에 질문만 두 번 연달아 남는다. 이 AI 는 사용자와 AI 의 말이 번갈아 와야 해서, 그 다음 요청부터는 계속 거절당한다. 봇이 갑자기 입을 닫는다.

그래서 위 코드는 답을 받았을 때만 질문과 답을 한꺼번에 적는다. 실패한 질문은 기록에 남지 않는다. 대신 사용자는 다시 물어보면 된다.

2. 비용을 아끼려다 시계가 멈췄다

대화가 길어지면 매번 넘기는 양이 늘고, 그게 곧 비용이다. 그래서 다른 비서 하나에는 "이미 보낸 설정은 다시 보내지 않는" 최적화를 넣었다. 비용은 줄었다.

며칠 뒤 그 비서가 새벽에 이렇게 말했다. "벌써 금요일 새벽 3시네요." 실제로는 5시 47분이었다.

원인은 현재 시각이 '다시 보내지 않는 설정' 덩어리 안에 들어 있었던 것이다. 대화를 시작할 때의 시각이 두 시간 반 넘게 그대로 쓰였다. 성격이나 말투 같은 안 바뀌는 것과, 현재 시각 같은 매번 바뀌는 것을 한 덩어리로 묶어 둔 탓이다.

위 코드에서 시각을 system 에 매번 새로 계산해 넣는 이유가 이것이다. 기억을 넣을 때 같이 정해 두지 않으면, 나중에 최적화가 시간까지 같이 먹는다.

됐는지 확인하는 법

봇에게 기억할 만한 걸 하나 알려 준다. 예를 들면 "내 자전거 자물쇠 번호는 4719야".

그리고 봇을 껐다가 다시 켠다. 이게 핵심이다. 켜 둔 채로 되물으면 프로그램 메모리에 남은 걸 보고 맞힐 수도 있다. 껐다 켜야 파일에서 읽어 온다는 게 증명된다.

다시 켠 뒤 "내 자물쇠 번호 뭐였지?" 라고 묻는다. 4719 가 나오면 성공이다. 대화 파일 memory/<대화번호>.jsonl 을 열어 보면 방금 오간 말이 한 줄씩 쌓여 있다.

이 글의 코드는 어디까지 확인했나

memory.js 는 프로세스 두 개로 나눠 실행해 확인했다. 첫 프로세스에서 사실을 적고 끝낸 뒤, 새 프로세스에서 다음 요청에 실릴 메시지를 만들었을 때 4719 가 들어 있었다. 글자 예산을 넘는 긴 대화에서도 첫 메시지가 사용자 쪽으로 맞춰지는 것까지 봤다.

ask() 는 AI 쪽을 가짜 응답으로 바꿔 끼우고 돌렸다. 최근 대화가 앞에 붙어 세 개의 메시지가 나가는 것, 답을 받으면 질문과 답이 한 쌍으로 적히는 것, 요청이 실패하면 아무것도 적히지 않는 것(함정 1)까지 확인했다.

실제 AI 에 요청을 보내 보지는 않았다. 요청 형식은 지난 편과 같고 system 한 줄이 늘었을 뿐이지만, 실행해 보지 않은 것은 실행해 보지 않았다고 적어 둔다.

여기까지가 가장 작은 기억이다

최근 20개, 8,000자. 이 정도면 "아까 그거" 는 알아듣는다. 하지만 지난달에 알려 준 내 생일은 이미 잘려 나갔다.

그래서 실제로 쓰는 비서는 기억을 층으로 나눠 둔다. 절대 안 바뀌는 것, 나에 대한 사실, 쌓여 가는 기억, 오늘의 기록 — 이걸 따로 두고, 무엇을 오래 남길지 기준을 정한다. 그 이야기는 이 연재를 묶어 만들고 있는 따라하기 책에 이어 쓴다.

다음 편 — 봇이 말은 하는데 할 줄 아는 게 없다. 날씨를 찾아보거나 파일을 여는 도구를 붙이는 방법을 다룬다.

이 연재의 첫 편: AI 비서를 직접 만든다 ① — 텔레그램으로 말을 걸다