학습 자료

메모리 — 상태 있는 에이전트 (Part 6 마무리)


Article

21강에서 만든 agent.ts를 다시 실행해보자. 검색 도구와 계산기 도구를 쥐어주고, Claude 가 스스로 몇 번을 어떤 순서로 부를지 계획하는 걸 확인했다. 그런데 ask() 를 다시 호출하면 어떻게 될까. 직접 확인한다.

npx tsx agent.ts "그럼 10번 했으면 얼마야?"

이 질문 하나만 뚝 떼어 실행했다. "그럼" 이 가리키는 게 뭔지는 이 실행에겐 아무 정보도 없다.

터미널 (21강 agent.ts, 단독 실행)
  • 질문: "그럼 10번 했으면 얼마야?"
  • [1차 응답] stop_reason: end_turn
  • [최종 답]
  • 죄송하지만, 어떤 항목을 10번 했을 때의 금액을 계산해야 하는지 알 수 없습니다.
  • 좀 더 구체적으로 알려주시면 도움을 드릴 수 있습니다:
  • - 어떤 규정이나 정책과 관련된 내용인가요?
  • - 단가나 기본 금액이 얼마인가요?
  • [usage] input: 1874, output: 201

도구조차 부르지 않았다 (stop_reason 이 첫 응답부터 end_turn). 당연하다. ask() 는 매번 messages: [{ role: "user", content: question }] 하나로 새 대화를 시작한다. 방금 전에 무슨 얘기를 했는지는 이 함수 안 어디에도 남아있지 않다.

21강 vs 오늘
  1. 21강 agent.tsask() 를 부를 때마다 대화가 처음부터 시작된다
  2. 20강 chat.ts질문·답변만 history 배열에 쌓아 다음 턴에 이어붙였다
  3. 22강 — 오늘도구 쓰는 에이전트에 20강의 히스토리 관리를 결합한다
새 개념은 없다. 21강 도구 실행 루프 + 20강 히스토리 관리, 이미 있는 두 조각을 합칠 뿐이다.

히스토리는 "질문·최종답"만 쌓는다

20강에서 정한 원칙 그대로다. 도구 호출·도구 결과까지 히스토리에 쌓으면 턴이 늘수록 메시지가 기하급수로 불어난다. 그래서 매 턴, 실행 루프가 다 끝난 뒤의 최종 텍스트만 남긴다.

interface Turn {
  role: "user" | "assistant";
  content: string;
}

async function askOnce(history: Turn[], question: string): Promise<void> {
  const messages: Anthropic.MessageParam[] = [
    ...history.map((t) => ({ role: t.role, content: t.content })),
    { role: "user", content: question },
  ];

  // ...21강과 같은 tool_use 루프 (search_company_docs · calculator) 그대로...

  let finalText = "";
  for (const block of response.content) {
    if (block.type === "text") finalText += block.text;
  }

  history.push({ role: "user", content: question });
  history.push({ role: "assistant", content: finalText });
}

바뀐 건 함수 시그니처에 history: Turn[] 가 늘고, 맨 앞에서 messages 배열을 만들 때 그 히스토리를 펼쳐 넣고, 맨 끝에서 이번 턴 결과를 히스토리에 쌓는 것뿐이다. 루프 안쪽의 도구 호출 분기, search_company_docs·calculator 실행은 21강과 한 글자도 다르지 않다.

시스템 프롬프트에도 한 줄을 더한다.

const SYSTEM_PROMPT = `...(21강과 동일)...
이전 대화에서 이미 확인한 숫자나 사실은 다시 검색하지 않고 그대로 활용해도 된다.`;

대화형으로 감싼다

20강의 readline 큐 패턴을 그대로 가져와 main() 을 만든다. 새 질문이 들어올 때마다 history 를 넘겨 askOnce 를 부르고, 그 함수가 알아서 히스토리를 갱신한다.

async function main() {
  const history: Turn[] = [];
  const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: "You> " });
  rl.prompt();

  const queue: string[] = [];
  let processing = false;

  async function drainQueue() {
    if (processing) return;
    processing = true;
    while (queue.length > 0) {
      const question = queue.shift()!;
      if (!question || question === "exit") process.exit(0);
      await askOnce(history, question);
      rl.prompt();
    }
    processing = false;
  }

  rl.on("line", (line) => { queue.push(line.trim()); void drainQueue(); });
}

실행 — 두 번째 질문이 도구를 다시 안 부른다

