NxtCloud NxtCloud Workshop / Amazon Bedrock 챗봇 핸즈온
로그인

Lab 05: 세 API 비교

학습 흐름

Lab 01부터 04까지 우리는 같은 “모델 호출”이라는 일을 세 가지 다른 방식으로 직접 해봤습니다. Lab 01에서 invoke_model로 한 번에 응답을 받았고, Lab 03에서 invoke_model_with_response_stream으로 글자가 흘러나오게 만들었으며, Lab 04에서 Converse로 페르소나 챗봇을 완성했습니다.

이번 마지막 Lab은 새 기능을 추가하지 않습니다. 대신 지금까지 만든 세 가지 호출 방식을 한 장으로 정리하고, 같은 질문을 세 방식으로 동시에 던지는 데모(lab5_compare_apis.py)를 직접 실행해 “왜 고수준 API(Converse)가 존재하는가”를 몸으로 체득합니다. 코드를 더 짜는 시간이 아니라, 무엇을 언제 써야 하는지 판단하는 근육을 기르는 시간입니다.

학습 목표
  • 세 가지 호출 방식(invoke_model / invoke_model_with_response_stream / Converse)의 요청 형식·응답 파싱 차이를 표로 정리합니다
  • 저수준 API가 “내가 형식을 다 책임지는” 구조이고, 고수준 API가 “표준 형식으로 통일하는” 구조임을 이해합니다
  • 같은 질문을 세 방식으로 호출하는 데모를 실행해 응답과 지연을 직접 비교합니다
  • 새 프로젝트에서 어떤 API를 기본값으로 선택하고, 언제 저수준으로 내려가야 하는지 판단 기준을 세웁니다
세 가지 호출 방식의 계층 구조

먼저 세 가지가 서로 어떤 관계인지 큰 그림을 잡습니다. 이들은 “더 좋은 것 / 더 나쁜 것”의 관계가 아니라, 추상화 계층이 다른 도구입니다.

graph TD
  A["같은 일: Claude 모델 호출"] --> B["저수준 계층"]
  A --> C["고수준 계층"]
  B --> D["invoke_model<br/>한 번에 응답"]
  B --> E["invoke_model_with_response_stream<br/>스트리밍 응답"]
  C --> F["Converse / converse_stream<br/>표준 통합 인터페이스"]

저수준(low-level) API인 invoke_model 계열은 모델에게 보낼 요청 본문(body)을 내가 직접 JSON으로 조립하고, 돌아온 응답 JSON도 내가 직접 파싱합니다. 자유도가 높은 대신 모델마다 형식이 다르고, 그 차이를 전부 내가 외우고 책임져야 합니다.

고수준(high-level) API인 Converse는 AWS가 모델별 차이를 안쪽으로 감추고 하나의 표준 형식으로 통일한 인터페이스입니다. messages, system, inferenceConfig라는 정해진 구조에 값만 채우면, 그 아래에서 Anthropic이든 Titan이든 Llama든 맞는 형식으로 변환해 호출합니다.

핵심 메시지는 한 문장으로 요약됩니다. 저수준은 자유롭지만 내가 다 책임지고, 고수준은 표준화로 편하다. 이번 Lab의 모든 비교는 결국 이 한 줄을 설명하기 위한 것입니다.

한눈 비교표: 세 호출 방식

Lab 01~04에서 직접 써 본 내용을 한 장으로 정리한 비교표입니다. 각 행은 실제로 코드를 짜다가 “불편하다” 혹은 “편하다”를 체감했던 지점입니다.

항목invoke_modelinvoke_model_with_response_streamConverse / converse_stream
계층저수준저수준(스트리밍)고수준(통합)
요청 형식모델별 body 직접 작성
(anthropic_version, max_tokens, messages)
동일 body표준 형식
(messages + system + inferenceConfig)
content 모양문자열
("content": "...")
문자열블록 리스트
("content": [{"text": "..."}])
시스템 프롬프트messages에 끼워넣기(번거로움)동일system= 별도 인자
응답 파싱json.loadscontent[0]["text"] 직접청크별 type 보고 delta.text 골라내기output.message.content[0].text
스트리밍불가(한 번에)가능(raw 청크 직접 처리)가능(converse_stream, 깔끔한 이벤트)
멀티턴내가 messages 직접 관리동일내가 관리하되 형식은 통일
모델 교체body가 모델군마다 다름동일modelId만 교체, 코드 0줄 변경
언제 쓰나세밀한 제어·특수 모델저수준에서 실시간 출력대부분의 실무 — 기본값으로 권장

