Notification
새로운 알림이 없습니다.

[Observability] Datadog Alert 분석 SRE OpsAgent 구축(2)

Datadog, SRE, OpsAgent
Datadog Logo
1편에서 "이런 구조로 만들었다"까지는 얘기했는데, 막상 코드를 짜다 보면 구조보다 디테일에서 삽질이 많다. 알람 데이터를 LLM에 어떻게 넘길지, 프롬프트를 어떻게 짜야 도구를 제대로 쓰는지, MCP 클라이언트는 왜 직접 만들어야 했는지. 이번 글에서는 그 과정을 정리한다.
1. 알람 사전 파싱 — LLM에게 날것을 주지 않는다
문제: raw JSON을 그대로 넘기면
Datadog 웹훅이 보내는 페이로드는 이렇게 생겼다.
{ "alert_title": "checkout-service error rate 급증", "alert_type": "error", "metric": "trace.http.request.errors", "tags": "service:checkout-service,env:prod,team:commerce", "timestamp": "2026-06-04T00:15:00Z", "link": "https://app.datadoghq.com/monitors/12345" }
LLM에 그대로 넘기면 어떻게 될까?
LLM이 먼저 JSON 구조를 파악하는 데 턴을 쓴다. "tags 필드에서 service 값을 추출해야 하고, timestamp를 KST로 변환해야 하고..." 같은 작업을 LLM이 스스로 하게 되면 도구 호출까지 가는 데 1~2턴이 낭비된다. 에이전트의 턴 예산이 5회인데, 파싱에 2턴을 쓰면 실제 분석에 쓸 수 있는 턴이 3회밖에 안 남는다.
해결: 코드에서 미리 파싱한다
_parse_alert 함수가 이 역할을 한다.
def _parse_alert(payload: dict) -> dict: raw_tags = payload.get("tags") or [] if isinstance(raw_tags, str): raw_tags = [t.strip() for t in raw_tags.split(",")] service = _extract_tag(tags, "service") # "checkout-service" env = _extract_tag(tags, "env") # "prod" # 시간대 변환: UTC → KST, 조회 윈도우 계산 alert_dt = _parse_timestamp(raw_ts) kst_dt = alert_dt.astimezone(KST) window_start = kst_dt - timedelta(minutes=15)
tags 문자열을 파싱해서 서비스명과 환경을 추출하고, UTC 타임스탬프를 KST로 변환하고, 알람 발생 15분 전부터의 조회 윈도우를 계산한다.
_format_alert_context가 결과를 LLM이 바로 쓸 수 있는 텍스트로 변환한다.
[알람 정보] 제목: checkout-service error rate 급증 서비스: checkout-service (prod) 메트릭: trace.http.request.errors (현재 12.4% / 임계값 5%) 발생 시각: 2026-06-04 09:15 KST 조회 권장 시간대: 09:00~09:15 (from="-15m")
from="-15m" 같은 값을 미리 계산해서 넣어두는 게 핵심이다. LLM이 도구 호출할 때 시간 범위를 고민할 필요 없이 그대로 쓰면 된다.
Before / After 비교
코드 한 단계를 추가함으로써 에이전트의 행동이 확연히 달라진다.
항목 Before (raw JSON) After (사전 파싱)
첫 턴 JSON 파싱 + 필드 확인 바로 도구 호출
시간 범위 LLM이 직접 계산 (오류 가능) 코드가 정확히 계산
소비 턴 3~5턴 1~2턴
시간대 UTC/KST 혼동 가능 KST로 통일
LLM이 잘하는 일(판단, 분석)은 LLM에게 맡기고, 코드가 잘하는 일(파싱, 계산)은 코드가 처리한다. 이런 분업이 에이전트 성능을 좌우한다.
2. 프롬프트 엔지니어링 — 추상적 지시보다 구체적 예시
프롬프트 구조
에이전트의 시스템 프롬프트(SRE_INSTRUCTIONS_TEMPLATE)는 네 블록으로 구성된다.
[역할 정의] → "너는 MTTR을 줄이는 SRE 자동화 에이전트다" [스킬 컨텍스트] → Datadog 스킬 가이드 (사전 로드) [운영 가이드라인] → 턴 예산, 병렬 호출, 시간 범위 규칙 [출력 형식] → 요약/원인 가설/조치 권고 포맷 [Few-shot 예시] → 구체적인 입출력 예시
각 블록이 하는 역할이 다르다.
역할 정의는 LLM의 페르소나를 설정한다. "MTTR을 줄이는"이라는 목표를 명시하면 LLM이 불필요한 분석을 줄이고 빠르게 결론으로 수렴하는 경향이 생긴다.
운영 가이드라인은 구체적인 제약을 건다.
1. 1턴에 필요한 도구를 병렬로 모두 호출하라. 목표는 2턴 이내 결론. 2. 도구 호출 예산: 최대 5회. 3회 이내 해결이 이상적이다. 3. 시간 범위는 [알람 정보]의 '조회 권장 시간대'를 쓰라.
"병렬로 호출하라"고 명시하지 않으면 LLM은 도구를 하나씩 순차 호출한다. 메트릭 조회하고, 결과 보고, 그다음 로그 조회하고 — 이러면 턴이 금방 소진된다. 병렬 호출을 지시하면 1턴에 메트릭+로그를 동시에 가져온다.
출력 형식은 LLM이 자유롭게 답변하지 못하게 틀을 잡아준다. "요약 -> 원인 가설 (확신도 포함) -> 조치 권고" 순서를 강제하면 매번 일관된 형태의 리포트가 나온다.
Few-shot이 LLM 행동을 바꾸는 방식
프롬프트에서 가장 큰 영향을 주는 건 Few-shot 예시다. 추상적인 지시("도구를 효율적으로 사용하라")보다 구체적인 예시("이 알람이 오면 이 도구를 이렇게 호출하고, 결과를 이렇게 정리하라")가 LLM 행동을 훨씬 정확하게 유도한다.
[예시 — 이런 식으로 동작하라] 입력: 서비스: checkout-service | 환경: prod 메트릭: trace.http.request.errors (현재 12.4% / 임계값 5%) 1턴 (병렬 호출): - get_datadog_metric(query="avg:trace.http.request.errors{...}", from="-15m") - search_datadog_logs(query="service:checkout-service status:error", from="-15m") 결과 확인 후 출력: ■ [요약] checkout-service OOM으로 5xx 급증 (🚨 위험) ■ [원인 가설] - 가설 1 (확신도: 상): Pod 메모리 부족 → OOM Kill → 근거: 09:12 memory 99.2% 도달, OOM kill 로그 3건
위 예시가 하는 일은 세 가지다.
1. 도구 호출 패턴 — 어떤 도구를 어떤 인자로 호출하는지 보여준다. LLM이 query 형식을 짐작하지 않고 예시를 따라간다.
2. 병렬 호출 — 1턴에 여러 도구를 동시에 호출하는 패턴을 시연한다. 보여주지 않으면 순차 호출로 돌아간다.
3. 출력 포맷 — 확신도, 근거 인용, 조치 권고까지 완성된 형태를 보여줘서 LLM이 동일한 구조로 응답한다.
Few-shot 예시를 넣기 전과 후의 차이는 명확했다. 예시 없이는 도구를 3~5턴에 걸쳐 하나씩 호출하고, 출력 형식도 매번 달라졌다. 예시를 넣고 나서는 거의 매번 1~2턴 안에 끝났고, 출력 구조도 일관됐다.
Skill Prefetch로 턴 절약
Datadog MCP 서버는 list_datadog_skillsload_datadog_skill이라는 도구를 제공한다. "이 알람과 관련된 Datadog 운영 가이드"를 가져오는 기능인데, LLM이 런타임에 호출하면 스킬 검색에 1턴, 스킬 로드에 1턴이 추가로 든다.
코드에서 미리 처리하면 이 비용을 없앨 수 있다.
async def _prefetch_skills(mcp, alert_title): # 알람 제목으로 관련 skill 검색 skills_result = await mcp.call_tool("list_datadog_skills", {"query": alert_title}) # 상위 2개만 로드 for name in skill_names[:2]: text = await mcp.call_tool("load_datadog_skill", {"skill_name": name}) skill_texts.append(f"[{name}]\n{text[:2000]}") return "참고할 Datadog 스킬 가이드 (사전 로드됨):\n" + "\n\n".join(skill_texts)
에이전트가 실행되기 전에 관련 스킬을 미리 로드해서 프롬프트에 넣어준다. LLM 입장에서는 이미 알고 있는 정보이므로 스킬을 조회할 필요가 없다. 턴 2회를 아낄 수 있다.
3. MCP 클라이언트 — 107줄짜리 직접 구현
MCP 프로토콜의 실체는 JSON-RPC 2.0
MCP(Model Context Protocol)는 LLM이 외부 도구를 호출하기 위한 표준 프로토콜이다. 거창해 보이지만 실제 통신은 JSON-RPC 2.0이다.
JSON-RPC의 핵심은 간단하다. HTTP POST로 JSON을 보내고, JSON으로 응답을 받는다.
// 요청 { "jsonrpc": "2.0", "method": "tools/call", "id": 3, "params": { "name": "search_datadog_logs", "arguments": {"query": "service:checkout status:error", "from": "-15m"} } } // 응답 { "jsonrpc": "2.0", "id": 3, "result": { "content": [{"type": "text", "text": "Found 12 error logs..."}] } }
REST API와 다른 점이라면, 엔드포인트가 하나이고 method 필드로 어떤 작업인지 구분한다는 것 정도다.
세션 생명주기
MCP 세션은 세 단계로 진행된다.
1단계: initialize — 세션을 시작한다.
result = await self._send("initialize", { "protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "sre-opsagent", "version": "1.0.0"}, }) await self._notify("notifications/initialized")
서버가 응답 헤더에 Mcp-Session-Id를 내려주고, 이후 모든 요청에 이 세션 ID를 포함해야 한다.
2단계: tools/list — 사용 가능한 도구 목록을 조회한다.
async def list_tools(self) -> list[dict]: resp = await self._send("tools/list") return resp.get("result", {}).get("tools", [])
Datadog MCP 서버는 여기서 25개 도구를 반환하는데, 앞서 말한 대로 6개만 필터링해서 쓴다.
3단계: tools/call — 실제 도구를 호출한다.
async def call_tool(self, name: str, arguments: dict | None = None) -> str: resp = await self._send("tools/call", { "name": name, "arguments": arguments or {}, }) content = resp.get("result", {}).get("content", []) return "\n".join(item.get("text", str(item)) for item in content)
세션이 끝나면 __aexit__에서 DELETE 요청을 보내 세션을 정리한다.
왜 직접 구현했나
OpenAI Agents SDK에 MCP 클라이언트가 내장되어 있다. 그런데 이 클라이언트는 SSE(Server-Sent Events) 기반이다. 서버와 장기 연결을 맺고 이벤트를 스트리밍으로 받는 방식인데, Datadog MCP 서버가 GET SSE를 지원하지 않는다.
SDK의 MCP 클라이언트로 Datadog에 연결하면 이런 에러가 나온다.
anyio cancel scope error
SSE 연결이 실패하면서 비동기 런타임이 취소 스코프를 정리하다가 발생하는 에러다.
그래서 SSE 없이 순수 POST 요청만으로 동작하는 클라이언트를 직접 구현했다. httpx.AsyncClient로 POST를 보내고 JSON 응답을 받는, 가장 단순한 형태다.
전체 코드가 107줄이다. __init__, _send, _notify, _initialize, list_tools, call_tool — 6개 메서드가 전부다. 외부 의존성도 httpx 하나뿐이고, anyio나 SSE 라이브러리가 필요 없다.
4. Slack 리포트 — Block Kit으로 구조화
Block Kit을 고른 이유
Slack 메시지를 보내는 방법은 크게 두 가지다. 단순 텍스트(text 필드)로 보내거나, Block Kit(blocks 필드)으로 보내거나.
단순 텍스트는 포맷팅이 제한적이다. mrkdwn이 지원되긴 하지만, 제목과 본문을 시각적으로 구분하기 어렵다. SRE 분석 리포트는 "제목 -> 분석 내용"이라는 구조가 있어서, Block Kit으로 구조를 명확히 보여주는 편이 낫다.
코드 구조
payload = { "channel": channel, "blocks": [ {"type": "section", "text": {"type": "mrkdwn", "text": header}}, {"type": "divider"}, {"type": "section", "text": {"type": "mrkdwn", "text": text[:3000]}}, ], "text": f"SRE-OpsAgent: {monitor_name}", # 알림 폴백 }
세 개의 블록으로 구성된다.
1. header section:rotating_light: SRE-OpsAgent 분석 — {모니터명} 형태의 제목. 알람이 왔을 때 Slack에서 한눈에 뭔지 파악할 수 있게 한다.
2. divider — 제목과 본문 사이의 구분선.
3. body section — LLM이 생성한 분석 결과 본문. 3000자로 잘라서 보내는데, Slack Block Kit의 text 필드 제한이 3000자이기 때문이다.
"text" 필드는 폴백용이다. Block Kit을 지원하지 않는 환경(모바일 알림, 이메일 등)에서 표시된다.
전송 흐름
async with httpx.AsyncClient(timeout=15) as client: resp = await client.post(SLACK_POST_MESSAGE_URL, json=payload, headers=headers) resp.raise_for_status() body = resp.json() if not body.get("ok"): log.error("Slack API error: %s", body.get("error", body))
Slack API는 HTTP 200을 반환하더라도 ok 필드가 false일 수 있다. 채널에 봇이 초대되지 않았거나, 토큰 스코프가 부족하거나 하는 경우다. resp.raise_for_status()body.get("ok") 두 단계로 검증하는 이유다.
타임아웃은 15초로 설정했다. Slack API가 보통 1~2초 안에 응답하므로 충분하다.
정리
네 가지 구현 포인트를 요약하면 아래와 같다.
모듈 핵심 아이디어 효과
알람 파싱 LLM 대신 코드가 JSON 파싱/변환 턴 1~2회 절약
프롬프트 Few-shot + 운영 가이드라인 일관된 도구 호출 패턴, 출력 형식
MCP 클라이언트 SSE 없이 POST only SDK 호환 문제 해결, 107줄
Slack 리포트 Block Kit 3블록 구조 구조화된 분석 리포트
에이전트의 품질은 LLM 모델 성능만으로 결정되지 않는다. 입력을 어떻게 가공하고, 프롬프트를 어떻게 설계하고, 도구를 어떻게 연결하느냐가 최종 결과에 큰 영향을 미친다.
다음 글에서는 배포(Cloud Run + Secret Manager)와 운영(모니터링, 알람 라우팅) 관련 내용을 다룰 예정이다.
Datadog Observability
Stan Cloud
Stan Cloud
An avid cloud engineering Fan
대화 참여하기
댓글 쓰기