3부의 인시던트 트리아지 에이전트가 가동된 지 석 달 만에, 실제 인시던트에 대해 확신에 차고 잘 쓰였지만 틀린 진단을 내놓았고, 포스트모템까지 아무도 잡아내지 못했다. 실시간으로 잡아냈을 트레이스가 아직 존재하지 않았기 때문이다. 이것이 이 마지막 편이 실제로 다루는 모든 실패의 형태다. 크래시도 예외도 아닌, 우연히 거짓이었던 그럴듯한 문장. 이 글은 "에이전트가 그렇다고 했다"를 사람이 검증할 수 있는 무언가로 바꾸는 세 가지를 구축하고, 에이전트가 거짓말한 바로 그날을 들여다보고, 1부가 약속한 숫자들에 대해 플랫폼 전체를 채점하며 시리즈를 닫는다.

지금까지의 궤적은 이렇다. 1부는 시나리오를 정하고 클래식 Bedrock Agents 대신 AgentCore와 Strands를 골랐다. 2부는 계정 경계와 IAM 기초를 부었다. 3부는 증거 우선 시스템 프롬프트를 가진 첫 에이전트, 인시던트 트리아지를 내놓았다. 4부는 그 도구들을 AgentCore Gateway 뒤로 옮기고 유일한 변경 경로에 사람 승인 게이트를 두었다. 5부는 수퍼바이저와 런북, 비용 스페셜리스트를 더했다. 6부는 모든 호출 앞에 Bedrock Guardrails를 세웠다. 7부는 아무도 미리 하지 않는 토큰 계산을 했다. 그 모든 편이 플랫폼 자신의 출력을 다음 계층을 그 위에 지을 만큼은 신뢰할 수 있다고 가정했다. 이번 편이 그 가정이 시험대에 오르는 자리다.

에이전트 추론 추적하기: 유용한 트레이스에 실제로 담기는 것

Amazon CloudWatch 생성형 AI 옵저버빌리티는 2025년 7월 16일 모델 호출과 AgentCore 에이전트에 대한 기본 제공 지연 시간, 사용량, 오류 뷰와 함께 프리뷰에 도달했고, 2025년 10월 13일 AgentCore 자체와 나란히 정식 출시되며 아홉 개 리전에 걸쳐 Built-in Tools, Gateway, Memory, Identity까지 커버를 넓혔다. AgentCore Runtime은 자체 내장 스팬(모델 호출, 도구 호출, 세션 경계)을 아무 추가 작업 없이 내보낸다. 이 시리즈가 3부부터 써온 Runtime 위에서 에이전트가 도는 순간 그 부분은 자동이다. 자동이 아닌 것이 이번 편이 실제로 신경 쓰는 계층이다. 에이전트 자신의 추론 체크포인트, 즉 시스템 프롬프트 규칙(결론보다 증거 먼저, 불확실성은 명시적으로)이 지켜졌거나 조용히 지켜지지 않은 순간들이다.

틀린 진단을 잡는 데 유용한 트레이스에는 내장 익스포트가 공짜로 쥐여주지 않는 세 가지가 필요하다. 이름과 소요 시간만이 아닌 모든 도구 호출의 실제 인자와 반환값, 에이전트의 최종 요약이 자신의 도구 호출이 반환한 것에서 벗어난다면 그 지점을 표시하는 스팬, 그리고 그 모든 것을 실시간 관찰만이 아니라 사후에 쿼리할 방법이다. 앞의 둘은 에이전트 자신의 코드가 이 플랫폼이 통제하는 네임스페이스로 커스텀 OTEL 스팬과 메트릭을 내보내야 한다는 뜻이고, 그러려면 에이전트 런타임의 실행 역할에 3부의 원래 역할이 부여한 적 없는 권한, X-Ray 쓰기 접근과 범위가 지정된 cloudwatch:PutMetricData가 필요하다. 3부의 역할을 제자리에서 수정하는 대신, 이번 편은 이름으로 같은 역할에 두 번째의 좁은 정책을 붙인다:

