Lab 01: invoke_model (단일 턴)
학습 흐름
이번 Lab에서는 AWS Bedrock 위에서 동작하는 AI 모델을 가장 낮은 수준의 API인 invoke_model로 직접 호출합니다. 화려한 라이브러리나 추상화 계층 없이, 모델이 요구하는 요청 형식(body)을 내 손으로 만들어 보내고, 돌아온 응답 JSON을 내 손으로 파싱해서 텍스트를 꺼냅니다.
이 과정은 일부러 조금 불편하게 설계되어 있습니다. “왜 이렇게 손이 많이 가지?”라는 감각을 직접 체감하는 것이 목표입니다. 그 불편함을 기억해 두면, 이후 Lab에서 등장하는 고수준 API(Converse)가 왜 만들어졌는지, 무엇을 대신 처리해 주는지를 몸으로 이해하게 됩니다.
- 환경 점검 —
00_check_env.py실행해 초록불 확인 → verify: ✅ 라인이 모두 출력된다 - invoke_model 개념 이해 — body를 만들어 보내고 JSON을 파싱한다 → verify: body 3요소를 말로 설명할 수 있다
respond()구현 — 단일 턴 응답 함수 완성 → verify: 콘솔 챗봇이 질문에 답한다- 저수준의 불편함 체감 — 모델별 body가 다르다는 사실 확인 → verify: Titan이면 body가 왜 달라지는지 설명할 수 있다
학습 목표
- AWS Bedrock의
bedrock-runtime클라이언트로 AI 모델을 직접 호출하는 방법을 익힙니다 - Anthropic Claude 모델이 요구하는 요청 body(
anthropic_version,max_tokens,messages)를 직접 작성합니다 - 모델이 돌려준 응답 JSON 구조를 직접 파싱하여 텍스트만 꺼냅니다
- “저수준 API = 형식을 내가 책임진다”는 의미를 단일 턴 챗봇을 만들며 체감합니다
Step 1: invoke_model이란 무엇인가
모델 호출의 본질
AI 모델을 호출한다는 것은 결국 “요청 JSON을 만들어 보내고, 응답 JSON을 받아 파싱하는 일”입니다. 우리가 평소 쓰는 ChatGPT 같은 서비스는 이 과정을 화면 뒤에서 숨겨주지만, Bedrock의 invoke_model은 그 과정을 거의 그대로 드러냅니다.
invoke_model은 Bedrock이 제공하는 가장 저수준(low-level)의 호출 방식입니다. 두 가지를 우리가 직접 책임져야 합니다.
- 요청 body — 모델이 이해할 수 있는 JSON 형식을 직접 만들어 넘긴다
- 응답 파싱 — 모델이 돌려준 JSON에서 필요한 텍스트를 직접 꺼낸다
graph LR A["사용자 입력"] --> B["body(JSON) 작성"] B --> C["invoke_model 호출"] C --> D["응답 JSON 수신"] D --> E["json.loads 파싱"] E --> F["텍스트 추출"]
왜 일부러 불편한 방식부터 배우는가
이 방식의 핵심은 body의 모양을 우리가 외워서 직접 써야 한다는 점입니다. Anthropic Claude 모델이라면 anthropic_version, max_tokens, messages 세 가지를 넣어야 하고, 만약 Amazon Titan이나 Meta Llama 모델이었다면 body의 모양이 완전히 달라집니다. 즉 모델마다 요청 형식이 다르고, 그 차이를 코드에서 우리가 직접 떠안아야 합니다.
이것이 바로 “저수준”의 의미입니다. 편의는 적지만, 무슨 일이 일어나는지가 투명하게 보입니다. 이 단계에서 느끼는 번거로움은 의도된 것이며, 이후 Lab에서 고수준 API가 이 부담을 어떻게 덜어주는지를 비교할 발판이 됩니다.
📚 공식 문서에서 직접 확인 — “어떤 모델을 쓸 수 있나”와 “모델마다 body(요청 형식)가 어떻게 다른가”는 AWS 공식 문서에 정리돼 있습니다.
- 지원되는 파운데이션 모델 — Bedrock에서 호출 가능한 모델과 모델 ID 목록
- 모델별 추론 파라미터·응답 형식 — Claude·Titan·Llama 등 모델마다
invoke_modelbody가 어떻게 달라지는지
Step 2: 환경 점검 — 초록불 확인
본격적인 구현에 앞서, 내 실습 환경이 Bedrock 모델을 호출할 준비가 되어 있는지 한 번에 점검합니다. 제공 스크립트 00_check_env.py를 실행하면 아래 네 가지를 차례로 확인하고 결과를 출력합니다.
무엇을 점검하는가
- boto3 설치와 버전 — AWS SDK가 설치되어 있고, Converse API를 지원하는 1.34 이상인지
- AWS 자격증명 — EC2에 부여된 IAM Role(또는 로컬의
AWS_PROFILE)을 boto3가 인식하는지 - 저수준 invoke_model 권한 — 최신 Haiku 4.5를
invoke_model로 호출할 수 있는지 - 인퍼런스 프로파일 권한 — 최신 Haiku 4.5(
us.프로파일 ID)를 Converse로 호출할 수 있는지
스크립트의 점검 로직은 다음과 같습니다. 특히 자격증명 확인 부분은 STS의 get_caller_identity로 “지금 나는 누구로 인식되는가”를 출력해 줍니다.
# 2) 자격증명
ident = boto3.client("sts", region_name=REGION).get_caller_identity()
print(f"✅ 자격증명 OK · account={ident['Account']} · arn={ident['Arn']}")
# 3-a) 저수준 invoke_model (Haiku 4.5 인퍼런스 프로파일)
body = {
"anthropic_version": ANTHROPIC_VERSION,
"max_tokens": 16,
"messages": [{"role": "user", "content": "ping"}],
}
rt.invoke_model(modelId=MODEL_HAIKU, body=json.dumps(body))
print(f"✅ invoke_model 호출 OK · {MODEL_HAIKU}")"ping"이라는 짧은 메시지를 실제로 모델에 보내 봄으로써, 권한과 네트워크가 모두 정상인지 확인합니다. max_tokens를 16으로 작게 둔 것은 점검 목적상 비용과 시간을 아끼기 위함입니다.
실습
터미널에서 환경 점검 스크립트를 실행하세요.
# 실습 코드 받기 (최초 1회) — 홈 디렉터리에 clone
git clone https://github.com/nxtcloud-org/nxt-workshop-code.git
# 챗봇 실습 폴더로 이동
cd ~/nxt-workshop-code/bedrock-chatbot/starter
# 환경 점검 실행
python 00_check_env.py모든 줄에 초록불(✅)이 뜨고 마지막에 ”🎉 모든 점검 통과!” 메시지가 보이면 준비 완료입니다.

