birchholt 직접 해보고 남기는 기록

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 라이선스).

질문은 두 묶음을 준비했습니다. 각각 세 번 호출합니다.

# 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 내부 구조가 공개된 것은 아닙니다.

한도 안에 들어가더라도 길수록 좋은 것은 아닙니다. Anthropic의 컨텍스트 윈도우 문서는 토큰 수가 늘면 정확도와 회상률이 떨어진다고 적고 이 현상을 context rot이라고 부릅니다. Anthropic 문서: Context windows.

이 실험에 대입하면, 하네스가 배열 전체를 다시 보내는 이상 모델 쪽은 매 요청마다 그 배열 전체 길이를 상대로 이 계산과 메모리를 씁니다. 직전 요청에서 이미 계산한 앞부분까지 매번 다시 계산하는지, 제공사가 그 부분을 어떻게 재사용하는지는 실측과 함께 프롬프트 캐싱 편에 따로 정리했습니다.

확인하지 못한 것

실습 환경

원 레포: harness-engineering-101. 본문은 CC BY-NC-ND 4.0이고 이 글은 본문을 인용하거나 번역하지 않았습니다. 코드는 MIT, Copyright (c) 2026 Isuru Wijesiri(LICENSE-CODE)입니다. 이 글은 그 코드를 직접 실행한 기록입니다.