
스스로 버그를 수정하고 자신의 작업 과정을 지켜보는 4개의 AI 에이전트를 구축했습니다
요약
SigNoz 해커톤을 위해 구축된 AgentOps는 버그를 탐지, 진단, 수정, 검증하는 4개의 자율 에이전트 시스템입니다. 에이전트의 추론 과정과 도구 호출을 SigNoz의 스팬(span)으로 기록하여 관측성을 확보한 것이 핵심입니다.
핵심 포인트
- 모니터링, 진단, 수정, 검증의 4단계 자율 루프 구성
- 에이전트의 사고 과정을 SigNoz 스팬으로 시각화하여 관측성 제공
- LLM이 소스 코드를 미리 읽지 않고 에러 로그와 트레이스만으로 원인 파악
- 샌드박스 환경에서 패치를 적용하고 로컬 검증 후 서비스 재시작
우리는 소프트웨어를 관찰하는 데는 능숙해졌습니다. 하지만 점점 더 많은 소프트웨어를 작성하고 운영하고 있는 _에이전트 (agents)_를 관찰하는 데는 아직 능숙하지 않습니다.
서비스가 500 에러를 발생시키면, 트레이스 (trace)를 열어 정확히 어떤 스팬 (span)이 실패했는지 확인할 수 있습니다. AI 에이전트가 무언가 잘못했을 때, 여러분은 보통... 실패했다는 로그 한 줄을 받게 됩니다. 어쩌면 스택 트레이스 (stack trace)가 있을 수도 있겠죠. 하지만 그 에이전트의 추론 과정, 수행한 도구 호출 (tool calls), 비용, 또는 — 가장 중요한 — _그 실패가 과연 에이전트의 잘못이었는지 여부_를 알 수 있는 경우는 드뭅니다.
그래서 SigNoz 해커톤을 위해 저는 AgentOps를 구축했습니다. 이는 인간의 개입 없이 (no human in the loop) 실제 애플리케이션에서 실제 버그를 탐지, 진단, 수정 및 검증하는 4개의 자율 에이전트입니다. 셀프 힐링 (self-healing) 부분은 데모일 뿐입니다. 실제 프로젝트의 핵심은 이 에이전트들이 하는 모든 생각이 SigNoz의 스팬 (span)이 된다는 점입니다.
설정 (The setup)
저는 일부러 버그를 일으키기 위한 저만의 장난감 앱을 스캐폴딩 (scaffold)하지 않았습니다. 직접 작성한 코드에 심어놓은 버그를 고치는 것은 설득력이 떨어지기 때문입니다.
대신 저는 Andrew Knight가 만든 MIT 라이선스 버그 트래커인 **BuggyBoard**를 가져왔습니다 — Express, TypeScript, SQLite로 구성되어 있습니다. 이 프로젝트 이전의 커밋 히스토리를 가진 외부 코드입니다. 그런 다음 'not-found' 가드 (guard)에 정확히 하나의 결함을 심었습니다:
const bug = getBug(id);
- if (!bug) {
+ if (!bug.id) {
...
이제 GET /api/bugs/999999는 null의 속성을 참조하여 TypeError를 발생시키고, Express는 404를 반환해야 할 곳에서 500을 반환합니다. 지루하지만 매우 현실적인 버그입니다.
여기에는 멋진 대칭성이 있습니다. 에이전트들이 버그 트래커의 버그를 수정한다는 점 말이죠.
루프 (The loop)
모니터링 (Monitor) → 진단 (Diagnosis) → 수정 (Fix) → 검증 (Verify)
**모니터링 (Monitor)**는 4초마다 SigNoz를 폴링 (polling)하며 "지난 확인 이후 demo-api로부터 발생한 에러 스팬 (error spans)이 있는가?"라고 묻습니다. 이 에이전트는 애플리케이션에 대한 특권적인 백채널 (privileged backchannel)이 없습니다. 온콜 (on-call) 엔지니어가 하는 것과 동일한 방식, 즉 관측성 (observability) 백엔드를 쿼리함으로써 문제를 찾아냅니다. LLM 호출은 전혀 하지 않으며, 임계값은 결정론적 (deterministic)입니다. 이 에이전트의 에이전트다움은 영리함이 아니라 자율적인 루프 (autonomous loop)에 있습니다.
Diagnosis는 SigNoz에서 ERROR 로그와 전체 트레이스 (trace)를 가져와, 오직 그 증거만을 LLM에 전달하고 파일, 라인, 원인에 대한 엄격한 JSON 응답을 요구합니다. 이 에이전트는 src/index.ts:87이라고 답변합니다. 이 에이전트는 소스 코드를 읽지 않으며, 정답지(answer key)는 당연히 읽지 않습니다.
Fix는 샌드박스화된 파일 시스템 MCP 서버를 통해 의심되는 파일을 읽고, 최소한의 패치 (patch)를 위해 한 번의 LLM 호출을 수행하며, 이를 로컬에서 검증하고, 작성한 뒤, 서비스를 재시작합니다. 만약 패치된 서비스가 실행되지 않으면 원래의 바이트 (bytes)로 롤백 (rollback)합니다.
Verify는 실패한 요청을 정확히 재현하여 정확히 404 응답을 요구한 다음, SigNoz를 다시 쿼리하여 새로운 에러가 없는지 확인합니다.
총 소요 시간: 사람의 개입 없이 30~60초.
📸 [IMG:1] 스크린샷: 파이프라인 중간 단계에 있는 4개의 에이전트가 포함된 mission-control
실제로 중요한 부분: 에이전트가 도구로서 SigNoz를 쿼리함
이것은 제가 가장 강력하게 옹호할 설계 결정입니다.
SigNoz는 이 에이전트들이 보고 (report) 하는 곳이 아니라, 이들이 인지 (perceive) 하는 곳입니다. SigNoz MCP 서버 (Foundry가 SigNoz와 함께 한 번에 설치함)를 통해 에이전트들은 실제 도구들을 호출합니다:
signoz_search_traces— Monitor의 탐지 루프(detection loop) 및 Verify의 수정 후 확인 작업signoz_search_logs— Diagnosis의 주요 증거 (스택 트레이스 (stack trace)가 로그 본문에 존재하기 때문)signoz_get_trace_details— 실패한 요청에 대한 전체 스팬 트리 (span tree)
그리고 저는 이 모든 호출을 하나의 스팬 (span)으로 감쌌기 때문에, 에이전트들의 SigNoz 사용 자체가 SigNoz 내에서 관찰 가능 (observable) 합니다. 각 도구 호출에 시간이 얼마나 걸렸는지, 그리고 성공했는지 여부를 확인할 수 있습니다.
이러한 재귀성 (recursion)이 이 프로젝트의 핵심 논지입니다.
하나의 인시던트 (incident) = 하나의 트레이스 (trace)
두 개의 OTel 서비스가 존재합니다: demo-api (애플리케이션)와 sdlc-agents (스웜 (swarm)). 단일 인시던트는 약 14개의 스팬으로 구성된 하나의 연속된 트레이스를 생성합니다:
agent.orchestrator.incident ← 루트 (root): incident.id, 단계별 이벤트, 스팬 링크
├── agent.diagnosis.run
│ ├── gen_ai.tool.signoz_search_logs
...
📸 [IMG:2] 스크린샷: 하나의 인시던트에 대한 SigNoz 트레이스 워터폴 (trace waterfall)
📸 [IMG:2] 스크린샷: 하나의 인시던트에 대한 SigNoz 트레이스 워터폴 (trace waterfall)
제가 초기에 확립하고 절대 깨지 않은 몇 가지 규칙들이 있습니다:
에이전트 단계(Agent steps)는 agent.<name>.<step> 형식을 따릅니다. MCP 도구 호출은 항상 해당 단계를 호출한 자식 요소로 gen_ai.tool.<실제 도구 이름> 형식입니다. gen_ai.tool.* 네임스페이스는 오직 MCP 호출만을 위해 엄격하게 사용됩니다. Fix 에이전트가 프로세스를 재시작하거나 Verify 에이전트가 HTTP 요청을 수행하는 경우, 이는 단계(steps) — 즉 agent.fix.restart, agent.verify.replay와 같은 형태입니다 — 로 간주되며 도구 호출로 위장되지 않습니다. 이 경계를 명확하게 유지하는 것이 트레이스를 한눈에 읽기 쉽게 만듭니다.
모든 LLM 호출에는 OTel GenAI 시맨틱 컨벤션에 따라 gen_ai.request.model, gen_ai.usage.input_tokens, 그리고 gen_ai.usage.output_tokens가 포함됩니다. 이를 통해 에이전트별 토큰 사용량을 쉽게 파악할 수 있습니다.
incident.id는 함수 매개변수가 아닌 OTel 컨텍스트(Context)를 통해 전파됩니다. 이 부분이 큰 도움이 되었습니다. ID를 모든 에이전트 시그니처에 직접 전달하려면 모든 함수를 수정해야 했을 것이고, 호출자가 이를 전달하는 것을 잊은 스팬(span)은 조용히 누락되었을 것입니다. 대신 두 개의 스팬 시작 래퍼 내부에서 읽는 하나의 컨텍스트 키가 제가 전혀 수정한 적 없는 코드까지 포함하여 모든 14개 스팬에 ID를 부여합니다. 만약 Java에서 요청 ID를 위해 ThreadLocal을 사용했다면, 이는 정확히 그것과 같지만 AsyncLocalStorage는 await를 거쳐도 이를 올바르게 유지해 줍니다.
루트 스팬(root span)은 인시던트를 감지한 Monitor의 폴링(poll)에 대한 스팬 링크(span link)를 포함합니다. 이는
| 이유 | 의미 |
|---|---|
llm_error | 모델이 실패했거나 쓰레기(junk) 값을 반환함, 또는 쓰기 전 가드(pre-write guard)가 패치를 거부함 |
| ... |
이것이 작동하게 만드는 규칙은 다음과 같습니다: 죽어버린 텔레메트리 파이프라인(telemetry pipeline)을 에이전트의 추론(reasoning)이 잘못된 것으로 보고해서는 안 됩니다. 이 둘은 완전히 다른 문제이며 해결 방법도 완전히 다릅니다. 이들을
에이전트 플릿 상태(Agent Fleet Health) — 에이전트별 호출 횟수, p50/p95 지연 시간, 에이전트당 LLM 토큰 사용량, MCP 도구 호출 내역, 실패율 및 실패 사유별 분석을 제공합니다.**
데모 API 상태(Demo API Health) — 요청 볼륨, 5xx 오류 카운트, 그리고 트리거 시 눈에 띄게 빨간색이었다가 수정 후 초록색으로 변하는 실패한 경로의 상태 코드별 내역을 보여줍니다.
여기에 에이전트 실패율에 대한 임계값 경고(threshold alert)까지 추가했습니다.
📸 스크린샷: 에이전트 플릿 상태 대시보드
가장 알아차리기 어려운 부분은 각 패널이 어떤 데이터 소스를 필요로 하는지였습니다. SigNoz의 스팬(span) 메트릭은 내부 스팬을 모두 포함하는데, 스팬 이름이 operation 레이블로 표시되는 것을 예상하지 못했습니다. 하지만 이들은 오직 service.name, operation, span.kind, 그리고 status.code만 담고 있습니다. 제가 공들여 추가했던 모든 사용자 정의 속성(예: 토큰 카운트, gen_ai.agent.name, incident.id, 실패 사유, 심지어 http.route)은 스팬에만 존재했습니다.
그래서 패널별로 분리해야 했습니다. 지연 시간과 호출 횟수는 메트릭(metrics)에서 가져오고 (저렴하고, 사전 집계됨), 사용자 정의 차원(custom dimension)이 필요한 것은 트레이스(traces)를 조회해야 합니다.
일반적인 교훈은 이렇습니다: 사전 집계된 메트릭은 사전에 선언된 차원만 담고 있습니다. 사용자 정의 스팬 속성이 메트릭 레이블로 쿼리 가능하다고 가정하면, 오류가 발생하는 대신 빈 패널이 나타나게 됩니다. 이는 더 나쁜데, 왜냐하면 비어 있다는 것은
또한 "API가 200을 반환했다"는 사실은 아무것도 증명하지 못하기 때문에, 모든 패널은 패널 자체의 쿼리를 실행하는 커밋된 스크립트에 의해 검증되며, 빈 데이터나 모두 0인 데이터를 반환하는 패널은 실패 처리됩니다. 저는 최근에 발생한 인시던트(incident)만을 포함하는 좁은 15분 시간 범위에 대해 이를 실행하므로, 패널들은 누적된 이력이 아닌 실시간 데이터로부터 채워져야만 합니다. 16/16.
이것이 일반화되는가, 아니면 단순히 내 버그에 대해 특수 사례를 만든 것인가?
명백한 도전 과제입니다. 그래서 테스트를 해보았습니다.
저는 시스템이 한 번도 본 적 없는 완전히 다른 결함을 주입했습니다. 다른 경로(GET /api/bugs, 목록 엔드포인트), 다른 오류 클래스(null 참조 대신 존재하지 않는 메서드 호출), 그리고 다른 라인에서 발생하도록 했습니다:
const bugs = listBugs();
- res.json(bugs);
+ res.json(bugs.sortByPriority());
코드 변경 없음. 프롬프트(prompt) 변경 없음. 저는 그저 시스템을 망가뜨리고 지켜보았습니다.
모니터(Monitor)가 이를 감지했습니다. 진단(Diagnosis)은 원인이 _"bugs.sortByPriority()를 호출하려고 시도했으나, bugs에 sortByPriority라는 함수가 정의되어 있지 않음"_이라고 **src/index.ts:77**을 보고했습니다 — 정확했습니다. 수정(Fix)이 이를 복구했습니다. 검증(Verify)이 이를 확인했습니다. 인시던트가 해결(resolved) 상태로 종료되었습니다.
해당 버그의 어떤 부분도 코드 어디에서도 예상되지 않았습니다.
가드레일 (Guardrails)
소스 코드를 다시 작성하고 라이브 서비스를 재시작하는 에이전트에게는 엄격한 제한이 필요합니다.
최소 권한 (Least privilege). 수정(Fix) 에이전트는 백엔드 디렉토리에 루트(root)를 둔 파일 시스템 MCP 서버를 통해서만 코드베이스에 접근할 수 있습니다. 이 서버는 그 외부의 경로는 해석할 수 없습니다. 커밋된 테스트를 통해 상대 경로 탐색, 절대 경로, 디렉토리 목록 보기, 그리고 검색이 모두 거부됨을 확인했습니다.
제가 거의 실수할 뻔한 세부 사항이 하나 있습니다: 파일 시스템 MCP 서버는 클라이언트가 roots 기능을 광고할 경우, 자신의 허용 목록(allow-list)을 클라이언트가 제공한 루트로 교체합니다. 제 클라이언트는 의도적으로 아무런 기능도 선언하지 않았습니다. 서버 측의 제한은 그것을 다시 작성할 수 있는 협상(negotiation)만큼만 강력합니다.
사전 쓰기 검증 (Pre-write validation). 패치는 정확히 한 번 일치해야 하며, 실제로 무언가를 변경해야 합니다. 또한 각 측면당 300자 및 200바이트의 순수 델타 (net delta)로 제한됩니다. 위반 사항이 발생하면 쓰기 작업이 시작되기 전에 중단됩니다.
자동 롤백 (Automatic rollback). 쓰기 전에 원본 바이트를 캡처합니다. 패치된 서비스가 상태 확인 (health check)에 실패하면 복구됩니다. 추측이 아닌 기계적인 복구입니다.
시간을 잡아먹은 것들
ListModels에 있는 모델이 호출 가능한 모델은 아닙니다. gemini-2.5-flash가 목록에 나타났지만, 실제 호출 시에는 404 "새 사용자에게 더 이상 제공되지 않음"을 반환했습니다. 목록 엔드포인트 (listing endpoint)는 권한 확인 (entitlement check)이 아닙니다. 자신의 자격 증명 (credentials)을 통해 확인하십시오.
무료 티어 할당량 (Free-tier quota)은 모델별, 일별로 적용됩니다. 모델당 하루 20회의 요청이 가능하며, 각 사고(incident)마다 2회씩 소모됩니다. 하루 동안의 리허설로 모델 하나를 다 써버렸습니다. 할당량이 모델별로 적용되기 때문에, ID를 전환하면 다시 20회를 확보할 수 있었습니다. 그래서 모델은 환경 변수 (environment variable)가 되었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기