nova tech
Other

LLM to Agentic 3) 에이전트에게 예외는 없다 — 실패를 관측으로 바꾸기

박재연·2026. 7. 26.·조회 8

배경

에이전트는 결국 세 가지의 반복이다. 모델이 도구를 고르고, 우리가 그 도구를 실행하고, 결과를 다시 모델에게 먹여 루프를 돈다. 앞선 단계에서 우리는 이 루프의 뼈대를 손으로 만들었다. 도구의 결과는 언제나 ToolResult라는 값이고, 하나의 레지스트리가 "모델이 보는 스키마"와 "실제 실행되는 handler"를 잇고, 필터드 뷰가 이번 실행에 필요한 도구만 노출한다.

여기까지의 코드는 모두 한 문장 위에 서 있었다. "모든 것이 잘 풀린다면."

  • 모델이 부른 도구는 존재하고,
  • 인자는 계약에 맞고,
  • handler는 성공하고,
  • 전달된 JSON은 온전하고,
  • 모델은 언젠가 최종 답을 낸다.

문제는 실제 모델이 이 다섯 가정을 전부 어긴다는 것이다. 그리고 각 위반은 지금 구조에서 하나의 결말로 수렴한다 — 잡히지 않은 Python 예외로 실행 전체가 죽는다.


문제 — 에이전트는 "어떻게 실패해야 하는가"

흔히 실패 처리를 "어떻게 실패를 막을까"의 문제로 본다. 하지만 에이전트에서는 질문이 다르다.

막을 수 없는 실패가 일어났을 때, 그 실패는 무엇이 되어야 하는가?

이 질문이 중요한 이유는 에이전트의 본질에 있다. 에이전트는 관측에 반응하는 루프다. 실패가 예외라면 루프는 그 자리에서 끊기고, 모델은 자기가 무엇을 잘못했는지 볼 기회조차 얻지 못한다. 반대로 실패가 모델이 읽을 수 있는 값이라면, 모델은 그것을 관측으로 받아 스스로 다음 수를 고친다.

즉 실패 처리의 목표는 "죽지 않기"가 아니라, **"실패를 모델이 학습 가능한 신호로 만들기"**다.


분석 — 실패를 두 축으로 가른다

일곱 개의 실패 시나리오를 하나씩 방어 코드로 덧붙이는 대신, 먼저 두 개의 축으로 정리하면 처리 방식이 저절로 결정된다.

축 1 — 실패는 어느 층에서 나는가. 원인의 위치를 추적하면 잡을 지점이 정해진다.

실패의 근원어디서 터지나
없는 이름으로 dispatch 조회레지스트리 조회
계약 위반 인자Pydantic 검증
handler 내부 오류도구 실행
깨진 JSON인자 파싱
빈/무한/반복 응답모델 응답 자체

축 2 — 실패는 "계속"인가 "종료"인가. 이게 핵심 통찰이다. 모든 실패가 "다시 시도해"는 아니다.

  • 관측형(observation)한 도구 호출의 실패다. 값으로 바꿔 돌려주면 루프는 계속되고 모델이 고친다.
  • 종료형(terminal)실행 자체가 끝나야 하는 실패다. 예외로 흘리지 말고, 타입이 있는 종료 상태로 깔끔히 끝낸다.

이 두 축을 겹치면, 방어 코드를 흩뿌리는 대신 실패마다 정확한 이름과 처리 위치가 나온다.

분석의 하이라이트 — "경계가 관측 가능성을 정한다"

가장 흥미로운 건 깨진 JSON이었다. OpenAI는 인자를 JSON 문자열로 준다. 그런데 우리 adapter가 그걸 루프에 넘기기 전에 너무 일찍 파싱해 버렸다. 그 결과 깨진 JSON은 adapter 안에서 예외로 죽었고, 루프는 그 실패를 관측할 창조차 갖지 못했다.

여기서 해결책은 "adapter에 try/except를 추가한다"가 아니었다. 진짜 문제는 파싱 책임의 위치였다. 파싱을 루프 경계로 옮겨, 정규 호출을 (id, name, arguments_json) 형태로 넘기자 — 깨진 JSON은 비로소 루프가 볼 수 있는 곳에서 실패했다.