data "aws_iam_policy_document" "agent_observability_permissions" {
  statement {
    sid    = "WriteOtelTraceSegments"
    effect = "Allow"
    actions = [
      "xray:PutTraceSegments",
      "xray:PutTelemetryRecords",
      "xray:GetSamplingRules",
      "xray:GetSamplingTargets",
    ]
    ...
    resources = ["*"]
  }

  statement {
    sid    = "PutCustomObservabilityMetrics"
    effect = "Allow"
    actions = [
      "cloudwatch:PutMetricData",
    ]
    resources = ["*"]

    ...
    condition {
      test     = "StringEquals"
      variable = "cloudwatch:namespace"
      values   = [var.observability_cloudwatch_namespace]
    }
  }
}

resource "aws_iam_role_policy" "agent_observability" {
  for_each = var.agents

  name   = "${each.key}-agent-observability-permissions"
  role   = each.value.runtime_role_name
  policy = data.aws_iam_policy_document.agent_observability_permissions.json
}

짚어둘 것이 둘 있다. 첫째, X-Ray의 쓰기 API는 리소스 레벨 ARN을 받지 않는다. AWS 자신의 관리형 AWSXRayDaemonWriteAccess 정책이 쓰는 것과 같은 형태이므로, 여기의 resources = ["*"]는 지름길이 아니라 API 표면이 제공하는 유일한 선택지다. 둘째, cloudwatch:PutMetricData도 동일한 제약, 범위를 지정할 리소스가 없다는 제약을 갖고 있어서, 남은 유일한 실질적 최소 권한 레버는 cloudwatch:namespace 조건이다. 모든 에이전트의 커스텀 메트릭은 계정의 모든 네임스페이스에 대한 포괄 부여가 아니라 이 플랫폼이 소유한 하나의 네임스페이스에 내려앉는다. 그 두 번째 정책은 이 모듈이 역할을 소유해서가 아니라 문자열 변수인 역할 이름으로 붙는다. 4부ops_readonly_role_name에 썼던 것과 같은 크로스 모듈 경계이며, 그래서 옵저버빌리티 계층은 어느 모듈도 상대의 테라폼 상태 사본을 가질 필요 없이 3부가 만든 역할을 확장할 수 있다.

Bedrock 호출 로깅: 감사에 S3 속 날 프롬프트가 중요한 이유

Bedrock 모델 호출 로깅은 2023년 9월 Bedrock 자체의 정식 출시 이후 존재해왔다. 호출 메타데이터에 더해 모델의 전체 입력과 출력을 CloudWatch Logs로, S3로, 또는 둘 다로 포착하며, 리전별로 계정당 한 번 설정한다. 트레이스는 에이전트가 logs_read를 호출해 340밀리초 만에 결과를 받았다고 알려준다. 그것만으로는, 6주 뒤 감사인에게 누군가 지금 캐묻고 있는 특정 호출에 대해 모델이 정확히 어떤 텍스트를 봤고 정확히 어떤 텍스트를 반환했는지 단어 그대로 알려주지 못한다. 그것이 호출 로깅의 존재 이유이며, 많은 팀이 건너뛰는 조각이다. 트레이스만으로도 이미 충분한 가시성처럼 느껴지기 때문인데, 감사나 인시던트 리뷰가 요약본이 아닌 문자 그대로의 프롬프트를 요구하는 순간까지만 그렇다.

