7 분 소요

🧠 AWS Bedrock 챗봇 — 대화 맥락 유지 설계 4부작 — Bedrock 으로 챗봇을 만들면 가장 먼저 부딪히는 벽이 “모델이 대화를 하나도 기억하지 못한다” 예요. API 가 스테이트리스라서 맥락은 전부 우리가 들고 다녀야 하죠. 이 시리즈는 그 맥락을 어디에 쌓고(저장), 어떻게 싸게 다시 보내고(캐싱), 넘칠 때 어떻게 줄이는지(압축) 를 순서대로 설계합니다. 전체 그림과 Bedrock 특유의 제약을 먼저 잡고(1편), DynamoDB 세션 스토어와 메시지 스키마를 짜고(2편), 프롬프트 캐싱으로 재전송 비용을 걷어내고(3편), 컨텍스트 한계에서 압축·요약·장기기억으로 버티는 것(4편)까지 이어집니다. 전체 4편.

  1. 대화 맥락은 어떻게 유지하나 — 전체 설계도지금 글
  2. 대화 저장소 — DynamoDB 세션 스토어와 메시지 스키마
  3. 프롬프트 캐싱 — 같은 히스토리를 매번 보내면서 돈 아끼기
  4. 컨텍스트가 꽉 찰 때 — 압축·정리·요약과 장기 기억

Summary

AWS Bedrock 으로 챗봇을 붙여보면 첫 응답까지는 놀랄 만큼 쉬워요. SDK 깔고, 리전 지정하고, 질문 하나 던지면 답이 옵니다. 그런데 두 번째 질문을 던지는 순간 벽이 나와요.

나:   내 이름은 재현이야.
봇:   안녕하세요 재현님!
나:   내 이름 뭐였지?
봇:   죄송하지만 이름을 알려주지 않으셨어요.

당황스럽지만 버그가 아니에요. Bedrock 의 Messages API 는 스테이트리스라서 그렇습니다. 서버가 대화를 붙들고 있어주지 않아요. 그래서 “맥락 유지”는 모델 기능이 아니라 우리가 짜야 하는 애플리케이션 설계예요.

이 시리즈는 그 설계를 처음부터 끝까지 해봅니다. 1편은 전체 지도예요. 무엇을 우리가 책임져야 하는지, 그리고 Bedrock 을 쓸 때만 달라지는 제약이 무엇인지를 먼저 잡습니다.

📎 예전에 AWS Bedrock 으로 스트리밍 챗봇 만들기 라는 글을 썼는데, 거기서 “대화 상태와 세션 저장”을 한 절로만 짧게 다뤘어요. 이 시리즈는 그 한 절을 4편으로 펼친 것입니다. 스트리밍 전달(SSE·WebSocket)이나 IAM·모델 액세스 같은 준비물은 그 글을 보시면 되고, 여기서는 맥락 유지에만 집중해요.

💡 이 글에서 다루는 것

  • 왜 모델이 대화를 기억하지 못하는가 — 스테이트리스의 의미
  • 그래서 생기는 세 가지 문제 — 저장 / 비용 / 한계
  • 맥락 유지를 4층으로 분해하기
  • Bedrock 특유의 제약 — 자동 캐싱·중간 시스템 메시지가 없어요
  • 요청 한 번을 해부해보기 (최소 코드)
  • 4부작 로드맵



1. 모델은 대화를 기억하지 않아요

먼저 이 사실을 확실히 해두고 가야 뒤의 설계가 다 이해돼요.

Bedrock 에 요청을 보낼 때 우리가 넘기는 건 messages 라는 배열 하나예요. 모델은 그 배열만 보고 답합니다. 그 배열에 없는 건 세상에 존재하지 않는 것과 같아요. 방금 전 턴이든, 5분 전 턴이든 마찬가지예요.

1턴 요청:  messages = [ 사용자: "내 이름은 재현이야" ]
1턴 응답:  "안녕하세요 재현님!"

2턴 요청:  messages = [ 사용자: "내 이름 뭐였지?" ]      ← 1턴이 사라졌음
2턴 응답:  "이름을 알려주지 않으셨어요"                   ← 당연한 결과

즉 챗봇이 기억하는 것처럼 보이려면, 우리가 매 턴마다 이전 대화 전체를 다시 실어 보내야 해요.

