AI 메모리 완벽 해설: 모델은 잊고, 시스템이 기억한다
요약
LLM 자체는 상태를 저장하지 못하는(stateless) 함수이며, '기억'은 시스템 레벨에서 관리되어야 합니다. 대화 기록을 유지하려면 앱 백엔드가 모든 메시지를 데이터베이스에 저장하고, 새로운 호출 시 전체 컨텍스트를 모델에게 다시 전송해야 합니다. 이 방식은 토큰 길이 증가에 따라 비용과 작업량이 급격히 늘어나는 문제가 있습니다.
핵심 포인트
- LLM은 상태가 없는(stateless) 함수로 작동합니다.
- 기억은 시스템 레벨에서 DB를 통해 관리됩니다.
- 대화 기록을 유지하려면 전체 컨텍스트를 매번 전송해야 합니다.
- 컨텍스트 길이가 길어지면 비용과 작업량이 급증합니다.
저는 매일 AI 도구를 사용합니다. 얼마 전 몇 일 전에 남겨둔 대화를 다시 열어 후속 질문을 입력했는데, 우리가 멈췄던 바로 그 지점부터 대화가 이어졌습니다.
그래서 저는 스스로에게 이것이 어떻게 작동하는지 설명하려 했지만, 할 수 없었습니다.
그 메모리는 어디에 저장되는 걸까요? 사람들은 LLM이 무언가를 '캐시(cache)'한다고 계속 말합니다. 정확히 무엇을 캐싱하는 건가요? 그리고 모델은 지난주 대화를 어떻게 기억할 수 있나요?
그래서 저는 자리에 앉아 단계별로 제대로 배웠습니다. 이 글은 제가 가졌으면 좋았을 경로입니다: '모델이 실제로 무엇을 보는가?'에서 '나만의 AI 기능을 위해 메모리를 어떻게 설계할까?'까지.
요약하자면: 모델은 아무것도 기억하지 못합니다. 시스템이 기억하는 것입니다. 모든 메모리 기법은 하나의 질문에 대한 답입니다: 이번 호출의 컨텍스트(context)에는 무엇이 들어가야 하는가?
1단계: 모델은 상태를 가지지 않는 함수 (stateless function)
언어 모델은 한 가지 일만 합니다. 토큰 시퀀스를 받으면, 다음 토큰을 예측하는 것입니다. 텍스트 생성은 그 주변을 도는 루프에 불과합니다:
tokens = tokenize(prompt)
while True:
next_token = sample(model(tokens)) # 전체 시퀀스를 읽음
...
모델의 가중치(weights)는 훈련 후 고정됩니다. 채팅에서 당신이 하는 어떤 말도 그것들을 바꾸지 않습니다. 그리고 모델은 오직 **컨텍스트 창(context window)**에 들어갈 수 있는 양, 즉 단일 호출에서 읽을 수 있는 최대 토큰 수만큼만 볼 수 있습니다.
그럼 채팅은 어떻게 작동할까요? 세 번의 API 호출이 이를 명확하게 보여줍니다:
import anthropic
client = anthropic.Anthropic()
...
모델은 제 이름을 기억하지 못했습니다. 제가 다시 보내주었기 때문입니다.
모델에 도달하기 전에, 그 messages 배열은 특수 역할 마커와 함께 하나의 긴 텍스트 조각으로 평탄화(flattened)됩니다. 대략 다음과 같습니다:
<|user|>My name is Shayan.<|end|>
<|assistant|>Nice to meet you, Shayan!<|end|>
<|user|>What's my name?<|end|>
...
모델은 단순히 텍스트를 계속 이어갈 뿐입니다.
이제 제 원래 질문에 대한 답을 얻었습니다. 해당 채팅방을 다시 열었을 때, 앱이 모든 메시지를 데이터베이스에 저장하고 있었습니다. 새로운 메시지를 보낼 때마다, 전체 대화 내용을 불러와서 그 내용 전부와 제가 보낸 새 메시지까지 함께 전송했습니다. 모델은 마치 처음인 것처럼 첫 단어부터 전체 대화를 읽었습니다.
┌──────┐ 새로운 메시지 ┌─────────────────┐ 전체 기록 + 새로운 메시지 ┌──────────────────┐
│ 당신 │ ────────────▶ │ 앱 백엔드 │ ───────────────────────────▶ │ 상태 비저장 모델 (Stateless model) │
└──────┘ │ (메시지 DB) │ ◀────────── 답변 ────────── └──────────────────┘
...
이 결과는 이후 모든 것에 중요한 영향을 미칩니다. 만약 한 턴마다 약 500 토큰이 추가된다면, 100번째 턴은 약 50,000 토큰을 전송합니다. 대화 전체에 걸친 총 작업량은 길이의 제곱에 비례하여 증가합니다. 캐싱(Caching)과 압축(Compaction)은 바로 이 때문에 다음 주제로 다루어집니다.
단계 2: 메모리가 존재할 수 있는 세 곳
모델 자체가 상태를 저장하지 못하는 (stateless) 경우, '기억'은 어디에 있을까요? 제가 유용하다고 생각한 모델에는 세 가지 계층이 있습니다:
그것은 이 포스트의 나머지 부분이 계속 돌아오는 규칙을 우리에게 알려줍니다:
모든 메모리 시스템은 하나의 질문에 답합니다: 이번 호출(call)을 위해 외부 계층에서 컨텍스트로 무엇이 이동하는가?
슬라이딩 윈도우(Sliding windows), 요약(summaries), RAG, 그리고 메모리 도구들은 모두 이 동일한 질문에 대한 다른 답변들입니다. 이것이 바로 사람들이 현재 '컨텍스트 엔지니어링 (context engineering)'이라고 부르는 것입니다.
캐싱(caching)이 그 표에 없는 것을 알아차릴 수 있습니다. 그것은 의도된 것입니다.
3단계: KV 캐시가 실제로 무엇인지
이 부분이 제가 가장 오랫동안 잘못 이해했던 부분이었으므로, 천천히 설명하겠습니다.
모든 토큰이 표의 행(row)이 됩니다
모델이 토큰을 읽을 때, 그에 대해 몇 개의 벡터를 계산합니다. 이 중 두 개가 중요합니다:
- 키(key, K): 이 토큰이 무엇인지 나타내는 것, 인덱스의 레이블과 같습니다.
- 값(value, V): 이 토큰이 담고 있는 정보입니다.
문장 _"Shayan writes code. He loves Python."_을 아주 작게 만든 2차원 벡터로 예시를 들어보면, 첫 번째 토큰들은 다음과 같습니다:
| 토큰 | K (레이블) | V (내용) |
|---|---|---|
| Shayan | [1, 0] (사람) | Shayan에 대한 정보 |
| ... |
실제 벡터는 수천 개의 차원을 가지고 있으며, 모델은 그 의미를 스스로 학습합니다. 아이디어는 같습니다.
새로운 토큰이 표를 검색합니다
토큰 _"He"_가 도착하면, 누가
저장되는 양이 상당합니다. 프롬프트가 1,000 토큰이고 모델이 100 토큰을 생성한다고 가정해 봅시다:
| 계산된 행 수 | |
|---|---|
| 캐시 없음 (Without a cache) | 1,000 + 1,001 + … + 1,099 ≈ 105,000 |
| KV 캐시 사용 시 (With a KV cache) | 1,000 토큰을 한 번 계산(이것을 _prefill_이라고 부름), 이후 단계마다 1개씩 계산 ≈ 1,100 |
실제 모델은 레이어당 이러한 테이블 중 하나를 유지하므로 캐시가 커집니다. 16비트 정밀도의 Llama-3-70B 스타일 모델의 경우 토큰당 약 320 KB이며, 이는 100K 토큰 프롬프트에 대해 캐시만으로도 대략 32 GB의 GPU 메모리가 필요하다는 것을 의미합니다. 이것이 바로 LLM 서비스에서 GPU 메모리가 주요 병목 지점인 이유입니다.
이는 가격 책정 세부 사항을 설명하기도 합니다. _Prefill_은 모든 프롬프트 토큰을 병렬로 처리하며, 이는 GPU가 매우 잘하는 작업입니다. _Decode_는 출력을 한 번에 하나의 토큰씩 생성하면서 매 단계마다 가중치와 캐시를 읽습니다. 이것이 출력 토큰 비용이 입력 토큰 비용보다 더 많이 드는 큰 이유 중 하나입니다.
캐시는 메모리가 아니다
KV 캐시는 새로운 정보를 추가하지 않습니다. 그것을 삭제해도 토큰으로부터 다시 구축할 수 있습니다. 잃는 것은 시간과 돈뿐입니다. 이것은 데이터베이스 인덱스나 구현된 뷰(materialized view)와 같습니다.
저는 처음에 이 부분을 잘못 이해했습니다. '캐싱'이 제공업체가 반복되는 질문에 대한 답변을 저장한다는 의미라고 생각했기 때문입니다. 그것은 _응답 캐시(response cache)_이며, 사용자가 자체 앱에서 구축할 수 있는 다른 개념이고, 오래된 답변을 반환할 수도 있습니다. KV 캐싱과 프롬프트 캐싱의 경우, 모든 요청이 여전히 모델에 도달하며, 모델은 매번 새로운 답변을 작성합니다. 재사용되는 유일한 것은 입력 읽기(reading) 작업입니다.
4단계: 프롬프트 캐싱은 요청 간에 테이블을 유지한다
채팅의 연속된 두 턴을 살펴보겠습니다:
요청 1: [system | msg1]
요청 2: [system | msg1 | reply1 | msg2]
요청 2는 요청 1과 정확히 동일한 토큰으로 시작하므로, 테이블의 첫 번째 행들도 완전히 같습니다.
일반적으로 KV 캐시는 요청이 끝나면 폐기됩니다. **프롬프트 캐싱(Prompt caching)**은 제공업체가 이를 짧은 시간 동안 보관하여, 동일한 토큰으로 시작하는 다음 요청이 재계산 단계를 건너뛸 수 있게 합니다.
접두사 규칙 (The prefix rule)
프롬프트의 무언가가 변경될 때 어떤 행들이 살아남을까요? 접두사 합계(prefix sum)가 이를 명확하게 보여줍니다:
array: [3, 5, 2, 7, 1]
prefix sum: [3, 8, 10, 17, 18]
...
각 셀은 오직 자신 앞에 있는 요소들에만 의존합니다. 변경되기 전의 셀들은 아무 일도 일어나지 않았다는 것을 압니다. 하지만 그 이후의 모든 셀은 틀립니다. KV 테이블(Key-Value table)도 같은 방식으로 작동합니다. 이는 오래된 git 커밋을 수정하는 것과 같습니다: 나중에 생성된 해시값은 모두 바뀌지만, 더 이전에 생성된 것은 그대로 유지됩니다.
캐시는 오직 첫 토큰부터 시작하여 토큰 단위로 동일한 접두사에 대해서만 도움이 됩니다.
공백 하나, 재배열된 JSON 키, 또는 변경된 숫자 하나만으로도 그 이후의 모든 것을 무효화시키기에 충분합니다.
따라서 프롬프트를 안정적인 순서에서 불안정한 순서로 구성하세요
┌──────────────────────────────┐
│ 1. Tool definitions │ ← 거의 변경되지 않음
│ 2. System prompt │
...
흔한 실수는 f"Current time: {datetime.now()}"를 시스템 프롬프트의 맨 위에 두는 것입니다. 요청마다 새로운 접두사가 생기므로 캐시가 절대 적중하지 않습니다.
이를 시스템 프롬프트의 _끝_으로 옮겨도 해결되지 않는데, 그 이유는 전체 대화 기록이 시스템 프롬프트 뒤에 오기 때문입니다. 올바른 위치는 새로운 사용자 메시지 안에 있습니다:
user_msg = f"<context>Today is {date.today()}</context>\n\n{user_text}"
이 메시지는 기록의 일부가 되어 다시는 변경되지 않습니다. 그리고 모델이 날짜만 필요하다면, 초(seconds)를 보내지 마세요.
다른 조용한 캐시 무효화 요소들로는 요청 간에 순서가 다른 도구 목록, 불안정한 키 순서로 직렬화된 JSON, 공유 지침보다 먼저 배치되는 사용자별 데이터 등이 있습니다.
코드로 보면 어떤 모습인가요
Anthropic API를 사용할 경우, cache_control로 중단점을 표시하고 usage 필드를 확인하여 무슨 일이 일어났는지 확인할 수 있습니다:
resp = client.messages.create(
model=MODEL,
max_tokens=1024,
...
작성 시점(2026년 10월) 기준으로 Anthropic의 문서에 따르면 캐시(cache)는 기본적으로 5분 동안 유지되며 사용될 때마다 무료로 새로고침되고, 선택적으로는 수명 기간을 1시간으로 설정할 수 있습니다. 5분짜리 캐시에 데이터를 쓰는 비용은 일반 입력 가격의 1.25배가 들며, 읽는 비용은 약 0.1배(일부 최신 모델에서는 그보다 적습니다). 매우 짧은 프롬프트는 아예 캐시되지 않으며, 대화가 진행됨에 따라 브레이크포인트(breakpoint)를 자동으로 앞으로 이동시키는 자동 모드도 있습니다. OpenAI는 1,024 토큰을 초과하는 프롬프트에 대해 자동으로 캐싱합니다. 이 수치들은 변동되므로, 이를 기반으로 시스템을 구축하기 전에는 반드시 최신 문서를 확인해야 합니다.
노력을 기울일 가치가 있는 이유
만약 50,000 토큰짜리 문서를 가지고 20개의 질문을 한다고 가정해 봅시다:
| 입력 토큰 비용 | |
|---|---|
| 캐시 없음 | 20 × 50K = 1,000K |
| 캐시 사용 시 | 50K × 1.25 (첫 쓰기) + 19 × 50K × 0.1 (읽기) ≈ 157K |
이는 입력 비용 면에서 약 84% 저렴하며, 문서가 매 질문마다 재독해되지 않기 때문에 첫 토큰이 훨씬 빨리 도착합니다.
원래의 질문으로 돌아가서
며칠 후에 그 채팅방에 다시 돌아왔을 때, 캐시는 이미 사라진 상태였습니다. 캐시는 며칠이 아니라 몇 분 동안만 지속됩니다. 하지만 앱의 데이터베이스에 실제 메모리인 토큰 자체가 남아 있었기 때문에 채팅은 계속될 수 있었습니다. 테이블은 그것들로부터 재구축되었습니다. 이것은 더 느리고 비용도 많이 들지만, 답변 자체는 동일합니다.
5단계: 컨텍스트가 가득 찼을 때
컨텍스트(Context)에는 세 가지 종류의 한계가 있습니다:
- 하드(Hard): 컨텍스트 창(context window). 이를 넘어서면 API가 오류를 반환합니다.
- 경제적(Economic): 비용과 첫 토큰까지 걸리는 시간(time-to-first-token)이 토큰마다 증가합니다.
- 품질(Quality): 컨텍스트가 늘어날수록 모델은 정보를 사용하는 능력이 떨어집니다. 연구에 따르면, 모델들은 긴 컨텍스트의 시작 부분과 끝 부분에 있는 정보는 중간 부분보다 더 잘 사용한다는 것이 밝혀졌습니다(Lost in the Middle). 실제로 유용한(useful) 컨텍스트는 광고된 것보다 작습니다.
옵션 A: 슬라이딩 윈도우와 그 숨겨진 함정
가장 간단한 접근 방식은 최근 메시지 20개만 유지하는 것입니다. 비용은 저렴하지만, 그보다 오래된 내용은 완전히 사라집니다. 메시지가 25개가 된 후 "제 이름이 무엇인가요?"라고 물어보면 다시 1단계로 돌아갑니다.
또한 놓치기 쉬운 함정이 있습니다. 히스토리의 시작 부분을 살펴보세요:
turn 21: [system] [m2 m3 m4 … m21]
turn 22: [system] [m3 m4 m5 … m22]
↑ 히스토리의 첫 번째 토큰이 변경됨
시스템 프롬프트는 여전히 캐시를 적중하지만, 전체 히스토리는 매 턴마다 누락됩니다. 채팅에서 히스토리는 프롬프트의 대부분을 차지하므로, 적중률은 거의 0%로 떨어집니다.
해결책은 배치(batches) 단위로 제거하는 것입니다. 창 크기를 20개에서 40개 메시지로 늘린 다음, 한 번에 다시 20개로 줄이세요. 자르기 사이에는 히스토리가 끝 부분에서만 성장하므로 캐시 적중을 유지합니다. 매 턴마다 누락되는 대신, 20턴마다 한 번의 누락만 발생하게 됩니다.
컨텍스트를 매번 조금씩 바꾸지 말고, 드물게 배치 단위로 변경하세요.
옵션 B: 압축(compaction)
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기