SHIFTONE

트레이스는 전부 초록색인데 답이 틀렸어요 — AI 에이전트 관측성에서 도구 호출 실패가 사라지는 자리

도구가 권한 오류를 HTTP 200 + 빈 목록으로 돌려주면 스팬은 성공으로 닫혀요. 계측만 네 가지로 바꿔 도구 호출 900회를 돌려보니, 예외만 보는 계측은 실패의 85%를 놓쳤어요. 실패 판정을 계측이 아니라 도구 반환값으로 옮긴 기록입니다.

서버 랙 전면에 늘어선 드라이브 베이와 일렬로 켜진 초록색 상태 표시등 — 전부 정상으로 보이는 화면

에이전트가 "남은 연차는 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 +HTTPC 결과 계약D 빈 결과
타임아웃23/2323/2323/2323/23
HTTP 5000/2121/2121/2121/21
200 + 본문 오류0/450/4545/4545/45
200 + 빈 결과0/430/4343/4343/43
200 + 부분 결과0/240/2424/240/24

유형별 표는 실패 분포 가정과 무관해요. 아래 집계는 저희가 넣은 분포(실패 17%) 위에서만 성립하는 값이고요.

계측실패로 보인 것탐지율오탐트레이스가 전부 초록이던 세션
A 예외만23 / 15614.7%0108 / 131
B +HTTP 상태44 / 15628.2%089 / 131
C 결과 계약156 / 156100%00 / 131
D 빈 결과 휴리스틱132 / 15684.6%3211 / 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 이어도, 도구가 스스로 실패라고 말하면 스팬이 빨개져요.

계약 없는 도구 — 실패가 여기서 사라져요 인사 시스템403 권한 없음 도구 래퍼200 + items: [] 로 번역 execute_tool 스팬status OK · error.type 없음 대시보드에러율 0% 모델빈 목록을 근거로 "12일 남았습니다" → 틀린 답 결과 계약이 있는 도구 — 실패가 끝까지 남아요 인사 시스템403 권한 없음 도구 래퍼status: error execute_tool 스팬ERROR · PERMISSION_DENIED 대시보드실패로 집계 → 보임 모델이 받는 것도 달라져요 — "결과 없음"이 아니라 "조회 실패", 그래서 답을 지어내지 않아요.

이 층을 밖에서 읽겠다는 제품이 나오기 시작했어요

구글이 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

본문 수치는 이 회차에서 직접 돌린 재현 실험 결과예요. 실제 LLM 호출과 고객사 데이터는 쓰지 않았고, 가짜 도구 여섯 개 · 세션 300개 · 도구 호출 900회 · seed 20260923 으로 실행했습니다. 유형별 표는 실패 분포와 무관하지만, 집계 표의 탐지율은 본문에 적은 분포 가정 위에서만 성립해요.