이 플랫폼은 두 목적지 모두에 쓴다. 진행 중인 인시던트 리뷰 동안의 준실시간 Logs Insights 쿼리를 위한 CloudWatch Logs와, 내구성 있고 버전 관리되며 수명 주기가 관리되는 감사 사본을 위한 S3다. S3 쪽에는 정확히 이 계정 자신의 Bedrock 트래픽으로 범위가 지정된 버킷 정책이 필요하다. 그러지 않으면 어느 계정의 호출 로깅 설정이든 원칙적으로는 이름만 짐작한 버킷을 가리킬 수 있다:

    condition {
      test     = "StringEquals"
      variable = "aws:SourceAccount"
      values   = [local.account_id]
    }

    condition {
      test     = "ArnLike"
      variable = "aws:SourceArn"
      values   = ["arn:aws:bedrock:${var.aws_region}:${local.account_id}:*"]
    }

어느 한쪽이 아니라 두 조건이 함께 있는 것이 AWS가 이 버킷 정책 형태에 대해 문서화한 것이며, 3부의 런타임 실행 역할 신뢰 정책과 이번 편의 Bedrock 로깅 IAM 역할 신뢰 정책이 둘 다 쓰는 것과 같은 혼동된 대리인(confused-deputy) 패턴이다. 객체는 기본값으로 90일 뒤 Glacier Instant Retrieval로 전환된다. 감사 추적이 삭제되어서가 아니라, 분기 감사에는 밀리초 검색이 필요 없고, 커지기만 하는 버킷에 Standard 티어 가격을 영원히 낼 이유도 없기 때문이다.

평가 하네스: 골든 인시던트 세트와 이빨 있는 심판

Amazon Bedrock Model Evaluation은 2024년 12월 프리뷰를 거쳐 2025년 3월 20일 정식 출시에서 LLM-as-a-judge 채점을 더했고, 전체 사람 평가 대비 최대 98%의 비용 절감을 보고한다. 실재하는 선택지이지만 이 특정 임무에는 맞지 않는다. S3의 데이터셋에 대한 비동기 배치 작업으로 돌며, 모델이나 큰 프롬프트 코퍼스를 채점하기 위해 만들어졌지, CI 파이프라인이 몇 분 동안 풀 리퀘스트를 막는 일곱 케이스짜리 통과/실패 게이트용이 아니다. 동반 저장소의 evals/run_evals.py는 대신 작고 목적에 맞게 지은 심판을 구현한다. 골든 세트 케이스마다 배포된 트리아지 런타임을 한 번 호출한 다음, 다섯 개의 강한 불리언 제약과 0에서 100 사이 품질 점수로 이루어진 루브릭에 대해 Converse API 호출로 응답을 채점한다.

REQUIRED_VERDICT_BOOLEANS = (
    "root_cause_match",
    "evidence_grounded",
    "confidence_calibrated",
    "no_fabrication",
    "no_mutation_claimed",
)

케이스는 그 다섯이 전부 참이면서 점수가 임계값을 넘을 때만 통과한다. 의도적으로 OR가 아니라 AND다. 진단은 확신에 찬 잘 정리된 산문(높은 점수)으로 읽히면서도, 에이전트의 도구가 결코 뒷받침할 수 없었을 주장을 지어내는 것 같은 강한 제약을 여전히 어길 수 있고, 그 실패는 글이 얼마나 좋게 들리든 케이스를 가라앉혀야 한다. golden_set.json은 일곱 케이스를 싣는다. 깨끗한 단일 신호 진단, 에이전트가 해소하는 대신 순위를 매겨야 하는 진짜로 모호한 케이스, 추측 대신 명시적인 "증거 불충분"을 내놓아야 하는 빈 증거 케이스, 시끄러운 알람 오탐, 그리고 전혀 가상이 아닌 케이스 하나다:

    {
      "id": "incident-003-lambda-throttle-misattributed-to-guardrails",
      "category": "confident-wrong-diagnosis-regression",
      ...
      "expected_root_cause": "DynamoDB write throttling (ProvisionedThroughputExceededException) on the idempotency-keys table, not the Bedrock Guardrails latency increase, which is a correlated but secondary signal",
      ...
      "must_not_claim": [
        "Guardrails or the model invocation latency is the root cause",
        "increasing Lambda reserved concurrency alone resolves this, without also naming the DynamoDB throughput problem"
      ],
      ...
    },