실패를 어디서 잡을 수 있느냐는, 파싱(해석)을 어디서 하느냐가 결정한다. 좋은 실패 처리의 절반은 새 코드가 아니라 책임의 이동이었다.


해결 — 실패마다 이름을 붙인다

원칙은 정상 흐름에서 이미 배운 것의 확장이다. "도구의 결과는 값이다"를 **"도구의 실패도 값이다"**로 넓힌다. 값의 모양은 단 하나다.

ToolResult.failure(error_code, message)   # 같은 tool_call ID로 모델에게 돌아간다

이 하나의 모양 위에서, 두 축에 따라 일곱 실패를 처리했다.

관측형 — 루프는 계속된다. unknown_tool(없는 도구), invalid_arguments(계약 위반 인자), tool_error(handler 예외), malformed_arguments(깨진 JSON). 네 실패 모두 근원은 다르지만 결말은 같다 — 예외 대신 이름 붙은 실패 값이 되어 다음 라운드로 흐른다. 특히 malformed_arguments는 위에서 말한 경계 이동 덕분에 비로소 관측 가능해졌다.

종료형 — 여기서 끝이다. invalid_model_response는 "tool call도 없고 content도 빈" 응답을 거짓 성공으로 인정하지 않고 실패로 종료한다. max_rounds_exceeded는 라운드를 다 써도 답이 없을 때 예외를 던지는 대신 타입 있는 종료 상태를 반환한다. 둘 다 "계속"이 아니라 "정직한 끝"이다.

선제 방어 — 위험이 오기 전에. repeated_tool_call은 직전과 이름·원문 인자가 완전히 같은 호출을 handler 재실행 없이 막는다. 지금 도구(예: add_numbers)는 부작용이 없어 재실행해도 무해하다. 하지만 곧 들어올 run_shell·파일 수정·외부 API는 같은 호출을 반복하면 비용과 부작용이 중복된다. 아직 무해할 때 세워 두는 방어선이다.


검증 — 각 실패를 테스트로 못박는다

실험 방식은 단순하고 엄격했다. 각 실패 모드마다 먼저 그 실패를 단언하는 테스트를 써서 실패(RED)시키고, 최소 구현으로 통과(GREEN)시켰다. 즉 "없는 도구를 부르면 unknown_tool 관측이 돌아온다", "빈 응답이면 invalid_model_response로 끝난다" 같은 계약이 코드가 아니라 테스트로 고정된다. 덕분에 나중에 어떤 리팩터링을 해도 이 실패 계약이 조용히 깨지지 않는다.


결과 — 실패 지도

일곱 단계를 지나면, 에이전트가 마주치는 모든 실패에 이름과 분류가 생긴다.

실패코드분류
없는 도구unknown_tool관측(계속)
잘못된 인자invalid_arguments관측(계속)
도구 내부 예외tool_error관측(계속)
깨진 JSONmalformed_arguments관측(계속)
빈 모델 응답invalid_model_response종료(terminal)
라운드 초과max_rounds_exceeded종료(terminal)
연속 중복 호출repeated_tool_call관측(계속)

에이전트는 이제 잘못된 요청에 무너지지 않는다. 모델은 자기 실수를 읽고 고치고, 호출자는 실패의 종류를 안다.


인사이트

세 가지가 남는다.

  1. 에이전트에게 예외는 없다 — 오직 값과 상태가 있다. 잡히지 않은 예외로 죽는 대신, 실패는 모델이 반응할 값이거나 호출자가 처리할 상태가 된다. 이건 방어 코드의 집합이 아니라 하나의 설계 원칙이다.

  2. 모든 실패가 "계속하라"는 아니다. 관측형과 종료형을 구분하는 것이 핵심이었다. 빈 응답을 "성공한 빈 답"으로 넘기거나, 무한 루프를 예외로 방치하는 대신, 정직하게 "여기서 끝"이라고 말하는 것도 좋은 실패 처리다.

  3. 경계가 관측 가능성을 정한다. 깨진 JSON 사례가 보여줬듯, 무엇을 처리할 수 있느냐는 어디서 해석하느냐에 달려 있다. 실패를 다루기 전에, 먼저 책임의 위치를 물어야 한다.


댓글 0