lesson-21docs·lib.ts·vectors.json 을 그대로 가져와 lesson-22 를 만들고, 21강과 같은 질문 두 개를 이어서 던진다.

npx tsx agent.ts
You> 야근을 5번 했어. 야근 식대 한도로 총 얼마까지 정산받을 수 있어?
You> 그럼 10번 했으면 얼마야?
터미널 — 1턴
  • 질문: "야근을 5번 했어. 야근 식대 한도로 총 얼마까지 정산받을 수 있어?"
  • [1차 응답] stop_reason: tool_use
  • [실행] search_company_docs(query: "야근 식대 한도")
  • [2차 응답] stop_reason: tool_use
  • [실행] calculator({"operation":"multiply","a":20000,"b":5}) = 100000
  • [3차 응답] stop_reason: end_turn
  • [최종 답]
  • 야근을 5번 했을 때 총 정산 가능한 금액: 100,000원
  • (1회당 2만 원 × 5회 = 10만 원)
  • [usage] input: 2736, output: 57
터미널 — 2턴 (검색 없이 바로 계산)
  • 질문: "그럼 10번 했으면 얼마야?"
  • [1차 응답] stop_reason: tool_use
  • [실행] calculator({"operation":"multiply","a":20000,"b":10}) = 200000
  • [2차 응답] stop_reason: end_turn
  • [최종 답]
  • 야근을 10번 했을 때 총 정산 가능한 금액: 200,000원
  • (1회당 2만 원 × 10회 = 20만 원)
  • [usage] input: 2123, output: 57

방금 전 실패했던 바로 그 질문("그럼 10번 했으면 얼마야?")이 이번엔 한 번의 도구 호출(calculator)만으로 정답을 냈다. search_company_docs 를 다시 부르지 않았다. 1턴에서 검색해 얻은 "야근 식대 2만 원"이라는 숫자가 history 를 통해 이미 전달돼 있었기 때문이다. 앞서 안 되던 질문이 되게 된 것도, 21강엔 없던 검색을 한 번 아낀 것도 전부 이 history 배열 하나가 한 일이다.

같은 질문, 메모리 유무 차이

실행 — 새 주제를 물으면 다시 검색한다

메모리가 있다고 해서 검색을 무조건 생략하는 건 아니어야 한다. 완전히 다른 주제를 이어서 물어 확인한다.

You> 야근을 5번 했어. 야근 식대 한도로 총 얼마까지 정산받을 수 있어?
You> 원격근무는 주에 며칠까지 가능해?
터미널 — 2턴 (새 주제, 다시 검색)
  • 질문: "원격근무는 주에 며칠까지 가능해?"
  • [1차 응답] stop_reason: tool_use
  • [실행] search_company_docs(query: "원격근무 주 며칠")
  • [2차 응답] stop_reason: end_turn
  • [최종 답]
  • 원격근무는 주 3일까지 가능합니다.
  • [usage] input: 2633, output: 177

야근 식대 이야기와 아무 관련 없는 질문이라, 히스토리에 야근 얘기가 있어도 검색을 건너뛰지 않고 새로 불렀다. 메모리는 검색을 무조건 아끼는 장치가 아니라 이미 아는 걸 또 물을 때만 아끼는 장치고, 그 판단도 결국 Claude 의 몫이다.

history 배열엔 실제로 뭐가 들어있나

말로만 설명하지 말고 직접 찍어보자. 방금 1턴·2턴(야근 5번 → 그럼 10번)을 실행한 뒤 history 를 그대로 출력하는 스크립트다.

const history: Turn[] = [];
await askOnce(history, "야근을 5번 했어. 야근 식대 한도로 총 얼마까지 정산받을 수 있어?");
await askOnce(history, "그럼 10번 했으면 얼마야?");
console.log(JSON.stringify(history, null, 2));
npx tsx inspect-history.ts
터미널 — history 배열
  • [
  • {
  • "role": "user",
  • "content": "야근을 5번 했어. 야근 식대 한도로 총 얼마까지 정산받을 수 있어?"
  • },
  • {
  • "role": "assistant",
  • "content": "...야근 식대 2만 원 × 5회 = 10만 원..."
  • },
  • {
  • "role": "user",
  • "content": "그럼 10번 했으면 얼마야?"
  • },
  • {
  • "role": "assistant",
  • "content": "...야근 10번이면 계산상 200만 원이지만, 사내 규정에서 명시된 월별 한도가 있는지 추가 확인이 필요합니다..."
  • }
  • ]