표를 읽는 한 가지 요령: 오른쪽으로 갈수록 “AWS가 대신 해주는 일”이 늘어나고, 왼쪽으로 갈수록 “내가 직접 하는 일”이 늘어납니다. content 모양이 문자열에서 블록 리스트로 바뀌는 것, 시스템 프롬프트가 messages 안에 끼이다가 별도 인자로 빠지는 것 모두 같은 방향의 변화입니다.

코드로 본 차이

같은 “안녕”이라는 질문을 세 방식으로 보냈을 때, 코드가 어떻게 달라지는지를 나란히 놓고 봅니다. 줄 수보다 어디가 다른지에 집중하세요.

① invoke_model — body를 내가 만든다

# body(JSON)를 직접 조립한다
body = {
    "anthropic_version": "bedrock-2023-05-31",
    "max_tokens": 512,
    "messages": [{"role": "user", "content": "안녕"}],
}
resp = rt.invoke_model(modelId=MID, body=json.dumps(body))
# 응답 JSON을 직접 판다
text = json.loads(resp["body"].read())["content"][0]["text"]

anthropic_version은 Anthropic 모델에만 필요한 필드입니다. Titan이나 Llama로 바꾸면 이 키 자체가 사라지고 body 구조가 완전히 달라집니다. content는 단순 문자열이고, 응답은 resp["body"].read()로 바이트를 읽어 json.loads한 뒤 content[0]["text"]까지 직접 파고들어야 텍스트가 나옵니다.

② invoke_model_with_response_stream — 청크 type을 보고 골라낸다

# body는 ①과 동일하다
resp = rt.invoke_model_with_response_stream(modelId=MID, body=json.dumps(body))
for ev in resp["body"]:
    d = json.loads(ev["chunk"]["bytes"])
    # 여러 type 중 우리가 원하는 건 content_block_delta 뿐
    if d.get("type") == "content_block_delta":
        print(d["delta"]["text"], end="")

요청 body는 ①과 똑같습니다. 차이는 응답입니다. 응답이 한 덩어리가 아니라 message_start, content_block_delta, message_stop 같은 여러 종류의 이벤트 스트림으로 옵니다. 우리는 그중 실제 글자가 담긴 content_block_delta만 골라 delta.text를 꺼내야 합니다. type이 추가·변경되면 이 분기 코드도 따라 바뀌어야 합니다.

③ Converse — 표준 형식, system 분리, 모델만 바꿔도 동작

resp = rt.converse(
    modelId=MID,
    system=[{"text": "너는 친절한 멘토야"}],
    messages=[{"role": "user", "content": [{"text": "안녕"}]}],
    inferenceConfig={"maxTokens": 512, "temperature": 0.7},
)
text = resp["output"]["message"]["content"][0]["text"]

anthropic_version 같은 모델 전용 필드가 없습니다. 시스템 프롬프트는 system=이라는 별도 인자로 깔끔하게 분리됩니다. content[{"text": ...}] 블록 리스트 형태인데, 이 구조 덕분에 나중에 이미지·문서 같은 다른 블록을 같은 자리에 끼워넣을 수 있습니다. 그리고 결정적으로, modelId만 다른 모델로 바꿔도 이 코드는 한 줄도 고치지 않고 그대로 동작합니다.

실행 데모: lab5_compare_apis.py

이제 말 대신 직접 실행해 봅니다. lab5_compare_apis.py같은 질문 하나를 세 방식으로 호출해서, 응답 내용과 걸린 시간을 한 화면에 나란히 출력합니다.

데모의 구조

세 함수가 각각 하나의 호출 방식을 담당하고, 응답 텍스트는 함수 안에서 직접 출력한 뒤 걸린 시간(초)만 반환합니다. 질문은 세 함수가 똑같이 씁니다.

QUESTION = "클라우드가 뭔지 초등학생도 알게 한 문장으로 설명해줘."

via_invoke_model — 저수준, 한 번에 응답을 받는 방식입니다. body를 직접 만들고 응답 JSON을 직접 팝니다.

def via_invoke_model() -> float:
    t0 = time.time()
    body = {
        "anthropic_version": ANTHROPIC_VERSION,
        "max_tokens": 256,
        "messages": [{"role": "user", "content": QUESTION}],
    }
    resp = rt.invoke_model(modelId=MODEL_HAIKU, body=json.dumps(body))
    text = json.loads(resp["body"].read())["content"][0]["text"]
    print(f"  {text.strip()}")          # 다 받은 뒤 한 번에 출력
    return time.time() - t0

via_invoke_stream — 저수준 스트리밍입니다. 청크가 도착할 때마다 content_block_deltadelta.text즉시 print(..., flush=True)로 흘려보냅니다. 그래서 ①③은 답이 한 번에 뜨지만, ②만 글자가 실시간으로 흘러나오는 것을 화면에서 직접 볼 수 있습니다.

