티스토리 뷰
에이전트가 무엇을 했는지 모르면 디버깅도 개선도 불가능합니다. 이 문장은 단순한 상식처럼 보이지만, 실제 운영에서는 여전히 많은 시스템이 결과 문자열만 남기고 그 뒤의 의사결정 과정을 잃어버립니다.
문제는 에이전트 시스템이 전통적인 함수보다 훨씬 많은 중간 단계를 가진다는 점입니다. 모델 호출, retrieval, tool invocation, reflection, approval, retry가 모두 실행 경로에 들어가는데, 이 중 하나라도 기록이 끊기면 사고 재현이 거의 불가능해집니다.
Observability는 로그를 많이 남기는 일이 아니라, 한 번의 실행을 외부에서 다시 구성할 수 있게 만드는 설계입니다. 무엇을 했는지, 왜 그렇게 했는지, 얼마나 걸리고 얼마나 들었는지를 같은 trace 안에서 읽을 수 있어야 합니다.
이 글은 Harness Engineering 101 시리즈의 9번째 글입니다.
운영 가능한 에이전트는 답변만 내는 시스템이 아니라, 답변이 만들어진 경로까지 설명할 수 있는 시스템입니다.
이 글에서 다룰 문제
- Observability에서 말하는 trace와 span은 에이전트 실행을 어떻게 표현할까요?
- 결과뿐 아니라 어떤 입력과 의사결정 근거를 함께 남겨야 할까요?
- replay 가능한 로그를 만들려면 무엇이 반드시 기록되어야 할까요?
- 대시보드에는 왜 평균보다 p95 latency와 per-run cost가 중요할까요?
- 언제 사람을 깨우고, 언제는 대시보드만 보면 되는지 어떻게 나눌까요?
왜 이 글이 중요한가
Observability가 중요한 첫 번째 이유는 재현성입니다. 사고가 났을 때 당시 모델이 어떤 prompt와 retrieved context를 봤는지 모르면 같은 문제를 다시 만들 수 없습니다.
두 번째 이유는 비용과 성능입니다. 에이전트는 어느 단계에서 비용이 터지고 어느 단계에서 느려졌는지 모르면 최적화 대상도 찾기 어렵습니다. 모델만 비싼 것이 아니라 retrieval, judge, reflection도 모두 비용을 만듭니다.
세 번째 이유는 책임 추적입니다. approval, retry, tool execution이 섞인 시스템에서 누가 무엇을 결정했는지 보이지 않으면 운영 개선은 추측에 머무릅니다.
Observability를 이해하는 가장 좋은 방법: 한 번의 실행을 나중에 다시 구성할 수 있게 만드는 추적 모델로 보는 것입니다
좋은 observability는 단순 로그 목록이 아니라 구조화된 trace를 남깁니다. span은 작업 단위이고, trace는 한 실행의 전체 트리입니다. 이 구조가 있어야 어떤 단계가 느렸고 어떤 단계가 실패했는지 즉시 볼 수 있습니다.
기록은 최소 세 층을 가져야 합니다. 무엇을 했는지, 왜 그렇게 했는지, 얼마나 걸리고 얼마나 들었는지입니다. 결과만 남기면 replay가 안 되고, 비용만 남기면 원인을 찾을 수 없습니다.
또한 observability는 알림 설계까지 포함합니다. 모든 이상을 다 깨우면 alert fatigue가 오므로, baseline 대비 error rate, p95 latency, per-run cost 같은 소수의 신호만 paging 대상으로 올려야 합니다.
결과만 남기는 로그는 설명이 아닙니다. Observability는 한 번의 실행을 외부에서 다시 구성할 수 있을 만큼 충분한 구조를 남기는 일입니다.
핵심 개념
Agent가 무엇을 했는지 모르면 디버깅도 개선도 불가능합니다. Observability는 Agent의 모든 단계를 추적, 기록, 재현 가능하게 만드는 일입니다.

Observability란 무엇인가요?
Observability(관측성)는 에이전트가 무엇을, 왜, 어떻게 실행했는지를 외부에서 재구성할 수 있는 능력입니다. 단순히 로그를 남기는 것이 아니라, 사고가 났을 때 "그 시점의 의사결정"을 추적하고 재현할 수 있어야 합니다.
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
import uuid
@dataclass
class Span:
span_id: str
trace_id: str
parent_id: str | None
name: str
started_at: datetime
ended_at: datetime | None = None
attributes: dict[str, Any] = field(default_factory=dict)
events: list[dict] = field(default_factory=list)
status: str = "ok"
Span은 "에이전트의 한 가지 작업 단위"입니다. 도구 호출 1회, LLM 호출 1회, 사고(reflect) 1회가 각각 하나의 span이 됩니다. 같은 trace_id를 공유하는 span들이 모여 하나의 실행 흐름이 됩니다.
무엇을 기록해야 할까요?