문서 원문·도구 호출 흔적은 하나도 없다. 정확히 설계한 대로 질문과 최종 답 텍스트만 들어있다. 그런데 두 번째 답을 자세히 보면 "200만 원"이라고 적혀 있다. 2만 원 곱하기 10은 20만 원인데, 10배 틀렸다.

이런 걸 감추고 "메모리를 붙이니 잘 됩니다"로 끝냈다면 반쪽짜리 강의였을 것이다. 실측이 아니었다면 이 실패는 그냥 안 보이고 지나갔을 것이다. 정확히 20강에서 검색 실패를 실측으로 잡아낸 것과 같은 이유로, 오늘도 코드를 실제로 여러 번 돌려보고서야 이 함정을 알게 됐다.

턴이 늘수록 토큰은 어떻게 늘었나

앞의 두 실행(검색 생략 사례)의 usage 를 모아보면 20강과 같은 패턴이 보인다.

메모리 있는 에이전트 — 턴별 usage
1야근 5번, 식대 한도는?search + calculator (2회)273657
2그럼 10번 했으면?calculator (1회, 검색 생략)212357

2턴이 도구를 하나 덜 불렀는데도 input 토큰은 오히려 줄지 않고 비슷한 수준이다. 1턴의 질문·답변이 히스토리로 들어간 몫이 검색 한 번 줄어든 몫과 상쇄됐기 때문이다. 20강에서 배운 "히스토리는 계속 쌓인다" 원칙이 도구 쓰는 에이전트에도 똑같이 적용된다.

지금까지 만든 것

my-ai-assistant/ (ai-lab-b, lesson-22)
  • my-ai-assistant
    • docs/사내 규정 문서 3개
    • lib.ts청킹 + 임베딩 + 코사인 유사도
    • vectors.json저장된 벡터
    • agent.tssearch_company_docs + calculator + history 메모리, readline 대화형 (오늘)
    • inspect-history.tshistory 배열 내용을 직접 찍어보는 디버깅 스크립트 (오늘)
    • .env.example
    • package.json

정리하면

21강의 도구 실행 루프에 20강의 히스토리 관리를 그대로 결합해, 상태를 유지하는 에이전트를 완성했다. 메모리 덕분에 이미 확인한 사실은 도구 재호출 없이 재사용했지만, 그 판단이 계산까지 암산으로 건너뛰는 실패로 이어지는 것도 실측으로 확인했다.

다시 짚어보기
  1. 01

    21강 agent.ts 는 매 호출마다 새 대화였다

    "그럼 10번 했으면 얼마야?" 단독 실행은 도구조차 못 부르고 되물었다.

  2. 02

    20강의 히스토리 원칙을 그대로 결합했다

    질문·최종답만 history 배열에 쌓고, 도구 호출·결과는 매 턴 새로 만든다.

  3. 03

    메모리 덕분에 도구 재호출을 아꼈다

    같은 후속 질문이, 검색 없이 calculator 한 번만으로 정확히 20만 원을 냈다.

    npx tsx agent.ts
  4. 04

    새 주제는 메모리가 있어도 다시 검색했다

    원격근무처럼 무관한 질문에는 히스토리와 상관없이 새로 search_company_docs 를 불렀다.

  5. 05

    메모리가 암산 실패로 번지는 사례도 실측으로 잡았다

    history 배열을 직접 찍어보니, 같은 질문인데 실행에 따라 calculator 없이 답해 10배 틀린 계산을 낸 경우가 있었다.

Part 6이 끝났다. 21강에서 검색을 도구로 감싸 Claude 에게 계획을 맡겼고, 오늘 거기에 메모리를 더해 상태 있는 에이전트로 마무리했다. 도구 하나(1215강)에서 시작해 검색(1620강)을 거쳐 여러 도구를 스스로 계획해 잇는(21~22강) 에이전트까지, Course B 의 핵심 능력이 여기서 완성됐다.

다음 23강부터는 Part 7, 프로덕션이다. 지금까지는 전부 터미널 안에서만 대화했다. 23강에서는 이 에이전트를 Next.js API Route 뒤에 세우고, 답이 오는 대로 화면에 흘려보내는 스트리밍 UI를 붙인다. 5강에서 SDK 스트리밍을 처음 배웠던 그 개념이, 이번엔 브라우저까지 이어진다.

메모리 — 상태 있는 에이전트 (Part 6 마무리) — 디코드랩(DCODELAB)