에이전트가 "남은 연차는 12일입니다"라고 답했어요. 실제 값은 3일이었고요. 트레이스를 열었더니 스팬 아홉 개가 전부 초록색이었어요.
원인은 중간에 있었어요. 인사 시스템 조회가 권한 오류를 만났는데, 도구 래퍼가 그걸 HTTP 200 에 {"items": []} 로 바꿔 돌려줬거든요. 예외도 안 났고 상태코드도 정상이니 스팬은 성공으로 닫혔어요. 모델은 빈 목록을 받고 앞선 대화에 남아 있던 숫자로 문장을 완성했고요.
실패한 건 도구인데, 실패를 실패라고 말한 곳이 한 군데도 없었어요. 고객사 시스템이 아니라 가짜 도구 여섯 개로 같은 구조를 세운 재현 환경입니다.
그런데 "실패했다"는 판정은 누가 내리나요
OpenTelemetry 의 GenAI 시맨틱 컨벤션에는 도구 실행 스팬 규약이 있어요. 스팬 이름은 execute_tool {gen_ai.tool.name}, error.type 은 "작업이 오류로 끝났다면" 조건부 필수예요.
읽고 나면 안심이 되는데, 한 번 더 보면 빈자리가 보여요. "오류로 끝났다"를 누가 판정하는지가 규약에 없어요. 계측 라이브러리가 볼 수 있는 건 예외가 떴는지, 상태코드가 몇인지 정도거든요. 규약은 실패를 어떻게 적을지를 정하지, 무엇이 실패인지를 정해주지 않아요.
도구는 그대로 두고 계측만 바꿔 네 번 재봤어요
가짜 도구 여섯 개에 세션 300개, 세션당 호출 3회, 총 900회를 돌렸어요. 스팬은 흉내가 아니라 실제 opentelemetry-sdk 로 만들고 InMemorySpanExporter 로 회수해 채점했고요. 주입한 실패는 다섯 가지 — 타임아웃, HTTP 500, 200 + 본문 오류, 200 + 빈 결과, 200 + 부분 결과예요.
계측은 넷이에요. A 예외만, B 예외 + 상태코드 400 이상, C 도구가 돌려준 {status, error_code} 로 판정, D 는 계약을 만들기 싫을 때의 지름길로 B 에 "결과가 비면 실패로 치자"를 얹은 것.
예외만 보는 계측은 실패의 85%를 못 봤어요
| 실패 유형 | A 예외만 | B +HTTP | C 결과 계약 | D 빈 결과 |
|---|---|---|---|---|
| 타임아웃 | 23/23 | 23/23 | 23/23 | 23/23 |
| HTTP 500 | 0/21 | 21/21 | 21/21 | 21/21 |
| 200 + 본문 오류 | 0/45 | 0/45 | 45/45 | 45/45 |
| 200 + 빈 결과 | 0/43 | 0/43 | 43/43 | 43/43 |
| 200 + 부분 결과 | 0/24 | 0/24 | 24/24 | 0/24 |
유형별 표는 실패 분포 가정과 무관해요. 아래 집계는 저희가 넣은 분포(실패 17%) 위에서만 성립하는 값이고요.
| 계측 | 실패로 보인 것 | 탐지율 | 오탐 | 트레이스가 전부 초록이던 세션 |
|---|---|---|---|---|
| A 예외만 | 23 / 156 | 14.7% | 0 | 108 / 131 |
| B +HTTP 상태 | 44 / 156 | 28.2% | 0 | 89 / 131 |
| C 결과 계약 | 156 / 156 | 100% | 0 | 0 / 131 |
| D 빈 결과 휴리스틱 | 132 / 156 | 84.6% | 32 | 11 / 131 |
눈에 띄는 건 A 와 C 의 차이가 아니라 B 와 C 사이 간격이에요. 상태코드까지 챙겨도 놓치는 112건은 전부 "응답은 정상인데 내용이 실패인" 경우거든요.
C 가 100% 인 건 자랑이 아니라 동어반복이에요
실패를 아는 쪽에게 실패를 말하게 했으니 100%가 나온 거예요. 의미 있는 건 C 의 점수가 아니라 C 없이는 B 가 천장이라는 사실이에요.
D 를 넣은 이유도 거기 있어요. 탐지율은 84.6%까지 오르는데, 정상적으로 0건인 호출 32건을 실패로 찍었어요. 연차가 0일이라 빈 목록인 것과 조회가 막혀서 빈 목록인 것을 밖에서는 구분할 수 없거든요. 부분 결과 24건은 D 도 전부 놓쳤고요.
실패인지 아닌지를 아는 건 호출한 쪽이 아니라 호출당한 쪽입니다.
그래서 도구 반환값부터 바꿨어요
먼저 정한 건 반환 타입이에요. 데이터를 바로 돌려주는 대신 상태와 데이터를 함께 돌려주기로 했어요. 실패 판정을 계측에서 도구 안으로 옮기려고요. 부분 결과는 실패로 묶지 않고 degraded 로 따로 뒀어요.
@dataclass
class ToolResult:
status: Literal["ok", "degraded", "error"]
data: Any = None
error_code: str | None = None # PERMISSION_DENIED, UPSTREAM_TIMEOUT ...
def get_leave_balance(emp_id: str) -> ToolResult:
r = hr.get(f"/leave/{emp_id}")
if r.status_code == 403:
return ToolResult("error", error_code="PERMISSION_DENIED")
body = r.json()
if body.get("partial"):
return ToolResult("degraded", data=body["items"], error_code="UPSTREAM_PARTIAL")
return ToolResult("ok", data=body["items"])
달라진 건 403 이 더 이상 빈 목록으로 번역되지 않는다는 점이에요. 모델이 받는 것도 "결과 없음"에서 "조회 실패"로 바뀌고요. 계측 쪽은 오히려 단순해졌어요 — 상태코드를 해석하던 분기가 사라지고, 도구가 말한 걸 옮겨 적기만 해요.
with tracer.start_as_current_span(f"execute_tool {name}") as span:
res = tool(**args)
span.set_attribute("gen_ai.tool.name", name)
span.set_attribute("shiftone.tool.status", res.status) # ok | degraded | error
if res.error_code:
span.set_attribute("error.type", res.error_code)
if res.status == "error":
span.set_status(StatusCode.ERROR)
이제 예외가 안 나도, 200 이어도, 도구가 스스로 실패라고 말하면 스팬이 빨개져요.
이 층을 밖에서 읽겠다는 제품이 나오기 시작했어요
구글이 2026년 9월 Gemini Enterprise Agent Platform 에 Agent Anomaly Detection 을 프라이빗 프리뷰로 열었어요. 도구 오용(ASI02) · 권한 남용(ASI03) 같은 OWASP Agentic Top 10 항목에 맞춘 탐지기예요. 눈여겨볼 건 방식이에요 — 에이전트가 이미 내보내는 OpenTelemetry 트레이스와 로그를 읽어서 판단해요. 스팬에 실패가 안 적혀 있으면 그 위에 무엇을 얹어도 못 읽는다는 뜻이기도 해요.
Guild.ai 가 Morning Consult 에 의뢰한 조사(100인 이상 기업 IT 의사결정자 362명, 2026-09-22 발표)에서는 96.4%가 "AI 에이전트 목록을 정확히 갖고 있다"고 답했는데 로그나 감사 추적을 갖춘 곳은 39.8% 였어요. 벤더 설문이라 그대로 받을 숫자는 아니고요.
잠정 결론
트레이스가 초록색인 것과 실패가 없는 것은 다른 말이에요. 예외와 상태코드만 보는 계측에는 천장이 있고, 이 실험의 분포에서는 28.2% 였어요. 그래서 이건 관측성 작업이라기보다 인터페이스 설계 작업에 가까워요. 대시보드가 아니라 도구를 만드는 팀이 반환 타입에서 해야 하는 일이에요.
파일럿은 통과했는데 운영에서 멈추는 네 지점에서 스팬 단위 관측을 얘기했는데, 그때 한 칸 덜 들어간 게 이거였어요. 스팬을 남기는 것과 실패를 남기는 것은 다른 일이에요. 권한 오류를 빈 결과로 바꾸지 않는 건 문서 권한 필터를 검색 앞에 두는 이야기와도 같은 줄기고요.
완성된 규칙은 아니에요. 지금은 도구마다 실패 코드 목록을 문서로 들고 있는데, 수십 개를 넘어가면 다시 정리해야 할 거예요.
자주 묻는 질문
Q. 도구를 전부 고쳐야 하나요? A. 아니요. "실패가 답변에 그대로 반영되는 도구"부터면 충분해요. 조회 · 계산을 먼저 손보고, 생성 · 전송처럼 부수효과가 있는 도구는 그다음이에요.
Q. LangChain 이나 LangGraph 를 쓰면 자동 계측이 해주지 않나요? A. 스팬은 만들어줘요. 다만 자동 계측이 판정할 수 있는 범위가 본문의 B 예요. 무엇이 업무적 실패인지는 도구 코드만 알기 때문에 프레임워크를 바꿔도 이 경계는 그대로예요.
Q. MCP 로 붙인 도구도 같은 문제가 생기나요?
A. MCP 는 자리를 마련해 뒀어요. 도구 실행 오류를 결과의 isError: true 로 보고하도록 규약에 적혀 있고, 업무 로직 오류도 여기 포함돼요. 문제는 서버 구현이에요. 업무 오류를 isError 없이 텍스트로만 돌려주면 클라이언트 쪽에서는 성공한 호출과 구분이 안 가요.
Sources
- OpenTelemetry GenAI Semantic Conventions — Execute tool span: https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-spans.md
- Model Context Protocol Specification (2025-06-18) — Tools, Error Handling: https://modelcontextprotocol.io/specification/2025-06-18/server/tools
- Google Developers Blog — Agent Anomaly Detection, now in Private Preview on the Gemini Enterprise Agent Platform (2026-09): https://developers.googleblog.com/agent-anomaly-detection-now-in-private-preview-on-the-gemini-enterprise-agent-platform/
- Help Net Security — Google's new agent security system detects tool misuse, loops and rogue behavior (2026-09-17): https://www.helpnetsecurity.com/2026/09/17/google-agent-anomaly-detection-audit-layer/
- Guild.ai / Morning Consult — The AI Agent Management Gap Is Growing (2026-09-22): https://www.globenewswire.com/news-release/2026/09/22/3366611/0/en/the-ai-agent-management-gap-is-growing.html
본문 수치는 이 회차에서 직접 돌린 재현 실험 결과예요. 실제 LLM 호출과 고객사 데이터는 쓰지 않았고, 가짜 도구 여섯 개 · 세션 300개 · 도구 호출 900회 · seed 20260923 으로 실행했습니다. 유형별 표는 실패 분포와 무관하지만, 집계 표의 탐지율은 본문에 적은 분포 가정 위에서만 성립해요.