3가지 정보 계층을 모두 기록해야 추적이 가능합니다.
- 무엇을 했는가 (What): tool name, input, output
- 왜 그렇게 결정했는가 (Why): prompt, model, temperature, retrieved context
- 얼마나 걸리고 얼마나 들었는가 (Cost): latency, token count, cost in dollars
def record_llm_call(span: Span, prompt: str, model: str, response: str, usage: dict):
span.attributes.update({
"llm.model": model,
"llm.prompt_tokens": usage["prompt_tokens"],
"llm.completion_tokens": usage["completion_tokens"],
"llm.cost_usd": _calculate_cost(model, usage),
})
span.events.append({
"name": "llm.prompt",
"timestamp": datetime.now(timezone.utc).isoformat(),
"body": prompt,
})
span.events.append({
"name": "llm.response",
"timestamp": datetime.now(timezone.utc).isoformat(),
"body": response,
})
prompt와 response를 attributes가 아니라 events로 기록하는 것에 주의하세요. attributes는 검색·필터링용 짧은 메타데이터고, events는 시간 순서가 있는 페이로드입니다.
Trace 모델 — 한 실행을 끝까지 따라가기

에이전트 한 번 실행은 다음과 같은 트리 구조의 trace를 만듭니다.
class Tracer:
def __init__(self, exporter):
self.exporter = exporter
self._stack: list[Span] = []
def start(self, name: str, **attrs) -> Span:
parent_id = self._stack[-1].span_id if self._stack else None
trace_id = self._stack[0].trace_id if self._stack else str(uuid.uuid4())
span = Span(
span_id=str(uuid.uuid4()),
trace_id=trace_id,
parent_id=parent_id,
name=name,
started_at=datetime.now(timezone.utc),
attributes=dict(attrs),
)
self._stack.append(span)
return span
def end(self, status: str = "ok"):
span = self._stack.pop()
span.ended_at = datetime.now(timezone.utc)
span.status = status
self.exporter.export(span)
trace 7a3f...
├── span: agent.run (12.3s, $0.04)
│ ├── span: llm.plan (1.2s, $0.01)
│ ├── span: tool.search (0.8s)
│ ├── span: llm.synthesize (2.1s, $0.02)
│ └── span: tool.send_email (0.3s)
이 트리만 있으면 "느린 단계는 어디였나", "비용이 어디서 터졌나", "어느 도구에서 실패했나"를 즉시 답할 수 있습니다.
Replay — 로그에서 실행을 재현하기