빨간불이 뜬다면
- 자격증명 실패(❌) — EC2 IAM Role이 인스턴스에 붙어 있는지, 또는 로컬이라면
AWS_PROFILE이 올바른지 확인합니다. - invoke_model 실패 — 해당 모델에 대한 invoke 권한이 있는지 확인합니다.
- Converse(인퍼런스 프로파일) 실패 —
us.anthropic.*프로파일 invoke 권한이 있는지, 리전이us-east-1인지 확인합니다.
-
python 00_check_env.py를 실행했다 - boto3 · 자격증명 · invoke_model · Converse 네 줄이 모두 초록불이다
- “모든 점검 통과” 메시지를 확인했다
Step 3: 제공 모듈 이해 — config.py와 chat_ui.py
실습은 두 개의 제공 모듈(강사가 만들어 둔 블랙박스) 위에서 진행됩니다. 학습자는 이 파일들을 수정하지 않으며, 오직 lab 파일의 respond() 함수 하나만 구현합니다. 두 모듈이 무엇을 대신해 주는지 먼저 이해하면, 내가 채워야 할 부분이 명확해집니다.
config.py — 리전과 모델 ID, 클라이언트 생성
리전, 모델 ID, bedrock-runtime 클라이언트 생성을 한곳에 모은 설정 모듈입니다. 이번 Lab에서 쓰는 핵심 값은 다음과 같습니다.
REGION = "us-east-1"
# 주력 실습 모델 — 최신 Haiku. "us." 인퍼런스 프로파일 ID (invoke_model·Converse 공통)
MODEL_HAIKU = "us.anthropic.claude-haiku-4-5-20251001-v1:0"
# invoke_model의 Anthropic 바디에 들어가는 고정 값
ANTHROPIC_VERSION = "bedrock-2023-05-31"
def get_runtime():
return boto3.client("bedrock-runtime", region_name=REGION)MODEL_HAIKU— 이 과정의 주력 모델us.anthropic.claude-haiku-4-5-20251001-v1:0입니다. 최신 모델은us.로 시작하는 인퍼런스 프로파일 ID로 호출합니다(자세한 이유는 Lab 02에서 다룹니다).invoke_model이든Converse든 이 ID 하나로 호출합니다.ANTHROPIC_VERSION— Bedrock의 Anthropic 모델 body에 반드시 넣어야 하는 고정 문자열입니다. 우리가 외워야 하는 “형식”의 대표적인 예입니다.get_runtime()—bedrock-runtime클라이언트를 만들어 돌려줍니다. EC2에서는 부여된 IAM Role 자격증명을, 로컬에서는AWS_PROFILE을 boto3가 자동으로 사용합니다.
chat_ui.py — 콘솔 챗 UI
입력 루프, 대화 히스토리 관리, 화면 출력을 모두 처리하는 콘솔 UI 모듈입니다. 학습자는 화면이나 입력 루프를 직접 만들 필요 없이, “응답 함수 하나”만 넘기면 됩니다.
이 모듈은 대화를 다음과 같은 중립 포맷으로 관리합니다.
history = [
{"role": "user", "text": "안녕"},
{"role": "assistant", "text": "안녕하세요!"},
]이 중립 포맷을 각 API가 요구하는 형식(invoke_model body / Converse 형식)으로 변환하는 것이 바로 학습자가 lab에서 연습할 부분입니다. 응답 함수가 지켜야 할 형식은 간단합니다.
- 일반 함수:
respond(history) -> str - 스트리밍 함수:
respond(history) -> 제너레이터(이번 Lab에서는 일반 함수만)
run() 함수에 single_turn=True를 넘기면, 매 입력을 독립적으로 처리합니다. 즉 이전 대화 맥락 없이 직전 입력 하나만 모델에 전달합니다. 이번 Lab이 “단일 턴”인 이유가 바로 이 옵션 때문입니다.
Step 4: respond() 구현 — 단일 턴 응답
이제 학습자가 직접 채울 핵심 코드입니다. lab1_invoke.py의 respond() 함수는 딱 세 단계로 이뤄집니다. 한 줄씩 따라가 봅시다.
전체 코드
import json
from config import get_runtime, MODEL_HAIKU, ANTHROPIC_VERSION
rt = get_runtime()
def respond(history: list[dict]) -> str:
"""단일 턴: 직전 사용자 입력 하나만 모델에 보낸다."""
user_text = history[-1]["text"]
# (1) 모델이 요구하는 형식대로 body 를 '직접' 만든다.
body = {
"anthropic_version": ANTHROPIC_VERSION, # Bedrock 의 Anthropic 모델 고정값
"max_tokens": 512,
"messages": [
{"role": "user", "content": user_text},
],
}
# (2) invoke_model 호출. body 는 JSON 문자열로 직렬화해서 넘긴다.
resp = rt.invoke_model(
modelId=MODEL_HAIKU,
body=json.dumps(body),
)
# (3) 응답 스트림을 읽어 JSON 으로 파싱하고, 텍스트를 꺼낸다.
payload = json.loads(resp["body"].read())
return payload["content"][0]["text"]
if __name__ == "__main__":
from chat_ui import run
run(respond, single_turn=True, title="Lab1 · invoke_model (단일 턴)")한 줄씩 뜯어보기
-
user_text = history[-1]["text"]— UI가 넘겨준 히스토리에서 가장 마지막 항목의 텍스트만 꺼냅니다. 단일 턴이므로 이전 대화는 보지 않고, 직전 사용자 입력 하나에만 답합니다. 바로 이 지점이 “기억 없는 챗봇”을 만드는 부분입니다. -
body = {...}— Anthropic Claude 모델이 요구하는 요청 형식을 직접 딕셔너리로 만듭니다. 세 가지 필수 요소가 들어갑니다.anthropic_version— Bedrock의 Anthropic 모델에 넣는 고정값(bedrock-2023-05-31). 빼먹으면 호출이 실패합니다.max_tokens— 응답으로 생성할 최대 토큰 수. 여기서는 512로 두어 적당한 길이의 답변을 받습니다.messages— 대화 메시지 배열. 각 메시지는role(user/assistant)과content(텍스트)로 구성됩니다. 단일 턴이라user메시지 하나만 들어갑니다.
-
rt.invoke_model(modelId=..., body=json.dumps(body))— 실제 호출입니다. 주목할 점은 body를 JSON 문자열로 직렬화(json.dumps) 해서 넘긴다는 것입니다.invoke_model은 딕셔너리가 아니라 바이트/문자열 형태의 body를 받습니다. 이것도 우리가 직접 챙겨야 하는 저수준의 디테일입니다. -
payload = json.loads(resp["body"].read())— 응답의body는 스트림 객체입니다..read()로 바이트를 읽고,json.loads로 다시 딕셔너리로 파싱합니다. 보낼 때 직렬화하고 받을 때 역직렬화하는, 이 왕복을 우리가 직접 처리합니다. -
return payload["content"][0]["text"]— 파싱된 응답 JSON에서 텍스트를 꺼냅니다. Claude 응답은content배열 안에 블록들이 들어 있고, 그 첫 번째 블록의text필드에 답변이 담겨 있습니다. 이 응답 구조 역시 모델마다 다르므로, 우리가 정확한 경로를 알고 있어야 합니다.
실습
respond()를 완성했으면 lab 파일을 실행하세요.
# Lab 1 챗봇 실행
python lab1_invoke.py콘솔에 챗 배너가 뜨면 질문을 입력해 보세요. 예: “안녕, 너는 누구야?” 모델이 답변을 출력하면 성공입니다.
┌──────────────────────────────────────────────────────────┐
💬 Lab1 · invoke_model (단일 턴)
종료: exit / quit / :q · Ctrl-C
└──────────────────────────────────────────────────────────┘
🙋 나 > 안녕, 너는 누구야?
🤖 봇 > 안녕하세요! 저는 AI 어시스턴트입니다. ...
-
respond()의 (1)(2)(3) 단계를 모두 채웠다 -
python lab1_invoke.py로 챗봇이 실행된다 - 질문을 입력하면 모델이 답변을 출력한다
Step 5: 저수준의 불편함 체감 — 단일 턴과 모델별 body
구현이 동작했다면, 이제 이 방식의 한계와 불편함을 의도적으로 체감해 봅니다. 이 감각이 다음 Lab들의 출발점입니다.
체감 ① — 챗봇은 기억이 없다
챗봇에게 다음처럼 두 번 물어보세요.
🙋 나 > 내 이름은 지민이야.
🤖 봇 > 반갑습니다, 지민님!
🙋 나 > 내 이름이 뭐였지?
🤖 봇 > 죄송하지만 이름을 말씀해 주시지 않았습니다.분명히 방금 이름을 알려줬는데 기억하지 못합니다. 왜일까요? respond()가 history[-1], 즉 직전 입력 하나만 모델에 보내기 때문입니다. 단일 턴 챗봇은 매 호출이 완전히 독립적이라, 이전 대화를 전혀 참조하지 않습니다. 모델 자체에는 “기억” 기능이 없고, 맥락을 유지하려면 이전 대화 전체를 매번 다시 보내야 합니다. 이것이 다음 Lab(멀티턴)에서 해결할 과제입니다.
체감 ② — 모델이 바뀌면 body도 바뀐다
우리가 작성한 body는 Anthropic Claude 전용 형식입니다. 만약 같은 작업을 Amazon Titan 모델로 했다면, body의 모양이 완전히 달라집니다. 예를 들어 Titan 임베딩 모델은 메시지 배열이 아니라 단순히 다음과 같은 형식을 요구합니다.
# Titan 임베딩 모델의 body — Claude와 전혀 다른 형식
body = {"inputText": "임베딩할 텍스트"}anthropic_version도 없고, messages 배열도 없고, max_tokens도 없습니다. 즉 invoke_model을 쓰는 한 모델마다 요청 형식과 응답 구조를 우리가 외우고 책임져야 합니다. 모델을 교체하려면 body 작성 코드와 응답 파싱 코드를 모두 바꿔야 한다는 뜻입니다.
이 불편함을 기억하세요
| 불편함 | 이번 Lab에서의 모습 | 어디서 해결되나 |
|---|---|---|
| 모델별 body를 직접 작성 | anthropic_version 등을 외워서 씀 | Converse 단계 |
| 응답 JSON 경로를 직접 파싱 | content[0]["text"] 직접 추출 | Converse 단계 |
| 대화 맥락이 유지되지 않음 | 단일 턴, 매번 독립 호출 | 멀티턴(Lab 02) |
저수준 API는 무슨 일이 일어나는지 투명하게 보여주는 대신, 그 모든 형식을 우리가 떠안습니다. 이 “투명하지만 번거로운” 경험이 있어야, 다음 단계의 고수준 API가 제공하는 편의의 가치를 제대로 평가할 수 있습니다.
- 이름을 알려준 뒤 다시 물어봐서 “기억하지 못함”을 확인했다
- 단일 턴 챗봇이 왜 맥락을 잃는지 설명할 수 있다
- Titan이면 body가 왜 달라지는지 설명할 수 있다
핵심 정리: invoke_model 단일 턴
| 단계 | 동작 | 코드 |
|---|---|---|
| 입력 추출 | 직전 사용자 입력 하나만 꺼냄 | history[-1]["text"] |
| body 작성 | Anthropic 형식 JSON 직접 구성 | {"anthropic_version", "max_tokens", "messages"} |
| 호출 | body를 문자열로 직렬화해 전송 | rt.invoke_model(modelId, body=json.dumps(body)) |
| 파싱 | 응답 스트림을 읽어 JSON으로 변환 | json.loads(resp["body"].read()) |
| 추출 | 응답 JSON에서 텍스트만 꺼냄 | payload["content"][0]["text"] |
invoke_model은 Bedrock의 가장 저수준 호출 방식으로, 요청 body 작성과 응답 파싱을 모두 우리가 책임집니다. 그 대가로 무슨 일이 일어나는지가 투명하게 보입니다. 단, 모델별로 형식이 다르고(Claude ≠ Titan) 대화 맥락도 유지되지 않는다는 불편함이 남습니다. 이 불편함이 다음 Lab들의 동기입니다.
🤔 생각해 보기 (다 끝냈다면)
max_tokens를 4로 주면 응답이 어디서 잘릴까요? 모델은 자기가 잘릴 걸 “알까요”? (힌트: 응답의stop_reason)- 같은 호출을 Titan 모델로 한다면, 이
body의 어디가 통째로 달라질까요?
다음 단계
단일 턴 챗봇이 동작하지만, 방금 알려준 이름조차 기억하지 못합니다. 모델에는 기억이 없고, 맥락을 유지하려면 이전 대화 전체를 매번 다시 보내야 하기 때문입니다. Lab 02에서는 history를 messages 배열로 변환해 전체 대화를 전송하는 멀티턴 챗봇을 구현하고, “기억 없는 챗봇”의 한계가 어떻게 해결되는지 확인하세요.
NxtCloud Workshop