LLM은 앞 질문을 어떻게 기억하나 — 대화는 매번 통째로 보내는 JSON 배열입니다
LLM API로 질문을 두 번 보내면 두 번째 답이 첫 질문을 알고 있는 것처럼 나옵니다. LLM API를 한 번쯤 호출해 봤고 모델이 서버 어딘가에 대화를 저장해 두는 것인지 궁금했던 분을 위한 글입니다. 읽고 나면 실제 요청 JSON이 턴마다 어떻게 자라는지, 그 대가로 입력 토큰(모델이 글을 잘게 잘라 세는 단위로, 요금과 입력 한도를 이 수로 잽니다)이 얼마나 늘어나는지를 숫자로 확인할 수 있습니다.
이 글은 「에이전트 하네스를 직접 만들어 본다」 시리즈의 첫 번째 내용입니다. 하네스(harness)는 모델 바깥에서 모델을 호출하고 대화를 들고 있고 도구를 실행해 주는 코드를 말합니다. 이 글은 대화 배열을 다루고, 이어지는 프롬프트 캐싱 글을 따로 두었습니다. 두 번째 내용은 도구 호출, 세 번째 내용은 에이전트 루프입니다.
결론부터 말하면 대화 상태는 모델이 아니라 내 코드의 배열 하나가 쥐고 있고 매 호출마다 그 배열 전체를 다시 보내기 때문에 배열을 비우면 기억도 사라지고 질문이 쌓일수록 입력 토큰도 쌓입니다.
이번 gemini-3.6-flash(추론 켜짐) 실행에서는 그 증가 폭이 직전 호출의 전체 토큰만큼이었습니다.
실험한 코드: 반복문 하나와 배열 하나
출발점은 공개 레포 harness-engineering-101의 v1_chat.py입니다. 대화 부분은 아래가 전부입니다.
# 원본: harness-engineering-101 src/harness/v1_chat.py (MIT, © 2026 Isuru Wijesiri) — 발췌. 내용은 그대로 두고 형식만 black으로 정리
def main():
messages = [] # the entire "session"
while True:
user_text = input("\nyou> ").strip()
if user_text in ("/quit", ""):
break
if user_text == "/clear":
messages = [] # "new conversation"
continue
messages.append({"role": "user", "content": user_text})
reply = call_llm(messages) # resend EVERYTHING
text = "".join(b["text"] for b in reply["content"] if b["type"] == "text")
messages.append({"role": "assistant", "content": reply["content"]})
print(f"\nassistant> {text}")messages 리스트에 사용자 말을 붙이고 리스트 통째로 call_llm에 넘기고 돌아온 답을 다시 리스트에 붙입니다.
/clear는 리스트를 빈 리스트로 바꾸는 한 줄입니다. 서버에 「대화 초기화」를 요청하는 API 호출 같은 것은 없습니다.
원본은 Anthropic Messages API를 호출합니다. 저는 Gemini API(gemini-3.6-flash)로 돌렸고 그래서 call_llm 하나만 바꿨습니다.
바꾼 부분은 이렇습니다(Gemini용으로 수정한 코드, 원본과 같은 MIT 라이선스).
- 반복문과
messages배열 구조는 원본 그대로 두고call_llm이 보내기 직전에 배열을 Gemini 형식으로 바꿉니다. 역할 이름과 필드 이름이 어떻게 대응하는지는 도구 호출 글에서 표로 정리했습니다. input()대신 시나리오 파일을 한 줄씩 읽게 해서 같은 질문을 그대로 다시 돌릴 수 있게 했습니다.- 호출할 때마다 변환 전 배열(
messages_before), 실제로 보낸 요청(gemini_request), 응답, 토큰 사용량을runs/<이름>.json에 남깁니다. API 키는 키 이름 기준으로 지운 뒤 기록합니다.
질문은 두 묶음을 준비했습니다. 각각 세 번 호출합니다.
# scenarios/v1-1.txt
A Kafka consumer group has a lag of 18000 messages and drains at a net 250 messages per second. How many seconds until the lag is zero? Answer with the number and one line of working.
If I add a second consumer instance and the net drain rate doubles, what is the new time?
/clear
What was the drain rate I told you earlier?# scenarios/v1-2.txt
In one sentence: what is the difference between HTTP 502 and HTTP 504 from the point of view of a backend behind a reverse proxy?
Now give me a one-line nginx directive that would most directly affect the 504 case.
Repeat my very first question back to me word for word.첫 호출: 배열에 원소가 하나뿐입니다
v1-1의 첫 질문을 보낼 때 call_llm이 받은 배열과, 그걸 Gemini 형식으로 바꿔 실제로 보낸 요청입니다.
// runs/v1-1.json calls[0].messages_before
[
{
"role": "user",
"content": "A Kafka consumer group has a lag of 18000 messages and drains at a net 250 messages per second. How many seconds until the lag is zero? Answer with the number and one line of working."
}
]// runs/v1-1.json calls[0].gemini_request
{
"contents": [
{
"role": "user",
"parts": [
{
"text": "A Kafka consumer group has a lag of 18000 messages and drains at a net 250 messages per second. How many seconds until the lag is zero? Answer with the number and one line of working."
}
]
}
],
"generationConfig": {
"maxOutputTokens": 4096
},
"systemInstruction": {
"parts": [
{
"text": "You are a concise assistant."
}
]
}
}답은 72 seconds였고(18000 / 250 = 72) 응답의 토큰 사용량은 이렇습니다.
// runs/v1-1.json calls[0].usageMetadata (발췌)
{
"promptTokenCount": 54,
"candidatesTokenCount": 23,
"thoughtsTokenCount": 126,
"totalTokenCount": 203
}promptTokenCount는 보낸 입력, candidatesTokenCount는 화면에 나온 답, thoughtsTokenCount는 모델이 답하기 전에 내부적으로 쓴 추론(thinking) 토큰입니다.
「72초」라는 짧은 답에도 추론 토큰이 답보다 다섯 배 넘게 들었습니다.
두 번째 호출: 첫 질문과 첫 답이 다시 실려 갑니다
두 번째 질문은 「소비자를 하나 더 붙여 처리 속도가 두 배가 되면?」입니다. 이 질문만 보면 원래 지연이 몇 건이었는지 알 수 없습니다. 그런데 실제로 나간 요청에는 원소가 세 개 들어 있습니다.
// runs/v1-1.json calls[1].gemini_request.contents
[
{
"role": "user",
"parts": [
{
"text": "A Kafka consumer group has a lag of 18000 messages and drains at a net 250 messages per second. ..."
}
]
},
{
"role": "model",
"parts": [
{
"text": "72 seconds\n\n18000 messages / 250 messages per second = 72 seconds",
"thoughtSignature": "<signature len=628>"
}
]
},
{
"role": "user",
"parts": [
{
"text": "If I add a second consumer instance and the net drain rate doubles, what is the new time?"
}
]
}
](첫 질문 문장은 지면 때문에 ...로 줄였고 로그에는 전문이 그대로 있습니다.)
모델은 36 seconds, 18000 messages / 500 messages per second = 36 seconds로 답했습니다.
18000이라는 숫자는 두 번째 질문 어디에도 없습니다. 같은 요청 안에 첫 질문이 다시 들어 있었기 때문에 쓸 수 있었던 것입니다.
thoughtSignature는 Gemini 3 계열이 응답에 붙여 주는 불투명한 문자열입니다.
어댑터가 이 값을 블록에 보관했다가 다음 요청에 그대로 돌려보냅니다(로그에는 길이만 남겼습니다).
Anthropic API에도 추론(thinking) 블록에 signature가 붙고 다음 요청에 그대로 돌려줘야 하지만 원본 코드는 응답 content를 통째로 배열에 다시 넣기 때문에 이 값을 따로 다룰 필요가 없었습니다.
이식본은 Anthropic 형식과 Gemini 형식 사이를 변환하므로 이 값을 직접 챙겨야 했습니다.
이 값을 빼고 보내면 오류가 나는지는 시험하지 않았습니다.
/clear 뒤 세 번째 호출: 배열이 비면 대화도 없습니다
세 번째 줄 /clear에서 코드는 messages = []만 실행하고 API는 부르지 않습니다.
그다음 「아까 말한 처리 속도가 얼마였지?」라고 물었을 때 나간 배열입니다.
// runs/v1-1.json calls[2].messages_before
[
{
"role": "user",
"content": "What was the drain rate I told you earlier?"
}
]// runs/v1-1.out.txt
you> What was the drain rate I told you earlier?
assistant> You haven't mentioned a drain rate to me yet in this conversation. Please let me know what it is!같은 프로세스, 같은 API 키, 같은 모델이었고 바뀐 것은 내 코드의 리스트 하나뿐입니다.
모델 입장에서는 이번 요청에 들어 있는 것이 대화의 전부이고 「이 대화에서는 처리 속도를 들은 적이 없다」는 답은 그 요청 기준으로 정확합니다.
이 실험에서 대화를 이어 준 것은 요청 안의 배열뿐이었습니다. 응답마다 responseId가 붙어 오지만 다음 요청에 그 값을 넣지는 않았고 다음 요청에 되돌려 보낸 것은 배열 안의 앞 턴들(그리고 그 안에 든 thoughtSignature)뿐입니다.
v1-2도 같은 구조를 확인합니다. 세 번째 호출에서 「내 첫 질문을 글자 그대로 다시 말해 달라」고 하자, 모델은 첫 질문을 한 글자도 다르지 않게 돌려줬습니다.
// runs/v1-2.out.txt (마지막 턴)
you> Repeat my very first question back to me word for word.
assistant> In one sentence: what is the difference between HTTP 502 and HTTP 504 from the point of view of a backend behind a reverse proxy?그 호출의 messages_before에는 원소가 다섯 개(질문 3개, 답 2개) 들어 있었고 첫 질문은 그 요청 맨 앞에 그대로 적혀 있었습니다.
토큰은 어떻게 늘어나나: 이번 실행에서는 직전 호출 전체가 다음 입력이 됐습니다
배열을 통째로 다시 보낸다는 것은 앞 턴의 비용을 매번 다시 낸다는 뜻입니다. 두 시나리오의 호출별 사용량을 로그에서 그대로 옮겼습니다.
run 호출 배열 원소 prompt thoughts candidates total 걸린 시간
v1-1 0 1 54 126 23 203 2.275s
v1-1 1 3 225 168 23 416 4.76s
v1-1 2 1 17 104 23 144 1.916s ← /clear 뒤
v1-2 0 1 40 559 47 646 4.275s
v1-2 1 3 669 300 12 981 2.913s
v1-2 2 5 995 178 33 1206 2.26s여기서 눈에 띄는 규칙이 있습니다. 두 번째 이후 호출의 prompt는 직전 호출의 total에 새 질문 몇 토큰을 더한 값과 맞아떨어집니다.
v1-1 호출1: 225 = 203(직전 total) + 22
v1-2 호출1: 669 = 646(직전 total) + 23
v1-2 호출2: 995 = 981(직전 total) + 14더해진 22·23·14는 새로 보낸 질문 한 문장 길이에 해당하는 크기입니다. v1-2 첫 호출은 입력 40토큰에 답 47토큰이었는데, 두 번째 호출 입력은 669토큰으로 뛰었습니다. 화면에 보이는 첫 질문과 첫 답만으로는 이만큼 커질 수 없고 직전 호출의 추론 토큰 559까지 다시 입력으로 세어졌다고 봐야 숫자가 맞습니다.
다만 요청 JSON에 추론 내용 자체는 들어 있지 않습니다. 어댑터는 응답의 thought 부분을 버리고 다음 요청에는 보이는 텍스트와 thoughtSignature만 실어 보냅니다.
그래서 이전 추론 토큰이 thoughtSignature를 통해 다음 입력으로 다시 세어지는 것으로 보이지만 시그니처를 빼고 보내 보는 실험은 하지 않았으므로 원인은 확인하지 못했습니다. 위 세 줄의 덧셈이 맞는다는 것까지가 이 로그로 확인한 사실입니다.
시나리오 전체 합계도 같은 이야기를 합니다.
// runs/v1-1.json usage_total
{
"promptTokenCount": 296,
"candidatesTokenCount": 69,
"thoughtsTokenCount": 398,
"totalTokenCount": 763
}
// runs/v1-2.json usage_total
{
"promptTokenCount": 1704,
"candidatesTokenCount": 92,
"thoughtsTokenCount": 1037,
"totalTokenCount": 2833
}v1-2에서 화면에 나온 답은 세 번을 합쳐 92토큰인데 입력은 1704토큰을 냈습니다. 세 번째 호출 하나의 입력(995)이 첫 호출 입력(40)의 스물네 배가 넘습니다.
v1-1 세 번째 호출의 prompt가 17로 떨어진 것은 /clear가 배열을 비웠기 때문입니다. 대화를 「잊게 하는」 일과 입력 비용을 줄이는 일이 같은 한 줄로 일어났습니다.
배열 전체를 다시 보내는 한 입력은 턴마다 쌓이고 이번 모델처럼 직전 호출의 추론 토큰까지 다음 입력에 세어지면 그 속도가 더 빨라집니다. 대화가 길어지면 하네스가 앞 내용을 줄이거나 요약해야 하는 이유입니다. 추론을 끄거나 다른 제공사 모델을 쓸 때도 증가 폭이 같은지는 이번에 재지 않았습니다. 이 실험은 세 턴짜리라 아래에서 다룰 컨텍스트 한도에 닿지는 않았습니다. 도구 결과가 배열에 쌓이기 시작하면 이 증가가 훨씬 가팔라지는데, 그건 이어지는 도구 호출 글과 에이전트 루프 글에서 로그로 보여 드리겠습니다.
배열이 자라면 부딪히는 벽: 컨텍스트 한도가 있는 이유
배열이 턴마다 자라면 언젠가는 모델이 한 번에 받을 수 있는 크기를 넘습니다.
요청 하나에 담을 수 있는 토큰 상한을 컨텍스트 윈도우라고 부릅니다. 이번에 쓴 gemini-3.6-flash는 입력 1,048,576토큰, 출력 65,536토큰까지 받습니다.
Google의 모델 개요 페이지는 이 모델을 이제 이전 세대 Flash로 분류하고 현행 Flash는 3.8 Flash입니다. Google의 모델별 한도 페이지: Gemini 3.6 Flash.
백만 토큰이면 넉넉해 보이지만 상한이 있는 데는 이유가 있습니다. 입력이 길어질수록 계산량과 메모리가 늘고 모델이 학습한 길이라는 한계도 있습니다. 아래는 트랜스포머 일반에 대한 공개 자료이고 Gemini 내부 구조가 공개된 것은 아닙니다.
- 계산량: 트랜스포머 모델의 핵심 연산인 self-attention은 층마다 O(n²·d)만큼 계산합니다(n은 토큰 수, d는 내부 벡터 차원). 토큰이 두 배가 되면 이 부분 계산은 네 배가 됩니다. 트랜스포머 원 논문의 층 종류별 계산량 비교 표: Attention Is All You Need.
- 메모리: 모델은 답을 한 토큰씩 만들면서 앞 토큰마다 계산해 둔 중간값(Key·Value)을 GPU 메모리에 쌓아 두는데, 이것을 KV 캐시라고 합니다. KV 캐시가 없으면 매 스텝 앞의 값을 전부 다시 계산해야 해서 비용이 길이의 제곱으로 늘고 KV 캐시를 쓰면 스텝당 계산은 길이에 비례하는 수준으로 줄어드는 대신 메모리가 토큰 수에 비례해 늘어납니다. Hugging Face Transformers의 캐시 설명 문서: Cache explanation.
- 메모리 크기의 예: OPT-13B 모델은 토큰 하나에 KV 캐시가 800KB 들고 2,048토큰짜리 요청 하나에 최대 1.6GB가 듭니다. GPU 메모리를 전부 KV 캐시에 써도 동시에 처리할 수 있는 요청은 수십 개 수준이라고 합니다. 추론 서버 vLLM을 소개한 PagedAttention 논문: arXiv 2309.06180.
- 학습한 길이: 토큰 위치를 표현하는 방식의 하나인 RoPE를 쓰는 모델은 학습할 때 본 시퀀스 길이를 넘어서면 일반화하지 못합니다. 그래서 컨텍스트를 늘리려면 YaRN 같은 별도 확장 기법이 필요합니다. 컨텍스트 확장 기법 YaRN 논문: arXiv 2309.00071.
한도 안에 들어가더라도 길수록 좋은 것은 아닙니다. Anthropic의 컨텍스트 윈도우 문서는 토큰 수가 늘면 정확도와 회상률이 떨어진다고 적고 이 현상을 context rot이라고 부릅니다. Anthropic 문서: Context windows.
이 실험에 대입하면, 하네스가 배열 전체를 다시 보내는 이상 모델 쪽은 매 요청마다 그 배열 전체 길이를 상대로 이 계산과 메모리를 씁니다. 직전 요청에서 이미 계산한 앞부분까지 매번 다시 계산하는지, 제공사가 그 부분을 어떻게 재사용하는지는 실측과 함께 프롬프트 캐싱 편에 따로 정리했습니다.
확인하지 못한 것
- Gemini 형식으로 바꿀 때 빈 텍스트 블록은 버려야 합니다. 어댑터 주석에 「Gemini rejects empty parts」라고 적어 두었는데, 이 규칙을 어겨 거절당하는 장면을 이번 실행에서 직접 본 것은 아닙니다.
- 이 글의 결론은 이번에 쓴
generateContent호출 기준입니다. 제공사에 따라 대화를 서버 쪽에 보관해 주는 선택 기능을 둔 API도 있는데, 이 실험에서는 쓰지 않았습니다(문서 내용은 프롬프트 캐싱 편에 옮겼습니다). thoughtSignature없이 보냈을 때의 동작, 추론 토큰이 다음 입력에 다시 세어지는 정확한 원인, Anthropic API로 같은 시나리오를 돌렸을 때의 토큰 수는 확인하지 못했습니다(Anthropic은 이번에 한 번도 호출하지 않았습니다).
실습 환경
- 모델:
gemini-3.6-flash(고정 ID), APIv1beta generateContent, 무료 등급. 2026-10-26 실행, 호출은 동시에 보내지 않고 하나씩 보냈습니다. - 두 시나리오 모두 모든 호출이 HTTP 200이었습니다. 시나리오 전체 소요는 v1-1 8.951초, v1-2 9.449초입니다.
- 무료 등급은 프롬프트가 학습에 쓰일 수 있어서 질문은 지어낸 운영 상황(Kafka 지연, 502/504)으로만 만들었습니다. 회사 로그나 개인 정보를 넣지 마십시오.
원 레포: harness-engineering-101. 본문은 CC BY-NC-ND 4.0이고 이 글은 본문을 인용하거나 번역하지 않았습니다. 코드는 MIT, Copyright (c) 2026 Isuru Wijesiri(LICENSE-CODE)입니다. 이 글은 그 코드를 직접 실행한 기록입니다.