def via_invoke_stream() -> float:
    t0 = time.time()
    body = {
        "anthropic_version": ANTHROPIC_VERSION,
        "max_tokens": 256,
        "messages": [{"role": "user", "content": QUESTION}],
    }
    resp = rt.invoke_model_with_response_stream(modelId=MODEL_HAIKU, body=json.dumps(body))
    print("  ", end="")
    for event in resp["body"]:
        data = json.loads(event["chunk"]["bytes"])
        if data.get("type") == "content_block_delta":
            print(data["delta"].get("text", ""), end="", flush=True)   # ← 도착 즉시 출력
    print()
    return time.time() - t0

via_converse — 고수준 통합입니다. messages/inferenceConfig만 넘기면 끝이고, 응답은 output.message.content[0].text 한 경로로 꺼냅니다. body를 조립하는 줄도, 청크를 분기하는 줄도 없습니다.

def via_converse() -> float:
    t0 = time.time()
    resp = rt.converse(
        modelId=MODEL_HAIKU,
        messages=[{"role": "user", "content": [{"text": QUESTION}]}],
        inferenceConfig={"maxTokens": 256},
    )
    text = resp["output"]["message"]["content"][0]["text"]
    print(f"  {text.strip()}")          # 다 받은 뒤 한 번에 출력
    return time.time() - t0

세 함수를 차례로 실행합니다. 라벨을 먼저 찍고, 각 함수가 응답을 직접 출력한 뒤(②는 글자가 실시간으로 흘러나옴) 소요 시간을 붙입니다.

for label, fn in [
    ("① invoke_model (저수준, 한 번에)", via_invoke_model),
    ("② invoke_model_stream (저수준, 실시간 스트리밍)", via_invoke_stream),
    ("③ Converse (고수준)", via_converse),
]:
    print(f"[{label}]")
    dt = fn()           # 함수가 응답을 직접 출력(②는 글자가 실시간으로 흘러나온다)
    print(f"  ⏱ {dt:.2f}s\n")

세 함수 모두 같은 모델 MODEL_HAIKU(주력 모델 us.anthropic.claude-haiku-4-5-20251001-v1:0)를 사용합니다. 따라서 **응답 내용은 비슷하고, 차이는 코드 형태와 출력 방식(②만 글자가 실시간으로 흘러나옴)**에서 드러납니다. 같은 모델·같은 질문이므로 “어느 API가 더 똑똑한가”가 아니라 “어느 API가 더 쓰기 편한가”를 보는 데모입니다.

실습

code-server 터미널에서 실습 폴더로 이동해 데모를 실행하세요.

# 챗봇 실습 폴더로 이동
cd ~/nxt-workshop-code/bedrock-chatbot/starter

# 세 방식 비교 데모 실행
python lab5_compare_apis.py

질문 한 줄 아래로 세 방식이 차례로 실행됩니다 — ②는 글자가 실시간으로 흘러나오고 ①③은 한 번에 뜨며, 각 방식의 소요 시간과 마지막 “코드로 본 차이” 요약이 함께 나옵니다.

출력을 보며 다음 세 가지를 직접 확인하세요.

  • 응답 내용 — 같은 모델·같은 질문이므로 세 답변이 거의 비슷하다(API가 답을 바꾸지 않는다)
  • 소요 시간 — 단발 호출은 측정 환경에 따라 들쭉날쭉할 수 있으니, 숫자 자체보다 “세 방식 모두 같은 모델을 부른다”는 사실에 주목
  • 코드 형태 — ①②는 body를 만들고 JSON을 직접 팠지만, ③은 그 과정이 사라졌다
🎯 체크포인트
  • lab5_compare_apis.py가 오류 없이 세 방식의 응답을 모두 출력했다
  • 세 응답 내용이 서로 비슷하다는 것을 확인했다
  • ①②는 body를 직접 조립하고, ③은 그 과정이 없다는 차이를 코드에서 짚을 수 있다
왜 고수준 API가 존재하는가

이 Lab의 진짜 결론입니다. Lab 01~04를 거치며 우리가 직접 겪은 불편을 순서대로 되짚으면, Converse가 왜 만들어졌는지가 저절로 드러납니다.

  1. ①②에서 모델별 body를 외워 써야 했다. Anthropic은 anthropic_version이 필요하지만, Titan·Llama는 또 다른 형식을 씁니다. 모델을 바꿀 때마다 body 구조를 새로 익혀야 한다는 건 명백한 학습 비용입니다.
  2. ②에서 raw 스트림 청크를 직접 분기해야 했다. content_block_delta만 골라내는 코드를 손으로 짰고, type이 바뀌면 그 코드도 따라 바뀌어야 했습니다.
  3. ③ Converse는 그 모든 것을 표준 형식 하나로 통일한다. messages / system / inferenceConfig라는 정해진 구조만 익히면, 모델이 무엇이든 같은 코드로 부를 수 있습니다. 학습 비용은 내려가고, 모델 교체 자유도는 올라갑니다.

