본문으로 건너뛰기

AWS Agentcore와 Strands Agent를 활용한 AI 서버 구축기

· 약 19분
하승오
백엔드팀

똑닥 AI 서버는 병원 리뷰 감성분석, 리뷰 AI 요약, 상담 태그 추천, CS 답변 추천처럼 LLM 추론이 필요한 똑닥의 기능을 한곳에 모아 운영하는 서버입니다. 이 글에서는 이 서버를 AWS Bedrock AgentCore Runtime과 Strands Agents 위에 어떻게 구축하였는지, 그 과정에서 어떤 결정을 내렸고 어떤 문제를 겪었는지를 공유합니다.

들어가며​

안녕하세요. 비브로스에서 백엔드 개발을 담당하고 있는 하승오입니다.

똑닥에는 LLM 추론이 필요한 기능이 하나씩 늘어나고 있습니다. 병원 리뷰의 감성분석, 리뷰 AI 요약, 상담 종료 시 태그 추천, CS 답변 추천까지 현재 네 가지 기능이 운영 중입니다. 저희는 이 기능들을 담는 서버 ddocdoc-ai를 AWS Bedrock AgentCore Runtime 위에서 Strands Agents 프레임워크로 구축하였습니다.

이 글은 크게 두 부분으로 나뉩니다. 앞부분에서는 이미 EKS를 운영하고 있던 저희 팀이 왜 k8s 위의 FastAPI 대신 AgentCore를 선택하였는지, 그리고 새 플랫폼을 도입하면서 기존 인프라와의 경계를 어디에 두었는지를 다룹니다. 뒷부분에서는 실제 코드 구조를 다룹니다. 이미지 하나로 Runtime 여러 개를 운영하는 방법, Strands 에이전트 정의, Runtime 계약과 호출, 배포, 그리고 도입의 계기가 되었던 평가까지, 리뷰 AI 요약 에이전트(hospital_review_summary)를 예시로 따라가며 설명합니다.

  1. AgentCore와 Strands를 선택한 이유
  2. k8s를 프록시로 남긴 이유
  3. 이미지 하나로 Runtime 여러 개 운영하기
  4. 코드 구조
  5. Strands 에이전트 정의
  6. Runtime 계약과 호출
  7. 배포
  8. 평가
  9. 마치며

1. AgentCore와 Strands를 선택한 이유​

처음 검토하였던 구성은 평범하였습니다. k8s 위에 FastAPI 서버를 올리고, 그 안에서 Bedrock의 InvokeModel API를 직접 호출하는 방식입니다. 이미 EKS를 운영하고 있었고 다른 서비스와 CI/CD 파이프라인을 그대로 공유할 수 있어 가장 자연스러운 선택지였습니다. 실제로 첫 번째 기능인 리뷰 감성분석은 이 구성으로 k8s에 올라가 트래픽을 받았습니다.

하지만 이 구성을 계속 가져가기에는 마음에 걸리는 부분이 두 가지 있었습니다.

(1) 관측성

LLM 호출은 일반적인 HTTP 요청과는 관측해야 하는 단위가 다릅니다. 어떤 프롬프트가 어떤 모델에 들어가서 토큰을 얼마나 사용하고 몇 초가 걸렸는지가 gen_ai.* 스팬으로 남아야 하고, 그 스팬들이 세션 단위로 묶여 있어야 오분류 한 건을 재현해볼 수 있습니다. 이를 직접 갖추려면 OpenTelemetry 계측, collector, 저장소, 대시보드를 모두 손수 구성해야 합니다.

AgentCore Observability는 이 묶음을 플랫폼 기본 기능으로 제공합니다. Runtime이 스팬을 CloudWatch로 전송하고, 세션 id로 묶인 GenAI 트레이스를 콘솔에서 바로 확인할 수 있습니다.

(2) 평가

LLM 기능은 배포 이후에도 품질이 계속 변합니다. 모델이 바뀌고, 입력 분포가 바뀌고, 프롬프트 한 줄을 고친 영향이 전혀 다른 케이스에서 나타나기도 합니다. 이를 잡아내려면 실제 트래픽을 샘플링하여 judge 모델로 채점하는 장치가 필요한데, 직접 만들자면 스팬 수집, 샘플링, judge 호출, 결과 저장, 알림까지 전부 구현해야 합니다.

AgentCore Evaluations는 CloudWatch에 쌓인 실제 트래픽의 트레이스를 그대로 채점 대상으로 사용합니다. 온라인 평가(상시 샘플링)와 배치 평가(특정 구간 소급)가 API로 제공되고, 머지 전 오프라인 골든셋 평가는 strands-agents-evals로 같은 judge를 붙일 수 있습니다. 이 부분은 8장에서 자세히 다룹니다.

