왜 에이전트 디버깅이 어려운가
LLM 에이전트는 같은 입력에도 다른 도구를 다른 순서로 호출한다. 실패가 재현되지 않고, 로그에는 "결과가 이상하다"만 남는다. 근본 원인은 대개 세 가지다. 첫째, 모델이 도구 인자를 잘못 채운다. 둘째, 도구는 성공했지만 반환값이 컨텍스트 창을 넘겨 잘렸다. 셋째, 여러 도구 호출이 얽히면서 어느 단계에서 상태가 오염됐는지 알 수 없다. 이걸 print 로그로 쫓는 건 비효율적이다. 필요한 건 호출 단위 트레이싱이다. 각 도구 호출을 span으로 만들고, 입력·출력·지연·토큰을 부모 요청에 연결해 트리로 본다.
무엇을 span으로 남길 것인가
에이전트 한 번의 실행(run)을 루트 span으로 두고, 그 아래에 LLM 호출과 도구 호출을 자식 span으로 붙인다. 최소한 아래 속성은 반드시 기록한다.
- 도구 이름과 직렬화된 입력 인자(민감정보는 마스킹)
- 반환값 또는 예외, 그리고 반환 바이트/토큰 크기
- 지연 시간과 재시도 횟수
- 이 호출을 유발한 LLM 응답의 span id(인과관계 연결)
OpenTelemetry 기반 최소 구현
표준을 따르면 벤더 종속 없이 어떤 백엔드로도 보낼 수 있다. 도구 실행부를 데코레이터로 감싸 span을 생성한다.
from opentelemetry import trace
import functools, json
tracer = trace.get_tracer("agent.tools")
def traced_tool(fn):
@functools.wraps(fn)
def wrapper(**kwargs):
with tracer.start_as_current_span(f"tool.{fn.__name__}") as span:
span.set_attribute("tool.name", fn.__name__)
span.set_attribute("tool.input", json.dumps(kwargs, ensure_ascii=False)[:2000])
try:
result = fn(**kwargs)
out = json.dumps(result, ensure_ascii=False, default=str)
span.set_attribute("tool.output_bytes", len(out.encode()))
span.set_attribute("tool.output", out[:2000])
return result
except Exception as e:
span.record_exception(e)
span.set_status(trace.StatusCode.ERROR, str(e))
raise
return wrapper
@traced_tool
def search_orders(user_id: str, status: str):
...
핵심은 output_bytes를 잘림 여부와 별개로 기록하는 것이다. 컨텍스트 초과로 인한 실패는 대부분 여기서 드러난다.
인과관계를 잃지 않게 연결하기
도구 호출 span만 있으면 "왜 이 도구를 호출했나"를 알 수 없다. LLM 응답 span의 id를 도구 span에 심어 두면, 잘못된 인자가 어떤 프롬프트/응답에서 나왔는지 역추적할 수 있다.
with tracer.start_as_current_span("llm.completion") as llm_span:
resp = client.messages.create(model="claude-sonnet-4-6", tools=tools, messages=msgs)
llm_span.set_attribute("llm.stop_reason", resp.stop_reason)
for block in resp.content:
if block.type == "tool_use":
# 도구 span에 유발자 id를 전달
run_tool(block.name, block.input, caused_by=llm_span.get_span_context().span_id)
수집 후 실제로 보는 쿼리
span을 컬럼형 저장소(예: ClickHouse)로 내보내면 패턴을 집계로 잡을 수 있다. 개별 실패보다 반복되는 실패 모양이 더 중요하다.
SELECT tool_name,
count() AS calls,
countIf(status = 'ERROR') AS errors,
quantile(0.95)(duration_ms) AS p95_ms,
max(output_bytes) AS max_out
FROM tool_spans
WHERE ts > now() - INTERVAL 1 DAY
GROUP BY tool_name
ORDER BY errors DESC;
에러율이 높은 도구, p95가 튀는 도구, 출력이 비정상적으로 큰 도구를 한눈에 골라낸다.
로깅 방식 비교
| 방식 | 인과 추적 | 집계 분석 | 운영 부담 |
|---|---|---|---|
| print/파일 로그 | 어려움 | 어려움 | 낮음 |
| 구조화 JSON 로그 | 보통(수동 상관) | 가능 | 보통 |
| OTel 트레이싱 | 강함(span 트리) | 강함 | 보통~높음 |
주의점
첫째, 입력·출력을 통째로 저장하면 개인정보와 토큰이 새어 나간다. 도구별 마스킹 규칙을 만들고 길이도 상한을 둔다(위 예시의 2000자 절단). 둘째, 트레이싱 자체가 지연을 만든다. 배치 익스포터와 샘플링을 쓰되, 에러 span은 항상 100% 수집하도록 tail-based 샘플링을 고려한다. 셋째, span 속성 이름을 팀 표준으로 고정하지 않으면 나중에 쿼리가 불가능해진다. tool.name, tool.input처럼 규칙을 문서화하라. 마지막으로 트레이스는 원인을 가리킬 뿐 고쳐 주지 않는다. 잘못된 인자가 반복된다면 프롬프트나 도구 스키마 설명을 수정하는 것이 실제 해결이다.