Lab 04에서 우리가 직접 본 장면을 떠올려 보세요. MODEL_HAIKUMODEL_NOVA(us.amazon.nova-lite-v1:0)으로 한 줄만 바꿔도 코드 변경 0줄로 모델이 교체됐습니다. 저수준이었다면 body 구조까지 손봐야 했을 일이, 고수준에서는 modelId 문자열 하나로 끝납니다. 이것이 고수준 API가 주는 가장 실질적인 가치입니다.

저수준은 언제 내려가나

그렇다고 저수준 API가 무의미한 것은 아닙니다. Converse가 표준화를 위해 감춰 둔 영역에 접근해야 할 때, 우리는 의도적으로 한 계층 아래로 내려갑니다.

  • 특수 파라미터가 필요할 때 — 특정 모델만 지원하는 세밀한 추론 옵션이 Converse의 표준 인터페이스에 노출되지 않는 경우, body에 직접 그 필드를 써야 합니다.
  • Converse가 아직 지원하지 않는 모델·기능을 쓸 때 — 새로 나온 모델이나 비표준 기능은 저수준 invoke_model로만 접근 가능한 시점이 있습니다.
  • 응답 원형(raw)을 그대로 다뤄야 할 때 — 청크 단위의 원시 이벤트를 직접 가공하거나, 표준 파싱이 가린 메타데이터를 봐야 하는 경우입니다.

판단 기준은 단순합니다. 기본값은 Converse로 시작하고, “Converse로는 안 되는 무언가”가 명확해질 때만 저수준으로 내려갑니다. 처음부터 저수준을 고른다면, 그 자유도를 쓸 이유가 분명한지 스스로 물어보세요. 대부분의 실무에서는 그 이유가 없습니다.

결론: 새 프로젝트는 Converse를 기본으로, 필요할 때만 저수준으로 내려간다.

핵심 정리: 세 API 선택 가이드

오늘 배운 것을 “언제 무엇을 쓰나”로 한 번 더 압축합니다.

상황권장 방식이유
새 프로젝트의 기본값Converse표준 형식, 모델 교체 자유, 학습 비용 최소
ChatGPT처럼 글자가 흘러나오는 UXconverse_stream고수준에서 깔끔한 이벤트로 스트리밍
특수 파라미터·비표준 모델·raw 제어invoke_model 계열형식을 직접 통제할 수 있는 자유도
저수준에서 실시간 출력이 꼭 필요할 때invoke_model_with_response_stream저수준의 자유도 + 스트리밍

이 과정에서 만든 챗봇은 “아는 것만” 답합니다. 모델이 학습한 범위 안의 지식만 활용할 수 있다는 뜻입니다. 다음 과정에서는 여기에 회사 문서를 읽고 답하는 능력을 더합니다. 그러나 그 RAG 시스템도 결국 안쪽에서는 오늘 배운 세 가지 호출 방식 중 하나로 모델을 부릅니다. 오늘의 비교표는 앞으로 만들 모든 시스템의 가장 밑바닥에 깔리는 기초입니다.

🤔 생각해 보기 (다 끝냈다면)
  • Converse가 이렇게 편한데, 그래도 저수준 invoke_model로 내려가야 하는 상황은 언제일까요?
  • 세 방식의 지연 시간은 왜 다를까요? 스트리밍은 ‘총 시간’이 아니라 무엇을 줄여 줄까요?

다음 단계

이것으로 bedrock-chatbot 과정의 마지막 Lab을 마쳤습니다. 여러분은 이제 페르소나를 가진 멀티턴 챗봇을 직접 만들 수 있고, 세 가지 호출 방식의 차이를 코드로 설명할 수 있으며, 새 프로젝트에서 어떤 API를 기본값으로 골라야 하는지 판단 기준을 갖췄습니다.

이 과정에서 만든 챗봇은 모델이 학습한 지식 안에서만 답합니다. 다음 과정에서는 외부 문서를 읽어 들이고, 질문과 관련된 내용을 검색해 답변에 반영하는 RAG(Retrieval-Augmented Generation)를 만듭니다. 오늘 익힌 모델 호출이 그 모든 시스템의 기초가 됩니다. 수고하셨습니다.