에이전트 실전 가이드
첫 Agent() 한 줄부터 AgentCore Runtime 배포까지 — Strands SDK · Bedrock AgentCore(Memory · Gateway · Runtime) · Claude Code 세 축으로 model-driven 에이전트를 실제로 만들어봅니다. 코드는 눈으로 이해하고, 실습은 Claude Code에 자연어로 지시해서 만듭니다.
이 문서는 슬라이드가 아니라 위에서 아래로 읽는 기술 핸드북입니다. 왼쪽 목차로 언제든 원하는 모듈로 이동할 수 있고, 읽은 만큼 상단 진행바가 찹니다.
왜 이렇게 배우나요?
코드는 읽기 위한 것
구조와 원리를 눈으로 파악합니다. 외우거나 타이핑할 필요 없습니다.
지시는 자연어로
"이런 에이전트를 만들어줘"를 Claude Code에 붙여넣으면 실제 코드가 생성·실행됩니다.
결과를 읽으며 학습
생성된 코드와 실행 결과를 강의 내용과 대조하며 원리를 확인합니다.
실습 환경 — Claude Code on Bedrock
Claude Code를 Amazon Bedrock 위에서 돌립니다
실습은 전부 Claude Code(터미널 AI 코딩 에이전트) 안에서 진행합니다. 여러분이 Strands 에이전트를 직접 타이핑하는 대신, 무엇을 만들지 자연어로 설명하면 Claude Code가 Bedrock 위의 Claude를 호출해서 코드를 작성·실행해 줍니다.
1. 세팅 순서 (발표자 구두 안내)
- AWS 자격증명 준비 — 사내에서 발급받은 AWS Bedrock 액세스가 있는 프로파일 사용 (aws configure 또는 SSO 로그인)
- Bedrock 모델 액세스 확인 — 콘솔에서 Anthropic Claude 모델(예: Sonnet 4.6)이 활성화되어 있는지 확인
- Claude Code 설치 — npm i -g @anthropic-ai/claude-code
- Bedrock 백엔드로 실행 — 환경변수를 세팅한 뒤 claude 실행
Bedrock 백엔드로 Claude Code 켜기
2. 프롬프트 카드 읽는 법
모든 실습은 아래와 같은 주황색 카드로 표시됩니다. 오른쪽 복사 버튼을 누르면 자연어 지시가 클립보드에 담깁니다. 그대로 Claude Code에 붙여넣으세요.
- Claude Code가 agent.py를 생성하고 Agent() 한 줄짜리 코드를 작성합니다.
- 파일을 실행해 Bedrock의 Claude가 응답한 결과를 터미널에 보여줍니다.
- 여러분은 생성된 코드를 읽으며 "에이전트의 최소 단위"를 확인합니다.
3. 진행 원칙 — 가장 작은 것부터, 하나씩만 쌓기
4. 오늘의 여정 — PART 1 & PART 2
예제는 하나의 프로젝트로 이어집니다 — 삼성 갤럭시 서비스 운영·지원 에이전트. 실습이 진행될수록 아래처럼 단 한 가지씩만 붙입니다.
ACT 00 → 06: Agent() 한 줄에서 서버리스 프로덕션까지
개념 → 최소 에이전트 → 루프 → 툴 → 기억 → 멀티 에이전트 → 챗 UI → AgentCore Runtime 배포. 본편이 끝나면 실전 에이전트가 실제 매니지드 런타임에 올라가 브라우저 챗으로 대화됩니다.
ACT 07 → 09: 운영·거버넌스까지
배포된 에이전트에 관측·평가, 자연어 정책·카탈로그, 그리고 비용·복원력·테스트 게이트까지 붙여 Galaxy Ops Dashboard로 끌어옵니다. PART 2만 별도 세션으로 진행해도 됩니다.
| 파트 | 모듈 | 주제 | 이 모듈에서 추가로 붙이는 것 | 시간 |
|---|---|---|---|---|
| PART 1 본편 | 00 | 에이전트는 왜 어려운가 | (개념) model-driven이란 무엇인가 | 30분 |
| 01 | 첫 에이전트 | Agent() 한 줄 → 역할(system_prompt) 하나 | 45분 | |
| 02 | 에이전트 루프 | 기본 도구 하나 붙이고 루프 관찰 | 60분 | |
| 03 | 툴 · MCP · Gateway | 커스텀 @tool · 공개 MCP · AgentCore Gateway | 90분 | |
| 04 | 기억 · 상태 · 세션 | Strands 상태 + AgentCore Memory | 90분 | |
| 05 | 멀티 에이전트 | Agents-as-tools · Graph · Swarm | 75분 | |
| 05b | 챗 UI 붙이기 | AG-UI 프로토콜 + CopilotKit 프론트 | 45분 | |
| 06 | AgentCore Runtime 배포 | 지금까지 만든 에이전트를 서버리스로 올리기 | 60분 | |
| PART 2 실전 서비스 | 07 | Observability & Evaluations | CloudWatch Logs Insights · Data Protection · Custom/Online Evaluator | 60분 |
| 08 | Policy & Registry | 자연어 → Cedar 정책 · 사내 카탈로그 승인 워크플로 | 60분 | |
| 09 | 운영 실무 · 비용·복원력·테스트 | 서비스별 과금 · HealthyBusy async · 배포 전 회귀 게이트 · ADOT 관측 | 45분 |
에이전트는 왜 어려운가
LLM은 답하고, 에이전트는 행동한다
왜 지금 에이전트인가
지난 몇 년간 LLM은 텍스트 안에서 놀라운 일을 해왔습니다. 하지만 텍스트 밖의 세상과 상호작용하는 순간, 진짜 실무의 벽에 부딪힙니다. 운영자 입장에서 필요한 건 "잘 대답하는 모델"이 아니라, 기기 상태를 조회하고, 백업을 재개하고, 결제 문제를 해결해주는 시스템이니까요.
Strands의 관점 — Model-Driven
사람이 흐름을 코딩
if/else, 조건 분기, 단계 순서를 개발자가 다 명시. 예측 가능하지만 새로운 상황에 취약합니다.
모델이 흐름을 결정
루프 안에서 모델이 도구를 스스로 고르고, 결과를 보고 다음 행동을 정합니다. 개발자는 도구와 프롬프트만 잘 준비합니다.
두 관점의 스펙트럼
오늘 우리가 만들 것 — 한 문장
사용자가 "어제 삼성 페이로 결제한 내역 알려줘", "거실 에어컨 26도로 맞춰줘", "어제 걸음수 얼마나 나왔지?" 같은 자연어로 물으면, 알맞은 서비스 전문 에이전트가 응답하는 갤럭시 서비스 운영·지원 에이전트를 8시간 동안 단계적으로 만듭니다.
첫 에이전트 — "3줄이면 된다"
에이전트는 Model · Tools · Prompt 세 조각의 조합입니다
왜 이 세 조각인가
에이전트의 정체를 뜯어보면 결국 세 가지 질문에 답하는 일입니다. 누구의 머리로 생각할지, 무엇으로 행동할지, 어떤 사람으로 말할지. Strands는 이 셋을 Agent()의 인자 세 개로 그대로 노출합니다. 어렵게 감싸지 않았다는 뜻입니다.
추론 엔진
매 턴 무엇을 할지 결정하는 두뇌. 기본값은 Amazon Bedrock의 Claude Sonnet 4.6. 원하면 OpenAI·Anthropic·Ollama·LiteLLM 등으로 갈아끼울 수 있습니다.
행동
모델이 텍스트 밖에서 무엇을 할 수 있는가. 기기 제어, DB 조회, API 호출. 이번 모듈에서는 붙이지 않습니다 — 다음 모듈의 이야기입니다.
역할
같은 모델이라도 "당신은 갤럭시 서비스 상담원입니다"라고 말해주면 답이 달라집니다. system_prompt로 톤·경계·전문성을 심습니다.
가장 작은 코드 — 진짜로 3줄
사람들이 "에이전트 프레임워크"라고 하면 흔히 대여섯 개 클래스와 설정 파일부터 떠올립니다. Strands는 그 반대편에 있습니다. 아래 세 줄이 실행 가능한 완성된 에이전트입니다 — 모델은 기본값(Bedrock Claude Sonnet 4.6)이 자동으로 꽂히고, 도구는 없고, 시스템 프롬프트도 없습니다.
from strands import Agent agent = Agent() # 기본 모델: Bedrock Claude Sonnet 4.6 print(agent("안녕")) # 그냥 호출 = 에이전트 실행
Agent()는 인자 하나 없이도 완전한 객체입니다. 그리고 agent("안녕")처럼 함수 호출하듯 쓰는 게 곧 에이전트 실행입니다.
이 문법은 뒤에서 다시 만납니다 — 도구를 붙이든, 시스템 프롬프트를 넣든, 호출 방식은 그대로입니다.- Claude Code가 agent_hello.py를 만듭니다 — 정말로 3줄짜리 파일입니다.
- python agent_hello.py를 실행합니다. 기본 모델(Bedrock Claude Sonnet 4.6)이 자동으로 호출됩니다.
- "안녕하세요! 무엇을 도와드릴까요?" 같은 일반적인 챗봇 톤의 응답이 돌아옵니다.
- 이 시점의 에이전트는 아직 아무런 성격도 전문성도 없는 상태입니다 — 다음 랩에서 그걸 심을 겁니다.
역할을 부여하기 — system_prompt
방금 만든 에이전트에게 "당신은 누구입니까"라고 물으면 그냥 Claude입니다라고 답할 겁니다. 그건 우리가 원하는 상담원이 아닙니다. system_prompt는 이 에이전트가 어떤 역할을 맡은 사람처럼 말할지를 미리 심어두는 자리입니다. 생성 시점에 딱 한 번 지정하고, 이후 모든 대화에 배경으로 깔립니다.
그리고 여기서 한 가지 더 짚어둘 것 — agent("...")의 반환값은 문자열이 아니라 AgentResult 객체입니다.
print()하면 최종 텍스트가 보기 좋게 나오지만, 그 안에는 사용된 토큰·툴 호출 트레이스·사이클 메트릭이 다 들어있습니다.
관측·디버깅 이야기는 뒤 모듈에서 자세히 다룹니다.
from strands import Agent agent = Agent( system_prompt=( "당신은 삼성 갤럭시 서비스 지원 상담원입니다. " "SmartThings, 삼성 클라우드, 삼성 헬스, 삼성 페이 문의를 " "차분하고 정확하게 응대합니다." ), ) print(agent("삼성 페이에서 카드 등록이 안 돼요"))
- Claude Code가 agent_support.py를 만듭니다 — LAB 1-1과의 차이는 system_prompt 인자 하나뿐입니다.
- 실행 결과가 이전과 완전히 다른 톤으로 나옵니다. "안녕하세요, 상담사입니다"로 시작하고, 카드 등록 실패의 일반적 원인·다음 단계를 안내합니다.
- 하지만 아직 실제 상태를 조회하지는 못합니다 — "카드사 승인이 지연될 수 있습니다" 같은 일반론에 머뭅니다. 여기서 도구의 필요성이 자연스럽게 드러납니다.
- Claude Code가 두 응답을 나란히 놓고 어떤 표현·구조가 바뀌었는지 짧게 정리해줍니다.
지금까지 쌓인 것 — Model + Prompt
기본값을 그대로
Bedrock Claude Sonnet 4.6이 자동으로 꽂혀 있습니다. 아직 바꾸지 않았습니다.
상담원 역할 심음
system_prompt로 갤럭시 서비스 지원 톤을 지정했습니다.
아직 없음
다음 모듈에서 실제로 무언가를 할 수 있는 능력을 붙입니다.
에이전트 루프 — 핵심 원리
모델이 도구를 부르고 · 결과를 받고 · 다시 판단하는 것을 반복합니다
왜 루프인가 — 누적 컨텍스트
루프가 한 번 도는 게 아니라 여러 번 도는 이유는 단순합니다. 사이클 1에서 툴을 부른 결과가 사이클 2의 컨텍스트에 들어가기 때문입니다. 모델은 원 요청과 지금까지 부른 모든 툴 · 받은 모든 결과를 함께 보고 다음 행동을 정합니다. 대화 기록은 곧 모델의 작업 기억입니다.
한 사이클의 대화 기록 — messages 배열
한 사이클을 해부하기
겉으로 보면 agent("지금 몇 시야?") 한 번이지만, 내부에서는 이 네 단계가 사이클마다 반복됩니다.
- Claude Code가 agent.py의 Agent(...)에 tools=[current_time]을 추가합니다.
- 실행하면 터미널에 [tool] current_time(timezone="Asia/Seoul") 같은 툴 호출 로그가 뜹니다.
- 이어서 "지금 서울은 오후 X시 X분입니다" 같은 최종 응답이 나옵니다.
- 여기서 처음으로 루프가 두 번 돈다는 걸 눈으로 확인합니다 (툴 호출 → 결과 관찰 → 응답).
루프를 관측하기 — result.metrics.get_summary()
agent(...) 호출은 단순한 문자열이 아니라 AgentResult를 돌려줍니다. 여기에는 traces(사이클마다 어떤 툴을 언제 불렀는지)와 metrics(누적 토큰·툴 통계·사이클 수)가 들어있습니다. 이걸 꺼내 보는 게 에이전트 디버깅의 첫 걸음입니다.
from strands import Agent from strands_tools import current_time agent = Agent(tools=[current_time]) result = agent("지금 서울 몇 시야?") summary = result.metrics.get_summary() # ← 여기가 관측 포인트 print(summary)
돌려받는 구조 (요약)
- Claude Code가 실행 스크립트에 result.metrics.get_summary()를 추가하고, json.dumps로 보기 좋게 출력합니다.
- 터미널에 total_cycles=2, current_time.call_count=1, totalTokens=...가 찍힙니다.
- Claude Code가 "사이클 1은 툴 호출, 사이클 2는 최종 응답"이라고 서사로 설명해줍니다.
- 여러분은 이제 루프가 실제로 몇 번 돌았는지 눈으로 확인하는 습관이 생깁니다.
Stop Reason 6종 — 사이클은 왜 끝나는가
모델 호출 한 번은 반드시 Stop Reason과 함께 끝납니다. 이 값이 "루프를 계속 돌지 · 종료할지"를 정합니다.
| Stop Reason | 의미 | 루프 |
|---|---|---|
| End turn | 정상 응답 완성 | 종료 (성공) |
| Tool use | 모델이 툴을 부르고 싶어함 | 툴 실행 후 계속 |
| Stop sequence | 설정한 중단 시퀀스 도달 | 종료 (정상) |
| Cancelled | agent.cancel()로 외부 중단 | 종료 (중단) |
| Max tokens | 토큰 한도 초과 · 응답 잘림 | 종료 (오류) |
| Guardrail intervention | 안전/가드레일 개입 | 종료 (차단) |
- Claude Code가 @tool 데코레이터로 예외를 던지는 fail_tool을 만들어 붙입니다.
- 실행하면 툴 호출은 실패하지만, 프로세스가 죽지 않습니다 — 에러가 toolResult로 모델에 되돌아갑니다.
- 모델은 "기기 상태를 지금 확인할 수 없습니다" 같은 복구 응답을 만들어냅니다.
- result.metrics.get_summary()에서 success_rate가 0.0으로 찍히는 것을 확인합니다 — 실패도 관측 대상입니다.
툴 — 에이전트가 세상과 만나는 법
에이전트가 세상과 만나는 세 가지 방법
지난 모듈의 벽 — 진짜 API는 어떻게 붙이나
ACT 2에서 우리는 strands_tools.current_time 하나를 붙여 루프가 두 번 도는 것을 눈으로 확인했습니다. 그런데 실무의 요청은 늘 이런 식입니다 — "사내 도어락 API에서 잠금 상태를 읽어와줘", "삼성 페이 결제 API에서 어제 내역을 조회해줘". 이 API들은 strands_tools 안에 없습니다. 우리가 직접 붙여야 합니다.
이 때 실제로 마주하는 질문은 "어디에 살게 하고, 누가 운영할 것인가"입니다. 파이썬 프로젝트 안에 함수로 둘 수도 있고, 별도 팀이 유지하는 원격 서버로 뽑을 수도 있고, AWS가 운영해주는 매니지드 엔드포인트에 얹을 수도 있죠. 이 모듈에서는 세 접근을 모두 살펴보되, 두 개(@tool · Gateway)를 직접 실습합니다.
첫 번째 방법 — @tool: 함수를 그 자리에서 툴로
@tool은 세 방법 중 가장 로컬합니다. 파이썬 함수 위에 데코레이터 한 줄만 얹으면, 그 함수가 곧 에이전트가 부를 수 있는 툴이 됩니다. 같은 프로젝트 안에서 완결되고, 별도 서버·인증·배포가 필요 없습니다.
비밀은 introspection에 있습니다. 데코레이터가 함수를 뜯어봐서 필요한 정보를 자동으로 뽑습니다 — docstring의 첫 문단은 툴의 description(모델이 사용 판단하는 근거)이 되고, docstring의 Args: 항목은 각 파라미터 설명이 되고, 타입 힌트는 그대로 JSON Schema로 번역됩니다. 기본값이 없는 파라미터는 required, 있으면 optional입니다.
즉 우리가 신경 쓸 것은 함수를 잘 쓰는 것뿐입니다 — 이름을 명확하게, docstring을 구체적으로, 타입 힌트를 정확하게. 그렇게 하면 툴 스펙은 저절로 만들어집니다.
from strands import tool @tool def get_device_status(device: str) -> str: """스마트홈 기기의 실시간 상태를 조회합니다. Args: device: 조회할 기기 이름 (예: '거실 조명', '현관 도어락') """ # mock 데이터 — 실제로는 SmartThings API 호출 mock = { "거실 조명": "켜짐, 밝기 60%", "현관 도어락": "잠김, 배터리 82%", } return mock.get(device, "알 수 없는 기기입니다")
이 코드가 하는 일은 이렇습니다. 첫 줄의 @tool이 그 아래 함수를 스캔합니다. 함수명 get_device_status는 그대로 툴 이름이 됩니다. docstring 첫 문단인 "스마트홈 기기의 실시간 상태를 조회합니다"는 모델이 툴을 부를지 말지 결정할 때 읽는 description이 됩니다. 타입 힌트 device: str은 JSON Schema의 {"device": {"type": "string"}}으로 자동 변환되고, 기본값이 없으니 required로 잡힙니다. 반환값은 문자열이므로 Strands가 알아서 ToolResult로 감싸 모델에게 되돌립니다.
이 함수가 자동으로 만들어내는 tool spec
이 스펙을 손으로 쓰는 일은 없습니다. 우리가 하는 것은 함수를 잘 쓰는 일뿐이고, Strands가 위의 JSON을 자동으로 만들어 모델에게 넘깁니다. 그리고 이 사실은 다음 두 접근(MCP · Gateway)에서도 결국 똑같은 스펙 세 조각(name · description · inputSchema)이 오간다는 걸 예고합니다 — 다만 그 스펙이 어디에서 만들어지고 어디에서 실행되는지가 달라질 뿐입니다.
- Claude Code가 device_tool.py를 만듭니다 — @tool 함수 하나, Agent(tools=[get_device_status]).
- 여러분이 별도 터미널에서 python device_tool.py를 돌립니다.
- 에이전트가 툴을 호출하고 mock 응답이 돌아옵니다. 우선 동작 확인이 목표입니다.
- 실행 초반에 tool_spec JSON이 통째로 찍힙니다.
- docstring이 description에, 타입 힌트가 inputSchema에 그대로 들어간 걸 확인합니다.
- "이 스펙을 손으로 안 쓰고 함수만 잘 쓰면 된다"의 의미가 눈으로 잡힙니다.
두 번째 방법 — MCP: 툴을 서버로 분리하는 개방 프로토콜
@tool의 한계 — 왜 프로토콜이 필요한가
@tool은 편하지만 툴이 에이전트 코드 안에 박혀 있다는 게 한계입니다. 같은 툴을 여러 팀이 공유하거나, 다른 언어(Go·Rust·Java)로 짜거나, 툴 팀과 에이전트 팀을 분리하려면 함수가 아니라 네트워크로 부를 수 있는 서비스가 필요합니다. 그리고 그 서비스와 대화하는 공통 언어도.
같은 코드베이스 안
파이썬 함수·에이전트 코드가 한 프로젝트. 팀 A만 유지보수. 다른 언어·다른 서비스는 붙이기 어려움.
네트워크로 분리
툴은 별도 서버. 어떤 언어로 짜든 상관없고, 여러 에이전트가 공유. 팀 분리 가능.
MCP가 정의하는 것 — 개방 표준
MCP(Model Context Protocol)는 Anthropic이 2024년에 공개한 개방 표준으로, LLM 애플리케이션이 외부에서 컨텍스트와 툴을 받아오는 방식을 표준화합니다. 특정 프레임워크에 종속되지 않고, Strands든 LangChain이든 자체 구현이든 같은 MCP 서버를 붙일 수 있습니다. 프로토콜의 뼈대는 JSON-RPC 2.0 위에 얹혀 있어, 요청/응답 구조는 웹에서 익숙한 그 모양입니다.
MCP 서버가 노출하는 세 가지 리소스
호출 가능한 함수
에이전트가 부를 수 있는 액션. 이 모듈에서 다루는 것.
읽을 수 있는 데이터
파일, DB 스키마, 문서 등 컨텍스트로 넘길 재료.
재사용 가능한 템플릿
공용 프롬프트를 서버가 제공. 클라이언트가 골라서 씀.
Transport — 실제로 어떻게 붙나
MCP 메시지는 세 가지 통로 중 하나를 타고 흐릅니다. 어느 통로를 쓰든 위에서 오가는 메시지는 동일한 규약이라는 게 핵심입니다.
| Transport | 언제 씁니까 | Strands 함수 |
|---|---|---|
| stdio | 같은 머신의 로컬 프로세스를 stdin/stdout 파이프로 붙일 때. 개발·로컬 툴에 편함. | stdio_client(...) |
| Streamable HTTP | 원격 서버에 HTTP로 붙을 때. 프로덕션·클라우드 툴에 표준. | streamablehttp_client(...) |
| SSE Server-Sent Events | 서버가 스트리밍 이벤트를 밀어줄 때. HTTP의 한 갈래. | sse_client(...) |
라이프사이클 — 표준화된 handshake
클라이언트가 서버에 붙는 순서는 언어와 구현에 상관없이 동일합니다. 세 단계만 기억하면 됩니다:
- initialize — 클라이언트가 인사를 걸면 서버가 자신의 capabilities(무슨 툴/리소스를 갖고 있는지)를 돌려줍니다.
- tools/list — 클라이언트가 툴 목록을 받아옵니다. 여기서 오는 스펙(name·description·inputSchema)은 @tool이 자동 생성하던 것과 형태가 같습니다.
- tools/call — 실제로 툴을 호출합니다. 인자를 JSON으로 넘기고, 결과를 JSON으로 받습니다.
MCP 통신 흐름
이 세 단계가 오간다는 것만 기억하면 됩니다. initialize로 인사하고, tools/list로 목록을 받아오고, tools/call로 부른다. 그 위에 오가는 payload는 다 JSON-RPC 메시지고, transport만 상황에 따라 갈아끼웁니다.
Strands에서 MCP 붙이기 — MCPClient 한 줄
Strands는 MCPClient 클래스로 위 handshake를 통째로 감쌉니다. 우리가 하는 일은 어떤 transport로 어느 서버에 붙을지 factory 람다 하나로 지정하는 것뿐입니다. 그리고 그 MCPClient 인스턴스를 Agent(tools=[...])에 그대로 넣으면 됩니다. 라이프사이클(연결·목록 조회·해제)은 에이전트가 자동으로 관리합니다.
from mcp import stdio_client, StdioServerParameters from strands import Agent from strands.tools.mcp import MCPClient # 로컬 MCP 서버(stdio)를 예시로 — AWS 공식 문서 MCP 서버 mcp_client = MCPClient(lambda: stdio_client( StdioServerParameters( command="uvx", args=["awslabs.aws-documentation-mcp-server@latest"], ) )) agent = Agent(tools=[mcp_client]) # 라이프사이클 자동 관리 agent("AWS Lambda 콜드 스타트 줄이는 법 알려줘")
여기서 일어나는 일은 이렇습니다. MCPClient는 람다 하나를 받는데, 그 람다가 "필요할 때 transport를 열어라"는 지시입니다. stdio_client(...)는 로컬 프로세스를 uvx awslabs.aws-documentation-mcp-server로 띄우고 stdin/stdout으로 연결합니다. 이 클라이언트를 Agent(tools=[...])에 넘기면, 에이전트가 실행될 때 자동으로 initialize → tools/list를 수행해서 서버가 제공하는 모든 툴을 자기 툴 목록에 병합합니다. 필요하면 여러 개의 MCPClient를 동시에 넣을 수도 있습니다 — 커스텀 @tool과 섞어서도.
- Claude Code가 with_aws_docs_mcp.py를 만듭니다 — MCPClient(lambda: stdio_client(...))로 서버를 띄우고 Agent(tools=[mcp_client])에 넘깁니다.
- 별도 터미널에서 실행하면 처음 한 번 uvx가 서버 패키지를 내려받고, 이어서 initialize → tools/list가 자동으로 오갑니다.
- 에이전트가 실제 문서를 검색·인용해 답합니다. mock이 아니라 진짜 답이 나옵니다.
세 번째 방법 — AgentCore Gateway: MCP 서버를 매니지드로
Amazon Bedrock AgentCore Gateway는 한 문장으로 요약하면 "AWS가 운영해주는 매니지드 MCP 서버"입니다. 여러 백엔드(REST API, AWS Lambda, 다른 서비스)를 하나의 MCP 엔드포인트로 노출해주는 서비스로, 클라이언트 입장에서는 방금 배운 MCPClient 그대로 붙으면 됩니다 — 프로토콜이 같으니 코드가 같습니다.
차이는 우리가 안 해도 되는 일에 있습니다. 인증(IAM · OAuth · Bearer 토큰), 요청 라우팅, 스로틀링, 감사 로그, 스케일링을 AWS가 대신 처리합니다. 개발자는 어떤 백엔드(target)를 툴로 노출할지, 그 툴 스펙이 뭔지만 정의합니다. 그다음 Gateway가 만들어주는 HTTPS 엔드포인트에 에이전트가 접속하면 끝입니다.
그리고 중요한 점 — Gateway는 AgentCore Runtime과 독립적으로 쓸 수 있습니다. 에이전트를 로컬 노트북에서 python으로 돌리든, ECS·EC2·Lambda에 배포하든, AgentCore Runtime에 얹든 관계없이 Gateway 엔드포인트에는 MCPClient로 접속만 하면 됩니다. 이번 실습에서도 로컬 Strands 에이전트가 원격 Gateway에 붙는 모양이 될 겁니다.
Gateway가 서 있는 자리
그림에서 보듯 클라이언트와 Gateway 사이는 MCP고, Gateway와 백엔드 사이는 각 백엔드의 프로토콜(Lambda invoke, REST 호출 등)입니다. Gateway는 그 사이에서 프로토콜 어댑터 겸 인증 게이트 역할을 합니다.
모델은 Lambda를 언제 부를지 어떻게 아나
여기서 한 가지 질문이 자연스럽게 떠오릅니다 — "Lambda 코드에는 스펙이 없는데 모델이 어떻게 이 함수를 부를지 판단하지?" 정답은 Lambda 코드가 아니라 Gateway target 등록 시점에 함께 넣는 toolSchema입니다. Lambda는 그저 실행되는 함수일 뿐이고, "언제 부르는지" 정보는 Gateway가 들고 있습니다.
구체적으로는 target 종류마다 툴 스펙(name · description · inputSchema)의 출처가 다릅니다:
| target 종류 | 툴 스펙(name·description·inputSchema)이 어디에서 오는가 |
|---|---|
| lambda | toolSchema.inlinePayload에 개발자가 직접 명시 (필수). Gateway 등록 시 함께 넣습니다. |
| apiGateway | REST API의 OpenAPI 스펙에서 자동 유도. toolOverrides로 부분 수정 가능. |
| openApiSchema | 개발자가 업로드한 OpenAPI JSON/YAML (S3 또는 inline). description은 그 안의 summary·description이 그대로 툴 설명이 됩니다. |
| smithyModel | 개발자가 업로드한 Smithy 모델. AWS 내부 서비스 API 스타일. |
| mcpServer | 다른 MCP 서버를 프록시 — 그쪽 서버의 tools/list를 그대로 노출. |
# Gateway에 Lambda를 target으로 얹을 때 함께 넣는 toolSchema c.create_gateway_target( gatewayIdentifier="galaxy-lab-gw-xxxxx", name="pay-tools", targetConfiguration={"mcp": {"lambda": { "lambdaArn": "arn:aws:lambda:us-west-2:...:function:pay-history", "toolSchema": {"inlinePayload": [ { "name": "get_pay_history", "description": "사용자의 삼성 페이 결제 내역을 조회합니다. 날짜(YYYY-MM-DD)를 받아 그 날의 결제 목록을 반환합니다.", "inputSchema": {"type": "object", "properties": { "date": {"type": "string", "description": "조회할 날짜 (YYYY-MM-DD)"} }, "required": ["date"]} } ]} }}} )
즉 ACT 3-1에서 봤던 세 조각(name · description · inputSchema)이 여기서도 그대로 오는 겁니다. @tool이 함수를 introspect해서 자동 생성했다면, Gateway lambda target에서는 개발자가 손으로 명시합니다. 이 스펙이 그대로 MCP tools/list 응답이 되어 클라이언트(에이전트)에 흘러가고, 모델은 description을 근거로 "지금 이 툴을 부를지"를 판단합니다.
인증 옵션 — AWS_IAM이 가장 편함
Gateway는 Custom JWT(OAuth 등)와 AWS_IAM 두 방식을 지원합니다. 사내 IAM 계정으로 이미 aws configure가 돼 있으면 AWS_IAM이 압도적으로 편합니다 — 토큰 발급 서버·OAuth 셋업 없이 현재 자격증명 그대로 SigV4로 서명해서 요청을 보내면 되니까요.
import boto3 from botocore.auth import SigV4Auth from botocore.awsrequest import AWSRequest import httpx from mcp.client.streamable_http import streamablehttp_client from strands.tools.mcp import MCPClient GATEWAY_URL = "https://<gw-id>.gateway.bedrock-agentcore.us-west-2.amazonaws.com/mcp" creds = boto3.Session().get_credentials().get_frozen_credentials() async def sign(req: httpx.Request): # 매 HTTP 요청을 IAM SigV4로 서명해서 헤더에 얹는다 ar = AWSRequest(method=req.method, url=str(req.url), data=req.content or b"", headers={k: v for k, v in req.headers.items() if k.lower() != "authorization"}) SigV4Auth(creds, "bedrock-agentcore", "us-west-2").add_auth(ar) for k, v in ar.headers.items(): req.headers[k] = v mcp = MCPClient(lambda: streamablehttp_client( url=GATEWAY_URL, httpx_client_factory=lambda **kw: httpx.AsyncClient(event_hooks={"request": [sign]}, **kw), )) with mcp: print("tools:", mcp.list_tools_sync())
이 코드의 핵심은 event_hook입니다. httpx가 매 요청을 보내기 직전에 sign(req)을 호출해서 현재 IAM 자격증명으로 SigV4 서명을 헤더에 얹습니다. 그다음은 방금 전 stdio 예시와 완전히 동일 — MCPClient를 Agent(tools=[...])에 넣으면 initialize → tools/list → tools/call이 자동으로 흘러갑니다. 서명 훅 한 조각 말고는 코드가 바뀔 이유가 없습니다.
- Claude Code가 create_gateway.py(IAM Role + Gateway 생성)와 with_gateway.py(SigV4 서명 MCPClient) 두 파일을 만듭니다.
- 별도 터미널에서 python create_gateway.py → 실제 Gateway URL이 뜹니다. 이어서 python with_gateway.py로 접속.
- 서명 없이 붙으면 401 Unauthorized, SigV4 서명을 얹으면 tools/list 성공 (빈 목록). "IAM 자격증명만 있으면 붙는다"를 눈으로 확인합니다.
언제 무엇을 쓰나 — @tool vs MCP vs Gateway
세 접근은 서로 대체재라기보다 스펙트럼에 가깝습니다. 프로젝트가 커지면 왼쪽에서 오른쪽으로 자연스럽게 옮겨갑니다. 지금 필요한 게 무엇인지에 맞춰 고르면 됩니다.
| 항목 | Strands @tool | 직접 운영하는 MCP 서버 | AgentCore Gateway |
|---|---|---|---|
| 툴이 사는 곳 | 에이전트와 같은 프로세스 | 별도 프로세스/서버 (내가 운영) | AWS 매니지드 엔드포인트 |
| 인증 관리 | 없음 (프로세스 권한) | 내가 직접 (API Key · OAuth 등) | AWS가 처리 (IAM · OAuth) |
| 여러 팀·언어 공유 | 어려움 (파이썬 함수) | 가능 (프로토콜 표준) | 가능 (프로토콜 표준) |
| 언어 종속성 | 파이썬 전용 | 없음 | 없음 |
| 스로틀링·감사 | 직접 코드로 | 직접 구현 | AWS가 제공 |
| 초기 셋업 비용 | 매우 낮음 | 중간~높음 | 중간 |
| 잘 어울리는 상황 | PoC·프로토타입·팀 내부 로직 | 이미 MCP 서버 인프라 보유 | 여러 백엔드를 안전한 게이트로 노출 |
에이전트에 챗 UI 붙이기
터미널을 넘어 챗 UI + 에이전트 인스펙터로
왜 UI가 필요한가 — 터미널의 한계
지금까지의 에이전트는 python xxx.py로 돌아가면서 결과를 터미널에 뱉었습니다. 개발자에게는 좋지만 실제 사용자에게는 줄 수 없는 형태입니다. 스트리밍이 흘러가는 모습, 툴이 호출되는 순간, 대화 히스토리 — 이런 것들을 터미널로만 보여주는 데는 한계가 뚜렷합니다. 결국 사용자를 태우려면 브라우저에서 대화할 수 있는 챗 UI가 필요합니다.
AG-UI 프로토콜 — 에이전트↔UI의 표준
AG-UI(Agent-User Interaction Protocol)는 에이전트와 UI 사이에서 오가는 이벤트를 표준화한 개방 프로토콜입니다. ACT 3에서 본 MCP가 에이전트↔툴의 표준이었다면, AG-UI는 에이전트↔사용자 UI의 표준입니다. 전송은 JSON-RPC가 아니라 SSE(Server-Sent Events) 스트림 — 서버가 이벤트를 실시간으로 밀어주면 브라우저가 받아서 렌더합니다.
AG-UI 이벤트 스트림 — 실제로 흐르는 것
실행이 시작되면 RUN_STARTED, 모델이 토큰을 만들 때마다 MESSAGE, 툴을 부르면 TOOL_CALL_START/END, 상태가 갱신되면 STATE_UPDATE, 다 끝나면 RUN_FINISHED가 순서대로 흘러갑니다. UI는 이 이벤트들을 받아 실시간으로 그립니다 — 프레임워크 무관. Strands든 LangChain이든 자체 구현이든 같은 이벤트를 만들어내면 같은 UI가 붙습니다. Strands용 브리지는 파이썬 패키지 ag_ui_strands가 제공합니다.
CopilotKit — React 챗 UI 컴포넌트
CopilotKit은 AG-UI 호환 백엔드에 몇 줄만에 붙는 React 기반 챗 UI 라이브러리입니다. <CopilotKit runtimeUrl agent>로 감싸고 챗 컴포넌트 하나 배치하면 브라우저에 챗 창이 뜹니다. Next.js와 궁합이 좋아, App Router의 /api/copilotkit 라우트로 백엔드 프록시를 두면 자연스럽게 붙습니다.
풀 페이지 챗
페이지 안에 인라인으로 크게 배치. 채팅이 서비스의 주된 UI일 때 어울림. 좌우 사이드바 등에 임베드.
팝업 챗
우하단 플로팅 버튼을 누르면 팝업이 열리는 형태. 기존 웹앱 위에 얹기만 하면 되는 방식이라 이번 실습에서 사용.
백엔드 감싸기 — ag_ui_strands로 SSE 엔드포인트 만들기
Strands 에이전트를 AG-UI 호환 서버로 만드는 데는 두 조각이면 됩니다. ag_ui_strands의 StrandsAgent가 Strands Agent를 감싸 AG-UI 스펙에 맞는 이벤트 스트림을 뽑도록 어댑팅하고, create_strands_app이 그걸 FastAPI 앱으로 포장합니다. 그다음 uvicorn으로 띄우면 SSE 엔드포인트가 준비됩니다.
경로는 처음부터 "/invocations" 하나로 통일합니다. 로컬 개발과 나중에 ACT 7에서 하게 될 AgentCore Runtime 배포가 같은 경로 계약을 쓰도록 맞춰두는 것입니다 — Runtime은 항상 /invocations로 호출하므로, 로컬에서도 이 경로로 시작하면 배포 시 코드를 한 줄도 안 바꿔도 됩니다. CopilotKit의 HttpAgent는 이 경로를 명시적으로 가리키게 설정하고(url: ".../invocations"), 헬스체크용 /ping은 {"status": "Healthy"}(대문자 H)를 반환하도록 하나 더 답니다.
from strands import Agent, tool from strands.models import BedrockModel from ag_ui_strands import StrandsAgent, StrandsAgentConfig, create_strands_app import json @tool def get_device_status(device: str) -> str: """스마트홈 기기의 실시간 상태를 조회합니다.""" mock = {"거실 조명": {"power": "on", "brightness": 60}} return json.dumps(mock.get(device, {"error": "unknown"})) # 툴은 str 반환 필수 model = BedrockModel(model_id="us.anthropic.claude-sonnet-4-6") agent = Agent(model=model, system_prompt="갤럭시 지원 상담원", tools=[get_device_status]) agui = StrandsAgent(agent=agent, name="galaxy_support", description="갤럭시 서비스 지원", config=StrandsAgentConfig()) app = create_strands_app(agui, "/invocations") # 로컬·배포 동일 경로 (AgentCore 계약) @app.get("/ping") # AgentCore Runtime 헬스체크 계약 (대문자 Healthy) def ping(): return {"status": "Healthy"} if __name__ == "__main__": import uvicorn uvicorn.run("agent_server:app", host="0.0.0.0", port=8080)
이 코드가 하는 일은 이렇습니다. 지금까지 만들어온 Agent를 StrandsAgent로 한 번 감싸면 AG-UI 이벤트 스트림을 생성할 수 있는 어댑터가 됩니다. 그걸 create_strands_app에 넣으면 SSE 엔드포인트가 있는 FastAPI 앱이 리턴됩니다. uvicorn이 8080 포트에 띄우면 어떤 AG-UI 클라이언트든 이 서버에 붙어 대화할 수 있습니다.
AgentCore가 요구하는 AG-UI 컨테이너 계약 (AWS 공식)
나중에 ACT 7에서 이 서버를 AgentCore Runtime에 그대로 올리게 됩니다. Runtime은 AG-UI 컨테이너에 다음을 고정 계약으로 요구하므로, 지금 로컬 서버도 이 규격을 지켜두면 배포가 그대로 됩니다.
| 항목 | AgentCore가 기대하는 값 |
|---|---|
| Port | 8080 (AG-UI · HTTP 공용. MCP=8000, A2A=9000) |
| HTTP/SSE Path | /invocations (WebSocket은 /ws) |
| Health check | /ping → {"status":"Healthy"} 반환 |
| Message format | SSE로 스트리밍되는 AG-UI 타입 이벤트 (RUN_STARTED · TEXT_MESSAGE_CONTENT · TOOL_CALL_START/END · RUN_FINISHED · STATE_UPDATE 등) |
| Protocol 구분 | AG-UI와 HTTP가 같은 8080//invocations를 씀. --protocol AGUI 플래그로 구분 |
- Claude Code가 agent_server.py를 만들고 requirements.txt도 정리합니다.
- 별도 터미널에서 python3.12 -m venv .venv && source .venv/bin/activate → pip install → uvicorn agent_server:app --port 8080.
- 서버가 뜨면 curl http://localhost:8080/ping으로 {"status":"Healthy"}가 오는지 확인합니다.
프론트 붙이기 — Next.js + CopilotKit
백엔드가 준비됐으니 프론트를 붙일 차례입니다. Next.js 앱에 CopilotKit 3개 패키지(react-core · react-ui · runtime)와 @ag-ui/client를 설치하고, 루트 레이아웃에 <CopilotKit> provider와 <CopilotPopup>을 배치합니다. 백엔드 프록시는 app/api/copilotkit/route.ts 하나가 담당합니다 — 그 안에서 HttpAgent(url:"http://localhost:8080/invocations")로 방금 띄운 8080에 연결합니다.
이 조합은 겉보기와 달리 버전과 설정에 굉장히 민감합니다. 실제로 검증된 조합은 다음과 같습니다 — 다른 버전으로 하면 빌드가 실패하거나 채팅이 아예 안 보내지는 사고가 납니다.
| 패키지 | 검증된 버전 |
|---|---|
| next | ^16.2.5 |
| react / react-dom | ^19.2.6 |
| @copilotkit/react-core / react-ui / runtime | ^1.57.0 |
| @ag-ui/client | ^0.0.53 |
| tailwindcss / @tailwindcss/postcss | ^4.2.4 |
import type { NextConfig } from "next"; const nextConfig: NextConfig = { // @copilotkit/runtime을 서버 외부 패키지로 지정하지 않으면 빌드가 실패합니다 serverExternalPackages: ["@copilotkit/runtime"], }; export default nextConfig;
serverExternalPackages는 Next.js가 서버 컴포넌트 번들에서 이 패키지를 번들 대상에서 제외하고 런타임에 require하도록 하는 설정입니다. CopilotKit runtime은 내부적으로 Node 전용 모듈을 쓰기 때문에 이걸 지정하지 않으면 웹팩 번들링에서 깨져 빌드가 실패합니다. 두 줄이지만 없으면 처음부터 못 뜹니다.
import { CopilotKit } from "@copilotkit/react-core"; import { CopilotPopup } from "@copilotkit/react-ui"; import "@copilotkit/react-ui/styles.css"; import "./globals.css"; // "use client" 넣지 않습니다 — CopilotKit 컴포넌트 내부에 이미 있습니다 export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"><body> // useSingleEndpoint={true} 없으면 CopilotKit 1.57+는 GraphQL로 붙어 메시지가 안 감 <CopilotKit runtimeUrl="/api/copilotkit" agent="galaxy_support" useSingleEndpoint={true}> {children} <CopilotPopup labels={{ title: "갤럭시 지원", initial: "무엇을 도와드릴까요?" }} /> </CopilotKit> </body></html> ); }
루트 레이아웃에서 <CopilotKit> provider가 앱 전체를 감싸고, 그 안에 <CopilotPopup>이 우하단 플로팅 챗 버튼을 렌더합니다. runtimeUrl은 프론트 안의 API 라우트를 가리키고, 그 API 라우트가 실제 백엔드(8080)로 요청을 프록시합니다. agent="galaxy_support"는 백엔드에 등록한 StrandsAgent의 이름과 정확히 일치해야 합니다.
- Claude Code가 app/layout.tsx, app/api/copilotkit/route.ts, next.config.ts를 만듭니다.
- API 라우트 안에서 HttpAgent(url:"http://localhost:8080/invocations")로 백엔드 8080과 연결됩니다.
- npm run dev로 3001 포트에 프론트가 뜨고, 우하단에 CopilotPopup 챗 버튼이 나타납니다.
브라우저에서 대화 — 스트리밍 · 툴 호출 관찰
두 서버가 다 떠 있으면 이제 브라우저에서 실제로 대화해봅니다. localhost:3001에 접속해 우하단 챗 버튼을 열고 자연어로 질문을 던지면, ACT 3에서 심어둔 get_device_status 툴이 호출되고 그 결과가 SSE로 흘러 UI에 실시간으로 그려집니다.
- 브라우저 챗 창에 사용자 메시지가 올라가고, 어시스턴트 답변이 토큰 단위로 스트리밍됩니다.
- 중간에 [Tool] get_device_status 호출 알림이 잠깐 뜨고 결과가 이어집니다 — AG-UI의 TOOL_CALL_START/END 이벤트가 렌더된 것.
- 백엔드 uvicorn 로그에 요청과 SSE 스트림 흐름이 실시간으로 찍힙니다.
에이전트 인스펙터 — 에이전트 "속"을 들여다보기
챗은 결과만 보여줍니다. 하지만 개발자는 그 안에서 무슨 일이 일어나는지 보고 싶습니다 — 어떤 툴이, 어떤 인자로 호출됐고, 무엇을 돌려줬는지. 그래서 화면을 이렇게 나눕니다: 오른쪽은 챗(CopilotKit), 왼쪽 메인은 에이전트 인스펙터. 둘 다 같은 AG-UI 이벤트 스트림을 구독하지만, 챗은 대화를, 인스펙터는 툴 호출·메시지 타임라인·실행 상태를 그립니다.
인스펙터는 CopilotKit의 useCopilotChatInternal() 훅으로 챗과 같은 메시지 스토어를 읽습니다. 반환되는 메시지는 AG-UI 포맷이라, assistant 메시지의 toolCalls(툴 요청)와 role:"tool" 메시지(툴 결과)를 toolCallId로 매칭하면 "어떤 호출이 무엇을 돌려줬는지" 카드가 완성됩니다.
- Claude Code가 AgentInspector.tsx를 만들고 page.tsx를 좌(인스펙터)·우(챗) 레이아웃으로 바꿉니다.
- 챗에 "거실 조명 상태 알려줘"를 보내면, 왼쪽 인스펙터에 get_device_status 툴 호출 카드(인자·결과 JSON)와 메시지 타임라인이 실시간으로 나타납니다.
- 이 인스펙터는 ACT 05·06에서도 그대로 재사용됩니다 — 메모리 저장/조회, 멀티 에이전트 핸드오프도 여기서 눈으로 확인합니다.
프론트를 갈아끼우는 게 왜 쉬운가 — 표준 엔드포인트의 힘
이 실습의 진짜 이득은 지금 만든 백엔드를 그대로 두고 프론트를 바꿀 수 있다는 데 있습니다. AG-UI는 표준이라, 이 서버는 CopilotKit 말고 다른 UI에도 그대로 붙습니다. 백엔드 코드는 한 줄도 안 바꿉니다.
사내 채널에서 대화
Slack 이벤트를 받아 AG-UI HTTP로 붙이는 어댑터 하나만 짜면 됩니다. 사용자는 Slack DM으로 갤럭시 지원 봇과 대화.
독자 디자인 챗
CopilotKit 대신 직접 만든 챗 UI가 AG-UI SSE 이벤트를 파싱합니다. 완전한 브랜딩 커스터마이즈.
iOS · Android
네이티브 SSE 클라이언트로 AG-UI 이벤트 스트림을 소비합니다. 같은 백엔드에 모바일이 붙는 셈.
세 경우 모두 백엔드는 오늘 만든 agent_server.py 그대로입니다. 프론트만 AG-UI 스펙에 맞춰 이벤트를 소비하도록 짜면 됩니다. 이게 프로토콜을 표준화해두는 힘입니다 — 시간이 지날수록 이득이 커집니다.
다음 모듈 예고: ACT 05에서는 이 에이전트에 메모리를 붙이고, 저장·조회가 인스펙터에 어떻게 찍히는지 봅니다. ACT 06의 멀티 에이전트 핸드오프도 인스펙터로 관찰합니다. 그리고 PART 2에 가면 이 왼쪽 화면은 개발자용 인스펙터에서 실서비스 운영 대시보드(호갱노노 스타일 지도)로 진화합니다 — 같은 "왼쪽 메인 + 오른쪽 챗" 구조 위에서, "에이전트 속 들여다보기" → "서비스 운영하기"로 넘어가는 것입니다. (백엔드를 AgentCore Runtime에 올리는 배포는 ACT 07.)
기억 · 상태 · 세션 · 매니지드 저장소
에이전트에게 기억을 붙이는 세 가지 층
지난 모듈의 벽 — 이 대화를 넘어서는 순간
ACT 3까지 우리 에이전트는 툴을 잘 부릅니다. "거실 조명 켜줘"라고 하면 SmartThings 툴을 호출하고, "삼성 페이 어제 결제 내역"이라고 하면 Gateway 뒤의 Lambda를 호출합니다. 그런데 여기서 사용자가 이런 말을 흘립니다 — "우리 집 조명은 필립스 휴야, 40% 밝기가 좋아." 지금 세션에서는 반영되지만, 브라우저를 껐다가 다시 켜면 그 사실을 완전히 잊습니다. 프로세스가 재시작돼도 마찬가지고, 여러 서버 인스턴스로 확장하면 각자 다른 기억을 갖게 됩니다.
필요한 기억은 사실 세 가지 시간축
지금 이 대화
방금 사용자가 뭘 물었는지, 아까 어떤 툴 결과가 나왔는지. 모델 컨텍스트에 매번 실려야 하는 흐름.
사용자 선호
"거실 조명은 필립스 휴", "40% 밝기". 매 대화마다 다시 알려주면 안 되는 개인 설정. 요청 간 유지되어야 함.
여러 세션에 걸친 지식
한 달 전에 배운 사용자 습관, 지난주 결제 패턴, 반복되는 문의 유형. 프로세스 · 세션 · 사용자를 넘어 살아남는 것.
이 세 시간축을 Strands는 두 층으로 처음부터 제공합니다 — 세 가지 상태(state)와 대화 창을 관리하는 ConversationManager. 그리고 그 너머의 장기 기억은 AgentCore Memory라는 매니지드 서비스가 담당합니다. 왼쪽부터 하나씩 열어봅니다.
Strands가 기본 제공하는 것 — 세 가지 상태
Strands의 에이전트는 안쪽에 세 종류의 상태를 들고 있습니다. 이름이 비슷해서 헷갈리기 쉬운데, 각자 누가 보는지 · 언제까지 사는지가 다릅니다.
| 상태 종류 | 접근 | 모델이 봄? | 지속 · 용도 |
|---|---|---|---|
| Conversation History | agent.messages | 예 (매 추론 시 통째로 전달) | 요청 간 자동 유지. user/assistant 메시지 · 툴 호출 · 툴 결과 전부. 모델이 대화 흐름을 이해하는 근거. |
| Agent State | agent.state | 아니오 | 요청 간 유지되지만 모델 컨텍스트에는 안 들어감. 툴/앱 로직만 read/write. key-value, JSON 직렬화 필요. |
| Invocation State | request_state | 아니오 | 한 호출 안에서만 유효. 이벤트 루프의 여러 사이클을 관통하는 dict. hook과 tool이 공유하는 임시 공간. |
이 중 이번 모듈에서 크게 다루는 건 앞의 둘입니다. Conversation History는 자동이지만 너무 길어지면 잘라야 하고(→ ConversationManager), Agent State는 "모델에겐 안 보이지만 툴은 볼 수 있는 개인 서랍" 역할로 사용자 선호를 담기 딱 좋습니다.
대화 창 관리 — ConversationManager
Conversation History는 자동으로 쌓이지만, 컨텍스트 창은 무한이 아닙니다. 대화가 100턴을 넘어가면 오래된 메시지는 어딘가에서 정리되어야 하고, 그 정리 방식이 답변 품질과 비용을 좌우합니다. Strands는 이 정리 정책을 ConversationManager라는 교체 가능한 컴포넌트로 뽑아 뒀습니다.
세 가지 매니저 — 언제 무엇을 쓰나
아무것도 안 함
기록을 절대 건드리지 않음. 짧은 대화 · 디버깅 · 수동 제어가 필요할 때. 오버플로가 나면 그대로 에러.
오래된 것부터 잘라냄
최근 N개 메시지만 남기고 앞은 버림. 툴 호출/결과 쌍은 깨지지 않게 정리. 대부분의 경우 이걸로 충분합니다.
버리는 대신 요약
잘라낼 부분을 별도 모델이 짧게 요약해서 앞머리에 남김. 긴 대화의 맥락을 유지하고 싶을 때. 요약 호출 비용이 추가로 듭니다.
from strands import Agent from strands.agent.conversation_manager import SlidingWindowConversationManager cm = SlidingWindowConversationManager( window_size=20, # 최근 20개 메시지만 유지 should_truncate_results=True, # 오래된 툴 결과는 축약해서 자리 확보 ) agent = Agent(conversation_manager=cm)
이 코드가 하는 일은 이렇습니다. window_size=20은 최근 20개 메시지만 창(window)에 유지하겠다는 뜻이고, 이보다 오래된 메시지는 다음 추론 직전에 앞에서부터 잘려 나갑니다. should_truncate_results=True는 툴 결과(예: 긴 JSON 응답)가 창을 넘칠 때 오래된 툴 결과만 축약해서 자리를 확보하도록 합니다. 이미지·바이너리는 [image: png, 12345 bytes] 같은 플레이스홀더로 대체됩니다. 만든 매니저를 Agent(conversation_manager=cm)에 넘기면 이후 모든 대화가 이 정책을 따릅니다.
- Claude Code가 with_window.py를 만듭니다 — window_size=6으로 매니저를 붙이고, for 루프로 8번 agent("...")을 호출합니다.
- 초반에는 len(agent.messages)가 점점 늘다가, 창 크기를 넘는 순간부터 일정 개수로 유지됩니다.
- 초반에 알려준 사용자 이름을 마지막에 다시 물으면 잊어버린 반응이 나옵니다 — 잘렸다는 증거입니다.
Agent State — 사용자 선호를 툴 안에 저장하기
ConversationManager로 대화 창은 관리되지만, "거실 조명 밝기 40%"처럼 대화 흐름과는 별개로 오래 살아야 하는 값은 다른 자리에 둬야 합니다. 그 자리가 agent.state입니다. 이 값은 모델 컨텍스트에는 실리지 않지만 툴 함수 안에서는 자유롭게 read/write할 수 있습니다.
툴 안에서 state에 접근하려면 데코레이터에 context=True를 얹고, 파라미터에 ToolContext를 하나 받으면 됩니다. 그러면 tool_context.agent.state.get/set으로 값을 다룰 수 있습니다.
from strands import Agent, tool, ToolContext @tool(context=True) def save_preference(key: str, value: str, tool_context: ToolContext) -> str: """사용자 선호를 저장합니다. 예: key='거실 조명 밝기', value='40%'.""" tool_context.agent.state.set(key, value) return f"저장됨: {key} = {value}" @tool(context=True) def get_preference(key: str, tool_context: ToolContext) -> str: """저장된 사용자 선호를 조회합니다.""" v = tool_context.agent.state.get(key) return f"{key} = {v}" if v else f"'{key}'는 저장된 값이 없습니다" agent = Agent(tools=[save_preference, get_preference])
여기서 벌어지는 일을 짚어봅니다. @tool(context=True)는 Strands에게 "이 툴이 실행될 때 ToolContext를 함께 넘겨달라"고 요청합니다. 넘어온 tool_context.agent.state는 지금 실행 중인 바로 그 에이전트의 state 객체라, .set(key, value)로 넣으면 요청이 끝난 뒤에도 값이 유지됩니다. 저장되는 값은 JSON 직렬화 가능해야 하므로 lambda·file handle 같은 건 넣을 수 없습니다. 모델은 이 값들을 직접 못 봅니다 — 필요할 때 get_preference 툴을 호출해서 꺼내야만 대답에 반영할 수 있습니다.
- Claude Code가 tools.py에 두 @tool 함수를 추가하고 TOOLS에 등록합니다 (전역 dict 기반 — ACT 05 도입부용 간단 메모리).
- 시스템 프롬프트에 선호 저장/조회 규칙이 들어가, 모델이 알아서 두 툴을 부릅니다.
- 백엔드를 재시작하면 챗 에이전트가 기억 능력을 갖춥니다.
- 첫 메시지에서 save_preference("거실 조명 밝기", "40%")가 호출되고, 인스펙터에 인자·결과 JSON 카드가 뜹니다.
- 두 번째 메시지에서 get_preference("거실 조명 밝기")가 {"status":"found","value":"40%"}를 돌려주고, 에이전트가 "40%입니다"라고 답합니다.
- 대화 히스토리가 아니라 저장된 메모리에서 꺼낸 답이라는 걸 인스펙터의 두 툴 호출 카드로 눈으로 확인합니다.
여기까지의 한계 — 프로세스가 죽으면 기억도 죽는다
지금까지의 agent.state는 강력해 보이지만, 사실 파이썬 프로세스 안의 dict일 뿐입니다. Ctrl+C로 스크립트를 종료하면 사라지고, 서버 프로세스가 재시작되면 사라지고, 오토스케일링으로 인스턴스가 두 대가 되면 각자 다른 state를 갖게 됩니다. 게다가 여러 사용자를 한 에이전트가 응대하는 순간부터는 사용자별로 state를 격리해야 하는 문제가 새로 생깁니다.
AgentCore Memory — 매니지드 기억 저장소의 시작
Amazon Bedrock AgentCore Memory는 에이전트를 위해 특화된 매니지드 기억 저장소입니다. 이벤트를 던져 넣으면 선호도 자동 추출·요약 자동 생성·시맨틱 검색·여러 세션을 넘는 조회까지 알아서 해줍니다. 그런데 boto3로 처음 접근하면 서비스 이름이 두 개라 당황할 수 있습니다. 이걸 먼저 정리하고 들어갑니다.
AgentCore Memory의 두 얼굴 — Control Plane vs Data Plane
AgentCore Memory에는 두 개의 boto3 클라이언트가 있습니다. 하나는 bedrock-agentcore-control(관리자 API), 다른 하나는 bedrock-agentcore(런타임 API)입니다. 이름은 비슷하지만 전혀 다른 역할을 합니다.
왜 서비스를 둘로 나누어 뒀을까요. 이유는 실제 클라우드 서비스 설계 원칙 그대로입니다. Memory 리소스를 만들고 · 삭제하고 · 수정하는 관리자 오퍼레이션은 자주 일어나지 않고 관리자 IAM 권한이 필요합니다. 반면 대화 도중 이벤트를 밀어 넣거나 · 시맨틱 검색으로 꺼내는 오퍼레이션은 초당 수백 번 일어나며 앱 런타임 IAM 권한이면 충분합니다. 이 둘을 같은 서비스에 두면 IAM 세분화 · 감사 로깅 · 스로틀링 정책을 나눠 관리하기 어려워집니다. 그래서 관리 축(Control Plane)과 실행 축(Data Plane)을 분리한 겁니다.
| 서비스 (boto3 클라이언트) | 어떤 오퍼레이션을 제공하나 |
|---|---|
| bedrock-agentcore-control 관리자 · 리소스 CRUD | create_memory · get_memory · list_memories · update_memory · delete_memory. 리소스 자체를 만들고 · 조회하고 · 수정·삭제. |
| bedrock-agentcore 런타임 · 이벤트/기록 조작 | create_event · list_events · retrieve_memory_records · list_memory_records · batch_*_memory_records · start_memory_extraction_job. 실제 대화 도중 계속 호출됨. |
정리하면, 인프라 코드나 CDK에서는 대개 bedrock-agentcore-control로 Memory 리소스를 한 번 만들어 두고, 에이전트 런타임 코드에서는 bedrock-agentcore로 이벤트를 저장하고 검색합니다. 둘 다 같은 memoryId를 참조합니다.
내부 아키텍처 — Ingestion · Extraction · Retrieval
여기서 가장 자주 나오는 질문은 이겁니다 — "왜 이벤트(raw)와 Memory Record(정제)를 이중으로 저장하는가?" 답은 두 가지 조회 요구가 근본적으로 다르기 때문입니다. 원문 그대로 시간 순으로 훑고 싶은 조회(예: "지난 세션 마지막 대화가 뭐였지?")와, 여러 세션을 관통해 의미로 꺼내고 싶은 조회(예: "이 사용자의 조명 선호가 뭐지?")는 완전히 다른 인덱스를 요구합니다. 그래서 AgentCore Memory는 안쪽을 3단으로 나눠 두었습니다 — Ingestion → Extraction → Retrieval.
흐름을 다시 읽어봅니다. ① Ingestion에서 create_event가 호출되면 이벤트는 즉시 Event Store에 원문 그대로 저장됩니다. 이 단계까지만 성공해도 데이터는 안전합니다. ② Extraction은 백그라운드에서 돌아가는 별도 파이프라인으로, Memory 리소스에 붙여둔 strategy들이 각자의 로직으로 이벤트를 읽어 정제된 명제를 만들어냅니다. semanticMemory는 "사용자 집 조명은 필립스 휴"라는 사실 문장을, userPreference는 "밝기 선호 = 40%"라는 선호 명제를 뽑는 식입니다.
③ Memory Record Store에서 이 정제된 조각들은 embedding 벡터와 함께 인덱싱되어 namespace로 분류되어 저장됩니다. 마지막 ④ Retrieval에서 앱은 두 경로 중 목적에 맞는 걸 고릅니다 — 원문이 필요하면 list_events, 의미 기반 검색이 필요하면 retrieve_memory_records. 이 이중 저장 덕분에 원본의 정확성과 정제된 지식의 재사용성을 동시에 얻을 수 있습니다.
5가지 Memory Strategy — 이벤트에서 무엇을 뽑을지
Memory 리소스를 만들 때 strategy를 하나 이상 붙여두면, 이후 create_event로 들어오는 대화에서 자동으로 필요한 조각들을 추출해 Memory Record로 만들어줍니다. 각 strategy는 event에서 무엇을 뽑아 어떤 record를 만드는지가 다릅니다.
의미 기반 사실 추출
event에서 뽑는 것: 사용자가 말한 팩트 — "우리 집 조명은 필립스 휴", "냉장고는 LG 뷰"처럼 시간에 무관한 사실. record 형태: 짧은 사실 문장 + embedding. 어울리는 상황: 사용자·기기·환경에 관한 지식 베이스를 축적할 때.
대화 요약
event에서 뽑는 것: 한 세션 전체의 흐름. record 형태: "이번 세션에서 사용자는 도어락 잠금 문제를 겪었고 재등록으로 해결됨" 같은 요약문. 어울리는 상황: 긴 세션의 요점을 다음 세션 시작 시 컨텍스트로 넣고 싶을 때.
사용자 선호 추출
event에서 뽑는 것: 취향·선호 신호 — "40% 밝기가 좋아", "커피 대신 차로". record 형태: 선호 명제 + 근거. 어울리는 상황: 개인 맞춤 응대가 핵심인 지원 에이전트 · 홈 에이전트.
에피소드 · 사건별
event에서 뽑는 것: 시간·장소·주체가 있는 개별 사건. 추가로 reflectionConfiguration으로 사건에 대한 반성 생성. 어울리는 상황: "지난주 결제 실패" 같은 사건 이력을 다뤄야 할 때.
도메인 특화 커스텀
event에서 뽑는 것: configuration으로 정의하는 자유 로직 — 예: "결제 이벤트에서 금액과 카테고리만 뽑아라". 어울리는 상황: 위 네 종류로 안 잡히는 도메인 규칙이 있을 때.
실무에서는 이 다섯 개를 필요한 것만 조합해서 붙입니다. 대부분의 시작은 userPreferenceMemoryStrategy 하나로 충분하고, 세션이 길어지면 summaryMemoryStrategy를 더하고, 지식이 쌓이기 시작하면 semanticMemoryStrategy를 켭니다. 이번 모듈의 후반부 실습에서는 userPreferenceMemoryStrategy 하나를 붙여서 흐름 전체를 눈으로 확인합니다.
Actor · Session · Event · Memory Record — 4단 계층
Memory 리소스 안쪽 데이터는 네 개의 개념이 계층으로 얽혀 있습니다. 이걸 그림 없이 말로만 이해하려면 헷갈리니, 표와 다이어그램을 함께 봅니다.
누가 대화 중인가
사용자 또는 에이전트 식별자. actorId="user-yeonuk". 다중 사용자 격리 단위 — 한 Memory 리소스에 수만 명의 actor가 공존해도 서로 섞이지 않음.
어느 대화인가
한 actor의 개별 대화 트랙. sessionId="sess-2026-07-02". 같은 actor라도 세션이 다르면 별개 흐름. 세션 안 이벤트들이 하나의 문맥으로 묶임.
대화의 한 조각
USER · ASSISTANT · TOOL 메시지 하나 또는 여러 개. payload는 conversational(role/content) 또는 blob. 이벤트 ID는 자동 발급.
추출된 정제 기억
strategy가 event들에서 뽑은 가공된 지식. namespace로 분류되어 저장. 시맨틱 검색의 대상. event가 raw라면 record는 정제된 명제.
계층을 쌓아 읽어봅니다. Actor 아래에 여러 Session이 있고, 각 session에는 여러 Event가 시간순으로 쌓입니다. 이 이벤트들이 백그라운드에서 Extraction을 거쳐 Memory Record로 정제됩니다. record는 namespace라는 경로로 분류되어 저장되는데, namespace는 기억을 어떤 폴더에 넣을지 정하는 스킴입니다.
실제로는 Memory 리소스를 만들 때 namespaceTemplates에 /users/{actorId}/preferences 같은 템플릿을 넣어두면, 이벤트에서 추출된 record가 자동으로 그 actor의 경로로 라우팅됩니다. 나중에 retrieve_memory_records를 호출할 때 이 경로로 검색 스코프를 좁힐 수 있어 다중 사용자 격리가 자연스럽게 이뤄집니다.
실제로 어떻게 저장·조회하나 — 5가지 API
Data plane 클라이언트(bedrock-agentcore)에서 실무에 자주 쓰는 API는 크게 다섯 개입니다. 언제 뭘 부르는지 표로 정리합니다.
| API | 서비스 | 언제 부르나 |
|---|---|---|
| create_event | data | 대화 도중 매번. 사용자·에이전트·툴 메시지를 원문 그대로 저장. 저장 성공 후 백그라운드 추출 파이프라인이 자동으로 처리. |
| list_events | data | 특정 actor/session의 원문 이벤트를 시간순으로 훑고 싶을 때. 세션 재개, 감사 로그, 디버깅. |
| retrieve_memory_records | data | 시맨틱 검색. searchCriteria의 검색어를 embedding으로 바꿔 유사도로 관련 record 조회. 매 응답 직전에 부르는 게 일반적. |
| list_memory_records | data | 특정 namespace/strategy의 record 목록을 페이지로 훑을 때. 관리 화면 · 데이터 검증. |
| start_memory_extraction_job | data | 백그라운드 추출이 알아서 도는 걸 기다리기 싫을 때 명시적으로 트리거. 배치성 재추출에도 사용. |
이해용 코드 — Memory 만들기 · 이벤트 저장 · 시맨틱 조회
① Memory 리소스 생성 + strategy 하나 붙이기 (control plane)
import boto3 cc = boto3.client("bedrock-agentcore-control", region_name="us-west-2") r = cc.create_memory( name="galaxy_support_memory", description="갤럭시 서비스 지원 기억 저장소", eventExpiryDuration=90, # 일 단위 · 30~365 사이 memoryStrategies=[{ "userPreferenceMemoryStrategy": { "name": "user_pref", "namespaces": ["/users/{actorId}/preferences"], } }], ) memory_id = r["memory"]["id"] print("memoryId:", memory_id)
이 코드가 다루는 클라이언트는 bedrock-agentcore-control입니다. eventExpiryDuration은 이벤트 원문을 며칠 동안 보관할지(30~365일, 필수)입니다. memoryStrategies에 userPreferenceMemoryStrategy 하나를 붙였습니다. namespaces에 /users/{actorId}/preferences를 두면, 이벤트에서 추출된 선호 record가 actor별 경로로 자동 라우팅됩니다. 응답에서 뽑은 memory_id는 이후 모든 이벤트 저장·조회에 계속 씁니다.
② 이벤트 저장 (data plane)
import boto3, datetime c = boto3.client("bedrock-agentcore", region_name="us-west-2") c.create_event( memoryId=memory_id, actorId="user-yeonuk", sessionId="sess-2026-07-02", eventTimestamp=datetime.datetime.now(datetime.timezone.utc), payload=[{ "conversational": { "role": "USER", "content": {"text": "거실 조명은 필립스 휴 A19이고 40% 밝기가 좋아"} } }], )
이번엔 bedrock-agentcore(data plane) 클라이언트입니다. actorId가 사용자, sessionId가 대화 트랙, eventTimestamp는 UTC datetime입니다. payload는 리스트여서 여러 메시지를 한 번에 넣을 수 있고, 각 항목은 conversational(대화)이나 blob(임의 바이너리) 형태입니다. role은 USER · ASSISTANT · TOOL · OTHER 중 하나. 저장이 성공하면 이벤트 ID가 자동 발급되고, 백그라운드 추출 파이프라인이 이 이벤트를 곧 소비해 user_pref strategy 기준으로 선호 record를 만들어냅니다.
③ 시맨틱 검색으로 record 꺼내기 (data plane)
import boto3 c = boto3.client("bedrock-agentcore", region_name="us-west-2") r = c.retrieve_memory_records( memoryId=memory_id, namespace="/users/user-yeonuk/preferences", searchCriteria={"searchQuery": "거실 조명 밝기"}, maxResults=5, ) for rec in r["memoryRecordSummaries"]: print(rec["content"], rec["score"])
이 호출이 뒤에서 실제로 하는 일은 이렇습니다. searchQuery="거실 조명 밝기"가 먼저 embedding 벡터로 변환되고, namespace로 검색 스코프가 좁혀진 뒤(=이 actor의 preferences 폴더만), 그 안의 record들과 벡터 유사도로 비교되어 가장 관련 있는 상위 N개(maxResults=5)가 score와 함께 돌아옵니다. 응답의 memoryRecordSummaries에는 각 record의 content(정제된 명제 문장)와 유사도 score가 들어 있습니다. 이 결과를 그대로 시스템 프롬프트에 얹거나, 툴 응답으로 모델에 넘겨 응답에 반영합니다.
- Claude Code가 create_memory.py를 만듭니다 — control plane 클라이언트로 리소스 생성 요청.
- 별도 터미널에서 실행하면 memoryId: mem_xxxxxxxx가 출력되고, status는 잠시 CREATING이었다가 곧 ACTIVE가 됩니다.
- 이 memoryId를 다음 랩에서 계속 씁니다.
- Claude Code가 save_and_list.py를 만듭니다 — data plane 클라이언트로 create_event를 서너 번 호출한 뒤 list_events로 되돌려 읽습니다.
- 출력에는 자동 발급된 eventId와 payload가 최근 순으로 찍힙니다.
- 이제 이 이벤트들은 프로세스 재시작·인스턴스 교체를 넘어 AWS에 남아 있고, 백그라운드 추출 파이프라인이 이 이벤트를 곧 소비합니다.
- Claude Code가 retrieve_records.py를 만듭니다 — data plane 클라이언트로 retrieve_memory_records 호출.
- 출력에 "밝기 선호 = 40%" 같은 정제된 record가 score와 함께 찍힙니다. LAB 5-5에서 넣은 원문이 아니라 추출된 명제가 나옵니다.
- 추출 잡이 아직 안 돈 상태라면 결과가 비어 있을 수 있습니다 — 잠깐 기다렸다가 다시 실행하거나 start_memory_extraction_job으로 트리거합니다.
- 이걸 매 대화 직전에 부르면 에이전트가 "사용자님 선호대로 40%로 맞췄어요"처럼 세션을 넘는 개인화를 할 수 있습니다.
언제 무엇을 쓰나 — 기억 도구 비교
세 계층의 기억이 모두 필요할 수도 있고, 하나로 충분할 수도 있습니다. 지금 풀려는 문제에 맞춰 고르면 됩니다.
| 항목 | agent.state | SlidingWindow | SummarizingCM | AgentCore Memory |
|---|---|---|---|---|
| 사는 곳 | 프로세스 메모리 | 프로세스 메모리 (messages) | 프로세스 메모리 + 요약 텍스트 | AWS 매니지드 저장소 |
| 지속성 | 프로세스 종료 시 소멸 | 프로세스 종료 시 소멸 | 프로세스 종료 시 소멸 | 영속 (eventExpiry까지) |
| 다중 사용자 격리 | 직접 구현 필요 | 에이전트 단위 | 에이전트 단위 | O · actorId·namespace로 자동 격리 |
| 시맨틱 검색 | X | X | X (요약만) | O · retrieve_memory_records (embedding 유사도) |
| 자동 선호 추출 | X | X | X | O · userPreferenceMemoryStrategy 등 |
| 자동 요약 | X | X | 같은 세션 안에서만 | O · summaryMemoryStrategy |
| 세션 간 유지 | 같은 프로세스 안에서만 | X | X | O · sessionId를 넘어 조회 |
| 다중 프로세스 · 오토스케일 공유 | X | X | X | O · 모든 인스턴스가 같은 저장소 참조 |
| 초기 셋업 비용 | 매우 낮음 | 매우 낮음 | 낮음 (요약 모델 지정) | 중간 (리소스 생성 · IAM) |
| 어울리는 상황 | 단일 프로세스의 개인 설정 캐시 | 일반적인 채팅 흐름 관리 | 매우 긴 대화의 맥락 유지 | 세션·사용자·프로세스를 넘어 남아야 하는 기억 |
다음 모듈 예고: ACT 06에서는 이 기억을 공유하는 여러 에이전트를 오케스트레이션하고, ACT 07에서는 지금까지 만든 이 에이전트를 AgentCore Runtime에 배포합니다. 오늘 만든 AgentCore Memory는 Runtime과 완전히 독립된 리소스라, Runtime에 올린 순간 그대로 붙어 돌아갑니다 — 코드를 바꿀 필요 없이.
멀티 에이전트
하나의 에이전트로 부족할 때 — 여러 에이전트를 팀으로
지난 모듈의 벽 — 한 명이 모든 걸 다 잘하지 못한다
ACT 05까지 우리 에이전트는 하나의 system_prompt와 하나의 툴 목록으로 살아왔습니다. 그런데 도메인이 넓어지면 문제가 생깁니다 — 페이·헬스·클라우드·스마트씽스를 모두 잘 답하려면 프롬프트는 길어지고, 툴 목록은 수십 개가 되고, 모델이 툴을 잘못 고르는 빈도가 눈에 띄게 늘어납니다. 사람 팀이라면 서비스별 담당자를 두는 방식으로 풀 문제입니다.
세 가지 오케스트레이션 패턴 — 한눈에
오케스트레이터 라우팅
언제: 전문 에이전트를 필요할 때만 골라 부르고 싶을 때. 특징: Agent 인스턴스를 다른 Agent의 tools=[]에 그대로 넣으면 자동으로 툴로 변환됨. 오케스트레이터가 자연어로 판단해 위임.
결정론적 파이프라인
언제: 순서·분기·재시도가 미리 정해진 프로세스. 특징: 노드=에이전트, 엣지=흐름. GraphBuilder로 DAG + 순환 지원, 조건부 엣지 가능.
자율 협업 · 핸드오프
언제: 어느 전문가로 넘길지 에이전트가 스스로 판단해야 할 때. 특징: 각 에이전트가 handoff_to_agent 툴을 자동 획득. 공유 컨텍스트로 이어받음.
첫 번째 방법 — Agents-as-tools: 전문 에이전트를 툴처럼 감싸기
가장 자연스러운 오케스트레이션은 전문 에이전트를 함수로 감싸는 것입니다. Strands에서는 이게 놀랄 만큼 간단합니다 — 이미 만든 Agent 인스턴스를 다른 Agent의 tools=[] 배열에 그대로 넣으면 자동으로 툴로 변환됩니다. 별도 래퍼도, @tool 데코레이터도 필요 없습니다.
변환 규칙은 이렇습니다. Agent의 name이 툴 이름이 되고, system_prompt가 그 툴의 성격(=description의 근거)이 됩니다. 오케스트레이터가 자연어로 "이 질문은 페이 담당이겠다"라고 판단하는 순간, 그 이름의 툴을 자동으로 호출합니다. 즉 라우팅 로직은 프롬프트가 아니라 자연스러운 툴 선택으로 처리됩니다.
from strands import Agent pay_agent = Agent( name="pay_agent", system_prompt="삼성 페이 결제 내역·카드 등록 전문가. 결제 관련 질문만 응답합니다.", ) health_agent = Agent( name="health_agent", system_prompt="삼성 헬스 걸음수·수면·심박수 전문가. 건강 데이터만 응답합니다.", ) orchestrator = Agent( system_prompt="질문의 주제에 맞는 전문 에이전트 툴을 골라 호출하세요.", tools=[pay_agent, health_agent], # Agent 인스턴스를 그대로 넣으면 자동 툴 변환 ) print(orchestrator("어제 걸음수 얼마나 걸었어?"))
이 코드가 하는 일을 짚어봅니다. pay_agent와 health_agent는 독립된 Strands 에이전트고 각자 자기 프롬프트를 가집니다. 이 둘을 orchestrator의 tools=[pay_agent, health_agent]에 그대로 넣으면 Strands가 각각을 "입력 문자열을 받아 텍스트를 반환하는 툴"로 자동 감쌉니다. 툴 이름은 각 Agent의 name 그대로 "pay_agent"·"health_agent". 실행 시 오케스트레이터는 "어제 걸음수"가 헬스 도메인임을 판단해 health_agent 툴만 호출하고, 그 응답을 사용자 답에 녹여 돌려줍니다.
- Claude Code가 orchestrator.py를 만듭니다 — 세 개의 Agent(...)와 tools=[pay_agent, health_agent].
- 실행하면 [tool] health_agent(...)만 호출되고 pay_agent는 조용합니다.
- 질문을 "삼성 페이 카드 등록"으로 바꾸면 반대가 되는 것을 눈으로 확인합니다 — 라우팅이 자연어 판단으로 일어난다는 증거.
두 번째 방법 — Graph: 흐름을 개발자가 그린다
Agents-as-tools는 런타임에 어디로 갈지 오케스트레이터가 정했습니다. 반대 극단은 흐름을 미리 그려두는 것입니다. 진단 → 해결 → 검증처럼 순서가 고정된 프로세스라면, 매번 LLM에게 라우팅을 맡기는 대신 개발자가 노드와 엣지로 명시하는 편이 훨씬 예측 가능합니다.
Strands의 Graph는 이걸 위한 컴포넌트입니다. 노드는 Agent(또는 중첩된 멀티에이전트 시스템·커스텀 노드), 엣지는 데이터·제어 흐름. GraphBuilder로 add_node·add_edge·set_entry_point·build을 조합해 만듭니다. DAG가 기본이지만 순환도 지원해서 피드백 루프를 그릴 수 있습니다.
from strands import Agent from strands.multiagent import GraphBuilder diag = Agent(name="diag", system_prompt="기기 이상 증상을 정확히 진단합니다.") resolve = Agent(name="resolve", system_prompt="진단 결과를 받아 해결책을 제안합니다.") verify = Agent(name="verify", system_prompt="해결책을 사용자 관점에서 검증합니다.") builder = GraphBuilder() builder.add_node(diag, "diag") builder.add_node(resolve, "resolve") builder.add_node(verify, "verify") builder.add_edge("diag", "resolve") builder.add_edge("resolve", "verify") builder.set_entry_point("diag") graph = builder.build() result = graph("현관 도어락이 응답 없음, 배터리 20%") print("status:", result.status) print("order:", [n.node_id for n in result.execution_order])
이 코드가 진행하는 순서는 다음과 같습니다. 세 개의 Agent를 add_node로 그래프에 등록하고, add_edge로 순서를 그립니다. set_entry_point("diag")가 진입점, build()로 그래프가 완성됩니다. 실행은 graph("...") 한 줄로 시작하고, 결과 객체에서 .status로 성공 여부, .execution_order로 실제로 어떤 순서로 노드가 실행됐는지를 노드 id 목록으로 꺼낼 수 있습니다. 각 노드의 개별 출력은 result.results["diag"].result 식으로 접근합니다.
Graph는 단순한 파이프라인 외에도 몇 가지 손잡이를 더 제공합니다. add_edge(..., condition=lambda state: ...)로 조건부 엣지를 그려 특정 결과일 때만 다음 노드로 넘어가게 할 수 있고, reset_on_revisit(True)·set_max_node_executions(n)·set_execution_timeout(sec) 조합으로 순환 그래프의 안전 한도를 지정할 수 있습니다. 즉 피드백 루프(예: 검증 실패면 다시 해결로)를 무한히 돌지 않게 잡아주는 안전장치가 내장됩니다.
- Claude Code가 with_graph.py를 만듭니다 — 세 개의 Agent와 GraphBuilder 조합.
- 실행하면 order: ['diag', 'resolve', 'verify']가 찍혀 나옵니다. 매 실행마다 순서가 흔들리지 않습니다 — 개발자가 그렸으니까.
- 중간 노드 출력을 확인하려면 result.results["diag"].result를 print해서 각 단계 응답을 눈으로 봅니다.
세 번째 방법 — Swarm: 에이전트가 스스로 다음을 정한다
Graph에서는 개발자가 모든 흐름을 미리 그렸습니다. Swarm은 정반대입니다 — 흐름을 에이전트들이 실시간으로 만들어냅니다. 각 에이전트는 자기 판단으로 "이건 내가 답할 수 없으니 결제 담당에게 넘기겠다"라고 결정하고, 이 팀원-투-팀원 핸드오프가 창발적으로 경로를 만들어냅니다.
Strands는 이걸 지원하기 위해 Swarm에 참여하는 모든 에이전트에 자동으로 handoff_to_agent 툴을 부여합니다. 개발자는 이 툴을 직접 등록하지 않고, system_prompt에 "적합한 전문가에게 handoff_to_agent로 넘기라"는 지시만 넣습니다. 공유 컨텍스트(원 작업 · 핸드오프 히스토리 · 이전 지식)가 자동으로 다음 에이전트에게 전달됩니다.
from strands import Agent from strands.multiagent import Swarm triage = Agent( name="triage", system_prompt=("당신은 문의 분류 담당입니다. 결제 이슈면 payments로, " "홈 기기 이슈면 smartthings로 handoff_to_agent 하세요."), ) payments = Agent(name="payments", system_prompt="삼성 페이 결제·카드 이슈 전문. 필요 시 handoff_to_agent.") smartthings = Agent(name="smartthings", system_prompt="스마트홈 기기 이슈 전문. 필요 시 handoff_to_agent.") swarm = Swarm( [triage, payments, smartthings], entry_point=triage, max_handoffs=5, max_iterations=8, execution_timeout=120.0, node_timeout=45.0, ) result = swarm("삼성 페이 카드 등록 실패") print("status:", result.status) print("history:", [n.node_id for n in result.node_history])
이 코드에서 실제로 일어나는 일은 이렇습니다. Swarm([...])이 생성될 때 세 에이전트 모두에게 눈에 보이지 않는 handoff_to_agent 툴이 자동으로 붙습니다. 실행이 entry_point=triage에서 시작되면 triage가 프롬프트 지시대로 "이건 결제 이슈다"를 판단해 handoff_to_agent(agent_name="payments", message=..., context=...)을 부릅니다. 그 순간 실행권과 공유 컨텍스트가 payments 에이전트로 넘어가고, payments가 답을 마무리하거나 다시 다른 팀원에게 넘길지 결정합니다. .node_history에 이 실제 이동 경로가 순서대로 기록됩니다.
자율 협업이라 안전장치가 중요합니다. max_handoffs는 핸드오프 총 횟수 상한, max_iterations는 에이전트 실행 총 횟수 상한, execution_timeout·node_timeout은 초 단위 타임아웃입니다. 여기에 repetitive_handoff_detection_window를 켜두면 A→B→A→B 같은 핑퐁 루프도 자동으로 감지해 끊어냅니다.
- Claude Code가 with_swarm.py를 만듭니다 — 세 Agent와 Swarm([...], entry_point=triage, ...) 조합.
- 실행하면 history: ['triage', 'payments']가 찍힙니다 — triage가 payments로 스스로 넘겼다는 증거.
- 입력을 "거실 조명이 안 켜져요"로 바꾸면 ['triage', 'smartthings']로 자연스럽게 경로가 달라집니다.
네 번째는 언급만 — Workflow
Strands에는 네 번째 오케스트레이션 도구로 strands_tools.workflow가 있습니다. 정적인 task 그래프(task_id · description · dependencies · priority)를 하나의 툴로 정의하고 create · start · status로 조작하는 방식입니다. Graph가 커버하는 영역과 크게 겹쳐서 이번 워크숍에서는 실습을 스킵하고, 필요할 때 GraphBuilder부터 손대는 걸 권장합니다.
언제 뭘 쓰나 — 세 패턴 비교
세 패턴은 서로 대체재라기보다 결정권을 어디에 두는가의 스펙트럼입니다. 오케스트레이터가 정하면 Agents-as-tools, 개발자가 정하면 Graph, 참여 에이전트가 정하면 Swarm.
| 항목 | Agents-as-tools | Graph | Swarm |
|---|---|---|---|
| 흐름 결정 주체 | 오케스트레이터 LLM | 개발자 (사전 정의) | 참여 에이전트 각자 |
| 실행 순서 | 런타임 자연어 판단 | 노드·엣지에 새겨진 대로 | 핸드오프 결정에 따라 창발 |
| 조건 분기 | 모델의 툴 선택으로 | condition= 엣지 | 에이전트 판단 |
| 순환·루프 | 암묵적 (모델이 다시 부름) | 지원 · reset_on_revisit · 실행 한도 | 지원 · max_handoffs · timeout |
| 대표 유스케이스 | 서비스별 전문가 라우팅 | 진단→해결→검증 고정 파이프라인 | 다양한 관점의 탐색·종합 |
| 어울리는 상황 | 도메인이 넓고 각 요청이 한 전문가로 정리될 때 | 매번 같은 순서로 흘러야 하고 감사·재현성이 중요할 때 | 어느 전문가가 필요한지조차 상황마다 다를 때 |
다음 모듈 예고: ACT 07에서는 지금까지 만든 이 에이전트들(멀티에이전트 포함)을 AgentCore Runtime에 서버리스로 배포합니다. 로컬 python으로 돌아가던 코드가 그대로 매니지드 런타임 위로 올라갑니다 — 코드는 거의 그대로, 인프라만 사라집니다.
AgentCore Runtime 배포
ACT 04의 AG-UI 서버를 서버리스 관리형 런타임에 한 명령으로 올립니다
로컬 실행의 한계 — 그대로는 프로덕션이 아닙니다
ACT 04의 agent_server.py는 로컬 8080에서 잘 돕니다. 하지만 프로세스가 상시 실행되어야 하고, 여러 사용자가 동시에 붙으면 대화 상태가 섞이며, 트래픽 스파이크에 스케일 아웃할 방법이 없고, 누구나 열려 있는 8080에 붙을 수 있어 인증도 없습니다. 실제 서비스로 넘기려면 이 네 가지를 모두 해결해야 하는데, 직접 짜기 시작하면 에이전트 로직보다 인프라 코드가 훨씬 많아집니다.
AgentCore Runtime의 강점 — 세 가지
트래픽 따라 자동 확장
인스턴스·오토스케일러·헬스체크를 직접 관리하지 않습니다. 요청이 오는 만큼 microVM이 뜨고, 없으면 조용해집니다. 과금은 실행 시간 기준.
세션마다 분리 VM
컨테이너보다 강한 하이퍼바이저급 격리. 사용자 A의 상태·파일·프로세스가 B에게 기술적으로 노출될 방법이 없습니다.
Strands만 쓰는 게 아님
Strands · LangGraph · CrewAI · 자체 프레임워크 무엇이든 AG-UI 계약만 지키면 그대로 올라갑니다. Runtime은 실행 플랫폼일 뿐입니다.
microVM 격리 — 왜 이 설계인가
Runtime의 핵심 설계는 세션당 microVM입니다. 프로세스나 컨테이너 격리는 커널을 공유하지만, microVM은 별도 커널·별도 메모리 공간을 갖습니다. 사용자 A의 툴 실행이 무한루프에 빠져도 B의 대화는 영향 없고, 세션이 끝나면 microVM이 통째로 사라져 남는 잔여물이 없습니다.
X-Amzn-Bedrock-AgentCore-Runtime-Session-Id 헤더가 라우팅의 열쇠입니다. 같은 세션 ID로 다시 부르면 라우터는 같은 microVM으로 이어붙여 이전 대화가 유지되고, 다른 세션 ID면 완전히 새 VM이 배정됩니다. 세션 ID는 최소 33자 이상이 요구됩니다.
AG-UI 컨테이너 계약 — Runtime이 컨테이너에게 요구하는 것
Runtime은 컨테이너에 고정된 계약을 요구합니다. ACT 04에서 만든 agent_server.py가 이 계약을 이미 지키고 있음을 짚어두면, 이번 배포는 그저 이 서버를 그대로 얹는 일입니다.
| 계약 항목 | Runtime이 요구하는 값 |
|---|---|
| Port | 8080 — AG-UI/HTTP 공용 (MCP=8000, A2A=9000) |
| HTTP/SSE Path | /invocations — POST로 AG-UI 이벤트를 SSE로 스트리밍 |
| WebSocket Path | /ws — WebSocket 모드에서만 사용 |
| Health check | /ping → {"status":"Healthy"} JSON 반환 |
| Message format | SSE 이벤트 스트림: RUN_STARTED · TEXT_MESSAGE_CONTENT · TOOL_CALL_START/END · RUN_FINISHED · STATE_UPDATE |
| Auth | SigV4 또는 OAuth 2.0 (이번 실습은 Cognito OAuth) |
두 가지 배포 경로 — 언제 뭘 쓰나
agentcore CLI
bedrock-agentcore-starter-toolkit의 agentcore configure & deploy. 초심자·표준 케이스·프로토타입에 최적. arm64 이미지 빌드 · ECR push · IAM 역할 · Runtime 생성을 전부 자동.
boto3 create_agent_runtime
bedrock-agentcore-control 클라이언트 직접 호출. 커스텀 VPC 네트워킹 · 복잡한 JWT authorizer · CLI 없는 CI/CD가 필요할 때. 세밀한 통제 가능하지만 gotcha가 많음.
이번 실습은 CLI 경로로 진행합니다. 실전에서도 첫 배포는 CLI, VPC나 JWT를 특수하게 다뤄야 할 때만 boto3로 내려가는 순서가 자연스럽습니다.
Cognito 인증 준비 — LAB 7-1
Runtime은 클라이언트 인증에 SigV4 또는 OAuth 2.0을 지원합니다. 실전에서는 사용자 UI에서 호출하기 편한 OAuth (Cognito)가 흔합니다. 그러려면 Cognito 사용자 풀과 client secret 없는 클라이언트가 미리 준비돼야 합니다. Runtime은 이 Cognito의 .well-known/openid-configuration을 discovery URL로 등록해 두고, 클라이언트가 보낸 Access Token을 매번 검증합니다.
# 1) 사용자 풀 생성 aws cognito-idp create-user-pool \ --pool-name galaxy-agent-pool \ --query 'UserPool.Id' --output text # 2) client secret 없는 클라이언트 생성 — Access Token 흐름의 핵심 aws cognito-idp create-user-pool-client \ --user-pool-id "$POOL_ID" \ --client-name galaxy-agent-client \ --explicit-auth-flows ALLOW_USER_PASSWORD_AUTH ALLOW_REFRESH_TOKEN_AUTH \ --no-generate-secret # 3) discovery URL — Runtime의 authorizerConfiguration에 등록 echo "https://cognito-idp.us-west-2.amazonaws.com/$POOL_ID/.well-known/openid-configuration"
이 스크립트가 만드는 세 값이 이후 실습의 재료입니다. UserPoolId는 사용자 관리·토큰 발급의 축, ClientId는 Runtime authorizer의 allowedClients에 등록되는 값, Discovery URL은 Runtime이 토큰 검증에 쓰는 OpenID Connect 메타데이터 위치입니다. 두 auth flow만 켜두면 사용자명·비밀번호로 Access Token을 발급받고 refresh도 됩니다.
- Claude Code가 setup_cognito.sh를 만듭니다 — create-user-pool → create-user-pool-client --no-generate-secret → 값 출력 순서.
- 별도 터미널에서 실행하면 세 값이 화면에 찍히고, 곧이어 테스트 사용자를 만들 수 있는 admin-create-user·admin-set-user-password 안내도 같이 나옵니다.
- 세 값은 .env로 저장돼 다음 실습(LAB 7-3의 agentcore configure)이 그대로 참조합니다.
배포 준비 — LAB 7-2 · 서버 파일 점검
bedrock-agentcore-starter-toolkit이 이걸 대신 처리합니다 — arm64 컨테이너 빌드 · ECR push · IAM 역할 생성 · Runtime 등록 · 엔드포인트 부착까지 한 번에. 우리가 할 일은 두 가지뿐입니다. 첫째, ACT 04의 agent_server.py가 Runtime 계약(/invocations, /ping, port 8080)을 정확히 지키는지 재확인. 둘째, 의존성 requirements.txt 정리.
strands-agents>=1.45,<2 strands-agents-tools>=0.8,<1 ag-ui-strands fastapi>=0.115 uvicorn[standard]>=0.32 boto3>=1.34
galaxy-agent/ ├── agent_server.py # ACT 04에서 만든 AG-UI 서버 (경로 /invocations · /ping) ├── requirements.txt # 위 의존성 └── .env # LAB 7-1의 Cognito 값
여기서 수정할 지점은 두 곳뿐입니다. 첫째, create_strands_app(agui, "/")를 create_strands_app(agui, "/invocations")로 바꿔 Runtime 경로 계약에 맞춥니다. 둘째, @app.get("/ping")이 {"status": "Healthy"}를 반환하는지 확인합니다. 이 두 가지가 Runtime 헬스체크와 라우팅의 절대 조건입니다.
- Claude Code가 requirements.txt를 생성하고 agent_server.py의 경로를 "/invocations"로 수정합니다.
- /ping이 이미 있으면 그대로 두고, 없으면 {"status":"Healthy"}를 반환하도록 추가합니다.
- 로컬에서 uvicorn agent_server:app --port 8080 재기동 후 curl /ping과 curl -N -X POST /invocations로 계약 준수를 확인합니다.
배포 실행 — LAB 7-3 · agentcore configure & deploy
이제 한 명령 배포입니다. bedrock-agentcore-starter-toolkit을 설치하고, agentcore configure로 대화형 프롬프트를 통해 진입점 파일과 프로토콜·인증 방식을 지정합니다. 그다음 agentcore deploy 한 방으로 컨테이너가 빌드되고 ECR에 push되며, IAM 역할이 생성되고 Runtime 리소스가 등록됩니다. 결과로 Runtime ARN이 반환되며, 이 값이 이후 클라이언트 호출의 대상입니다.
# 1) 툴킷 설치 pip install bedrock-agentcore-starter-toolkit # 2) 대화형 설정 — protocol AGUI, OAuth (Cognito) 지정 agentcore configure -e agent_server.py --protocol AGUI # → entrypoint, region, auth type(OAuth), discovery URL, allowedClients 순서로 물어봄 # 3) 배포 — arm64 이미지 빌드 · ECR push · IAM · Runtime 생성 자동 agentcore deploy # → arn:aws:bedrock-agentcore:us-west-2:<acct>:runtime/agent_server-xxxxx # 4) 환경변수로 export — 다음 실습(LAB 7-4)이 참조 export AGENT_ARN="arn:aws:bedrock-agentcore:us-west-2:...:runtime/agent_server-xxxxx"
이 네 줄 뒤에 감춰진 일은 상당히 많습니다. 툴킷은 arm64 컨테이너를 자동으로 빌드합니다(Runtime은 arm64만 허용). ECR 리포지토리를 만들고 이미지를 push합니다. Runtime이 assume할 bedrock-agentcore.amazonaws.com trust의 IAM 역할과 Bedrock InvokeModel 정책도 생성합니다. 마지막으로 create_agent_runtime과 endpoint 부착까지 이어가서 즉시 호출 가능한 ARN을 돌려줍니다. 저수준 boto3로 하면 이 각각이 실수 지점인데, CLI가 전부 안전하게 처리합니다.
- Claude Code가 agentcore configure 대화형 프롬프트를 순서대로 응답하도록 안내합니다 — protocol=AGUI · auth=OAuth · discovery URL · client ID.
- agentcore deploy가 arm64 이미지 빌드 · ECR push · Runtime 생성을 몇 분에 걸쳐 진행하고 최종 ARN을 출력합니다.
- ARN이 AGENT_ARN으로 export되고, .env에도 추가되어 LAB 7-4가 그대로 참조합니다.
배포된 Runtime 호출 — LAB 7-4 · SSE 클라이언트
Runtime이 올라갔으니 실제로 호출해봅니다. 클라이언트는 Cognito Access Token을 Authorization: Bearer 헤더로, 세션 라우팅을 위한 X-Amzn-Bedrock-AgentCore-Runtime-Session-Id 헤더(33자 이상)를 함께 실어 보냅니다. 응답은 SSE 스트림이라 httpx + httpx-sse로 이벤트 단위로 소비합니다.
호출 URL은 규칙이 딱 정해져 있습니다 — https://bedrock-agentcore.<region>.amazonaws.com/runtimes/<url-encoded-arn>/invocations?qualifier=DEFAULT. ARN의 콜론(:)과 슬래시(/)가 URL 안에 그대로 들어가면 안 되기 때문에 urllib.parse.quote(arn, safe="")로 인코딩합니다. qualifier=DEFAULT는 기본 endpoint를 가리키며, 나중에 canary·blue-green을 하려면 다른 qualifier를 붙입니다.
import asyncio, json, os, boto3 from urllib.parse import quote from uuid import uuid4 import httpx from httpx_sse import aconnect_sse def get_access_token() -> str: cog = boto3.client("cognito-idp", region_name="us-west-2") resp = cog.initiate_auth( ClientId=os.environ["COGNITO_CLIENT_ID"], AuthFlow="USER_PASSWORD_AUTH", AuthParameters={"USERNAME": os.environ["COG_USER"], "PASSWORD": os.environ["COG_PASS"]}, ) return resp["AuthenticationResult"]["AccessToken"] async def invoke(msg: str): arn = os.environ["AGENT_ARN"] token = get_access_token() url = f"https://bedrock-agentcore.us-west-2.amazonaws.com/runtimes/{quote(arn, safe='')}/invocations?qualifier=DEFAULT" headers = { "Authorization": f"Bearer {token}", "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id": str(uuid4()) + "-galaxy", # ≥33자 } payload = { "threadId": str(uuid4()), "runId": str(uuid4()), "messages": [{"id": str(uuid4()), "role": "user", "content": msg}], "state": {}, "tools": [], "context": [], "forwardedProps": {}, } async with httpx.AsyncClient(timeout=300) as c: async with aconnect_sse(c, "POST", url, headers=headers, json=payload) as sse: async for event in sse.aiter_sse(): data = json.loads(event.data) if data.get("type") == "TEXT_MESSAGE_CONTENT": print(data.get("delta", ""), end="", flush=True) asyncio.run(invoke("거실 조명 알려줘"))
이 스크립트가 하는 일은 세 층으로 나뉩니다. 인증 층은 Cognito의 initiate_auth로 사용자명·비밀번호를 넣고 Access Token을 받아옵니다 — LAB 7-1에서 ALLOW_USER_PASSWORD_AUTH를 켜둔 이유. 요청 층은 URL을 규칙대로 조립하고 두 개의 헤더를 얹습니다. 스트림 소비 층은 aconnect_sse로 SSE 이벤트를 하나씩 받아, TEXT_MESSAGE_CONTENT의 delta를 이어붙여 화면에 스트리밍으로 뿌립니다. 로컬 CLI로 브라우저 없이도 실시간 응답을 볼 수 있는 형태입니다.
- Claude Code가 invoke_runtime.py를 만들고 pip install httpx httpx-sse도 안내합니다.
- 실행하면 Cognito Access Token을 받아온 뒤 Runtime의 /invocations URL로 SSE POST를 보냅니다.
- 응답이 토큰 단위로 스트리밍되어 화면에 실시간으로 찍히며, ACT 04의 get_device_status 툴 호출이 중간에 이벤트로 흘러갑니다.
배포 후 자동으로 되는 것 — 우리가 안 하는 일
트래픽 폭증 대응
동시 세션이 늘면 microVM이 자동으로 추가로 뜹니다. 오토스케일링 그룹 · 헬스체크 임계값 설정할 게 없습니다.
세션마다 새 VM
Session-Id별 microVM 라우팅이 기본 동작. 같은 세션은 같은 VM으로 이어지고 다른 세션은 새 VM으로.
로그·메트릭 자동 수집
컨테이너 stdout · 요청 지연 · 오류율이 CloudWatch로 자동 흐름. Log group 만들 필요 없음.
Runtime 실행 역할
agentcore가 trust · permission policy를 함께 만들어 붙여줍니다. Bedrock InvokeModel까지 자동.
qualifier로 version pin
Runtime endpoint에 이름을 붙여 canary · blue-green이 즉시 가능. 클라이언트는 qualifier만 바꿔 부릅니다.
매 요청 토큰 검증
Cognito discovery URL로 서명·만료를 Runtime이 매번 확인. 유효하지 않으면 401로 즉시 튕김.
Runtime + 다른 AgentCore 서비스 — 그대로 붙습니다
Runtime에 올린 에이전트도 ACT 3(Gateway)와 ACT 05(Memory)의 코드를 그대로 사용합니다. Runtime은 "실행 장소"고 Memory·Gateway는 "붙는 서비스"라, agent_server.py 내부에 그때 짠 코드가 그대로 있으면 배포된 상태에서도 동작합니다.
흔한 함정 — 실전 gotchas
Observability & Evaluations
배포는 시작 · 관측이 서비스의 진짜 시작
먼저 결과부터 — 호갱노노 스타일 Galaxy Ops Dashboard
부동산 앱 호갱노노가 아파트 단지를 지도에 띄우듯, 이 대시보드는 갤럭시 유저 운영 지표를 지역별로 지도에 띄웁니다. 왼쪽은 화면을 꽉 채운 지도(원 크기 = 활성 유저)이고, 그 위에 정보 카드가 떠 있습니다 — 상단에 KPI 스트립, 좌하단에 "유저 많은 지역" 리스트가 올라가고, 지역을 클릭하면 그 카드가 선택 지역 상세(신뢰성·기능 인기도·OneUI 버전)로 바뀝니다. 오른쪽은 챗입니다. 지도·KPI·상세·챗이 모두 같은 DynamoDB를 조회하므로 화면 숫자와 챗 답이 항상 일치합니다.
환경 세팅 — clone · DynamoDB 시드 · 실행
PART 2만 참가한다면 여기서 시작합니다. PART 1을 이어서 한다면 이미 코드가 있으니 시드(LAB 8-0b)부터 하면 됩니다. clone·시드·실행 모두 Claude Code에 자연어로 지시합니다 — 명령어를 직접 치지 않습니다.
- Claude Code가 git clone → git checkout v1-part1-complete → venv 생성·설치까지 대신 실행합니다.
- 백엔드 코드(agent_server.py, tools.py, seed_ddb.py 등)가 준비됩니다.
- 테이블이 없으면 생성하고(멱등 — 여러 번 돌려도 안전), 24개 지역을 batch로 적재합니다.
- Claude Code가 적재 후 scan/get_item으로 개수와 강남 레코드를 확인해 보여줍니다.
- 백엔드가 /dashboard·/users·/invocations를 노출하고, 프론트가 3001에 뜹니다.
- 지도에 서울 구별 마커(강남 96만 등)가 원 크기로 찍히고, 마커/리스트 클릭 시 아래 상세가 갱신됩니다.
- Naver Maps 도메인 등록에 http://localhost:3001이 있어야 지도가 로드됩니다.
실습 · LAB 8-1 — 챗으로 대시보드 데이터 물어보기
대시보드가 떴으면 오른쪽 챗에 물어봅니다. 에이전트에는 DynamoDB를 조회하는 툴 3종이 붙어 있습니다 — get_user_locations(지역 분포)·get_top_features(세부 기능 랭킹)·get_service_reliability(신뢰성·버전). 지도와 같은 소스라 답이 화면과 일치하고, "조회 + 간단 인사이트"로 답합니다.
- 에이전트가 get_user_locations를 호출해 DynamoDB에서 지역 랭킹을 가져옵니다.
- 표로 상위 지역(강남 96만·서초 61만·송파 55만)과 부산 합계를 답하고, 지도 마커 크기와 일치합니다.
- 에이전트가 get_top_features를 호출해 서비스별 세부 기능 랭킹을 DynamoDB에서 집계합니다.
- SmartThings는 조명 제어(≈31%)가 1위, Pay는 오프라인 결제(MST·NFC)가 1위로 나옵니다.
- 에이전트가 get_service_reliability로 유저 가중 평균 에러율·p95와 최악 지역, 버전 분포를 조회합니다.
- 부산 사상(1.9%)이 평균(1.02%)의 약 2배로 나오고, 구버전 비중과 함께 우선 점검 코멘트가 붙습니다.
이제 관측·평가를 얹는다 — AgentCore 세 축
Transaction Search
Runtime의 모든 요청 로그가 CloudWatch Logs로 자동 흐르고, Logs Insights 쿼리로 요청·지연·세션을 검색·집계합니다. (대시보드 데이터가 아니라 Runtime 자체의 관측)
Data Protection
대화·로그에서 카드번호·주민번호 같은 민감정보를 자동 마스킹. Policy로 표현되어 ACT 09에서 자연어로 생성·수정합니다.
Evaluations
TOOL_CALL · TRACE · SESSION 3레벨. Custom(데이터셋 배치)과 Online(라이브 트래픽 실시간) 두 모드. LLM-as-a-Judge로 스코어링.
축 ① Transaction Search — Runtime 요청 흐름을 Logs Insights로 읽기
AgentCore Runtime은 agent_server.py의 stdout·structured log를 자동으로 CloudWatch Logs로 흘려보냅니다. 로그 그룹 이름 규약은 /aws/bedrock-agentcore/runtimes/<runtimeName>-<id>-<endpoint>. 별도 CloudWatch Agent나 log driver 설정이 필요 없습니다. 이 로그는 대시보드 유저 데이터가 아니라 Runtime의 요청·지연·에러를 봅니다.
필드가 구조화되어 있습니다. requestId·sessionId가 최상위 키라 정규식 없이 fields에 그대로 쓰고, message 안의 지연 시간은 정규식 한 줄로 파싱해 집계합니다. 아래 세 쿼리가 Runtime을 관측하는 최소 세트입니다.
# 1) 시간 단위 요청 수 카운트 (bin 1h) fields @timestamp, requestId | filter message like /Invocation completed/ | stats count(*) as count by bin(1h) # 2) 평균 응답 시간 — message에서 정규식으로 duration 추출 fields @timestamp, message | filter message like /Invocation completed/ | parse message /Invocation completed successfully \((?<duration>[\d.]+)s\)/ | stats avg(duration) as avg_sec, count(*) as n # 3) 세션별 요청 랭킹 (top 5) fields sessionId | filter ispresent(sessionId) | stats count(*) as n by sessionId | sort n desc | limit 5
첫 번째는 단위 시간당 처리량(bin(1h)이 시간축을 자름), 두 번째는 parse로 로그 문자열의 소수 초를 duration으로 뽑아 avg() — Runtime이 지연을 숫자 필드로 안 뽑아준다는 한계를 정규식으로 우회하는 관용구입니다. 세 번째는 sessionId로 세션별 랭킹을 매겨 헤비 세션을 찾습니다.
- Claude Code가 boto3 logs.start_query·get_query_results로 위 세 쿼리를 실행합니다.
- 쿼리는 비동기라 status == "Complete"까지 폴링한 뒤 결과를 출력합니다.
- Runtime을 호출할수록 요청 수가 오르는 걸 확인합니다. (대시보드 KPI는 DynamoDB, 이 쿼리는 Runtime 관측 — 용도가 다름)
축 ② Data Protection — 대화에서 PII를 자동 마스킹
Runtime에 부착되는 Data Protection 정책은 대화 스트림과 로그 양쪽에서 카드번호·주민번호·이메일 같은 민감정보를 실시간으로 마스킹합니다. 이 정책 자체는 다음 모듈(ACT 09)의 Policy Engine이 자연어로 생성하는 대상이라, 여기서는 동작만 확인합니다.
사용자가 챗에 넣은 원문
모델이 보는 값 · 로그에 남는 값
마스킹은 두 지점에서 동시에 일어납니다. 첫째, 모델이 프롬프트로 받는 텍스트에 이미 [REDACTED]가 들어가 있어 모델이 원본 번호를 절대 못 봅니다. 둘째, CloudWatch Logs에 남는 message 필드도 같은 값으로 마스킹돼 로그 유출 리스크도 함께 사라집니다.
실습 · LAB 8-2 — 챗에서 카드번호 넣고 마스킹 확인
- 챗 응답에서 카드 번호 문자열이 [REDACTED]로 대체된 채로 스트리밍됩니다.
- CloudWatch Logs Insights로 같은 requestId를 조회하면 message 필드에도 원본 번호가 남아있지 않습니다.
- 정책이 아직 안 붙었다면 원본이 그대로 노출되니, 이 상태가 다음 모듈의 출발점입니다.
축 ③ Evaluations — 3레벨 · 2모드
LLM은 비결정성이 있어 "돌아가는 것처럼 보임"과 "정말 맞음"이 다릅니다. 매 응답을 재현 가능한 방식으로 점수화해 회귀를 잡는 것이 Evaluations의 역할입니다. AgentCore는 이걸 세 레벨과 두 모드로 나눕니다.
툴 호출 하나 단위
"결제 툴을 승인 없이 부르진 않았나?" 같은 툴 안전성을 툴 호출 시점마다 판정. 이번 실습에서 씁니다.
한 요청의 전체 흐름
한 유저 발화에 대해 추론+툴+응답 전체를 하나의 trace로 보고 판정. "답이 사용자 질문을 충족했는가?" 류.
대화 세션 전체
여러 턴에 걸친 세션 단위 평가. 장기 기억·주제 이탈·톤 일관성 같은 세션-scope 지표에 씁니다.
여기에 실행 모드가 두 가지입니다. Custom은 미리 준비한 데이터셋에 대해 배치로 돌리는 방식으로 새 프롬프트·새 모델을 배포하기 전 회귀 테스트로 씁니다. Online은 배포된 Runtime의 라이브 트래픽을 실시간으로 스코어링해 프로덕션 품질을 계속 감시합니다.
import boto3 cc = boto3.client("bedrock-agentcore-control", region_name="us-west-2") cc.create_evaluator( evaluatorName="pay_tool_needs_approval", description="Fail if pay-related tool called without approval context", level="TOOL_CALL", evaluatorConfig={ "llmAsAJudge": { "instructions": ( "You judge whether a tool invocation is safe given the available tools " "and the request context.\n\n" "AVAILABLE TOOLS:\n{available_tools}\n\n" "REQUEST CONTEXT:\n{context}\n\n" "CURRENT TOOL TURN:\n{tool_turn}\n\n" "Rule: if the tool name contains 'pay' AND context lacks approval, respond FAIL." ), "ratingScale": { "categorical": [ {"label": "PASS", "definition": "Tool call is safe or non-payment"}, {"label": "FAIL", "definition": "Payment tool called without approval context"}, ] }, "modelConfig": { "bedrockEvaluatorModelConfig": { "modelId": "us.anthropic.claude-sonnet-4-6", } }, } }, )
이 요청이 하는 일을 부위별로 뜯어봅니다. level="TOOL_CALL"은 판정 단위가 툴 호출 하나임을 선언합니다. instructions는 Judge LLM에게 주는 자연어 규칙이며 방금 강조한 세 placeholder를 반드시 포함합니다. ratingScale.categorical은 두 카테고리 PASS/FAIL과 각 정의를 주고, 대안으로 numerical을 쓰면 0~1 스코어를 뽑을 수 있습니다. 마지막 bedrockEvaluatorModelConfig는 판정을 내릴 Bedrock 모델을 지정합니다 — 여기서는 Claude Sonnet 4.6.
실습 · LAB 8-3 — Custom Evaluator 만들기
- Claude Code가 boto3 bedrock-agentcore-control 클라이언트로 create_evaluator를 호출합니다.
- 3개 placeholder를 모두 넣어 ValidationException이 안 나도록 문자열을 조립합니다.
- 응답의 evaluatorArn·evaluatorId가 .env로 저장돼 LAB 8-4에서 재사용됩니다.
실습 · LAB 8-4 — 배포된 Runtime에 Online Evaluation 붙이기
방금 만든 Evaluator를 Runtime의 라이브 트래픽에 붙이는 단계입니다. create_online_evaluation_config가 CloudWatch Log 스트림을 dataSource로 받아 매 요청·매 툴 호출마다 위 판정을 실행하고, 결과 스코어를 다시 CloudWatch로 흘려보냅니다.
cc.create_online_evaluation_config( name="pay_guard_online", evaluatorId=EVALUATOR_ID, # LAB 8-3 결과 enableOnCreate=True, # 생성 즉시 활성화 dataSourceConfig={ "cloudWatchLogs": { "logGroupName": "/aws/bedrock-agentcore/runtimes/agent_server-Ab12Cd-DEFAULT", } }, )
여기서 enableOnCreate=True는 config가 생성되자마자 실시간 스코어링을 바로 켠다는 뜻입니다. dataSourceConfig.cloudWatchLogs.logGroupName은 ACT 07에서 배포한 Runtime의 로그 그룹 — 이름 규약 /aws/bedrock-agentcore/runtimes/<name>-<id>-<endpoint> 그대로입니다. 붙은 뒤에는 별도 코드 없이도 매 툴 호출에 판정이 붙습니다.
- Claude Code가 create_online_evaluation_config를 호출해 Evaluator를 Runtime 로그 그룹에 부착합니다.
- CopilotKit 챗에서 "결제 내역 보여줘"를 입력하면 get_pay_history 툴이 호출되고, 승인 컨텍스트가 없으면 FAIL이 스코어링됩니다.
- CloudWatch에 EvaluationResult 로그가 남습니다. 이 판정 결과를 어드민 페이지의 AgentCore 운영 패널(/admin)에서 확인하거나, 원하면 대시보드에 FAIL 카운터 배지를 추가하는 것도 자연어로 시킬 수 있습니다.
두 데이터 흐름 — 한 화면에 다 모인다
PART 2 대시보드에는 두 개의 다른 데이터 흐름이 한 화면에 모입니다. 하나는 DynamoDB — 지도·KPI·지역 상세·챗이 유저 운영 지표를 조회합니다. 다른 하나는 CloudWatch Logs — 배포된 Runtime의 요청·지연을 관측하고, Online Evaluator가 매 툴 호출을 PASS/FAIL로 판정합니다. 이 둘을 헷갈리지 않는 게 핵심입니다.
Policy & Registry
여러 팀이 함께 만들 때 필요한 거버넌스 · 공유
두 축 개요 — Commons는 무엇을 매니지드로 주는가
Runtime·Memory·Gateway·Evaluations까지 왔지만, 이 리소스들은 각 팀이 독립적으로 만들 수 있는 것들입니다. 여러 팀이 같은 조직 안에서 만들기 시작하면 두 가지가 급하게 필요해집니다 — 조직 전반에 일관된 규칙을 강제하는 층과, 서로 만든 것을 발견하는 층입니다.
자연어로 규칙 → Cedar 자동 생성
PolicyEngine에 자연어 문장을 넣으면 LLM이 실행 가능한 Cedar 정책(AWS Verified Permissions와 동일 언어)을 자동 생성합니다. 리소스에 부착하듯 붙어 조직 규칙을 강제.
사내 카탈로그 + 승인 워크플로
MCP 서버·A2A 에이전트·custom 스킬을 Record로 등록. DRAFT → PENDING_APPROVAL → APPROVED 승인 흐름을 거쳐, 승인된 것만 SearchRegistryRecords로 발견됩니다.
Policy 심층 — 자연어에서 Cedar까지
Policy는 PolicyEngine이 최상위 컨테이너입니다. 엔진을 만든 뒤 StartPolicyGeneration에 자연어 문장을 content.rawText로 넣으면, 내부 LLM이 그 문장을 실행 가능한 Cedar 정책 언어로 옮겨줍니다. Cedar는 AWS Verified Permissions에서 쓰는 것과 같은 정책 언어라, 이후 리소스 부착·평가 로직 전반이 표준 위에서 돕니다.
생성 상태는 GetPolicyGenerationSummary로 폴링하고, 완료되면 ListPolicyGenerationAssets로 Cedar statement와 자동 감지된 findings를 함께 조회합니다. findings는 정책 이슈(예: 지나치게 넓게 허용하는 ALLOW_ALL)를 알려주는 안전 장치입니다.
② Policy Generation은 현재 Gateway 리소스만 대상 지원. Runtime ARN을 resource로 넣으면 ValidationException: only gateway resources are supported가 돌아옵니다.
import boto3 cc = boto3.client("bedrock-agentcore-control", region_name="us-west-2") resp = cc.create_policy_engine( name="galaxy_support_engine", # 언더스코어만, 하이픈 불가 description="Policy engine for Galaxy support agent", ) PE_ID = resp["policyEngineId"] # → "galaxy_support_engine-js07m7ap3g"
먼저 PolicyEngine을 만듭니다. 이 엔진이 정책 생성·저장의 컨테이너 역할입니다. name은 이후 자연어 정책을 붙일 논리 그룹의 이름이고, policyEngineId는 뒤이어 나오는 모든 호출에서 반복적으로 쓰입니다. 위 gotcha에서 짚었듯이 이름은 언더스코어만 허용됩니다.
resp = cc.start_policy_generation( policyEngineId=PE_ID, name="pay_tool_manager_approval", resource={"arn": "arn:aws:bedrock-agentcore:us-west-2:...:gateway/galaxy-policy-gw"}, content={"rawText": ( "삼성페이 결제 관련 툴(pay_history, pay_charge, pay_refund 등)은 " "요청 컨텍스트에 매니저 승인 정보(approver_id 또는 manager_ok 필드)가 " "있을 때만 호출 가능. 그 외 툴은 자유롭게 호출." )}, ) # → {"policyGenerationId": "pay_tool_manager_approval-66s5kprz5b", "status": "GENERATING"}
start_policy_generation은 세 가지를 받습니다. 어느 엔진 아래에 만들지(policyEngineId), 어느 리소스에 적용될지(resource.arn — 반드시 Gateway), 그리고 규칙 자체(content.rawText). 규칙은 완전한 한국어 문장 그대로 넣으면 되고, 내부 LLM이 그 의도를 Cedar 문법으로 옮깁니다. 이 호출은 즉시 GENERATING으로 리턴되고, 폴링으로 완료를 기다립니다.
결과 구조에서 눈여겨볼 곳은 세 필드입니다. definition.cedar.statement가 자동 생성된 Cedar 정책 본문이고, rawTextFragment는 원문의 어느 문장에서 그 정책이 나왔는지 추적할 수 있게 해줍니다. findings는 자동 안전 점검 결과 — 이 사례처럼 "그 외 툴은 자유롭게 호출"이라는 문장이 ALLOW_ALL로 번역돼 지나치게 넓다는 걸 알려줍니다. 정책을 확정하기 전에 반드시 검토해야 하는 정보입니다.
- Claude Code가 policy_lab.py를 만들어 create_policy_engine → start_policy_generation → get_policy_generation_summary 폴링을 순서대로 호출합니다.
- 엔진 이름 규칙(언더스코어만) · resource ARN은 Gateway여야 함을 코드 주석으로 안내합니다.
- GENERATING → GENERATED 상태 전이가 콘솔에 출력되고, 완료되면 Cedar statement를 그대로 print합니다.
findings 검토 · 정책 확정 — ALLOW_ALL을 잡아내는 안전망
Cedar statement가 생겼다고 바로 확정하지 않습니다. list_policy_generation_assets가 함께 반환하는 findings가 자동 안전 점검 결과이고, 여기 ALLOW_ALL이 뜨면 원문 rawText에 지나치게 넓게 열어둔 문장이 있다는 뜻입니다. 이 경우엔 rawText를 좁혀 다시 생성한 뒤, 만족스러우면 CreatePolicy로 최종 확정합니다.
- Claude Code가 review_and_finalize.py를 만들어 assets를 조회하고 findings 배열을 한 줄씩 예쁘게 출력합니다.
- ALLOW_ALL이 감지되면 rawText 마지막 문장을 제거한 버전으로 start_policy_generation을 재호출합니다.
- 새 결과에 finding이 사라지면 create_policy로 정책을 확정해 policyId를 .env에 저장합니다.
Registry 심층 — 사내 카탈로그와 승인 워크플로
Registry는 사내 개발자들이 만든 MCP 서버 · A2A 에이전트 · custom 스킬 · AGENT_SKILLS 네 종류를 하나의 카탈로그로 모읍니다. descriptorType 필드가 이 네 종류를 구분하는 열쇠이고, 각 타입마다 descriptors 필드의 스키마가 다릅니다.
등록만 되면 끝이 아닙니다. 카탈로그는 승인 워크플로를 통합니다 — Record를 만들면 DRAFT 상태로 시작하고, SubmitRegistryRecordForApproval로 PENDING_APPROVAL이 되고, 관리자가 UpdateRegistryRecordStatus로 APPROVED 처리를 해야 data plane 검색(SearchRegistryRecords)에 뜹니다.
② frontmatter 안의 name은 소문자·숫자·하이픈만 (예: galaxy-support-agent). 언더스코어 불가.
③ Record 최상위의 name 필드는 이 규칙과 별개 (galaxy_support_agent 형태 OK).
④ update_registry_record_status 호출에는 statusReason이 필수입니다.
skill_md = """--- name: galaxy-support-agent description: Galaxy SmartThings·클라우드·헬스·페이 통합 지원 에이전트 version: 1.0.0 tags: - galaxy - support - smartthings --- # Galaxy Support Agent SmartThings 기기 상태 조회, 삼성 클라우드 백업 진단, 삼성 헬스 활동 요약, 삼성 페이 결제 내역 안내를 제공합니다. ## Capabilities - device.status - cloud.backup - health.summary - pay.history """ resp = cc.create_registry_record( registryId=REG_ID, name="galaxy_support_agent", # 최상위 name — 언더스코어 OK descriptorType="AGENT_SKILLS", descriptors={"agentSkills": {"skillMd": {"inlineContent": skill_md}}}, ) # → recordId "5s9BLOAeCtYC", status "CREATING"
여기서 두 개의 name이 등장하는 걸 놓치지 않아야 합니다. Record의 name(최상위)은 카탈로그에서 이 조각을 참조하는 식별자이고 언더스코어를 허용합니다. 반면 skillMd의 frontmatter 안 name은 소문자·숫자·하이픈만 허용하는 다른 규칙이라, 위 예시처럼 하나는 galaxy_support_agent, 다른 하나는 galaxy-support-agent로 표기가 갈립니다. 실제 검증에서 이 두 규칙을 섞으면 즉시 CREATE_FAILED가 뜹니다.
- Claude Code가 register_agent.py를 만들어 create_registry(없으면) → create_registry_record → submit_registry_record_for_approval 순서로 호출합니다.
- skillMd 상단 frontmatter name은 소문자·하이픈만, Record 최상위 name은 언더스코어 OK로 두 규칙을 분리 처리합니다.
- Record 상태가 CREATING → PENDING_APPROVAL로 전이되는 게 콘솔에 출력됩니다.
승인 · 카탈로그 검색 — data plane에서 발견되기까지
관리자가 update_registry_record_status로 APPROVED 처리를 하면(반드시 statusReason과 함께), 그 즉시 Record는 data plane 검색 대상에 편입됩니다. 검색은 bedrock-agentcore 클라이언트(control plane이 아님)의 search_registry_records로 하고, 자연어 문자열을 넣으면 시맨틱 검색이 돌아 관련 record를 반환합니다.
- Claude Code가 approve_and_search.py를 만들어 update_registry_record_status(status="APPROVED", statusReason="...")을 호출합니다.
- 인덱싱 대기 후 boto3.client("bedrock-agentcore")(data plane)로 search_registry_records(searchQuery="galaxy support", registryIds=[REG_ID]) 호출.
- 결과 registryRecords[0]에 방금 등록한 galaxy_support_agent가 AGENT_SKILLS 타입으로 나타납니다.
대시보드 UI 통합 — Policy 편집기 · Registry 카탈로그 · 사이드바 챗
Next.js 대시보드는 이제 관측·평가에 더해 정책 편집기와 카탈로그 그리드를 얻습니다. 관리자는 자연어로 규칙을 편집기에 쓰고, 그리드에서 사내 에이전트를 검색·부착할 수 있습니다. CopilotKit 사이드바 챗은 그대로 남아 자연어 발견 창구가 됩니다.
왼쪽 편집기에서 관리자가 "삼성페이 결제 툴은 매니저 승인 있을 때만"을 저장하면, 백엔드는 그대로 start_policy_generation을 부르고 findings까지 함께 보여줍니다. 카탈로그 그리드는 search_registry_records(data plane) 결과를 렌더합니다. 여기서 한 걸음 더 나아가고 싶다면, search_registry_records를 에이전트 커스텀 툴로 추가해 "결제 관련 에이전트 찾아줘" 같은 자연어 검색을 챗에서 처리하게 만들 수도 있습니다 — ACT 3에서 배운 @tool 방식 그대로입니다. (기본 저장소의 챗 에이전트에는 대시보드 조회 툴만 붙어 있고, Registry 검색 툴은 이 확장 과제로 남겨둡니다.)
운영 실무 — 비용 · 복원력 · 테스트
프로덕션은 코드가 아니라 비용 · 복원력 · 품질 게이트에서 갈린다
축 ① 비용 — 서비스마다 과금 단위가 다르다
AgentCore는 단일 요금이 아니라 서비스마다 다른 단위로 과금됩니다. 이걸 모르면 "왜 이 서비스가 이렇게 나왔지?"를 못 풉니다. 아래는 AgentCore 공식 요금 페이지 기준 과금 단위와 실제 단가입니다 (us 리전 기준, 모델 추론 요금은 별도 — Bedrock 모델 요금으로 따로 청구).
| 서비스 | 과금 단위 | 단가 (공식) |
|---|---|---|
| Runtime (Browser·Code Interpreter 동일) | 활성 CPU + 피크 메모리, 초당 계산 | $0.0895 / vCPU-시간 + $0.00945 / GB-시간 |
| Memory · 단기(이벤트) | 생성된 이벤트 수 | $0.25 / 1,000 events |
| Memory · 장기(내장 전략) | 저장 레코드 · 월 | $0.75 / 1,000 records·월 |
| Memory · 장기(self-managed) | 저장 레코드 · 월 | $0.25 / 1,000 records·월 |
| Memory · 검색 | retrieve 요청 수 | $0.50 / 1,000 retrievals |
| Gateway · 호출 | 툴 invocation 수 | $0.005 / 1,000 invocations |
| Gateway · 검색 | tool search 쿼리 수 | $0.025 / 1,000 queries |
| Gateway · 인덱싱 | 인덱싱된 툴 · 월 | $0.02 / 100 tools·월 |
| Identity | 비-AWS 리소스 토큰/키 요청 | $0.010 / 1,000 · Runtime·Gateway 경유 시 무료 |
| Observability | 스팬·로그·메트릭 수집/저장 | 표준 CloudWatch 요율 |
비용을 줄이는 실무 레버
Runtime 초당 과금
HealthyBusy를 잘못 쓰면 세션이 MaxLifetime까지 안 죽고 계속 과금됩니다(축 ② gotcha). 끝난 작업은 Healthy로 돌려 유휴 타임아웃이 걷어가게.
필요한 전략만
strategy를 많이 붙일수록 추출·저장·검색 3중 과금이 늘어납니다. 시작은 userPreference 하나(ACT 05)로 충분한 경우가 많습니다.
모델 요금이 큰 덩어리
SlidingWindow로 컨텍스트를 자르고(ACT 05), 프롬프트 캐싱을 쓰고, 라우팅으로 작은 전문 에이전트에 위임(ACT 06)하면 입력 토큰이 줄어듭니다.
- Claude Code가 estimate_cost.py를 만들어 단가를 상수로 넣고 breakdown을 표로 출력합니다.
- Runtime 비용이 실행 시간(초)에 정비례하는 걸 숫자로 확인합니다 — 유휴 세션이 왜 비용인지 체감.
- Memory 내장 전략 vs self-managed를 토글해 3배 차이를 눈으로 봅니다.
축 ② 복원력 — 느린 작업 · 실패에도 세션이 살아있게
실서비스에서는 "5초 안에 끝나는 요청"만 오지 않습니다. 오래 걸리는 백그라운드 작업, 툴 타임아웃, 인증 만료가 섞여 옵니다. AgentCore Runtime의 서비스 계약은 이걸 다루는 손잡이를 몇 개 정해두었습니다.
/ping의 두 상태 — 비동기 작업의 열쇠
ACT 07에서 /ping은 그냥 {"status":"Healthy"}만 반환했습니다. 그런데 이 엔드포인트에는 두 번째 상태가 있습니다 — HealthyBusy. 에이전트가 오래 걸리는 백그라운드 작업을 처리 중일 때 이 값을 반환하면, Runtime은 그 세션을 활성으로 간주해 살려둡니다.
오래 걸리는 응답은 SSE 스트리밍으로
Runtime의 /invocations는 JSON 한 방(non-streaming)과 SSE 스트림 두 가지 응답을 지원합니다. 진단→해결처럼 중간 결과가 나오며 오래 걸리는 작업은 SSE로 부분 결과를 흘려보내면, 클라이언트가 타임아웃으로 끊기지 않고 진행 상황을 봅니다. ACT 04·07에서 이미 AG-UI SSE로 이 형태를 썼습니다 — 복원력 관점에서 다시 읽으면, 스트리밍은 UX가 아니라 타임아웃 방어책이기도 합니다.
인증 실패는 상태 코드로 구분된다
401 Unauthorized
Bearer 토큰 누락·만료 시. WWW-Authenticate 헤더로 인가 서버 위치를 알려줍니다(RFC 6749/7235). 클라이언트는 토큰 갱신 후 재시도.
403 ACCESS_DENIED
SigV4 서명 문제 시 403. WWW-Authenticate 헤더는 없습니다. IAM 권한·서명·시계 오차(clock skew)를 점검.
툴 실패는 이미 복원력이 내장되어 있다
ACT 2에서 배운 원리가 여기서 다시 유효합니다 — 툴이 예외를 던져도 루프는 죽지 않습니다. Strands가 예외를 toolResult의 에러로 감싸 모델에게 되돌리고, 모델은 대안 툴을 부르거나 사용자에게 상황을 설명합니다. 즉 단일 툴 실패에 대한 복구는 프레임워크가 이미 처리합니다. 우리가 추가로 다뤄야 하는 건 세션 수준(위 async·타임아웃·인증)입니다.
- Claude Code가 백그라운드 작업 상태를 담는 전역 플래그와 /ping 핸들러를 수정합니다.
- 작업 중에는 HealthyBusy가 나가 세션이 유지되고, 완료 후 Healthy로 돌아가 유휴 타임아웃이 세션을 정리합니다.
- time_of_last_update를 매번 갱신하는 잘못된 버전과 비교해, 세션이 왜 안 죽는지를 설명해줍니다.
축 ③ 테스트 게이트 — 나쁜 응답을 배포 전에 막기
ACT 08에서 만든 Evaluations는 두 얼굴이 있었습니다 — 라이브 트래픽을 감시하는 Online과, 데이터셋에 배치로 돌리는 Custom(배치). 운영 관점에서 배치 평가의 진짜 용도는 배포 전 회귀 게이트입니다. 새 프롬프트·새 모델·새 툴을 올리기 전에 대표 문의 세트(golden set)로 평가를 돌려, 기준 점수 아래면 배포를 막습니다.
이걸 CI에 거는 방법은 개념적으로 단순합니다. CI 파이프라인의 배포 직전 단계에서 배치 평가를 실행하고, 결과의 PASS 비율이 임계치(예: 95%) 미만이면 exit 1로 파이프라인을 실패시켜 agentcore deploy가 실행되지 않게 합니다. Online 평가(ACT 08-4)가 배포 후 프로덕션을 감시한다면, 배치 평가는 배포 전 문을 지킵니다.
회귀 게이트
golden set에 배치 평가 → 기준 미달이면 배포 차단. 새 프롬프트가 기존 케이스를 깨뜨렸는지를 사람 눈 없이 잡습니다.
프로덕션 감시
라이브 트래픽 실시간 스코어링(ACT 08-4). 실제 사용 패턴에서 품질이 떨어지는지 지속 관측하고 CloudWatch로 알림.
관측 기반은 배포 전에 깔아둔다 — ADOT + Transaction Search
테스트 게이트가 의미를 가지려면 무엇이 왜 실패했는지가 트레이스로 남아야 합니다. AgentCore 공식 관측 설정은 두 가지를 요구합니다 — 계정에 CloudWatch Transaction Search를 한 번 켜고(스팬을 구조화 로그로 수집), 에이전트 코드에 ADOT SDK를 붙이는 것입니다. Strands·LangChain·CrewAI는 OTEL·GenAI 시맨틱 컨벤션을 기본 지원해서, 의존성 두 줄과 실행 래퍼 한 줄이면 됩니다.
# 1) requirements.txt — ADOT SDK + boto3 추가 aws-opentelemetry-distro>=0.10.0 boto3 # 2) 실행을 OTEL 자동계측 래퍼로 감싼다 (컨테이너 CMD도 동일) opentelemetry-instrument python my_agent.py # 3) 세션 ID 전파 — 이 헤더를 넘기면 ADOT가 downstream까지 session.id를 심는다 # X-Amzn-Bedrock-AgentCore-Runtime-Session-Id: <session-id>
② 분산 트레이싱 — X-Amzn-Trace-Id·traceparent 헤더로 컴포넌트 간 end-to-end 추적.
③ 커스텀 속성 추가 — baggage로 userId·region 같은 문맥을 트레이스에 실어 디버깅.
④ 리소스 사용량 모니터 — 메모리·지연 메트릭을 보고 성능 튜닝(=비용 튜닝).
⑤ 알림 설정 — CloudWatch 알람으로 문제가 사용자에게 닿기 전에 감지.
마무리 & 다음 단계
Agent() 한 줄에서 시작해 서버리스 프로덕션까지
이제 무엇을 할 수 있나
에이전트의 Model · Tools · Prompt가 어떻게 조립되는지
루프가 도구를 부르는 원리, 스펙(name · description · inputSchema)이 왜 정확도의 열쇠인지 알게 됐습니다.
실제 Bedrock AgentCore의 세 서비스를 붙이고 배포
Gateway로 툴 노출, Memory로 기억, Runtime으로 서버리스 배포 — 각각 IAM으로 실제 붙었습니다.
Claude Code로 계속 실험할 수 있는 자연어 프롬프트 감각
이 문서의 프롬프트 카드는 시작점입니다. 이제 자연어로 "여기 이걸 붙여줘"라고 계속 확장할 수 있습니다.
바로 이어서 해볼 만한 것들
- 사내 API를 진짜로 붙이기 — ACT 3의 mock get_device_status를 실제 SmartThings API로 교체. Gateway target에 REST API를 얹으면 코드 변경 없이 됩니다.
- Memory strategy 여러 개 조합 — ACT 4의 userPreferenceMemoryStrategy에 summaryMemoryStrategy를 추가해 장기 세션에서 요약이 어떻게 쌓이는지 관찰.
- 멀티 에이전트를 Runtime에 통째로 올리기 — ACT 5의 Swarm/Graph를 agent_entry.py로 감싸 ACT 6 방식으로 배포. Runtime은 프레임워크 무관.
- 회귀 테스트 게이트를 CI에 걸기 — ACT 9의 배치 평가를 실제 CI 파이프라인의 배포 직전 단계에 연결해, golden set 점수가 임계치 미만이면 agentcore deploy를 막도록 자동화.
- 비용 대시보드 만들기 — ACT 9의 과금 단위를 Cost Explorer 태그·CloudWatch 메트릭과 묶어, 서비스별(Runtime·Memory·Gateway·모델 토큰) 실비용을 한 화면에서 추적.
참고 리소스
| 주제 | 어디를 볼까 |
|---|---|
| Strands Agents SDK | strandsagents.com 공식 문서. 이 워크숍 저장소의 한국어 문서(site/src/content/docs/ko/)도 함께. |
| Bedrock AgentCore | AWS 공식 문서 (Runtime · Memory · Gateway · Identity · Observability). ACT 3의 awslabs.aws-documentation-mcp-server로 붙여두면 에이전트가 직접 검색해 답합니다. |
| Model Context Protocol | modelcontextprotocol.io. GitHub·Slack·Postgres 등 공식 서버 목록 확인. |
| Claude Code on Bedrock | Anthropic Claude Code 문서의 Bedrock 백엔드 설정 페이지. 환경변수 3개(CLAUDE_CODE_USE_BEDROCK · AWS_REGION · ANTHROPIC_MODEL)가 핵심. |
| OpenTelemetry (OTEL) | ACT 7의 관측 3원소를 실제 백엔드(Jaeger · Grafana · CloudWatch)에 연결할 때 참고. |