2턴 요청:  messages = [
             사용자:    "내 이름은 재현이야",
             어시스턴트: "안녕하세요 재현님!",
             사용자:    "내 이름 뭐였지?"
           ]
2턴 응답:  "재현님이시죠!"                                ← 이제 됨

여기서 오해 하나를 짚고 갈게요. “세션 ID 만 넘기면 알아서 이어주는 옵션이 있지 않나요?” 라고 자주 물어보시는데, Messages API 에는 없어요. 대화를 서버가 들고 있는 방식(session_id 로 이어 쓰는 방식)은 별도의 에이전트 제품군에서 제공하는 기능이고, Bedrock 의 Claude Messages API 는 아니에요. 우리가 직접 들고 다녀야 합니다.

⚠️ 스테이트리스가 나쁜 설계라서 그런 게 아니에요. 요청이 서버 상태에 의존하지 않으니 어느 인스턴스로 가도 결과가 같고, Lambda 처럼 다 죽었다 살아나는 런타임에서도 문제가 없어요. 대신 그 대가로 “기억”이라는 숙제가 우리 쪽으로 넘어옵니다.



2. 그래서 생기는 세 가지 문제

“이전 대화를 다 다시 보낸다”는 한 문장이, 실제로는 세 개의 서로 다른 문제를 만들어요. 이 세 개를 구분해서 봐야 설계가 정리됩니다.

문제 증상 어디서 해결
저장 이전 대화를
어디서 꺼내오나
세션 스토어
(2편)
비용 같은 앞부분을
매번 다시 계산
프롬프트 캐싱
(3편)
한계 대화가 길어지면
창이 넘침
압축·요약
(4편)

하나씩 감만 잡아둘게요.

  • 저장 — 대화를 프로세스 메모리에 담아두는 건 로컬에서 테스트할 때만 됩니다. 서버가 두 대만 돼도, 혹은 Lambda 로 올리면 다음 요청이 다른 인스턴스로 가서 대화가 통째로 사라져요. 외부 저장소가 필요합니다.
  • 비용 — 20턴짜리 대화의 20번째 요청은 앞의 19턴을 입력 토큰으로 다시 넣어요. 그래서 대화가 길어질수록 한 턴의 비용이 계속 올라갑니다. 턴 수에 대해 대략 제곱으로 늘어난다고 보면 돼요. 이게 챗봇 청구서가 예상보다 크게 나오는 가장 흔한 이유예요.
  • 한계 — 컨텍스트 창이 아무리 커도 결국 찹니다. 그리고 실무에서는 창이 차기 전에 비용과 지연이 먼저 아파와요. 그래서 “넘치면 어떻게 하나”가 아니라 “적당한 크기로 계속 유지하려면 어떻게 하나”가 진짜 질문이에요.



3. 맥락 유지를 4층으로 분해하기

위 세 문제를 그대로 층으로 쌓으면 설계가 나와요. 저는 여기에 “세션을 넘어서는 기억” 하나를 더 붙여서 4층으로 봅니다.

하는 일 대표 수단
1. 세션 스토어 대화를 원문 그대로
쌓고 꺼낸다
DynamoDB
· S3
2. 프롬프트 캐싱 안 바뀐 앞부분을
재사용한다
cache_control
브레이크포인트
3. 컨텍스트 관리 길어진 히스토리를
줄인다
압축 · 정리
· 요약
4. 장기 기억 세션이 끝나도
남길 걸 남긴다
요약 카드
· 검색
  • 1층 — 세션 스토어가 제일 먼저예요. 여기서 스키마를 잘못 잡으면 위 세 층이 전부 흔들립니다. 특히 흔한 함정이 하나 있는데, 응답에서 텍스트만 뽑아서 저장하는 거예요. 그러면 도구 호출 블록이나 사고 블록이 사라져서 다음 턴 요청이 그냥 거절당해요. (2편 주제)
  • 2층 — 프롬프트 캐싱은 비용 곡선을 눌러주는 층이에요. 캐시는 앞부분이 완전히 똑같을 때만 먹기 때문에, 프롬프트를 조립하는 코드의 “순서”가 곧 캐시 적중률이 됩니다. 그래서 이건 요령이 아니라 설계예요. (3편 주제)
  • 3층 — 컨텍스트 관리는 자르기·정리·압축·요약 네 가지 중 무엇을 언제 쓸지 고르는 층입니다. 정답이 하나가 아니고, 대화 성격에 따라 갈려요. (4편 주제)
  • 4층 — 장기 기억은 “이 사용자는 존댓말을 싫어한다” 처럼 세션이 끝나도 남아야 하는 것을 다루는 층이에요. 대화 히스토리를 무한정 들고 다니는 게 아니라, 압축된 사실만 밖에 두고 필요할 때 꺼내 씁니다.