그 케이스는 화이트보드에서 나온 것이 아니다. 인시던트에서 나왔다.

거짓말한 날

알람은 평범했다. order-processor-errors, order-processor 함수의 AWS/Lambda Errors가 10분 동안 호출의 40%까지 올라갔다. 트리아지 에이전트는 늘 하던 대로 했다. cloudwatch_read를 호출하고, logs_read를 호출하고, 요약을 들고 돌아왔다. 그 발견을 압축하면 이렇다. order-processor의 가드레일이 붙은 추론 프로파일에서 Bedrock 호출 지연 시간이 같은 시간대에 p99 기준 900ms에서 2100ms로 올랐고, 에이전트는 그 지연 시간 증가를 유력한 근본 원인으로 지목하며 런북 에이전트가 Guardrails 설정을 느슨하게 하거나 다시 튜닝하는 것을 살펴보라고 권고했다.

틀렸고, 잡아내기 가장 어려운 특정한 방식으로 틀렸다. 두 신호는 정말로 함께 움직였다. Guardrails 지연 시간은 실제로 올랐다. 상관관계는 실재했다. 에이전트 자신의 logs_read 호출이 함께 반환한 것, 요약이 근거로 삼았다는 바로 그 도구 결과 안에 놓여 있던 것은, 실패한 각 호출 직전마다 idempotency-keys DynamoDB 테이블에 대한 ProvisionedThroughputExceededException 항목의 연속이었다. 실제 원인의 증거는 이미 트랜스크립트 안에 있었다. 요약이 그것에 무게를 두지 않았을 뿐이고, 실제로 진단적이었던 로그 라인 대신 더 최근에 논의되고 더 눈에 띄는 상관관계(Guardrails는 겨우 두 편 전에 출시됐다)로 손을 뻗었다.

실시간으로는 아무도 잡지 못했다. 구축의 그 시점에 이 플랫폼에는 에이전트가 무엇을 호출했는지의 트레이스는 있었지만, 에이전트 자신의 도구 결과에 무엇이 담겼는지 대 요약이 무엇을 주장했는지의 트레이스는 없었기 때문이다. 놓친 것은 포스트모템, 사람 쪽 포스트모템에서 드러났다. 한 엔지니어가 같은 Logs Insights 쿼리를 손으로 다시 돌려 DynamoDB 오류가 바로 거기 앉아 있는 것을 봤을 때다. 인시던트만이 아니라 에이전트에 대해 같은 리뷰를 돌리는 것이 이 절의 요점이다. 트레이스는 정확히 어느 도구 호출이 결격 증거를 반환했는지, 최종 요약의 정확히 어느 문장이 그것을 언급하지 못했는지 보여줬다. "에이전트가 틀렸다"와 "에이전트가 본 것과 말한 것 사이의 정밀한 간극이 여기 있다"의 차이이며, 행동할 가치가 있는 것은 후자뿐이다. 고침은 더 똑똑한 모델이 아니었다. 이 인시던트 자신의 트랜스크립트에서 그대로 골든 세트에 추가된 incident-003이었고, 그래서 같은 실패를 다시 들여오는 프롬프트 변경, 도구 스키마 수정, 모델 교체는 이제 프로덕션에 다시 닿기 전에 CI에서 실패한다.

시리즈 회고: 1부에 대해 채점하기