좋은 trace는 "재현 가능"합니다. 동일한 입력으로 같은 단계를 다시 실행해서 같은 결과가 나오는지 확인할 수 있어야 합니다.
def replay_trace(trace_id: str, store) -> list[dict]:
spans = store.load_spans(trace_id)
results = []
for span in spans:
if span.name.startswith("tool."):
tool_name = span.attributes["tool.name"]
tool_input = span.attributes["tool.input"]
actual = invoke_tool(tool_name, tool_input)
expected = span.attributes["tool.output"]
results.append({
"span": span.name,
"matches": actual == expected,
"expected": expected,
"actual": actual,
})
return results
Replay가 가능하려면 모든 입력(prompt, retrieved context, tool input)이 span에 기록되어 있어야 합니다. "결과만 기록"하면 재현할 수 없습니다.
Cost와 Latency 대시보드
운영 중인 에이전트는 비용과 응답 시간이 갑자기 튀는 일이 잦습니다. 대시보드는 다음 4개 지표를 실시간으로 보여줘야 합니다.
@dataclass
class AgentMetrics:
total_runs: int
avg_latency_ms: float
p95_latency_ms: float
avg_cost_usd: float
error_rate: float
def aggregate(spans: list[Span]) -> AgentMetrics:
runs = [s for s in spans if s.name == "agent.run"]
latencies = [(s.ended_at - s.started_at).total_seconds() * 1000 for s in runs]
costs = [s.attributes.get("total.cost_usd", 0) for s in runs]
errors = [s for s in runs if s.status != "ok"]
latencies_sorted = sorted(latencies)
p95_idx = int(len(latencies_sorted) * 0.95)
return AgentMetrics(
total_runs=len(runs),
avg_latency_ms=sum(latencies) / len(latencies) if latencies else 0,
p95_latency_ms=latencies_sorted[p95_idx] if latencies_sorted else 0,
avg_cost_usd=sum(costs) / len(costs) if costs else 0,
error_rate=len(errors) / len(runs) if runs else 0,
)
p95 latency는 평균보다 훨씬 중요합니다. 평균은 정상이어도 5%의 사용자가 30초씩 기다리고 있을 수 있기 때문입니다.
Alerting — 언제 사람을 깨워야 하는가
모든 이상을 알리면 알림 피로(alert fatigue)가 옵니다. 다음 3가지 조건만 깨우는 알림으로 둡니다.
def should_alert(metrics: AgentMetrics, baseline: AgentMetrics) -> str | None:
if metrics.error_rate > baseline.error_rate * 2 and metrics.error_rate > 0.05:
return f"Error rate spike: {metrics.error_rate:.1%}"
if metrics.p95_latency_ms > baseline.p95_latency_ms * 3:
return f"P95 latency spike: {metrics.p95_latency_ms:.0f}ms"
if metrics.avg_cost_usd > baseline.avg_cost_usd * 5:
return f"Cost spike: ${metrics.avg_cost_usd:.4f}/run"
return None
- 에러율 급증: baseline의 2배 이상 + 절대값 5% 이상
- P95 latency 급증: baseline의 3배 이상
- 건당 비용 급증: baseline의 5배 이상
흔히 헷갈리는 지점
- 출력만 저장해도 디버깅은 가능하다고 생각하기 쉽지만, prompt와 retrieved context가 없으면 replay가 불가능합니다.
- PII는 나중에 마스킹하면 된다고 보기 쉽지만, raw span에 들어가는 순간 이미 새로운 위험이 됩니다.
- trace_id 전파는 구현 세부라고 생각하기 쉽지만, 비동기 경계에서 trace가 끊기면 전체 실행을 읽을 수 없습니다.
- 평균 latency만 보면 충분하다고 생각하기 쉽지만, 실제 체감 문제는 대개 p95에서 먼저 나타납니다.
- 모든 이상에 paging을 걸면 더 안전할 것 같지만, alert fatigue는 결국 진짜 알림을 묻어 버립니다.
운영 체크리스트
- 모든 실행을 trace와 span 구조로 기록합니다.
- What, Why, Cost 세 층의 메타데이터를 함께 저장합니다.
- prompt, retrieved context, tool input처럼 replay에 필요한 입력을 남깁니다.
- 대시보드에 error rate, p95 latency, avg cost per run을 기본 지표로 둡니다.
- baseline 대비 급증 조건에만 paging을 걸고 나머지는 리포트성 알림으로 분리합니다.
정리
Observability는 에이전트가 무엇을 했는지 보는 기능이 아니라, 왜 그런 결과가 나왔는지 나중에 다시 설명할 수 있게 만드는 운영 능력입니다. 이것이 있어야 디버깅, 비용 최적화, 사고 분석이 모두 가능해집니다.
여기서 가장 중요한 것은 구조입니다. span과 trace로 실행을 묶고, 결과뿐 아니라 입력과 근거와 비용을 함께 남겨야 replay가 가능합니다.
다음 글에서는 마지막으로 Production Harness를 다룹니다. 지금까지 만든 모든 harness를 배포, 롤백, on-call까지 포함한 실제 운영 환경으로 묶는 단계입니다.
시리즈 목차
- Harness Engineering이란 무엇인가?
- Task Harness — 모호한 일을 실행 가능한 작업으로 바꾸기
- Context Harness — Agent에게 줄 정보와 숨길 정보 설계하기
- Constraint Harness — 규칙, 경계, 금지 행동 정의하기
- Tool Harness — Agent가 사용할 도구를 안전하게 설계하기
- Test Harness — 완료 조건을 테스트로 고정하기
- Feedback Loop — 실패를 고치게 만드는 반복 구조
- Approval Gate — 사람 승인이 필요한 지점 설계하기
- Observability — Agent 작업을 추적하고 재현하기 (현재 글)
- Production Harness — 운영 가능한 Agent 작업 환경 만들기 (예정)
참고 자료
공식 문서
'AI·LLM' 카테고리의 다른 글
| AI Evaluation 101 : 왜 LLM 애플리케이션을 평가해야 하는가 (0) | 2026.05.18 |
|---|---|
| Harness Engineering 101 : Production Harness — 운영 가능한 Agent 작업 환경 만들기 (0) | 2026.05.17 |
| Harness Engineering 101 : Approval Gate — 사람 승인이 필요한 지점 설계하기 (2) | 2026.05.17 |
| Harness Engineering 101 : Feedback Loop — 실패를 고치게 만드는 반복 구조 (0) | 2026.05.17 |
| Harness Engineering 101 : Test Harness — 완료 조건을 테스트로 고정하기 (0) | 2026.05.17 |
- Total
- Today
- Yesterday
- Azure Functions
- Computer Science
- DesignPatterns
- AI Evaluation
- reliability
- APIDesign
- LLM
- http
- embeddings
- Python
- Architecture
- Prompt engineering
- softwaredesign
- openAI
- Agent
- ai safety
- harness
- Refactoring
- serverless
- langchain
- Production
- vector search
- AZURE
- Cloud
- ai agent
- Cleancode
- rag
- Tool Use
- backend
- DevOps
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | |||||
| 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 10 | 11 | 12 | 13 | 14 | 15 | 16 |
| 17 | 18 | 19 | 20 | 21 | 22 | 23 |
| 24 | 25 | 26 | 27 | 28 | 29 | 30 |
| 31 |