✅ 순서가 중요해요. 1층 → 2층 → 3층 순으로 쌓아야 합니다. 저장 스키마가 정해지지 않은 상태에서 캐싱을 먼저 붙이면 캐시가 왜 안 먹는지 추적이 안 되고, 캐싱 없이 압축을 먼저 붙이면 압축이 캐시를 깨는 트레이드오프를 아예 못 봅니다.



4. Bedrock 에서만 달라지는 것

여기가 이 글에서 제일 실용적인 부분이에요. Bedrock 의 Claude 는 Anthropic 이 직접 제공하는 API 와 같은 Messages API 모양을 쓰지만, 파트너가 운영하는 경로라서 일부 기능이 빠져 있어요. 이걸 모르고 설계하면 나중에 갈아엎게 됩니다.

4-1. 클라이언트와 모델 ID

먼저 붙이는 방법부터요. Bedrock 전용 클라이언트를 써야 하고, 모델 ID 에 anthropic. 접두사가 붙어요.

pip install -U "anthropic[bedrock]"
from anthropic import AnthropicBedrockMantle

# 리전은 필수예요. 자격증명은 표준 AWS 체인(환경변수 → 프로파일 → IAM 롤)을 따릅니다.
client = AnthropicBedrockMantle(aws_region="us-west-2")

response = client.messages.create(
    model="anthropic.claude-opus-5",   # ← anthropic. 접두사 필수
    max_tokens=16000,
    messages=[{"role": "user", "content": "안녕!"}],
)
print(response.content[0].text)

두 가지만 기억하면 돼요.

  • 클라이언트는 AnthropicBedrockMantle 을 쓰세요. 이름이 비슷한 AnthropicBedrock 은 예전 bedrock-runtimeInvokeModel 경로라서 새로 만드는 코드에는 권장하지 않아요.
  • 모델 ID 는 anthropic. 접두사를 붙입니다. 접두사 없이 claude-opus-5 를 넣으면 그대로 실패해요. 반대로 Anthropic 직접 API 용 코드를 Bedrock 으로 옮길 때 접두사를 빼먹는 게 가장 흔한 실수예요.

4-2. 맥락 유지에 영향 있는 가용성

이 시리즈에서 쓸 기능들만 골라 정리했어요. ✅ 정식 / β 베타 / ❌ 미지원입니다.

기능 Bedrock 메모
프롬프트 캐싱 5분 · 1시간 TTL
자동 프롬프트 캐싱 직접 배치해야 함
토큰 카운팅 사전 계측 가능
압축(compaction) β 베타 헤더 필요
컨텍스트 편집 β 베타 헤더 필요
1M 컨텍스트 창  
중간 시스템 메시지 우회 필요
Files API S3 로 대체
Batches API  

특히 이 두 개가 설계를 바꿉니다.

  • 자동 프롬프트 캐싱이 없어요. Anthropic 직접 API 에서는 요청 최상단에 cache_control 을 한 번 던져두면 알아서 마지막 캐시 가능 블록에 붙여줘요. Bedrock 에서는 그게 안 됩니다. 우리가 어느 블록에 붙일지 직접 지정해야 해요. 3편에서 이 배치를 자세히 다룹니다.
  • 중간 시스템 메시지가 없어요. 대화 중간에 운영자 지시(“지금부터 간결 모드”)를 넣고 싶을 때, messages 배열에 role: "system" 항목을 끼워넣는 방법이 있는데 Bedrock 에서는 미지원이에요. 최상단 system 을 고치면 캐시가 통째로 날아가므로, 사용자 턴 안에 표시용 블록으로 끼워넣는 우회를 씁니다. 이것도 3편에서요.

⚠️ 여기 적은 가용성은 이 글을 쓴 시점 기준이에요. 베타(β) 로 표시한 것들은 특히 변합니다. 설계에 넣기 전에 실제 요청 한 번 던져서 확인하는 걸 권해요. 그리고 요금 단가는 Anthropic 직접 API 와 다릅니다 — 비용 계산할 때는 반드시 Bedrock 요금표를 보세요.