프레임워크로 Strands Agents를 선택한 이유는 다음과 같습니다:

  • Bedrock 네이티브 프레임워크라 BedrockModel 하나로 Converse API와 extended thinking 같은 모델별 필드를 그대로 전달할 수 있습니다.
  • 구조화 출력이 Tool Use 기반으로 구현되어 있어, Pydantic 모델을 넘기면 스키마가 보장됩니다.
  • AgentCore Observability가 기대하는 스팬 계약(gen_ai.agent.name 같은 속성)을 프레임워크가 알아서 만들어줍니다. Invoke 루프나 tool 재시도를 직접 구현할 필요가 없습니다.

다만 AgentCore를 도입한다고 해서 k8s를 완전히 걷어내기로 한 것은 아니었습니다. 이 경계를 어디에 두었는지는 다음 장에서 따로 설명하겠습니다.

2. k8s를 프록시로 남긴 이유​

현재 구조에서 호출자인 병원 서버(hospital-server)와 채팅 서버(ddocdoc-talk)는 기존과 동일하게 HTTP로 k8s 파드를 호출합니다. 그 파드가 SigV4로 서명한 InvokeAgentRuntime을 호출하여 에이전트별 AgentCore Runtime에 추론을 위임하는 구조입니다. 즉, REST 프로세스는 더 이상 추론을 직접 수행하지 않고, HTTP 요청을 SigV4 서명 요청으로 바꿔주는 어댑터 역할만 하게 되었습니다.

호출자 → k8s REST 프록시 → agent별 AgentCore Runtime → Bedrock, 아래 CloudWatch

정확히는 k8s를 앞에 남기고 AgentCore를 뒤에 붙인 구조입니다. 이렇게 구성한 데에는 다음과 같은 이유가 있었습니다.

(1) 권한을 한 곳에 모을 수 있습니다.

저희 팀은 파드의 AWS 권한을 IRSA(IAM Roles for Service Accounts)로 부여하고 있습니다. 프록시 없이 호출자가 Runtime을 직접 호출하도록 하면, 호출자마다 bedrock-agentcore:InvokeAgentRuntime 권한이 붙은 IAM role과 에이전트별 Runtime ARN 설정, 그리고 AWS SDK 의존성이 생깁니다. 호출자의 언어 스택이 다르면 SDK도 그만큼 늘어납니다. 결국 AI 기능을 사용하려는 서비스가 하나 늘어날 때마다 Terraform과 IAM 작업이 따라붙게 되는 구조입니다. 반면 프록시 한 곳이 권한과 ARN을 소유하면, 새 호출자는 내부 ALB 주소 하나만 알면 됩니다.

(2) HTTP 계약을 그대로 유지할 수 있습니다.

기존 REST 응답은 성공 시 200과 결과 본문, 업스트림(Bedrock) 실패 시 502, 배포 구성 문제 시 503이었습니다. 프록시가 Runtime의 응답을 이 상태 코드로 다시 매핑해주므로 호출자 코드는 한 줄도 바꾸지 않았습니다.

(3) 프록시를 제거하여 얻는 이점이 크지 않습니다.

프록시를 제거하면 얻는 것은 지연 한 홉과 파드 하나를 줄이는 것뿐입니다. 대신 모든 호출자가 AWS SDK와 SigV4, IRSA를 직접 떠안아야 합니다. 그래서 저희는 권한이 모이는 지점에 경계를 두었습니다.

물론 홉이 하나 늘어나면서 프록시의 SigV4 왕복 지연이 추가되긴 합니다. 그럼에도 불구하고, LLM Invoke의 느린 응답 시간 특성 상 이 홉으로 늘어나는 지연의 영향이 크지 않을 것이라고 판단하였습니다.

3. 이미지 하나로 Runtime 여러 개 운영하기​

AgentCore Runtime은 컨테이너의 CMD를 오버라이드할 수 없다는 제약과, 저희가 공유하는 CI는 서비스당 이미지 1개만 빌드한다는 제약이 여기서 겹쳤습니다. AgentCore는 에이전트 1개가 Runtime 1개에 대응하므로 Runtime이 4개 필요한데, 진입점을 CMD로 나눌 수 없으니 그대로 가면 이미지 4개, 즉 CI 파이프라인 4벌을 유지해야 하는 상황이었습니다.

