
에이전트 궤적을 Span의 트리로 추적하기
요약
LLM 에이전트의 복잡한 실행 과정을 Span 트리 구조로 모델링하여 관측성(Observability)을 높이는 방법을 설명합니다. OpenTelemetry를 활용해 에이전트 실행을 하나의 부모 Span으로 감싸 궤적을 체계적으로 추적하고 분석하는 가이드를 제공합니다.
핵심 포인트
- 에이전트 실행을 단일 Span이 아닌 트리 구조로 모델링하여 복잡한 궤적 추적 가능
- OpenTelemetry의 invoke_agent 컨벤션을 활용한 부모 Span 설정 권장
- 트리 구조를 통해 작업당 비용, 턴 수 계산 및 실행 간 비교(diff) 용이
- 기존 LLM 호출 계측을 유지하면서 바깥쪽 루프만 감싸는 간단한 마이그레이션
- 도서: Observability for LLM Applications — Tracing, Evals, and Shipping AI You Can Trust
- 저자의 다른 저서: Agents in Production — The AI Engineer's Library (2권 시리즈)의 동반 도서
- 내 프로젝트: Hermes IDE | GitHub — Claude Code 및 기타 AI 코딩 도구를 사용하여 작업하는 개발자를 위한 IDE
- 나: xgabriel.com | GitHub
당신이 호출을 받았습니다: 분류 (triage) 에이전트가 지원 티켓에 잘못된 라벨을 작성하고 있습니다. 당신은 추적 (tracing) 백엔드를 열었지만 지난 한 시간 동안 4,000개의 실행 (run)이 있습니다. 당신은 단일 LLM 호출을 읽는 법은 알고 있습니다. gen_ai.request.model을 방출하고, 워터폴 (waterfall)에 Span이 나타나는 것을 지켜본 뒤, 위에서 아래로 읽는 방식 말입니다. 하지만 그 기술은 여기에서 확장되지 않습니다. 질문이 바뀌었습니다. 이제는 "모델이 무엇을 말했는가"가 아닙니다. "이 14단계 실행 중 에이전트가 어디에서 잘못된 결정을 내렸으며, 그것이 어제 잘못되었던 지점과 동일한 곳인가"가 질문입니다.
LLM 호출은 하나의 Span입니다. 에이전트 실행은 하나의 트리 (tree)입니다. 일단 실행을 트리로 모델링하면, 에이전트에 관한 모든 어려운 질문은 해당 트리에 대한 쿼리 (query)가 됩니다. 여기에서 이를 구축하는 방법, 각 노드 (node)가 무엇을 담아야 하는지, 그리고 실행이 잘못되었을 때 어떻게 재현 (replay)하는지에 대해 설명합니다.
루트 Span은 궤적의 경계입니다
당신이 추가할 수 있는 가장 유용한 것은 전체 실행을 감싸는 부모 Span (parent span)입니다. OpenTelemetry GenAI conventions에서는 이를 invoke_agent라고 부릅니다. 이는 작업당 한 번 실행되며, 그 아래에 있는 모든 채팅 및 도구 (tool) Span의 부모가 됩니다.
그러한 부모(parent)가 없다면, 트레이스(trace)는 채팅 Span과 도구(tool) Span의 평면적인 리스트(flat list)가 되며, 여러분은 타임스탬프(timestamp)를 통해 머릿속으로 궤적(trajectory)을 재구성해야 합니다. 이러한 재구성은 두 번의 실행이 서로 얽히는(interleave) 순간 처음으로 실패하게 됩니다. 부모가 있다면 백엔드(backend)에 경계(boundary)가 생깁니다. 여러분이 아무것도 스크립트로 작성하지 않아도, 백엔드는 궤적 뷰(trajectory view)를 렌더링하고, 작업당 비용을 합산하며, 턴(turn) 수를 세고, 한 실행을 다른 실행과 비교(diff)할 수 있습니다.
from opentelemetry import trace
tracer = trace.get_tracer("triage-agent")
...
만약 이미 개별 LLM 호출에 대해 계측(instrumentation)을 완료했다면, 이것이 마이그레이션(migration)의 전부입니다. 바깥쪽 루프를 감싸기만 하면 됩니다. 기존의 gen_ai.* 채팅 및 도구 Span은 변경 없이 그 아래에 중첩(nest)됩니다. 두 번째 컬렉터(collector)도, 재계측(re-instrumentation)도 필요 없습니다.
위의 네 가지 속성 중, 팀들이 건너뛰고 나중에 후회하는 것은 바로 version입니다. 에이전트는 조용히 퇴보(regress)합니다. 프롬프트(prompt) 수정, 화이트리스트(whitelist)에 추가된 새로운 도구, 두 개의 마이너 리비전(minor revision) 사이의 모델 교체 등은 모두 궤적 분포(trajectory distribution)를 변화시킵니다. 평가(eval) 결과가 하룻밤 사이에 나빠졌을 때, 여러분의 첫 번째 조치는 gen_ai.agent.version으로 필터링하여 어제와 오늘 사이에 에이전트가 변경되었는지 확인하는 것입니다. 만약 변경되었다면, 해결책은 버전 관리(version control)에 있습니다. 변경되지 않았다면, 퇴보는 모델 제공자, 도구 백엔드, 또는 데이터에 있는 것입니다.
각 자식 Span은 응답이 아닌 결정을 기록합니다
여기서 사고의 전환이 필요합니다. 일반적인 LLM 호출에서 채팅 Span은 모델이 말한 것, 즉 토큰(tokens), 모델 이름, 지연 시간(latency), 그리고 아마도 메시지 페이로드(message payload)를 기록합니다. 그것은 응답(response)에 대한 기록이며, 답변하는 것이 유일한 작업이었을 때는 그것으로 충분합니다.
에이전트 내부에서 모델의 출력은 사용자에게 보여지는 답변인 경우가 드뭅니다. 그것은 결정(decision)입니다. "이러한 인자(arguments)로 이 도구를 호출하라", "저 에이전트에게 넘겨라", "멈추고 최종 메시지를 내보내라"와 같은 것들 말입니다. Span에 담고 싶은 것은 응답 페이로드가 아니라 결정입니다. 그리고 결정에는 구조가 있습니다. 모델이 루프의 어느 분기(branch)를 탔는지, 어떤 도구를 선택했는지, 어떤 인자를 골랐는지, 그리고 왜 그랬는지와 같은 구조 말입니다.
with tracer.start_as_current_span(
"chat claude-sonnet-4-6"
) as chat:
...
decision을 여러분의 하네스(harness)가 소유한 작은 고정 어휘 집합인 call_tool, handoff, final_answer, reflect, stop으로 유지하세요. 일관성이 있어야 나중에 이를 기준으로 그룹화할 수 있습니다. 궤적 평가(trajectory evals)를 구축할 때, gen_ai.agent.decision을 기준으로 그룹화하고 각 결정 유형이 작업 완료로 얼마나 자주 이어지는지를 측정하여 실행 결과(runs)를 점수화합니다. 이 속성이 모든 단계(step)에 포함되어 있지 않다면 해당 쿼리는 불가능합니다.
루프가 종료될 때 루트(root)에 하나의 정수를 설정하세요: gen_ai.agent.step.count, 즉 최종 턴(turn) 횟수입니다. "에이전트가 25단계를 초과했습니다"와 같은 가드레일(guardrail) 경고는 이를 직접적으로 참조합니다. 만약 여러분의 하네스가 재귀 제한(recursion limit)으로 인해 예외를 발생시킨다면, 다시 예외를 던지기(re-raise) 전에 예외 처리기(exception handler)에서 이를 설정하여 스팬(span)이 여전히 진실된 정보를 담고 있도록 하세요.
트리를 읽으면 병리적 현상이 명확히 보입니다
여기 깔끔한 실행 결과가 있습니다. 들여쓰기는 부모-자식 중첩(parent-child nesting)을 나타내며, 숫자는 루트로부터의 실제 경과 시간(wall-clock offset)을 초 단위로 나타냅니다.
invoke_agent triage-agent 0.000 4.812s
├─ chat claude-sonnet-4-6 0.004 1.102s
│ in=842 out=96 decision: call search_kb
...
4번의 채팅 턴, 3번의 도구 호출(tool calls), 컨텍스트가 축적됨에 따라 입력 토큰이 842에서 2004로 증가합니다. 마지막 채팅 스팬은 답변을 결정하는 데 걸린 2밀리초의 결정입니다. 모델은 성찰(reflect)하지 않았고, 재확인(double-check)하지 않았으며, 루프(loop)를 돌지 않았습니다. 이것이 바로 '좋은 상태'의 모습입니다.
이제 다른 티켓에 대해 동일한 에이전트를 실행한 결과입니다:
invoke_agent triage-agent 0.000 31.204s
├─ chat claude-sonnet-4-6 0.004 1.240s
│ decision: call search_kb
...
모든 execute_tool 스팬은 녹색입니다. 오류가 발생한 도구는 없습니다. 이 실행은 결정 루프(decision loop)로 인해 실패했습니다. 모델이 계속해서 동일한 쿼리를 요청하고, 계속해서 결과가 0개인 행을 받았으며, 계속해서 다시 시도하기로 결정하면서 포기하기 전까지 30초를 허비했습니다. 단일 호출 폭포(single-call waterfall) 방식으로는 이를 절대 발견할 수 없을 것입니다. 루트 스팬이 한눈에 턴 횟수를 보여주고, 궤적 뷰(trajectory view)가 "이 실행은 search_kb를 20번 호출했습니다"를 최상위 이상 징후(anomaly)로 드러내기 때문에 발견할 수 있는 것입니다. 디버깅의 단위는 바로 궤적(trajectory)입니다.
핸드오프(Handoffs)는 자식 호출로 중첩됩니다
멀티 에이전트 시스템(Multi-agent systems)도 동일한 형태를 유지합니다. 관리자(supervisor)가 전문가(specialist)에게 핸드오프(handoff)를 수행하면, 전문가의 전체 실행 과정은 관리자의 현재 채팅 스팬(chat span) 아래에 부모로 묶인 또 다른 invoke_agent 스팬이 됩니다.
invoke_agent research-supervisor 0.000 18.440s
├─ chat claude-opus-4-6 0.004 2.110s
│ decision: handoff to web-searcher
...
비용 회계(Cost accounting)는 이러한 중첩 구조를 통해 도출됩니다. 각 invoke_agent의 자식 채팅 스팬(child chat spans)을 합산하면 에이전트별 토큰 사용량을 얻을 수 있습니다. 관리자의 컨텍스트(context)는 보통 작지만, 전문가들이 비용의 대부분을 차지합니다. 모든 채팅 스팬을 평면적(flat)으로 합산해서는 이를 확인할 수 없습니다.
여기서 흔히 발생하는 실패 사례는 멀티 에이전트 실행 시 중첩된 invoke_agent 스팬 대신 평면적인 채팅 스팬 목록을 내보내는 프레임워크를 사용하는 것입니다. 이 경우 트리가 한 줄로 붕괴되어 궤적(trajectory) 뷰를 사용할 수 없게 됩니다. 해결책은 각 핸드오프(handoff) 시점에 한 줄짜리 래퍼(wrapper)를 추가하는 것입니다. 전문가를 호출하기 전에 자식 invoke_agent를 시작하고, 호출이 끝난 후에 종료하십시오. 멀티 에이전트 트레이스(trace)가 잘못되어 보인다면 이 부분을 가장 먼저 확인하십시오.
오류를 찾기 위한 실행 재현(Replaying a run)
페이지 호출(paged)을 받으면, 매번 동일한 절차를 수행하십시오.
첫째, gen_ai.agent.version으로 필터링하십시오. 만약 지난 한 시간 내에 버전이 변경되었다면, 버전 간의 차이(diff)를 비교하십시오. 회귀(regression) 문제는 거의 확실히 그곳에 있습니다.
둘째, decision = final_answer와 실패한 결과(failing outcome)로 필터링한 다음, 잘못된 실행(run) 하나를 가져와 처음부터 끝까지 읽으십시오. 잘못된 출력을 생성한 결정이 아니라, 당신을 놀라게 하는 '첫 번째 결정'을 찾으십시오. 잘못된 출력은 보통 문제가 된 결정으로부터 서너 단계 뒤(downstream)에 위치합니다. 당신이 찾아야 할 것은 상류(upstream)에 있는 결정입니다.
셋째, 회귀가 발생하기 전의 성공적인 실행(passing run)을 가져와 나란히 놓고 비교하십시오. 열에 아홉은 차이점이 단 하나뿐입니다. 특정 도구 호출(tool call)이 이전과 다른 값을 반환하거나, 모델이 이전과는 다른 도구를 선택한 채팅 스팬(chat span)이 원인입니다.
넷째, 전체 모집단(population)에서 해당 발산(divergence) 지점을 grep으로 검색하십시오. 만약 decision.tool = classify_ticket이 이전에는 실행의 92%에서 발생했는데 현재는 74%에서만 발생한다면, 회귀 지점을 국소화(localized)한 것입니다. 그곳을 수정하십시오.
이 절차는 정확히 한 가지 경우, 즉 불완전한 트리(incomplete tree)인 경우에 실패합니다. 부모가 없는 채팅 Span, 자식이 없는 invoke_agent, 단일 레벨로 평탄화(flattened)된 멀티 에이전트 실행 등이 이에 해당합니다. 이 경우 여러분은 가공되지 않은 Span들을 스크롤하며 한 시간을 허비하게 됩니다. 전체 트리를 출력하는 것이야말로 2시간의 디버깅 대신 15분 만에 문제를 해결할 수 있게 해주는 비결입니다.
트리가 알려줄 수 없는 것
궤적(trajectory)은 에이전트가 무엇을 했는지를 보여줍니다. 하지만 에이전트가 한 행동이 좋았는지 여부는 알려주지 않습니다. 20번의 재시도(retry)가 발생한 실행은 명백합니다. 20번 연속으로 결과가 0인 것은 눈에 띄게 잘못된 것이기 때문입니다. 하지만 대부분의 잘못된 실행은 눈에 띄게 잘못되지 않았습니다. 합리적인 도구(tool), 합리적인 인자(arguments), 합리적인 결과, 그리고 약간 어긋난 답변이 돌아오는 식입니다. 정답(ground truth)을 모르는 상태에서 해당 추적(trace)을 읽는다면 여러분은 그냥 고개를 갸웃거릴 뿐입니다.
추적(Tracing)은 기질(substrate)입니다. 평가(Evals)는 궤적에 점수를 매기는 판단 계층(judgment layer)이며, 모든 평가 기술은 트리가 먼저 존재한다는 것을 전제로 합니다. 그러니 월요일이 오기 전에, 백엔드를 열어 실제 운영 중인 에이전트 하나를 선택하고, 모든 실행이 gen_ai.agent.name, gen_ai.agent.version을 포함한 invoke_agent 루트와 완전한 자식 트리(child tree)를 가지고 있는지 확인하십시오. 만약 그렇지 않다면, 다른 무엇보다 그것부터 수정하십시오. 볼 수 없는 궤적은 평가할 수 없습니다.
만약 여러분이 에이전트를 구축하고 있다면, _Agents in Production_에서 루프(loop), 핸드오프(handoff), 그리고 이러한 Span을 출력하는 하네스(harness)에 대해 살펴볼 수 있습니다. 만약 에이전트의 내부를 들여다보고 싶다면, _Observability for LLM Applications_가 그 쌍의 절반인 추적 및 평가 부분을 다룹니다. 이 둘을 합치면 _The AI Engineer's Library_가 되며, 이 포스트는 그 둘이 만나는 접점에 위치합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기