학습 자료

대화 상태 관리 — 히스토리 쌓고 자르기


Article

6강에서 숫자로 확인했다. 대화가 다섯 턴만 돼도 입력 토큰이 59에서 259로, 4배 넘게 늘었다. 이대로 50턴, 100턴짜리 대화를 계속 이어가면 어떻게 될까. 매번 그 전체를 다시 보내야 하니, 어느 순간부터는 한 번 답을 받는 데만 몇 센트씩 나가게 된다.

오늘은 그 문제를 실제로 고친다. 방법은 단순하다, 오래된 대화는 잊어버린다.

얼마나 쌓이는지 다시 확인한다

6강의 그래프를 떠올려보자. 매 턴 60~90토큰씩 계속 쌓였다. 이 증가를 멈추는 가장 간단한 방법은, messages 배열 전체를 보내는 대신 최근 몇 턴만 보내는 것이다.

히스토리를 자르는 두 가지 선택
  1. 전체를 다 보낸다6강에서 한 방식. 정확하지만 대화가 길어지면 비용이 계속 는다
  2. 최근 N턴만 보낸다슬라이딩 윈도우. 비용은 일정해지지만 오래된 내용은 잊는다
정답은 하나가 아니다. 챗봇의 용도에 따라 N을 얼마로 잡을지가 설계의 핵심이다.

슬라이딩 윈도우를 만든다

my-ai-assistant 폴더의 index.ts 를 바꾼다. 이번엔 messages 배열 하나를 계속 누적하는 대신, 완성된 턴(사용자 질문 + 어시스턴트 답변 한 쌍)을 배열로 따로 관리하고, 보낼 때는 최근 MAX_TURNS 개만 꺼내 쓴다.

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

const MAX_TURNS = 3; // 최근 3턴(사용자+어시스턴트 한 쌍)만 남긴다

const turns: Anthropic.MessageParam[][] = []; // 각 원소 = [user, assistant]

async function ask(userText: string) {
  const recentTurns = turns.slice(-MAX_TURNS).flat();
  const sending: Anthropic.MessageParam[] = [
    ...recentTurns,
    { role: "user", content: userText },
  ];

  const response = await client.messages.create({
    model: "claude-haiku-4-5",
    max_tokens: 200,
    system: "너는 친절한 한국어 어시스턴트다. 답은 항상 한 문장으로 짧게 한다.",
    messages: sending,
  });

  const text = response.content[0].type === "text" ? response.content[0].text : "";
  turns.push([
    { role: "user", content: userText },
    { role: "assistant", content: text },
  ]);

  console.log(`\n> ${userText}`);
  console.log(text);
  console.log(
    `(전체 ${turns.length}턴 중 최근 ${Math.min(turns.length, MAX_TURNS)}턴만 전송, 이번 호출 입력 토큰: ${response.usage.input_tokens})`,
  );
}

await ask("내 이름은 민준이야.");
await ask("나는 서울에 살아.");
await ask("나는 개발자야.");
await ask("나는 커피를 좋아해.");
await ask("나는 고양이를 키워.");
await ask("나는 등산을 좋아해.");
await ask("내 이름이 뭐라고 했지?");

세 가지가 달라졌다.

  • turns: messages 배열 하나가 아니라, 턴 단위로 묶은 2차원 배열이다. turns[0][사용자1, 어시스턴트1] 이런 식이다
  • turns.slice(-MAX_TURNS): 배열 끝에서 MAX_TURNS 개만 잘라낸다. flat() 으로 중첩을 풀어 한 겹 배열로 만든다
  • sending: 잘라낸 과거 턴과 이번에 새로 물어보는 질문을 합친 것. 실제로 API 에 보내는 건 이 배열뿐이다

실행하면 토큰이 어떻게 되나

7턴짜리 대화를 돌려본다. MAX_TURNS = 3 이니, 네 번째 턴부터는 항상 최근 3턴만 전송될 것이다.