저희는 진입점을 환경 변수로 나누는 방법을 택하였습니다. uvicorn 타깃은 src.asgi:app 하나뿐이고, DD_AI_AGENT가 설정되어 있으면 해당 에이전트의 Runtime 앱(POST /invocations, GET /ping)을, 없으면 REST 앱(/v1/*, /health)을 조립합니다.

ECR 이미지 하나에서 DD_AI_AGENT 값별로 Runtime 4개와 k8s REST 파드로 갈라진다

# src/asgi.py
def _build_app() -> FastAPI:
if settings.dd_ai_agent:
# 등록되지 않은 이름이면 여기서 부팅이 실패한다 — 조용히 REST 모드로 떨어지면
# Runtime 이 /invocations 404 를 받고도 원인이 오타라는 걸 알 수 없다.
return create_runtime_app(get_runtime_agent(settings.dd_ai_agent))
return create_rest_app()


app = _build_app()

환경 변수로 나눈 덕분에 모든 Runtime과 k8s 파드가 동일한 아티팩트로 동작한다는 것도 확인할 수 있게 되었습니다. 첫 멀티 에이전트 배포 당시 Runtime 두 개의 containerUri가 같고 DD_AI_AGENT와 OTEL_SERVICE_NAME만 다른 것을 확인하였습니다. 롤백 역시 에이전트별로 독립적입니다. Runtime은 이름 기준으로 create-or-update라 ARN이 바뀌지 않고, 특정 에이전트만 이전 이미지 태그로 되돌릴 수 있습니다.

이 구조에서 신경 쓴 세부 사항이 두 가지 더 있습니다. 첫째, 등록되지 않은 이름이 들어오면 부팅 자체가 실패하도록 하였습니다. DD_AI_AGENT=hospital_review_sumary처럼 오타가 나면 REST 모드로 넘어가는 대신 유효한 이름 목록을 출력하고 종료합니다. 조용히 REST 모드로 넘어가버리면 Runtime이 /invocations에서 404를 받아도 원인을 찾기 어렵습니다. 둘째, Runtime 앱에는 /health를 두지 않았습니다. Runtime의 헬스 계약은 GET /ping이 {"status": "Healthy"}를 돌려주는 것 하나이고, 프로브가 둘이면 어느 쪽이 기준인지 애매해집니다.

4. 코드 구조​

레포는 두 개의 루트로 나뉩니다. packages/에는 프레임워크에 의존하지 않는 도메인과 공유 코드가, src/에는 inbound 어댑터(Runtime 진입점, REST 프록시, 배치 잡)가 있습니다. Ports & Adapters 구조를 그대로 따랐고, 의존성은 아래 방향으로만 흐르도록 하였습니다.

src → packages/agents → packages 공유의 아래 방향 의존, 도메인끼리·앱끼리는 금지

  • src/* → packages
  • packages/agents/* → packages/{agentcore, llm, core, knowledge}
  • 도메인끼리 import 금지, 앱(src/runtime, src/api)끼리 import 금지, 순환 없음
  • 유일한 예외는 src/asgi.py 인데요, 이 모듈은 둘을 모두 import합니다. (3장에 자세한 내용이 나옵니다)

현재 운영 중인 에이전트는 다음 네 가지입니다:

에이전트역할모델특징
hospital_review_filter병원 리뷰 감성분석Sonnet 4.6extended thinking + turns=1, system prompt cachePoint, 87건 골든셋
cs_tag_recommendation상담 종료 시 태그 추천Sonnet 4.6태그 후보를 payload로 받아 stateless 유지
hospital_review_summary리뷰 최대 100건 요약 + 키워드 하이라이트 오프셋Sonnet 5adaptive thinking, 온라인 평가 3축, 이 글의 예시
cs_suggested_replyCS 답변 추천Sonnet 5Strands Skills 플러그인 + hooks + 지식 베이스

이 구조가 실제로 도움이 되는지는 "에이전트 하나를 추가할 때 무엇이 바뀌는가"로 확인해볼 수 있습니다. 새 에이전트를 추가하면 다음 파일들이 바뀝니다:

  1. packages/agents/<agent>/ — 도메인(에이전트 팩토리, 서비스, 스키마, normalizer) + 매니페스트
  2. packages/agentcore/registry.py — 매니페스트 등록
  3. src/runtime/agents/<agent>.py — Runtime spec + 서비스 조립
  4. src/runtime/registry.py — spec 등록
  5. packages/core/{metrics, exceptions}.py — 도메인 카운터와 예외
  6. CI 매트릭스 등재

반대로 바뀌지 않는 것은 src/runtime/invocations.py(라우터), Dockerfile, CI 워크플로 본문입니다. 라우터는 spec을 읽기만 하고, 이미지는 단일로만 존재합니다.

에이전트 하나에 대한 정보는 한곳에 모으지 않고 두 곳으로 나누어 두었습니다. 나눈 기준은 "누가 이 정보를 읽는가"입니다.

(1) AgentManifest: 배포 스크립트가 읽는 정보

AWS에 무엇을 어떤 이름으로 만들지를 담습니다. Runtime 이름, 트레이스에 찍히는 서비스 이름(service.name), Memory 사용 여부, 배포 직후 정상 동작을 확인할 때 보낼 테스트 요청이 여기에 있습니다.

이 모듈은 파이썬 표준 라이브러리 외에는 import하지 않도록 제한하였습니다. 배포·운영 스크립트가 이 모듈을 읽는데, 여기서 FastAPI나 strands, OTel 같은 무거운 라이브러리까지 함께 불러오면 --help 한 번 보는 데에도 수 초가 걸리기 때문입니다.

(2) RuntimeAgentSpec: Runtime 라우터가 읽는 정보

요청이 들어왔을 때 이 에이전트를 어떻게 실행할지를 담습니다. 요청·응답 모델, 서비스를 만드는 함수, 업스트림 호출이 실패했을 때 잡을 예외와 응답에 담을 에러 코드가 여기에 있습니다. 라우터는 spec에 적힌 대로만 움직이므로, 에이전트가 늘어나도 라우터 코드는 고칠 필요가 없습니다.

# src/runtime/agents/hospital_review_summary.py
HOSPITAL_REVIEW_SUMMARY_RUNTIME_AGENT = RuntimeAgentSpec(
manifest=HOSPITAL_REVIEW_SUMMARY_MANIFEST,
payload_model=HospitalReviewSummaryInvocationPayload,
result_model=HospitalReviewSummaryResult,
service_dependency=get_hospital_review_summary_service,
invoke=_invoke,
upstream_error=HospitalReviewSummaryError,
upstream_error_code="HOSPITAL_REVIEW_SUMMARY_UPSTREAM_ERROR",
label="hospital review summary",
# memory_turn 미지정 = Memory 미사용
)

매니페스트와 spec이 서로 맞는지(uses_memory와 memory_turn이 같은 의미인지), AWS 이름 제약(Runtime 이름에 하이픈 불가, evaluator 이름 48자 이내)을 지키는지는 별도의 테스트 코드에서 검증합니다. 레포에서 유일하게 코드가 아닌 "데이터"를 검증하는 테스트인데, 이런 실패는 배포 시점에, 그것도 해당 이름을 처음 쓰는 환경에서만 드러나므로 단위 테스트 단계로 앞당겨 두었습니다.

5. Strands 에이전트 정의​

여기서부터는 리뷰 AI 요약 에이전트 하나를 따라가 보겠습니다. 병원 하나의 리뷰를 최대 100건 받아 150~200자 요약과 자주 언급된 키워드, 그리고 각 키워드가 어느 리뷰의 어느 위치에 등장하는지를 나타내는 오프셋을 돌려주는 기능입니다. 병원 서버가 주간 배치로 호출합니다.

(1) 에이전트는 요청마다 새로 생성합니다.

strands의 Agent는 대화 이력을 갖는 stateful 객체라, 싱글톤 서비스에 저장해두면 요청 간에 메시지가 누적됩니다. 그래서 팩토리를 두고 서비스가 매 요청마다(그리고 매 재시도마다) create()를 호출하도록 하였습니다.

# packages/agents/hospital_review_summary/agent.py
class HospitalReviewSummaryAgentFactory:
def __init__(self, *, model_factory: BedrockModelFactory, model_id: str) -> None:
self.model_factory = model_factory
self.model_id = model_id

def create(self) -> Agent:
model = self.model_factory.create(
model_id=self.model_id,
# Sonnet 5 는 sampling 파라미터를 받지 않는다 — 넘기지 않는 것이 유일한 선택지다.
temperature=None,
max_tokens=HOSPITAL_REVIEW_SUMMARY_MAX_TOKENS,
# Sonnet 5 의 thinking 은 adaptive 뿐 — budget_tokens 는 400 으로 거부된다.
additional_request_fields={
"thinking": {"type": "adaptive"},
"output_config": {"effort": HOSPITAL_REVIEW_SUMMARY_THINKING_EFFORT},
},
)
return Agent(
name=HOSPITAL_REVIEW_SUMMARY_AGENT_NAME, # 트레이스 식별용 (gen_ai.agent.name)
model=model,
system_prompt=HOSPITAL_REVIEW_SUMMARY_SYSTEM_PROMPT,
)

(2) 모델 설정은 코드 상수로 관리합니다.

모델 ID(global.anthropic.claude-sonnet-5), thinking effort(medium), max_tokens(32,000)는 constants.py에 있고, 환경 변수로 받는 것은 region뿐입니다. 처음에는 FEATURE_*_BEDROCK_MODEL_ID 같은 환경 변수 컨벤션이 있었지만, 모든 에이전트가 이를 코드 상수로 덮어쓰게 되었습니다. 모델 ID는 에이전트마다 다르고 IAM 권한과 평가 베이스라인도 그 값에 묶여 있어서, 상수를 사용하는 것을 규칙으로 정하였습니다.

temperature=None은 요청에서 해당 키 자체를 제외한다는 의미입니다. BedrockModelFactory는 값이 None이면 kwargs에 넣지 않습니다. Sonnet 5는 sampling 파라미터가 제거되어 0.0을 보내면 ValidationException이 발생합니다. 그래서 이전 모델에서 재현성을 위해 0을 사용하던 에이전트를 올리려면 이 처리가 필요합니다.

(3) 구조화 출력을 위해 invoke_async에 Pydantic 모델을 넘깁니다.

# packages/agents/hospital_review_summary/service.py
agent = self.agent_factory.create()
result = await agent.invoke_async(
prompt,
structured_output_model=HospitalReviewSummaryToolOutput,
)
raw: HospitalReviewSummaryToolOutput | None = result.structured_output
if raw is None:
raise HospitalReviewSummaryError(
"Bedrock hospital review summary returned no structured output"
)
normalized = self.normalizer.normalize(raw, comments_by_id)

strands는 structured_output_model을 Tool Use로 구현합니다. 모델이 해당 tool을 호출하면 스키마가 보장된 객체가 result.structured_output에 담깁니다. 다만 첫 시도에서는 tool_choice를 강제하지 않아 모델이 드물게 산문으로 답하는 경우가 있고, 이때는 None이 돌아옵니다. 저희는 이를 도메인 에러로 매핑하여 서비스의 재시도 대상으로 넘기고 있습니다.

(4) 출력 검증은 거부하기보다 잘라내는 방식을 택하였습니다.

LLM 출력 스키마인 ToolOutput에는 개수나 형식 제약을 걸지 않았습니다. summary: str, keywords: list가 전부입니다. 형식에 어긋난 mention은 normalizer가 버리고, 키워드는 6개까지만 남깁니다. 하이라이트 오프셋은 LLM에게 받지 않습니다. 모델은 원문 발췌(excerpt)만 돌려주고, normalizer가 그 발췌를 리뷰 원문에서 검색하여 코드포인트 기준 오프셋을 계산합니다. 모델에게 숫자를 세게 하는 대신, 결정론적으로 계산할 수 있는 것은 모두 코드로 옮긴 결과입니다. 엄격한 검증은 API 응답 모델인 Result에만 두었습니다.

(5) 서비스는 실패 시 한 번 더 시도합니다(총 2회).

Sonnet 5로 전환하면서 temperature를 쓸 수 없게 되어 출력이 비결정적이 되었는데, 대신 재시도에는 도움이 되었습니다. 같은 입력으로 다시 호출하면 다른 결과가 나오니 "구조화 출력 없음"이나 "normalizer reject"는 재시도할 가치가 있습니다. 반대로 ValidationException, AccessDeniedException, ResourceNotFoundException은 요청 형식이나 권한 문제라 두 번째 시도도 똑같이 실패하므로 즉시 예외를 올립니다. 그리고 첫 시도가 120초를 넘겼다면 재시도하지 않습니다. 호출자인 주간 배치의 타임아웃이 5분이라 두 번째 결과를 받아줄 곳이 없습니다.

6. Runtime 계약과 호출​

AgentCore Runtime이 컨테이너에 요구하는 계약은 단순합니다. POST /invocations로 요청을 받고, GET /ping에 {"status": "Healthy"}를 돌려주면 됩니다. 문제는 에이전트가 넷인데 라우터를 넷 만들고 싶지는 않았다는 점입니다.

컨테이너 쪽​

build_invocations_router(spec) 하나가 모든 에이전트를 담당합니다. 4장의 RuntimeAgentSpec을 받아 해당 에이전트의 payload 모델로 요청을 검증하고, 서비스를 조립하여 호출한 뒤, 결과를 result 또는 error 키로 감싼 응답 형식(이하 envelope)에 담습니다. 흐름은 신원 해석 → 서비스 호출 → 실패 시 envelope error → 성공 시 Memory 기록(옵션) → envelope result 순서입니다. 여기서 신원 해석 단계의 session.id를 서비스 호출 전에 bind해야 strands가 만드는 하위 스팬까지 세션 id가 기록됩니다.

src/runtime/invocations.py (except Exception 분기와 주석은 생략)

@router.post("/invocations", response_model=response_model,
response_model_by_alias=True, response_model_exclude_none=True)
async def post_invocations(
body: spec.payload_model,
request: Request,
service: Annotated[Any, Depends(spec.service_dependency)],
recorder: Annotated[AgentCoreMemoryRecorder | None, Depends(get_memory_recorder)],
) -> response_model:
identity = await resolve_runtime_identity(request, actor_id=body.actor_id)
try:
result = await spec.invoke(service, body)
except spec.upstream_error as exc:
log.warning("invocation_upstream_error", agent=agent, error=str(exc))
return response_model(error=InvocationError(
code=spec.upstream_error_code,
message=f"{spec.label} upstream (Bedrock) failed"))
if spec.memory_turn is not None and recorder is not None:
await recorder.record_turn(agent=agent, actor_id=identity.actor_id,
session_id=identity.session_id,
turn=spec.memory_turn(body, result))
return response_model(result=result)

응답은 항상 HTTP 200에 envelope를 담아 반환합니다. 성공이면 {"result": {...}}, 업스트림 실패면 {"error": {"code": "HOSPITAL_REVIEW_SUMMARY_UPSTREAM_ERROR", ...}}, 예기치 못한 실패면 INTERNAL_ERROR입니다.

상태 코드로 구분하지 않은 이유는 SDK 쪽에 있습니다. invoke_agent_runtime은 컨테이너가 non-2xx를 반환하면 SDK 예외로 감싸는데, 응답 body가 예외에 실려 오는 것을 보장하지 않습니다. REST 시절의 502(업스트림)와 500(내부) 구분을 보존하려면 그 구분을 응답 본문 안에 넣고 상태 코드는 항상 200이어야 했습니다. 2장의 프록시가 이 envelope를 읽어 다시 HTTP 상태 코드로 매핑합니다.

다만 payload 검증 실패(필수 필드 누락)는 envelope가 아니라 FastAPI의 422로 나가므로 프록시에는 SDK 예외로 보입니다. envelope는 "요청은 유효하였으나 처리에 실패하였다"는 경우만 다룹니다.

호출 쪽​

2장에서 설명한 것처럼 InvokeAgentRuntime을 호출하는 유일한 주체는 REST 프록시입니다.

# packages/agentcore/runtime_client.py
session_id = new_session_id("rest") # rest-<uuid4> = 41자, 33자 하한 충족

response = self._client.invoke_agent_runtime(
agentRuntimeArn=self._runtime_arn,
runtimeSessionId=session_id,
contentType="application/json", # 생략하면 컨테이너가 스키마 검증 전에 422
accept="application/json",
payload=json.dumps(payload, ensure_ascii=False),
qualifier="DEFAULT",
**invoke_trace_params(), # traceParent / traceState / baggage
)
return json.loads(response["response"].read())

세션 id는 요청마다 새로 만듭니다. 세션 1개가 microVM 1개에 대응하고 같은 세션의 요청은 직렬화되므로, 호출자의 세션 id를 그대로 넘기면 처리량이 크게 떨어집니다. 또한 runtimeSessionId는 33자 이상이어야 해서 32자인 uuid4().hex는 거부됩니다. contentType은 API 상으로는 선택 파라미터이지만, 생략하면 컨테이너의 FastAPI가 본문을 JSON으로 읽지 못해 422를 반환합니다.

boto3 클라이언트는 동기 방식이라 asyncio.to_thread로 감싸 이벤트 루프를 막지 않도록 하였습니다. 트레이스 전파는 호출 측이 traceParent·traceState·baggage를 파라미터로 직접 넘깁니다. 이 셋은 API에 정의된 파라미터라 SigV4 서명 대상에 포함되는데, 서명 이후에 HTTP 헤더로 덮어쓰면 모든 호출이 403 SignatureDoesNotMatch로 실패합니다. 넘기지 않아도 호출 자체는 200으로 성공하지만, Runtime이 매번 새 트레이스로 시작하여 REST→Runtime 연결이 끊깁니다. 겉으로는 정상처럼 보이는 유실이라 이 부분은 테스트로 고정해 두었습니다.

boto3 클라이언트 설정 세 가지는 모두 운영 중 실측을 통해 정해졌습니다:

  • retries={"total_max_attempts": 1}: SDK 재시도 1회는 곧 Bedrock 재호출이라 과금이 두 배가 됩니다. 재시도 판단은 호출자의 몫으로 두었습니다. 이름에 함정이 하나 있는데, botocore의 max_attempts는 최초 요청을 제외한 재시도 횟수라 max_attempts=1이면 총 2회를 허용합니다. 그래서 total_max_attempts를 사용합니다.
  • tcp_keepalive=True: 트래픽이 적은 경로에서 커넥션 풀의 유휴 소켓이 AWS 쪽에서 먼저 닫히고, 재사용 시 ConnectionClosedError가 발생하였습니다. 재시도를 꺼둔 탓에 그대로 표면화되었습니다.
  • 저빈도 호출자만 응답마다 Connection: close: keepalive로도 분 단위 유휴는 막을 수 없어서, before-send 훅에서 헤더를 붙여 풀에 유휴 소켓이 남지 않도록 하였습니다. Connection은 hop-by-hop 헤더라 SigV4 서명 대상이 아니어서 서명이 깨지지 않습니다. 비용은 호출마다 TLS 핸드셰이크 한 번이 추가되는 것이라, 배치로 몰아서 호출하는 리뷰 계열은 커넥션 재사용을 유지하고 상담 계열만 켰습니다.

read timeout도 에이전트마다 다르게 두었습니다. 100건 요약은 300초, 감성 단건은 실측 4~16초라 5분을 기다리고 있을 이유가 없습니다. 상수로 고정하지 않고 각 기능의 조립 지점에서 넘깁니다.

7. 배포​

배포 흐름은 다음과 같습니다. 공유 CI가 이미지 1개를 빌드하여 ECR에 push합니다. 그다음 agentcore-deploy 매트릭스 잡이 에이전트별로 돌면서 Runtime의 이미지를 교체합니다. Runtime 리소스 자체(이름, 실행 role, 네트워크)는 Terraform이 소유하고, CI는 이름 기준 create-or-update로 이미지만 갈아끼웁니다. 그래서 배포를 반복해도 ARN이 바뀌지 않고, 프록시의 설정값도 안정적으로 유지됩니다.

배포 스크립트는 다음 두 가지를 중심으로 짰습니다.

(1) environmentVariables는 배포마다 맵 전체가 교체됩니다.

UpdateAgentRuntime은 선언적으로 동작하여 넘기지 않은 키는 사라집니다. 콘솔에서 손으로 넣은 값도 다음 배포에서 사라집니다. 전형적인 실수는 Memory id를 빼먹고 배포하여 Memory가 꺼진 것을 모르고 지나가는 것입니다. 저희 스크립트는 배포 직전에 라이브 Runtime의 환경 변수와 diff하여 사라질 키를 경고합니다. 차단까지는 하지 않는데, 키를 의도적으로 지우는 것 역시 정당한 배포이기 때문입니다. 대신 DD_AI_AGENT와 OTEL_SERVICE_NAME은 매니페스트에서 무조건 다시 주입합니다.

(2) --wait-ready 없이는 스모크 테스트를 신뢰할 수 없습니다.

create/update는 Runtime이 CREATING/UPDATING 상태일 때 즉시 반환합니다. create 직후의 스모크는 실패하기 때문에 알아챌 수라도 있지만, update의 경우는 더 좋지 않습니다. DEFAULT 엔드포인트가 아직 이전 버전을 가리키고 있는 동안 스모크가 통과해버려서, 옛 이미지를 검증하고 새 배포에 성공 표시를 남깁니다. 그래서 GetAgentRuntime.status == READY를 기다린 다음, GetAgentRuntimeEndpoint.liveVersion이 새 version과 같은지까지 확인한 뒤에야 스모크를 실행합니다.

스모크에 사용할 payload는 매니페스트에서 정의합니다. 요약 에이전트의 payload는 다음과 같습니다.

# packages/agents/hospital_review_summary/manifest.py
smoke_payload=MappingProxyType(
{
"reviews": [
{"reviewId": "smoke-1", "comment": "선생님이 친절하고 설명이 자세했어요."},
{"reviewId": "smoke-2", "comment": "직원분들이 친절하고 대기시간이 짧았습니다."},
{"reviewId": "smoke-3", "comment": "예약 시간에 맞춰 바로 진료를 봐서 대기시간이 거의 없었어요."},
{"reviewId": "smoke-4", "comment": "진료실이 깨끗하고 주차 공간도 넉넉해서 방문하기 편했습니다."},
]
}
),
smoke_result_key="summary",

리뷰가 2건이 아니라 4건인 데에는 이유가 있습니다. 5-1의 사고 이후 normalizer가 100자 미만 요약을 reject하게 되었는데, 재료가 너무 적으면 모델이 150~200자를 채우지 못해 "배포 실패"처럼 보이는 502가 발생합니다. 스모크는 배포를 검증하는 자리이니 재료를 충분히 실어두었습니다.

Runtime이 linux/arm64만 받는다는 플랫폼 제약도 있습니다. Dockerfile에 --platform을 고정하지 않은 것은 CI 러너와 개발 머신(Apple Silicon)이 모두 네이티브 arm64여서 기본값이 맞기 때문입니다. 그리고 실행 role 이름이 *agentcore* 패턴이어야 iam:PassRole이 통과합니다.

8. 평가​

1장에서 Evaluations가 도입의 계기였다고 설명하였습니다. 실제로 운영해보니 평가 경로가 세 가지 필요하였고, 각 경로는 서로를 대체하지 못합니다.

경로시점입력정답출력
오프라인 골든셋머지 전 게이트체크인된 JSONL, 실 Bedrock 호출있음exit 0/1
온라인배포 후 상시실트래픽 트레이스 샘플링없음CloudWatch
배치주 1회 소급지정 구간의 실트래픽 트레이스없음잡 결과

머지 전 오프라인 골든셋, 배포 후 상시 온라인, 주 1회 배치 — 데이터 원천은 골든셋과 CloudWatch 트레이스

(1) 온라인 평가

배포된 Runtime의 실제 트레이스를 샘플링하여 상시 채점합니다. 요약 에이전트는 정답이 없는 기능이라 루브릭 기반의 LLM judge를 사용합니다. 사실 일치성, 개인정보 노출, 감성 정합성 세 축을 evaluator 세 개로 분리하였는데, 축마다 허용 임계값이 다르기 때문입니다.

초안에는 "형식 준수"라는 네 번째 축이 있었지만 제외하였습니다. 키워드 수, 발췌가 원문의 부분 문자열인지, 오프셋이 맞는지는 결정론적으로 계산할 수 있고, normalizer와 Hypothesis 속성 테스트가 CI에서 이미 보장하고 있어 LLM으로 다시 볼 이유가 없었습니다.

데이터 소스가 CloudWatch Logs로 고정되어 있어 Observability 없이는 이 기능 자체가 성립하지 않고, 샘플 1건마다 judge 호출 1회가 과금되므로 prod는 샘플링 비율을 낮게, dev/test는 100%로 잡았습니다.

(2) 배치 평가

이미 쌓인 트레이스 중 지정한 구간만 골라 한 번에 채점합니다. 주간 배치(Argo CronWorkflow)가 전주 월요일부터 일요일까지를 창으로 잡아 실행합니다. 특정 세션 id만 지정할 수도 있어 사고 소급 조사에도 활용합니다. 채점할 세션이 0건이면 실패가 아니라 SKIPPED로 끝내는데, 간헐적인 트래픽에서 빈 잡이 매일 쌓이지 않도록 하기 위해서입니다.

(3) 오프라인 골든셋 평가

요약 에이전트에는 오프라인 골든셋이 없습니다. 정답이 없는 기능이라 골든셋이 성립하지 않습니다. 대신 감성분석 에이전트가 87건의 JSONL 골든셋을 가지고 있어 그쪽을 기준으로 설명하겠습니다.

strands-agents-evals의 Case/Experiment에 결정론적 evaluator를 붙였고, 기대값은 정확한 값이 아니라 밴드로 두었습니다. 프로덕션이 extended thinking(temperature 1.0)으로 동작하다 보니 점수가 실행마다 조금씩 달라집니다. 게이트 임계값(라벨 0.95, 점수 밴드 0.90)은 베이스라인 98.9%보다 여유를 두고 잡았습니다. 케이스 1건이 1.1%p에 해당하므로 베이스라인에서 라벨이 3건 더 뒤집혀도 통과합니다.

실제 Bedrock을 호출하므로 PR CI에서는 실행하지 않고, 프롬프트·모델·normalizer를 바꿀 때 머지 전에 수동으로 실행합니다.

정리하면, 오프라인 평가는 정답이 있지만 입력이 고정되어 있어 실제 트래픽의 드리프트를 볼 수 없고, 온라인과 배치 평가는 실제 트래픽을 보지만 정답이 없어 머지 게이트가 될 수 없습니다. 그래서 세 경로를 모두 사용하고 있습니다.

9. 마치며​

지금까지의 내용을 간단히 정리하면 다음과 같습니다:

  • 관측성과 평가를 직접 구축하는 대신 플랫폼 기본 기능으로 얻기 위해 AgentCore Runtime과 Strands Agents를 선택하였습니다.
  • 기존 k8s 파드는 권한이 모이는 프록시로 남겨, 호출자는 기존과 같은 HTTP만 알면 되도록 하였습니다.
  • CMD를 오버라이드할 수 없는 제약 때문에 이미지 하나를 환경 변수로 나누어 Runtime 여러 개를 운영하고 있습니다.
  • 에이전트는 요청마다 새로 만들고, 구조화 출력과 결정론적인 후처리로 출력 형식을 보장하였습니다.
  • Runtime은 항상 200에 envelope를 담아 응답하고, 프록시가 이를 다시 HTTP 상태 코드로 매핑합니다.
  • 배포는 환경 변수 맵 전체 교체와 --wait-ready를 전제로 짰습니다.
  • 평가는 오프라인 골든셋, 온라인, 배치 세 경로를 함께 사용하고 있습니다.

돌아보면 플랫폼의 제약이 코드 구조를 결정한 부분이 많았습니다. serviceNames가 1개만 받아서 service.name이 매니페스트로 내려갔고, 환경 변수 맵이 전체 교체되어서 매니페스트가 SSOT가 되었습니다.

앞으로 남은 과제도 있습니다.

(1) 온라인 평가 결과에 대한 모니터링 시스템

현재 온라인 평가는 실트래픽을 샘플링하여 채점하고 그 결과를 CloudWatch에 쌓고 있지만, 점수가 떨어졌을 때 이를 알아챌 장치는 아직 갖추지 못하였습니다. 평가를 붙인 목적이 배포 이후의 품질 변화를 잡아내는 것이었던 만큼, 축별 점수 추이를 보는 대시보드와 임계값 아래로 떨어졌을 때의 알림을 구축하는 것이 다음 과제입니다.

(2) Memory 도입

Memory는 과거 결과를 다시 읽어야 하는 소비자가 생기는 에이전트부터 켤 예정입니다.

그리고 이 글에서 표로만 소개한 cs_suggested_reply 에이전트에는 Strands Skills 플러그인과 hooks를 사용하여 모델의 병렬 tool 호출을 코드로 막는 게이트가 들어 있는데, 이 이야기는 분량이 따로 필요하여 추후 기회가 되면 다른 글에서 다뤄보겠습니다.

AgentCore 도입을 검토하고 계신 분들께 이 글이 조금이나마 도움이 되었으면 합니다. 읽어주셔서 감사합니다.

참고 자료​