Lab 04: Converse (고수준 통합)
학습 흐름
Lab 01~03에서 우리는 세 가지 번거로움을 차례로 만났습니다. 모델마다 다른 요청 바디(anthropic_version 같은 모델 종속 필드), 대화 맥락을 직접 쌓아 보내야 하는 수동 히스토리 관리, 그리고 스트리밍에서 raw 이벤트 청크를 손수 분기하고 추출하는 일입니다.
이 Lab에서는 이 세 가지를 하나의 API로 동시에 해결합니다. converse / converse_stream은 멀티턴, 시스템 프롬프트(페르소나), 추론 파라미터, 스트리밍을 모두 통합된 표준 형식으로 다룹니다. 그리고 이 Lab의 하이라이트는 모델 ID 한 줄만 바꾸면 — 제공사가 다른 모델(Amazon Nova)로도 — 코드 변경 0줄로 동작한다는 점입니다. 이것이 고수준 API가 주는 가장 큰 가치입니다.
학습 목표
- 저수준
invoke_model과 고수준converse의 차이를 설명하고, 왜 고수준 API가 필요한지 이해합니다 - Converse의 표준 메시지 형식(
content가[{"text": ...}]블록 리스트)을 이해하고 중립 히스토리를 변환합니다 system인자로 페르소나를 부여하고,inferenceConfig로 추론 파라미터를 모델 무관하게 지정합니다- 모델 ID만 교체하여 코드 변경 없이 제공사가 다른 모델(Haiku → Amazon Nova)로도 스왑되는 고수준 API의 핵심 가치를 체험합니다
temperature를 조절하여 답변 성향이 어떻게 달라지는지 실험합니다
고수준 API가 필요한 이유
지금까지의 Lab을 돌아보면, 매 단계마다 “내가 직접 책임져야 하는 형식”이 하나씩 늘어났습니다.
- Lab 01 (
invoke_model) — 요청 바디(JSON)를 직접 조립했습니다.anthropic_version,max_tokens,messages를 손으로 채워 보냈고, 응답 JSON도 직접 파싱했습니다. 이 바디 형식은 모델 제공사마다 다릅니다. Claude는anthropic_version이 필요하지만 Titan은{"inputText": ...}처럼 완전히 다른 구조입니다. 즉, 모델을 바꾸면 코드도 바꿔야 합니다. - Lab 02 (멀티턴) — 모델은 기억이 없으므로 대화 전체를 매번 다시 보내야 했습니다. 우리가 직접
messages배열을 쌓아 관리했습니다. - Lab 03 (스트리밍) — 응답이 한 덩어리가 아니라 이벤트 스트림으로 왔습니다.
message_start,content_block_delta,message_stop같은 청크 타입을 직접 분기하고, 우리가 원하는delta.text만 골라냈습니다.
이 세 가지는 모두 “저수준이라서 형식을 내가 책임진다”는 공통점이 있습니다. 강력하지만 번거롭고, 모델이나 타입이 바뀌면 코드도 따라 바뀝니다.
Converse는 이 책임을 AWS 쪽으로 넘깁니다. 메시지 형식이 표준화되고, 시스템 프롬프트와 파라미터를 모델과 무관하게 지정하며, 일반/스트리밍을 동일한 형식으로 사용합니다. 핵심은 다음 한 문장입니다.
모델별 바디를 외우지 않는다. 표준 형식 하나로 통일하고, 모델 ID는 그냥 갈아끼운다.
| 구분 | invoke_model (저수준) | converse (고수준) |
|---|---|---|
| 메시지 형식 | 모델별로 다름 (anthropic_version 등) | 표준 통일 (content가 블록 리스트) |
| 시스템 프롬프트 | 바디 안에 모델 규칙대로 끼워넣음 | system 별도 인자 |
| 파라미터 | 바디 필드명이 모델마다 다름 | inferenceConfig로 통일 |
| 스트리밍 | raw 청크 타입 직접 분기 | converse_stream 동일 형식 |
| 모델 교체 | 바디 구조까지 수정 필요 | 모델 ID만 교체 (코드 0줄) |
Converse 통합 흐름
Converse 한 번의 호출이 처리하는 일을 그림으로 보면 다음과 같습니다. 중립 히스토리를 표준 형식으로 변환하고, 시스템 프롬프트와 파라미터를 함께 넘기면, Bedrock이 모델 종속 부분을 알아서 처리합니다.
graph TD
A["중립 히스토리<br/>{role, text}"] --> B["_to_converse()<br/>content 블록 리스트로 변환"]
B --> C["converse / converse_stream 호출"]
S["system<br/>(페르소나)"] --> C
P["inferenceConfig<br/>(maxTokens, temperature)"] --> C
M["modelId<br/>(Haiku → Amazon Nova)"] --> C
C --> D["표준 응답 파싱<br/>output.message.content[0].text"]
이 그림에서 modelId만 다른 값으로 바꿔도 나머지 화살표는 전혀 건드리지 않는다는 점이 핵심입니다. 메시지 변환, 시스템 프롬프트, 파라미터 지정 로직이 모델과 분리되어 있기 때문입니다.
표준 메시지 형식과 변환
Converse의 가장 큰 형식적 변화는 content가 단순 문자열이 아니라 블록(block) 리스트라는 점입니다. 각 메시지의 내용은 [{"text": "..."}] 형태의 리스트로 표현됩니다. 지금은 텍스트 한 블록만 쓰지만, 이 구조 덕분에 나중에 이미지나 도구 호출 결과 같은 다른 종류의 블록도 같은 형식으로 섞어 넣을 수 있습니다. 즉 확장을 염두에 둔 표준입니다.
우리는 앱 내부에서 대화를 {"role": ..., "text": ...}라는 단순한 “중립 히스토리” 형태로 들고 다닙니다. Converse에 보낼 때만 이 표준 형식으로 변환합니다.
def _to_converse(history: list[dict]) -> list[dict]:
"""중립 히스토리 -> Converse messages 형식(content 는 블록 리스트)."""
return [{"role": h["role"], "content": [{"text": h["text"]}]} for h in history]- 입력은
[{"role": "user", "text": "안녕"}, ...]처럼 평범한 딕셔너리 리스트입니다. - 출력은
[{"role": "user", "content": [{"text": "안녕"}]}, ...]로,text값을content블록 리스트 안으로 감싸 넣은 형태입니다. - 중립 히스토리를 따로 두는 이유는, 앱 로직이 특정 API 형식에 묶이지 않게 하기 위해서입니다. API가 바뀌면 이 변환 함수만 고치면 됩니다.
페르소나와 추론 파라미터
시스템 프롬프트로 페르소나 부여
저수준에서는 시스템 역할을 메시지 배열 앞에 어색하게 끼워넣어야 했습니다. Converse에서는 system을 별도 인자로 깔끔하게 전달합니다. 여기서는 ‘넥클(NxtCloud)‘이라는 코딩 멘토 페르소나를 정의합니다.
# 시스템 프롬프트(페르소나) — Converse 에서는 별도 인자로 깔끔하게 전달한다.
SYSTEM_PROMPT = (
"너는 '넥클(NxtCloud)'이라는 이름의 친절하고 활기찬 코딩 멘토야. "
"항상 한국어로, 핵심만 간결하게 답하고, 가끔 이모지를 한두 개 곁들여 격려해."
)시스템 프롬프트는 모델의 말투, 역할, 제약을 정의합니다. 사용자 메시지와 분리되어 있어 대화가 길어져도 페르소나가 흔들리지 않고 일관되게 유지됩니다.
inferenceConfig로 파라미터 지정
maxTokens, temperature 같은 추론 파라미터는 inferenceConfig로 한곳에 모아 지정합니다. 이 필드명은 모델과 무관하게 동일합니다. 저수준에서는 모델마다 파라미터 필드명이 달랐다는 점과 대비됩니다.
inferenceConfig={"maxTokens": 512, "temperature": 0.7},maxTokens— 응답으로 생성할 최대 토큰 수입니다. 답변 길이의 상한을 정합니다.temperature— 응답의 무작위성(다양성)을 조절합니다. 낮으면(예: 0.1) 결정적이고 일관된 답을, 높으면(예: 1.0) 창의적이고 변화가 큰 답을 냅니다.
일반 응답과 스트리밍 응답
핵심은 일반 응답(converse)과 스트리밍 응답(converse_stream)이 거의 동일한 형식을 쓴다는 것입니다. 인자 구성이 같고, 결과를 받는 방식만 다릅니다.
일반(비스트리밍) 응답
converse는 완성된 답변을 한 번에 돌려줍니다. 응답 구조가 표준화되어 있어 output.message.content[0].text로 텍스트를 꺼냅니다.
def respond(history: list[dict]) -> str:
"""일반(비스트리밍) 응답."""
resp = rt.converse(
modelId=MODEL,
messages=_to_converse(history),
system=[{"text": SYSTEM_PROMPT}],
inferenceConfig={"maxTokens": 512, "temperature": 0.7},
)
return resp["output"]["message"]["content"][0]["text"]modelId— 호출할 모델. 바로 이 값만 바꾸면 모델이 교체됩니다(아래 데모 참고).messages—_to_converse로 변환한 표준 형식 히스토리.system— 페르소나. 리스트 안에 텍스트 블록으로 전달합니다.- 반환값은
output > message > content > 첫 블록 > text경로로 꺼냅니다. 모델이 무엇이든 이 경로가 동일합니다.
스트리밍 응답
converse_stream은 ChatGPT처럼 글자 조각이 흘러나오게 합니다. Lab 03에서 raw 청크 타입을 직접 분기했던 것과 달리, 여기서는 contentBlockDelta 이벤트에서 delta.text만 꺼내면 됩니다. 형식이 표준화되어 분기가 단순해졌습니다.
def respond_stream(history: list[dict]):
"""스트리밍 응답 (글자 조각 제너레이터)."""
resp = rt.converse_stream(
modelId=MODEL,
messages=_to_converse(history),
system=[{"text": SYSTEM_PROMPT}],
inferenceConfig={"maxTokens": 512, "temperature": 0.7},
)
for event in resp["stream"]:
if "contentBlockDelta" in event:
yield event["contentBlockDelta"]["delta"].get("text", "")- 함수가
yield를 쓰는 제너레이터라는 점에 주목하세요. 호출하는 쪽(콘솔 UI)은 이 제너레이터를 돌며 글자 조각을 받아 즉시 화면에 찍습니다. resp["stream"]은 이벤트들의 흐름입니다. 우리는 그중contentBlockDelta(텍스트 조각이 담긴 이벤트)만 골라delta.text를 흘려보냅니다..get("text", "")로 안전하게 꺼내, 텍스트가 없는 이벤트가 섞여 와도 깨지지 않습니다.
두 함수의 인자 구성(
modelId,messages,system,inferenceConfig)이 완전히 같다는 점을 확인하세요. 일반/스트리밍의 차이는 “결과를 통째로 받느냐, 조각으로 받느냐”뿐입니다.
핵심 데모: 모델 교체 코드 0줄 (제공사가 달라도)
이 Lab에서 가장 중요한 실습입니다. 모델은 코드 상단의 상수 한 줄로 지정되어 있습니다.
from config import get_runtime, MODEL_HAIKU, MODEL_NOVA
# 여기 한 줄만 바꾸면 동일 코드가 다른 모델로 동작합니다.
MODEL = MODEL_HAIKUrespond와 respond_stream은 모두 MODEL 상수를 참조할 뿐, 모델이 무엇인지 신경 쓰지 않습니다. 따라서 이 한 줄만 바꾸면 됩니다.
MODEL = MODEL_NOVA # 제공사가 다른 모델(Amazon Nova) — 그래도 코드 0줄- 주력 모델:
us.anthropic.claude-haiku-4-5-20251001-v1:0(Anthropic Claude, 빠르고 저렴) - 스왑 대상:
us.amazon.nova-lite-v1:0(Amazon Nova — 제공사 자체가 다름)
여기서 Converse의 진짜 가치가 드러납니다
만약 같은 Claude 계열끼리 바꾸는 거라면, 사실 invoke_model이어도 한 줄이면 됩니다 — Claude는 요청 바디가 모두 같으니까요(anthropic_version, max_tokens, messages). 그래서 같은 제공사 안의 교체만으로는 Converse의 고유 이점이 잘 드러나지 않습니다.
진짜 차이는 제공사가 다른 모델로 갈 때입니다. Amazon Nova는 invoke_model 바디 구조가 Claude와 완전히 다릅니다. 저수준이었다면 Nova용 바디를 새로 짜고 응답 파싱도 다시 해야 했습니다. 하지만 Converse는 표준 형식이 모델을 추상화하므로, MODEL을 MODEL_NOVA로 바꾸는 한 줄이면 끝납니다. 이것이 고수준 API의 핵심 가치입니다.
같은 질문을 Haiku와 Nova Lite에 각각 던져 보세요. 코드는 한 글자도 안 바뀌었는데 제공사가 다른 모델이 답하는 것 — 이게 이 데모의 핵심입니다.
temperature 실험
inferenceConfig의 temperature를 바꿔가며 답변 성향을 비교해 보세요. 같은 질문, 같은 페르소나라도 값에 따라 결과가 달라집니다.
# 보수적 / 일관적
inferenceConfig={"maxTokens": 512, "temperature": 0.1},
# 창의적 / 다양함
inferenceConfig={"maxTokens": 512, "temperature": 1.0},
| temperature | 성향 | 적합한 용도 |
|---|---|---|
| 0.1 | 결정적·일관적, 거의 같은 답 반복 | 사실 질의응답, 코드 설명, 요약 |
| 0.7 | 균형 (기본값) | 일반 대화, 멘토링 |
| 1.0 | 창의적·다양, 매번 다른 표현 | 아이디어 브레인스토밍, 카피 작성 |
같은 질문을 temperature 0.1과 1.0으로 각각 여러 번 던져 보면, 낮은 값에서는 답이 거의 똑같이 반복되고 높은 값에서는 표현과 내용이 매번 달라지는 것을 관찰할 수 있습니다. 페르소나 문구(SYSTEM_PROMPT)도 바꿔 보며 말투가 어떻게 달라지는지 함께 실험하세요.
실습: 챗봇 실행
제공된 콘솔 챗봇 모듈(chat_ui.py)이 입력과 출력 화면을 담당하고, 학습자는 boto3 호출 함수(respond_stream)만 구현해 연결합니다. 별도의 웹 프레임워크 없이 순수 Python 콘솔에서 동작합니다.
if __name__ == "__main__":
from chat_ui import run
run(
respond_stream,
streaming=True,
title="Lab4 · Converse (페르소나 + 스트리밍)",
system_hint="넥클 멘토",
)run(...)에 우리가 만든respond_stream을 넘기면, 콘솔 UI가 사용자 입력을 받아 히스토리를 쌓고 이 함수를 호출합니다.streaming=True이므로 응답이 글자 단위로 실시간 출력됩니다.
code-server 터미널에서 Lab 폴더로 이동해 실행하세요.
# 챗봇 실습 폴더로 이동
cd ~/nxt-workshop-code/bedrock-chatbot/starter
# Converse 챗봇 실행
python lab4_converse.py“파이썬 리스트랑 튜플 차이가 뭐야?” 같은 질문을 입력해 보세요. 넥클 멘토 페르소나로 친절하고 간결하게, 가끔 이모지를 곁들인 답변이 글자 단위로 흘러나옵니다. 이어서 “방금 그거 예시도 보여줘”처럼 후속 질문을 던져 멀티턴 맥락이 유지되는지 확인하세요.
그다음 MODEL = MODEL_HAIKU를 MODEL_NOVA(제공사가 다른 Amazon Nova)로 바꾸고 같은 질문을 다시 던져, 코드를 한 줄도 더 고치지 않고 모델이 교체되는 것을 확인하세요.
- 넥클 페르소나로 한국어 답변이 글자 단위로 스트리밍된다
- 후속 질문에서 이전 대화 맥락이 유지된다
-
MODEL을MODEL_NOVA으로 바꿔도 코드 변경 없이 동작한다 -
temperature를 0.1과 1.0으로 바꿔 답변 성향 차이를 관찰했다
핵심 정리: Converse 통합
| 통합 대상 | Lab 01~03의 번거로움 | Converse의 해결 |
|---|---|---|
| 메시지 형식 | 모델별 바디를 직접 조립 | content 블록 리스트 표준 형식 |
| 시스템 프롬프트 | 메시지 앞에 어색하게 삽입 | system 별도 인자로 페르소나 부여 |
| 파라미터 | 모델마다 필드명이 다름 | inferenceConfig로 통일 |
| 스트리밍 | raw 청크 타입 직접 분기 | contentBlockDelta만 추출 |
| 모델 교체 | 바디 구조까지 수정 | 모델 ID 한 줄만 교체 |
Converse는 멀티턴, 페르소나, 파라미터, 스트리밍을 하나의 표준 인터페이스로 묶습니다. 덕분에 코드는 모델로부터 독립적이 되고, 모델 ID만 갈아끼우면 됩니다. 이것이 “표준 형식 하나로 통일한다”는 고수준 API의 가치입니다.
🤔 생각해 보기 (다 끝냈다면)
temperature를 0으로 두고 같은 질문을 5번 하면 정말 매번 똑같을까요? 아니라면 왜일까요?system과user에 서로 모순되는 지시를 주면 누가 이길까요? 페르소나를 무너뜨릴 수 있을까요?
다음 단계
이제 우리는 같은 챗봇을 세 가지 방식으로 만들 수 있습니다. 저수준 invoke_model, 스트리밍 invoke_model_with_response_stream, 그리고 고수준 converse입니다. 각각은 통제권과 편의성 사이에서 다른 선택을 합니다.
Lab 05에서는 이 세 API를 한 화면에서 나란히 비교합니다. 같은 질문에 대한 응답, 지연 시간, 코드 복잡도를 직접 견주어 보며 “언제 어떤 API를 써야 하는가”에 대한 판단 기준을 세웁니다.
NxtCloud Workshop