Lab 02: 멀티턴 대화
학습 흐름
Lab 01에서는 한 번 묻고 한 번 답하는 단일 턴 챗봇을 만들었습니다. 그 챗봇에 “내 이름은 OO야”라고 알려준 뒤 “내 이름 뭐야?”라고 다시 물으면, 모델은 기억하지 못합니다. 이번 Lab에서는 그 한계를 직접 체감하고, 대화의 맥락을 유지하는 멀티턴 챗봇을 구현합니다.
핵심 한 문장: “모델은 기억이 없다. 맥락을 유지하려면 매 호출마다 대화 전체를 다시 보내야 한다.”
학습 목표
- 모델이 상태(state)를 갖지 않는다는 점, 즉 “기억이 없다”는 사실을 이해합니다
- 맥락 유지를 위해 매 호출마다 대화 전체(
messages)를 다시 보내는 구조를 구현합니다 - 중립 형식의 대화 히스토리를 Anthropic
messages배열로 변환하는 방법을 익힙니다 - 최신 모델을 호출하는 인퍼런스 프로파일 ID(
us.anthropic.*)의 의미를 이해합니다 - 히스토리를 직접 관리하는 방식의 번거로움과 토큰 비용 증가를 체감합니다
모델은 기억이 없다
대화형 AI를 처음 다룰 때 가장 먼저 깨야 하는 오해가 있습니다. “ChatGPT는 앞에서 한 말을 기억하니, 모델 내부에 대화 기록이 저장되어 있겠지”라는 생각입니다. 실제로는 정반대입니다.
LLM 호출은 상태가 없는(stateless) 함수에 가깝습니다. 입력을 받아 출력을 내놓을 뿐, 이전 호출이 무엇이었는지 모델 쪽에는 어떤 흔적도 남지 않습니다. Lab 01에서 우리가 한 번의 invoke_model 호출로 단일 턴 대화를 했던 것을 떠올려 보세요. 그 호출이 끝나는 순간 모델 입장에서 대화는 완전히 종료됩니다.
그렇다면 ChatGPT 같은 서비스는 어떻게 앞 내용을 “기억”하는 것처럼 보일까요? 답은 단순합니다. 애플리케이션 쪽에서 지금까지의 대화 전체를 모아두었다가, 다음 질문을 보낼 때 그 전체를 함께 다시 보내기 때문입니다. 모델이 기억하는 게 아니라, 우리가 매번 “여기까지가 지금까지의 대화였고, 이게 새 질문이야”라고 통째로 다시 알려주는 것입니다.
sequenceDiagram participant U as 사용자 participant A as 우리 앱(history) participant M as 모델(stateless) U->>A: "내 이름은 넥클이야" A->>M: messages = [user: "내 이름은 넥클이야"] M-->>A: "반가워요, 넥클님" Note over A: 히스토리에 둘 다 쌓아둔다 U->>A: "내 이름 뭐야?" A->>M: messages = [user, assistant, user: "내 이름 뭐야?"] M-->>A: "넥클이에요"
위 그림에서 핵심은 두 번째 호출입니다. 모델에게 “내 이름 뭐야?”만 던지면 모델은 답할 길이 없습니다. 첫 번째 대화 turn(사용자 발화 + 모델 응답)을 앞에 통째로 붙여서 보내야, 비로소 모델이 맥락을 읽고 “넥클”이라고 답할 수 있습니다. 이 “통째로 붙여 보내는 배열”이 바로 messages입니다.
모델 ID — 왜 `us.`로 시작할까 (인퍼런스 프로파일)
이 과정의 모든 Lab은 최신 Claude Haiku 4.5를 사용하며, 모델 ID로 us.anthropic.claude-haiku-4-5-20251001-v1:0처럼 us.로 시작하는 값을 씁니다. 이건 단순한 모델 ID가 아니라 인퍼런스 프로파일(inference profile) ID입니다.
신형 모델을 온디맨드로 호출할 때는 모델 ID를 그대로 넘기지 않고 이 인퍼런스 프로파일 ID를 modelId로 넘깁니다. 인퍼런스 프로파일은 요청을 여러 리전에 걸쳐 분산(cross-Region inference)하는 AWS의 라우팅 단위입니다. 한 리전에 트래픽이 몰리거나 한도에 닿아도 다른 리전으로 넘겨 처리하므로, 가용성과 처리량(throughput)을 높이기 위한 장치입니다. us. 접두는 “미국 리전군으로 라우팅한다”는 뜻입니다. 즉 us.는 외워야 할 규칙이 아니라, 이 모델을 안정적으로 호출하기 위한 주소로 이해하면 됩니다.
| 형태 | 예시 | 비고 |
|---|---|---|
| 인퍼런스 프로파일 ID | us.anthropic.claude-haiku-4-5-20251001-v1:0 | 신형 모델은 이 형태로 호출 — 이 과정에서 사용 |
| bare 모델 ID | anthropic.claude-3-haiku-20240307-v1:0 | 과거 일부 모델은 ID를 그대로 호출했으나, 현재 Bedrock LEGACY(지원 종료 수순) |
참고: Claude 3 계열(Haiku·Sonnet)은 Bedrock에서 Legacy로 분류됩니다. Claude 3 Sonnet은 이미 차단되었고(
ResourceNotFoundException ... Legacy), Claude 3 Haiku도 같은 수순이라, 이 과정은 처음부터 최신 Haiku 4.5(인퍼런스 프로파일)만 사용합니다. 호출이 막힌다면 인퍼런스 프로파일 invoke 권한과 리전(us-east-1)을 먼저 확인하세요.
모델 ID는 외우지 말고 조회하세요
모델 ID는 암기 대상이 아니라 공식 출처에서 확인하는 값입니다.
- 📚 지원되는 파운데이션 모델 — AWS 공식 — 모델별 ID·리전·모달리티가 표로 정리돼 있습니다.
- 📚 크로스 리전 추론으로 처리량 높이기 — AWS 공식 —
us.인퍼런스 프로파일이 왜·어떻게 동작하는지. - 🧰 boto3로 직접 조회 — 조회용 관리 클라이언트는
bedrock입니다(런타임 호출용bedrock-runtime과 다릅니다).
import boto3
bedrock = boto3.client("bedrock", region_name="us-east-1")
# 기본 모델 ID 목록
for m in bedrock.list_foundation_models()["modelSummaries"]:
print(m["modelId"])
# 'us.'로 시작하는 크로스 리전 인퍼런스 프로파일 ID 목록
for p in bedrock.list_inference_profiles()["inferenceProfileSummaries"]:
print(p["inferenceProfileId"])우리 실습에서는 모델 ID를 config.py에 상수로 모아두고 가져다 씁니다.
# config.py — 넥스트클라우드가 제공하는 모듈 (학생은 수정하지 않음)
# 주력 실습 모델 — 최신 Haiku. "us." 인퍼런스 프로파일 ID (invoke_model·Converse 공통)
MODEL_HAIKU = "us.anthropic.claude-haiku-4-5-20251001-v1:0"
# Converse 단계에서 모델만 바꿔도 동작하는지 보여줄 비교용
MODEL_NOVA = "us.amazon.nova-lite-v1:0"
# invoke_model 의 Anthropic 바디에 들어가는 고정 값
ANTHROPIC_VERSION = "bedrock-2023-05-31" 중립 히스토리를 Anthropic messages로 변환하기
이번 Lab의 핵심 코드는 단 한 줄의 변환입니다. 우리 앱은 대화를 중립 형식으로 들고 있습니다. 즉 {"role": ..., "text": ...} 형태의 딕셔너리 리스트입니다. 이 중립 형식을 Anthropic이 요구하는 messages 형식 {"role": ..., "content": ...}으로 바꿔주어야 합니다.
왜 굳이 중립 형식을 따로 두고 변환할까요? 모델 제공사마다 요구하는 body 형식이 다르기 때문입니다. Anthropic은 content라는 키를 쓰지만, 다른 모델은 또 다른 키를 씁니다. 앱 내부는 한 가지 표준 형식으로 통일해두고, 모델에 보낼 때만 그 모델의 형식으로 변환하면 코드가 깔끔해집니다. 저수준 invoke_model에서는 이 형식을 우리가 직접 책임진다는 점이 핵심입니다.
def respond(history: list[dict]) -> str:
"""전체 히스토리를 messages 로 만들어 보낸다 (맥락 유지)."""
# 중립 히스토리 -> Anthropic messages 배열로 변환
messages = [{"role": h["role"], "content": h["text"]} for h in history]
body = {
"anthropic_version": ANTHROPIC_VERSION,
"max_tokens": 512,
"messages": messages, # ← 매번 '대화 전체'를 보낸다
}
resp = rt.invoke_model(
modelId=MODEL_HAIKU, # ← 인퍼런스 프로파일 ID (모든 Lab 공통)
body=json.dumps(body),
)
payload = json.loads(resp["body"].read())
return payload["content"][0]["text"]한 줄씩 살펴보겠습니다.
-
messages = [{"role": h["role"], "content": h["text"]} for h in history]— 이 한 줄이 변환의 전부입니다. 중립 히스토리의 각 항목{"role", "text"}를 Anthropic이 요구하는{"role", "content"}로 바꿉니다.text라는 키 이름을content로 옮기는 것이 핵심입니다.history에는 사용자 발화와 모델 응답이 번갈아 쌓여 있으므로, 변환된messages에도 그 전체 대화가 그대로 담깁니다. -
body— Lab 01에서 배운 body의 3요소가 그대로 들어갑니다.anthropic_version(형식 버전),max_tokens(응답 최대 길이),messages(대화 전체). 달라진 것은messages가 단 하나의 발화가 아니라 지금까지의 대화 전체라는 점뿐입니다. -
rt.invoke_model(modelId=MODEL_HAIKU, body=json.dumps(body))— 호출 방식은 Lab 01과 완전히 동일합니다. body를 JSON 문자열로 직렬화하여 넘깁니다. 달라진 것은messages에 대화 전체가 담긴다는 점뿐입니다. -
payload["content"][0]["text"]— 응답 JSON에서 모델이 생성한 텍스트를 꺼냅니다. Anthropic 응답은content배열의 첫 블록에 텍스트가 들어 있습니다.
respond 함수가 매번 history 전체를 받아 messages로 만든다는 점에 주목하세요. 대화가 진행될수록 history는 길어지고, 그만큼 매 호출에서 보내는 messages도 길어집니다. 이것이 다음 토글에서 다룰 비용 이야기로 이어집니다.
🤔 먼저 생각해 보기 — 대화가 길어질수록 매 호출에 보내는 메시지가 쌓입니다. 이때 호출 비용은 어떻게 변할까요? 비용을 줄이려 오래된 대화를 덜 보낸다면, 그 대신 무엇을 잃게 될까요? 아래 토글에서 답을 보기 전에 잠깐 생각해 보세요.
히스토리 수동 관리의 번거로움과 토큰 비용
멀티턴을 직접 구현하면 두 가지 부담이 드러납니다. 둘 다 “왜 나중에 더 높은 수준의 API가 필요한가”를 설명하는 복선입니다.
1. 히스토리 수동 관리의 번거로움
맥락을 유지하기 위해 우리가 직접 messages 배열을 쌓아 보내고 있습니다. 대화가 오갈 때마다 사용자 발화와 모델 응답을 빠짐없이 히스토리에 추가하고, 호출할 때마다 전체를 다시 변환해 보내야 합니다. 지금은 단순해 보이지만, 여기에 시스템 역할(페르소나)을 끼우거나 일부 메시지를 잘라내는 처리를 더하기 시작하면 코드가 금세 번잡해집니다. “내가 직접 상태를 관리한다”는 부담이 바로 저수준 API의 대가입니다.
2. 토큰 비용의 증가
매 호출마다 대화 전체를 다시 보낸다는 것은, 대화가 길어질수록 매번 보내는 입력 토큰이 누적적으로 늘어난다는 뜻입니다. 10번째 질문을 할 때는 1~9번째 대화 전체가 입력에 다시 실립니다. 입력 토큰에는 비용이 부과되므로, 긴 대화는 호출 한 번의 비용도 함께 커집니다.
graph LR T1["turn 1<br/>짧은 입력"] --> T2["turn 2<br/>+이전 대화"] T2 --> T3["turn 3<br/>++이전 대화"] T3 --> T4["turn N<br/>전체 누적"]
이 문제를 다루는 실전 전략들이 있습니다. 오래된 메시지를 잘라내는 윈도우 방식, 앞부분을 요약으로 압축하는 방식 등입니다. 이번 Lab에서는 “전체를 다 보내면 맥락은 완벽하지만 비용이 누적된다”는 트레이드오프를 체감하는 것까지가 목표입니다. 윈도우·요약 같은 본격적인 관리 전략은 이 입문 과정의 범위 밖이라 여기서는 개념만 짚고 넘어갑니다.
실습: 멀티턴 챗봇 실행
respond 함수를 완성했으면, 제공되는 콘솔 챗봇 UI 모듈(chat_ui.py)에 연결하여 실행합니다. 학습자는 boto3 호출 로직(respond)만 구현하고, 채팅 입출력 루프는 제공 모듈이 담당합니다.
실행 코드
import json
from config import get_runtime, MODEL_HAIKU, ANTHROPIC_VERSION
rt = get_runtime()
def respond(history: list[dict]) -> str:
messages = [{"role": h["role"], "content": h["text"]} for h in history]
body = {
"anthropic_version": ANTHROPIC_VERSION,
"max_tokens": 512,
"messages": messages,
}
resp = rt.invoke_model(modelId=MODEL_HAIKU, body=json.dumps(body))
payload = json.loads(resp["body"].read())
return payload["content"][0]["text"]
if __name__ == "__main__":
from chat_ui import run
run(respond, title="Lab2 · invoke_model 멀티턴")if __name__ == "__main__": 블록에서 제공 모듈 chat_ui의 run을 불러 우리가 만든 respond 함수를 넘깁니다. run은 콘솔에서 사용자 입력을 받고, 히스토리를 쌓아 respond에 넘기고, 응답을 출력하는 루프를 대신 돌려줍니다. 우리는 모델 호출 한 가지에만 집중하면 됩니다.
실행
code-server 터미널에서 Lab 02 파일을 실행하세요.
# 챗봇 실습 폴더로 이동
cd ~/nxt-workshop-code/bedrock-chatbot/starter
python lab2_multiturn.py맥락 유지 검증
다음 순서로 입력하여 멀티턴이 동작하는지 직접 확인하세요.
내 이름은 넥클이야라고 입력합니다. 모델이 인사하며 받아줍니다.- 이어서
내 이름 뭐야?라고 묻습니다.
두 번째 질문에 모델이 “넥클”이라고 답한다면, 우리가 보낸 messages에 첫 번째 대화가 함께 실려 모델이 맥락을 읽었다는 뜻입니다. Lab 01의 단일 턴 챗봇이었다면 두 번째 질문에서 이름을 알지 못했을 것입니다.