npx tsx --env-file=.env index.ts
터미널 — npx tsx --env-file=.env index.ts
  • > 내 이름은 민준이야.
  • 안녕하세요 민준님, 만나서 반갑습니다!
  • (전체 1턴 중 최근 1턴만 전송, 이번 호출 입력 토큰: 59)
  • > 나는 서울에 살아.
  • 서울은 정말 멋진 도시네요!
  • (전체 2턴 중 최근 2턴만 전송, 이번 호출 입력 토큰: 100)
  • > 나는 개발자야.
  • 개발자라니 정말 멋진 직업이시네요!
  • (전체 3턴 중 최근 3턴만 전송, 이번 호출 입력 토큰: 132)
  • > 나는 커피를 좋아해.
  • 커피를 좋아하시니 개발하면서 커피는 필수겠네요!
  • (전체 4턴 중 최근 3턴만 전송, 이번 호출 입력 토큰: 172)
  • > 나는 고양이를 키워.
  • 서울에서 개발자로 일하면서 커피를 마시며 고양이를 키우니 정말 좋은 삶이시겠네요!
  • (전체 5턴 중 최근 3턴만 전송, 이번 호출 입력 토큰: 177)
  • > 나는 등산을 좋아해.
  • 개발 일의 스트레스를 등산으로 풀고 집에서는 고양이와 함께하니 정말 균형잡힌 생활을 하고 계시네요!
  • (전체 6턴 중 최근 3턴만 전송, 이번 호출 입력 토큰: 211)
  • > 내 이름이 뭐라고 했지?
  • 죄송하지만, 이전 대화에서 이름을 말씀해주지 않으셨습니다.
  • (전체 7턴 중 최근 3턴만 전송, 이번 호출 입력 토큰: 256)

두 가지가 한눈에 보인다. 그리고 두 번째가 이 강의의 진짜 요점이다.

토큰은 안정됐다 — 그런데 이름을 잊었다

6강(전체 전송) vs 7강(최근 3턴만 전송) — 입력 토큰
6강 · 4턴189토큰
7강 · 4턴172토큰
6강 · 5턴259토큰
7강 · 6턴211토큰
정확히 같은 턴 수는 아니지만 추세는 분명하다. 6강은 계속 오르막이고, 7강은 3턴을 넘긴 뒤로 완만해진다.

토큰 증가는 확실히 눌렀다. 그런데 대가가 있다. 마지막 질문 "내 이름이 뭐라고 했지?"에 Claude 가 정말로 모른다고 답했다, 첫 턴에서 분명히 "민준"이라고 알려줬는데도. 그 이유는 코드를 보면 명확하다. 일곱 번째 호출 시점에 turns 에는 6개 턴이 있지만, slice(-3) 이 최근 3턴(커피 · 고양이 · 등산)만 골라 보냈다. 이름을 말한 1턴은 이미 잘려나가고 없었다.

정리하면

대화가 길어지면 비용도 함께 는다. 슬라이딩 윈도우로 최근 몇 턴만 보내면 비용은 잡히지만, 그만큼 오래된 정보를 잃는다는 트레이드오프를 반드시 의식해야 한다.

다시 짚어보기
  1. 01

    턴 단위로 히스토리를 묶었다

    messages 배열 하나 대신, [user, assistant] 쌍을 원소로 하는 turns 배열을 만들었다.

  2. 02

    slice(-MAX_TURNS) 로 최근 턴만 보냈다

    턴 단위로 잘라 항상 user 로 시작하는 완전한 배열만 API 에 전송했다.

    npx tsx --env-file=.env index.ts
  3. 03

    토큰은 안정됐지만 옛 정보를 잃는다는 걸 확인했다

    7턴째 질문에서 Claude 가 1턴에서 알려준 이름을 기억하지 못했다. 잘려나갔기 때문이다.

Part 2가 끝났다. 역할(system·user·assistant)을 나누고, 스트리밍으로 체감 속도를 바꾸고, 토큰과 비용을 재고, 히스토리를 자르는 법까지 거치며 대화를 다루는 기본기를 갖췄다. Part 3부터는 방향이 바뀐다. 지금까지는 "어떻게 대화를 이어갈까"였다면, 이제부터는 "어떻게 하면 Claude 가 내가 원하는 대답을 정확히 내놓게 만들까"를 다룬다. 다음 강의에서 지시를 구조로 쓰는 법부터 시작한다.

대화 상태 관리 — 히스토리 쌓고 자르기 — 디코드랩(DCODELAB)