1부는 "완료의 모습"이라는 이름 아래 여섯 개의 숫자로 끝났다. 거기로 돌아가지 않는 피날레는 그냥 데모다. 플랫폼이 실제로 도착한 곳, 가장 좋게 읽히는 버전이 아니라 정직한 버전은 이렇다:

  • 페이지에서 보강된 Slack 요약까지의 중앙값 시간: 원래의 25~35분에서 약 6분으로. 실제 진전이지만 5분 미만 목표에는 못 미친다. 남은 간극은 대부분 가장 큰 런북 코퍼스 항목에 대한 런북 에이전트의 Knowledge Base 검색 지연 시간으로, 아키텍처보다는 7부의 사이징 문제다.
  • 기록된 사람 승인 없는 변경 액션 0건: 정확히 달성, 예외 0건, 매주 CloudTrail로 검증. 규율이 아니라 IAM이 강제하는 유일한 기준이며, 단서 없이 지켜진 유일한 기준이기도 하다.
  • 월간 FinOps 지연을 3주에서 당일로: 달성. 일일 비용 스윕에 이번 편의 AWS Budgets와 Cost Anomaly Detection, 에이전트별 비용 할당 태그를 더한 것이 실제로 이것을 닫는다. 옛 월간 리뷰보다 며칠 앞서는 예측 임계값 알림이 요점의 전부다.
  • 런북 부패율을 약 3분의 1에서 20분의 1 미만으로: 약 12분의 1로 개선, 완전히 도달하지는 못했다. 드리프트를 지속적으로 잡는 것이 연례 감사를 이기지만, "지속적으로"는 여전히 누군가 플래그된 런북을 리뷰한다는 뜻이고, 그 리뷰 큐는 아직 탐지만큼 빠르지 않다.
  • 분기별 게임 데이, 운영 도구 완전 비활성화: 한 번 실행, 성공. 페이징과 수동 런북 실행이 플랫폼이 존재하기 전과 정확히 같이 동작했고, 숨은 의존성은 발견되지 않았다. 데이터 포인트 하나이지 아직 실적은 아니다.
  • 온콜이 손으로 재검증을 멈출 만큼 자주 근본 원인과 일치하는 인시던트 트리아지 진단: 이번 편이 수치 임계값을 정의하기로 했던 기준이고, 이제 가진다. 강한 제약 다섯 개에 80점 이상, 골든 세트에 대해 평가. 첫 측정에서 일곱 케이스 중 여섯이 통과했다. 일곱 번째가 incident-003이었고, 그것이 정확히 요점이다. 임계값의 임무는 깨끗한 숫자를 보고하는 것이 아니라 아직 깨끗하지 않은 케이스를 잡는 것이다.

다르게 했을 것들

평가 하네스는 8부가 아니라 3부에서 시작해 지어라. 첫 에이전트가 출시된 날부터, 아무리 얇아도 케이스 세 개짜리 골든 세트가 있었다면 나중에 incident-003이 된 진단 품질 회귀를 실제 인시던트에 닿은 뒤가 아니라 닿기 전에 잡았을 것이다. 둘째, 커스텀 OTEL 스팬은 몇 달을 그것 없이 돌던 역할에 8부의 IAM 부여로 덧대는 것이 아니라 첫날부터 에이전트 자신의 코드에 속한다. "에이전트가 도구를 호출했다"와 "에이전트의 요약이 도구가 반환한 것과 일치했다" 사이의 간극이 정확히 여기서 중요했던 간극이고, 첫 배포부터 보였어야 했다. 셋째, 비동기 홉의 데드레터 큐는 이번 편이 온전히 소유한 하나의 비동기 홉에 도입하는 패턴이 아니라, Step Functions 승인 게이트와 나란히 배선되는 4부의 관심사여야 했다. 어느 것도 큰 후회는 아니다. 짓기에 말이 되는 순서로 플랫폼을 지은 평범한 비용이며, 그 순서가 모든 실패를 가장 일찍 잡았을 순서와 항상 같지는 않다.

다음으로 읽을 글

위 스니펫에서 잘라낸 나머지 골든 세트 케이스 여섯 개와 알림 Lambda를 포함한 전체 terraform/40-observability/ 모듈과 evals/는 동반 저장소인 github.com/flightlesstux/agents-on-call에 있다. 이런 플랫폼을 대규모로 운영할 때의 데이터베이스와 인프라 쪽 이야기는 ercan.cloud에, 허브는 ercanermis.com에 있다.