온콜 에이전트, 3부. 첫 번째 에이전트: Strands로 만드는 인시던트 트리아지
Strands 트리아지 에이전트가 AgentCore Runtime에 배포된다. 증거 우선 시스템 프롬프트와 읽기 전용 도구 두 개, 배포용 테라폼을 다룬다.

이 시리즈에서 처음으로 실제로 동작하는 에이전트는 약 260줄의 파이썬으로 되어 있다. Strands Agent 하나, boto3를 호출하기 전에 크로스 계정 IAM 역할을 어썸하는 읽기 전용 도구 두 개, 그리고 모델이 실제로 확인하지 않은 것을 자신 있게 말하지 못하게 막는 것이 유일한 임무인 시스템 프롬프트다. 아직 AgentCore Gateway도, 수퍼바이저도, 다중 에이전트 핸드오프도 없다. 그것들은 이후 편에서 다룬다. 이번 편은 디버깅을 어렵게 만드는 무언가를 더하기 전에, 에이전트 하나가 일 하나를 제대로 하도록 만들어 AgentCore Runtime에 배포하는 것이 전부다.
1부는 시나리오를 정했다. 엔지니어 50명 규모의 SaaS 기업, 매주 40건의 페이지, 타협 불가능한 제약으로 IAM이 강제하는 읽기 전용, 그리고 클래식 Bedrock Agents나 직접 만든 루프 대신 AgentCore와 Strands를 선택한 결정이다. 2부는 이 에이전트들이 살아가는 계정 경계를 구축했다. ops-readonly와 ops-mutate 스포크 역할, 그리고 Bedrock의 크로스 리전 라우팅을 감싸는 에이전트별 애플리케이션 추론 프로필이다. 이번 편은 이 둘을 그대로 사용한다. 여기서 처음부터 새로 프로비저닝하는 것은 아무것도 없다. 2부가 이미 만들어 둔 것을 그대로 소비할 뿐이다. 동반 코드는 github.com/flightlesstux/agents-on-call의 agents/triage/agent.py와 terraform/10-agent-runtime/에 있으며, 아래의 모든 스니펫은 글을 위해 단순화한 것이 아니라 그 파일들에서 그대로 발췌했다.
Strands 에이전트의 해부
Strands Agents는 모델 주도형 파이썬 SDK로, AWS가 2025년 5월 Apache 2.0 라이선스로 오픈소스화했고, 외부로 나오기 전부터 Amazon Q Developer, AWS Glue, VPC Reachability Analyzer에서 내부적으로 쓰이고 있었다. 이 도구가 내세우는 것은 새로운 오케스트레이션 언어가 아니다. 이미 도구 호출을 할 줄 아는 모델을 감싸는 얇은 루프로, 에이전트를 작성하는 방식을 바꾸지 않고도 Bedrock, Anthropic 직접 호출, Ollama, LiteLLM으로 프록시된 프로바이더 어디에든 연결된다. 에이전트 하나의 전체 형태는 다섯 단계에 담긴다.
1. 설정은 파일 안의 리터럴이 아니라 환경에서 온다. 런타임이 알아야 할 모든 것은 아래의 테라폼 모듈이 환경 변수로 설정해주므로, 계정 고유 정보나 비밀 값이 소스에 하드코딩되는 일이 없다:
AWS_REGION = os.environ.get("AWS_REGION", "eu-west-1")
INFERENCE_PROFILE_ARN = os.environ["AGENT_INFERENCE_PROFILE_ARN"]
OPS_READONLY_ROLE_NAME = os.environ.get("OPS_READONLY_ROLE_NAME", "ops-readonly")
SPOKE_EXTERNAL_ID = os.environ["SPOKE_EXTERNAL_ID"]2. 도구는 데코레이터가 붙은 평범한 파이썬 함수다. Strands는 함수의 타입 힌트와 독스트링을 읽어 모델이 실제로 보게 될 도구 스펙을 만든다. 그래서 독스트링은 사람을 위한 주석이 아니라 인터페이스 계약이다:
@tool
def cloudwatch_read(
spoke_account_id: str,
namespace: str,
metric_name: str,
dimensions: dict[str, str],
lookback_minutes: int = 60,
stat: str = "Average",
period_seconds: int = 60,
) -> str:
"""Read a CloudWatch metric's recent datapoints from a spoke account.
...
"""3. 모델은 원시 모델 ID가 아니라 2부의 추론 프로필을 통해 연결된다. 온도(temperature)는 의도적으로 낮게 유지한다. 이 에이전트는 증거로부터 진단을 제시할 뿐, 표현에 창의적인 다양성이 필요하지 않기 때문이다.
model = BedrockModel(
model_id=INFERENCE_PROFILE_ARN,
region_name=AWS_REGION,
temperature=0.1,
max_tokens=2048,
)4. 에이전트 자체는 생성자 호출 한 번이다. 모델 하나, 시스템 프롬프트 하나, 도구 목록 하나. 작성해야 할 오케스트레이션 그래프는 없다. Strands가 모델 자신의 도구 사용 출력으로부터 도구 호출 루프를 유추하기 때문이다:
agent = Agent(
model=model,
system_prompt=SYSTEM_PROMPT,
tools=[cloudwatch_read, logs_read],
name="incident-triage",
description=(
"First-pass responder for CloudWatch alarms: reads metrics and logs, proposes a "
"root cause candidate, defers all remediation to a human or the runbook agent."
),
)5. AgentCore Runtime 엔트리포인트가 이를 감싼다. BedrockAgentCoreApp은 Strands 자체가 아니라 별도의 bedrock-agentcore 패키지에서 온다. Strands는 에이전트 프레임워크이고 AgentCore는 호스팅 계층이며, @app.entrypoint 데코레이터가 둘 사이의 이음매다.
app = BedrockAgentCoreApp()
@app.entrypoint
def invoke(payload: dict) -> dict:
"""AgentCore Runtime entrypoint.
...
"""
prompt = payload.get("prompt")
if not prompt:
return {"error": "payload.prompt is required"}
result = agent(prompt)
return {"result": str(result)}
if __name__ == "__main__":
app.run()agent(prompt)를 호출하는 것이 외부에서 보이는 에이전트 루프의 전부다. Strands는 프롬프트와 도구 스펙을 Bedrock에 보내고, 최종 답변이나 도구 사용 요청을 돌려받고, 요청된 도구를 실행하고, 그 결과를 다시 모델에 넣어주는 과정을 모델이 더 이상 도구를 요구하지 않을 때까지 반복한다. 이 루프의 코드는 이 프로젝트가 소유한 코드가 아니다. 1부에서 기각한 자체 구축 옵션을 택했다면 직접 손으로 작성해야 했을 코드다.
시스템 프롬프트 설계: 결론보다 증거가 먼저
자신 있게 말하는 트리아지 에이전트는 "아직 모르겠다"고 말하는 에이전트보다 더 위험하다. 새벽 3시에 그 출력을 읽는 사람은 내용만큼이나 어조에 따라 행동하기 때문이다. 이 시스템 프롬프트는 에이전트를 더 똑똑하게 만들기 위해서가 아니라, 거의 전적으로 이 실패 모드를 막기 위해 존재한다:
1. 결론보다 증거가 먼저다. 근본 원인 후보를 말하기 전에 cloudwatch_read와 logs_read를
호출하라. 메트릭 하나와 로그 그룹 하나를 최소한 조회하기 전에 세운 가설은 진단이 아니라
추측이며, 진단인 것처럼 제시해서는 안 된다.
2. 모든 주장에 메트릭 이름과 네임스페이스를 인용하라: "지연이 늘었다"가 아니라 "AWS/ApplicationELB
TargetResponseTime이 지난 15분 동안 120ms에서 4.8초로 상승했다"라고 말하라. 당신의 도구
호출을 볼 수 없는 독자도 같은 쿼리를 다시 실행해 당신이 말한 모든 숫자를 검증할 수 있어야
한다.
3. 불확실성을 명시적으로 진술하라. ...
4. 도구가 볼 수 없는 원인에 대해서는 추측하지 마라. ...
5. 빈 결과도 발견이다. 쿼리 구간에 데이터포인트가 없는 메트릭이나 0행을 반환하는 로그
쿼리는 증거다(장애가 조용히 일어났거나 이 로그 그룹보다 상류에서 일어났다는 뜻이다).
무언가 나올 때까지 구간을 넓혀 재시도할 이유가 아니다.
6. 구조화된 요약으로 마무리하라: 알람, 신뢰도와 함께 제시하는 근본 원인 후보, 실행한
정확한 메트릭과 로그 쿼리, 도구 호출이 한 번 더 있다면 다음에 무엇을 확인할지.가장 많은 일을 하는 것은 규칙 2다. "메트릭 이름을 인용하라"는 문체상의 선호처럼 들리지만, 실제로는 에이전트의 출력을 사람이 그냥 믿어야 하는 주장에서, 같은 CloudWatch 쿼리를 붙여넣어 15초 만에 검증할 수 있는 주장으로 바꿔주는 장치다. 규칙 5가 존재하는 이유는, 재시도 예산이 있는 에이전트의 자연스러운 실패 양상이 무언가를 찾을 때까지 시간 구간을 계속 넓히거나 쿼리를 계속 바꾸는 것이기 때문이다. 그 시점에서는 관련이 없더라도 그 "무언가"가 원인으로 보고된다. 빈 결과도 데이터다. 계속 낚시질할 신호가 아니라 보고해야 할 막다른 길로 취급하는 것이야말로 에이전트가 잡음에서 진단을 지어내지 못하게 막는다.
도구 두 개, 평범한 파이썬, 아직 Gateway는 없음
1부에서 그린 목표 아키텍처는 모든 도구를 AgentCore Gateway 뒤에 둔다. MCP 호환, 중앙에서 IAM으로 범위를 지정하는, 플랫폼 전체에 걸친 도구 4개를 위한 허용 목록 하나. 그것은 4부의 몫이다. 여기서는 에이전트 하나와 도구 두 개뿐이므로, Gateway는 아직 라우팅할 대상도 없는 상태에서 간접 계층만 하나 더하는 셈이다. 두 도구 모두 에이전트가 직접 호출하는, @tool이 붙은 평범한 파이썬 함수이며, AWS를 건드리기 전에 둘 다 같은 일을 한다. 대상 스포크 계정의 ops-readonly를 어썸하는 것이다.
def _assume_ops_readonly(spoke_account_id: str) -> boto3.Session:
sts = boto3.client("sts", region_name=AWS_REGION, config=_boto_config)
role_arn = f"arn:aws:iam::{spoke_account_id}:role/{OPS_READONLY_ROLE_NAME}"
creds = sts.assume_role(
RoleArn=role_arn,
RoleSessionName="triage-agent-read",
ExternalId=SPOKE_EXTERNAL_ID,
DurationSeconds=900,
)["Credentials"]
return boto3.Session(
aws_access_key_id=creds["AccessKeyId"],
aws_secret_access_key=creds["SecretAccessKey"],
aws_session_token=creds["SessionToken"],
region_name=AWS_REGION,
)15분이며, ops-readonly의 신뢰 정책이 허용하는 한 시간 상한이 아니다. 15분이 지나도 여전히 자격 증명이 필요한 도구 호출이라면 다른 무언가에 걸려 있는 것이고, 수명이 짧은 자격 증명은 혹시라도 프로세스 밖으로 유출됐을 때의 블라스트 반경을 줄여준다. cloudwatch_read는 get_metric_statistics를 감싸고 데이터포인트를 오래된 순서로 JSON으로 반환한다. logs_read는 CloudWatch Logs Insights 쿼리를 시작하고 완료를 폴링하는데, 20회 시도에 걸쳐 총 18초로 상한을 두며, 멈춘 쿼리에서는 빈 결과를 반환하는 대신 예외를 던진다. 조용히 타임아웃되어 0건 일치를 보고하는 로그 쿼리는 "오류 없음"과 정확히 똑같이 읽히는데, 이 도구가 절대 가져서는 안 되는 유일한 실패 양상이 바로 그것이다. 두 도구 모두 spoke_account_id 인자를 받으므로, 배포에 박아넣은 값이 아니라 알람 이벤트가 알려주는 정보를 바탕으로 모델이 어느 계정을 조회할지 직접 결정한다.
Lambda나 ECS 대신 AgentCore Runtime을 쓰면 얻는 것
여기서 AgentCore Runtime이 엄밀히 필수인 것은 아니다. Strands 에이전트는 평범한 파이썬이라 boto3 클라이언트와 루프만 있으면 Lambda 함수나 ECS 태스크에서도 돌아갈 수 있다. 2025년 10월 13일부터 정식 출시된 AgentCore Runtime은 그렇게 하지 않으면 직접 구축해야 했을 인프라 세 가지를 대신 사준다:
- 세션당 8시간의 실행 시간. Lambda의 15분이라는 하드 상한은 아주 짧게 끝나는 에이전트가 아닌 이상, 재시도가 있는 다중 도구 진단 루프처럼 더 오래 걸릴 수 있는 작업에 대해 여러 호출에 걸친 수작업 연속 실행 메커니즘을 강요한다. AgentCore Runtime의 세션 모델에는 그런 우회가 필요 없다.
- 호출 간 완전한 세션 격리를, 개별 Lambda 실행 컨텍스트나 ECS 태스크로 세션별 샌드박스를 직접 구축하지 않고도 얻는다.
- 유휴 CPU에는 과금하지 않는 초 단위 과금. vCPU-시간당 $0.0895, GB-시간당 $0.00945를 초 단위로 과금하며, 최소 메모리는 128MB다. 특히 에이전트가 LLM 응답이나 도구 호출을 기다리며 블록되어 있는 시간, 세션의 실제 경과 시간 중 보통 30~70퍼센트를 차지하는 그 시간에는 과금하지 않는다. ECS 태스크나 프로비저닝된 Lambda라면 CPU가 실제로 뭔가 하고 있는지와 무관하게 그 유휴 시간에도 과금됐을 것이다.
트레이드오프는 각주가 아니라 실제 제약이다. AgentCore Runtime은 ARM64 명령어 집합만 지원하므로, 컴파일된 확장이 있는 의존성은 ARM64나 manylinux 휠로 해결돼야 하며, 그렇지 않으면 업로드 시점에 ELF 헤더 검증에서 배포가 실패한다. 이 에이전트의 의존성 두 개, strands-agents와 bedrock-agentcore는 오늘 시점에 컴파일된 확장을 전혀 담고 있지 않으므로 여기서는 문제가 되지 않는다. 이 시리즈에서 네이티브 데이터베이스 드라이버 같은 것이 필요한 첫 에이전트가 나오면 문제가 될 것이고, 그때의 해법은 평범한 pip install 대신 pip install --platform manylinux2014_aarch64 --only-binary=:all:로 배포 아티팩트를 빌드하는 것이다.
런타임을 위한 테라폼
aws_bedrockagentcore_agent_runtime은 agent_runtime_artifact 아래에 상호 배타적인 아티팩트 타입 두 가지를 지원한다. 런타임 버전과 진입점을 가진 S3 zip인 code_configuration과, ECR 이미지 URI인 container_configuration이다. 이 모듈은 code_configuration을 쓴다. AgentCore 자체 CLI도 새 에이전트에 대해 컨테이너 빌드 대신 같은 직접 코드 배포 경로(내부적으로 CodeZip이라 불린다)를 기본값으로 쓰며, 의존성이 정확히 두 개뿐이고 설치할 시스템 패키지가 없는 상황에서 Dockerfile은 이 에이전트에 아직 필요 없는 겉치레일 뿐이다. 의존성에 컴파일이 필요하거나 런타임이 파이썬이 아닌 미래의 에이전트가 등장하면 그것이 container_configuration으로 전환할 계기이지, 어느 한쪽 아티팩트 타입을 기본으로 선호할 이유는 아니다.
resource "aws_bedrockagentcore_agent_runtime" "triage" {
agent_runtime_name = "${replace(var.platform_name, "-", "_")}_triage"
description = "Incident-triage agent: reads CloudWatch metrics and Logs Insights across spokes, diagnoses, never mutates."
role_arn = aws_iam_role.triage_runtime.arn
agent_runtime_artifact {
code_configuration {
entry_point = ["agent.py"]
runtime = "PYTHON_3_12"
code {
s3 {
bucket = aws_s3_bucket.agent_artifacts.id
prefix = aws_s3_object.triage_agent_code.key
version_id = aws_s3_object.triage_agent_code.version_id
}
}
}
}
environment_variables = {
AWS_REGION = var.aws_region
AGENT_INFERENCE_PROFILE_ARN = var.agent_inference_profile_arn
OPS_READONLY_ROLE_NAME = var.ops_readonly_role_name
SPOKE_EXTERNAL_ID = var.spoke_external_id
}
network_configuration {
network_mode = "PUBLIC"
}
protocol_configuration {
server_protocol = "HTTP"
}
tags = merge(var.tags, {
Agent = "triage"
})
}VPC 연결 대신 network_mode = "PUBLIC"을 쓴 것은 의도적이다. 이 에이전트가 내보내는 호출은 Bedrock 모델 호출과 크로스 계정 sts:AssumeRole뿐이며, 둘 다 IAM으로 인증된 HTTPS로 접근하는 공개 AWS 서비스 엔드포인트다. 이 에이전트가 직접 이야기하는 프라이빗 네트워크 리소스, 데이터베이스나 내부 로드밸런서 같은 것이 없으므로, VPC 연결은 아무 이득 없이 NAT와 엔드포인트 비용만 더할 것이다. 실행 역할의 권한 정책은 이 에이전트가 실제로 건드릴 수 있도록 허용된 세 가지에 정확히 대응하는 세 방향으로 범위가 정해진다. 추론 프로필 ARN과 그것이 라우팅할 수 있는 기반 파운데이션 모델 ARN 둘 다에 대한 bedrock:InvokeModel(크로스 리전 추론은 프로필 수준과 대상 모델 수준 양쪽에서 권한을 확인하므로 둘 다 필요하며, 두 번째 권한을 빠뜨리면 마침 그 권한이 없는 리전으로 라우팅되는 호출만 실패한다. 2부가 모델 접근 자체에 대해 지적한 것과 똑같이 간헐적으로 보이는 그 실패다), 이 에이전트가 조회하도록 허용된 모든 스포크 계정의 정확한 ops-readonly ARN으로 범위를 지정한(와일드카드가 아니라 목록으로 만든) sts:AssumeRole, 그리고 자신의 코드 아티팩트 프리픽스에 한정된, 그 이상은 아닌 s3:GetObject다.
첫 진단, 알람에서 요약까지
스포크 계정의 checkout 서비스에서 AWS/ApplicationELB TargetResponseTime에 대한 CloudWatch 알람이 울린다. 수퍼바이저 에이전트는 아직 존재하지 않으므로(5부), 이번 편에서는 사람이 알람 컨텍스트를 에이전트에 직접 붙여넣는다. 돌아오는 결과를 요약하면:
> cloudwatch_read(spoke_account_id="222222222222",
namespace="AWS/ApplicationELB", metric_name="TargetResponseTime",
dimensions={"LoadBalancer": "app/prod-checkout/50dc6c495c0c9188"},
lookback_minutes=30)
< 30 datapoints, p-average rising from 0.14s to 4.6s over the last 12 minutes
> cloudwatch_read(spoke_account_id="222222222222",
namespace="AWS/ApplicationELB", metric_name="HTTPCode_Target_5XX_Count",
dimensions={"LoadBalancer": "app/prod-checkout/50dc6c495c0c9188"},
lookback_minutes=30)
< 5XX count flat near zero for 18 minutes, then rising with TargetResponseTime
> logs_read(spoke_account_id="222222222222",
log_group_name="/aws/ecs/prod-checkout",
query_string='fields @timestamp, @message | filter @message like /timeout|pool/ | sort @timestamp desc',
lookback_minutes=15)
< 41 rows, dominant message: "HikariPool-1 - Connection is not available,
request timed out after 30000ms"에이전트의 요약은 이렇다. app/prod-checkout의 AWS/ApplicationELB TargetResponseTime이 12분에 걸쳐 140ms에서 4.6초로 상승했고, 지연 상승이 시작된 지 약 6분 뒤부터 HTTPCode_Target_5XX_Count가 오르기 시작했다. 같은 구간에서 /aws/ecs/prod-checkout에 대한 CloudWatch Logs Insights 쿼리는 커넥션 풀 타임아웃과 일치하는 41개 행을 반환한다. 가장 유력한 근본 원인은 서비스의 데이터베이스 커넥션 풀이 고갈되어 요청이 30초 풀 타임아웃에 걸릴 때까지 대기열에 쌓이고 있다는 것이며, 이것이 완전한 5XX가 되기 전에 상승한 TargetResponseTime으로 나타나고 있다. 신뢰도는 높음이 아니라 중간이다. 이 에이전트에는 데이터베이스 자체의 커넥션 수나 풀에 설정된 최댓값을 읽을 수 있는 도구가 없어서, 예를 들어 다운스트림 의존성이 평소보다 오래 커넥션을 붙잡고 있는 경우와 실제로 풀이 가득 찬 경우를 구분해 확인할 수 없기 때문이다. 다음으로 권장하는 확인 사항은, 런북 에이전트가 존재하게 되면 그 에이전트의 데이터베이스 커넥션 메트릭을 보거나, 사람이 풀의 현재 활성 커넥션 수를 직접 확인하는 것이다.
마지막 문단은 글을 위해 덧붙인 안전장치가 아니라, 시스템 프롬프트의 규칙이 의도한 대로 작동한 결과다. 에이전트는 실제 가설에 대한 실제 증거를 갖고 있고, 그 뒤에 있는 정확한 메트릭과 쿼리를 인용하며, 자신의 두 도구가 실제로 볼 수 있는 범위의 경계에서 정확히 멈춘다. 그 경계 너머를 추측하는 대신 빈틈을 이름 붙여 밝히는 것이다.
지켜봐야 할 실패 모드
이 에이전트가 실제 알람을 상대로 돌아가기 전에 알아둘 가치가 있는 것이 세 가지 있다. 첫째, ops-readonly에 대한 AssumeRole 실패, 잘못된 계정 ID, 만료된 외부 ID, 아직 온보딩되지 않은 스포크 같은 경우는 최종 요약에서 "이 계정을 읽을 수 없었다"로 드러나야 하며, 모델이 조용히 건너뛴 도구 호출을 성공한 하나의 데이터 소스만으로 추론해 우회하는 식이어서는 안 된다. 완전한 진단과 같은 확신으로 제시된 부분적 진단은 진단이 아예 없는 것보다 나쁘다. 둘째, 18초라는 Logs Insights 타임아웃은 실제 상한이다. 대용량 로그 그룹에 대해 넓은 필터로 쿼리하면 정당하게 더 오래 걸릴 수 있고, 이때 해법은 멈춘 도구 호출을 멈춘 에이전트 호출로 바꿔버리는 더 긴 타임아웃이 아니라, 모델이 더 촘촘한 쿼리(더 좁은 시간 구간, 더 구체적인 필터)를 쓰는 것이다. 셋째, 낮은 온도는 표현의 변동성을 줄이지만 모델이 답하기 전에 반드시 두 도구를 모두 호출한다는 것을 보장하지는 않는다. 실제 강제 메커니즘은 시스템 프롬프트의 규칙 1이며, 지름길을 택하고 싶게 일부러 설계한 알람, 알람 이름만으로 원인이 강하게 암시되는 알람을 상대로 테스트해서 에이전트가 결론 내리기 전에 여전히 조회부터 하는지 확인할 가치가 있다.
다음으로 읽을 글
- 2부, 기반: 토큰보다 테라폼이 먼저다, 이 에이전트가 직접 소비하는
ops-readonly역할과 애플리케이션 추론 프로필을 다룬다. - 4부, 도구와 Gateway: MCP, 허용 목록, 기본값은 읽기 전용, 지금의 두 도구에 두 개를 더해 AgentCore Gateway 뒤로 옮기고, 파이썬 함수를 하나씩 배선하는 대신 중앙에서 범위를 지정한 IAM을 갖게 되는 편이다.
- ercan.cloud의 Automating AWS CloudWatch Log Group Tagging with Python and Boto3, 이번 편의 도구가 쓰는 것과 같은 boto3-CloudWatch 패턴을, 에이전트 쪽이 아니라 순수 자동화 쪽에서 다룬다.
위 스니펫에서 생략한 IAM 정책 문서를 포함한 전체 agent.py와 완전한 terraform/10-agent-runtime/ 모듈은 동반 저장소인 github.com/flightlesstux/agents-on-call에 있다. 이런 플랫폼을 대규모로 운영할 때의 데이터베이스와 인프라 쪽 이야기는 ercan.cloud에, 허브는 ercanermis.com에 있다.
Ercan의 다른 글
같은 저자, 다른 영역의 사이트 두 개.