-
respond에서 중립 히스토리를 Anthropicmessages로 변환했다 -
modelId로 인퍼런스 프로파일 ID(us.anthropic.claude-haiku-4-5-...)를 사용했다 - “내 이름은 넥클이야” → “내 이름 뭐야?”로 맥락이 유지되는 것을 확인했다
- 대화가 길어질수록 매 호출에 전체 대화가 다시 실린다는 점을 이해했다
핵심 정리
| 개념 | 내용 |
|---|---|
| 모델은 stateless | 모델은 기억이 없다. 이전 호출의 흔적이 남지 않는다 |
| 맥락 유지 방식 | 매 호출마다 대화 전체(messages)를 다시 보낸다 |
| 변환의 핵심 | 중립 {"role","text"} → Anthropic {"role","content"} |
| 모델 ID | 인퍼런스 프로파일 ID(us.anthropic.*)로 호출 — 전 Lab 동일 |
| 트레이드오프 | 전체 전송 = 완벽한 맥락, 단 누적되는 토큰 비용과 수동 관리 부담 |
이번 Lab에서 우리는 “맥락을 유지하려고 직접 messages를 쌓아 보내는” 저수준 방식을 경험했습니다. 동작은 하지만 번거롭고, 응답은 한 덩어리로 한 번에 도착합니다. 다음 Lab에서 그 응답을 ChatGPT처럼 실시간으로 흘러나오게 만듭니다.
🤔 생각해 보기 (다 끝냈다면)
history에 assistant 턴을 가짜로 끼워 넣으면 모델이 속을까요? 이게 보안상 무엇을 뜻할까요?
다음 단계
멀티턴 챗봇이 동작하지만, 모델의 응답은 모든 글자가 다 생성된 뒤 한 덩어리로 도착합니다. 답변이 길수록 사용자는 빈 화면을 오래 기다리게 됩니다. Lab 03에서는 invoke_model_with_response_stream으로 응답을 이벤트 스트림으로 받아, 글자가 실시간으로 흘러나오는 스트리밍 챗봇을 구현합니다.
NxtCloud Workshop