5. 요청 한 번을 해부해보기

층 이야기를 코드로 한 번 보고 가면 감이 잡혀요. 아래는 맥락 유지를 다 넣은 형태의 골격이에요. 지금은 각 조각이 왜 저기 있는지만 눈에 담으면 됩니다.

from anthropic import AnthropicBedrockMantle

client = AnthropicBedrockMantle(aws_region="us-west-2")

SYSTEM = """당신은 사내 업무를 돕는 어시스턴트입니다.
답변은 한국어로, 근거를 함께 제시하세요.
... (길고, 절대 바뀌지 않는 지시문) ..."""

def answer(session_id: str, user_text: str) -> str:
    # 1층 — 세션 스토어에서 이전 대화를 원문 그대로 꺼냄
    history = load_messages(session_id)

    messages = history + [
        {"role": "user", "content": [{"type": "text", "text": user_text}]}
    ]

    response = client.messages.create(
        model="anthropic.claude-opus-5",
        max_tokens=16000,
        # 2층 — 절대 안 바뀌는 시스템 프롬프트 끝에 캐시 경계
        system=[{
            "type": "text",
            "text": SYSTEM,
            "cache_control": {"type": "ephemeral"},
        }],
        messages=messages,
    )

    # 1층 — content 를 통째로 저장 (텍스트만 뽑으면 안 됨)
    append_message(session_id, "user", messages[-1]["content"])
    append_message(session_id, "assistant", response.content)

    # 캐시가 먹었는지 확인 — 0 이면 뭔가 앞부분을 흔들고 있다는 뜻
    print("cache read:", response.usage.cache_read_input_tokens)

    return "".join(b.text for b in response.content if b.type == "text")

이 20여 줄에 시리즈의 뼈대가 다 들어 있어요.

  • load_messages / append_message1층입니다. 2편에서 이 두 함수를 실제로 짭니다.
  • system 블록의 cache_control2층이에요. 3편에서 이걸 어디에 몇 개 붙이는지, 왜 자꾸 안 먹는지를 파헤칩니다.
  • 지금 이 코드에는 3층이 없어요. 대화가 길어지면 messages 가 계속 자라기만 합니다. 4편에서 여기에 압축을 끼워넣어요.
  • cache_read_input_tokens 를 찍는 이유는, 캐싱이 실패해도 에러가 안 나기 때문이에요. 조용히 제값을 내고 있을 뿐이죠. 그래서 계측이 필수예요.



6. 4부작 로드맵

이 시리즈가 어디로 가는지 한 장으로 정리하면 이래요.

  • 1편(지금) — 왜 기억을 못 하나, 문제 세 개, 4층 구조, Bedrock 제약.
  • 2편 — 세션 스토어 — DynamoDB 로 대화를 쌓는 스키마. 블록 원문을 왜 그대로 저장해야 하는지, 도구 호출 짝을 어떻게 지키는지, 400KB 를 넘는 결과는 어떻게 밀어내는지.
  • 3편 — 프롬프트 캐싱 — prefix 일치라는 단 하나의 규칙, 브레이크포인트 배치 패턴, 캐시를 조용히 죽이는 범인들, Bedrock 전용 우회로.
  • 4편 — 컨텍스트 한계 — 자르기·정리·압축·요약 네 가지 비교, 압축 블록을 잃어버리지 않는 법, 세션을 넘어가는 장기 기억, 운영 체크리스트.

정리하면 Bedrock 챗봇의 맥락 유지는 “대화를 원문으로 쌓고(1층), 안 바뀐 앞부분은 캐시로 재사용하고(2층), 길어지면 줄이고(3층), 세션 밖에 남길 건 따로 남기는(4층)” 일이에요. 나머지 편들은 각 층을 하나씩 깊게 파는 거고요.

일단 오늘은 여기까지…..
다음 글에서는 가장 아래층인 세션 스토어부터 짜볼게요. 여기서 스키마를 잘못 잡으면 위층이 다 흔들리니까, 제일 공들여야 하는 부분이에요.


다음 글 →: (2/4) 대화 저장소 설계 — DynamoDB 세션 스토어와 메시지 스키마